diff --git a/scripts/parse-address-data.ts b/scripts/parse-address-data.ts index 854b34f..7d4b968 100644 --- a/scripts/parse-address-data.ts +++ b/scripts/parse-address-data.ts @@ -42,6 +42,7 @@ export function parseAddressData(rawDir: string, outDir: string) { // Generate base.json const base = { + generatedAt: new Date().toISOString(), cities: cityData .filter((c) => !SKIP_CITIES.has(c.CityName)) .map((c) => ({ diff --git a/src/app/robots.ts b/src/app/robots.ts index c2e732d..d2450c5 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -4,11 +4,13 @@ export default function robots(): MetadataRoute.Robots { const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; return { - rules: { - userAgent: "*", - allow: "/", - disallow: "/data/", - }, + rules: [ + { userAgent: "*", allow: "/", disallow: "/data/" }, + { userAgent: "GPTBot", allow: "/", disallow: "/data/" }, + { userAgent: "Google-Extended", allow: "/", disallow: "/data/" }, + { userAgent: "PerplexityBot", allow: "/", disallow: "/data/" }, + { userAgent: "ClaudeBot", allow: "/", disallow: "/data/" }, + ], sitemap: `${siteUrl}/sitemap.xml`, }; } diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 4ce6ac2..e63910e 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,14 +1,35 @@ import type { MetadataRoute } from "next"; +import { toSlug } from "@/lib/slug"; +import type { City } from "@/lib/types"; +import baseData from "../../public/data/base.json"; export default function sitemap(): MetadataRoute.Sitemap { const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; + const cities = baseData.cities as City[]; + const generatedAt = + (baseData as { generatedAt?: string }).generatedAt ?? + new Date().toISOString(); + const lastModified = new Date(generatedAt); - return [ + const entries: MetadataRoute.Sitemap = [ { url: siteUrl, - lastModified: new Date(), + lastModified, changeFrequency: "monthly", priority: 1, }, ]; + + for (const city of cities) { + for (const district of city.districts) { + entries.push({ + url: `${siteUrl}/tw/${toSlug(city.en)}/${toSlug(district.en)}`, + lastModified, + changeFrequency: "yearly", + priority: 0.7, + }); + } + } + + return entries; } diff --git a/src/app/tw/[city]/[district]/page.tsx b/src/app/tw/[city]/[district]/page.tsx new file mode 100644 index 0000000..b2d2105 --- /dev/null +++ b/src/app/tw/[city]/[district]/page.tsx @@ -0,0 +1,345 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import type { Metadata } from "next"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { toSlug } from "@/lib/slug"; +import type { City, District, Road, ZipRange } from "@/lib/types"; +import baseData from "../../../../../public/data/base.json"; + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; + +interface Params { + city: string; + district: string; +} + +const CITIES = baseData.cities as City[]; +const GENERATED_AT = + (baseData as { generatedAt?: string }).generatedAt ?? + new Date().toISOString(); + +function findBySlug( + citySlug: string, + districtSlug: string, +): { city: City; district: District } | null { + for (const city of CITIES) { + if (toSlug(city.en) !== citySlug) continue; + const district = city.districts.find((d) => toSlug(d.en) === districtSlug); + if (district) return { city, district }; + } + return null; +} + +async function loadRoads(zip3: string): Promise { + const path = join(process.cwd(), "public", "data", "roads", `${zip3}.json`); + const raw = await readFile(path, "utf-8"); + return (JSON.parse(raw) as { roads: Road[] }).roads; +} + +async function loadZipRanges(zip3: string): Promise { + const path = join( + process.cwd(), + "public", + "data", + "zip-ranges", + `${zip3}.json`, + ); + const raw = await readFile(path, "utf-8"); + return (JSON.parse(raw) as { ranges: ZipRange[] }).ranges; +} + +export function generateStaticParams(): Params[] { + const params: Params[] = []; + for (const city of CITIES) { + for (const district of city.districts) { + params.push({ + city: toSlug(city.en), + district: toSlug(district.en), + }); + } + } + return params; +} + +export async function generateMetadata({ + params, +}: { + params: Promise; +}): Promise { + const { city: citySlug, district: districtSlug } = await params; + const match = findBySlug(citySlug, districtSlug); + if (!match) return {}; + const { city, district } = match; + const title = `${district.name}郵遞區號 & 英文地址 | ${city.name}`; + const description = `${city.name}${district.name} 3 碼郵遞區號 ${district.zip3},英文標準寫法為 ${district.en}, ${city.en}。查看該區完整 3+3 郵遞區號列表與道路英譯對照。`; + const url = `${SITE_URL}/tw/${citySlug}/${districtSlug}`; + return { + title, + description, + alternates: { canonical: url }, + openGraph: { + type: "article", + locale: "zh_TW", + siteName: "zipkit", + title, + description, + url, + }, + twitter: { + card: "summary", + title, + description, + }, + }; +} + +export default async function DistrictPage({ + params, +}: { + params: Promise; +}) { + const { city: citySlug, district: districtSlug } = await params; + const match = findBySlug(citySlug, districtSlug); + if (!match) notFound(); + const { city, district } = match; + + const [roads, ranges] = await Promise.all([ + loadRoads(district.zip3), + loadZipRanges(district.zip3), + ]); + + const zip6Set = new Set(ranges.map((r) => r.zip6)); + + const summary = `${city.name}${district.name}為${city.name}下轄之行政區,3 碼郵遞區號為 ${district.zip3},英文標準寫法為 ${district.en}, ${city.en}。本區共收錄 ${String(roads.length)} 條登錄道路與 ${String(zip6Set.size)} 組 3+3 郵遞區號區段。`; + + const breadcrumbJsonLd = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: "首頁", + item: SITE_URL, + }, + { + "@type": "ListItem", + position: 2, + name: `${city.name}${district.name}`, + item: `${SITE_URL}/tw/${citySlug}/${districtSlug}`, + }, + ], + }; + + const rangesByRoad = new Map(); + for (const r of ranges) { + const list = rangesByRoad.get(r.road) ?? []; + list.push(r); + rangesByRoad.set(r.road, list); + } + const sortedRoads = [...roads].sort((a, b) => + a.name.localeCompare(b.name, "zh-Hant"), + ); + + const ctaHref = `/?city=${citySlug}&district=${districtSlug}`; + + return ( + <> +