v0.10.35: 原站新增内容增量导入(import-live-posts REST 19 篇 + download-missing-media 16 图)+ 博客 HTML 文章统一 single 模板布局(PostHero 白摘要/Post Views,弃旧自定义卡片版式)

This commit is contained in:
bigcode
2026-11-04 00:36:19 +08:00
parent 65861acf5a
commit 57cf5956e8
6 changed files with 171 additions and 90 deletions
+29
View File
@@ -0,0 +1,29 @@
/**
* # 脚本 下载缺失媒体文件(media 表行存在但 public/uploads 无物理文件的)
* 配合 import-live-posts.ts:dump 快照之后新上传的图片需从原站拉取。
* 用法:bun scripts/download-missing-media.ts
*/
import { SQL } from "bun";
import { existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
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 rows = await sql`select id, path, url from media order by id`;
let ok = 0, fail = 0;
for (const r of rows) {
const f = join("public", "uploads", r.path);
if (existsSync(f)) continue;
const res = await fetch(`${LIVE}/wp-content/uploads/${r.path}`, { headers: { "User-Agent": "Mozilla/5.0" } });
if (!res.ok) {
console.log("MISS", res.status, r.path);
fail++;
continue;
}
mkdirSync(dirname(f), { recursive: true });
await Bun.write(f, res);
ok++;
if (ok % 20 === 0) console.log(`... ${ok} downloaded`);
}
console.log("下载完成:", ok, "缺失:", fail);
await sql.end();
+115
View File
@@ -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();