| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423 |
- #!/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', 'tikhub', 'justone', '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
- };
|