fix: preserve semantic news sections in WeChat morning drafts

This commit is contained in:
john
2026-10-20 06:07:45 +08:00
parent 03bdb09d8b
commit b6349269c0
7 changed files with 102 additions and 22 deletions
@@ -0,0 +1,27 @@
# 2026-10-20 早报草稿仅剩导语与页尾
## 现场与根因
05:29:19 自动推送记录为成功,但微信原草稿正文只有约 2,980 字符、1 张关注二维码,无新闻栏目。当天网页有 26 张新闻卡片、4 张新闻图,使用 `<section class="section">`,转换器 `splitSections` 仅识别 `<div class="section">`,导致所有栏目被漏掉。不能把数据库 success 当成正文完整的验收。
此前防复发发布 `20261019-141247-03bdb09` 在全目录备份时遇到运行中消失的 `.agents/.skills-sync.lock` 目录而中止。live manifest 仍是 `20261009-102835-05b440c`;未完成生产切换。
## 用户授权的当天修复
用户明确要求重新推送 10 月 20 日草稿。保留新闻标题、来源链接和 event key,对栏目容器作兼容规范化,补回真实来源配图,更新同一份原草稿,无重复创建。
- 目标 media_id:`GR19aTT35YYg2GKlTdWRZRS6wk6luKdSpZ4mdyjf-6hsNQ05LTFOqWQsFHbKZQjL`。
- 原稿与内容文件备份:生产 `data/news-image-repair-1792447569943/`。
- 网页恢复 9 张真实新闻图;微信正文约 19,730 字符,保留 6 张新闻配图与原关注二维码共 7 图,封面采用报道中的航空货运照片。
- 同一原草稿更新后读回,完整正文文字、顺序图片、标题、作者、摘要、阅读原文和评论设置均通过核对。
- 原网页 SHA256:`8023926b5ef6096136dabe26574c61f145d8075a0533223a7d2aa0791aeda3df`;修复后:`74680279ac5cdcd86424a1a696c4407f475789fba44c8c54bdc94b83b7583326`。
- 本地审计目录 `.local/news-morning-image-release/`;内容修复与验收文件 `/tmp/memind-news-1020-repair/`。
## 防复发代码
- 微信栏目解析兼容 div/section、多 class、属性顺序和单引号,仍按栏目边界分段。
- news002 源稿存在新闻卡片却渲染出零新闻卡片时拒绝生成草稿,避免再次将只有导语、页尾的内容标为成功。
- 备份与持久目录复制排除临时 `.agents/.skills-sync.lock`;不排除用户页面、资料或普通目录,不跳过备份错误与健康检查。
- 新增语义 section 与旧 div 等价、边界隔离、空新闻正文阻断,以及 tar 排除锁但保留用户内容的实际回归验证。
当天草稿修复已验收。永久代码须通过测试、明确批准、main、CI 与整包发布后才生效;实际生产版本以 live manifest 为准。
+21 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { readFileSync, mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
@@ -55,3 +55,23 @@ test('an unavailable configured target still blocks remount', () => {
assert.notEqual(result.status, 0);
assert.match(result.stderr, /did not become healthy/);
});
test('release tar excludes transient skills locks while keeping user content', () => {
const root = mkdtempSync(join(tmpdir(), 'portal-backup-'));
try {
const owner = join(root, 'Memind', 'MindSpace', 'user');
mkdirSync(join(owner, '.agents', '.skills-sync.lock'), { recursive: true });
writeFileSync(join(owner, '.agents', '.skills-sync.lock', 'owner'), 'transient');
writeFileSync(join(owner, 'page.html'), 'persistent page');
const archive = join(root, 'backup.tar');
const exclusion = release.match(/tar --exclude='Memind\/\.tailscale\/\*\.sock' --exclude='([^']+)'/);
assert.ok(exclusion, 'full backup explicitly excludes transient skills lock');
const packed = spawnSync('tar', [`--exclude=${exclusion[1]}`, '-cf', archive, '-C', root, 'Memind'], { encoding: 'utf8' });
assert.equal(packed.status, 0, packed.stderr);
const listed = spawnSync('tar', ['-tf', archive], { encoding: 'utf8' });
assert.equal(listed.status, 0, listed.stderr);
assert.match(listed.stdout, /page\.html/);
assert.doesNotMatch(listed.stdout, /skills-sync\.lock/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
+4 -4
View File
@@ -822,7 +822,7 @@ copy_persisted_item() {
set +e
(
cd "${APP_DIR}"
COPYFILE_DISABLE=1 tar --exclude='*.sock' -cf - "${item}"
COPYFILE_DISABLE=1 tar --exclude='*.sock' --exclude='*/.agents/.skills-sync.lock' -cf - "${item}"
) | (
cd "${dest_root}"
COPYFILE_DISABLE=1 tar -xf -
@@ -845,7 +845,7 @@ copy_persisted_item() {
say "备份当前 live 全目录"
set +e
COPYFILE_DISABLE=1 tar --exclude='Memind/.tailscale/*.sock' \
COPYFILE_DISABLE=1 tar --exclude='Memind/.tailscale/*.sock' --exclude='*/.agents/.skills-sync.lock' \
-czf "${FULL_BACKUP_TAR}" -C "$(dirname "${APP_DIR}")" "$(basename "${APP_DIR}")"
tar_status=$?
set -e
@@ -866,7 +866,7 @@ for item in "${PERSISTED_ITEMS[@]}"; do
mkdir -p "${tmp_persist_dir}/${item}"
(
cd "${APP_DIR}/${item}"
COPYFILE_DISABLE=1 tar --exclude='*.sock' -cf - .
COPYFILE_DISABLE=1 tar --exclude='*.sock' --exclude='*/.agents/.skills-sync.lock' -cf - .
) | (
cd "${tmp_persist_dir}/${item}"
tar -xf -
@@ -904,7 +904,7 @@ for item in "${PERSISTED_ITEMS[@]}"; do
mkdir -p "${RUNTIME_DIR}/${item}"
(
cd "${APP_DIR}/${item}"
COPYFILE_DISABLE=1 tar --exclude='*.sock' -cf - .
COPYFILE_DISABLE=1 tar --exclude='*.sock' --exclude='*/.agents/.skills-sync.lock' -cf - .
) | (
cd "${RUNTIME_DIR}/${item}"
tar -xf -
+16 -17
View File
@@ -699,24 +699,23 @@ function renderQuickLinks(html) {
function splitSections(html) {
const body = extractBodyInner(html);
const sections = [];
const openRe = /<div class="section"([^>]*)>/gi;
let match = openRe.exec(body);
while (match) {
const start = match.index + match[0].length;
const attrs = match[1] ?? '';
const id = attrs.match(/\bid="([^"]+)"/i)?.[1] ?? '';
const nextBoundary = body.slice(start).search(
/<div class="section"|<div class="footer"|<div[^>]*data-mindspace-public-share/i,
);
const inner = nextBoundary === -1 ? body.slice(start) : body.slice(start, start + nextBoundary);
const htmlChunk = `<div class="section"${attrs}>${inner}`;
const title = stripInlineText(extractInnerHtml(htmlChunk, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i));
sections.push({ id, title, html: htmlChunk });
openRe.lastIndex = nextBoundary === -1 ? body.length : start + nextBoundary;
match = openRe.exec(body);
const boundaries = [];
for (const match of body.matchAll(/<(?:div|section)\b[^>]*>/gi)) {
const tag = match[0];
const classes = (tag.match(/\bclass\s*=\s*(["'])(.*?)\1/i)?.[2] ?? '').split(/\s+/);
const isSection = classes.includes('section');
if (isSection || classes.includes('footer') || /\bdata-mindspace-public-share\b/i.test(tag)) {
boundaries.push({ match, isSection });
}
}
return sections;
return boundaries.flatMap(({ match, isSection }, index) => {
if (!isSection) return [];
const id = match[0].match(/\bid\s*=\s*(["'])(.*?)\1/i)?.[2] ?? '';
const end = boundaries[index + 1]?.match.index ?? body.length;
const htmlChunk = body.slice(match.index, end);
const title = stripInlineText(extractInnerHtml(htmlChunk, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i));
return [{ id, title, html: htmlChunk }];
});
}
function renderGenericSection(
+24
View File
@@ -275,3 +275,27 @@ test('renderTodayDigest works off the news002 lede', () => {
assert.match(digest, /今日导读/u);
assert.match(digest, /中美外长再次通话<br>/u);
});
test('semantic section tags preserve the same full news and weather body as div tags', () => {
const semantic = `<html><body><section class="section" id="headlines"><div class="section-title">要闻</div><div class="card"><h3>头条</h3><p>正文</p></div></section><section class="section" id="weather"><div class="section-title">天气</div><div class="weather-grid"><div class="weather-card"><div class="wc">北京</div><div class="we">晴</div><div class="wt">20℃</div></div></div></section></body></html>`;
const legacy = semantic.replace(/<section/g, '<div').replace(/<\/section>/g, '</div>');
const sections = wechatDailyNewsInlineInternals.splitSections(semantic);
assert.deepEqual(sections.map((item) => item.id), ['headlines', 'weather']);
const original = convertDailyNewsHtmlToWechatInlineArticle(legacy);
const actual = convertDailyNewsHtmlToWechatInlineArticle(semantic);
assert.equal(actual.content, original.content);
assert.match(actual.content, /头条/u);
assert.match(actual.content, /北京/u);
});
test('section parsing supports attribute order, extra classes and single quotes without matching section-title', () => {
const html = `<body><section id='domestic' class='section featured'><div class="section-title">国内</div><div class="card"><h3>国内新闻标题</h3><p>国内新闻正文</p></div></section><div id='world' class='featured section'><div class="section-title">国际</div><div class="card"><h3>国际新闻标题</h3><p>国际新闻正文</p></div></div><section class='footer'>页尾</section></body>`;
const sections = wechatDailyNewsInlineInternals.splitSections(html);
assert.deepEqual(sections.map((item) => item.id), ['domestic', 'world']);
assert.doesNotMatch(sections[0].html, /国际新闻正文/);
assert.doesNotMatch(sections[1].html, /页尾/);
const rendered = convertDailyNewsHtmlToWechatInlineArticle(html);
assert.match(rendered.content, /国内新闻标题/u);
assert.match(rendered.content, /国际新闻正文/u);
});
+4
View File
@@ -871,6 +871,10 @@ export function buildDailyNewsWechatDraftArticle({
includeFixedLayout: false,
fixedLayoutLength: footerLength,
});
if (/class=["'][^"']*\bmasthead\b/i.test(String(html ?? ''))
&& extractCardArticles(html).length > 0 && inline.cardCount === 0) {
throw new Error('News morning draft has source news cards but rendered no news cards');
}
let content = applyDailyNewsWechatDraftFooter(inline.content, {
publicUrl,
qrcodeImageUrl,
+6
View File
@@ -365,3 +365,9 @@ test('isNewsMorningContentDegraded counts news002 lead cards', async () => {
assert.equal(isNewsMorningContentDegraded(html, { minStoryCards: 8 }), false);
assert.equal(isNewsMorningContentDegraded(html, { minStoryCards: 9 }), true);
});
test('news002 push refuses a draft that loses every source news card', () => {
const html = '<html><body><div class="masthead"><h1>每日新闻早报</h1></div><main class="section" id="domestic"><div class="section-title">国内</div><div class="card"><h3>不能丢失的新闻</h3><p>真实新闻正文</p><a href="https://example.com/story">来源</a></div></main></body></html>';
assert.throws(() => buildDailyNewsWechatDraftArticle({ html }), /rendered no news cards/);
});