dedup-five-topic-multisource-20261111.mjs 3.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import assert from 'node:assert/strict';
  4. import {read, publicApi, save, load} from './lib/research-enrichment.mjs';
  5. const work=process.argv[2]||'work/five-topic-research-20261111';
  6. const review=load(path.join(work,'reviewed-selection.json'));
  7. const before=load(path.join(work,'contract-before.json'));
  8. const contract=await read('contract');
  9. assert.equal(contract.generation,before.generation,'BASELINE_CHANGED_REAUDIT_REQUIRED');
  10. const inventory=load('work/library-cleanup-20261111/round-02/inventory-after-result.json').body.result;
  11. const documents=load(path.join(work,'documents-before.json')).records;
  12. const normalizeUrl=u=>{try{const p=new URL(u);return p.hostname.replace(/^www\./,'')+p.pathname.replace(/\/$/,'');}catch{return '';}};
  13. const normalizeText=t=>String(t||'').replace(/[\s\p{P}\p{S}]/gu,'').toLowerCase();
  14. const existingSocial=inventory.social.map(r=>({...r,urlKey:normalizeUrl(r.url),normalized:normalizeText(r.content)}));
  15. // Include later verified batches that are newer than the inventory snapshot.
  16. for(const name of fs.readdirSync('work/database-enrichment-20261110/batches')){
  17. const directory=path.join('work/database-enrichment-20261110/batches',name);
  18. if(!fs.existsSync(path.join(directory,'apply-result.json')))continue;
  19. const file=path.join(directory,'reviewed-source-index.json');
  20. if(!fs.existsSync(file))continue;
  21. for(const row of load(file).filter(r=>r.entity==='social'))existingSocial.push({...row,urlKey:normalizeUrl(row.url),normalized:normalizeText(row.body)});
  22. }
  23. const remotelyFound=new Map();
  24. for(const entity of ['social','documents']){
  25. const ids=review.selected.filter(r=>r.entity===entity).map(r=>r.id);
  26. if(!ids.length)continue;
  27. const page=await publicApi({op:'browse',entity,quality:'audit',filters:{recordId:ids},limit:100,generation:contract.generation});
  28. save(path.join(work,'dedup-remote-'+entity+'.json'),page);
  29. for(const r of page.records)remotelyFound.set(entity+':'+r.record_id,r);
  30. }
  31. const rejected=[],accepted=[];
  32. for(const row of review.selected){
  33. let existing=remotelyFound.get(row.entity+':'+row.id),reason=existing?'existing_exact_id':null;
  34. if(row.entity==='social'&&!existing){
  35. existing=existingSocial.find(r=>r.urlKey===normalizeUrl(row.url));
  36. if(existing)reason='existing_original_url';
  37. else {
  38. const text=normalizeText(row.body);
  39. existing=existingSocial.find(r=>text.length>80&&r.normalized.length>80&&
  40. (r.normalized===text||r.normalized.includes(text)||(text.includes(r.normalized)&&r.normalized.length/text.length>0.85)));
  41. if(existing)reason='same_original_text';
  42. }
  43. }
  44. if(row.entity==='documents'&&!existing){
  45. // Full source URL is confirmed by get because document browse is an index.
  46. for(const doc of documents){
  47. if(normalizeText(doc.title)===normalizeText(row.title)){
  48. existing=await read('get',{entity:'documents',id:doc.record_id,generation:contract.generation});reason='existing_document_title_requires_reuse';break;
  49. }
  50. }
  51. }
  52. if(existing)rejected.push({id:row.id,url:row.url,reason,existingId:existing.id||existing.record_id||existing.record?.id});
  53. else {
  54. const prior=accepted.find(r=>normalizeUrl(r.url)===normalizeUrl(row.url)||normalizeText(r.body)===normalizeText(row.body));
  55. if(prior)rejected.push({id:row.id,url:row.url,reason:'duplicate_within_batch',existingId:prior.id});
  56. else accepted.push(row);
  57. }
  58. }
  59. assert.equal(new Set(accepted.map(r=>r.entity+':'+r.id)).size,accepted.length);
  60. const result={schema:'ksf.five-topic.dedup.v1',ok:true,generation:contract.generation,checkedAt:new Date().toISOString(),
  61. acceptedIds:accepted.map(r=>({entity:r.entity,id:r.id})),rejected,selected:accepted,existingRecords:rejected.length,
  62. countsByTopic:Object.fromEntries(['wellness','student','premium','stockpile','novelty'].map(t=>[t,accepted.filter(r=>r.topics.some(x=>x.topic===t)).length]))};
  63. save(path.join(work,'dedup-result.json'),result);
  64. console.log(JSON.stringify({ok:true,accepted:accepted.length,rejected,topics:result.countsByTopic}));