diff --git a/docs/incidents/2026-10-20-news-morning-empty-wechat-body.md b/docs/incidents/2026-10-20-news-morning-empty-wechat-body.md new file mode 100644 index 0000000..ba86fdf --- /dev/null +++ b/docs/incidents/2026-10-20-news-morning-empty-wechat-body.md @@ -0,0 +1,27 @@ +# 2026-10-20 早报草稿仅剩导语与页尾 + +## 现场与根因 + +05:29:19 自动推送记录为成功,但微信原草稿正文只有约 2,980 字符、1 张关注二维码,无新闻栏目。当天网页有 26 张新闻卡片、4 张新闻图,使用 `
`,转换器 `splitSections` 仅识别 `
`,导致所有栏目被漏掉。不能把数据库 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 为准。 diff --git a/release-portal-targets.test.mjs b/release-portal-targets.test.mjs index 71d612d..18f6cce 100644 --- a/release-portal-targets.test.mjs +++ b/release-portal-targets.test.mjs @@ -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 }); } +}); diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index 6d7c00e..ba28faa 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -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 - diff --git a/wechat-daily-news-inline.mjs b/wechat-daily-news-inline.mjs index a931518..3bb9d25 100644 --- a/wechat-daily-news-inline.mjs +++ b/wechat-daily-news-inline.mjs @@ -699,24 +699,23 @@ function renderQuickLinks(html) { function splitSections(html) { const body = extractBodyInner(html); - const sections = []; - const openRe = /
]*)>/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( - /
]*data-mindspace-public-share/i, - ); - const inner = nextBoundary === -1 ? body.slice(start) : body.slice(start, start + nextBoundary); - const htmlChunk = `
${inner}`; - const title = stripInlineText(extractInnerHtml(htmlChunk, /
]*>([\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, /
]*>([\s\S]*?)<\/div>/i)); + return [{ id, title, html: htmlChunk }]; + }); } function renderGenericSection( diff --git a/wechat-daily-news-inline.test.mjs b/wechat-daily-news-inline.test.mjs index 9b51645..da2ef6a 100644 --- a/wechat-daily-news-inline.test.mjs +++ b/wechat-daily-news-inline.test.mjs @@ -275,3 +275,27 @@ test('renderTodayDigest works off the news002 lede', () => { assert.match(digest, /今日导读/u); assert.match(digest, /中美外长再次通话
/u); }); + + +test('semantic section tags preserve the same full news and weather body as div tags', () => { + const semantic = `
要闻

头条

正文

天气
北京
晴
20℃
`; + const legacy = semantic.replace(/
/g, '
'); + 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 = ``; + 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); +}); diff --git a/wechat-news-morning-draft.mjs b/wechat-news-morning-draft.mjs index 6446e08..5971c7d 100644 --- a/wechat-news-morning-draft.mjs +++ b/wechat-news-morning-draft.mjs @@ -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, diff --git a/wechat-news-morning-draft.test.mjs b/wechat-news-morning-draft.test.mjs index 105b1a5..19a5e82 100644 --- a/wechat-news-morning-draft.test.mjs +++ b/wechat-news-morning-draft.test.mjs @@ -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 = '

每日新闻早报

国内

不能丢失的新闻

真实新闻正文

来源
'; + assert.throws(() => buildDailyNewsWechatDraftArticle({ html }), /rendered no news cards/); +});