v0.10.35: 原站新增内容增量导入(import-live-posts REST 19 篇 + download-missing-media 16 图)+ 博客 HTML 文章统一 single 模板布局(PostHero 白摘要/Post Views,弃旧自定义卡片版式)
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* # 脚本 增量导入原站新增内容(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();
|
||||
Reference in New Issue
Block a user