#!/usr/bin/env node import crypto from 'node:crypto'; import fs from 'node:fs/promises'; const ROOT = new URL('../', import.meta.url); const CONFIG_FILE = new URL('../docs/JD-CONNECTION-CONFIG.local.md', import.meta.url); const OFFICIAL_TABLES = new URL('../logs/jd-official-upgrade-tables-2026-08-24.json', import.meta.url); const OUTPUT_FILE = new URL('../logs/jd-sp-api-probe-2026-08-24.json', import.meta.url); const API_BASE = 'https://api-cn.jd.com/rest'; function parseLocalDotenv(text) { const values = {}; for (const line of text.split(/\r?\n/)) { const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/); if (match) values[match[1]] = match[2].trim(); } return values; } function signature(secret, params) { const plain = Object.keys(params).sort().map((key) => `${key}${params[key] ?? ''}`).join(''); return crypto.createHash('md5').update(`${secret}${plain}${secret}`).digest('hex').toUpperCase(); } function queryString(query) { const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { params.set(key, Array.isArray(value) ? JSON.stringify(value) : String(value)); } return params.toString(); } async function requestJson(url, init = {}, timeoutMs = 30000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { ...init, signal: controller.signal }); const text = await response.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch { body = { raw: text.slice(0, 500) }; } return { httpStatus: response.status, body }; } finally { clearTimeout(timer); } } async function fetchToken(config) { const base = config.JD_SOURCE_PARSE_URL.replace(/\/+$/, ''); const params = new URLSearchParams({ where: JSON.stringify({ platform: 'jd', type: 'access_token' }), limit: '1', order: '-createdAt', }); for (let attempt = 1; attempt <= 8; attempt += 1) { const result = await requestJson(`${base}/classes/EcomAuth?${params}`, { headers: { 'X-Parse-Application-Id': config.JD_SOURCE_PARSE_APP_ID, 'X-Parse-Master-Key': config.JD_SOURCE_PARSE_MASTER_KEY, }, }); const token = result.body?.results?.[0]?.data?.access_token; if (token) return token; await new Promise((resolve) => setTimeout(resolve, 350 * attempt)); } throw new Error('EcomAuth 中没有可用京东 access_token'); } function officialRows(tables) { const rows = []; for (const table of tables.filter((entry) => entry.ti >= 4 && entry.ti <= 11)) { let inherited = ['', '', '', '']; for (const cells of table.rows.slice(1)) { const next = [...cells]; for (let i = 0; i < 4; i += 1) { if (next[i]) inherited[i] = next[i]; else next[i] = inherited[i]; } if (/^(GET|POST|PUT|PATCH|DELETE)\s/.test(next[2] || '')) inherited = next.slice(0, 4); rows.push({ domain: next[0] || '', subdomain: next[1] || '', spApi: next[2] || '', spName: next[3] || '', josApi: next[4] || '', josName: next[5] || '', josCategory: next[6] || '', }); } } return rows; } function endpointInventory(rows) { const map = new Map(); for (const row of rows) { if (!/^(GET|POST|PUT|PATCH|DELETE)\s/.test(row.spApi)) continue; if (!map.has(row.spApi)) map.set(row.spApi, row); } return [...map.values()].map((row) => { const [method, ...parts] = row.spApi.split(/\s+/); return { ...row, method, template: `/${parts.join(' ')}` }; }); } function replacePathParams(template, ids) { const pathParams = {}; const path = template.replace(/\{([^}]+)\}/g, (_, name) => { const value = ids[name] ?? '0'; pathParams[name] = value; return encodeURIComponent(value); }); return { path, pathParams }; } function queryFor(template, ids) { const pagination = { page: 1, pageSize: 1 }; const byPath = { '/sp-product/v0/products': { ...pagination, scopeSet: 'productName' }, '/sp-product/v0/skus': { ...pagination, productIdList: ids.productId, scopeSet: 'skuName' }, '/sp-product/v0/sku-stocks': { skuIdList: ids.skuId }, '/sp-product/v0/products/{productId}': { scene: 'pop' }, '/sp-address/v0/areas': { level: 1 }, }; return byPath[template] || pagination; } function summarize(httpStatus, body) { const error = body?.errorList?.[0] || body?.error_response || body?.error || {}; const code = String(error?.code ?? error?.numberCode ?? body?.code ?? ''); const message = String(error?.message ?? error?.zh_desc ?? error?.msg ?? body?.message ?? '').slice(0, 300); let status = '返回请求错误'; if (body?.success === true) status = '成功'; else if (code === '99904030008') status = '无接口调用权限'; else if (code === '99904030005') status = '要求云鼎调用'; else if (code === '99904000016' || code === '19') status = 'Token 校验失败'; else if (httpStatus >= 500) status = '服务端错误'; return { status, code, message }; } async function spRequest(config, token, method, template, ids) { const { path, pathParams } = replacePathParams(template, ids); const query = queryFor(template, ids); const timestamp = String(Date.now()); const common = { 'X-JOS-App-Key': config.JD_APP_KEY, 'X-JOS-Access-Token': token, 'X-JOS-Timestamp': timestamp, }; const headers = { ...common, 'X-JOS-Sign-Method': 'md5', 'X-JOS-Request-Identity': 'vender', 'X-JOS-Sign': signature(config.JD_APP_SECRET, { ...query, ...pathParams, ...common }), }; const init = { method, headers }; if (['POST', 'PUT', 'PATCH'].includes(method)) { headers['Content-Type'] = 'application/json'; init.body = '{}'; } const qs = queryString(query); const started = Date.now(); try { const result = await requestJson(`${API_BASE}${path}${qs ? `?${qs}` : ''}`, init); return { ...summarize(result.httpStatus, result.body), httpStatus: result.httpStatus, durationMs: Date.now() - started }; } catch (error) { return { status: '网络或超时错误', code: '', message: String(error?.message || error).slice(0, 300), httpStatus: 0, durationMs: Date.now() - started }; } } const configText = await fs.readFile(CONFIG_FILE, 'utf8'); const config = { ...parseLocalDotenv(configText), ...process.env }; for (const name of ['JD_APP_KEY', 'JD_APP_SECRET', 'JD_SOURCE_PARSE_URL', 'JD_SOURCE_PARSE_APP_ID', 'JD_SOURCE_PARSE_MASTER_KEY']) { if (!config[name]) throw new Error(`缺少配置 ${name}`); } const tables = JSON.parse(await fs.readFile(OFFICIAL_TABLES, 'utf8')); const rows = officialRows(tables); const endpoints = endpointInventory(rows); const token = await fetchToken(config); // Use real product/SKU identifiers only to make read-only detail/list probes meaningful. const seedProduct = await spRequest(config, token, 'GET', '/sp-product/v0/products', { productId: '0', skuId: '0' }); let ids = { productId: '10026650610613', skuId: '10116518781681' }; if (seedProduct.status === '成功') { const timestamp = String(Date.now()); const query = { page: 1, pageSize: 1, scopeSet: 'productName' }; const common = { 'X-JOS-App-Key': config.JD_APP_KEY, 'X-JOS-Access-Token': token, 'X-JOS-Timestamp': timestamp }; const headers = { ...common, 'X-JOS-Sign-Method': 'md5', 'X-JOS-Request-Identity': 'vender', 'X-JOS-Sign': signature(config.JD_APP_SECRET, { ...query, ...common }) }; const productResponse = await requestJson(`${API_BASE}/sp-product/v0/products?${queryString(query)}`, { headers }); const first = productResponse.body?.data?.[0] || {}; ids.productId = String(first.productId ?? first.wareId ?? ids.productId); const skuTimestamp = String(Date.now()); const skuQuery = { page: 1, pageSize: 1, scopeSet: 'skuName', productIdList: ids.productId }; const skuCommon = { 'X-JOS-App-Key': config.JD_APP_KEY, 'X-JOS-Access-Token': token, 'X-JOS-Timestamp': skuTimestamp }; const skuHeaders = { ...skuCommon, 'X-JOS-Sign-Method': 'md5', 'X-JOS-Request-Identity': 'vender', 'X-JOS-Sign': signature(config.JD_APP_SECRET, { ...skuQuery, ...skuCommon }) }; const skuResponse = await requestJson(`${API_BASE}/sp-product/v0/skus?${queryString(skuQuery)}`, { headers: skuHeaders }); const sku = skuResponse.body?.data?.[0] || {}; ids.skuId = String(sku.skuId ?? ids.skuId); } const results = []; for (let index = 0; index < endpoints.length; index += 1) { const endpoint = endpoints[index]; const result = await spRequest(config, token, endpoint.method, endpoint.template, ids); results.push({ domain: endpoint.domain, subdomain: endpoint.subdomain, method: endpoint.method, path: endpoint.template, name: endpoint.spName, ...result, }); console.log(`[${index + 1}/${endpoints.length}] ${endpoint.method} ${endpoint.template} -> ${result.httpStatus} ${result.code || result.status}`); await new Promise((resolve) => setTimeout(resolve, 120)); } const output = { testedAt: new Date().toISOString(), officialSource: 'https://open.jd.com/v2/#/doc/api?apiCateId=200436&articleId=1100596&gwType=1', inventory: { endpointCount: endpoints.length, testedEndpointCount: endpoints.length, readEndpointCount: endpoints.filter((entry) => entry.method === 'GET').length, nonReadEndpointCount: endpoints.filter((entry) => entry.method !== 'GET').length, josMappingCount: rows.filter((row) => row.josApi).length, }, results, }; await fs.writeFile(OUTPUT_FILE, JSON.stringify(output, null, 2), 'utf8'); console.log(`Saved ${OUTPUT_FILE.pathname}`);