| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- #!/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}`);
|