run-nightly-direct-sync.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. #!/usr/bin/env node
  2. import {readFile,writeFile,mkdir,rename,rm,readdir} from 'node:fs/promises';
  3. import {homedir} from 'node:os';
  4. import {join,resolve} from 'node:path';
  5. import {randomUUID,randomBytes,createHash} from 'node:crypto';
  6. import {fileURLToPath} from 'node:url';
  7. import {specs,modelOrder} from './lib/legacy-direct-specs.mjs';
  8. import {projectionFor,legacyAddonFields,signatureValue} from './lib/legacy-direct-projection.mjs';
  9. export function changesFor(m,source,target,fields){
  10. const result={};for(const field of fields){const key=Object.keys(source).find(k=>k.toLowerCase()===field.toLowerCase()),raw=source[key];
  11. if(raw===undefined||m===59&&field==='pf'&&(raw===null||raw===''))continue;
  12. const normalize=v=>legacyAddonFields[m]?signatureValue(m,field,v):String(v??'').trim();
  13. if(normalize(raw)!==normalize(target[field]))result[field]=normalize(raw);
  14. }return result;
  15. }
  16. export function scheduledBusinessDate(now=new Date()){
  17. const format=d=>new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit'}).format(d);
  18. const hour=Number(new Intl.DateTimeFormat('en-GB',{timeZone:'Asia/Shanghai',hour:'2-digit',hourCycle:'h23'}).format(now));
  19. return format(new Date(now.getTime()-(hour<23?86400000:0)));
  20. }
  21. // Keep durable reports; only expire raw page caches of completed runs after seven days.
  22. export async function pruneCompletedCaches(root,now=Date.now()){
  23. for(const name of await readdir(root)){
  24. if(!/^\d{4}-\d{2}-\d{2}-direct-[a-f0-9-]{36}\.json$/.test(name))continue;
  25. let report;try{report=JSON.parse(await readFile(join(root,name),'utf8'));}catch{continue;}
  26. if(!['completed','completed-with-preserved-conflicts'].includes(report.status)||!/^([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/.test(report.runId||''))continue;
  27. const finished=Date.parse(report.finishedAt||report.completedAt);
  28. if(Number.isFinite(finished)&&finished<now-7*86400000)await rm(join(root,'direct-'+report.runId),{recursive:true,force:true});
  29. }
  30. }
  31. export async function runDirectSync(env=process.env,args=process.argv.slice(2)){
  32. const root=resolve(env.XIAOSHU_SYNC_REPORT_DIR||join(homedir(),'.local/state/xiaoshu/legacy-sync'));
  33. await mkdir(root,{recursive:true,mode:0o700});
  34. const report={source:'old-api/WXAPP',bridgeUsed:false,startedAt:new Date().toISOString(),status:'running',datasets:[],failed:0};
  35. const owner=randomUUID(),token=env.XIAOSHU_SYNC_OPERATOR_TOKEN,url=(env.XIAOSHU_FUNCTION_URL||'https://server.xiaoshu.pro/api/functions').replace(/\/$/,'')+'/xiaoshu/sync/direct-v1';
  36. const lock=join(root,'direct-nightly.lock');let locked=false,runId,runRoot;
  37. const save=async(path,value)=>{const tmp=path+'.tmp';await writeFile(tmp,JSON.stringify(value),{mode:0o600});await rename(tmp,path);};
  38. const load=async path=>{try{return JSON.parse(await readFile(path,'utf8'));}catch(e){if(e.code==='ENOENT')return null;throw e;}};
  39. async function call(params,retries=0){
  40. for(let n=0;;n++){try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{...params,runId,owner}}),signal:AbortSignal.timeout(90000)}),j=await r.json();
  41. if(!r.ok||!j.success)throw Error(j.message||'direct_gateway_http_'+r.status);return j.data;
  42. }catch(e){if(n>=retries)throw e;await new Promise(r=>setTimeout(r,1000*(n+1)));}}
  43. }
  44. try{
  45. if(!token)throw Error('missing_sync_operator_session');
  46. if(env.XIAOSHU_SYNC_OPERATOR_EXPIRES_AT&&Date.parse(env.XIAOSHU_SYNC_OPERATOR_EXPIRES_AT)<=Date.now())throw Error('expired_sync_operator_session');
  47. try{await mkdir(lock,{mode:0o700});locked=true;}catch(e){
  48. if(e.code!=='EEXIST')throw e;
  49. const prior=await load(join(lock,'owner.json'));let exited=false;
  50. if(Number.isSafeInteger(prior?.pid)&&prior.pid>0){try{process.kill(prior.pid,0);}catch(probe){if(probe.code==='ESRCH')exited=true;}}
  51. if(!exited)throw Error('another_run_or_stale_lock');
  52. await rm(lock,{recursive:true});await mkdir(lock,{mode:0o700});locked=true;
  53. }
  54. await save(join(lock,'owner.json'),{pid:process.pid,owner,startedAt:report.startedAt});
  55. await pruneCompletedCaches(root);
  56. const status=await call({action:'status'},2);if(status.source!=='old-api/WXAPP'||status.bridgeUsed!==false)throw Error('wrong_direct_gateway');
  57. if(args.includes('--check')){report.status='ready';return report;}
  58. const claim=await call({action:'claim',proposedRunId:randomUUID(),...(args.includes('--scheduled')?{targetBusinessDate:scheduledBusinessDate()}: {})});
  59. if(claim.alreadyCompleted){Object.assign(report,{status:'already_completed',businessDate:claim.businessDate,previous:claim.summary});return report;}
  60. runId=claim.runId;report.runId=runId;report.businessDate=claim.businessDate;runRoot=join(root,'direct-'+runId);await mkdir(runRoot,{recursive:true,mode:0o700});
  61. report.supplement=await call({action:'supplement'},2);
  62. if(Number(report.supplement.accounts?.identityConflicts||0)>0)throw Error('legacy_account_identity_conflict: resolve reading-generated IDs before syncing business records');
  63. const company={__type:'Pointer',className:'Company',objectId:env.XIAOSHU_SYNC_COMPANY_ID||'7pIbDBJmKx'},projection=projectionFor(company,specs);
  64. const physical=b=>Object.fromEntries(Object.entries(b).map(([k,v])=>[k,v?.__type==='Date'?v.iso:v?.__type==='Pointer'?v.objectId:v]));
  65. for(const m of modelOrder){
  66. const attemptPath=join(runRoot,`${m}-attempt.json`);let attempt=(await load(attemptPath))?.attempt||0;
  67. while(true){try{
  68. const s=specs[m],fields=legacyAddonFields[m]||(m===52?['sy','yb','lj','yp']:['content','synopsis','mp4','ico','author','source']);
  69. const sourcePage=async page=>{const path=join(runRoot,`${m}-source-${page}.json`),cached=await load(path);if(cached)return cached;const value=await call({action:'source',modelId:m,page},2);await save(path,value);return value;};
  70. const first=await sourcePage(1),total=Number(first.page.itemCount??first.items.length),pages=Math.max(1,Number(first.page.pageCount)||Math.ceil(total/1000));
  71. const allIds=new Set(),stats={modelId:m,label:s.label,source:total,created:0,createdAddon:0,updated:0,conflicts:0,failed:0,pages,reconciled:false},contentHash=createHash('sha256');
  72. for(let page=1;page<=pages;page++){
  73. const source=page===1?first:await sourcePage(page);contentHash.update(JSON.stringify(source.items));
  74. const ids=source.items.map(r=>Number(r.GeneralID??r.generalId));for(const id of ids){if(!Number.isSafeInteger(id)||id<1)throw Error('invalid_source_id');allIds.add(id);}
  75. const donePath=join(runRoot,`${m}-page-${page}-done.json`);let result=await load(donePath);
  76. if(!result){
  77. const planPath=join(runRoot,`${m}-page-${page}-plan.json`);let plan=await load(planPath);
  78. if(!plan){
  79. const target=await call({action:'read',modelId:m,ids},2),map=new Map();for(const t of target){if(map.has(Number(t.generalId)))throw Error('duplicate_target_id');map.set(Number(t.generalId),t);}
  80. plan={append:[],patch:[],conflicts:[]};const seen=new Set();
  81. for(let row of source.items){const id=Number(row.GeneralID??row.generalId);if(seen.has(id))continue;seen.add(id);const t=map.get(id);
  82. if(!t?.addon){let item=t?Number(t.common.itemId):id;
  83. if(m===52||m===2){const detail=(await call({action:'source',modelId:m,id},2)).items[0];if(detail){row={...row,...detail};if(!t)item=Number(detail.ID??detail.id??id);}}
  84. for(const f of fields){const k=Object.keys(row).find(k=>k.toLowerCase()===f.toLowerCase());if(k)row={...row,[f]:row[k]};}
  85. const common=physical(projection.commonBody(m,row,item)),addon=physical(projection.addonBody(m,row,item,id));
  86. if(!legacyAddonFields[m])for(const f of fields){const k=Object.keys(row).find(k=>k.toLowerCase()===f.toLowerCase());if(k)addon[f]=String(row[k]??'');}
  87. common.objectId=randomBytes(10).toString('hex');addon.objectId=randomBytes(10).toString('hex');addon.id=item;plan.append.push({generalId:id,common,addon,before:t||null});continue;
  88. }
  89. const values=changesFor(m,row,t.addon,fields);
  90. const metadata=['title','subtitle','inputer'].filter(f=>{const k=Object.keys(row).find(k=>k.toLowerCase()===f);return k!==undefined&&String(row[k]??'').trim()!==String(t.common[f]??'').trim();});
  91. if(metadata.length)plan.conflicts.push({modelId:m,generalId:id,fields:metadata,reason:'保留新系统元数据'});
  92. if(Object.keys(values).length)plan.patch.push({generalId:id,commonId:t.common.objectId,addonId:t.addon.objectId,commonVersion:t.common.updatedAt,addonVersion:t.addon.updatedAt,values,before:t});
  93. }await save(planPath,plan);
  94. }
  95. result={created:0,createdAddon:0,updated:0,conflicts:[...plan.conflicts]};
  96. for(const action of ['append','patch'])for(let i=0;i<plan[action].length;i+=50){const rows=plan[action].slice(i,i+50),r=await call({action,modelId:m,rows,batchKey:`${m}:r${attempt}:${page}:${action}:${i}`},2);
  97. result.created+=r.createdCommon;result.createdAddon+=r.createdAddon;result.updated+=r.updated;result.conflicts.push(...r.conflicts.map(c=>({...c,modelId:m})));
  98. const post=new Map(r.postimages.map(t=>[Number(t.generalId),t]));
  99. for(const x of rows){const after=post.get(x.generalId);if(after&&action==='patch'&&Object.keys(changesFor(m,x.values,after.addon,fields)).length)throw Error('post_write_field_mismatch');}
  100. }
  101. const dedup=new Map();for(const c of result.conflicts){const previous=dedup.get(c.generalId);dedup.set(c.generalId,previous?{...c,fields:[...new Set([...(previous.fields||[]),...(c.fields||[])])]}:c);}result.conflicts=[...dedup.values()];
  102. for(let i=0;i<result.conflicts.length;i+=100)await call({action:'conflicts',rows:result.conflicts.slice(i,i+100)},2);
  103. await save(donePath,result);
  104. }
  105. for(const key of ['created','createdAddon','updated'])stats[key]+=result[key];stats.conflicts+=result.conflicts.length;
  106. if(page%20===0||page===pages)console.log(JSON.stringify({dataset:s.label,pagesDone:page,pages,created:stats.created,updated:stats.updated,conflicts:stats.conflicts}));
  107. }
  108. if(allIds.size!==total)throw Error('source_shifted_restart_dataset');
  109. stats.sourceContentChecksum=contentHash.digest('hex');stats.sourceIdChecksum=createHash('md5').update([...allIds].sort((a,b)=>a-b).join('\n')).digest('hex');
  110. const evidence=await call({action:'evidence',modelId:m,conflicts:stats.conflicts},2);if(evidence.sourceChecksum!==stats.sourceIdChecksum||!evidence.reconciled)throw Error('source_target_checksum_mismatch');
  111. Object.assign(stats,evidence);report.datasets.push(stats);await save(join(runRoot,'progress.json'),report);break;
  112. }catch(e){if(!String(e.message).includes('source_shifted_restart_dataset')||attempt>=2)throw e;attempt++;await call({action:'reset-dataset',modelId:m});for(const file of await readdir(runRoot))if(file.startsWith(m+'-')&&file!==m+'-attempt.json')await rm(join(runRoot,file));await save(attemptPath,{attempt});console.log(JSON.stringify({modelId:m,restartForSourceChanges:attempt}));}
  113. }
  114. }
  115. report.createdBusinessRecords=report.datasets.reduce((n,d)=>n+d.created,0);report.refreshedBusinessRecords=report.datasets.reduce((n,d)=>n+d.updated,0);report.accounts=report.supplement.accounts;report.nodes=report.supplement.nodes;report.conflicts=report.datasets.reduce((n,d)=>n+d.conflicts,0)+Number(report.accounts.conflicts||0);
  116. report.status=report.conflicts?'completed-with-preserved-conflicts':'completed';report.allCapturedSourceIdsReconciled=true;
  117. Object.assign(report,await call({action:'finish',summary:report},2));
  118. return report;
  119. }catch(e){report.status='blocked';report.reason=String(e.message||'sync_failed').replace(/r:[A-Za-z0-9]+/g,'[redacted]').slice(0,240);report.failed=1;return report;}
  120. finally{report.finishedAt=new Date().toISOString();await save(join(root,`${report.businessDate||new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit'}).format(new Date())}-direct-${runId||owner}.json`),report);if(locked)await rm(lock,{recursive:true});}
  121. }
  122. if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){
  123. const r=await runDirectSync();console.log(JSON.stringify({status:r.status,reason:r.reason||'',runId:r.runId,created:r.createdBusinessRecords,updated:r.refreshedBusinessRecords,conflicts:r.conflicts}));
  124. process.exitCode=['completed','completed-with-preserved-conflicts','already_completed','ready'].includes(r.status)?0:1;
  125. }