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
+1 -1
View File
@@ -249,6 +249,6 @@ cookie-policy:19 段纯文本。
| 2 | ~~移动端 H1 ticker 高度差 ~14px~~ ✅ v0.10.34 修复:≤767 `.eael-ticker-wrap` flex-wrap:wrap + `.eael-ticker` 通栏(原站断点实测 767 wrap/768 nowrap),slide 390×73.6 精确一致 | H1 ticker 移动端 | 已修 |
| 3 | ~~全局 `a:hover` 为 --brand-dark(#c85600),kit 全局 secondary 是 #FFA11B~~ ✅ v0.10.31 修复(a:hover=secondary #FFA11B,e-global 全套变量补入 :root) | G1/全局样式 | 已修 |
| 4 | PG 连接曾被 stale vite dev 进程(3311 端口)泄漏占满(290 连接),已清理;重复发生时检查残留 dev 进程 | 环境 | 记录 |
| 5 | 内嵌模板文章列表取 dump 快照,原站新增文章(161855/161748)尚未导入,卡片内容不同步(版式一致) | 内容数据 | 中 |
| 5 | ~~内嵌模板文章列表取 dump 快照,原站新增文章(161855/161748)尚未导入~~ ✅ v0.10.35:`scripts/import-live-posts.ts` REST 增量导入 19 篇(post+page,幂等可重跑)+ `scripts/download-missing-media.ts` 补拉 2026/11 特色图 16 张;博客 HTML 文章详情统一原站 single 模板布局(hero 白摘要/Post Views 行) | 内容数据 | 已修 |
| 6 | posts 卡片移动端个别卡高 ±5px(原站 srcset 选 768/medium 变体,本地用原图;宽高比略有差异) | posts widget | 低 |
| 7 | dev 脚本须用 `bun --bun vite dev`:纯 node 下 Vite module-runner 无法解析 Bun 内置模块;vite.config 已把 "bun"/node:* 标记为 ssr builtins | 环境 | 记录 |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dusuniot-rebuild",
"version": "0.10.34",
"version": "0.10.35",
"private": true,
"type": "module",
"scripts": {
+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();
+11 -1
View File
@@ -16,6 +16,7 @@ export type PostHeroData = {
featuredAlt?: string | null;
publishedAt?: Date | string | null;
updatedAt?: Date | string | null;
views?: number | null;
};
/** # 函数 日期长格式(原站 October 21, 2026) **/
@@ -51,6 +52,13 @@ export function PostHero(props: { post: PostHeroData }) {
<span className="ds-info-text">{fmtDate(post.publishedAt)}</span>
</li>
) : null}
{/* Post Views(原站 hero 内 ele-view-dif shortcode widget,取证 161855) */}
{post.views != null && post.views > 0 ? (
<li>
<svg viewBox="0 0 512 512" className="ds-ico" aria-hidden><path d="M33.9 330.7H10c-2.8 0-5 2.2-5 5v80c0 2.8 2.2 5 5 5h23.9c2.8 0 5-2.2 5-5v-80c0-2.8-2.2-5-5-5zM130.7 298.7h-23.9c-2.8 0-5 2.2-5 5v112c0 2.8 2.2 5 5 5h23.9c2.8 0 5-2.2 5-5v-112c0-2.8-2.2-5-5-5zM227.6 250.7h-23.9c-2.8 0-5 2.2-5 5v160c0 2.8 2.2 5 5 5h23.9c2.8 0 5-2.2 5-5v-160c0-2.8-2.2-5-5-5zM324.4 202.7h-23.9c-2.8 0-5 2.2-5 5v208c0 2.8 2.2 5 5 5h23.9c2.8 0 5-2.2 5-5v-208c0-2.8-2.2-5-5-5zM421.3 154.7h-23.9c-2.8 0-5 2.2-5 5v256c0 2.8 2.2 5 5 5h23.9c2.8 0 5-2.2 5-5v-256c0-2.8-2.2-5-5-5zM502 61.7h-23.9c-2.8 0-5 2.2-5 5v352c0 2.8 2.2 5 5 5H502c2.8 0 5-2.2 5-5v-352c0-2.8-2.2-5-5-5z" /></svg>
<span className="ds-info-text">Post Views: {post.views}</span>
</li>
) : null}
</ul>
</div>
<div className="ds-col-r">
@@ -83,7 +91,9 @@ export const POST_HERO_CSS = `
.ds-posthero .ds-col-l { width: 50%; padding: 0 10px 0 30px; display: flex; flex-direction: column; justify-content: center; }
.ds-posthero .ds-col-r { width: 50%; padding: 0 30px 0 10px; display: flex; }
.ds-posthero .ds-title { margin: 0; font-size: 28px; font-weight: 600; line-height: 1.25; color: #fff; }
.ds-posthero .ds-excerpt { margin: 0 0 20px; font-size: 16px; line-height: 24px; color: #333; }
/* 摘要:原站 hero 深蓝底上为白色(161855 取证 widget 1049c63 color #fff 18px;
* 16px 取自早期单篇取证,颜色统一按深底可读的 #fff) */
.ds-posthero .ds-excerpt { margin: 0 0 20px; font-size: 16px; line-height: 24px; color: #fff; }
.ds-posthero .ds-info { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; }
.ds-posthero .ds-info li { display: flex; align-items: center; margin: 0 10px; font-size: 16px; line-height: 24px; }
.ds-posthero .ds-ico { width: 16px; height: 16px; fill: #fff; margin-right: 5px; }
+14 -87
View File
@@ -9,7 +9,6 @@ import { ElementorRenderer, extractElementor } from "~/components/elementor/Rend
import { InquiryForm } from "~/components/website/InquiryForm";
import { Button } from "~/components/ui/button";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
import { getPublishedContent, getMenuTree, listPosts, type MenuNode, type PostListItem } from "~/server/data";
import { PostHero } from "~/components/website/PostHero";
import { resolveImageVariants } from "~/utils/image-variant";
@@ -141,7 +140,6 @@ function BlogPostPage() {
);
}
const post = data.post;
const cats = post.terms.filter((t) => t.taxonomy === "category");
const elementor = extractElementor(post.meta);
// Elementor 文章(原站 single 模板):hero + 面包屑条 + 三列正文
// (cd804a1 取证 2026-11-03:分享栏 60px / 正文 776px / 侧栏 360px,容器 1200)
@@ -166,41 +164,19 @@ function BlogPostPage() {
</Layout>
);
}
// HTML 文章:从正文提取 h2/h3 生成目录(并注入锚点 id)
// HTML 文章(无 elementor 的 dump 文章 / REST 增量导入):与原站 single 模板同布局
// (hero + 面包屑 + 分享栏/正文/侧栏 三列),正文走 content-html(WP 渲染 HTML)
const toc = post.html ? buildToc(post.html) : null;
return (
<Layout layout={data.layout}>
<div className="site-container">
<Breadcrumb aria-label="Breadcrumb">
<BreadcrumbList>
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem><BreadcrumbLink to="/blog/">Blog</BreadcrumbLink></BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem><BreadcrumbPage>{post.title}</BreadcrumbPage></BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="grid grid-cols-1 items-start gap-7 min-[1080px]:grid-cols-[1fr_340px]">
{/* 主栏:白卡正文(目录 + 内容) */}
<article className="rounded-lg bg-white p-6 min-[760px]:p-9">
<h1 className="mt-0 text-[30px] leading-[1.35]">{post.title}</h1>
<div className="mb-5 text-[13px] text-muted-foreground">
{post.publishedAt ? new Date(post.publishedAt).toISOString().slice(0, 10) : ""}
{cats.length > 0 ? (
<>
{" · "}
{cats.map((c, i) => (
<span key={c.slug}>
{i > 0 ? ", " : ""}
{c.name}
</span>
))}
</>
) : null}
</div>
{post.featuredUrl ? (
<img src={post.featuredUrl} alt={post.featuredAlt || post.title} className="my-3 mb-5 w-full rounded-lg" />
) : null}
<PostHero post={post} />
<style>{POST_LAYOUT_CSS}</style>
<div className="ds-postlayout">
<div className="ds-wrap">
<div className="ds-rail">
<ShareRail />
</div>
<div className="ds-main">
{toc && toc.items.length >= 3 ? (
<Accordion type="single" collapsible defaultValue="toc" className="mb-6 rounded-md border px-4">
<AccordionItem value="toc" className="border-b">
@@ -219,65 +195,16 @@ function BlogPostPage() {
</AccordionItem>
</Accordion>
) : null}
{elementor ? (
<ElementorRenderer doc={elementor} />
) : post.html ? (
{post.html ? (
<div className="content-html" dangerouslySetInnerHTML={{ __html: toc?.html || post.html }} />
) : (
<p className="text-muted-foreground">{post.excerpt || t("blog_importing")}</p>
)}
</article>
{/* 侧栏:社交图标 / CONTACT US 表单 / IoT Gateways 产品分组 */}
<aside className="space-y-5">
<div className="flex justify-center gap-3 rounded-lg bg-white p-4">
<BrandIcon label="LinkedIn" bg="#0a66c2" path="M20.45 20.45h-3.55v-5.57c0-1.33-.03-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.27 2.37 4.27 5.45v6.29zM5.34 7.43a2.06 2.06 0 1 1 0-4.12 2.06 2.06 0 0 1 0 4.12zM7.12 20.45H3.56V9h3.56v11.45z" />
<BrandIcon label="YouTube" bg="#ff0000" path="M23.5 6.19a3.02 3.02 0 0 0-2.12-2.14C19.5 3.55 12 3.55 12 3.55s-7.5 0-9.38.5A3.02 3.02 0 0 0 .5 6.19C0 8.07 0 12 0 12s0 3.93.5 5.81a3.02 3.02 0 0 0 2.12 2.14c1.88.5 9.38.5 9.38.5s7.5 0 9.38-.5a3.02 3.02 0 0 0 2.12-2.14C24 15.93 24 12 24 12s0-3.93-.5-5.81zM9.55 15.57V8.43L15.82 12l-6.27 3.57z" />
<BrandIcon label="Facebook" bg="#1877f2" path="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.09 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.7 4.53-4.7 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.95.93-1.95 1.89v2.26h3.32l-.53 3.49h-2.79V24C19.61 23.09 24 18.1 24 12.07z" />
</div>
<div className="rounded-lg bg-white p-5">
<InquiryForm formKey="contact-us-form-sidebar" productInterest={post.title} title={t("blog_contact_title")} />
</div>
{data.gwGroups.length > 0 ? (
<div className="rounded-lg bg-white p-5">
<h3 className="mt-0 text-[20px] font-bold text-brand">{t("blog_gateways")}</h3>
<Accordion type="multiple" className="border-t">
{data.gwGroups.map((g) => (
<AccordionItem key={g.id} value={`gw-${g.id}`}>
<AccordionTrigger className="text-[15px] font-medium">{g.title}</AccordionTrigger>
<AccordionContent>
<ul className="m-0 list-none p-0">
{g.children.map((c) => (
<li key={c.id} className="py-1">
<a href={c.url} className="text-[14px] text-muted-foreground hover:text-brand hover:underline">{c.title}</a>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
) : null}
</div>
<aside className="ds-side">
<PostSidebarWidget groups={data.gwGroups} productInterest={post.title} />
</aside>
</div>
{/* 底部 Related Posts(原站:4 列卡片,灰渐变占位 + 标题) */}
{data.related.length > 0 ? (
<section className="my-12">
<h2 className="mb-6 text-center text-[26px]">{t("blog_related_posts")}</h2>
<div className="grid grid-cols-2 gap-5 min-[960px]:grid-cols-4">
{data.related.map((p) => (
<Link key={p.id} to="/blog/$slug/" params={{ slug: p.slug }} className="block">
<div className="mb-3 aspect-[4/3] overflow-hidden rounded-md bg-gradient-to-b from-[#9aa3ad] to-[#c9ced4]">
{p.imageUrl ? <img src={p.imageUrl} alt={p.title} loading="lazy" className="h-full w-full object-cover" /> : null}
</div>
<div className="text-[15px] leading-[1.45] font-medium hover:text-brand">{p.title}</div>
</Link>
))}
</div>
</section>
) : null}
</div>
</Layout>
);