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:
@@ -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);
|
||||
});
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user