diff --git a/docs/sections-inventory.md b/docs/sections-inventory.md index eb4f452dd..ed5f3d032 100644 --- a/docs/sections-inventory.md +++ b/docs/sections-inventory.md @@ -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 | 环境 | 记录 | diff --git a/package.json b/package.json index fe5d9c794..58ad0b280 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dusuniot-rebuild", - "version": "0.10.34", + "version": "0.10.35", "private": true, "type": "module", "scripts": { diff --git a/scripts/download-missing-media.ts b/scripts/download-missing-media.ts new file mode 100644 index 000000000..749fe2bc7 --- /dev/null +++ b/scripts/download-missing-media.ts @@ -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(); diff --git a/scripts/import-live-posts.ts b/scripts/import-live-posts.ts new file mode 100644 index 000000000..7cee2e4cb --- /dev/null +++ b/scripts/import-live-posts.ts @@ -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 { + 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(); diff --git a/src/components/website/PostHero.tsx b/src/components/website/PostHero.tsx index 8e3e0dd58..6778861ca 100644 --- a/src/components/website/PostHero.tsx +++ b/src/components/website/PostHero.tsx @@ -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 }) { {fmtDate(post.publishedAt)} ) : null} + {/* Post Views(原站 hero 内 ele-view-dif shortcode widget,取证 161855) */} + {post.views != null && post.views > 0 ? ( +
  • + + Post Views: {post.views} +
  • + ) : null}
    @@ -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; } diff --git a/src/routes/blog.$slug.tsx b/src/routes/blog.$slug.tsx index 773527d20..15330bbf2 100644 --- a/src/routes/blog.$slug.tsx +++ b/src/routes/blog.$slug.tsx @@ -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() { ); } - // HTML 文章:从正文提取 h2/h3 生成目录(并注入锚点 id) + // HTML 文章(无 elementor 的 dump 文章 / REST 增量导入):与原站 single 模板同布局 + // (hero + 面包屑 + 分享栏/正文/侧栏 三列),正文走 content-html(WP 渲染 HTML) const toc = post.html ? buildToc(post.html) : null; return ( -
    - - - Home - - Blog - - {post.title} - - -
    - {/* 主栏:白卡正文(目录 + 内容) */} -
    -

    {post.title}

    -
    - {post.publishedAt ? new Date(post.publishedAt).toISOString().slice(0, 10) : ""} - {cats.length > 0 ? ( - <> - {" · "} - {cats.map((c, i) => ( - - {i > 0 ? ", " : ""} - {c.name} - - ))} - - ) : null} -
    - {post.featuredUrl ? ( - {post.featuredAlt - ) : null} + + +
    +
    +
    + +
    +
    {toc && toc.items.length >= 3 ? ( @@ -219,65 +195,16 @@ function BlogPostPage() { ) : null} - {elementor ? ( - - ) : post.html ? ( + {post.html ? (
    ) : (

    {post.excerpt || t("blog_importing")}

    )} -
    - - {/* 侧栏:社交图标 / CONTACT US 表单 / IoT Gateways 产品分组 */} -
    +
    - - {/* 底部 Related Posts(原站:4 列卡片,灰渐变占位 + 标题) */} - {data.related.length > 0 ? ( -
    -

    {t("blog_related_posts")}

    -
    - {data.related.map((p) => ( - -
    - {p.imageUrl ? {p.title} : null} -
    -
    {p.title}
    - - ))} -
    -
    - ) : null}
    );