| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155 |
- #!/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=<redacted>')
- .replace(/ip:\s*[0-9a-f:.]+/gi, 'ip:<redacted>')
- .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}`);
|