9b395f5cc3
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
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
189 lines
8.3 KiB
JavaScript
189 lines
8.3 KiB
JavaScript
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||
import { isRecord } from '@jackwener/opencli/utils';
|
||
import { assertAuthenticatedState, buildDetailUrl, buildProvenance, canonicalizeSellerUrl, cleanMultilineText, cleanText, extractLocation, extractMemberId, extractOfferId, extractShopId, gotoAndReadState, normalizePriceTiers, parseMoqText, parsePriceText, toNumber, uniqueNonEmpty, } from './shared.js';
|
||
function normalizeItemPayload(payload) {
|
||
const href = cleanText(payload.href);
|
||
const bodyText = cleanMultilineText(payload.bodyText);
|
||
const sellerName = cleanText(payload.seller?.companyName);
|
||
const sellerUrlRaw = cleanText(payload.seller?.winportUrl
|
||
?? payload.seller?.sellerWinportUrlMap?.defaultUrl
|
||
?? payload.seller?.sellerWinportUrlMap?.indexUrl);
|
||
const sellerUrl = canonicalizeSellerUrl(sellerUrlRaw);
|
||
const offerId = cleanText(String(payload.offerId ?? '')) || extractOfferId(href) || null;
|
||
const memberId = cleanText(payload.seller?.memberId) || extractMemberId(sellerUrlRaw || href) || null;
|
||
const shopId = extractShopId(sellerUrl ?? href);
|
||
const unit = cleanText(payload.trade?.unit);
|
||
const priceDisplay = cleanText(payload.trade?.priceDisplay);
|
||
const priceRange = parsePriceText(priceDisplay ? `¥${priceDisplay}` : bodyText);
|
||
const moqText = extractMoqText(bodyText, payload.trade?.beginAmount, unit);
|
||
const moq = parseMoqText(moqText);
|
||
const services = uniqueServices(payload);
|
||
const serviceBadges = uniqueNonEmpty(services.map((service) => cleanText(service.serviceName)));
|
||
const attributes = normalizeVisibleAttributes(payload.trade?.offerIDatacenterSellInfo);
|
||
const priceTiers = normalizePriceTiers(payload.trade?.offerPriceModel?.currentPrices ?? [], unit || null);
|
||
const images = uniqueNonEmpty([
|
||
...(payload.gallery?.mainImage ?? []),
|
||
...(payload.gallery?.offerImgList ?? []),
|
||
...((payload.gallery?.wlImageInfos ?? []).map((item) => item.fullPathImageURI ?? '')),
|
||
]);
|
||
const detailUrl = offerId ? buildDetailUrl(offerId) : href;
|
||
const provenance = buildProvenance(href || detailUrl);
|
||
return {
|
||
offer_id: offerId,
|
||
member_id: memberId,
|
||
shop_id: shopId,
|
||
title: cleanText(payload.offerTitle) || stripAlibabaSuffix(payload.title) || firstNonEmptyLine(bodyText) || null,
|
||
item_url: detailUrl,
|
||
main_images: images,
|
||
price_text: priceRange.price_text || null,
|
||
price_tiers: priceTiers,
|
||
currency: priceRange.currency,
|
||
moq_text: moq.moq_text || null,
|
||
moq_value: moq.moq_value,
|
||
seller_name: sellerName || null,
|
||
seller_url: sellerUrl,
|
||
shop_name: sellerName || null,
|
||
origin_place: extractLocation(bodyText),
|
||
delivery_days_text: extractDeliveryDaysText(bodyText, services, payload.shipping),
|
||
customization_text: extractKeywordLine(bodyText, ['来样定制', '来图定制', '支持定制', '可定制', '定制']),
|
||
private_label_text: extractKeywordLine(bodyText, ['贴牌', '贴标', '定制logo', '打logo', 'OEM', 'ODM']),
|
||
visible_attributes: attributes,
|
||
sales_text: extractSalesText(bodyText),
|
||
service_badges: serviceBadges,
|
||
stock_quantity: extractStockQuantity(bodyText),
|
||
...provenance,
|
||
};
|
||
}
|
||
function normalizeVisibleAttributes(raw) {
|
||
if (!isRecord(raw))
|
||
return [];
|
||
return Object.entries(raw)
|
||
.filter(([key, value]) => key !== 'sellPointModel' && cleanText(key) && cleanText(String(value)))
|
||
.map(([key, value]) => ({ key: cleanText(key), value: cleanText(String(value)) }));
|
||
}
|
||
function uniqueServices(payload) {
|
||
const combined = [
|
||
...(Array.isArray(payload.services) ? payload.services : []),
|
||
...(Array.isArray(payload.shipping?.protectionInfos) ? payload.shipping.protectionInfos : []),
|
||
...(Array.isArray(payload.shipping?.buyerProtectionModel) ? payload.shipping.buyerProtectionModel : []),
|
||
];
|
||
const seen = new Set();
|
||
const result = [];
|
||
for (const service of combined) {
|
||
const key = cleanText(service.serviceName);
|
||
if (!key || seen.has(key))
|
||
continue;
|
||
seen.add(key);
|
||
result.push(service);
|
||
}
|
||
return result;
|
||
}
|
||
function stripAlibabaSuffix(title) {
|
||
return cleanText(title).replace(/\s*-\s*阿里巴巴$/, '').trim();
|
||
}
|
||
function firstNonEmptyLine(text) {
|
||
return text.split('\n').map((line) => cleanText(line)).find(Boolean) ?? '';
|
||
}
|
||
function extractMoqText(bodyText, beginAmount, unit) {
|
||
const lineMatch = bodyText.match(/\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只)\s*起批/);
|
||
if (lineMatch)
|
||
return lineMatch[0];
|
||
const moqValue = toNumber(beginAmount);
|
||
if (moqValue !== null) {
|
||
return `${moqValue}${unit || ''}起批`;
|
||
}
|
||
return '';
|
||
}
|
||
function extractDeliveryDaysText(bodyText, services, shipping) {
|
||
const shippingText = cleanText(shipping?.deliveryLimitText) || cleanText(shipping?.logisticsText);
|
||
if (shippingText)
|
||
return shippingText;
|
||
const textMatch = bodyText.match(/\d+\s*(?:小时|天)(?:内)?发货/);
|
||
if (textMatch)
|
||
return textMatch[0];
|
||
const hourMatch = services.find((service) => typeof service.agreeDeliveryHours === 'number');
|
||
if (hourMatch && typeof hourMatch.agreeDeliveryHours === 'number') {
|
||
return `${hourMatch.agreeDeliveryHours}小时内发货`;
|
||
}
|
||
return null;
|
||
}
|
||
function extractKeywordLine(bodyText, keywords) {
|
||
const lines = bodyText.split('\n').map((line) => cleanText(line)).filter(Boolean);
|
||
for (const line of lines) {
|
||
if (keywords.some((keyword) => line.includes(keyword))) {
|
||
return line;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
function extractSalesText(bodyText) {
|
||
const match = bodyText.match(/(?:全网销量|已售)\s*\d+(?:\.\d+)?\+?\s*[件套个单]?/);
|
||
return match ? cleanText(match[0]) : null;
|
||
}
|
||
function extractStockQuantity(bodyText) {
|
||
const match = bodyText.match(/库存\s*(\d+)/);
|
||
return match ? Number.parseInt(match[1], 10) : null;
|
||
}
|
||
async function readItemPayload(page, itemUrl) {
|
||
const state = await gotoAndReadState(page, itemUrl, 2500, 'item');
|
||
assertAuthenticatedState(state, 'item');
|
||
const payload = await page.evaluate(`
|
||
(() => {
|
||
const root = window.context ?? {};
|
||
const model = root.result?.global?.globalData?.model ?? null;
|
||
const toJson = (value) => JSON.parse(JSON.stringify(value ?? null));
|
||
return {
|
||
href: window.location.href,
|
||
title: document.title || '',
|
||
bodyText: document.body ? document.body.innerText || '' : '',
|
||
offerTitle: model?.offerTitleModel?.subject ?? '',
|
||
offerId: model?.tradeModel?.offerId ?? '',
|
||
seller: toJson(model?.sellerModel),
|
||
trade: toJson(model?.tradeModel),
|
||
gallery: toJson(root.result?.data?.gallery?.fields ?? null),
|
||
shipping: toJson(root.result?.data?.shippingServices?.fields ?? null),
|
||
services: toJson(root.result?.data?.shippingServices?.fields?.protectionInfos ?? []),
|
||
};
|
||
})()
|
||
`);
|
||
const resolvedOfferId = cleanText(String(payload.offerId ?? '')) || extractOfferId(cleanText(payload.href));
|
||
if (!resolvedOfferId) {
|
||
throw new CommandExecutionError('1688 item page did not expose product context', '当前 tab 非商品详情上下文,请切到 detail.1688.com 商品页并重试');
|
||
}
|
||
return payload;
|
||
}
|
||
cli({
|
||
site: '1688',
|
||
name: 'item',
|
||
access: 'read',
|
||
description: '1688 商品详情(公开商品字段、价格阶梯、卖家基础信息)',
|
||
domain: 'www.1688.com',
|
||
strategy: Strategy.COOKIE,
|
||
navigateBefore: false,
|
||
args: [
|
||
{
|
||
name: 'input',
|
||
required: true,
|
||
positional: true,
|
||
help: '1688 商品 URL 或 offer ID(如 887904326744)',
|
||
},
|
||
],
|
||
columns: ['offer_id', 'title', 'price_text', 'moq_text', 'seller_name', 'origin_place'],
|
||
func: async (page, kwargs) => {
|
||
const itemUrl = buildDetailUrl(String(kwargs.input ?? ''));
|
||
const payload = await readItemPayload(page, itemUrl);
|
||
return [normalizeItemPayload(payload)];
|
||
},
|
||
});
|
||
export const __test__ = {
|
||
normalizeItemPayload,
|
||
normalizeVisibleAttributes,
|
||
stripAlibabaSuffix,
|
||
extractMoqText,
|
||
extractDeliveryDaysText,
|
||
extractKeywordLine,
|
||
extractSalesText,
|
||
extractStockQuantity,
|
||
};
|