collect-topic-research.cjs 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. /** Catalog-driven topical collection with preserved HTTP bytes and a single-run lock. */
  2. const fs = require('node:fs');
  3. const path = require('node:path');
  4. const crypto = require('node:crypto');
  5. const {createRequire} = require('node:module');
  6. const {AsyncLocalStorage} = require('node:async_hooks');
  7. const fromVoc = createRequire('C:/Users/JSHY/Doubao/skills/voc-intelligence/package.json');
  8. const {searchVocApis, getVocApiDoc} = fromVoc('./mcp/src/tools/voc-api-catalog-run.js');
  9. const {readCredentialContext} = fromVoc('./mcp/src/core/credentials.js');
  10. const gateways = {social: fromVoc('./mcp/src/providers/voc-gateway.js').callSocialGateway,
  11. ecommerce: fromVoc('./mcp/src/providers/ecommerce-gateway.js').callEcommerceGateway};
  12. const planFile = path.resolve(process.argv[2] || '');
  13. const plan = JSON.parse(fs.readFileSync(planFile, 'utf8'));
  14. const directory = path.dirname(planFile), rawRoot = path.join(directory, 'collected');
  15. fs.mkdirSync(rawRoot, {recursive: true});
  16. const sha = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
  17. const save = (file, data) => {
  18. const next = file + '.next';
  19. fs.writeFileSync(next, JSON.stringify(data, null, 2));
  20. fs.renameSync(next, file);
  21. };
  22. const lockFile = path.join(directory, 'collector.lock.json');
  23. if (fs.existsSync(lockFile)) {
  24. const old = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
  25. let alive = false; try { process.kill(old.pid, 0); alive = true; } catch {}
  26. if (alive) throw Error('TOPIC_COLLECTOR_ALREADY_RUNNING');
  27. fs.renameSync(lockFile, lockFile + '.stale-' + Date.now());
  28. }
  29. fs.writeFileSync(lockFile, JSON.stringify({pid: process.pid, startedAt: new Date().toISOString()}), {flag: 'wx'});
  30. const context = new AsyncLocalStorage(), nativeFetch = global.fetch;
  31. global.fetch = async (url, init = {}) => {
  32. const response = await nativeFetch(url, {...init, signal: init.signal || AbortSignal.timeout(60000)});
  33. const current = context.getStore();
  34. if (current) {
  35. const bytes = Buffer.from(await response.clone().arrayBuffer());
  36. if (bytes.length > 45000000) throw Error('RESPONSE_TOO_LARGE');
  37. const file = current.key + '--attempt-' + (current.attempts.length + 1) + '.body';
  38. fs.writeFileSync(path.join(rawRoot, file), bytes);
  39. current.attempts.push({responseFile: file, httpStatus: response.status,
  40. bytes: bytes.length, sha256: sha(bytes), receivedAt: new Date().toISOString()});
  41. current.lastBytes = bytes;
  42. }
  43. return response;
  44. };
  45. (async () => {
  46. const credentials = readCredentialContext({});
  47. if (!credentials.newApiToken && !credentials.sessionToken) throw Error('VOC_CREDENTIAL_REQUIRED');
  48. const docs = new Map(), records = [];
  49. for (const platform of [...new Set(plan.jobs.map(j => j.platform))]) {
  50. const catalog = await searchVocApis({platform, query: '搜索', limit: 30});
  51. save(path.join(directory, 'catalog-' + platform + '.json'), catalog);
  52. }
  53. for (const id of [...new Set(plan.jobs.map(j => j.endpoint))]) {
  54. const doc = await getVocApiDoc({id});
  55. if (doc.status !== 'ok') throw Error('ENDPOINT_DOCUMENT_REQUIRED');
  56. docs.set(id, doc.data.endpoint);
  57. save(path.join(directory, 'endpoint-' + id.replace(/[^a-z0-9_-]/gi, '_') + '.json'), doc);
  58. }
  59. let stopReason = null;
  60. for (let index = 0; index < plan.jobs.length; index++) {
  61. const job = plan.jobs[index], endpoint = docs.get(job.endpoint);
  62. const key = String(index + 1).padStart(3, '0') + '-' + job.topic + '-' + job.platform + '-' + Date.now();
  63. const query = {}, body = {};
  64. for (const p of endpoint.params || []) {
  65. const value = job.params[p.name] ?? p.default;
  66. if (value !== undefined) ((p.in || endpoint.paramsIn || (endpoint.method === 'GET' ? 'query' : 'body')) === 'query' ? query : body)[p.name] = value;
  67. if (p.required && value === undefined) throw Error('REQUIRED_PARAMETER_MISSING:' + p.name);
  68. }
  69. const request = {key, attempts: [], lastBytes: null};
  70. const receipt = {...job, startedAt: new Date().toISOString(), productionWrites: 0};
  71. try {
  72. const result = await context.run(request, () => gateways[endpoint.channel || 'social']({
  73. proxyPath: endpoint.proxyPath, method: endpoint.method, query,
  74. body: endpoint.method === 'GET' ? undefined : body,
  75. token: credentials.newApiToken || credentials.sessionToken, fallbackToken: credentials.sessionToken,
  76. retries: 1
  77. }));
  78. if (!request.lastBytes) throw Error('RAW_HTTP_RESPONSE_REQUIRED');
  79. const file = key + '.response.json';
  80. fs.writeFileSync(path.join(rawRoot, file), request.lastBytes);
  81. Object.assign(receipt, {ok: true, httpStatus: result.httpStatus, responseFile: file,
  82. sha256: sha(request.lastBytes), bytes: request.lastBytes.length});
  83. } catch (error) {
  84. Object.assign(receipt, {ok: false, httpStatus: error.httpStatus || null,
  85. errorKind: error.kind || 'request', reason: error.reason || null, retryable: error.retryable ?? false});
  86. if ([401, 402, 403].includes(error.httpStatus)) stopReason = {code: 'UPSTREAM_HTTP_' + error.httpStatus,
  87. endpoint: job.endpoint, topic: job.topic, stoppedAt: new Date().toISOString()};
  88. }
  89. Object.assign(receipt, {finishedAt: new Date().toISOString(), attempts: request.attempts});
  90. save(path.join(rawRoot, key + '.meta.json'), receipt);
  91. records.push(receipt);
  92. save(path.join(directory, 'collection-result.json'), {schema: 'ksf.five-topic.collection.v1',
  93. updatedAt: new Date().toISOString(), status: stopReason ? 'stopped-upstream' : 'running',
  94. plannedJobs: plan.jobs.length, completedJobs: records.length, stopReason, records, productionWrites: 0});
  95. console.log(JSON.stringify({topic: job.topic, platform: job.platform, endpoint: job.endpoint,
  96. ok: receipt.ok, httpStatus: receipt.httpStatus, completed: records.length, total: plan.jobs.length}));
  97. if (stopReason) break;
  98. }
  99. save(path.join(directory, 'collection-result.json'), {schema: 'ksf.five-topic.collection.v1',
  100. updatedAt: new Date().toISOString(), status: stopReason ? 'stopped-upstream' : 'completed',
  101. plannedJobs: plan.jobs.length, completedJobs: records.length, stopReason, records, productionWrites: 0});
  102. if (stopReason) process.exitCode = 2;
  103. })().catch(error => {
  104. console.error(JSON.stringify({ok: false, errorCode: /^[A-Z][A-Z0-9_:.-]+$/.test(error.message) ? error.message : 'TOPIC_COLLECTION_FAILED'}));
  105. process.exitCode = 1;
  106. }).finally(() => {
  107. if (fs.existsSync(lockFile) && JSON.parse(fs.readFileSync(lockFile, 'utf8')).pid === process.pid) fs.unlinkSync(lockFile);
  108. });