jd-api-permission-probe.mjs 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. #!/usr/bin/env node
  2. import crypto from 'node:crypto';
  3. import fs from 'node:fs/promises';
  4. const ROOT = new URL('../', import.meta.url);
  5. const CONFIG_FILE = new URL('../docs/JD-CONNECTION-CONFIG.local.md', import.meta.url);
  6. const OFFICIAL_TABLES = new URL('../logs/jd-official-upgrade-tables-2026-08-24.json', import.meta.url);
  7. const OUTPUT_FILE = new URL('../logs/jd-sp-api-probe-2026-08-24.json', import.meta.url);
  8. const API_BASE = 'https://api-cn.jd.com/rest';
  9. function parseLocalDotenv(text) {
  10. const values = {};
  11. for (const line of text.split(/\r?\n/)) {
  12. const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
  13. if (match) values[match[1]] = match[2].trim();
  14. }
  15. return values;
  16. }
  17. function signature(secret, params) {
  18. const plain = Object.keys(params).sort().map((key) => `${key}${params[key] ?? ''}`).join('');
  19. return crypto.createHash('md5').update(`${secret}${plain}${secret}`).digest('hex').toUpperCase();
  20. }
  21. function queryString(query) {
  22. const params = new URLSearchParams();
  23. for (const [key, value] of Object.entries(query)) {
  24. params.set(key, Array.isArray(value) ? JSON.stringify(value) : String(value));
  25. }
  26. return params.toString();
  27. }
  28. async function requestJson(url, init = {}, timeoutMs = 30000) {
  29. const controller = new AbortController();
  30. const timer = setTimeout(() => controller.abort(), timeoutMs);
  31. try {
  32. const response = await fetch(url, { ...init, signal: controller.signal });
  33. const text = await response.text();
  34. let body = null;
  35. try { body = text ? JSON.parse(text) : null; }
  36. catch { body = { raw: text.slice(0, 500) }; }
  37. return { httpStatus: response.status, body };
  38. } finally {
  39. clearTimeout(timer);
  40. }
  41. }
  42. async function fetchToken(config) {
  43. const base = config.JD_SOURCE_PARSE_URL.replace(/\/+$/, '');
  44. const params = new URLSearchParams({
  45. where: JSON.stringify({ platform: 'jd', type: 'access_token' }),
  46. limit: '1',
  47. order: '-createdAt',
  48. });
  49. for (let attempt = 1; attempt <= 8; attempt += 1) {
  50. const result = await requestJson(`${base}/classes/EcomAuth?${params}`, {
  51. headers: {
  52. 'X-Parse-Application-Id': config.JD_SOURCE_PARSE_APP_ID,
  53. 'X-Parse-Master-Key': config.JD_SOURCE_PARSE_MASTER_KEY,
  54. },
  55. });
  56. const token = result.body?.results?.[0]?.data?.access_token;
  57. if (token) return token;
  58. await new Promise((resolve) => setTimeout(resolve, 350 * attempt));
  59. }
  60. throw new Error('EcomAuth 中没有可用京东 access_token');
  61. }
  62. function officialRows(tables) {
  63. const rows = [];
  64. for (const table of tables.filter((entry) => entry.ti >= 4 && entry.ti <= 11)) {
  65. let inherited = ['', '', '', ''];
  66. for (const cells of table.rows.slice(1)) {
  67. const next = [...cells];
  68. for (let i = 0; i < 4; i += 1) {
  69. if (next[i]) inherited[i] = next[i];
  70. else next[i] = inherited[i];
  71. }
  72. if (/^(GET|POST|PUT|PATCH|DELETE)\s/.test(next[2] || '')) inherited = next.slice(0, 4);
  73. rows.push({
  74. domain: next[0] || '',
  75. subdomain: next[1] || '',
  76. spApi: next[2] || '',
  77. spName: next[3] || '',
  78. josApi: next[4] || '',
  79. josName: next[5] || '',
  80. josCategory: next[6] || '',
  81. });
  82. }
  83. }
  84. return rows;
  85. }
  86. function endpointInventory(rows) {
  87. const map = new Map();
  88. for (const row of rows) {
  89. if (!/^(GET|POST|PUT|PATCH|DELETE)\s/.test(row.spApi)) continue;
  90. if (!map.has(row.spApi)) map.set(row.spApi, row);
  91. }
  92. return [...map.values()].map((row) => {
  93. const [method, ...parts] = row.spApi.split(/\s+/);
  94. return { ...row, method, template: `/${parts.join(' ')}` };
  95. });
  96. }
  97. function replacePathParams(template, ids) {
  98. const pathParams = {};
  99. const path = template.replace(/\{([^}]+)\}/g, (_, name) => {
  100. const value = ids[name] ?? '0';
  101. pathParams[name] = value;
  102. return encodeURIComponent(value);
  103. });
  104. return { path, pathParams };
  105. }
  106. function queryFor(template, ids) {
  107. const pagination = { page: 1, pageSize: 1 };
  108. const byPath = {
  109. '/sp-product/v0/products': { ...pagination, scopeSet: 'productName' },
  110. '/sp-product/v0/skus': { ...pagination, productIdList: ids.productId, scopeSet: 'skuName' },
  111. '/sp-product/v0/sku-stocks': { skuIdList: ids.skuId },
  112. '/sp-product/v0/products/{productId}': { scene: 'pop' },
  113. '/sp-address/v0/areas': { level: 1 },
  114. };
  115. return byPath[template] || pagination;
  116. }
  117. function summarize(httpStatus, body) {
  118. const error = body?.errorList?.[0] || body?.error_response || body?.error || {};
  119. const code = String(error?.code ?? error?.numberCode ?? body?.code ?? '');
  120. const message = String(error?.message ?? error?.zh_desc ?? error?.msg ?? body?.message ?? '').slice(0, 300);
  121. let status = '返回请求错误';
  122. if (body?.success === true) status = '成功';
  123. else if (code === '99904030008') status = '无接口调用权限';
  124. else if (code === '99904030005') status = '要求云鼎调用';
  125. else if (code === '99904000016' || code === '19') status = 'Token 校验失败';
  126. else if (httpStatus >= 500) status = '服务端错误';
  127. return { status, code, message };
  128. }
  129. async function spRequest(config, token, method, template, ids) {
  130. const { path, pathParams } = replacePathParams(template, ids);
  131. const query = queryFor(template, ids);
  132. const timestamp = String(Date.now());
  133. const common = {
  134. 'X-JOS-App-Key': config.JD_APP_KEY,
  135. 'X-JOS-Access-Token': token,
  136. 'X-JOS-Timestamp': timestamp,
  137. };
  138. const headers = {
  139. ...common,
  140. 'X-JOS-Sign-Method': 'md5',
  141. 'X-JOS-Request-Identity': 'vender',
  142. 'X-JOS-Sign': signature(config.JD_APP_SECRET, { ...query, ...pathParams, ...common }),
  143. };
  144. const init = { method, headers };
  145. if (['POST', 'PUT', 'PATCH'].includes(method)) {
  146. headers['Content-Type'] = 'application/json';
  147. init.body = '{}';
  148. }
  149. const qs = queryString(query);
  150. const started = Date.now();
  151. try {
  152. const result = await requestJson(`${API_BASE}${path}${qs ? `?${qs}` : ''}`, init);
  153. return { ...summarize(result.httpStatus, result.body), httpStatus: result.httpStatus, durationMs: Date.now() - started };
  154. } catch (error) {
  155. return { status: '网络或超时错误', code: '', message: String(error?.message || error).slice(0, 300), httpStatus: 0, durationMs: Date.now() - started };
  156. }
  157. }
  158. const configText = await fs.readFile(CONFIG_FILE, 'utf8');
  159. const config = { ...parseLocalDotenv(configText), ...process.env };
  160. for (const name of ['JD_APP_KEY', 'JD_APP_SECRET', 'JD_SOURCE_PARSE_URL', 'JD_SOURCE_PARSE_APP_ID', 'JD_SOURCE_PARSE_MASTER_KEY']) {
  161. if (!config[name]) throw new Error(`缺少配置 ${name}`);
  162. }
  163. const tables = JSON.parse(await fs.readFile(OFFICIAL_TABLES, 'utf8'));
  164. const rows = officialRows(tables);
  165. const endpoints = endpointInventory(rows);
  166. const token = await fetchToken(config);
  167. // Use real product/SKU identifiers only to make read-only detail/list probes meaningful.
  168. const seedProduct = await spRequest(config, token, 'GET', '/sp-product/v0/products', { productId: '0', skuId: '0' });
  169. let ids = { productId: '10026650610613', skuId: '10116518781681' };
  170. if (seedProduct.status === '成功') {
  171. const timestamp = String(Date.now());
  172. const query = { page: 1, pageSize: 1, scopeSet: 'productName' };
  173. const common = { 'X-JOS-App-Key': config.JD_APP_KEY, 'X-JOS-Access-Token': token, 'X-JOS-Timestamp': timestamp };
  174. const headers = { ...common, 'X-JOS-Sign-Method': 'md5', 'X-JOS-Request-Identity': 'vender', 'X-JOS-Sign': signature(config.JD_APP_SECRET, { ...query, ...common }) };
  175. const productResponse = await requestJson(`${API_BASE}/sp-product/v0/products?${queryString(query)}`, { headers });
  176. const first = productResponse.body?.data?.[0] || {};
  177. ids.productId = String(first.productId ?? first.wareId ?? ids.productId);
  178. const skuTimestamp = String(Date.now());
  179. const skuQuery = { page: 1, pageSize: 1, scopeSet: 'skuName', productIdList: ids.productId };
  180. const skuCommon = { 'X-JOS-App-Key': config.JD_APP_KEY, 'X-JOS-Access-Token': token, 'X-JOS-Timestamp': skuTimestamp };
  181. const skuHeaders = { ...skuCommon, 'X-JOS-Sign-Method': 'md5', 'X-JOS-Request-Identity': 'vender', 'X-JOS-Sign': signature(config.JD_APP_SECRET, { ...skuQuery, ...skuCommon }) };
  182. const skuResponse = await requestJson(`${API_BASE}/sp-product/v0/skus?${queryString(skuQuery)}`, { headers: skuHeaders });
  183. const sku = skuResponse.body?.data?.[0] || {};
  184. ids.skuId = String(sku.skuId ?? ids.skuId);
  185. }
  186. const results = [];
  187. for (let index = 0; index < endpoints.length; index += 1) {
  188. const endpoint = endpoints[index];
  189. const result = await spRequest(config, token, endpoint.method, endpoint.template, ids);
  190. results.push({
  191. domain: endpoint.domain,
  192. subdomain: endpoint.subdomain,
  193. method: endpoint.method,
  194. path: endpoint.template,
  195. name: endpoint.spName,
  196. ...result,
  197. });
  198. console.log(`[${index + 1}/${endpoints.length}] ${endpoint.method} ${endpoint.template} -> ${result.httpStatus} ${result.code || result.status}`);
  199. await new Promise((resolve) => setTimeout(resolve, 120));
  200. }
  201. const output = {
  202. testedAt: new Date().toISOString(),
  203. officialSource: 'https://open.jd.com/v2/#/doc/api?apiCateId=200436&articleId=1100596&gwType=1',
  204. inventory: {
  205. endpointCount: endpoints.length,
  206. testedEndpointCount: endpoints.length,
  207. readEndpointCount: endpoints.filter((entry) => entry.method === 'GET').length,
  208. nonReadEndpointCount: endpoints.filter((entry) => entry.method !== 'GET').length,
  209. josMappingCount: rows.filter((row) => row.josApi).length,
  210. },
  211. results,
  212. };
  213. await fs.writeFile(OUTPUT_FILE, JSON.stringify(output, null, 2), 'utf8');
  214. console.log(`Saved ${OUTPUT_FILE.pathname}`);