#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const OUTPUTS = path.join(ROOT, 'outputs'); function main() { const args = parseArgs(process.argv.slice(2)); const input = args.input || args.result || args.evidence || ''; const outputDir = path.resolve(args.output || process.env.TIHAO_HOMEPAGE_READINESS_OUTPUT || path.join(OUTPUTS, `homepage-evidence-readiness-${Date.now()}`)); const records = input ? loadRecords(path.resolve(input)) : loadDefaultRecords(); const summary = buildHomepageEvidenceReadiness({ inputPath: input ? path.resolve(input) : '', records }); fs.mkdirSync(outputDir, { recursive: true }); const jsonPath = path.join(outputDir, 'homepage-evidence-readiness-summary.json'); const reportPath = path.join(outputDir, 'homepage-evidence-readiness-report.md'); const repairCsvPath = path.join(outputDir, 'homepage-evidence-repair-actions.csv'); fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8'); fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8'); fs.writeFileSync(repairCsvPath, withBom(renderRepairCsv(summary.repairActions)), 'utf8'); console.log(JSON.stringify({ outputDir, json: jsonPath, report: reportPath, repairCsv: repairCsvPath, ready: summary.ready, passed: summary.passed, proofLevel: summary.proofLevel, candidateCount: summary.counts.candidates, providerEvidenceCount: summary.counts.providerEvidenceCreators, failureCount: summary.failureCount, repairActionCount: summary.repairActions.length }, null, 2)); if (args.strict && !summary.ready) process.exitCode = 2; } function buildHomepageEvidenceReadiness({ inputPath, records }) { const candidates = records.map((record, index) => normalizeCandidate(record, index + 1)); const providerEvidenceCreators = candidates.filter(item => item.hasProviderEvidence); const creatorsWithPosts = candidates.filter(item => item.recentPostCount > 0); const creatorsWithEnoughRecentPosts = candidates.filter(item => item.recentPostCount >= 10); const creatorsWithTitleOrText = candidates.filter(item => item.postTitleOrTextCount > 0 || item.reviewNoteCount > 0); const creatorsWithCover = candidates.filter(item => item.coverCount > 0 || item.visualQualityScore > 0); const creatorsWithPublishTime = candidates.filter(item => item.publishTimeCount > 0); const creatorsWithInteractions = candidates.filter(item => item.interactionCount > 0); const creatorsWithRiskReview = candidates.filter(item => item.riskSignalCount > 0 || item.reviewNoteCount > 0); const acceptance = { hasCandidates: candidates.length > 0, hasProviderOrPathEvidence: providerEvidenceCreators.length > 0, hasRecentPosts: creatorsWithPosts.length > 0, recentWindowUsable: providerEvidenceCreators.length > 0 && providerEvidenceCreators.every(item => item.recentContentWindow >= 10 || item.recentPostCount >= 10), hasTitleOrText: creatorsWithTitleOrText.length > 0, hasCoverEvidence: creatorsWithCover.length > 0, hasPublishTime: creatorsWithPublishTime.length > 0, hasInteractionEvidence: creatorsWithInteractions.length > 0, preservesRiskReview: creatorsWithRiskReview.length > 0, noSecrets: !containsSecret(JSON.stringify(records)) }; const ready = Object.values(acceptance).every(Boolean); const issues = buildIssues({ acceptance, candidates, providerEvidenceCreators }); const repairActions = buildRepairActions({ issues, inputPath }); return { generatedAt: new Date().toISOString(), inputPath, ready, passed: true, complete: ready, directCustomerProof: false, proofLevel: ready ? 'smoke_or_local' : 'not_business_proof', counts: { candidates: candidates.length, providerEvidenceCreators: providerEvidenceCreators.length, creatorsWithPosts: creatorsWithPosts.length, creatorsWithEnoughRecentPosts: creatorsWithEnoughRecentPosts.length, creatorsWithTitleOrText: creatorsWithTitleOrText.length, creatorsWithCover: creatorsWithCover.length, creatorsWithPublishTime: creatorsWithPublishTime.length, creatorsWithInteractions: creatorsWithInteractions.length, creatorsWithRiskReview: creatorsWithRiskReview.length }, acceptance, failureCount: issues.length, issues, repairActions, sample: candidates.slice(0, 10).map(item => ({ rowNumber: item.rowNumber, platform: item.platform, displayName: item.displayName, profileUrl: item.profileUrl, evidenceSource: item.evidenceSource, recentPostCount: item.recentPostCount, recentContentWindow: item.recentContentWindow, coverCount: item.coverCount, publishTimeCount: item.publishTimeCount, interactionCount: item.interactionCount, riskSignalCount: item.riskSignalCount })) }; } function loadDefaultRecords() { const candidates = [ path.join(OUTPUTS, 'software-client-latest', 'software-client-list.final.json'), path.join(OUTPUTS, 'software-client-latest', 'tihao-sourcing-result.json'), path.join(OUTPUTS, 'claude-code-tihao-sample', 'tihao-sourcing-result.json') ]; for (const file of candidates) { if (fs.existsSync(file)) return loadRecords(file); } return []; } function loadRecords(file) { if (!fs.existsSync(file)) return []; if (/\.csv$/i.test(file)) return readCsv(file).body; const json = readJson(file); if (!json) return []; return normalizeJsonRecords(json); } function normalizeJsonRecords(json) { if (Array.isArray(json)) return json; const pools = [ json.candidates, json.creators, json.records, json.homepageEvidence, json.evidence, json.data?.candidates, json.data?.creators, json.data?.records, json.data?.homepageEvidence, json.result?.candidates, json.summary?.candidates ]; return pools.find(Array.isArray) || []; } function normalizeCandidate(record, rowNumber) { const homepageEvidence = record.homepageEvidence || record.homepage || record.evidence || {}; const posts = normalizePosts(record.recentPosts || record.posts || homepageEvidence.recentPosts || homepageEvidence.posts || []); const reviewNotes = normalizeList(homepageEvidence.reviewNotes || record.recentEvidence || record.reviewNotes); const riskHits = normalizeList(homepageEvidence.riskHits || homepageEvidence.visualRiskSignals || record.homepageQualityRisks || record.riskSignals); const evidenceSource = value(homepageEvidence.source || record.homepageEvidenceSource || record.source); const recentContentWindow = number(homepageEvidence.recentContentWindow || homepageEvidence.recentPostCount || record.recentContentWindow || posts.length); const visualQualityScore = number(homepageEvidence.visualQualityScore || record.visualQualityScore); const coverCount = posts.filter(hasCover).length + number(homepageEvidence.coverCount || record.coverCount); const postTitleOrTextCount = posts.filter(post => value(post.title || post.desc || post.description || post.text || post.content || post.summary).length > 0).length; const publishTimeCount = posts.filter(post => value(post.publishTime || post.publishedAt || post.createdAt || post.time).length > 0).length; const interactionCount = posts.filter(hasInteraction).length; const hasProviderEvidence = ['provider', 'path', 'api', 'social-analysis', 'voc-e-commerce', 'voc-social'].includes(evidenceSource.toLowerCase()) || Boolean(record.recentPosts || record.posts || homepageEvidence.recentPosts || homepageEvidence.posts) || homepageEvidence.confidence === 'provider'; return { rowNumber, platform: value(record.platform || record['平台']), displayName: value(record.displayName || record.creatorName || record.name || record['博主名称']), profileUrl: value(record.profileUrl || record.homepageUrl || record.url || record['主页链接']), evidenceSource: evidenceSource || (hasProviderEvidence ? 'record' : 'fallback'), hasProviderEvidence, recentPostCount: posts.length, recentContentWindow, postTitleOrTextCount, coverCount, publishTimeCount, interactionCount, riskSignalCount: riskHits.length, reviewNoteCount: reviewNotes.length, visualQualityScore }; } function normalizePosts(posts) { if (!Array.isArray(posts)) return []; return posts.filter(item => item && typeof item === 'object'); } function buildIssues({ acceptance, candidates, providerEvidenceCreators }) { const issues = []; if (!acceptance.hasCandidates) issues.push(issue('missing-candidates', '没有可审计的候选博主记录。')); if (!acceptance.hasProviderOrPathEvidence) issues.push(issue('missing-provider-evidence', '没有 creator 具备 provider/path 级主页近期内容证据。')); if (!acceptance.hasRecentPosts) issues.push(issue('missing-recent-posts', '没有近期内容列表,不能判断最近 10/20 篇内容。')); if (!acceptance.recentWindowUsable) issues.push(issue('insufficient-recent-window', 'provider/path 证据的近期内容窗口不足 10 篇。')); if (!acceptance.hasTitleOrText) issues.push(issue('missing-title-or-text', '近期内容缺少标题、正文或摘要。')); if (!acceptance.hasCoverEvidence) issues.push(issue('missing-cover-evidence', '缺少封面或视觉质感证据。')); if (!acceptance.hasPublishTime) issues.push(issue('missing-publish-time', '近期内容缺少发布时间。')); if (!acceptance.hasInteractionEvidence) issues.push(issue('missing-interactions', '近期内容缺少互动字段。')); if (!acceptance.preservesRiskReview) issues.push(issue('missing-risk-review', '缺少风险信号或复核备注。')); if (!acceptance.noSecrets) issues.push(issue('secret-like-value', '主页证据输入中包含疑似 token 或鉴权字段。')); for (const candidate of providerEvidenceCreators) { if (candidate.recentPostCount > 0 && candidate.recentPostCount < 10) { issues.push(issue('creator-window-too-small', `${candidate.displayName || candidate.profileUrl || `row ${candidate.rowNumber}`} 的近期内容少于 10 篇。`, candidate.rowNumber)); } if (candidate.recentPostCount > 0 && candidate.publishTimeCount === 0) { issues.push(issue('creator-missing-publish-time', `${candidate.displayName || candidate.profileUrl || `row ${candidate.rowNumber}`} 的近期内容没有发布时间。`, candidate.rowNumber)); } } return issues; } function buildRepairActions({ issues, inputPath }) { return issues.map((item, index) => { const spec = repairSpec(item.type); return { priority: index + 1, owner: spec.owner, type: item.type, rowNumber: item.rowNumber || '', file: inputPath || 'outputs/software-client-latest/software-client-list.final.csv', field: spec.field, action: spec.action, acceptance: spec.acceptance }; }); } function repairSpec(type) { const map = { 'missing-candidates': ['技术/AI', '候选名单', '传入 tihao-sourcing-result.json 或包含候选博主的 CSV/JSON。', '候选记录数大于 0。'], 'missing-provider-evidence': ['技术/AI', 'homepageEvidence.source', '接入真实主页近期内容 provider 或提供 path/local JSON 证据。', '至少 1 个 creator 的 evidenceSource 为 provider/path/api。'], 'missing-recent-posts': ['商务/投放', 'recentPosts', '补最近 10/20 篇内容列表。', 'recentPosts 条数大于 0,强推荐候选优先达到 10 篇以上。'], 'insufficient-recent-window': ['商务/投放', 'recentPosts', '补足最近 10 篇以上主页内容。', 'provider/path 证据的 recentPostCount 或 recentContentWindow >= 10。'], 'missing-title-or-text': ['商务/投放', 'title/text/summary', '补每篇内容的标题、正文或摘要。', '至少 1 篇近期内容有标题、正文或摘要。'], 'missing-cover-evidence': ['商务/投放', 'coverUrl/visualQualityScore', '补封面链接或视觉质感判断。', '至少 1 篇近期内容有封面,或主页证据保留 visualQualityScore。'], 'missing-publish-time': ['商务/投放', 'publishTime', '补近期内容发布时间。', '至少 1 篇近期内容有发布时间;provider 声明 recent_posts 时应尽量全量保留。'], 'missing-interactions': ['商务/投放', 'likes/comments/shares', '补点赞、评论、收藏、分享等互动字段。', '至少 1 篇近期内容有互动字段。'], 'missing-risk-review': ['技术/AI', 'riskHits/reviewNotes', '保留主页下沉、封面混乱、调性不符等风险信号。', 'summary 中 preservesRiskReview=true。'], 'secret-like-value': ['技术/AI', 'secrets', '移除 token、Authorization、sessionToken 等敏感字段。', 'noSecrets=true。'], 'creator-window-too-small': ['商务/投放', 'recentPosts', '补足该博主最近 10 篇主页内容。', '该 creator 的 recentPostCount >= 10。'], 'creator-missing-publish-time': ['商务/投放', 'publishTime', '补该博主近期内容发布时间。', '该 creator 的 publishTimeCount > 0。'] }; const value = map[type] || ['技术/AI', 'homepageEvidence', '补齐主页证据字段。', '对应 gate 通过。']; return { owner: value[0], field: value[1], action: value[2], acceptance: value[3] }; } function renderReport(summary) { const lines = [ '# 主页近期内容证据就绪审计', '', `- 生成时间:${summary.generatedAt}`, `- 是否 ready:${summary.ready ? '是' : '否'}`, `- proofLevel:${summary.proofLevel}`, `- directCustomerProof:${summary.directCustomerProof ? 'true' : 'false'}`, `- failureCount:${summary.failureCount}`, '', '## 统计', '', `- 候选博主数:${summary.counts.candidates}`, `- provider/path 证据博主数:${summary.counts.providerEvidenceCreators}`, `- 有近期内容博主数:${summary.counts.creatorsWithPosts}`, `- 最近内容窗口 >= 10 的博主数:${summary.counts.creatorsWithEnoughRecentPosts}`, `- 有标题/正文/摘要证据博主数:${summary.counts.creatorsWithTitleOrText}`, `- 有封面/视觉证据博主数:${summary.counts.creatorsWithCover}`, `- 有发布时间博主数:${summary.counts.creatorsWithPublishTime}`, `- 有互动字段博主数:${summary.counts.creatorsWithInteractions}`, `- 有风险复核博主数:${summary.counts.creatorsWithRiskReview}`, '', '## 门禁', '', '| 门禁 | 状态 |', '| --- | --- |', ...Object.entries(summary.acceptance).map(([key, pass]) => `| ${key} | ${pass ? 'pass' : 'fail'} |`), '', '## 问题', '', summary.issues.length ? '| 类型 | 行号 | 说明 |\n| --- | --- | --- |\n' + summary.issues.map(item => `| ${item.type} | ${item.rowNumber || ''} | ${escapeCell(item.message)} |`).join('\n') : '- 暂无问题。', '', '## 修复清单', '', summary.repairActions.length ? '| 优先级 | 负责人 | 字段 | 修复动作 | 通过标准 |\n| ---: | --- | --- | --- | --- |\n' + summary.repairActions.map(item => `| ${item.priority} | ${item.owner} | ${escapeCell(item.field)} | ${escapeCell(item.action)} | ${escapeCell(item.acceptance)} |`).join('\n') : '- 暂无修复动作。', '', '## 边界', '', '- 本审计只判断主页近期内容证据是否足够支撑强推荐复核,不证明客户命中率提升。', '- provider/path 证据不足时,fallback 只能作为轻量判断,不能单独支撑强推荐。', '- sample、smoke、接口 200 或 provider fallback 不能当成真实业务效果证明。', '- 输出不得包含 sessionToken、Authorization、模型 token 或 npm token。' ]; return lines.join('\n'); } function renderRepairCsv(actions) { const header = ['优先级', '负责人', '类型', '行号', '文件', '字段', '修复动作', '通过标准']; return [ header.join(','), ...actions.map(item => [ item.priority, item.owner, item.type, item.rowNumber, item.file, item.field, item.action, item.acceptance ].map(csvCell).join(',')) ].join('\n'); } function readJson(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } catch { return null; } } function readCsv(file) { const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''); const lines = text.split(/\r?\n/).filter(Boolean).map(parseCsvLine); const header = lines[0] || []; const body = lines.slice(1).map(values => Object.fromEntries(header.map((key, index) => [key, values[index] || '']))); return { header, body }; } function parseCsvLine(line) { const cells = []; let current = ''; let quoted = false; for (let index = 0; index < line.length; index += 1) { const char = line[index]; if (char === '"' && line[index + 1] === '"') { current += '"'; index += 1; } else if (char === '"') { quoted = !quoted; } else if (char === ',' && !quoted) { cells.push(current); current = ''; } else { current += char; } } cells.push(current); return cells; } function normalizeList(value) { if (Array.isArray(value)) return value.filter(Boolean).map(String); if (!value) return []; return String(value).split(/[|,,、\n]/).map(item => item.trim()).filter(Boolean); } function hasCover(post) { return value(post.coverUrl || post.cover || post.imageUrl || post.thumbnail || post.noteCover).length > 0; } function hasInteraction(post) { return ['likeCount', 'likes', 'commentCount', 'comments', 'collectCount', 'favorites', 'shareCount', 'shares', 'viewCount', 'views'].some(key => number(post[key]) > 0 || value(post[key]).length > 0); } function containsSecret(text) { return /(sessionToken|Authorization|Bearer\s+|sk-[A-Za-z0-9_-]{12,}|npm_[A-Za-z0-9_-]{12,}|r:[A-Za-z0-9]{20,})/i.test(String(text || '')); } function issue(type, message, rowNumber = null) { return { type, message, rowNumber }; } function value(input) { return String(input ?? '').trim(); } function number(input) { const parsed = Number(input); return Number.isFinite(parsed) ? parsed : 0; } function escapeCell(input) { return value(input).replace(/\|/g, '/').replace(/\n/g, ' '); } function csvCell(input) { const text = value(input); return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; } function withBom(text) { return `\uFEFF${text}`; } function parseArgs(argv) { const result = {}; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (!arg.startsWith('--')) continue; const key = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase()); const next = argv[index + 1]; if (!next || next.startsWith('--')) result[key] = true; else { result[key] = next; index += 1; } } return result; } if (require.main === module) { try { main(); } catch (error) { console.error(error && error.stack ? error.stack : String(error)); process.exit(1); } } module.exports = { buildHomepageEvidenceReadiness, normalizeCandidate };