#!/usr/bin/env node import crypto from 'node:crypto'; import fs from 'node:fs/promises'; 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-jos-api-probe-2026-08-24.json', import.meta.url); 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 sign(secret, params) { const value = Object.keys(params).sort().map((key) => `${key}${params[key] ?? ''}`).join(''); return crypto.createHash('md5').update(`${secret}${value}${secret}`).digest('hex').toUpperCase(); } function jdTime() { const parts = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', }).formatToParts(new Date()); const get = (type) => parts.find((part) => part.type === type)?.value || ''; return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`; } 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 readJosMappings(tables) { const mappings = []; for (const table of tables.filter((entry) => entry.ti >= 4 && entry.ti <= 11)) { let current = null; for (const cells of table.rows.slice(1)) { if (/^(GET|POST|PUT|PATCH|DELETE)\s/.test(cells[2] || '')) { const [method, ...path] = cells[2].split(/\s+/); current = { domain: cells[0] || '', spMethod: method, spPath: `/${path.join(' ')}`, spName: cells[3] || '' }; } const josApi = cells[4] || ''; if (current && /^jingdong\./.test(josApi)) { mappings.push({ ...current, josApi, josName: cells[5] || '', josCategory: cells[6] || '' }); } } } const byMethod = new Map(); for (const entry of mappings) if (!byMethod.has(entry.josApi)) byMethod.set(entry.josApi, entry); const extras = [ { domain: '商品评价', spMethod: '', spPath: '', spName: '', josApi: 'jingdong.pop.PopCommentJsfService.getVenderCommentsForJos', josName: '商家商品评价查询', josCategory: '评价API', }, ]; for (const entry of extras) if (!byMethod.has(entry.josApi)) byMethod.set(entry.josApi, entry); return [...byMethod.values()]; } function summarize(httpStatus, body) { const error = body?.error_response || body?.error || {}; const code = String(error?.code ?? body?.code ?? ''); const message = String(error?.zh_desc ?? error?.en_desc ?? error?.message ?? body?.message ?? '') .replace(/appKey=[A-Z0-9]+/gi, 'appKey=') .replace(/ip:\s*[0-9a-f:.]+/gi, 'ip:') .slice(0, 300); const topKeys = body && typeof body === 'object' ? Object.keys(body) : []; let status = topKeys.some((key) => key !== 'error_response' && (/_response$/i.test(key) || /_responce$/i.test(key))) ? '方法响应' : '返回请求错误'; if (code === '19') status = 'Token 校验失败'; else if (code === '21') status = 'AppKey 禁用'; else if (code === '73') status = '要求云鼎调用'; else if (code === '24' || code === '88') status = '无接口调用权限'; else if (code === '61' || code === '400') status = '请求参数缺失'; else if (code === '65' || code === '67') status = '后端服务异常'; return { status, code, message, responseKeys: topKeys.slice(0, 5) }; } async function probe(config, token, method) { const common = { method, access_token: token, app_key: config.JD_APP_KEY, timestamp: jdTime(), v: '2.0', sign_method: 'md5', '360buy_param_json': '{}', }; const params = new URLSearchParams({ ...common, sign: sign(config.JD_APP_SECRET, common) }); const started = Date.now(); try { const result = await requestJson(`https://api.jd.com/routerjson?${params}`); return { httpStatus: result.httpStatus, durationMs: Date.now() - started, ...summarize(result.httpStatus, result.body) }; } catch (error) { return { httpStatus: 0, durationMs: Date.now() - started, status: '网络或超时错误', code: '', message: String(error?.message || error).slice(0, 300), responseKeys: [] }; } } const config = { ...parseLocalDotenv(await fs.readFile(CONFIG_FILE, 'utf8')), ...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 token = await fetchToken(config); const tables = JSON.parse(await fs.readFile(OFFICIAL_TABLES, 'utf8')); const mappings = readJosMappings(tables); const results = []; for (let index = 0; index < mappings.length; index += 1) { const mapping = mappings[index]; const result = await probe(config, token, mapping.josApi); results.push({ ...mapping, ...result }); console.log(`[${index + 1}/${mappings.length}] ${mapping.josApi} -> ${result.httpStatus} ${result.code || result.status}`); await new Promise((resolve) => setTimeout(resolve, 120)); } await fs.writeFile(OUTPUT_FILE, JSON.stringify({ testedAt: new Date().toISOString(), officialSource: 'https://open.jd.com/v2/#/doc/api?apiCateId=200436&articleId=1100596&gwType=1', methodCount: mappings.length, requestPayload: '{}', results, }, null, 2), 'utf8'); console.log(`Saved ${OUTPUT_FILE.pathname}`);