diff --git a/package.json b/package.json index 14eda1b..9666a8f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "packageManager": "pnpm@11.22.0", "scripts": { "dev": "next dev", + "cache-images": "node scripts/cache-images.mjs", "build": "next build", "start": "next start", "lint": "eslint" diff --git a/scripts/cache-images.mjs b/scripts/cache-images.mjs new file mode 100644 index 0000000..19be9be --- /dev/null +++ b/scripts/cache-images.mjs @@ -0,0 +1,84 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const contentRoot = path.join(root, "src", "data"); +const outputRoot = path.join(root, "public", "media", "news"); +const manifestPath = path.join(root, "src", "lib", "image-map.generated.ts"); +const remoteUrlPattern = /https?:\/\/[^\s)>'"]+/g; +const imageExtensionPattern = /\.(?:avif|gif|jpe?g|png|svg|webp)(?:[?#].*)?$/i; + +async function collectMarkdownFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) files.push(...await collectMarkdownFiles(entryPath)); + else if (entry.isFile() && entry.name.endsWith(".md")) files.push(entryPath); + } + + return files; +} + +function cleanUrl(value) { + return value.replace(/[.,;:!?]+$/, ""); +} + +function getExtension(url, contentType) { + const pathname = new URL(url).pathname; + const extension = path.extname(pathname).toLowerCase(); + if (extension && imageExtensionPattern.test(pathname)) return extension; + const typeExtension = contentType.split("/")[1]?.split(";")[0]?.toLowerCase(); + return typeExtension === "jpeg" ? ".jpg" : `.${typeExtension || "bin"}`; +} + +async function downloadImage(url) { + const response = await fetch(url, { + redirect: "follow", + signal: AbortSignal.timeout(15000), + }); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); + + const contentType = response.headers.get("content-type") || "application/octet-stream"; + if (!contentType.startsWith("image/")) throw new Error(`unexpected content type ${contentType}`); + + const bytes = Buffer.from(await response.arrayBuffer()); + const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 16); + const fileName = `${hash}${getExtension(url, contentType)}`; + await writeFile(path.join(outputRoot, fileName), bytes); + return `/media/news/${fileName}`; +} + +const markdownFiles = await collectMarkdownFiles(contentRoot); +const remoteUrls = new Set(); +for (const filePath of markdownFiles) { + const content = await readFile(filePath, "utf8"); + for (const match of content.matchAll(remoteUrlPattern)) { + const url = cleanUrl(match[0]); + if (imageExtensionPattern.test(url)) remoteUrls.add(url); + } +} + +await mkdir(outputRoot, { recursive: true }); +const imageMap = {}; +let failed = 0; +const urls = [...remoteUrls]; +const concurrency = 8; +for (let index = 0; index < urls.length; index += concurrency) { + const batch = urls.slice(index, index + concurrency); + await Promise.all(batch.map(async (url) => { + try { + imageMap[url] = await downloadImage(url); + } catch (error) { + failed += 1; + console.warn(`[cache-images] skipped ${url}: ${error.message}`); + } + })); +} + +const generated = `// Generated by scripts/cache-images.mjs. Do not edit.\nexport const imageMap: Record = ${JSON.stringify(imageMap, null, 2)};\n`; +await writeFile(manifestPath, generated, "utf8"); +console.log(`[cache-images] cached ${Object.keys(imageMap).length}/${remoteUrls.size} images${failed ? `; ${failed} failed` : ""}`); \ No newline at end of file diff --git a/src/data/news/index.ts b/src/data/news/index.ts index 7b1bc37..7762e0b 100644 --- a/src/data/news/index.ts +++ b/src/data/news/index.ts @@ -2,6 +2,7 @@ import fs from "fs/promises"; import path from "path"; import type { Locale } from "@/lib/i18n"; import { markdownToHtml } from "@/lib/markdown"; +import { imageMap } from "@/lib/image-map.generated"; export type NewsItem = { id: string; @@ -71,7 +72,8 @@ function normalizeNewsItem(fileName: string, raw: NewsFrontmatter, body: string) const date = raw.date?.trim() || "未注明日期"; const category = (Array.isArray(raw.category) ? raw.category[0] : raw.category)?.trim() || "未分类"; const tags = raw.tags ?? []; - const cover = raw.cover?.trim() || DEFAULT_NEWS_COVER; + const coverSource = raw.cover?.trim() || DEFAULT_NEWS_COVER; + const cover = imageMap[coverSource] ?? coverSource; const slug = raw.slug?.trim() || fileName.replace(/\.md$/i, ""); if (!title) { diff --git a/src/lib/image-map.generated.ts b/src/lib/image-map.generated.ts new file mode 100644 index 0000000..05414d9 --- /dev/null +++ b/src/lib/image-map.generated.ts @@ -0,0 +1,35 @@ +// Generated by scripts/cache-images.mjs. Do not edit. +export const imageMap: Record = { + "https://r2-image.kivotosrailway.com/2025/05/01/68137fa6a70de.jpg": "/media/news/897d764a65897170.jpg", + "https://r2-image.kivotosrailway.com/2025/05/01/68137fb192614.jpg": "/media/news/852dd5b369bf377d.jpg", + "https://r2-image.kivotosrailway.com/2025/05/01/68137fffd1a19.png": "/media/news/7ae2b8fba574195b.png", + "https://r2-image.kivotosrailway.com/2025/05/01/68137ff194540.png": "/media/news/7f7c61ff4b22140d.png", + "https://r2-image.kivotosrailway.com/2025/05/01/68137ffd40a9c.png": "/media/news/faa9264b9cbf052b.png", + "https://r2-image.kivotosrailway.com/2025/05/01/68137f9cf1f48.png": "/media/news/8ecb7a6b90f318e0.png", + "https://r2-image.kivotosrailway.com/2025/05/01/68137ffa67186.png": "/media/news/acd56aafe8b41f05.png", + "https://s21.ax1x.com/2024/09/20/pAMSLRJ.png": "/media/news/02882b262ac53ec2.png", + "https://r2-image.kivotosrailway.com/2025/05/01/6813812298936.png": "/media/news/d699056d1f6db7a2.png", + "https://r2-image.kivotosrailway.com/2025/05/01/681381518e5b7.png": "/media/news/e1ff2fb0e0402a83.png", + "https://r2-image.kivotosrailway.com/2025/04/02/67ece50d760f8.png": "/media/news/625c4eff0e6c3206.png", + "https://r2-image.kivotosrailway.com/2025/04/02/67ece40c0bc76.png": "/media/news/8419859923ddbe8f.png", + "https://r2-image.kivotosrailway.com/2025/04/02/67ecdfbf7e858.png": "/media/news/26f4a1313bbcf030.png", + "https://r2-image.kivotosrailway.com/2025/04/02/67ecf1b42ef19.png": "/media/news/f51e4ded0e8d6b85.png", + "https://r2-image.kivotosrailway.com/2025/01/07/677d16a21da4c.png": "/media/news/9e3cba51761fc108.png", + "https://r2-image.kivotosrailway.com/2025/01/14/6785481e73832.png": "/media/news/694e30e5ce74b863.png", + "https://r2-image.kivotosrailway.com/2025/02/17/67b20e14d2e42.png": "/media/news/c3ab207db385ddd7.png", + "https://image.kivotosrailway.com/uploads/696b7416b1764.jpg": "/media/news/f1e7be44f3b76abc.jpg", + "https://r2-image.kivotosrailway.com/2025/05/14/682374369d005.png": "/media/news/d67395b9a4d20687.png", + "https://r2-image.kivotosrailway.com/2025/05/13/68236919b3ebe.png": "/media/news/0dce62106c0c48a5.png", + "https://r2-image.kivotosrailway.com/2025/05/14/6823735cea4de.png": "/media/news/06b8cffc367a6f28.png", + "https://s21.ax1x.com/2024/09/20/pAMS0KI.png": "/media/news/c6e4e344e54d4976.png", + "https://s21.ax1x.com/2024/09/20/pAMSWxs.png": "/media/news/5149b301e12e850b.png", + "https://r2-image.kivotosrailway.com/2025/12/14/693ec8c79b4b8.jpg": "/media/news/3a2bc283672922bc.jpg", + "https://r2-image.kivotosrailway.com/2025/12/14/693ec8d313ba2.jpg": "/media/news/77bd04054f6a7224.jpg", + "https://r2-image.kivotosrailway.com/2025/12/14/693e5bbe608e5.jpg": "/media/news/99e9760bf7b5f31a.jpg", + "https://r2-image.kivotosrailway.com/2025/05/01/681396eb28788.jpg": "/media/news/17c8d9be981d42ea.jpg", + "https://r2-image.kivotosrailway.com/2025/05/01/681396fd3a792.jpg": "/media/news/cd0c5383ca66c6b2.jpg", + "https://r2-image.kivotosrailway.com/2025/05/01/681396f661df5.jpg": "/media/news/d245b5b6ea4f98d5.jpg", + "https://s21.ax1x.com/2024/12/19/pAO8xoT.png": "/media/news/09f416760a398ccd.png", + "https://r2-image.kivotosrailway.com/2025/05/01/681398635f9b2.png": "/media/news/378bea798cf7c855.png", + "https://r2-image.kivotosrailway.com/2025/05/01/681398634fbc4.png": "/media/news/378bea798cf7c855.png" +}; diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index a63221a..1991a42 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,5 +1,7 @@ import { marked } from "marked"; +import { imageMap } from "@/lib/image-map.generated"; export function markdownToHtml(markdown: string): string { - return marked.parse(markdown, { gfm: true, breaks: false }) as string; + const localizedMarkdown = markdown.replace(/https?:\/\/[^\s)>'"]+/g, (url) => imageMap[url.replace(/[.,;:!?]+$/, "")] ?? url); + return marked.parse(localizedMarkdown, { gfm: true, breaks: false }) as string; } \ No newline at end of file