fix(news): repair instead of block in daily news generation and push
Memind CI / Test, build, and release guards (push) Successful in 4m43s
Memind CI / Test, build, and release guards (push) Successful in 4m43s
- news002 image/structure audit no longer throws: prune empty sections and publish a best-effort page with a warning - WeChat push strips images that failed to upload instead of refusing the draft - forceGenerateOnce is cleared before running, so a failed run cannot loop; the archived page is restored when generation fails - degraded-page regeneration runs at most once per day - content safety rules cover vulgar sexual terms and gossip/adult sites Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -39,7 +39,7 @@ DB 值优先于环境变量。非法值一律回落到 `news001`。
|
||||
- 每张卡片必须带 `data-event-key="{事件指纹}"`,指纹 = 核心主体 + 核心动作,
|
||||
中英文报道共用同一指纹,全页不得重复。
|
||||
|
||||
### 2. 运行时去重闸门
|
||||
### 2. 运行时去重
|
||||
|
||||
prompt 层的「禁止重复」无法验证,所以在落盘后再做一次程序化清理:
|
||||
[`wechat-news-morning-dedup.mjs`](../wechat-news-morning-dedup.mjs),由 worker 在生成成功后调用。
|
||||
@@ -102,9 +102,15 @@ news002 的卡片图会先上传到微信正文素材接口,再由 inline 渲
|
||||
静态页与微信草稿均使用 **左上角 72~92px 小图**(`.card` / `.lead-card` 左侧缩略图),不再用全宽大图。
|
||||
能拿到真实配图的卡片必须显示并上传;没有真实图就只保留文字卡,不放默认小图。
|
||||
远程配图会先下载到当日 MindSpace `public/`,改写成本站相对 URL,避免海外图源在国内打不开。
|
||||
推送前不再删除无图故事卡(微信草稿保留纯文字卡);有图卡必须成功上传微信 CDN。
|
||||
生成闸门会拦截:配图上传失败、头条不是 3~4 条、事件卡超过 30 条,或页面仍混有 news001 DOM,
|
||||
才会由生成/推送闸门阻断,不允许进入草稿箱。
|
||||
推送前不再删除无图故事卡(微信草稿保留纯文字卡);有图卡未能上传微信 CDN 时只去掉该图,卡片保留文字。
|
||||
|
||||
配图 / 结构 / 写满检查**只引导修复、不阻断生成和推送**:
|
||||
检查不过时依次做 SearXNG 补位、补图、头条规整;仍不满足就删掉完全空的栏目,
|
||||
以现有内容出稿并照常推草稿,同时记 `news002 audit incomplete, publishing best-effort page` 告警。
|
||||
|
||||
强制重生成(`forceGenerateOnce`)在执行前就清掉标记,只跑一次、失败不重试;
|
||||
生成失败时把归档的原稿放回原位,当日页面不会因重生成失败而 404。
|
||||
降级页自动重生成每天最多一次。
|
||||
|
||||
真实图片来源按优先级取 SearXNG 结果里的 `img_src`/`thumbnail`,以及文章来源的
|
||||
`og:image:secure_url`、`og:image`、`twitter:image`;不使用与事件无法核验关系的泛图片搜索结果。
|
||||
|
||||
@@ -48,6 +48,7 @@ function rx(source, flags = 'iu') {
|
||||
const TERM_RULES = Object.freeze([
|
||||
// 成人低俗
|
||||
{ id: 'adult-explicit', category: 'adult', level: 'block', re: rx('色情(?:网站|视频|图片|直播|片)|黄色(?:网站|视频|小说)|黄网|黄片|成人(?:网站|视频|直播|电影)|AV女优|裸聊|约炮|援交|一夜情|性爱视频|艳照|露点(?:照|视频|写真)|福利姬|情色|擦边(?:视频|直播|写真|女主播)|porn|onlyfans|nsfw|xvideos') },
|
||||
{ id: 'adult-vulgar', category: 'adult', level: 'block', re: rx('鸡巴|几把|肉棒|屌大|操逼|草逼|骚逼|口交|肛交|乳交|群交|乱伦|啪啪啪|自慰视频|无码(?:视频|片|高清)|熟女|海角(?:网|社区)|黑料(?:网|社|不打烊)|(?:51|91)(?:吃瓜|黑料)|吃瓜(?:网|黑料)') },
|
||||
{ id: 'adult-sexual-crime', category: 'adult', level: 'review', re: rx('性侵|强奸|猥亵|性骚扰|卖淫|嫖娼|淫秽|裸照|不雅视频|偷拍') },
|
||||
|
||||
// 自杀自残
|
||||
@@ -131,7 +132,7 @@ const SELF_MEDIA_DOMAINS = Object.freeze([
|
||||
'sohu.com/a/',
|
||||
]);
|
||||
|
||||
const UNSAFE_DOMAIN_RE = /(?:porn|xxx|sex|casino|bet365|gamble|18av|hentai|jav)/i;
|
||||
const UNSAFE_DOMAIN_RE = /(?:porn|xxx|sex|casino|bet365|gamble|18av|hentai|jav|haijiao|heiliao|51cg|chigua)/i;
|
||||
const POLITICAL_RE = rx(POLITICAL_TERMS);
|
||||
const POLITICAL_SECTION_IDS = new Set(['policy', 'geopolitics', 'politics']);
|
||||
const POLITICAL_TAG_RE = /时政|政策|政务|外交/u;
|
||||
|
||||
@@ -17,6 +17,12 @@ test('adult explicit content is blocked, sexual-crime news goes to review', () =
|
||||
assert.equal(levelOf('91每日大赛-今日吃瓜爆料资讯平台,热门黑料绯闻每日更新'), 'block');
|
||||
assert.equal(levelOf('男子强奸案一审宣判,被判有期徒刑十年'), 'review');
|
||||
assert.equal(levelOf('央行宣布下调存款准备金率 0.5 个百分点'), 'clean');
|
||||
assert.equal(levelOf('AI短剧王鹤棣的3厘米鸡巴第1集 打玻尿酸坏死只剩3cm... | 海角网'), 'block');
|
||||
assert.equal(levelOf('熟女 - 热门标签,每日更新最新内容'), 'block');
|
||||
assert.equal(levelOf('51吃瓜 今日最新'), 'block');
|
||||
assert.equal(assessNewsSourceUrl('https://www.haijiao.com/post/123').level, 'block');
|
||||
assert.equal(assessNewsSourceUrl('https://heiliao.com/archives/1').level, 'block');
|
||||
assert.equal(levelOf('海角七号重映,票房破亿'), 'clean');
|
||||
});
|
||||
|
||||
test('common news phrases are not false positives', () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
enrichNews002HtmlWithSourceImages,
|
||||
localizeNews002CardImages,
|
||||
promoteNews002LeadCards,
|
||||
pruneEmptyNews002Sections,
|
||||
} from './wechat-news-morning-images.mjs';
|
||||
import {
|
||||
isWechatNewsMorningDraftWorkerEnabled,
|
||||
@@ -119,9 +120,7 @@ async function applyNews002ImageGate(
|
||||
{ searchGroups = [], safetyExemptSectionIds = [] } = {},
|
||||
) {
|
||||
if (config?.templateId !== 'news002') return null;
|
||||
if (!page?.localPath || !fs.existsSync(page.localPath)) {
|
||||
throw new Error('news002 配图闸门无法读取生成页面');
|
||||
}
|
||||
if (!page?.localPath || !fs.existsSync(page.localPath)) return null;
|
||||
let source = fs.readFileSync(page.localPath, 'utf8');
|
||||
const normalizedBefore = promoteNews002LeadCards(source);
|
||||
if (normalizedBefore.promotedCount > 0 || normalizedBefore.demotedCount > 0) {
|
||||
@@ -269,6 +268,20 @@ async function applyNews002ImageGate(
|
||||
}
|
||||
}
|
||||
|
||||
if (!audit.ok) {
|
||||
const pruned = pruneEmptyNews002Sections(localized.html, { requireImage: false });
|
||||
if (pruned.removedSections > 0) {
|
||||
localized = { ...localized, html: pruned.html };
|
||||
fs.writeFileSync(page.localPath, localized.html);
|
||||
audit = auditNews002ImageCoverage(localized.html, {
|
||||
requireTemplateStructure: true,
|
||||
requireSectionFill: true,
|
||||
exemptSectionIds: safetyExemptSectionIds,
|
||||
env,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.log?.('[NewsMorningDraft] news002 image audit', {
|
||||
slug: page.slug,
|
||||
attempted: enriched.attemptedCount,
|
||||
@@ -278,12 +291,15 @@ async function applyNews002ImageGate(
|
||||
...audit,
|
||||
});
|
||||
if (!audit.ok) {
|
||||
throw new Error(
|
||||
`news002 配图/结构/写满闸门未通过:${audit.violations.join(', ')}`
|
||||
+ `(卡片 ${audit.cardCount},真实配图 ${audit.imageCount},头条配图 ${audit.leadImageCount}/${audit.leadCount})`,
|
||||
);
|
||||
logger.warn?.('[NewsMorningDraft] news002 audit incomplete, publishing best-effort page', {
|
||||
slug: page.slug,
|
||||
violations: audit.violations,
|
||||
cardCount: audit.cardCount,
|
||||
imageCount: audit.imageCount,
|
||||
leadImages: `${audit.leadImageCount}/${audit.leadCount}`,
|
||||
});
|
||||
}
|
||||
return audit;
|
||||
return { ...audit, degraded: !audit.ok };
|
||||
}
|
||||
|
||||
function archiveTodayPageForForce(config, h5Root, now, logger) {
|
||||
@@ -292,15 +308,47 @@ function archiveTodayPageForForce(config, h5Root, now, logger) {
|
||||
const stamp = new Date(now).toISOString().replace(/[:.]/g, '-');
|
||||
const archived = path.join(path.dirname(page.localPath), `_archived-${page.slug}-${stamp}.html`);
|
||||
fs.renameSync(page.localPath, archived);
|
||||
let archivedThumb = null;
|
||||
if (page.thumbPath && fs.existsSync(page.thumbPath)) {
|
||||
const archivedThumb = archived.replace(/\.html$/i, '.thumbnail.png');
|
||||
archivedThumb = archived.replace(/\.html$/i, '.thumbnail.png');
|
||||
fs.renameSync(page.thumbPath, archivedThumb);
|
||||
}
|
||||
logger.log?.('[NewsMorningDraft] archived today page before force generate', {
|
||||
from: page.relativePath,
|
||||
to: path.basename(archived),
|
||||
});
|
||||
return archived;
|
||||
return {
|
||||
originalPath: page.localPath,
|
||||
archivedPath: archived,
|
||||
originalThumbPath: archivedThumb ? page.thumbPath : null,
|
||||
archivedThumbPath: archivedThumb,
|
||||
};
|
||||
}
|
||||
|
||||
/** 强制重生成失败时把原稿放回原位;新稿已落盘则保留新稿。 */
|
||||
function restoreArchivedTodayPage(archived, logger) {
|
||||
if (!archived?.archivedPath || !fs.existsSync(archived.archivedPath)) return false;
|
||||
if (fs.existsSync(archived.originalPath)) return false;
|
||||
try {
|
||||
fs.renameSync(archived.archivedPath, archived.originalPath);
|
||||
if (
|
||||
archived.archivedThumbPath
|
||||
&& fs.existsSync(archived.archivedThumbPath)
|
||||
&& !fs.existsSync(archived.originalThumbPath)
|
||||
) {
|
||||
fs.renameSync(archived.archivedThumbPath, archived.originalThumbPath);
|
||||
}
|
||||
logger.log?.('[NewsMorningDraft] restored archived today page after failed generate', {
|
||||
to: path.basename(archived.originalPath),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.warn?.(
|
||||
'[NewsMorningDraft] restore archived today page failed:',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startWechatNewsMorningDraftWorker({
|
||||
@@ -338,13 +386,15 @@ export function startWechatNewsMorningDraftWorker({
|
||||
let stopped = false;
|
||||
let running = false;
|
||||
let generationInFlightDateKey = null;
|
||||
let degradedRegenerateDateKey = null;
|
||||
|
||||
const ensureTodayPage = async (config, dateKey, { now = Date.now(), force = false } = {}) => {
|
||||
if (generationInFlightDateKey === dateKey) return null;
|
||||
generationInFlightDateKey = dateKey;
|
||||
let archived = null;
|
||||
try {
|
||||
if (force) {
|
||||
archiveTodayPageForForce(config, h5Root, now, logger);
|
||||
archived = archiveTodayPageForForce(config, h5Root, now, logger);
|
||||
}
|
||||
let searchBrief = '';
|
||||
let searchGroups = [];
|
||||
@@ -387,7 +437,14 @@ export function startWechatNewsMorningDraftWorker({
|
||||
stage: 'post-generate',
|
||||
});
|
||||
const safetyExemptSectionIds = safety?.exemptSectionIds ?? [];
|
||||
await applyNews002ImageGate(page, config, logger, env, { searchGroups, safetyExemptSectionIds });
|
||||
try {
|
||||
await applyNews002ImageGate(page, config, logger, env, { searchGroups, safetyExemptSectionIds });
|
||||
} catch (imageError) {
|
||||
logger.warn?.(
|
||||
'[NewsMorningDraft] news002 image repair failed, keeping generated page:',
|
||||
imageError instanceof Error ? imageError.message : imageError,
|
||||
);
|
||||
}
|
||||
await applyNewsMorningContentSafety(page, {
|
||||
reviewer: contentReviewer,
|
||||
reviewCache,
|
||||
@@ -411,6 +468,7 @@ export function startWechatNewsMorningDraftWorker({
|
||||
errorMessage: message,
|
||||
}).catch(() => {});
|
||||
logger.warn?.('[NewsMorningDraft] auto-generate failed:', message);
|
||||
restoreArchivedTodayPage(archived, logger);
|
||||
throw error;
|
||||
} finally {
|
||||
if (generationInFlightDateKey === dateKey) {
|
||||
@@ -462,6 +520,7 @@ export function startWechatNewsMorningDraftWorker({
|
||||
todayPageDegraded
|
||||
&& !config.forceGenerateOnce
|
||||
&& generationInFlightDateKey !== dateKey
|
||||
&& degradedRegenerateDateKey !== dateKey
|
||||
&& (
|
||||
isWithinNewsMorningGenerateLeadWindow(config, now)
|
||||
|| isLocalScheduleDue(
|
||||
@@ -476,6 +535,7 @@ export function startWechatNewsMorningDraftWorker({
|
||||
userId: config.sourceUserId,
|
||||
dateKey,
|
||||
});
|
||||
degradedRegenerateDateKey = dateKey;
|
||||
try {
|
||||
todayPage = await ensureTodayPage(config, dateKey, { now, force: true });
|
||||
if (
|
||||
@@ -507,11 +567,19 @@ export function startWechatNewsMorningDraftWorker({
|
||||
|
||||
if (config.forceGenerateOnce && generationInFlightDateKey !== dateKey) {
|
||||
try {
|
||||
todayPage = await ensureTodayPage(config, dateKey, { now, force: true });
|
||||
await wechatNewsMorningDraftService.updateConfig(
|
||||
{ forceGenerateOnce: false },
|
||||
{ updatedBy: 'auto-generate' },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn?.(
|
||||
'[NewsMorningDraft] clear forceGenerateOnce failed, skip force generate:',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
todayPage = await ensureTodayPage(config, dateKey, { now, force: true });
|
||||
if (config.autoPushEnabled && todayPage) {
|
||||
const result = await wechatNewsMorningDraftService.pushDraft({
|
||||
triggeredBy: 'manual-generate',
|
||||
@@ -522,8 +590,11 @@ export function startWechatNewsMorningDraftWorker({
|
||||
pageSlug: result.preview?.page?.slug ?? todayPage.slug,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// keep the flag so the next scan retries
|
||||
} catch (error) {
|
||||
logger.warn?.(
|
||||
'[NewsMorningDraft] force generate failed:',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -244,6 +244,140 @@ test('runOnce honors forceGenerateOnce even when today page exists', async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('failed forceGenerateOnce clears the flag, restores the original page and does not retry', async () => {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-force-fail-'));
|
||||
const userId = 'user-news';
|
||||
const publicDir = path.join(tmpRoot, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
const pagePath = path.join(publicDir, 'daily-news-0911.html');
|
||||
fs.writeFileSync(pagePath, HEALTHY_TODAY_HTML);
|
||||
|
||||
const service = createWechatNewsMorningDraftService(createPool(), {
|
||||
mpConfig: { enabled: true, appId: 'app', appSecret: 'secret' },
|
||||
h5Root: tmpRoot,
|
||||
env: {
|
||||
H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED: '1',
|
||||
H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID: userId,
|
||||
},
|
||||
});
|
||||
let config = {
|
||||
enabled: true,
|
||||
sourceUserId: userId,
|
||||
autoPushEnabled: true,
|
||||
autoGenerateEnabled: true,
|
||||
forceGenerateOnce: true,
|
||||
timezone: 'Asia/Shanghai',
|
||||
pushHour: 6,
|
||||
pushMinute: 0,
|
||||
pageSlugPattern: 'daily-news-*',
|
||||
};
|
||||
service.getConfig = async () => config;
|
||||
service.updateConfig = async (payload) => {
|
||||
config = { ...config, ...payload };
|
||||
return config;
|
||||
};
|
||||
const pushCalls = [];
|
||||
service.pushDraft = async (options = {}) => {
|
||||
pushCalls.push(options);
|
||||
return { ok: true, draftMediaId: 'draft-x' };
|
||||
};
|
||||
service.hasSuccessfulAutoPushToday = async () => true;
|
||||
|
||||
let executeCount = 0;
|
||||
const fixedNow = Date.UTC(2026, 8, 10, 22, 30, 0);
|
||||
const originalNow = Date.now;
|
||||
Date.now = () => fixedNow;
|
||||
try {
|
||||
const worker = startWechatNewsMorningDraftWorker({
|
||||
wechatNewsMorningDraftService: service,
|
||||
mpConfig: { enabled: true },
|
||||
userAuth: { canUseChat: async () => ({ ok: true }) },
|
||||
tkmindProxy: { id: 'proxy' },
|
||||
h5Root: tmpRoot,
|
||||
env: { H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED: '1' },
|
||||
executeTask: async () => {
|
||||
executeCount += 1;
|
||||
throw new Error('agent timeout');
|
||||
},
|
||||
prefetchNewsSearch: async () => ({ brief: '', groups: [] }),
|
||||
logger: { log() {}, warn() {} },
|
||||
intervalMs: 30_000,
|
||||
runOnStart: false,
|
||||
setIntervalFn: () => ({ unref() {} }),
|
||||
});
|
||||
|
||||
await worker.runOnce();
|
||||
await worker.runOnce();
|
||||
assert.equal(executeCount, 1);
|
||||
assert.equal(config.forceGenerateOnce, false);
|
||||
assert.equal(pushCalls.length, 0);
|
||||
assert.equal(fs.readFileSync(pagePath, 'utf8'), HEALTHY_TODAY_HTML);
|
||||
assert.equal(
|
||||
fs.readdirSync(publicDir).some((name) => name.startsWith('_archived-daily-news-0911-')),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('news002 page that misses structure checks is still published with empty sections pruned', async () => {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-news002-degraded-'));
|
||||
const userId = 'user-news';
|
||||
const publicDir = path.join(tmpRoot, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
|
||||
const service = createWechatNewsMorningDraftService(createPool(), {
|
||||
mpConfig: { enabled: true, appId: 'app', appSecret: 'secret' },
|
||||
h5Root: tmpRoot,
|
||||
env: {
|
||||
H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED: '1',
|
||||
H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID: userId,
|
||||
H5_WECHAT_NEWS_MORNING_TEMPLATE_ID: 'news002',
|
||||
},
|
||||
});
|
||||
const warnings = [];
|
||||
const html = '<!DOCTYPE html><html><body><div class="masthead"><h1>每日新闻早报</h1></div>'
|
||||
+ '<div class="section" id="domestic"><h2>国内</h2>'
|
||||
+ '<div class="card" data-event-key="c919"><h3>国产大飞机交付第100架</h3>'
|
||||
+ '<div class="body"><p>商飞公布最新交付数据。</p></div>'
|
||||
+ '<a href="https://www.news.cn/a/20260911/c919.html">来源</a></div></div>'
|
||||
+ '<div class="section" id="voices"><h2>声音</h2></div>'
|
||||
+ '</body></html>';
|
||||
const worker = startWechatNewsMorningDraftWorker({
|
||||
wechatNewsMorningDraftService: service,
|
||||
mpConfig: { enabled: true },
|
||||
userAuth: { canUseChat: async () => ({ ok: true }) },
|
||||
tkmindProxy: { id: 'proxy' },
|
||||
h5Root: tmpRoot,
|
||||
env: {
|
||||
H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED: '1',
|
||||
MEMIND_NEWS_MORNING_SEARXNG_FALLBACK: '0',
|
||||
},
|
||||
executeTask: async () => {
|
||||
fs.writeFileSync(path.join(publicDir, 'daily-news-0911.html'), html);
|
||||
},
|
||||
prefetchNewsSearch: async () => ({ brief: '', groups: [] }),
|
||||
contentReviewer: async () => ({ ok: true, violations: [] }),
|
||||
logger: { log() {}, warn: (...args) => warnings.push(args) },
|
||||
intervalMs: 30_000,
|
||||
runOnStart: false,
|
||||
setIntervalFn: () => ({ unref() {} }),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await worker.generateToday({ now: Date.UTC(2026, 8, 10, 22, 0, 0), force: true });
|
||||
assert.equal(result.skipped, false);
|
||||
const saved = fs.readFileSync(path.join(publicDir, 'daily-news-0911.html'), 'utf8');
|
||||
assert.match(saved, /国产大飞机交付第100架/u);
|
||||
assert.doesNotMatch(saved, /id="voices"/);
|
||||
assert.ok(warnings.some(([message]) => /publishing best-effort page/.test(String(message))));
|
||||
} finally {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('generateToday skips existing page unless force is set', async () => {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-force-'));
|
||||
const userId = 'user-news';
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
import {
|
||||
auditNews002ImageCoverage,
|
||||
extractNews002CardImageUrls,
|
||||
isWeakNewsMorningImageUrl,
|
||||
} from './wechat-news-morning-images.mjs';
|
||||
import {
|
||||
NEWS001_FRESH_CONTENT_SPEC,
|
||||
@@ -854,38 +853,51 @@ export async function buildDailyNewsWechatDraftArticleForPush({
|
||||
wechatFetch,
|
||||
memindLibRoot,
|
||||
});
|
||||
if (/class="[^"]*\bmasthead\b/i.test(String(html ?? ''))) {
|
||||
const audit = auditNews002ImageCoverage(html, {
|
||||
let draftHtml = String(html ?? '');
|
||||
const pushWarnings = [];
|
||||
if (/class="[^"]*\bmasthead\b/i.test(draftHtml)) {
|
||||
const audit = auditNews002ImageCoverage(draftHtml, {
|
||||
requireTemplateStructure: true,
|
||||
requireAllCoreSections: false,
|
||||
requireLeadCount: false,
|
||||
});
|
||||
const pushBlocking = audit.violations.filter((item) => (
|
||||
pushWarnings.push(...audit.violations.filter((item) => (
|
||||
item === 'story-image-missing'
|
||||
|| item === 'lead-image-missing'
|
||||
|| item === 'too-many-story-cards'
|
||||
|| item === 'legacy-news001-dom'
|
||||
));
|
||||
if (pushBlocking.length > 0) {
|
||||
throw new Error(`news002 页面未通过推送闸门:${pushBlocking.join(', ')}`);
|
||||
}
|
||||
const missingUploads = extractNews002CardImageUrls(html).filter((ref) => (
|
||||
!isWeakNewsMorningImageUrl(ref)
|
||||
)).filter((ref) => (
|
||||
)));
|
||||
const missingUploads = extractNews002CardImageUrls(draftHtml).filter((ref) => (
|
||||
!imageUrlMap.has(ref)
|
||||
&& !imageUrlMap.has(ref.replace(/^\.\//, ''))
|
||||
&& !imageUrlMap.has(ref.replace(/^\//, ''))
|
||||
));
|
||||
if (missingUploads.length > 0) {
|
||||
throw new Error(`news002 有 ${missingUploads.length} 张正文配图未成功上传微信,已阻止草稿推送`);
|
||||
draftHtml = stripNews002ImagesByRef(draftHtml, missingUploads);
|
||||
pushWarnings.push(`image-upload-missing:${missingUploads.length}`);
|
||||
}
|
||||
}
|
||||
return buildDailyNewsWechatDraftArticle({
|
||||
html,
|
||||
publicUrl,
|
||||
qrcodeImageUrl,
|
||||
imageUrlMap,
|
||||
});
|
||||
return {
|
||||
...buildDailyNewsWechatDraftArticle({
|
||||
html: draftHtml,
|
||||
publicUrl,
|
||||
qrcodeImageUrl,
|
||||
imageUrlMap,
|
||||
}),
|
||||
pushWarnings,
|
||||
};
|
||||
}
|
||||
|
||||
/** 微信正文只认已上传的 mmbiz 图片:未上传成功的配图整块去掉,卡片保留文字。 */
|
||||
export function stripNews002ImagesByRef(html, refs = []) {
|
||||
const missing = new Set(refs);
|
||||
if (missing.size === 0) return String(html ?? '');
|
||||
return String(html ?? '')
|
||||
.replace(/<div\b[^>]*class="[^"]*\bmedia\b[^"]*"[^>]*>[\s\S]*?<\/div>/gi, (block) => {
|
||||
const src = block.match(/<img\b[^>]*\bsrc=["']([^"']+)["']/i)?.[1];
|
||||
return src && missing.has(src) ? '' : block;
|
||||
})
|
||||
.replace(/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi, (tag, src) => (missing.has(src) ? '' : tag));
|
||||
}
|
||||
|
||||
export function convertNewsPageHtmlToWechatArticle(html, { publicUrl = '', qrcodeImageUrl = '' } = {}) {
|
||||
@@ -1588,6 +1600,12 @@ export function createWechatNewsMorningDraftService(
|
||||
publishDir: path.dirname(page.localPath),
|
||||
})
|
||||
: preview.article;
|
||||
if (article.pushWarnings?.length) {
|
||||
logger.warn?.('[NewsMorningDraft] draft pushed with repairs', {
|
||||
pageSlug: page.slug,
|
||||
warnings: article.pushWarnings,
|
||||
});
|
||||
}
|
||||
if (articleTitleOverride) {
|
||||
article.title = String(articleTitleOverride).trim().slice(0, 64);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,21 @@ import {
|
||||
createWechatNewsMorningDraftService,
|
||||
findLatestNewsMorningPage,
|
||||
NEWS_MORNING_TEMPLATE_VERSION,
|
||||
stripNews002ImagesByRef,
|
||||
wechatNewsMorningDraftInternals,
|
||||
} from './wechat-news-morning-draft.mjs';
|
||||
import { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR } from './user-publish.mjs';
|
||||
|
||||
test('stripNews002ImagesByRef drops only images that failed to upload and keeps card text', () => {
|
||||
const html = '<div class="card"><div class="media"><img src="./a.jpg"></div><h3>甲</h3></div>'
|
||||
+ '<div class="card"><div class="media"><img src="./b.jpg"></div><h3>乙</h3></div>';
|
||||
const out = stripNews002ImagesByRef(html, ['./a.jpg']);
|
||||
assert.doesNotMatch(out, /a\.jpg/);
|
||||
assert.match(out, /b\.jpg/);
|
||||
assert.match(out, /<h3>甲<\/h3>/u);
|
||||
assert.equal(stripNews002ImagesByRef(html, []), html);
|
||||
});
|
||||
|
||||
const SAMPLE_HTML = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
|
||||
@@ -420,7 +420,7 @@ export function promoteNews002LeadCards(html, { min = 3, max = 4 } = {}) {
|
||||
|
||||
const NEWS002_KEEP_EMPTY_SECTION_IDS = new Set(['weather', 'history', 'brief']);
|
||||
|
||||
function pruneEmptyNews002Sections(html) {
|
||||
export function pruneEmptyNews002Sections(html, { requireImage = true } = {}) {
|
||||
let output = String(html ?? '');
|
||||
const re = /<div\b[^>]*class=["'][^"']*\bsection\b[^"']*["'][^>]*id=["']([^"']+)["'][^>]*>/gi;
|
||||
const matches = [...output.matchAll(re)];
|
||||
@@ -435,7 +435,8 @@ function pruneEmptyNews002Sections(html) {
|
||||
match.index + match[0].length,
|
||||
);
|
||||
const slice = output.slice(start, end);
|
||||
const cards = extractNews002StoryCards(slice).filter((card) => cardHasDisplayableImage(card.raw));
|
||||
const cards = extractNews002StoryCards(slice)
|
||||
.filter((card) => (requireImage ? cardHasDisplayableImage(card.raw) : Boolean(card.title)));
|
||||
const hasDeep = /<div\b[^>]*class=["'][^"']*\bdeep\b/i.test(slice);
|
||||
if (cards.length === 0 && !hasDeep) {
|
||||
output = output.slice(0, start) + output.slice(end);
|
||||
|
||||
Reference in New Issue
Block a user