From 07e9244fbdff25b930b8dd40609aecb19cbf511d Mon Sep 17 00:00:00 2001 From: john Date: Mon, 19 Oct 2026 13:35:33 +0800 Subject: [PATCH] fix: recover morning news source images and preserve localized photos --- docs/branch-disposition.md | 11 +++ .../2026-10-18-news-morning-missing-images.md | 65 ++++++++++++++++ wechat-news-morning-images.mjs | 59 +++++++++++++-- wechat-news-morning-images.test.mjs | 74 ++++++++++++++++++- 4 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 docs/incidents/2026-10-18-news-morning-missing-images.md diff --git a/docs/branch-disposition.md b/docs/branch-disposition.md index fef8e26..d733cbf 100644 --- a/docs/branch-disposition.md +++ b/docs/branch-disposition.md @@ -1,5 +1,16 @@ # 历史分支处置登记 +## `feature/news-morning-staging-title-digest` + +**状态:未合并/只读封存/禁止再次引用。不是待开发分支,不阻塞新的开发分支。** + +审计日期:2026-10-19。用户已明确批准保留旧分支和提交供只读追溯、不合并其功能,从干净主线新建图片修复分支,登记随本次提交。 + +- 保留提交:`698c6a64a7d9c40f5e7a75fda350f187b5b41262`(2026-09-23)。 +- 该提交不属于当前 `origin/main`,包含早报安全阶段暂存、微信标题摘要等旧提案。本次不合并、不 cherry-pick、不发布这些功能。 +- 该历史分支和提交仅可只读追溯;不得再作为开发基线、合并来源、cherry-pick 来源或发布来源。 +- 本次图片修复基于当前完整主线 `05b440ce6af460a4b8eb03f48bc2671846d243df`,与旧分支无代码继承关系。 + ## `fix/morning-greeting-daily-continue` **状态:已提交 / 已进入 main / 禁止再次引用。该分支保留仅用于只读追溯,不是待合并开发分支;未创建远端功能分支。** diff --git a/docs/incidents/2026-10-18-news-morning-missing-images.md b/docs/incidents/2026-10-18-news-morning-missing-images.md new file mode 100644 index 0000000..415d7ea --- /dev/null +++ b/docs/incidents/2026-10-18-news-morning-missing-images.md @@ -0,0 +1,65 @@ +# 2026-10-18 早报没有真实封面与新闻配图 + +## 生产证据 + +- 当天 03:36:35 已自动进入微信草稿,草稿 ID 为 `GR19aTT35YYg2GKlTdWRZbGvkmgw3vpRdRUAF-42Z1WFdwoQWjuHW4EjS8s9q3mq`。 +- 页面实际含 29 张新闻卡片,但 `` 数量为 0;worker 记录 attempted=29、resolved=0、imageCount=0。 +- 本地 `.thumbnail.svg` 为系统生成的通用渐变/山形兜底封面。微信 `thumb_media_id` 和 CDN 图片 HTTP 200 只能说明存在素材,不能说明是实际新闻照片。 +- 29 个来源页面逐一检查:15 篇在中新网 `.left_zw` 正文中含真实新闻照片;它们没有 `og:image` 等元数据。 +- 对同一真实图片实测:带 TKMind-NewsBot 标记的 User-Agent 得到 403;标准浏览器 User-Agent 得到 200。仅补正文提取仍会被可用性探测剔除。 + +## 本地修复 + +- `wechat-news-morning-images.mjs`:元数据没有可用图片时,在明确的文章正文容器提取照片,支持协议相对链接与懒加载属性;不从站点页头、推荐新闻、脚本或注释取图。 +- 使用标准浏览器 User-Agent;图片探测携带原文 Referer,HEAD 403/405/501 时改用 GET,并释放响应流。 +- 重复 enrichment 保留已本地化的真实图片,避免将本地文件当远程 URL 探测失败而删除。 +- 保留现有有图则展示、无真实图则保留文字卡的业务策略,不加入默认新闻小图,也不阻断出稿。 +- 修正两处与当前模板/开发环境不一致的既有测试:卡片超限测试显式设置上限;默认缩略图复制测试自行创建 fixture,不依赖工作区里是否存在默认资产。 + +## 验证结果 + +- 图片、worker、草稿相关单测 42/42 通过。 +- 使用生产原稿(SHA256 `08a06f9f32bf762d7ec5d75753d510f324e16aae372bf43fdf9743f9f07bd34a`)验证:找回 15 张真实配图,15 张全部下载本地,失败 0。 +- 修复前后 29 张卡片的标题、来源链接与 event key 全部一致。 +- 真实微信正文渲染器预演保留 9 张新闻图,正文长度 19,299 字符;其余裁剪受既有微信 20,000 字符规则影响,未更改裁剪策略。 +- 本地预演目录:`/tmp/memind-news-1018-repair/`,包含修复页面、15 张本地配图、从正文真实照片裁切的封面、微信正文预演及 `repair-plan.json`。 +- 封面已实际打开核对为对应「中韩联合巡航」新闻的海警船照片,非兜底渐变图。浏览器截图验证受本机浏览器缺失/沙箱启动失败限制,未将微信渲染器字符串验证描述为浏览器视觉验收。 +- 本次尚未写入生产页面或修改微信原草稿,未发布代码。更新当天内容及原草稿应在上述验证后取得明确批准,并先备份旧页面/封面/草稿,核对原稿 hash,使用 draft/update 更新原草稿以避免重复。 + +## 待完成 + +1. 批准后把已核对的当天图片和页面作为内容修复写入生产,替换兜底封面;不上传本机业务源码。 +2. 使用正式微信图片素材接口上传真实图片,更新同一草稿的正文和 thumb_media_id;读回验证新闻图数量、素材可访问性和封面实际内容。 +3. 本地代码变更仍需按分支闭环与整包发布规则进入主线后上线;当前脏 main 不能直接发布。 + +## 已获批准的当天内容修复 + +用户通过确认项明确批准「更新今天线上早报和原微信草稿」。已使用 `scripts/apply-news-morning-image-repair.mjs` 执行,未上传运行时代码、未创建新草稿。 + +- 原页面、封面、原微信正文及 17 个内容文件写入清单已备份到生产 `data/news-image-repair-1792272917656/`。完整验收收据为该目录的 `receipt.json`。 +- 写入前核对原页面 SHA256,以及草稿标题、正文、封面等可编辑字段,防止覆盖期间的修改;微信临时预览 URL 每次变化,不能用整个 draft/get JSON 作为并发指纹。 +- 页面写入 15 张真实新闻图,并在 mindspace-cover 元数据中显式绑定真实照片,避免后续再次生成通用兜底封面。页面新 SHA256:`6d7f2e6547ab676ef4b748379aed08d3dbf88c4cca3a84c9885eefb5cdbd4185`。 +- 公网页面读回 15 个 ``,15 个图片链接全部 HTTP 200。 +- 15 张新闻图全部上传微信;使用真实 CDN URL 和原稿二维码重新渲染后,最终微信正文按现有 20,000 字符限制保留 7 张新闻配图,加原二维码共 8 图,提交正文 19,392 字符。 +- 更新的是同一份 `GR19aTT35YYg2GKlTdWRZbGvkmgw3vpRdRUAF-42Z1WFdwoQWjuHW4EjS8s9q3mq` 草稿,没有重复创建。 +- 微信会将图片改为 data-src、调整 CDN 尺寸及移除不支持的 CSS,所以不能用 HTML 字符串逐字相等验收。已核对完整正文文字、按顺序排列的图片、标题、作者、摘要、阅读原文地址和评论设置均匹配。 +- 封面被微信异步转为裁切后的 thumb_url,此时 thumb_media_id 为空,但真实封面 URL 可读。已下载解码封面进行内容核对:540×229 JPEG,与所提交新闻照片按相同比例中心裁切后的像素均方误差为 61.31;验证的是照片实际内容,不是单纯 HTTP 200。 +- 7 张微信新闻图全部 HTTP 200。47 项图片、worker、草稿及写入保护相关测试全部通过。 + +当天页面与草稿修复已完成。提取器修复和辅助脚本仍为本地改动,尚未提交或发布;后续长期防复发发布仍需按仓库分支及整包发布规则处理。 + +续查中还发现 05:29:06 的 auto-push 记录指向另一份 media_id,该 ID 在微信 draft/get 返回 40007;未据此创建第三份草稿或改动其记录。本次明确验收的是上述仍可读取的原草稿。 + +## 防复发发布准备与阻断 + +用户同意推进防复发修复发布后,已完成: + +- 图片、草稿、worker、配置、检索、模板、去重、内容安全等相关回归 110 项全部通过。 +- 为排除对其他未提交工作的依赖,单独取 main HEAD 的三个纯函数依赖文件,在普通临时测试目录中验证图片模块与测试,20/20 通过;没有创建 Git 分支或 worktree。 +- 两文件补丁与验证记录位于 `.local/news-morning-image-release/`。补丁只包含 `wechat-news-morning-images.mjs` 与其测试,也保留了这两个文件中此前已有的图片质量告警修改;不包含学习页、发布脚本或检索/RSS 的其他改动。 +- 执行 `ALLOW_MAIN_RELEASE=1 bash scripts/check-release-ready.sh --skip-fetch`,明确被 `worktree has uncommitted or untracked changes` 阻断。核对时工作区共 121 项未提交/未跟踪路径;未清理、暂存或丢弃其他任务的改动。 +- 当时会话权限将 `.git` 设为只读,无法合法完成提交、分支与主线闭环。没有尝试绕过该权限,也没有构建 runtime、推送、合并或发布。 + +发布授权已经收到;剩余阻断是工作区整理与 Git 写权限,并非再次索要发布批准。解除后须按现有规则继续提交、同步主线、CI、整包与生产 Gate,而不是单文件上传线上源码。当前不能保证次日自动生成已经使用修复后的提取器。 + +后续用户明确允许 Git 写入,正常 `git add` 已成功。图片代码与测试将单独提交;其余未提交内容需按 portal-release 流程先取得临时 stash 的单独同意,发布后恢复。发布前基础数据库、能力、provider 与微信测试已通过。 diff --git a/wechat-news-morning-images.mjs b/wechat-news-morning-images.mjs index 20baa70..7e7a011 100644 --- a/wechat-news-morning-images.mjs +++ b/wechat-news-morning-images.mjs @@ -15,7 +15,7 @@ import { const DEFAULT_TIMEOUT_MS = 6000; const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' - + '(KHTML, like Gecko) Chrome/126.0 Safari/537.36 TKMind-NewsBot/1.0'; + + '(KHTML, like Gecko) Chrome/126.0 Safari/537.36'; const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); export const NEWS002_CARD_FALLBACK_FILENAME = 'news002-card-fallback.svg'; const NEWS002_LOCAL_IMAGE_MAX_BYTES = 2.5 * 1024 * 1024; @@ -178,7 +178,30 @@ export function extractNewsMorningSourceImage(html, pageUrl = '') { ]; for (const candidate of candidates) { const normalized = normalizeNewsMorningImageUrl(candidate, { pageUrl }); - if (normalized) return normalized; + if (normalized && !isWeakNewsMorningImageUrl(normalized)) return normalized; + } + // Chinanews articles frequently have no image metadata. Read only an explicit + // article body, never the first image on the whole page (logo/related stories). + const source = String(html ?? '').replace(/|]*>[\s\S]*?<\/script\s*>|]*>[\s\S]*?<\/style\s*>/gi, ''); + const bodyOpen = /<(div|section|article)\b[^>]*>/gi; + let match; + while ((match = bodyOpen.exec(source))) { + const classes = attrOf(match[0], 'class').split(/\s+/); + const isBody = attrOf(match[0], 'itemprop').split(/\s+/).includes('articleBody') + || classes.some((name) => ['left_zw', 'article-body', 'article-content', 'entry-content'].includes(name)); + if (!isBody) continue; + const { end } = sliceBalancedBlock(source, match[1], match.index, bodyOpen.lastIndex); + const body = source.slice(bodyOpen.lastIndex, end); + for (const image of body.match(/]*>/gi) ?? []) { + // Ignore tracking pixels and explicit decorative images inside the body. + if (/^(?:presentation|none)$/i.test(attrOf(image, 'role')) || attrOf(image, 'aria-hidden') === 'true') continue; + if (['width', 'height'].some((key) => /^\d+$/.test(attrOf(image, key)) && Number(attrOf(image, key)) < 64)) continue; + for (const key of ['data-original', 'data-src', 'src']) { + const normalized = normalizeNewsMorningImageUrl(attrOf(image, key), { pageUrl }); + if (normalized && !isWeakNewsMorningImageUrl(normalized)) return normalized; + } + } + bodyOpen.lastIndex = end; } return ''; } @@ -189,6 +212,7 @@ export async function isFetchableNewsMorningImageUrl( fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS, userAgent = DEFAULT_USER_AGENT, + referrer = '', } = {}, ) { const normalized = normalizeNewsMorningImageUrl(imageUrl); @@ -200,16 +224,19 @@ export async function isFetchableNewsMorningImageUrl( method: 'HEAD', redirect: 'follow', signal: controller.signal, - headers: { 'User-Agent': userAgent, Accept: 'image/*,*/*;q=0.8' }, + headers: { 'User-Agent': userAgent, Accept: 'image/*,*/*;q=0.8', ...(referrer ? { Referer: referrer } : {}) }, }); - if (response.status === 405 || response.status === 501) { + if ([403, 405, 501].includes(response.status)) { + await response.body?.cancel?.(); response = await fetchImpl(normalized, { redirect: 'follow', signal: controller.signal, - headers: { 'User-Agent': userAgent, Accept: 'image/*,*/*;q=0.8' }, + headers: { 'User-Agent': userAgent, Accept: 'image/*,*/*;q=0.8', ...(referrer ? { Referer: referrer } : {}) }, }); } - return response.ok; + const ok = response.ok; + await response.body?.cancel?.(); + return ok; } catch { return false; } finally { @@ -690,10 +717,11 @@ export async function enrichNews002HtmlWithSourceImages( async function resolveCardImage(card) { const existingImage = extractCardImage(card.raw); + if (normalizeNewsMorningLocalImageRef(existingImage)) return existingImage; if ( existingImage && !isWeakNewsMorningImageUrl(existingImage) - && await isFetchableNewsMorningImageUrl(existingImage, { fetchImpl, timeoutMs }) + && await isFetchableNewsMorningImageUrl(existingImage, { fetchImpl, timeoutMs, referrer: card.url }) ) { return existingImage; } @@ -703,7 +731,7 @@ export async function enrichNews002HtmlWithSourceImages( if ( fromSource && !isWeakNewsMorningImageUrl(fromSource) - && await isFetchableNewsMorningImageUrl(fromSource, { fetchImpl, timeoutMs }) + && await isFetchableNewsMorningImageUrl(fromSource, { fetchImpl, timeoutMs, referrer: card.url }) ) { return fromSource; } @@ -839,9 +867,24 @@ export function auditNews002ImageCoverage( sectionFill = auditNews002SectionFill(source, { minFlexSections, exemptSectionIds }); violations.push(...sectionFill.violations); } + // Quality warnings are informational: never turn missing images/sources into + // a publication failure or a new automatic regeneration loop. + const nonHomepageSourceCount = cards.filter((card) => { + try { + const url = new URL(card.url); + return /^https?:$/.test(url.protocol) && !/^\/(?:index\.[^/]+)?$/i.test(url.pathname); + } catch { + return false; + } + }).length; + const qualityWarnings = []; + if (cards.length && missingImages.length === cards.length) qualityWarnings.push('no-story-images'); + if (cards.length && !nonHomepageSourceCount) qualityWarnings.push('no-nonhomepage-source-links'); return { ok: violations.length === 0, violations, + qualityWarnings, + nonHomepageSourceCount, cardCount: cards.length, imageCount: cards.length - missingImages.length, leadCount: headlineLeadCount, diff --git a/wechat-news-morning-images.test.mjs b/wechat-news-morning-images.test.mjs index b4d2043..50c6fd6 100644 --- a/wechat-news-morning-images.test.mjs +++ b/wechat-news-morning-images.test.mjs @@ -7,6 +7,7 @@ import { auditNews002ImageCoverage, cardHasDisplayableImage, isWeakNewsMorningImageUrl, + isFetchableNewsMorningImageUrl, enrichNews002HtmlWithSourceImages, enrichNewsMorningSearchGroupsWithImages, extractNewsMorningSourceImage, @@ -57,6 +58,57 @@ test('enrichNewsMorningSearchGroupsWithImages reads source article metadata', as assert.equal(result.groups[0].results[0].imageSource, 'article-meta'); }); +test('source image fallback reads Chinanews body photos, excluding logo and related news', () => { + const page = 'https://www.chinanews.com.cn/gn/2026/10-17/10716271.shtml'; + const html = ` +

