collect-review-coverage.cjs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. // A bounded continuation of the existing direct VOC MCP collection workflow.
  2. // Reserve before each call, reuse actual credentials, never repeat a request.
  3. require('../server/industry-collection-guard.cjs').assertPaidCollectionAllowed();
  4. const fs = require('node:fs');
  5. const path = require('node:path');
  6. const crypto = require('node:crypto');
  7. const { createRequire } = require('node:module');
  8. const plugin = 'C:/Users/JSHY/AppData/Roaming/npm/node_modules/@vocmarket/voc-skill';
  9. const req = createRequire(path.join(plugin, 'mcp/src/server.js'));
  10. const { Client } = req('@modelcontextprotocol/sdk/client/index.js');
  11. const { StdioClientTransport } = req('@modelcontextprotocol/sdk/client/stdio.js');
  12. const { readCredentialContext } = req('./core/credentials.js');
  13. const credentials = readCredentialContext({});
  14. const root = path.resolve('work/product-reviews/round2');
  15. const live = path.resolve('work/product-reviews/live-r2');
  16. const plan = JSON.parse(fs.readFileSync(path.join(root, 'plan-expanded.json'), 'utf8'));
  17. const limit = Number(process.argv[2] || plan.candidateCount);
  18. if (!Number.isInteger(limit) || limit < 1 || limit > 60) throw Error('INVALID_LIMIT');
  19. function save(file, value) {
  20. fs.mkdirSync(path.dirname(file), { recursive: true });
  21. let raw = JSON.stringify(value, null, 2);
  22. for (const secret of [credentials.newApiToken, credentials.sessionToken]) {
  23. if (secret) raw = raw.split(secret).join('[REDACTED]');
  24. }
  25. fs.writeFileSync(file, raw + '\n', { flag: 'wx' });
  26. }
  27. async function library(query) {
  28. const response = await fetch('https://kangshifu.fmode.cn/api/library', {
  29. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query),
  30. signal: AbortSignal.timeout(35000),
  31. });
  32. const data = await response.json();
  33. if (!response.ok || !data.ok) throw Error(data.error?.code || 'LIBRARY_READ_FAILED');
  34. return data;
  35. }
  36. const transport = new StdioClientTransport({ command: process.execPath, args: [path.join(plugin, 'mcp/src/server.js')],
  37. cwd: plugin, stderr: 'pipe', env: { ...process.env, VOC_SKILL_NO_UPDATE_CHECK: '1' } });
  38. transport.stderr?.on('data', () => {});
  39. const client = new Client({ name: 'ksf-expand-review-coverage', version: '1.0.0' });
  40. async function tool(name, args, file, timeout = 30000) {
  41. const response = await client.callTool({ name, arguments: args }, undefined, { timeout });
  42. save(file, { request: { name, arguments: args }, response });
  43. return response.structuredContent || {};
  44. }
  45. (async () => {
  46. try {
  47. if (!credentials.newApiToken || credentials.sessionToken) throw Error('EXPECTED_SINGLE_EXISTING_AUTH_WITHOUT_FALLBACK');
  48. if (plan.worstCaseOccupiedCny > 50 || plan.maxNewCostCny > 18) throw Error('BUDGET_LIMIT');
  49. const summary = await library({ op: 'summary' });
  50. if (summary.generation !== plan.generation) throw Error('LIBRARY_GENERATION_CHANGED_REVIEW_PLAN');
  51. await client.connect(transport, { timeout: 15000 });
  52. const pricing = await tool('voc_billing_query', { operations: [{ model: 'voc-e-commerce',
  53. path: 'jd/get-item-comments/v1', count: 1 }], createPaymentQr: false }, path.join(root, 'pricing-' + Date.now() + '.json'));
  54. if (pricing.status !== 'ok' || pricing.data?.estimate?.operations?.[0]?.priceCnyPerCall !== 0.30) throw Error('PRICE_CHANGED');
  55. const queue = plan.candidates.filter(c => !fs.existsSync(path.join(live, c.productId.replace(':', '-') + '-p1', 'reservation.json'))).slice(0, limit);
  56. let next = 0, started = 0, finished = 0, failures = 0, stop = false;
  57. async function worker() {
  58. while (!stop) {
  59. const index = next++;
  60. if (index >= queue.length) return;
  61. const item = queue[index];
  62. const label = item.productId.replace(':', '-') + '-p1';
  63. const out = path.join(live, label);
  64. try {
  65. const context = await library({ op: 'detail', entity: 'products', id: item.productId, generation: plan.generation });
  66. const count = context.relations.find(r => r.entity === 'reviews')?.count;
  67. if (context.generation !== plan.generation || count !== 0) throw Error('PRODUCT_ALREADY_UPDATED');
  68. const params = { itemId: item.productId.split(':')[1], page: item.page };
  69. if (item.productId.startsWith('taobao:')) params.orderType = 'general';
  70. // Exact endpoint/product/page has already been excluded from the old
  71. // round by the source-verified plan; this directory prevents replay.
  72. if (fs.existsSync(path.join(out, 'reservation.json'))) throw Error('REQUEST_ALREADY_RESERVED');
  73. save(path.join(out, 'product-context.json'), context);
  74. const usageTraceId = 'usage_ksf_reviews_r2_' + crypto.randomUUID();
  75. const reservation = { endpoint: item.endpoint, params, usageTraceId, maxNewCostCny: 0.30,
  76. maxGatewayRequests: 1, retries: 0, automaticFallback: false, timestamp: new Date().toISOString(),
  77. catalogGeneration: plan.generation, historicalCountDisplay: item.volumeEvidence.countText,
  78. scope: 'expand_missing_high_volume_product_coverage', sharedTaskBudgetCny: 50 };
  79. save(path.join(out, 'reservation.json'), reservation);
  80. started++;
  81. console.log(JSON.stringify({ event: 'started', productId: item.productId, number: started, maxCostCny: 0.30 }));
  82. const result = await tool('voc_api_call', { id: item.endpoint, params, retries: 0, usageTraceId,
  83. skillId: 'ksf-review-coverage-r2', outputDir: path.join(out, 'tool-output') }, path.join(out, 'mcp-result.json'), 600000);
  84. const rows = item.productId.startsWith('jd:') ? result.data?.result?.data : result.data?.result?.comments;
  85. finished++;
  86. if (result.status !== 'ok' || !Array.isArray(rows) || !rows.length) failures++;
  87. console.log(JSON.stringify({ event: 'finished', productId: item.productId, status: result.status,
  88. rows: Array.isArray(rows) ? rows.length : 0, finished, failures }));
  89. try {
  90. const usage = await tool('voc_usage_report', { usageTraceId, groupBy: ['usageTraceId', 'module', 'endpoint'],
  91. limit: 1000, pageSize: 200, refresh: true }, path.join(out, 'usage-' + Date.now() + '.json'), 60000);
  92. console.log(JSON.stringify({ event: 'usage', productId: item.productId, status: usage.status,
  93. costCny: usage.summary?.costCny, billedCalls: usage.summary?.billedCalls }));
  94. } catch (error) {
  95. // The collection response is already durable. An unavailable
  96. // billing query keeps its full reservation; never repeat data.
  97. save(path.join(out, 'usage-pending-' + Date.now() + '.json'),
  98. { usageTraceId, errorType: error.name, unsettledReservationCny: 0.30 });
  99. console.log(JSON.stringify({ event: 'usage-pending', productId: item.productId, reservationCny: 0.30 }));
  100. }
  101. if (['needs_valid_token', 'needs_recharge', 'permission_denied'].includes(result.status)
  102. || (finished >= 8 && failures / finished > 0.65)) {
  103. stop = true;
  104. console.log(JSON.stringify({ stopped: true, reason: 'ACCESS_OR_FAILURE_RATE', finished, failures }));
  105. }
  106. } catch (error) {
  107. stop = true;
  108. console.log(JSON.stringify({ stopped: true, productId: item.productId, errorType: error.name,
  109. reason: /GENERATION|UPDATED|RESERVED/.test(error.message) ? error.message : 'RESULT_UNCERTAIN_OR_READ_FAILED' }));
  110. }
  111. }
  112. }
  113. await Promise.allSettled([worker(), worker()]);
  114. console.log(JSON.stringify({ runFinished: true, started, finished, failures, stopped: stop,
  115. newReservationUpperCny: Number((started * .30).toFixed(2)) }));
  116. } finally {
  117. await client.close();
  118. await transport.close();
  119. }
  120. })().catch(error => {
  121. console.log(JSON.stringify({ ok: false, errorType: error.name,
  122. reason: /GENERATION|BUDGET|PRICE|AUTH/.test(error.message) ? error.message : 'ACCESS_FAILED_NO_RETRY' }));
  123. process.exitCode = 1;
  124. });