/** * migrate-featured.ts — 精选故事迁移脚本 * * 从 app/page.tsx 的 STORIES 常量生成 featured_stories INSERT SQL。 * 输出 SQL 到 stdout(可通过 wrangler d1 execute 导入),或 --dry-run 预览。 * * Usage: * npx tsx scripts/migrate-featured.ts > drizzle/seed-featured.sql * npx tsx scripts/migrate-featured.ts --dry-run * wrangler d1 execute infiplot-db --file=drizzle/seed-featured.sql */ import { readFileSync } from "node:fs"; import { join } from "node:path"; const DRY_RUN = process.argv.includes("--dry-run"); // ── Parse STORIES from app/page.tsx ────────────────────────────────────── type StoryContent = { title: string; outline: string; style: string; tags: string[] }; function extractStories(): Record<"男性向" | "女性向", StoryContent[]> { const src = readFileSync(join(process.cwd(), "app/page.tsx"), "utf-8"); const startIdx = src.indexOf("const STORIES:"); if (startIdx === -1) throw new Error("Cannot find 'const STORIES:' in app/page.tsx"); const eqIdx = src.indexOf("= {", startIdx); if (eqIdx === -1) throw new Error("Cannot find STORIES assignment"); let braceCount = 0; let objStart = -1; for (let i = eqIdx + 2; i < src.length; i++) { if (src[i] === "{") { if (objStart === -1) objStart = i; braceCount++; } else if (src[i] === "}") { braceCount--; if (braceCount === 0) { const objStr = src.slice(objStart, i + 1); // CR-11: Convert JS object literal to JSON safely (no eval/Function) // 1. Wrap unquoted keys in double-quotes (中文 and ASCII keys) // 2. Replace single-quotes with double-quotes // 3. Remove trailing commas before } or ] const jsonStr = objStr .replace(/^\s*([\w一-鿿]+)\s*:/gm, '"$1":') // unquoted keys → quoted .replace(/'/g, '"') // single → double quotes .replace(/,\s*([}\]])/g, "$1"); // trailing commas try { return JSON.parse(jsonStr) as Record<"男性向" | "女性向", StoryContent[]>; } catch (parseErr) { throw new Error(`Failed to parse STORIES as JSON: ${(parseErr as Error).message}. Consider extracting STORIES to a standalone JSON file.`); } } } } throw new Error("Cannot parse STORIES object — unbalanced braces"); } // ── Parse DISPLAY_ORDER from app/page.tsx ──────────────────────────────── function extractDisplayOrder(): Record<"男性向" | "女性向", number[]> { const src = readFileSync(join(process.cwd(), "app/page.tsx"), "utf-8"); const startIdx = src.indexOf("const DISPLAY_ORDER:"); if (startIdx === -1) throw new Error("Cannot find 'const DISPLAY_ORDER:' in app/page.tsx"); const eqIdx = src.indexOf("= {", startIdx); if (eqIdx === -1) throw new Error("Cannot find DISPLAY_ORDER assignment"); let braceCount = 0; let objStart = -1; for (let i = eqIdx + 2; i < src.length; i++) { if (src[i] === "{") { if (objStart === -1) objStart = i; braceCount++; } else if (src[i] === "}") { braceCount--; if (braceCount === 0) { const objStr = src.slice(objStart, i + 1); const fn = new Function(`return (${objStr})`); return fn() as Record<"男性向" | "女性向", number[]>; } } } throw new Error("Cannot parse DISPLAY_ORDER object — unbalanced braces"); } // ── Generate SQL ───────────────────────────────────────────────────────── function escSql(s: string): string { return s.replace(/'/g, "''"); } function generateSql(): string { const stories = extractStories(); const displayOrder = extractDisplayOrder(); const lines: string[] = [ "-- Auto-generated by scripts/migrate-featured.ts", "-- Idempotent: uses INSERT OR REPLACE", "", "DELETE FROM featured_stories;", "", ]; const genderMap: Record = { "男性向": "male", "女性向": "female" }; const prefixMap: Record = { "男性向": "m", "女性向": "f" }; for (const [genderCn, storyList] of Object.entries(stories)) { const gender = genderMap[genderCn]!; const prefix = prefixMap[genderCn]!; const order = displayOrder[genderCn as keyof typeof displayOrder] ?? Array.from({ length: storyList.length }, (_, i) => i); // Generate a sortOrder for each story based on its position in DISPLAY_ORDER const sortOrderMap = new Map(); for (let sortPos = 0; sortPos < order.length; sortPos++) { sortOrderMap.set(order[sortPos]!, sortPos); } for (let i = 0; i < storyList.length; i++) { const s = storyList[i]!; const id = `${prefix}${i}`; const sortOrder = sortOrderMap.get(i) ?? i; const coverPath = `/home/${id}.webp`; const firstactPath = `/home/firstact/${id}.json`; const firstscenePath = `/home/firstscene/${id}.webp`; const tagsJson = JSON.stringify(s.tags); lines.push( `INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at)` + ` VALUES ('${escSql(id)}', '${gender}', '${escSql(s.title)}', '${escSql(s.outline)}', '${escSql(s.style)}', '${escSql(tagsJson)}', '${escSql(coverPath)}', '${escSql(firstactPath)}', '${escSql(firstscenePath)}', ${sortOrder}, 1, 0, unixepoch());`, ); } lines.push(""); } return lines.join("\n"); } // ── Main ───────────────────────────────────────────────────────────────── try { const sql = generateSql(); if (DRY_RUN) { console.log("=== DRY RUN — SQL preview (not executing) ===\n"); console.log(sql); console.log("\n=== END DRY RUN ==="); console.log(`\nTotal: ${sql.split("INSERT").length - 1} records`); } else { process.stdout.write(sql); } } catch (err) { console.error("Migration script failed:", err instanceof Error ? err.message : err); process.exit(1); }