Files
infiplot-web/scripts/migrate-featured.ts
T
Zonghao Yuan 0e4c2ebef4 feat(engine): merge cloudflare-migration — paradigm D engine, BYOK proxy, story persistence (#95)
Squash-merge the cloudflare-migration branch (7 commits by Kai ki) into
staging with conflict resolution, feature integration, and bug fixes.

Engine:
- Paradigm D: single-stream Writer replacing dual-phase Plan/Beats
- Delete Architect agent; story bible generated via Writer <plan> tag
- Modular prompt architecture (segments/registry/builder)
- StreamRouter for tagged stream splitting (<plan>/<story>/<choices>)

Infrastructure:
- Cloudflare Workers deployment (wrangler.jsonc, OpenNext adapter)
- D1 database schema + Drizzle ORM (scaffolded, not yet active)
- R2 storage helpers (scaffolded, not yet active)
- Story persistence API routes + client-side persistence

BYOK (Bring Your Own Key):
- /api/llm/user-proxy with SSRF-protected LLM proxy (+ requireUser auth)
- CORS-aware fetch in ai-client: auto-detect CORS failure, fallback to
  server proxy transparently via OpenAI SDK custom fetch
- BYO config support added to classify-freeform and vision routes
- SettingsModal CORS privacy notice (keys never logged/stored)

SSE streaming:
- engineClient.ts: fetchSSE helper for progressive scene events
- startSession/requestScene accept optional emit callback
- Fix SSE error event field name (error → message) in scene/start routes

i18n integration:
- Wire buildLanguageDirective into paradigm D's prompt builder
- Update corsNotice i18n keys (zh-CN/en/ja) with CORS proxy privacy text
- Preserve Session.language + LanguageSwitcher from i18n commit

Co-authored-by: Kai ki <155355644+zbf1009@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-18 18:05:38 +08:00

158 lines
6.3 KiB
TypeScript

/**
* 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<string, string> = { "男性向": "male", "女性向": "female" };
const prefixMap: Record<string, string> = { "男性向": "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<number, number>();
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);
}