Files
dusuniot/scripts/import-live-posts.ts

116 lines
5.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 脚本 增量导入原站新增内容(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<string> {
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<number | null> {
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<number | null> {
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<number> {
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();