/** * # 脚本 增量导入原站新增内容(dump 之后发布/更新的 post 与 page) * * 背景:MariaDB dump 快照截至 2026-10-29(最大 post id 161733),之后原站陆续发布新文章 * (161855/161748 等出现在内嵌模板文章列表、/blog/ 归档)。本脚本走原站 REST API * (wp-json/wp/v2/posts|pages,_embed 特色图与术语)增量补齐;Elementor 原始数据 * (_elementor_data)不经 REST 暴露,导入的 html 走博客详情 content-html 分支渲染。 * * 用法:bun scripts/import-live-posts.ts [--dry] [after=ISO日期] * after 缺省 = 本地已发布内容最大 updated_at/published_at(dump 快照点) * 幂等:slug 已存在则跳过(与 dump 增量合并,不覆盖既有行)。 */ import { SQL } from "bun"; const LIVE = "https://www.dusuniot.com"; const sql = new SQL(process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot", { max: 1 }); const dry = process.argv.includes("--dry"); const afterArg = process.argv.find((a) => a.startsWith("after="))?.slice(6); /** # 函数 取本地内容快照水位(已有内容的最大发布时间,向后取 1 天重叠窗口) */ async function watermark(): Promise { if (afterArg) return afterArg; const r = await sql`select max(published_at)::text as t from contents`; return r[0]?.t ? new Date(new Date(r[0].t).getTime() - 86400_000).toISOString() : "2026-10-29T00:00:00"; } /** # 函数 剥 HTML 标签并解码常见实体(摘要/纯文本用) */ function stripHtml(html: string): string { return html .replace(/<[^>]*>/g, " ") .replace(/&[a-z#0-9]+;/gi, " ") .replace(/\s+/g, " ") .trim(); } /** # 函数 原站媒体 URL → 本地 /uploads 路径(不在 uploads 的返回 null) */ function localUploadPath(url: string): string | null { const i = url.indexOf("/uploads/"); return i === -1 ? null : url.slice(i + "/uploads/".length); } /** # 函数 确保媒体行存在(按 legacy_id 查,缺则插入),返回 media id */ async function ensureMedia(m: { id: number; source_url: string; alt_text?: string; title?: { rendered?: string }; mime_type?: string; media_details?: { width?: number; height?: number } } | null | undefined): Promise { if (!m?.source_url) return null; const found = await sql`select id from media where legacy_id = ${m.id} limit 1`; if (found[0]) return found[0].id as number; const path = localUploadPath(m.source_url); if (!path) return null; const [res]: any = await sql`insert into media (path, url, mime_type, width, height, alt_text, title, ref_count, legacy_id, created_at) values (${path}, ${"/uploads/" + path}, ${m.mime_type || "image/jpeg"}, ${m.media_details?.width ?? null}, ${m.media_details?.height ?? null}, ${m.alt_text || ""}, ${stripHtml(m.title?.rendered || "") || null}, 0, ${m.id}, now()) returning id`; return res.id; } /** # 函数 WP 分类 slug → 本地 categories.id(按 slug 匹配;无则忽略该术语) */ async function categoryId(wpSlug: string): Promise { const r = await sql`select id from categories where slug = ${wpSlug} limit 1`; return r[0]?.id ?? null; } /** # 函数 导入一批 REST 内容(post 或 page),返回新增条数 */ async function importBatch(kind: "posts" | "pages", after: string): Promise { const url = `${LIVE}/wp-json/wp/v2/${kind}?after=${encodeURIComponent(after)}&per_page=100&orderby=date&order=asc&_embed=wp:featuredmedia,wp:term`; const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" } }); if (!res.ok) { console.log(`SKIP(REST ${res.status}):`, url); return 0; } const items = (await res.json()) as any[]; let imported = 0; for (const p of items) { const slug = p.slug; const exists = await sql`select id from contents where slug = ${slug} limit 1`; if (exists[0]) { console.log("SKIP(已存在):", slug); continue; } // WooCommerce 桩页面跳过(shop/cart/checkout/my-account 由本地路由实现) if (["shop", "cart", "checkout", "my-account"].includes(slug)) continue; const html: string = p.content?.rendered || ""; const excerpt: string = stripHtml(p.excerpt?.rendered || "") || stripHtml(html).slice(0, 160); const featured = await ensureMedia(p._embedded?.["wp:featuredmedia"]?.[0]); const type = kind === "posts" ? "post" : "page"; const published = new Date(p.date_gmt ? p.date_gmt + "Z" : p.date); const updated = new Date(p.modified_gmt ? p.modified_gmt + "Z" : p.modified); if (dry) { console.log("DRY:", type, slug, published.toISOString().slice(0, 10)); continue; } const [row]: any = await sql`insert into contents (type, slug, title, excerpt, html, status, featured_media_id, template, menu_order, seo_title, seo_description, meta, legacy_id, published_at, created_at, updated_at) values (${type}, ${slug}, ${stripHtml(p.title?.rendered || "")}, ${excerpt}, ${html}, ${"published"}, ${featured}, ${"default"}, 0, ${stripHtml(p.title?.rendered || "")}, ${excerpt.slice(0, 160)}, ${{ elementor: null }}, ${p.id}, ${published}, ${published}, ${updated}) returning id`; // 术语关联(REST _embedded wp:term 第一组 = categories) for (const group of p._embedded?.["wp:term"] || []) { for (const t of group) { if (t.taxonomy !== "category") continue; const cid = await categoryId(t.slug); if (cid) await sql`insert into content_terms (content_id, category_id) values (${row.id}, ${cid}) on conflict do nothing`; else console.log(" 未匹配分类(忽略):", t.slug, "→", slug); } } imported++; console.log("导入:", type, p.id, slug); } return imported; } const after = await watermark(); console.log("水位:", after); const n1 = await importBatch("posts", after); const n2 = await importBatch("pages", after); console.log("完成 posts:", n1, "pages:", n2); await sql.end();