audit-five-topic-targets-20261111.mjs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. /** Exact product IDs and explicit title evidence; missing facts never become zero. */
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. import assert from 'node:assert/strict';
  5. import {load,save,publicApi,sha} from './lib/research-enrichment.mjs';
  6. import {request} from '../server/scripts/platform-client.mjs';
  7. const work='work/five-topic-bulk-20261111';
  8. const minimumProductsPerTopic=Number(process.argv[2]||100);
  9. assert(Number.isInteger(minimumProductsPerTopic)&&minimumProductsPerTopic>0);
  10. fs.mkdirSync(path.join(work,'product-details'),{recursive:true});
  11. fs.mkdirSync(path.join(work,'document-details'),{recursive:true});
  12. const generation=load(path.join(work,'contract-before.json')).generation;
  13. const inventory=load('work/library-cleanup-20261111/round-02/inventory-after-result.json').body.result;
  14. const plan=load('work/five-topic-collection-20261110/collection-plan.json');
  15. const definitions=plan.topics.map(t=>({...t,keywords:[...t.keywords]}));
  16. // The stated scope includes Chinese soup/food ingredients. These are explicit
  17. // recipe directions, not proof of health benefits or consumer income.
  18. definitions.find(t=>t.id==='wellness').keywords.push('老母鸡','鸡汤','骨汤','黑芝麻','小米','杂粮','荞麦','燕麦','鲜蔬','番茄鸡蛋');
  19. const excluded=new Set(['raw_noodle','sauce','other_food','dry_snack_noodle']);
  20. const countMap=kind=>new Map(inventory.dependencies[kind].map(r=>[r.product_id,Number(r.count)]));
  21. const reviewCounts=countMap('reviews'),priceCounts=countMap('prices');
  22. const canonical=url=>{try{const u=new URL(url);return u.protocol.startsWith('http')&&u.hostname?u.href:null;}catch{return null;}};
  23. let imageMetadata=[];
  24. for(let skip=0;;skip+=1000){
  25. const result=await request('GET','/classes/KsfProductImage?'+new URLSearchParams({keys:'productId,width,height,sourceUrl,sha256',limit:'1000',skip:String(skip),order:'objectId'}));
  26. imageMetadata.push(...result.results);if(result.results.length<1000)break;
  27. }
  28. save(path.join(work,'product-image-metadata.json'),imageMetadata);
  29. const imageIds=new Set(imageMetadata.filter(r=>r.width>0&&r.height>0).map(r=>r.productId));
  30. const topicRows=Object.fromEntries(definitions.map(t=>[t.id,inventory.products.filter(r=>{
  31. const title=String(r.title||'').replace(/五谷道场/g,'');
  32. return !excluded.has(r.category_code)&&canonical(r.url)&&t.keywords.some(k=>title.includes(k));
  33. }).map(r=>({id:r.id,title:r.title,url:r.url,shopId:r.shop_id,shopName:r.shop_name,brandCode:r.brand_code,category:r.category_code,
  34. basis:'商品标题明确出现研究要素;是研究相关性,不是功效、收入身份或已核规格',
  35. titleTerms:t.keywords.filter(k=>String(r.title||'').replace(/五谷道场/g,'').includes(k)),
  36. existingReviewCount:reviewCounts.get(r.id)||0,existingPriceCount:priceCounts.get(r.id)||0,imageMetadataPresent:imageIds.has(r.id),
  37. score:(reviewCounts.has(r.id)?20:0)+(priceCounts.has(r.id)?4:0)+(imageIds.has(r.id)?4:0)+(r.shop_id?2:0)+(r.brand_code!=='unknown'?2:0)
  38. })).sort((a,b)=>b.score-a.score||a.id.localeCompare(b.id))]));
  39. save(path.join(work,'product-candidates.json'),{generation,definitions,topics:topicRows,notice:'候选数量不是完整档案数量;品牌词五谷道场不作为五谷食材证据。'});
  40. const selected=Object.values(topicRows).flatMap(rows=>rows.slice(0,minimumProductsPerTopic));
  41. const unique=[...new Map(selected.map(r=>[r.id,r])).values()];
  42. console.log(JSON.stringify({stage:'product-scope',candidates:Object.fromEntries(Object.entries(topicRows).map(([k,v])=>[k,v.length])),selectedMemberships:selected.length,uniqueProducts:unique.length,images:imageIds.size}));
  43. let index=0;const checks=[];
  44. await Promise.all(Array.from({length:2},async()=>{
  45. while(index<unique.length){
  46. const row=unique[index++],file=path.join(work,'product-details',row.id.replace(':','-')+'.json');
  47. let result=fs.existsSync(file)?load(file):null;
  48. if(result?.generation!==generation){
  49. try{result=await publicApi({op:'detail',entity:'products',id:row.id,generation});save(file,result);}
  50. catch(error){checks.push({id:row.id,status:'read_error',error:String(error.message)});continue;}
  51. }
  52. const r=result.record;assert.equal(r.id,row.id);
  53. const missing=[];
  54. if(!r.title?.trim())missing.push('title');if(!canonical(r.url))missing.push('sourceUrl');
  55. if(!r.shop_id||!r.shop_name)missing.push('shopIdentity');
  56. if(!r.brand_code||r.brand_code==='unknown')missing.push('brandIdentity');
  57. const sourceSpecs=(r.specs||[]).filter(s=>s.verification==='source_attribute'&&s.spec_text?.trim());
  58. if(!sourceSpecs.length)missing.push('sourceVerifiedSpec');
  59. if(!r.specs?.some(s=>s.verification==='source_attribute'&&s.net_weight_g>0))missing.push('verifiedNetWeight');
  60. if(!result.relations?.some(v=>v.entity==='prices'&&v.count>0))missing.push('priceObservation');
  61. if(!result.relations?.some(v=>v.entity==='reviews'&&v.count>0))missing.push('reviewContent');
  62. if(!imageIds.has(row.id))missing.push('productImage');
  63. if(!r.source?.assetSha256||!r.source?.archiveSha256)missing.push('sourceProvenance');
  64. checks.push({id:row.id,status:'read_verified',fieldsVerified:true,missing,complete:missing.length===0,
  65. essentialFieldsPresent:!missing.some(f=>['title','sourceUrl','shopIdentity','brandIdentity','sourceVerifiedSpec','sourceProvenance'].includes(f)),
  66. specCount:sourceSpecs.length,reviewCount:result.relations.find(v=>v.entity==='reviews')?.count,
  67. imageMetadataPresent:imageIds.has(row.id),sourceRecordId:r.source_record_id,sourceSha256:r.source?.assetSha256,
  68. detailFile:path.relative(work,file)});
  69. if(checks.length%25===0){save(path.join(work,'product-audit-progress.json'),{generation,checked:checks.length,target:unique.length,updatedAt:new Date().toISOString()});console.log(JSON.stringify({stage:'product-details',checked:checks.length,target:unique.length}));}
  70. }
  71. }));
  72. const byId=new Map(checks.map(r=>[r.id,r]));
  73. const topics=Object.fromEntries(Object.entries(topicRows).map(([key,rows])=>{
  74. const picked=rows.slice(0,minimumProductsPerTopic),records=picked.map(r=>({...r,audit:byId.get(r.id)}));
  75. return [key,{candidateProducts:rows.length,selectedProducts:picked.length,completeProducts:records.filter(r=>r.audit?.complete).length,
  76. essentialFieldsPresent:records.filter(r=>r.audit?.essentialFieldsPresent).length,
  77. missingFieldCounts:records.reduce((o,r)=>{for(const f of r.audit?.missing||[])o[f]=(o[f]||0)+1;return o;},{}),records}];
  78. }));
  79. save(path.join(work,'product-target-audit.json'),{schema:'ksf.five-topic.product-target-audit.v1',generation,checkedAt:new Date().toISOString(),
  80. minimumProductsPerTopic,completeDefinition:['商品ID、平台、标题、链接、店铺和品牌','来源明确的规格及净含量','价格观察、评价正文、图片与来源归档'],
  81. titleTermsOnlyForTopicRelevance:true,uniqueProducts:unique.length,checks,topics,
  82. allProductTargetsComplete:Object.values(topics).every(t=>t.completeProducts>=minimumProductsPerTopic)});
  83. const docs=await publicApi({op:'browse',entity:'documents',quality:'audit',limit:100,generation});
  84. save(path.join(work,'documents-before.json'),docs);
  85. const docChecks=[];
  86. for(const row of docs.records){
  87. const file=path.join(work,'document-details',row.record_id+'.json');
  88. let data=fs.existsSync(file)?load(file):null;
  89. if(data?.generation!==generation){data=await publicApi({op:'detail',entity:'documents',id:row.record_id,generation});save(file,data);}
  90. const r=data.record,text=String(r.full_text||'');
  91. docChecks.push({id:r.id,title:r.title,sourceUrl:r.source_url,length:text.length,hasBody:text.length>300,
  92. sourceSha256:r.source?.assetSha256,attachmentHashMatched:r.attachment_hash_state==='matched',status:r.declared_status,
  93. complete:!!(text.length>300&&r.source_url&&r.source?.assetSha256&&r.attachment_hash_state==='matched')});
  94. }
  95. save(path.join(work,'document-target-audit.json'),{generation,documents:docChecks});
  96. console.log(JSON.stringify({stage:'audit-complete',products:Object.fromEntries(Object.entries(topics).map(([k,v])=>[k,{selected:v.selectedProducts,complete:v.completeProducts,essential:v.essentialFieldsPresent}])),documents:docChecks.length}));