中韩联合巡航

+ `; + assert.equal(extractNewsMorningSourceImage(html, page), 'https://i2.chinanews.com.cn/simg/patrol.jpg'); + assert.equal(extractNewsMorningSourceImage(html.replace(/ +
+
`; + assert.equal(extractNewsMorningSourceImage(html, 'https://news.example/story'), 'https://news.example/photo.jpg'); +}); + +test('source image fallback does not read images from scripts or comments', () => { + const html = ` + +

这是一条没有照片的新闻

+ `; + assert.equal(extractNewsMorningSourceImage(html, 'https://news.example/story'), ''); +}); + +test('image probe forwards article referer and falls back from forbidden HEAD to GET', async () => { + const requests = []; + let cancelled = 0; + const ok = await isFetchableNewsMorningImageUrl('https://cdn.example/photo.jpg', { + referrer: 'https://news.example/story', + fetchImpl: async (_url, options) => { + requests.push(options); + return { ok: options.method !== 'HEAD', status: options.method === 'HEAD' ? 403 : 200, + body: { cancel: async () => { cancelled += 1; } } }; + }, + }); + assert.equal(ok, true); + assert.equal(requests.length, 2); + assert.ok(requests.every((options) => options.headers.Referer === 'https://news.example/story')); + assert.equal(cancelled, 2); +}); + +test('a repeated enrichment retains already localized news photos without remote fetches', async () => { + const html = '
' + + '

有本地配图的新闻

'; + const result = await enrichNews002HtmlWithSourceImages(html, { + fetchImpl: async () => { throw new Error('must not fetch an already localized image'); }, + }); + assert.equal(result.resolvedCount, 1); + assert.match(result.html, /src="daily-news-1018-img-story.jpg"/); +}); + test('enrichNews002HtmlWithSourceImages inserts media and body wrappers', async () => { const source = `

每日新闻早报

@@ -86,6 +138,8 @@ test('enrichNews002HtmlWithSourceImages inserts media and body wrappers', async assert.deepEqual(auditNews002ImageCoverage(result.html), { ok: true, violations: [], + qualityWarnings: [], + nonHomepageSourceCount: 4, cardCount: 4, imageCount: 4, leadCount: 3, @@ -101,6 +155,7 @@ test('auditNews002ImageCoverage blocks excess cards but keeps imageless stories' )).join(''); const audit = auditNews002ImageCoverage( `
${cards}`, + { maxStoryCards: 28 }, ); assert.equal(audit.ok, false); assert.ok(audit.violations.includes('too-many-story-cards')); @@ -180,6 +235,8 @@ test('auditNews002ImageCoverage ignores default thumbnails and does not require assert.deepEqual(auditNews002ImageCoverage(html), { ok: true, violations: [], + qualityWarnings: ['no-story-images'], + nonHomepageSourceCount: 3, cardCount: 4, imageCount: 0, leadCount: 3, @@ -311,7 +368,10 @@ test('localizeNews002CardImages downloads remote images onto the publish host', test('materializeNews002CardFallbackImage copies the shared default thumbnail once', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'news002-fallback-')); try { - const filename = materializeNews002CardFallbackImage(tmp); + const assets = path.join(tmp, 'wechat/assets'); + fs.mkdirSync(assets, { recursive: true }); + fs.writeFileSync(path.join(assets, NEWS002_CARD_FALLBACK_FILENAME), ''); + const filename = materializeNews002CardFallbackImage(tmp, { memindLibRoot: tmp }); assert.equal(filename, NEWS002_CARD_FALLBACK_FILENAME); assert.equal(fs.existsSync(path.join(tmp, filename)), true); assert.match(fs.readFileSync(path.join(tmp, filename), 'utf8'), /svg/i); @@ -319,3 +379,15 @@ test('materializeNews002CardFallbackImage copies the shared default thumbnail on fs.rmSync(tmp, { recursive: true, force: true }); } }); + +test('all-homepage sources and zero images warn without blocking publication', () => { + const html = '
' + + ['https://www.xinhuanet.com/', 'https://www.people.com.cn/index.html', 'https://www.thepaper.cn/'] + .map((url, i) => `

新闻${i}

`).join('') + + '
'; + const audit = auditNews002ImageCoverage(html); + assert.equal(audit.ok, true); + assert.deepEqual(audit.violations, []); + assert.deepEqual(audit.qualityWarnings, ['no-story-images', 'no-nonhomepage-source-links']); + assert.equal(audit.nonHomepageSourceCount, 0); +});