9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
731 lines
31 KiB
JavaScript
731 lines
31 KiB
JavaScript
/**
|
||
* 京东商品详情 — browser cookie, DOM scraping + evaluate.
|
||
*
|
||
* 依赖: 需要在 Chrome 已登录京东
|
||
* 用法: opencli jd item 100291143898
|
||
*/
|
||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||
function normalizePositiveInt(value, fallback) {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
|
||
}
|
||
function normalizeJdSkuInput(input) {
|
||
const text = String(input || '').trim();
|
||
const itemMatch = text.match(/item\.jd\.com\/(\d+)\.html/i);
|
||
if (itemMatch)
|
||
return itemMatch[1];
|
||
if (/^\d+$/.test(text))
|
||
return text;
|
||
const paramMatch = text.match(/(?:skuId|sku|wareId|productId)[=:](\d+)/i);
|
||
if (paramMatch)
|
||
return paramMatch[1];
|
||
return text;
|
||
}
|
||
function normalizeJdImageUrl(rawUrl) {
|
||
if (!rawUrl || typeof rawUrl !== 'string')
|
||
return '';
|
||
let url = rawUrl.trim();
|
||
if (!url)
|
||
return '';
|
||
if (url.startsWith('//'))
|
||
url = `https:${url}`;
|
||
if (!/^https?:\/\//.test(url))
|
||
return '';
|
||
return url;
|
||
}
|
||
function normalizeJdImageSize(url) {
|
||
return normalizeJdImageUrl(url)
|
||
.replace(/\/pcpubliccms\/s\d+x\d+_jfs\//, '/pcpubliccms/jfs/')
|
||
.replace(/\/(n\d+)\/s\d+x\d+_jfs\//, '/$1/jfs/')
|
||
.replace(/\/s\d+x\d+_jfs\//, '/jfs/');
|
||
}
|
||
function isJdMainImage(url) {
|
||
const normalized = normalizeJdImageSize(url);
|
||
return /360buyimg\.com\/(?:pcpubliccms|n\d+)\/jfs\//.test(normalized) &&
|
||
!/\/(?:s\d+x\d+_|n\d\/s\d+x\d+_)/.test(normalized) &&
|
||
!/\/(?:imgzone|sku|shaidan|popWaterMark|babel|jdcms|cms|ddimg|vc)\//.test(normalized);
|
||
}
|
||
function collectImageUrlsFrom(root, options = {}) {
|
||
if (!root)
|
||
return [];
|
||
const urls = [];
|
||
const ignoredContexts = '#spec-list, [class*="spec-list"], [class*="recommend"], [id*="recommend"], [class*="shaidan"], [id*="shaidan"], [class*="review"], [id*="review"], [class*="comment"], [id*="comment"], [class*="thumb"], [id*="thumb"], [class*="related"], [id*="related"]';
|
||
const ignoreContexts = Boolean(options.ignoreContexts);
|
||
const isIgnoredContext = (el) => {
|
||
if (!ignoreContexts)
|
||
return false;
|
||
try {
|
||
return !!el?.closest?.(ignoredContexts);
|
||
}
|
||
catch {
|
||
return false;
|
||
}
|
||
};
|
||
const pushUrlsFromText = (text) => {
|
||
for (const match of String(text || '').matchAll(/url\(["']?([^"')]+360buyimg\.com[^"')]+)["']?\)/g)) {
|
||
push(match[1]);
|
||
}
|
||
};
|
||
const push = (value) => {
|
||
const url = normalizeJdImageUrl(value);
|
||
if (url && url.includes('360buyimg.com'))
|
||
urls.push(url);
|
||
};
|
||
for (const img of root.querySelectorAll?.('img') || []) {
|
||
if (isIgnoredContext(img))
|
||
continue;
|
||
push(img.currentSrc || img.src);
|
||
push(img.getAttribute('data-src'));
|
||
push(img.getAttribute('data-lazy-img'));
|
||
push(img.getAttribute('data-lazyload'));
|
||
push(img.getAttribute('data-original'));
|
||
}
|
||
for (const source of root.querySelectorAll?.('source') || []) {
|
||
if (isIgnoredContext(source))
|
||
continue;
|
||
push(source.getAttribute('src'));
|
||
push(source.getAttribute('srcset')?.split(/\s+/)[0]);
|
||
push(source.getAttribute('data-src'));
|
||
push(source.getAttribute('data-srcset')?.split(/\s+/)[0]);
|
||
}
|
||
for (const el of root.querySelectorAll?.('[style*="360buyimg.com"]') || []) {
|
||
if (isIgnoredContext(el))
|
||
continue;
|
||
const style = el.getAttribute('style') || '';
|
||
pushUrlsFromText(style);
|
||
}
|
||
if (typeof getComputedStyle === 'function') {
|
||
const elements = [root, ...Array.from(root.querySelectorAll?.('*') || [])];
|
||
for (const el of elements) {
|
||
if (isIgnoredContext(el))
|
||
continue;
|
||
try {
|
||
const style = getComputedStyle(el);
|
||
pushUrlsFromText(style?.backgroundImage);
|
||
pushUrlsFromText(style?.background);
|
||
}
|
||
catch {
|
||
// ignore inaccessible/computed-style edge cases in the page context
|
||
}
|
||
}
|
||
}
|
||
return [...new Set(urls)];
|
||
}
|
||
function collectImageUrlsFromText(text) {
|
||
const urls = [];
|
||
const push = (value) => {
|
||
const url = normalizeJdImageUrl(value);
|
||
if (url && url.includes('360buyimg.com'))
|
||
urls.push(url);
|
||
};
|
||
for (const match of String(text || '').matchAll(/(?:https?:)?\/\/[^"'`\s<>)\\]+360buyimg\.com[^"'`\s<>)\\]*/g)) {
|
||
push(match[0]);
|
||
}
|
||
for (const match of String(text || '').matchAll(/url\(["']?([^"')]+360buyimg\.com[^"')]+)["']?\)/g)) {
|
||
push(match[1]);
|
||
}
|
||
return urls;
|
||
}
|
||
function collectImageUrlsFromFramesAndScripts() {
|
||
const urls = [];
|
||
for (const script of document.scripts || []) {
|
||
const text = script.textContent || '';
|
||
if (!/360buyimg\.com/.test(text))
|
||
continue;
|
||
urls.push(...collectImageUrlsFromText(text));
|
||
}
|
||
for (const iframe of document.querySelectorAll('iframe')) {
|
||
try {
|
||
const frameDoc = iframe.contentDocument || iframe.contentWindow?.document;
|
||
if (!frameDoc)
|
||
continue;
|
||
urls.push(...collectImageUrlsFrom(frameDoc.body || frameDoc.documentElement || frameDoc));
|
||
for (const script of frameDoc.scripts || []) {
|
||
const text = script.textContent || '';
|
||
if (!/360buyimg\.com/.test(text))
|
||
continue;
|
||
urls.push(...collectImageUrlsFromText(text));
|
||
}
|
||
}
|
||
catch {
|
||
// ignore cross-origin or not-yet-loaded iframe content
|
||
}
|
||
}
|
||
return [...new Set(urls)];
|
||
}
|
||
function collectImageUrlsFromPayload(payload, seen = new Set(), depth = 0) {
|
||
if (payload == null || depth > 4)
|
||
return [];
|
||
const urls = [];
|
||
const push = (value) => {
|
||
const url = normalizeJdImageUrl(value);
|
||
if (url && url.includes('360buyimg.com'))
|
||
urls.push(url);
|
||
};
|
||
if (typeof payload === 'string') {
|
||
urls.push(...collectImageUrlsFromText(payload));
|
||
return [...new Set(urls)];
|
||
}
|
||
if (typeof payload !== 'object')
|
||
return [];
|
||
if (seen.has(payload))
|
||
return [];
|
||
seen.add(payload);
|
||
if (Array.isArray(payload)) {
|
||
for (const item of payload)
|
||
urls.push(...collectImageUrlsFromPayload(item, seen, depth + 1));
|
||
return [...new Set(urls)];
|
||
}
|
||
for (const value of Object.values(payload)) {
|
||
if (typeof value === 'string') {
|
||
push(value);
|
||
urls.push(...collectImageUrlsFromText(value));
|
||
continue;
|
||
}
|
||
urls.push(...collectImageUrlsFromPayload(value, seen, depth + 1));
|
||
}
|
||
return [...new Set(urls)];
|
||
}
|
||
function collectImageUrlsFromPageDataObjects() {
|
||
const urls = [];
|
||
const keys = ['__NEXT_DATA__', '__NUXT__', '__INITIAL_STATE__', '__INITIAL_DATA__', '__APOLLO_STATE__', '__INITIAL_PROPS__', '__REDUX_STATE__', '__PAGE_DATA__', 'pageData', '__data__', 'detailData'];
|
||
for (const key of keys) {
|
||
try {
|
||
if (typeof globalThis !== 'undefined' && key in globalThis) {
|
||
urls.push(...collectImageUrlsFromPayload(globalThis[key]));
|
||
}
|
||
}
|
||
catch {
|
||
// ignore inaccessible globals
|
||
}
|
||
}
|
||
return [...new Set(urls)];
|
||
}
|
||
async function collectImageUrlsFromNetworkResources() {
|
||
const urls = [];
|
||
const entries = typeof performance !== 'undefined' && typeof performance.getEntriesByType === 'function'
|
||
? performance.getEntriesByType('resource')
|
||
: [];
|
||
const candidates = [...new Set(entries
|
||
.map((entry) => entry?.name)
|
||
.filter((name) => typeof name === 'string' && /(?:jd\.com|360buyimg\.com)/.test(name) && /(?:detail|desc|ware|item|product|sku)/i.test(name) && !/pc_item_getWareGraphic/i.test(name)))].slice(0, 12);
|
||
for (const url of candidates) {
|
||
try {
|
||
const resp = await fetch(url, { credentials: 'include' });
|
||
if (!resp.ok)
|
||
continue;
|
||
const text = await resp.text();
|
||
const contentType = resp.headers.get('content-type') || '';
|
||
if (/json/i.test(contentType) || /^\s*[\[{]/.test(text)) {
|
||
try {
|
||
urls.push(...collectImageUrlsFromPayload(JSON.parse(text)));
|
||
continue;
|
||
}
|
||
catch {
|
||
// fall back to text scanning
|
||
}
|
||
}
|
||
urls.push(...collectImageUrlsFromText(text));
|
||
}
|
||
catch {
|
||
// ignore request failures and cross-origin oddities
|
||
}
|
||
}
|
||
return [...new Set(urls)];
|
||
}
|
||
function collectImageUrlsFromWareGraphicText(text) {
|
||
let graphicContent = '';
|
||
try {
|
||
const payload = JSON.parse(String(text || ''));
|
||
graphicContent = payload?.data?.graphicContent || payload?.graphicContent || '';
|
||
}
|
||
catch {
|
||
graphicContent = String(text || '');
|
||
}
|
||
return [...new Set(collectImageUrlsFromText(graphicContent))];
|
||
}
|
||
async function collectImageUrlsFromWareGraphicResources(sku) {
|
||
const urls = [];
|
||
const entries = typeof performance !== 'undefined' && typeof performance.getEntriesByType === 'function'
|
||
? performance.getEntriesByType('resource')
|
||
: [];
|
||
const candidates = [...new Set(entries
|
||
.map((entry) => entry?.name)
|
||
.filter((name) => typeof name === 'string' && /pc_item_getWareGraphic/i.test(name)))];
|
||
if (sku) {
|
||
const body = encodeURIComponent(JSON.stringify({ skuId: String(sku) }));
|
||
candidates.unshift(`https://api.m.jd.com/client.action?appid=item-v3&functionId=pc_item_getWareGraphic&client=pc&clientVersion=1.0.0&body=${body}`);
|
||
}
|
||
for (const url of candidates) {
|
||
try {
|
||
const resp = await fetch(url, { credentials: 'include' });
|
||
if (!resp.ok)
|
||
continue;
|
||
urls.push(...collectImageUrlsFromWareGraphicText(await resp.text()));
|
||
}
|
||
catch {
|
||
// ignore request failures and keep DOM/script fallbacks available
|
||
}
|
||
}
|
||
return [...new Set(urls)];
|
||
}
|
||
async function collectImageUrlsFromFallbackSources() {
|
||
return [
|
||
...collectImageUrlsFromPageDataObjects(),
|
||
...await collectImageUrlsFromNetworkResources(),
|
||
];
|
||
}
|
||
function isJdDetailImage(url) {
|
||
const normalized = normalizeJdImageSize(url);
|
||
return /360buyimg\.com\/(?:imgzone|skuimg|babel|jdcms|cms|popWaterMark|vc|ddimg)\//.test(normalized) &&
|
||
!/\/shaidan\//.test(normalized) &&
|
||
!/\/(?:s\d+x\d+_|n\d\/s\d+x\d+_|sku)\//.test(normalized);
|
||
}
|
||
function isJdWareGraphicDetailImage(url) {
|
||
const raw = normalizeJdImageUrl(url);
|
||
if (/\/(?:s\d+x\d+_|n\d\/s\d+x\d+_|sku\/s\d+x\d+_)jfs\//.test(raw))
|
||
return false;
|
||
const normalized = normalizeJdImageSize(url);
|
||
return /360buyimg\.com\/sku\/jfs\//.test(normalized) &&
|
||
!/\/shaidan\//.test(normalized);
|
||
}
|
||
function rankJdDetailImage(url) {
|
||
const normalized = normalizeJdImageSize(url);
|
||
if (/\.jpe?g(?:\.avif)?(?:$|[?#])/.test(normalized))
|
||
return 0;
|
||
if (/\.(?:png|webp)(?:\.avif)?(?:$|[?#])/.test(normalized))
|
||
return 1;
|
||
if (/\.gif(?:$|[?#])/.test(normalized))
|
||
return 3;
|
||
if (/\.avif(?:$|[?#])/.test(normalized))
|
||
return 2;
|
||
return 4;
|
||
}
|
||
function orderJdDetailImages(urls, options = {}) {
|
||
const allowWareGraphicSku = Boolean(options.allowWareGraphicSku);
|
||
return [...new Set(urls)]
|
||
.filter((url) => isJdDetailImage(url) || (allowWareGraphicSku && isJdWareGraphicDetailImage(url)))
|
||
.map((url, index) => ({ url, index, rank: rankJdDetailImage(url) }))
|
||
.sort((a, b) => a.rank - b.rank || a.index - b.index)
|
||
.map((item) => item.url);
|
||
}
|
||
function extractDetailImagesFromDom(maxImages) {
|
||
const detailTitleParent = document.querySelector('#SPXQ-title')?.parentElement;
|
||
const safeDetailTitleParent = detailTitleParent && detailTitleParent !== document.body && detailTitleParent !== document.documentElement
|
||
? detailTitleParent
|
||
: null;
|
||
const selectorRoots = [
|
||
'#J-detail',
|
||
'#J-detail-content',
|
||
'#detail',
|
||
'.detail',
|
||
'.detail-content',
|
||
'.detail-content-wrap',
|
||
'.ssd-module-wrap',
|
||
'#SPXQ-title + *',
|
||
];
|
||
const scopedRoots = [
|
||
safeDetailTitleParent,
|
||
...selectorRoots.flatMap((selector) => Array.from(document.querySelectorAll(selector))),
|
||
].filter((root) => root && root !== document.body && root !== document.documentElement);
|
||
const scoped = [
|
||
...scopedRoots.flatMap((root) => collectImageUrlsFrom(root, { ignoreContexts: true })),
|
||
...collectImageUrlsFromFramesAndScripts(),
|
||
];
|
||
return orderJdDetailImages(scoped).slice(0, maxImages);
|
||
}
|
||
async function extractDetailImagesFromPage(maxImages, sku) {
|
||
const scoped = extractDetailImagesFromDom(maxImages);
|
||
const fallback = await collectImageUrlsFromFallbackSources();
|
||
const wareGraphic = await collectImageUrlsFromWareGraphicResources(sku);
|
||
const regularImages = orderJdDetailImages([...scoped, ...fallback]);
|
||
const wareGraphicImages = orderJdDetailImages(wareGraphic, { allowWareGraphicSku: true });
|
||
return orderJdDetailImages([...regularImages, ...wareGraphicImages], { allowWareGraphicSku: true }).slice(0, maxImages);
|
||
}
|
||
function getJdDetailScrollSnapshot(maxImages) {
|
||
const doc = document.scrollingElement || document.documentElement || document.body;
|
||
const scrollY = window.scrollY || window.pageYOffset || doc?.scrollTop || 0;
|
||
const viewportHeight = window.innerHeight || document.documentElement?.clientHeight || 0;
|
||
const scrollHeight = Math.max(doc?.scrollHeight || 0, document.documentElement?.scrollHeight || 0, document.body?.scrollHeight || 0);
|
||
return {
|
||
detailImageCount: extractDetailImagesFromDom(maxImages).length,
|
||
scrollY,
|
||
viewportHeight,
|
||
scrollHeight,
|
||
nearBottom: scrollY + viewportHeight >= scrollHeight - 120,
|
||
};
|
||
}
|
||
function scrollJdDetailStep() {
|
||
const step = Math.max(900, Math.floor((window.innerHeight || 900) * 0.9));
|
||
window.scrollBy(0, step);
|
||
return step;
|
||
}
|
||
async function scrollJdDetailIntoView() {
|
||
const tab = document.querySelector('#SPXQ-tab-column');
|
||
const title = document.querySelector('#SPXQ-title');
|
||
if (tab)
|
||
tab.click();
|
||
title?.scrollIntoView({ block: 'start' });
|
||
const step = Math.max(900, Math.floor((window.innerHeight || 900) * 0.9));
|
||
for (let i = 0; i < 2; i++) {
|
||
window.scrollBy(0, step);
|
||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||
}
|
||
}
|
||
function extractMainImages(maxImages) {
|
||
const roots = [
|
||
document.querySelector('._gallery_116km_1'),
|
||
...Array.from(document.querySelectorAll('[class*="_gallery_"]')),
|
||
document.querySelector('.preview-wrap'),
|
||
document.querySelector('#spec-img')?.parentElement,
|
||
].filter(Boolean);
|
||
const urls = roots.flatMap((root) => collectImageUrlsFrom(root).map(normalizeJdImageSize));
|
||
return [...new Set(urls)]
|
||
.filter(isJdMainImage)
|
||
.slice(0, maxImages);
|
||
}
|
||
function extractAvifImages(imageUrls, maxImages) {
|
||
const unique = [...new Set(imageUrls.map(normalizeJdImageSize).filter(Boolean))];
|
||
return unique
|
||
.filter((url) => url.includes('.avif') && isJdDetailImage(url))
|
||
.slice(0, maxImages);
|
||
}
|
||
function extractPriceFromPayload(payload) {
|
||
const items = Array.isArray(payload) ? payload : [];
|
||
const item = items.find((entry) => entry && typeof entry === 'object');
|
||
for (const key of ['p', 'op', 'm']) {
|
||
const value = item?.[key];
|
||
if (value && value !== '-1.00')
|
||
return String(value);
|
||
}
|
||
return '';
|
||
}
|
||
function normalizePriceText(text) {
|
||
const match = String(text || '').replace(/\s+/g, '').match(/(?:¥|¥)?(\d{2,7}(?:\.\d{1,2})?)/);
|
||
return match ? match[1] : '';
|
||
}
|
||
function extractPriceFromDom(sku) {
|
||
const selectors = [
|
||
`.J-p-${sku}`,
|
||
'[class*="price"] [class*="num"]',
|
||
'[class*="price"]',
|
||
'.p-price strong',
|
||
'.price.jd-price',
|
||
];
|
||
for (const selector of selectors) {
|
||
for (const el of document.querySelectorAll(selector)) {
|
||
const price = normalizePriceText(el.textContent || '');
|
||
if (price)
|
||
return price;
|
||
}
|
||
}
|
||
for (const el of document.querySelectorAll('span, strong, div')) {
|
||
const text = (el.textContent || '').trim();
|
||
if (!/预售价|到手价|秒杀价|京东价|¥|¥/.test(text))
|
||
continue;
|
||
const direct = normalizePriceText(text);
|
||
if (direct)
|
||
return direct;
|
||
const parentPrice = normalizePriceText(el.parentElement?.textContent || '');
|
||
if (parentPrice)
|
||
return parentPrice;
|
||
}
|
||
return '';
|
||
}
|
||
async function fetchJdPrice(sku) {
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||
try {
|
||
const resp = await fetch(`https://p.3.cn/prices/mgets?skuIds=J_${encodeURIComponent(sku)}&type=1`, {
|
||
credentials: 'include',
|
||
signal: controller.signal,
|
||
});
|
||
if (!resp.ok)
|
||
return '';
|
||
return extractPriceFromPayload(await resp.json());
|
||
}
|
||
catch {
|
||
return '';
|
||
}
|
||
finally {
|
||
clearTimeout(timeout);
|
||
}
|
||
}
|
||
function extractSpecsFromText(text) {
|
||
const specs = {};
|
||
const lines = String(text || '').split('\n').map((line) => line.trim()).filter(Boolean);
|
||
const allowedKeys = new Set(['品牌', '商品名称', '商品编号', '商品毛重', '商品产地', '货号', '类型', '能效等级', '洗涤容量', '烘干容量', '排水方式', '颜色', '型号', '系列', '系列品', '款式', '版本', '规格', '容量']);
|
||
const setSpec = (key, val) => {
|
||
const normalizedKey = String(key || '').trim().replace(/[::]+$/, '');
|
||
const normalizedVal = String(val || '').trim();
|
||
if (!allowedKeys.has(normalizedKey))
|
||
return;
|
||
if (!normalizedVal || normalizedVal.length > 120)
|
||
return;
|
||
if (/^(服务|支付定金|加入购物车|立即购买|首页|购物车|我的|客服|品牌闪购|以旧换新)$/.test(normalizedVal))
|
||
return;
|
||
if (!specs[normalizedKey])
|
||
specs[normalizedKey] = normalizedVal;
|
||
};
|
||
for (const line of lines) {
|
||
const compactMatch = line.match(/^([^::]{1,12})[::]\s*(.{1,120})$/);
|
||
if (compactMatch) {
|
||
setSpec(compactMatch[1], compactMatch[2]);
|
||
continue;
|
||
}
|
||
}
|
||
for (let i = 0; i < lines.length - 1; i++) {
|
||
const key = lines[i].replace(/[::]+$/, '');
|
||
const val = lines[i + 1];
|
||
if (allowedKeys.has(key) && !allowedKeys.has(val)) {
|
||
setSpec(key, val);
|
||
}
|
||
}
|
||
return specs;
|
||
}
|
||
function extractSpecs() {
|
||
const specs = {};
|
||
const setSpec = (label, value) => {
|
||
const normalizedLabel = String(label || '').trim().replace(/[::]+$/, '');
|
||
const normalizedValue = String(value || '').replace(/\s+/g, ' ').trim();
|
||
if (!normalizedLabel || !normalizedValue)
|
||
return;
|
||
specs[normalizedLabel] = normalizedValue;
|
||
};
|
||
const selectedText = (root) => {
|
||
const selected = root.querySelector('.specification-item-sku--selected, .specification-series-item--selected, .selected, [class*="selected"]');
|
||
if (!selected)
|
||
return '';
|
||
return selected.querySelector('[class*="text"]')?.textContent?.trim() ||
|
||
selected.textContent?.trim() ||
|
||
selected.querySelector('img')?.getAttribute('alt')?.trim() ||
|
||
'';
|
||
};
|
||
for (const el of document.querySelectorAll('.specification-series-layout')) {
|
||
const label = el.querySelector('.layout-label')?.textContent?.trim();
|
||
setSpec(label, selectedText(el));
|
||
}
|
||
for (const el of document.querySelectorAll('.specification-group')) {
|
||
const label = el.querySelector('.specification-label, .specification-group-label, .label')?.textContent?.trim();
|
||
setSpec(label, selectedText(el));
|
||
}
|
||
const attrsRoot = document.querySelector('#SPXQ-title')?.parentElement?.querySelector('.attrs') ||
|
||
document.querySelector('#parameter2') ||
|
||
document.querySelector('.Ptable');
|
||
if (attrsRoot) {
|
||
Object.assign(specs, extractSpecsFromText(attrsRoot.innerText || attrsRoot.textContent || ''));
|
||
}
|
||
return specs;
|
||
}
|
||
function detectJdPageState(expectedSku) {
|
||
const href = location.href;
|
||
const title = document.title || '';
|
||
const bodyText = document.body?.innerText || document.body?.textContent || '';
|
||
const hasProductMarker = Boolean(document.querySelector('.product-title, .sku-title, #spec-list, #J-detail, #SPXQ-title, [class*="_gallery_"]'));
|
||
const text = `${title}\n${bodyText}`;
|
||
const isLoginPage = /passport\.jd\.com|\/login\.aspx/.test(href) || /京东-欢迎登录|京东登录/.test(title);
|
||
const hasSecurityChallenge = /risk_handler|安全验证|安全校验|完成安全验证|滑块|captcha|访问过于频繁|验证中心|京东验证/.test(`${href}\n${text}`);
|
||
const loginOnlyWithoutProduct = /请登录|登录/.test(text) && !hasProductMarker;
|
||
const looksBlocked = isLoginPage || hasSecurityChallenge || loginOnlyWithoutProduct;
|
||
const onExpectedItemUrl = new RegExp(`item\.jd\.com/${expectedSku}\.html`).test(href);
|
||
return {
|
||
href,
|
||
title,
|
||
isProductPage: hasProductMarker && onExpectedItemUrl && !looksBlocked,
|
||
hasProductMarker,
|
||
onExpectedItemUrl,
|
||
looksBlocked,
|
||
isLoginPage,
|
||
hasSecurityChallenge,
|
||
};
|
||
}
|
||
cli({
|
||
site: 'jd',
|
||
name: 'item',
|
||
access: 'read',
|
||
description: '京东商品详情(价格、店铺、规格参数、主图、详情图)',
|
||
domain: 'item.jd.com',
|
||
strategy: Strategy.COOKIE,
|
||
args: [
|
||
{
|
||
name: 'sku',
|
||
required: true,
|
||
positional: true,
|
||
help: '商品 SKU ID(如 100291143898)',
|
||
},
|
||
{
|
||
name: 'images',
|
||
type: 'int',
|
||
default: 200,
|
||
help: '图片数量上限(默认200)',
|
||
},
|
||
],
|
||
columns: ['title', 'price', 'shop', 'specs', 'mainImages', 'detailImages'],
|
||
func: async (page, kwargs) => {
|
||
const sku = normalizeJdSkuInput(kwargs.sku);
|
||
const maxImages = normalizePositiveInt(kwargs.images, 200);
|
||
const url = `https://item.jd.com/${sku}.html`;
|
||
const currentHref = await page.evaluate(`location.href`).catch(() => '');
|
||
if (!currentHref.includes(`item.jd.com/${sku}.html`)) {
|
||
await page.goto(url, { waitUntil: 'load' });
|
||
await page.wait(2);
|
||
}
|
||
const initialState = await page.evaluate(`(() => {
|
||
const detectJdPageState = ${detectJdPageState.toString()};
|
||
return detectJdPageState(${JSON.stringify(sku)});
|
||
})()`).catch(() => null);
|
||
if (!initialState?.looksBlocked) {
|
||
await page.evaluate(`
|
||
(async () => {
|
||
const scrollJdDetailIntoView = ${scrollJdDetailIntoView.toString()};
|
||
return scrollJdDetailIntoView();
|
||
})()
|
||
`);
|
||
await page.wait(1.5);
|
||
let previousDetailImageCount = -1;
|
||
let stableRounds = 0;
|
||
for (let i = 0; i < 30; i++) {
|
||
const snapshot = await page.evaluate(`(() => {
|
||
const normalizeJdImageUrl = ${normalizeJdImageUrl.toString()};
|
||
const normalizeJdImageSize = ${normalizeJdImageSize.toString()};
|
||
const collectImageUrlsFrom = ${collectImageUrlsFrom.toString()};
|
||
const collectImageUrlsFromText = ${collectImageUrlsFromText.toString()};
|
||
const collectImageUrlsFromFramesAndScripts = ${collectImageUrlsFromFramesAndScripts.toString()};
|
||
const collectImageUrlsFromPayload = ${collectImageUrlsFromPayload.toString()};
|
||
const collectImageUrlsFromPageDataObjects = ${collectImageUrlsFromPageDataObjects.toString()};
|
||
const collectImageUrlsFromNetworkResources = ${collectImageUrlsFromNetworkResources.toString()};
|
||
const collectImageUrlsFromWareGraphicText = ${collectImageUrlsFromWareGraphicText.toString()};
|
||
const collectImageUrlsFromWareGraphicResources = ${collectImageUrlsFromWareGraphicResources.toString()};
|
||
const collectImageUrlsFromFallbackSources = ${collectImageUrlsFromFallbackSources.toString()};
|
||
const isJdDetailImage = ${isJdDetailImage.toString()};
|
||
const isJdWareGraphicDetailImage = ${isJdWareGraphicDetailImage.toString()};
|
||
const rankJdDetailImage = ${rankJdDetailImage.toString()};
|
||
const orderJdDetailImages = ${orderJdDetailImages.toString()};
|
||
const extractDetailImagesFromDom = ${extractDetailImagesFromDom.toString()};
|
||
const extractDetailImagesFromPage = ${extractDetailImagesFromPage.toString()};
|
||
const getJdDetailScrollSnapshot = ${getJdDetailScrollSnapshot.toString()};
|
||
const scrollJdDetailIntoView = ${scrollJdDetailIntoView.toString()};
|
||
if (document.querySelector('#SPXQ-title') && window.scrollY < 1000) {
|
||
return scrollJdDetailIntoView().then(() => getJdDetailScrollSnapshot(${maxImages}));
|
||
}
|
||
return getJdDetailScrollSnapshot(${maxImages});
|
||
})()`).catch(() => null);
|
||
if (!snapshot)
|
||
break;
|
||
stableRounds = snapshot.detailImageCount === previousDetailImageCount ? stableRounds + 1 : 0;
|
||
previousDetailImageCount = snapshot.detailImageCount;
|
||
if (snapshot.nearBottom && stableRounds >= 2)
|
||
break;
|
||
await page.evaluate(`(() => {
|
||
const scrollJdDetailStep = ${scrollJdDetailStep.toString()};
|
||
return scrollJdDetailStep();
|
||
})()`);
|
||
await page.wait(0.8);
|
||
}
|
||
}
|
||
const data = await page.evaluate(`
|
||
(async () => {
|
||
const maxImg = ${maxImages};
|
||
const normalizeJdImageUrl = ${normalizeJdImageUrl.toString()};
|
||
const normalizeJdImageSize = ${normalizeJdImageSize.toString()};
|
||
const isJdMainImage = ${isJdMainImage.toString()};
|
||
const collectImageUrlsFrom = ${collectImageUrlsFrom.toString()};
|
||
const collectImageUrlsFromText = ${collectImageUrlsFromText.toString()};
|
||
const collectImageUrlsFromFramesAndScripts = ${collectImageUrlsFromFramesAndScripts.toString()};
|
||
const collectImageUrlsFromPayload = ${collectImageUrlsFromPayload.toString()};
|
||
const collectImageUrlsFromPageDataObjects = ${collectImageUrlsFromPageDataObjects.toString()};
|
||
const collectImageUrlsFromNetworkResources = ${collectImageUrlsFromNetworkResources.toString()};
|
||
const collectImageUrlsFromWareGraphicText = ${collectImageUrlsFromWareGraphicText.toString()};
|
||
const collectImageUrlsFromWareGraphicResources = ${collectImageUrlsFromWareGraphicResources.toString()};
|
||
const collectImageUrlsFromFallbackSources = ${collectImageUrlsFromFallbackSources.toString()};
|
||
const isJdDetailImage = ${isJdDetailImage.toString()};
|
||
const isJdWareGraphicDetailImage = ${isJdWareGraphicDetailImage.toString()};
|
||
const rankJdDetailImage = ${rankJdDetailImage.toString()};
|
||
const orderJdDetailImages = ${orderJdDetailImages.toString()};
|
||
const extractMainImages = ${extractMainImages.toString()};
|
||
const extractDetailImagesFromDom = ${extractDetailImagesFromDom.toString()};
|
||
const extractDetailImagesFromPage = ${extractDetailImagesFromPage.toString()};
|
||
const extractPriceFromPayload = ${extractPriceFromPayload.toString()};
|
||
const fetchJdPrice = ${fetchJdPrice.toString()};
|
||
const extractSpecsFromText = ${extractSpecsFromText.toString()};
|
||
const extractSpecs = ${extractSpecs.toString()};
|
||
const detectJdPageState = ${detectJdPageState.toString()};
|
||
const pageState = detectJdPageState(${JSON.stringify(sku)});
|
||
const normalizePriceText = ${normalizePriceText.toString()};
|
||
const extractPriceFromDom = ${extractPriceFromDom.toString()};
|
||
const apiPrice = await fetchJdPrice(${JSON.stringify(sku)});
|
||
const domPrice = extractPriceFromDom(${JSON.stringify(sku)});
|
||
const price = apiPrice || domPrice || 'not found';
|
||
|
||
const title = document.querySelector('.product-title')?.textContent?.trim() ||
|
||
document.querySelector('.sku-title')?.textContent?.trim() ||
|
||
document.title.split('-')[0].trim();
|
||
|
||
const shop = document.querySelector('.J-shop-name')?.textContent?.trim() ||
|
||
document.querySelector('.top-name')?.textContent?.trim() ||
|
||
document.querySelector('[class*="shop"] [class*="name"]')?.textContent?.trim() ||
|
||
'京东自营';
|
||
|
||
const allImgs = Array.from(document.querySelectorAll('img[src*="360buyimg.com"]'));
|
||
const srcs = allImgs.map(img => img.src).filter(Boolean);
|
||
|
||
const mainImages = extractMainImages(maxImg);
|
||
const detailImages = await extractDetailImagesFromPage(maxImg, ${JSON.stringify(sku)});
|
||
const specs = extractSpecs();
|
||
|
||
const result = { title, price, shop, specs, mainImages, detailImages, totalImages: new Set(srcs).size, pageState };
|
||
if (!pageState.isProductPage) {
|
||
result.error = pageState.looksBlocked
|
||
? 'JD page is blocked by login/security verification'
|
||
: 'JD product page was not loaded';
|
||
result.pageState = pageState;
|
||
}
|
||
return result;
|
||
})()
|
||
`);
|
||
if (data?.error) {
|
||
if (data?.pageState?.looksBlocked) {
|
||
throw new AuthRequiredError('item.jd.com', data.error);
|
||
}
|
||
throw new CommandExecutionError(data.error);
|
||
}
|
||
if (maxImages > 0 && data?.pageState?.isProductPage && (!Array.isArray(data.detailImages) || data.detailImages.length === 0)) {
|
||
throw new CommandExecutionError('JD item detail images were not found', 'The product page loaded, but no detail images were detected from DOM, scripts, frames, page data, or WareGraphic fallback.');
|
||
}
|
||
return [data];
|
||
},
|
||
});
|
||
export const __test__ = {
|
||
normalizePositiveInt,
|
||
normalizeJdSkuInput,
|
||
normalizeJdImageUrl,
|
||
normalizeJdImageSize,
|
||
isJdMainImage,
|
||
collectImageUrlsFrom,
|
||
collectImageUrlsFromText,
|
||
collectImageUrlsFromFramesAndScripts,
|
||
collectImageUrlsFromPayload,
|
||
collectImageUrlsFromPageDataObjects,
|
||
collectImageUrlsFromNetworkResources,
|
||
collectImageUrlsFromWareGraphicText,
|
||
collectImageUrlsFromWareGraphicResources,
|
||
collectImageUrlsFromFallbackSources,
|
||
isJdDetailImage,
|
||
isJdWareGraphicDetailImage,
|
||
rankJdDetailImage,
|
||
orderJdDetailImages,
|
||
getJdDetailScrollSnapshot,
|
||
scrollJdDetailStep,
|
||
extractMainImages,
|
||
extractDetailImagesFromDom,
|
||
extractDetailImagesFromPage,
|
||
extractAvifImages,
|
||
extractPriceFromPayload,
|
||
normalizePriceText,
|
||
extractPriceFromDom,
|
||
extractSpecsFromText,
|
||
extractSpecs,
|
||
detectJdPageState,
|
||
};
|