|
|
@@ -0,0 +1,122 @@
|
|
|
+#!/usr/bin/env node
|
|
|
+
|
|
|
+import crypto from 'node:crypto';
|
|
|
+
|
|
|
+const trim = (value) => String(value || '').replace(/\/+$/, '');
|
|
|
+const sourceHeaders = {
|
|
|
+ 'X-Parse-Application-Id': process.env.JD_SOURCE_PARSE_APP_ID,
|
|
|
+ 'X-Parse-Master-Key': process.env.JD_SOURCE_PARSE_MASTER_KEY,
|
|
|
+};
|
|
|
+const authQuery = new URLSearchParams({
|
|
|
+ where: JSON.stringify({ platform: 'jd', type: 'access_token' }),
|
|
|
+ limit: '1',
|
|
|
+ order: '-createdAt',
|
|
|
+});
|
|
|
+const productId = process.argv[2];
|
|
|
+if (!productId) throw new Error('usage: node tools/jd-detail-diagnostic.mjs <productId>');
|
|
|
+let successfulPayload = null;
|
|
|
+
|
|
|
+async function loadToken() {
|
|
|
+ const url = `${trim(process.env.JD_SOURCE_PARSE_URL)}/classes/EcomAuth?${authQuery}`;
|
|
|
+ for (let attempt = 1; attempt <= 4; attempt += 1) {
|
|
|
+ const response = await fetch(url, { headers: sourceHeaders });
|
|
|
+ const payload = await response.json().catch(() => null);
|
|
|
+ const transientUnauthorized = response.status === 403
|
|
|
+ && payload?.code === 119
|
|
|
+ && /unauthorized/i.test(String(payload?.error || ''));
|
|
|
+ if (response.ok) {
|
|
|
+ const accessToken = payload?.results?.[0]?.data?.access_token;
|
|
|
+ if (!accessToken) throw new Error('missing_token');
|
|
|
+ return accessToken;
|
|
|
+ }
|
|
|
+ if (!transientUnauthorized || attempt === 4) {
|
|
|
+ throw new Error(`source_parse_${response.status}`);
|
|
|
+ }
|
|
|
+ await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
|
|
|
+ }
|
|
|
+ throw new Error('source_parse_retry_exhausted');
|
|
|
+}
|
|
|
+
|
|
|
+const token = await loadToken();
|
|
|
+
|
|
|
+function signature(fields) {
|
|
|
+ const plain = Object.keys(fields).sort().map((key) => `${key}${fields[key] ?? ''}`).join('');
|
|
|
+ return crypto.createHash('md5')
|
|
|
+ .update(`${process.env.JD_APP_SECRET}${plain}${process.env.JD_APP_SECRET}`)
|
|
|
+ .digest('hex')
|
|
|
+ .toUpperCase();
|
|
|
+}
|
|
|
+
|
|
|
+async function probe(name, query, pathFields) {
|
|
|
+ const timestamp = String(Date.now());
|
|
|
+ const common = {
|
|
|
+ 'X-JOS-App-Key': process.env.JD_APP_KEY,
|
|
|
+ 'X-JOS-Access-Token': token,
|
|
|
+ 'X-JOS-Timestamp': timestamp,
|
|
|
+ };
|
|
|
+ const signed = { ...query, ...pathFields, ...common };
|
|
|
+ const headers = {
|
|
|
+ ...common,
|
|
|
+ 'X-JOS-Sign-Method': 'md5',
|
|
|
+ 'X-JOS-Request-Identity': 'vender',
|
|
|
+ 'X-JOS-Sign': signature(signed),
|
|
|
+ };
|
|
|
+ const queryString = new URLSearchParams(
|
|
|
+ Object.entries(query).map(([key, value]) => [key, String(value)]),
|
|
|
+ );
|
|
|
+ const response = await fetch(
|
|
|
+ `https://api-cn.jd.com/rest/sp-product/v0/products/${productId}?${queryString}`,
|
|
|
+ { headers },
|
|
|
+ );
|
|
|
+ let body = {};
|
|
|
+ try { body = await response.json(); } catch {}
|
|
|
+ if (response.ok && body?.success === true && successfulPayload == null) successfulPayload = body;
|
|
|
+ const error = body?.errorList?.[0] || body?.error || {};
|
|
|
+ const traceHeaders = {};
|
|
|
+ for (const [key, value] of response.headers) {
|
|
|
+ if (/request|trace/i.test(key)) traceHeaders[key] = value;
|
|
|
+ }
|
|
|
+ console.log(JSON.stringify({
|
|
|
+ name,
|
|
|
+ httpStatus: response.status,
|
|
|
+ success: body?.success ?? null,
|
|
|
+ errorCode: error?.code ?? body?.code ?? null,
|
|
|
+ errorMessage: error?.message ?? error?.details ?? body?.message ?? null,
|
|
|
+ signedFields: Object.keys(signed).sort(),
|
|
|
+ traceHeaders,
|
|
|
+ }, null, 2));
|
|
|
+}
|
|
|
+
|
|
|
+await probe('current_scene_without_path_field', { scene: 'pop' }, {});
|
|
|
+await probe('scene_with_productId_path_field', { scene: 'pop' }, { productId });
|
|
|
+await probe('request_json_with_path_field', { request: JSON.stringify({ scene: 'pop' }) }, { productId });
|
|
|
+await probe('request_json_without_path_field', { request: JSON.stringify({ scene: 'pop' }) }, {});
|
|
|
+
|
|
|
+function collectFieldPaths(value, prefix = '', paths = [], depth = 0) {
|
|
|
+ if (depth > 8 || paths.length >= 800 || value == null) return paths;
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+ paths.push({ path: `${prefix}[]`, type: 'Array', count: value.length });
|
|
|
+ if (value[0] !== undefined) collectFieldPaths(value[0], `${prefix}[]`, paths, depth + 1);
|
|
|
+ return paths;
|
|
|
+ }
|
|
|
+ if (typeof value !== 'object') {
|
|
|
+ paths.push({ path: prefix, type: typeof value });
|
|
|
+ return paths;
|
|
|
+ }
|
|
|
+ for (const [key, child] of Object.entries(value)) {
|
|
|
+ const path = prefix ? `${prefix}.${key}` : key;
|
|
|
+ if (child && typeof child === 'object') {
|
|
|
+ paths.push({ path, type: Array.isArray(child) ? 'Array' : 'Object' });
|
|
|
+ collectFieldPaths(child, path, paths, depth + 1);
|
|
|
+ } else {
|
|
|
+ paths.push({ path, type: child === null ? 'null' : typeof child });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return paths;
|
|
|
+}
|
|
|
+
|
|
|
+console.log(JSON.stringify({
|
|
|
+ successfulResponseFieldPaths: successfulPayload
|
|
|
+ ? collectFieldPaths(successfulPayload.data ?? successfulPayload)
|
|
|
+ : [],
|
|
|
+}, null, 2));
|