homepage-evidence-readiness-audit.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const ROOT = path.resolve(__dirname, '..');
  5. const OUTPUTS = path.join(ROOT, 'outputs');
  6. function main() {
  7. const args = parseArgs(process.argv.slice(2));
  8. const input = args.input || args.result || args.evidence || '';
  9. const outputDir = path.resolve(args.output || process.env.TIHAO_HOMEPAGE_READINESS_OUTPUT || path.join(OUTPUTS, `homepage-evidence-readiness-${Date.now()}`));
  10. const records = input ? loadRecords(path.resolve(input)) : loadDefaultRecords();
  11. const summary = buildHomepageEvidenceReadiness({
  12. inputPath: input ? path.resolve(input) : '',
  13. records
  14. });
  15. fs.mkdirSync(outputDir, { recursive: true });
  16. const jsonPath = path.join(outputDir, 'homepage-evidence-readiness-summary.json');
  17. const reportPath = path.join(outputDir, 'homepage-evidence-readiness-report.md');
  18. const repairCsvPath = path.join(outputDir, 'homepage-evidence-repair-actions.csv');
  19. fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8');
  20. fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
  21. fs.writeFileSync(repairCsvPath, withBom(renderRepairCsv(summary.repairActions)), 'utf8');
  22. console.log(JSON.stringify({
  23. outputDir,
  24. json: jsonPath,
  25. report: reportPath,
  26. repairCsv: repairCsvPath,
  27. ready: summary.ready,
  28. passed: summary.passed,
  29. proofLevel: summary.proofLevel,
  30. candidateCount: summary.counts.candidates,
  31. providerEvidenceCount: summary.counts.providerEvidenceCreators,
  32. failureCount: summary.failureCount,
  33. repairActionCount: summary.repairActions.length
  34. }, null, 2));
  35. if (args.strict && !summary.ready) process.exitCode = 2;
  36. }
  37. function buildHomepageEvidenceReadiness({ inputPath, records }) {
  38. const candidates = records.map((record, index) => normalizeCandidate(record, index + 1));
  39. const providerEvidenceCreators = candidates.filter(item => item.hasProviderEvidence);
  40. const creatorsWithPosts = candidates.filter(item => item.recentPostCount > 0);
  41. const creatorsWithEnoughRecentPosts = candidates.filter(item => item.recentPostCount >= 10);
  42. const creatorsWithTitleOrText = candidates.filter(item => item.postTitleOrTextCount > 0 || item.reviewNoteCount > 0);
  43. const creatorsWithCover = candidates.filter(item => item.coverCount > 0 || item.visualQualityScore > 0);
  44. const creatorsWithPublishTime = candidates.filter(item => item.publishTimeCount > 0);
  45. const creatorsWithInteractions = candidates.filter(item => item.interactionCount > 0);
  46. const creatorsWithRiskReview = candidates.filter(item => item.riskSignalCount > 0 || item.reviewNoteCount > 0);
  47. const acceptance = {
  48. hasCandidates: candidates.length > 0,
  49. hasProviderOrPathEvidence: providerEvidenceCreators.length > 0,
  50. hasRecentPosts: creatorsWithPosts.length > 0,
  51. recentWindowUsable: providerEvidenceCreators.length > 0 && providerEvidenceCreators.every(item => item.recentContentWindow >= 10 || item.recentPostCount >= 10),
  52. hasTitleOrText: creatorsWithTitleOrText.length > 0,
  53. hasCoverEvidence: creatorsWithCover.length > 0,
  54. hasPublishTime: creatorsWithPublishTime.length > 0,
  55. hasInteractionEvidence: creatorsWithInteractions.length > 0,
  56. preservesRiskReview: creatorsWithRiskReview.length > 0,
  57. noSecrets: !containsSecret(JSON.stringify(records))
  58. };
  59. const ready = Object.values(acceptance).every(Boolean);
  60. const issues = buildIssues({ acceptance, candidates, providerEvidenceCreators });
  61. const repairActions = buildRepairActions({ issues, inputPath });
  62. return {
  63. generatedAt: new Date().toISOString(),
  64. inputPath,
  65. ready,
  66. passed: true,
  67. complete: ready,
  68. directCustomerProof: false,
  69. proofLevel: ready ? 'smoke_or_local' : 'not_business_proof',
  70. counts: {
  71. candidates: candidates.length,
  72. providerEvidenceCreators: providerEvidenceCreators.length,
  73. creatorsWithPosts: creatorsWithPosts.length,
  74. creatorsWithEnoughRecentPosts: creatorsWithEnoughRecentPosts.length,
  75. creatorsWithTitleOrText: creatorsWithTitleOrText.length,
  76. creatorsWithCover: creatorsWithCover.length,
  77. creatorsWithPublishTime: creatorsWithPublishTime.length,
  78. creatorsWithInteractions: creatorsWithInteractions.length,
  79. creatorsWithRiskReview: creatorsWithRiskReview.length
  80. },
  81. acceptance,
  82. failureCount: issues.length,
  83. issues,
  84. repairActions,
  85. sample: candidates.slice(0, 10).map(item => ({
  86. rowNumber: item.rowNumber,
  87. platform: item.platform,
  88. displayName: item.displayName,
  89. profileUrl: item.profileUrl,
  90. evidenceSource: item.evidenceSource,
  91. recentPostCount: item.recentPostCount,
  92. recentContentWindow: item.recentContentWindow,
  93. coverCount: item.coverCount,
  94. publishTimeCount: item.publishTimeCount,
  95. interactionCount: item.interactionCount,
  96. riskSignalCount: item.riskSignalCount
  97. }))
  98. };
  99. }
  100. function loadDefaultRecords() {
  101. const candidates = [
  102. path.join(OUTPUTS, 'software-client-latest', 'software-client-list.final.json'),
  103. path.join(OUTPUTS, 'software-client-latest', 'tihao-sourcing-result.json'),
  104. path.join(OUTPUTS, 'claude-code-tihao-sample', 'tihao-sourcing-result.json')
  105. ];
  106. for (const file of candidates) {
  107. if (fs.existsSync(file)) return loadRecords(file);
  108. }
  109. return [];
  110. }
  111. function loadRecords(file) {
  112. if (!fs.existsSync(file)) return [];
  113. if (/\.csv$/i.test(file)) return readCsv(file).body;
  114. const json = readJson(file);
  115. if (!json) return [];
  116. return normalizeJsonRecords(json);
  117. }
  118. function normalizeJsonRecords(json) {
  119. if (Array.isArray(json)) return json;
  120. const pools = [
  121. json.candidates,
  122. json.creators,
  123. json.records,
  124. json.homepageEvidence,
  125. json.evidence,
  126. json.data?.candidates,
  127. json.data?.creators,
  128. json.data?.records,
  129. json.data?.homepageEvidence,
  130. json.result?.candidates,
  131. json.summary?.candidates
  132. ];
  133. return pools.find(Array.isArray) || [];
  134. }
  135. function normalizeCandidate(record, rowNumber) {
  136. const homepageEvidence = record.homepageEvidence || record.homepage || record.evidence || {};
  137. const posts = normalizePosts(record.recentPosts || record.posts || homepageEvidence.recentPosts || homepageEvidence.posts || []);
  138. const reviewNotes = normalizeList(homepageEvidence.reviewNotes || record.recentEvidence || record.reviewNotes);
  139. const riskHits = normalizeList(homepageEvidence.riskHits || homepageEvidence.visualRiskSignals || record.homepageQualityRisks || record.riskSignals);
  140. const evidenceSource = value(homepageEvidence.source || record.homepageEvidenceSource || record.source);
  141. const recentContentWindow = number(homepageEvidence.recentContentWindow || homepageEvidence.recentPostCount || record.recentContentWindow || posts.length);
  142. const visualQualityScore = number(homepageEvidence.visualQualityScore || record.visualQualityScore);
  143. const coverCount = posts.filter(hasCover).length + number(homepageEvidence.coverCount || record.coverCount);
  144. const postTitleOrTextCount = posts.filter(post => value(post.title || post.desc || post.description || post.text || post.content || post.summary).length > 0).length;
  145. const publishTimeCount = posts.filter(post => value(post.publishTime || post.publishedAt || post.createdAt || post.time).length > 0).length;
  146. const interactionCount = posts.filter(hasInteraction).length;
  147. const hasProviderEvidence = ['provider', 'path', 'api', 'social-analysis', 'voc-e-commerce', 'voc-social'].includes(evidenceSource.toLowerCase()) ||
  148. Boolean(record.recentPosts || record.posts || homepageEvidence.recentPosts || homepageEvidence.posts) ||
  149. homepageEvidence.confidence === 'provider';
  150. return {
  151. rowNumber,
  152. platform: value(record.platform || record['平台']),
  153. displayName: value(record.displayName || record.creatorName || record.name || record['博主名称']),
  154. profileUrl: value(record.profileUrl || record.homepageUrl || record.url || record['主页链接']),
  155. evidenceSource: evidenceSource || (hasProviderEvidence ? 'record' : 'fallback'),
  156. hasProviderEvidence,
  157. recentPostCount: posts.length,
  158. recentContentWindow,
  159. postTitleOrTextCount,
  160. coverCount,
  161. publishTimeCount,
  162. interactionCount,
  163. riskSignalCount: riskHits.length,
  164. reviewNoteCount: reviewNotes.length,
  165. visualQualityScore
  166. };
  167. }
  168. function normalizePosts(posts) {
  169. if (!Array.isArray(posts)) return [];
  170. return posts.filter(item => item && typeof item === 'object');
  171. }
  172. function buildIssues({ acceptance, candidates, providerEvidenceCreators }) {
  173. const issues = [];
  174. if (!acceptance.hasCandidates) issues.push(issue('missing-candidates', '没有可审计的候选博主记录。'));
  175. if (!acceptance.hasProviderOrPathEvidence) issues.push(issue('missing-provider-evidence', '没有 creator 具备 provider/path 级主页近期内容证据。'));
  176. if (!acceptance.hasRecentPosts) issues.push(issue('missing-recent-posts', '没有近期内容列表,不能判断最近 10/20 篇内容。'));
  177. if (!acceptance.recentWindowUsable) issues.push(issue('insufficient-recent-window', 'provider/path 证据的近期内容窗口不足 10 篇。'));
  178. if (!acceptance.hasTitleOrText) issues.push(issue('missing-title-or-text', '近期内容缺少标题、正文或摘要。'));
  179. if (!acceptance.hasCoverEvidence) issues.push(issue('missing-cover-evidence', '缺少封面或视觉质感证据。'));
  180. if (!acceptance.hasPublishTime) issues.push(issue('missing-publish-time', '近期内容缺少发布时间。'));
  181. if (!acceptance.hasInteractionEvidence) issues.push(issue('missing-interactions', '近期内容缺少互动字段。'));
  182. if (!acceptance.preservesRiskReview) issues.push(issue('missing-risk-review', '缺少风险信号或复核备注。'));
  183. if (!acceptance.noSecrets) issues.push(issue('secret-like-value', '主页证据输入中包含疑似 token 或鉴权字段。'));
  184. for (const candidate of providerEvidenceCreators) {
  185. if (candidate.recentPostCount > 0 && candidate.recentPostCount < 10) {
  186. issues.push(issue('creator-window-too-small', `${candidate.displayName || candidate.profileUrl || `row ${candidate.rowNumber}`} 的近期内容少于 10 篇。`, candidate.rowNumber));
  187. }
  188. if (candidate.recentPostCount > 0 && candidate.publishTimeCount === 0) {
  189. issues.push(issue('creator-missing-publish-time', `${candidate.displayName || candidate.profileUrl || `row ${candidate.rowNumber}`} 的近期内容没有发布时间。`, candidate.rowNumber));
  190. }
  191. }
  192. return issues;
  193. }
  194. function buildRepairActions({ issues, inputPath }) {
  195. return issues.map((item, index) => {
  196. const spec = repairSpec(item.type);
  197. return {
  198. priority: index + 1,
  199. owner: spec.owner,
  200. type: item.type,
  201. rowNumber: item.rowNumber || '',
  202. file: inputPath || 'outputs/software-client-latest/software-client-list.final.csv',
  203. field: spec.field,
  204. action: spec.action,
  205. acceptance: spec.acceptance
  206. };
  207. });
  208. }
  209. function repairSpec(type) {
  210. const map = {
  211. 'missing-candidates': ['技术/AI', '候选名单', '传入 tihao-sourcing-result.json 或包含候选博主的 CSV/JSON。', '候选记录数大于 0。'],
  212. 'missing-provider-evidence': ['技术/AI', 'homepageEvidence.source', '接入真实主页近期内容 provider 或提供 path/local JSON 证据。', '至少 1 个 creator 的 evidenceSource 为 provider/path/api。'],
  213. 'missing-recent-posts': ['商务/投放', 'recentPosts', '补最近 10/20 篇内容列表。', 'recentPosts 条数大于 0,强推荐候选优先达到 10 篇以上。'],
  214. 'insufficient-recent-window': ['商务/投放', 'recentPosts', '补足最近 10 篇以上主页内容。', 'provider/path 证据的 recentPostCount 或 recentContentWindow >= 10。'],
  215. 'missing-title-or-text': ['商务/投放', 'title/text/summary', '补每篇内容的标题、正文或摘要。', '至少 1 篇近期内容有标题、正文或摘要。'],
  216. 'missing-cover-evidence': ['商务/投放', 'coverUrl/visualQualityScore', '补封面链接或视觉质感判断。', '至少 1 篇近期内容有封面,或主页证据保留 visualQualityScore。'],
  217. 'missing-publish-time': ['商务/投放', 'publishTime', '补近期内容发布时间。', '至少 1 篇近期内容有发布时间;provider 声明 recent_posts 时应尽量全量保留。'],
  218. 'missing-interactions': ['商务/投放', 'likes/comments/shares', '补点赞、评论、收藏、分享等互动字段。', '至少 1 篇近期内容有互动字段。'],
  219. 'missing-risk-review': ['技术/AI', 'riskHits/reviewNotes', '保留主页下沉、封面混乱、调性不符等风险信号。', 'summary 中 preservesRiskReview=true。'],
  220. 'secret-like-value': ['技术/AI', 'secrets', '移除 token、Authorization、sessionToken 等敏感字段。', 'noSecrets=true。'],
  221. 'creator-window-too-small': ['商务/投放', 'recentPosts', '补足该博主最近 10 篇主页内容。', '该 creator 的 recentPostCount >= 10。'],
  222. 'creator-missing-publish-time': ['商务/投放', 'publishTime', '补该博主近期内容发布时间。', '该 creator 的 publishTimeCount > 0。']
  223. };
  224. const value = map[type] || ['技术/AI', 'homepageEvidence', '补齐主页证据字段。', '对应 gate 通过。'];
  225. return { owner: value[0], field: value[1], action: value[2], acceptance: value[3] };
  226. }
  227. function renderReport(summary) {
  228. const lines = [
  229. '# 主页近期内容证据就绪审计',
  230. '',
  231. `- 生成时间:${summary.generatedAt}`,
  232. `- 是否 ready:${summary.ready ? '是' : '否'}`,
  233. `- proofLevel:${summary.proofLevel}`,
  234. `- directCustomerProof:${summary.directCustomerProof ? 'true' : 'false'}`,
  235. `- failureCount:${summary.failureCount}`,
  236. '',
  237. '## 统计',
  238. '',
  239. `- 候选博主数:${summary.counts.candidates}`,
  240. `- provider/path 证据博主数:${summary.counts.providerEvidenceCreators}`,
  241. `- 有近期内容博主数:${summary.counts.creatorsWithPosts}`,
  242. `- 最近内容窗口 >= 10 的博主数:${summary.counts.creatorsWithEnoughRecentPosts}`,
  243. `- 有标题/正文/摘要证据博主数:${summary.counts.creatorsWithTitleOrText}`,
  244. `- 有封面/视觉证据博主数:${summary.counts.creatorsWithCover}`,
  245. `- 有发布时间博主数:${summary.counts.creatorsWithPublishTime}`,
  246. `- 有互动字段博主数:${summary.counts.creatorsWithInteractions}`,
  247. `- 有风险复核博主数:${summary.counts.creatorsWithRiskReview}`,
  248. '',
  249. '## 门禁',
  250. '',
  251. '| 门禁 | 状态 |',
  252. '| --- | --- |',
  253. ...Object.entries(summary.acceptance).map(([key, pass]) => `| ${key} | ${pass ? 'pass' : 'fail'} |`),
  254. '',
  255. '## 问题',
  256. '',
  257. summary.issues.length
  258. ? '| 类型 | 行号 | 说明 |\n| --- | --- | --- |\n' + summary.issues.map(item => `| ${item.type} | ${item.rowNumber || ''} | ${escapeCell(item.message)} |`).join('\n')
  259. : '- 暂无问题。',
  260. '',
  261. '## 修复清单',
  262. '',
  263. summary.repairActions.length
  264. ? '| 优先级 | 负责人 | 字段 | 修复动作 | 通过标准 |\n| ---: | --- | --- | --- | --- |\n' + summary.repairActions.map(item => `| ${item.priority} | ${item.owner} | ${escapeCell(item.field)} | ${escapeCell(item.action)} | ${escapeCell(item.acceptance)} |`).join('\n')
  265. : '- 暂无修复动作。',
  266. '',
  267. '## 边界',
  268. '',
  269. '- 本审计只判断主页近期内容证据是否足够支撑强推荐复核,不证明客户命中率提升。',
  270. '- provider/path 证据不足时,fallback 只能作为轻量判断,不能单独支撑强推荐。',
  271. '- sample、smoke、接口 200 或 provider fallback 不能当成真实业务效果证明。',
  272. '- 输出不得包含 sessionToken、Authorization、模型 token 或 npm token。'
  273. ];
  274. return lines.join('\n');
  275. }
  276. function renderRepairCsv(actions) {
  277. const header = ['优先级', '负责人', '类型', '行号', '文件', '字段', '修复动作', '通过标准'];
  278. return [
  279. header.join(','),
  280. ...actions.map(item => [
  281. item.priority,
  282. item.owner,
  283. item.type,
  284. item.rowNumber,
  285. item.file,
  286. item.field,
  287. item.action,
  288. item.acceptance
  289. ].map(csvCell).join(','))
  290. ].join('\n');
  291. }
  292. function readJson(file) {
  293. try {
  294. return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
  295. } catch {
  296. return null;
  297. }
  298. }
  299. function readCsv(file) {
  300. const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
  301. const lines = text.split(/\r?\n/).filter(Boolean).map(parseCsvLine);
  302. const header = lines[0] || [];
  303. const body = lines.slice(1).map(values => Object.fromEntries(header.map((key, index) => [key, values[index] || ''])));
  304. return { header, body };
  305. }
  306. function parseCsvLine(line) {
  307. const cells = [];
  308. let current = '';
  309. let quoted = false;
  310. for (let index = 0; index < line.length; index += 1) {
  311. const char = line[index];
  312. if (char === '"' && line[index + 1] === '"') {
  313. current += '"';
  314. index += 1;
  315. } else if (char === '"') {
  316. quoted = !quoted;
  317. } else if (char === ',' && !quoted) {
  318. cells.push(current);
  319. current = '';
  320. } else {
  321. current += char;
  322. }
  323. }
  324. cells.push(current);
  325. return cells;
  326. }
  327. function normalizeList(value) {
  328. if (Array.isArray(value)) return value.filter(Boolean).map(String);
  329. if (!value) return [];
  330. return String(value).split(/[|,,、\n]/).map(item => item.trim()).filter(Boolean);
  331. }
  332. function hasCover(post) {
  333. return value(post.coverUrl || post.cover || post.imageUrl || post.thumbnail || post.noteCover).length > 0;
  334. }
  335. function hasInteraction(post) {
  336. return ['likeCount', 'likes', 'commentCount', 'comments', 'collectCount', 'favorites', 'shareCount', 'shares', 'viewCount', 'views'].some(key => number(post[key]) > 0 || value(post[key]).length > 0);
  337. }
  338. function containsSecret(text) {
  339. 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 || ''));
  340. }
  341. function issue(type, message, rowNumber = null) {
  342. return { type, message, rowNumber };
  343. }
  344. function value(input) {
  345. return String(input ?? '').trim();
  346. }
  347. function number(input) {
  348. const parsed = Number(input);
  349. return Number.isFinite(parsed) ? parsed : 0;
  350. }
  351. function escapeCell(input) {
  352. return value(input).replace(/\|/g, '/').replace(/\n/g, ' ');
  353. }
  354. function csvCell(input) {
  355. const text = value(input);
  356. return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
  357. }
  358. function withBom(text) {
  359. return `\uFEFF${text}`;
  360. }
  361. function parseArgs(argv) {
  362. const result = {};
  363. for (let index = 0; index < argv.length; index += 1) {
  364. const arg = argv[index];
  365. if (!arg.startsWith('--')) continue;
  366. const key = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  367. const next = argv[index + 1];
  368. if (!next || next.startsWith('--')) result[key] = true;
  369. else {
  370. result[key] = next;
  371. index += 1;
  372. }
  373. }
  374. return result;
  375. }
  376. if (require.main === module) {
  377. try {
  378. main();
  379. } catch (error) {
  380. console.error(error && error.stack ? error.stack : String(error));
  381. process.exit(1);
  382. }
  383. }
  384. module.exports = {
  385. buildHomepageEvidenceReadiness,
  386. normalizeCandidate
  387. };