From 59b0475f462dd8aa5130be1c57e917aa448e9729 Mon Sep 17 00:00:00 2001 From: ronload <91997734+ronload@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:26:12 +0800 Subject: [PATCH 1/3] build(etl): stamp generatedAt on base.json base.json now carries the ETL run timestamp so downstream consumers (e.g. sitemap lastModified, landing page freshness line) can reflect the actual data generation time instead of request time. --- scripts/parse-address-data.ts | 1 + 1 file changed, 1 insertion(+) 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) => ({ From d4f72aa143d3bee57df75ec298ede4411655b234 Mon Sep 17 00:00:00 2001 From: ronload <91997734+ronload@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:26:29 +0800 Subject: [PATCH 2/3] feat(seo): add district landing pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce 368 statically-rendered landing pages under /tw/[city-slug]/[district-slug]/, each listing the district's road translations and 3+3 zip segments with a one-sentence machine summary and BreadcrumbList JSON-LD. Motivation: the single-page SPA surfaces no content for long-tail queries like "信義區郵遞區號" or "板橋區 英文". Pre-rendering per district gives search engines and LLM crawlers extractable, verifiable facts per URL. - add toSlug() utility for URL slugs derived from English names - expand sitemap from 1 entry to ~369 with ETL-timestamp lastModified - explicitly allow GPTBot, Google-Extended, PerplexityBot, ClaudeBot in robots (disallow /data/ asset dir) --- src/app/robots.ts | 12 +- src/app/sitemap.ts | 25 +- src/app/tw/[city]/[district]/page.tsx | 345 ++++++++++++++++++++++++++ src/lib/slug.ts | 7 + 4 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 src/app/tw/[city]/[district]/page.tsx create mode 100644 src/lib/slug.ts 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 ( + <> +