fix: preserve semantic news sections in WeChat morning drafts
This commit is contained in:
@@ -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 为准。
|
||||
@@ -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 }); }
|
||||
});
|
||||
|
||||
@@ -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 -
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user