feat(news): ship news002 fill gate and full WeChat draft push

Lock scheduled morning news to news002 with section-fill gates, headline lead-card normalization, SearXNG top-up to max, and push that keeps imageless cards as text while requiring uploaded images where present.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-22 14:05:40 +08:00
parent a96a9d0e14
commit 4a26db5e84
18 changed files with 1384 additions and 123 deletions
@@ -0,0 +1,305 @@
#!/usr/bin/env node
/**
* 一次性生成今日新闻早报页面并推送到微信公众号草稿箱。
*
* 用法:
* node scripts/run-news-morning-generate-push-now.mjs
* node scripts/run-news-morning-generate-push-now.mjs --generate-only
* node scripts/run-news-morning-generate-push-now.mjs --push-only
*/
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { createDbPool, isDatabaseConfigured } from '../db.mjs';
import { loadWechatMpConfig } from '../wechat-mp-config.mjs';
import {
resolveMindSpaceServerRuntimeOptions,
resolvePortalH5Root,
} from '../mindspace-runtime-config.mjs';
import { resolveMindSpaceAnalyticsConfig } from '../mindspace-analytics.mjs';
import { createHealthDataRuntime } from '../health-data-runtime.mjs';
import { createHealthChannelSessionStore } from '../health-channel-session-store.mjs';
import { startWorkspaceAssetSyncWatcher } from '../mindspace-workspace-sync.mjs';
import { startWorkspaceThumbnailWatcher } from '../mindspace-workspace-thumbnails.mjs';
import { bootstrapPortalDomainServices } from '../server/portal-domain-services-bootstrap.mjs';
import { bootstrapPortalAuthServices } from '../server/portal-auth-services-bootstrap.mjs';
import { bootstrapPortalAgentServices } from '../server/portal-agent-services-bootstrap.mjs';
import { bootstrapPortalMemorySessionServices } from '../server/portal-memory-session-services-bootstrap.mjs';
import { bootstrapPortalGatewayServices } from '../server/portal-gateway-services-bootstrap.mjs';
import { bootstrapPortalIntegrationServices } from '../server/portal-integration-services-bootstrap.mjs';
import { startWechatNewsMorningDraftWorker } from '../wechat-news-morning-draft-worker.mjs';
import { resolveGooseApiTargetsFromEnv } from './goose-v149-canary.mjs';
import { loadH5Environment } from './load-env.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const codeRoot = path.join(__dirname, '..');
function parseApiTargets(env) {
const gooseCanary = resolveGooseApiTargetsFromEnv(env);
if (gooseCanary?.targets?.length) return [...gooseCanary.targets];
const csvTargets = String(env.TKMIND_API_TARGETS ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const legacyTargets = [
env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
env.TKMIND_API_TARGET_1,
].filter(Boolean);
return [...new Set([...csvTargets, ...legacyTargets])];
}
async function bootstrapNewsMorningWorker(env) {
if (!isDatabaseConfigured()) {
throw new Error('DATABASE_URL 未配置');
}
const h5Root = resolvePortalH5Root(codeRoot, env);
const usersRoot = env.H5_USERS_ROOT?.trim() || path.join(h5Root, 'users');
const runtime = resolveMindSpaceServerRuntimeOptions(h5Root, env);
const apiTargets = parseApiTargets(env);
const apiTarget = apiTargets[0] ?? 'https://127.0.0.1:18006';
const apiSecret = env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
const wechatMpConfig = loadWechatMpConfig(env);
const mindSpaceAnalyticsConfig = resolveMindSpaceAnalyticsConfig();
const healthDataRuntime = createHealthDataRuntime({ env, logger: console });
const healthChannelStore = createHealthChannelSessionStore();
const pool = createDbPool();
let userAuth = null;
let sessionAccess = null;
let sessionSnapshotService = null;
const domainServices = await bootstrapPortalDomainServices({
pool,
h5Root,
env,
runtime,
analyticsConfig: mindSpaceAnalyticsConfig,
getUserAuth: () => userAuth,
getSessionSnapshotService: () => sessionSnapshotService,
workspaceMaintenanceEnabled: env.MEMIND_WORKSPACE_MAINTENANCE !== '0',
logger: console,
});
Object.assign(mindSpaceAnalyticsConfig, domainServices.mindSpaceAnalyticsConfig);
let llmProviderService = null;
let tkmindProxy = null;
let agentRunGateway = null;
const authServices = await bootstrapPortalAuthServices({
pool,
h5Root,
usersRoot,
mindSearchConfigService: domainServices.mindSearchConfigService,
env,
logger: console,
});
userAuth = authServices.userAuth;
sessionAccess = authServices.sessionAccess;
const agentServices = await bootstrapPortalAgentServices({
pool,
env,
runtime,
apiTarget,
apiTargets,
apiSecret,
userAuth,
sessionAccess,
subscriptionService: authServices.subscriptionService,
mindSpaceRuntimeAdapter: domainServices.mindSpaceRuntimeAdapter,
mindSpaceAssets: domainServices.mindSpaceAssets,
resolveUserIdByDirKey: domainServices.resolveUserIdByDirKey,
workspaceMaintenanceEnabled: env.MEMIND_WORKSPACE_MAINTENANCE !== '0',
startWorkspaceThumbnailWatcher,
startWorkspaceAssetSyncWatcher,
logger: console,
});
llmProviderService = agentServices.llmProviderService;
const memorySessionServices = await bootstrapPortalMemorySessionServices({
pool,
h5Root,
env,
llmProviderService,
userAuth,
sessionAccess,
logger: console,
});
sessionSnapshotService = memorySessionServices.sessionSnapshotService;
async function syncUserGeneratedPages(userId, options = {}) {
if (!domainServices.mindSpacePageSync) return;
return domainServices.mindSpacePageSync.syncUserGeneratedPages(userId, options);
}
const gatewayServices = bootstrapPortalGatewayServices({
pool,
h5Root,
env,
apiTarget,
apiTargets,
apiSecret,
userAuth,
sessionAccess,
sessionStreamStore: memorySessionServices.sessionStreamStore,
llmProviderService,
subscriptionService: authServices.subscriptionService,
billingConfigService: authServices.billingConfigService,
sessionSnapshotService,
conversationMemoryService: memorySessionServices.conversationMemoryService,
memoryV2: memorySessionServices.memoryV2,
systemDisclosurePolicyService: memorySessionServices.systemDisclosurePolicyService,
wechatCursorExecutorPolicyService: memorySessionServices.wechatCursorExecutorPolicyService,
mindSpaceAssets: domainServices.mindSpaceAssets,
directChatService: memorySessionServices.directChatService,
chatIntentRouter: memorySessionServices.chatIntentRouter,
syncUserGeneratedPages,
isSessionPageDeliveryActive: () => false,
experienceService: agentServices.experienceService,
});
tkmindProxy = gatewayServices.tkmindProxy;
agentRunGateway = gatewayServices.agentRunGateway;
const integrationServices = await bootstrapPortalIntegrationServices({
pool,
h5Root,
codeRoot,
env,
usersRoot,
wechatMpConfig,
mindSpacePublicFinish: domainServices.mindSpacePublicFinish,
userAuth,
sessionAccess,
tkmindProxy,
scheduleService: domainServices.scheduleService,
scheduledTaskService: domainServices.scheduledTaskService,
intentDraftService: domainServices.intentDraftService,
taskUnifiedService: domainServices.taskUnifiedService,
sessionSnapshotService,
wechatScheduleLlmConfigService: memorySessionServices.wechatScheduleLlmConfigService,
wechatSubscribeMorningLlmConfigService: memorySessionServices.wechatSubscribeMorningLlmConfigService,
wechatScheduledTaskManageLlmConfigService: memorySessionServices.wechatScheduledTaskManageLlmConfigService,
wechatIntentRouter: memorySessionServices.wechatIntentRouter,
wechatCursorExecutorPolicyService: memorySessionServices.wechatCursorExecutorPolicyService,
agentRunGateway,
llmProviderService,
chatIntentRouter: memorySessionServices.chatIntentRouter,
systemDisclosurePolicyService: memorySessionServices.systemDisclosurePolicyService,
mindSpaceAnalyticsConfig,
subscriptionService: authServices.subscriptionService,
apiTarget,
apiSecret,
mindSpacePages: domainServices.mindSpacePages,
mindSpacePageLiveEdit: domainServices.mindSpacePageLiveEdit,
syncUserGeneratedPages,
mindSpaceImageGeneration: agentServices.mindSpaceImageGeneration,
healthChannelStore,
healthObservationStore: healthDataRuntime.observationStore,
healthObservationService: healthDataRuntime.observationService,
healthDocumentStore: healthDataRuntime.documentStore,
healthDataRuntime,
healthEventStore: healthDataRuntime.eventStore,
logger: console,
setIntervalFn: () => ({ unref() {} }),
startWechatNewsMorningDraftWorkerFn: (options) =>
startWechatNewsMorningDraftWorker({ ...options, runOnStart: false }),
});
return {
pool,
worker: integrationServices.wechatNewsMorningDraftWorker,
draftService: integrationServices.wechatNewsMorningDraftService,
wechatMpConfig,
};
}
async function main() {
const generateOnly = process.argv.includes('--generate-only');
const pushOnly = process.argv.includes('--push-only');
loadH5Environment(__dirname);
process.env.H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED = '1';
process.env.MEMIND_WORKSPACE_MAINTENANCE = '0';
const newsEngineUrl = process.env.MEMIND_NEWS_ENGINE_URL?.trim();
if (newsEngineUrl && !pushOnly) {
try {
const response = await fetch(`${newsEngineUrl.replace(/\/$/, '')}/v1/collect`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}',
});
const payload = await response.json().catch(() => ({}));
console.log('[news-engine] collect', response.status, payload.status ?? payload.message ?? 'ok');
} catch (error) {
console.warn('[news-engine] collect skipped:', error instanceof Error ? error.message : error);
}
}
console.log('[bootstrap] starting portal integration services…');
const { pool, worker, draftService, wechatMpConfig } = await bootstrapNewsMorningWorker(process.env);
if (!wechatMpConfig.enabled) {
throw new Error('微信服务号未启用,无法推送草稿');
}
if (!worker?.generateToday) {
throw new Error('新闻早报 worker 未就绪(检查 H5_WECHAT_MP_* 与 tkmind 配置)');
}
const config = await draftService.getConfig();
console.log('[config]', JSON.stringify({
sourceUserId: config.sourceUserId,
templateId: config.templateId,
enabled: config.enabled,
}, null, 2));
let result = null;
if (!pushOnly) {
console.log('[generate] 开始生成今日早报页面(Agent 任务,约 10–20 分钟)…');
result = await worker.generateToday({
force: true,
pushDraft: !generateOnly,
});
console.log('[generate] done', JSON.stringify({
slug: result.page?.slug ?? null,
skipped: result.skipped ?? false,
pushed: Boolean(result.draft),
}, null, 2));
}
if (generateOnly) {
await pool.end();
return;
}
if (pushOnly || !result?.draft) {
console.log('[push] 推送今日草稿…');
const pushResult = await draftService.pushDraft({
triggeredBy: 'manual-generate-push-now',
requireToday: true,
});
console.log('[push] success', JSON.stringify({
draftMediaId: pushResult.draftMediaId,
pageSlug: pushResult.preview?.page?.slug ?? null,
pageUrl: pushResult.preview?.page?.publicUrl ?? null,
coverSource: pushResult.cover?.source ?? null,
runId: pushResult.run?.id ?? null,
}, null, 2));
} else {
console.log('[push] success', JSON.stringify({
draftMediaId: result.draft.draftMediaId,
pageSlug: result.draft.preview?.page?.slug ?? result.page?.slug ?? null,
pageUrl: result.draft.preview?.page?.publicUrl ?? null,
coverSource: result.draft.cover?.source ?? null,
runId: result.draft.run?.id ?? null,
}, null, 2));
}
await pool.end();
}
main().catch((error) => {
console.error('\n[failed]', error instanceof Error ? error.message : error);
if (error?.run) {
console.error(JSON.stringify(error.run, null, 2));
}
process.exit(1);
});
+4 -10
View File
@@ -10,21 +10,15 @@
import process from 'node:process';
import mysql from 'mysql2/promise';
import { loadH5Environment } from './load-env.mjs';
import { NEWS002_DEFAULT_TASK_SPEC_APPEND } from '../news-morning-templates.mjs';
loadH5Environment(import.meta.dirname);
const CONFIG_KEY = 'news_morning_draft';
const CONFIG_TABLE = 'h5_wechat_admin_config';
/** Agent 易回退到旧 daily-news 骨架,必须显式禁止 news001 DOM。 */
export const NEWS002_FORCE_TASK_APPEND = [
'【强制 news002 版式 · 覆盖一切旧稿参考】',
'1. 禁止输出 news001 结构:不得出现 class="hero"、class="date-badge"、class="quick-links"、class="highlight-box"、背景 #f7fafc / 主色 #b0191e。',
'2. 必须使用 news002 结构:div.masthead + div.lede + a.lead-card + ol.brief + div.card[data-event-key],并内嵌 taskSpec 里的 NEWS002_STYLE_BLOCK(暖纸 #FAF7F2、主色 #C8362F)。',
'3. 写页前不要 read_file 任何旧 daily-news-*.html 学版式;旧稿正文禁止复用。只允许按本 taskSpec 生成。',
'4. 全页每张故事卡必须带 data-event-key;独立事件 ≤28;flex 栏目无独立事件则整节删除。',
'5. 导航仅保留 news002 8 个主锚点(速读/头条/深读/国内/国际/财经/科技/天气/知识),禁止 25 项 quick-links。',
].join('\n');
/** @deprecated 使用 NEWS002_DEFAULT_TASK_SPEC_APPEND */
export const NEWS002_FORCE_TASK_APPEND = NEWS002_DEFAULT_TASK_SPEC_APPEND;
async function main() {
const apply = process.argv.includes('--apply');
@@ -51,7 +45,7 @@ async function main() {
enabled: current.enabled !== false,
templateId: restoreTemplate ? 'news001' : 'news002',
forceGenerateOnce: restoreTemplate ? false : true,
taskSpecAppend: restoreTemplate ? (current.taskSpecAppend ?? '') : NEWS002_FORCE_TASK_APPEND,
taskSpecAppend: restoreTemplate ? (current.taskSpecAppend ?? '') : NEWS002_DEFAULT_TASK_SPEC_APPEND,
};
console.log(JSON.stringify({
@@ -0,0 +1,60 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import {
formatRankedNewsCandidates,
resolveNewsEngineEndpoint,
} from '../news-engine-client.mjs';
import { prefetchNewsMorningSearxngBundle } from '../wechat-news-morning-search.mjs';
const env = process.env;
const newsEngineUrl = resolveNewsEngineEndpoint(env);
const searxngUrl = String(env.TKMIND_SEARCH_SEARXNG_URL ?? '').trim();
if (!newsEngineUrl) {
console.error('[verify-news-engine] 缺少 MEMIND_NEWS_ENGINE_URL');
process.exit(1);
}
if (!searxngUrl) {
console.error('[verify-news-engine] 缺少 TKMIND_SEARCH_SEARXNG_URL');
process.exit(1);
}
console.log('[verify-news-engine] news-engine:', newsEngineUrl);
console.log('[verify-news-engine] searxng:', searxngUrl);
const health = await fetch(`${newsEngineUrl}/health`).catch(() => null);
assert.ok(health?.ok, 'news-engine /health 不可用');
const healthBody = await health.json();
assert.equal(healthBody.service, 'memind-news-engine');
const started = Date.now();
const bundle = await prefetchNewsMorningSearxngBundle({
env,
limit: 3,
});
const elapsedMs = Date.now() - started;
assert.ok(Array.isArray(bundle.groups), 'bundle.groups 必须是数组');
assert.ok(bundle.groups.some((group) => group.results?.length > 0), '至少一个栏目应有预取结果');
assert.ok(bundle.ranking, 'ranking 不应为空');
assert.ok(Number(bundle.ranking.eventCount) >= 1, 'eventCount 应 >= 1');
assert.ok(Array.isArray(bundle.ranking.selected), 'selected 必须是数组');
assert.ok(bundle.ranking.selected.length > 0, 'news-engine 应返回至少 1 个选中事件');
const rankedBlock = formatRankedNewsCandidates(bundle.ranking);
assert.match(bundle.brief, /News Engine 编辑候选/u);
assert.match(rankedBlock, /must_include=/u);
console.log('[verify-news-engine] ok', {
elapsedMs,
candidateCount: bundle.ranking.candidateCount,
eventCount: bundle.ranking.eventCount,
selectedCount: bundle.ranking.selected.length,
dedupeRemovedCount: bundle.dedupeRemovedCount,
dateLabel: bundle.dateLabel?.iso,
});
for (const event of bundle.ranking.selected.slice(0, 5)) {
console.log(
` #${event.rank} [${event.category}] score=${event.finalScore} sources=${event.sourceCount} must=${event.mustInclude} ${event.canonicalTitle}`,
);
}