verify-five-topic-collection.mjs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. /** Verify each new topic source and report new collection separately from existing matches. */
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. import assert from 'node:assert/strict';
  5. import {read, publicApi, save, load, sha, unwrap, checkPublication} from './lib/research-enrichment.mjs';
  6. const work = path.resolve('work/five-topic-collection-20261110');
  7. const directory = path.resolve('work/database-enrichment-20261110/batches/five-topics-public-01');
  8. const plan = load(path.join(work, 'collection-plan.json'));
  9. const receipt = load(path.join(directory, 'receipt.json'));
  10. const expected = load(path.join(directory, 'expected-records.json'));
  11. const applied = unwrap(load(path.join(directory, 'apply-result.json')));
  12. const status = unwrap(load(path.join(directory, 'status-result.json')));
  13. const planned = unwrap(load(path.join(directory, 'plan-result.json'))).mergePlan;
  14. checkPublication(applied, receipt);
  15. assert.equal(applied.generation, planned.plannedGeneration);
  16. assert(status.ok && status.publicationFound);
  17. assert.equal(status.publishedGeneration, applied.generation);
  18. assert.equal(sha(fs.readFileSync(path.join(directory, 'transport.zip'))), receipt.archiveSha256);
  19. const contract = await read('contract');
  20. const before = load(path.join(work, 'contract-before.json'));
  21. assert.equal(contract.generation, applied.generation);
  22. for (const [table, count] of Object.entries(before.counts)) {
  23. assert.equal(contract.counts[table], count + (applied.delta.added[table] || 0), 'COUNT_DRIFT:' + table);
  24. }
  25. const checks = [];
  26. let next = 0;
  27. await Promise.all(Array.from({length: 2}, async () => {
  28. while (next < expected.length) {
  29. const index = next++, row = expected[index];
  30. const response = await read('get', {entity: 'social', id: row.id, generation: contract.generation});
  31. const actual = response.record;
  32. assert.equal(actual.id, row.id); assert.equal(actual.post_id, row.postId);
  33. assert.equal(actual.platform, 'smzdm'); assert.equal(actual.content_scope, 'packaged_noodles');
  34. assert.equal(actual.fulltext_available, 1);
  35. for (const field of ['title', 'content', 'url', 'published_at_raw']) assert.equal(actual[field], row[field]);
  36. const source = await read('source', {id: actual.source_record_id, generation: contract.generation});
  37. assert.equal(source.sourcePayload.content, row.content);
  38. assert.equal(source.sourcePayload.id, row.postId);
  39. assert(source.citation.upstreamSources.some(item => item.sha256 === row.adapterSha256));
  40. const adapter = load(path.join(directory, row.adapterPath));
  41. assert.equal(adapter.original.sha256, row.originalSha256);
  42. assert.equal(sha(fs.readFileSync(path.join(directory, row.originalPath))), row.originalSha256);
  43. assert.equal(adapter.record.content, row.content);
  44. save(path.join(directory, 'verified-source-' + index + '.json'), {record: response, source,
  45. original: {...adapter.original, shaVerified: true}});
  46. checks.push({id: row.id, fieldsVerified: true, sourceVerified: true,
  47. originalSha256: row.originalSha256, topics: row.topics});
  48. save(path.join(work, 'verification-state.json'), {stage: 'get-source', completed: checks.length,
  49. target: expected.length, generation: contract.generation, updatedAt: new Date().toISOString()});
  50. console.log(JSON.stringify({verified: checks.length, target: expected.length, id: row.id}));
  51. }
  52. }));
  53. const summary = await publicApi({op: 'summary', generation: contract.generation});
  54. for (const [table, count] of Object.entries(contract.counts)) assert.equal(summary.counts[table], count);
  55. const page = await publicApi({op: 'browse', entity: 'social', quality: 'audit', limit: 100,
  56. filters: {recordId: expected.map(row => row.id)}, generation: contract.generation});
  57. assert.equal(page.totalMatched, expected.length);
  58. assert.equal(page.records.length, expected.length);
  59. assert(page.records.every(row => row.fulltext_available === 1));
  60. const union = new Map(), topics = [];
  61. for (const topic of plan.topics) {
  62. const library = load(path.join(work, 'library-' + topic.id + '.json'));
  63. assert(library.allPagesRetrieved && library.sourceIdsPresent);
  64. assert.equal(library.generation, before.generation);
  65. const byEntity = {}, reviewFingerprints = new Set();
  66. for (const row of library.records) {
  67. byEntity[row.entity] = (byEntity[row.entity] || 0) + 1;
  68. union.set(row.entity + ':' + row.record_id, row);
  69. if (row.entity === 'reviews') {
  70. assert(row.match.matchedTerms.length > 0, 'REVIEW_TITLE_ONLY_MATCH');
  71. reviewFingerprints.add(row.content_fingerprint);
  72. }
  73. }
  74. assert.deepEqual(byEntity, library.counts);
  75. const newRows = expected.filter(row => row.topics.includes(topic.id));
  76. topics.push({id: topic.id, name: topic.name, newArticleOriginalsCollected: newRows.length,
  77. newArticlesImported: newRows.length, newProductsCollected: 0, newReviewsCollected: 0,
  78. existingProductCandidates: byEntity.products || 0, existingReviewBodyMatches: byEntity.reviews || 0,
  79. existingDistinctReviewTexts: reviewFingerprints.size,
  80. existingSocialCandidates: byEntity.social || 0, newRecordIds: newRows.map(row => row.id)});
  81. }
  82. const unionCounts = {};
  83. for (const row of union.values()) unionCounts[row.entity] = (unionCounts[row.entity] || 0) + 1;
  84. const collection = load(path.join(work, 'collection-result.json'));
  85. assert.equal(collection.stopReason.code, 'UPSTREAM_HTTP_402');
  86. const publication = {ok: true, checkedAt: new Date().toISOString(), batchGeneration: applied.generation,
  87. generation: contract.generation, allRowsVerified: true, publicReadbackPassed: true,
  88. sourceCitationReadbackPassed: true, checks, summary};
  89. save(path.join(directory, 'pipeline-readback.json'), publication);
  90. const result = {schema: 'ksf.five-topic.verified-results.v1', checkedAt: new Date().toISOString(),
  91. status: 'public-source-batch-verified-platform-collection-still-blocked',
  92. generation: contract.generation, existingLibrarySnapshotGeneration: before.generation,
  93. topics, newCollectionTotals: {articleOriginals: expected.length, importedSocialArticles: expected.length,
  94. products: 0, ecommerceReviews: 0}, existingLibraryUniqueRecords: unionCounts,
  95. topicRecordMemberships: topics.reduce((sum, row) => sum + row.existingProductCandidates + row.existingReviewBodyMatches + row.existingSocialCandidates, 0),
  96. allRowsVerified: true, publicReadbackPassed: true, sourceCitationReadbackPassed: true,
  97. productionCounts: contract.counts, upstreamStop: collection.stopReason,
  98. limitations: ['商品、评价和原有社媒数量为库内关键词候选,不是本轮外部新增量',
  99. '库内评价仅按正文或追评关键词匹配,尚未逐条判读实质关联,不作为已确认需求或analysis资格',
  100. '课题间可重叠,按精确记录ID计算总体去重数;同文也不等于独立消费者',
  101. '公开文章为保留原日期的历史/当时体验,不代表2026年趋势、现价或功效实证',
  102. '标题中的人群/场景营销词不证明实际消费者身份;评价列要求正文或追评命中',
  103. '新增公开社区文章可按精确recordId查询;未冒充京东/淘宝购买评价'],
  104. batchDirectory: path.relative(process.cwd(), directory)};
  105. save(path.join(work, 'verified-results.json'), result);
  106. const table = ['| 课题 | 本轮新采并入库文章 | 库内商品线索 | 库内评价正文关键词命中 | 库内社媒线索 |',
  107. '|---|---:|---:|---:|---:|', ...topics.map(row => `| ${row.name} | ${row.newArticlesImported} | ${row.existingProductCandidates} | ${row.existingReviewBodyMatches} | ${row.existingSocialCandidates} |`)];
  108. fs.writeFileSync(path.join(work, '五课题采集量.md'), `# 五课题采集与入库结果\n\n核验时间:${result.checkedAt}\n\n${table.join('\n')}\n\n本轮新增公开社区原文去重${expected.length}篇,正式入库${expected.length}篇;表内跨课题会重复。商品和电商评价新增均为0:现有VOC采集探测返回HTTP402,已停止排队请求。\n\n库内课题线索按记录ID总体去重:${JSON.stringify(unionCounts)}。库内数字来自版本${before.generation}的逐页查询;当前正式版本${contract.generation}新增上述6篇原文。\n\n6/6具体记录、6/6来源及原件SHA、看板接口和逐表计数均通过;未新增商品、评价、价格或规格。\n\n公开网页候选122件,读到可定位正文12篇,复核选入6篇产品体验原文;其余包括验证页、搜索跳转页、无有效正文、健康泛谈、无来源引语或食谱等,未计入有效入库量。\n\n${result.limitations.map(text => '- ' + text).join('\n')}\n\n具体来源链接、原文、日期与精确数据库ID见reviewed-public-sources.json和verified-results.json;原件和发布回执保留在${result.batchDirectory}。\n`, 'utf8');
  109. console.log(JSON.stringify({ok: true, generation: result.generation, topics,
  110. newCollectionTotals: result.newCollectionTotals, existingLibraryUniqueRecords: unionCounts}));