jd-jos-api-permission-probe.mjs 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. #!/usr/bin/env node
  2. import crypto from 'node:crypto';
  3. import fs from 'node:fs/promises';
  4. const CONFIG_FILE = new URL('../docs/JD-CONNECTION-CONFIG.local.md', import.meta.url);
  5. const OFFICIAL_TABLES = new URL('../logs/jd-official-upgrade-tables-2026-08-24.json', import.meta.url);
  6. const OUTPUT_FILE = new URL('../logs/jd-jos-api-probe-2026-08-24.json', import.meta.url);
  7. function parseLocalDotenv(text) {
  8. const values = {};
  9. for (const line of text.split(/\r?\n/)) {
  10. const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
  11. if (match) values[match[1]] = match[2].trim();
  12. }
  13. return values;
  14. }
  15. function sign(secret, params) {
  16. const value = Object.keys(params).sort().map((key) => `${key}${params[key] ?? ''}`).join('');
  17. return crypto.createHash('md5').update(`${secret}${value}${secret}`).digest('hex').toUpperCase();
  18. }
  19. function jdTime() {
  20. const parts = new Intl.DateTimeFormat('sv-SE', {
  21. timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
  22. hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
  23. }).formatToParts(new Date());
  24. const get = (type) => parts.find((part) => part.type === type)?.value || '';
  25. return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
  26. }
  27. async function requestJson(url, init = {}, timeoutMs = 30000) {
  28. const controller = new AbortController();
  29. const timer = setTimeout(() => controller.abort(), timeoutMs);
  30. try {
  31. const response = await fetch(url, { ...init, signal: controller.signal });
  32. const text = await response.text();
  33. let body = null;
  34. try { body = text ? JSON.parse(text) : null; }
  35. catch { body = { raw: text.slice(0, 500) }; }
  36. return { httpStatus: response.status, body };
  37. } finally { clearTimeout(timer); }
  38. }
  39. async function fetchToken(config) {
  40. const base = config.JD_SOURCE_PARSE_URL.replace(/\/+$/, '');
  41. const params = new URLSearchParams({
  42. where: JSON.stringify({ platform: 'jd', type: 'access_token' }), limit: '1', order: '-createdAt',
  43. });
  44. for (let attempt = 1; attempt <= 8; attempt += 1) {
  45. const result = await requestJson(`${base}/classes/EcomAuth?${params}`, {
  46. headers: {
  47. 'X-Parse-Application-Id': config.JD_SOURCE_PARSE_APP_ID,
  48. 'X-Parse-Master-Key': config.JD_SOURCE_PARSE_MASTER_KEY,
  49. },
  50. });
  51. const token = result.body?.results?.[0]?.data?.access_token;
  52. if (token) return token;
  53. await new Promise((resolve) => setTimeout(resolve, 350 * attempt));
  54. }
  55. throw new Error('EcomAuth 中没有可用京东 access_token');
  56. }
  57. function readJosMappings(tables) {
  58. const mappings = [];
  59. for (const table of tables.filter((entry) => entry.ti >= 4 && entry.ti <= 11)) {
  60. let current = null;
  61. for (const cells of table.rows.slice(1)) {
  62. if (/^(GET|POST|PUT|PATCH|DELETE)\s/.test(cells[2] || '')) {
  63. const [method, ...path] = cells[2].split(/\s+/);
  64. current = { domain: cells[0] || '', spMethod: method, spPath: `/${path.join(' ')}`, spName: cells[3] || '' };
  65. }
  66. const josApi = cells[4] || '';
  67. if (current && /^jingdong\./.test(josApi)) {
  68. mappings.push({ ...current, josApi, josName: cells[5] || '', josCategory: cells[6] || '' });
  69. }
  70. }
  71. }
  72. const byMethod = new Map();
  73. for (const entry of mappings) if (!byMethod.has(entry.josApi)) byMethod.set(entry.josApi, entry);
  74. const extras = [
  75. {
  76. domain: '商品评价', spMethod: '', spPath: '', spName: '',
  77. josApi: 'jingdong.pop.PopCommentJsfService.getVenderCommentsForJos',
  78. josName: '商家商品评价查询', josCategory: '评价API',
  79. },
  80. ];
  81. for (const entry of extras) if (!byMethod.has(entry.josApi)) byMethod.set(entry.josApi, entry);
  82. return [...byMethod.values()];
  83. }
  84. function summarize(httpStatus, body) {
  85. const error = body?.error_response || body?.error || {};
  86. const code = String(error?.code ?? body?.code ?? '');
  87. const message = String(error?.zh_desc ?? error?.en_desc ?? error?.message ?? body?.message ?? '')
  88. .replace(/appKey=[A-Z0-9]+/gi, 'appKey=<redacted>')
  89. .replace(/ip:\s*[0-9a-f:.]+/gi, 'ip:<redacted>')
  90. .slice(0, 300);
  91. const topKeys = body && typeof body === 'object' ? Object.keys(body) : [];
  92. let status = topKeys.some((key) => key !== 'error_response' && (/_response$/i.test(key) || /_responce$/i.test(key)))
  93. ? '方法响应'
  94. : '返回请求错误';
  95. if (code === '19') status = 'Token 校验失败';
  96. else if (code === '21') status = 'AppKey 禁用';
  97. else if (code === '73') status = '要求云鼎调用';
  98. else if (code === '24' || code === '88') status = '无接口调用权限';
  99. else if (code === '61' || code === '400') status = '请求参数缺失';
  100. else if (code === '65' || code === '67') status = '后端服务异常';
  101. return { status, code, message, responseKeys: topKeys.slice(0, 5) };
  102. }
  103. async function probe(config, token, method) {
  104. const common = {
  105. method,
  106. access_token: token,
  107. app_key: config.JD_APP_KEY,
  108. timestamp: jdTime(),
  109. v: '2.0',
  110. sign_method: 'md5',
  111. '360buy_param_json': '{}',
  112. };
  113. const params = new URLSearchParams({ ...common, sign: sign(config.JD_APP_SECRET, common) });
  114. const started = Date.now();
  115. try {
  116. const result = await requestJson(`https://api.jd.com/routerjson?${params}`);
  117. return { httpStatus: result.httpStatus, durationMs: Date.now() - started, ...summarize(result.httpStatus, result.body) };
  118. } catch (error) {
  119. return { httpStatus: 0, durationMs: Date.now() - started, status: '网络或超时错误', code: '', message: String(error?.message || error).slice(0, 300), responseKeys: [] };
  120. }
  121. }
  122. const config = { ...parseLocalDotenv(await fs.readFile(CONFIG_FILE, 'utf8')), ...process.env };
  123. for (const name of ['JD_APP_KEY', 'JD_APP_SECRET', 'JD_SOURCE_PARSE_URL', 'JD_SOURCE_PARSE_APP_ID', 'JD_SOURCE_PARSE_MASTER_KEY']) {
  124. if (!config[name]) throw new Error(`缺少配置 ${name}`);
  125. }
  126. const token = await fetchToken(config);
  127. const tables = JSON.parse(await fs.readFile(OFFICIAL_TABLES, 'utf8'));
  128. const mappings = readJosMappings(tables);
  129. const results = [];
  130. for (let index = 0; index < mappings.length; index += 1) {
  131. const mapping = mappings[index];
  132. const result = await probe(config, token, mapping.josApi);
  133. results.push({ ...mapping, ...result });
  134. console.log(`[${index + 1}/${mappings.length}] ${mapping.josApi} -> ${result.httpStatus} ${result.code || result.status}`);
  135. await new Promise((resolve) => setTimeout(resolve, 120));
  136. }
  137. await fs.writeFile(OUTPUT_FILE, JSON.stringify({
  138. testedAt: new Date().toISOString(),
  139. officialSource: 'https://open.jd.com/v2/#/doc/api?apiCateId=200436&articleId=1100596&gwType=1',
  140. methodCount: mappings.length,
  141. requestPayload: '{}',
  142. results,
  143. }, null, 2), 'utf8');
  144. console.log(`Saved ${OUTPUT_FILE.pathname}`);