feat: 试用过程中的细节优化
This commit is contained in:
@@ -47,11 +47,11 @@ export default defineBackground(() => {
|
||||
|
||||
// 通用文本代理:采集引擎拉跨域资源(1688 详情 CDN / mtop API)
|
||||
if (msg?.action === 'fetchText') {
|
||||
const headers: Record<string, string> = {};
|
||||
if (msg.referer) headers['Referer'] = msg.referer;
|
||||
fetch(msg.url, {
|
||||
headers,
|
||||
credentials: msg.credentials ? 'include' : 'omit',
|
||||
// Referer 是受保护请求头,不能通过 headers 设置;使用 Fetch 的标准选项。
|
||||
referrer: typeof msg.referer === 'string' ? msg.referer : undefined,
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
@@ -300,6 +300,11 @@ const App: React.FC = () => {
|
||||
}
|
||||
setReporting(true);
|
||||
try {
|
||||
// 上报遵循采集图片区的勾选状态:勾选了就只传勾选项;未勾选则全量传。
|
||||
// 避免详情图过多时超过服务端的单次素材上限,同时保留“全不选 = 全量”的便捷行为。
|
||||
const imagesToReport = selectedKeys.size > 0
|
||||
? allImages.filter(img => selectedKeys.has(img.key))
|
||||
: allImages;
|
||||
const data = await send<{ product_id: string }>('reportProduct', {
|
||||
reportBaseUrl: settings.reportBaseUrl,
|
||||
payload: {
|
||||
@@ -310,8 +315,7 @@ const App: React.FC = () => {
|
||||
collectedAt: result.scannedAt,
|
||||
},
|
||||
texts: editedTexts(),
|
||||
// 全量上报采集图(main/sku/detail/video),试算页需要完整素材
|
||||
images: result.images.map(img => ({
|
||||
images: imagesToReport.map(img => ({
|
||||
groupKey: img.groupKey,
|
||||
groupName: img.groupName,
|
||||
variantName: img.variantName ?? null,
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface State1688 {
|
||||
videos: Array<{ url: string; cover?: string }>;
|
||||
skus: Sku1688[];
|
||||
params: Array<{ key: string; value: string }>;
|
||||
detailUrl?: string;
|
||||
}
|
||||
|
||||
/** 深度查找指定键(BFS + 访问标记 + 节点数上限,防大对象拖死) */
|
||||
@@ -150,6 +151,13 @@ export function extract1688State(context: unknown): State1688 | null {
|
||||
temp.topCategoryId ? String(temp.topCategoryId) : '',
|
||||
].filter(Boolean);
|
||||
|
||||
// 图文详情位于独立 CDN,SSR 直接给出地址;无需点击标签或滚动页面。
|
||||
const description = deepFind(context, 'description');
|
||||
const candidateDetailUrl = description?.fields?.detailUrl ?? description?.detailUrl;
|
||||
const detailUrl = typeof candidateDetailUrl === 'string' && /^https:\/\//.test(candidateDetailUrl)
|
||||
? candidateDetailUrl
|
||||
: undefined;
|
||||
|
||||
if (!title && mainImages.length === 0 && skus.length === 0) return null;
|
||||
|
||||
return {
|
||||
@@ -164,5 +172,6 @@ export function extract1688State(context: unknown): State1688 | null {
|
||||
videos,
|
||||
skus,
|
||||
params,
|
||||
detailUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 图文详情资源提取。接口可返回 HTML,也可返回 `var desc = '...'` 形式的脚本;
|
||||
* 本模块只解析字符串和 HTML,绝不执行远端脚本。
|
||||
*/
|
||||
import { dedupeKey, pickBestFromSrcset, toOriginalUrl } from './url';
|
||||
import type { ImageMaterial } from './image';
|
||||
import type { SiteProfile } from '../profiles/types';
|
||||
|
||||
const IMAGE_ATTRS = ['src', 'data-src', 'data-lazyload-src', 'data-original', 'data-lazy', 'data-ks-lazyload'];
|
||||
|
||||
function normalizeEscapedText(raw: string): string {
|
||||
return raw
|
||||
.replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||
.replace(/\\x([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||
.replace(/\\\//g, '/');
|
||||
}
|
||||
|
||||
function normalizeUrl(raw: string, baseUrl: string): string | null {
|
||||
const value = raw.trim().replace(/^['"]|['"]$/g, '');
|
||||
if (!value || /^data:|^javascript:|^blob:/i.test(value)) return null;
|
||||
try {
|
||||
const url = new URL(value, baseUrl);
|
||||
return /^https?:$/i.test(url.protocol) ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addUrl(out: string[], seen: Set<string>, raw: string | null, baseUrl: string): void {
|
||||
if (!raw) return;
|
||||
const url = normalizeUrl(raw, baseUrl);
|
||||
if (!url || seen.has(url)) return;
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
}
|
||||
|
||||
/** 从详情 HTML 或字符串化的详情脚本中提取图片地址。 */
|
||||
export function extractDetailImageUrls(raw: string, baseUrl: string): string[] {
|
||||
const urls: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const decoded = normalizeEscapedText(raw);
|
||||
const doc = new DOMParser().parseFromString(decoded, 'text/html');
|
||||
|
||||
for (const el of Array.from(doc.querySelectorAll('img, source'))) {
|
||||
for (const attr of IMAGE_ATTRS) addUrl(urls, seen, el.getAttribute(attr), baseUrl);
|
||||
const srcset = el.getAttribute('srcset');
|
||||
if (srcset) addUrl(urls, seen, pickBestFromSrcset(srcset), baseUrl);
|
||||
}
|
||||
for (const el of Array.from(doc.querySelectorAll<HTMLElement>('[style]'))) {
|
||||
addUrl(urls, seen, el.style.backgroundImage.match(/url\(([^)]+)\)/i)?.[1] ?? null, baseUrl);
|
||||
}
|
||||
|
||||
// 某些 1688 响应会把 HTML 包在 JS 字符串里;补扫图片 CDN 的 URL 字面量。
|
||||
const candidates = decoded.match(/(?:https?:)?\/\/[^\s'"<>\\]+/g) ?? [];
|
||||
for (const candidate of candidates) {
|
||||
const url = normalizeUrl(candidate.replace(/[),;]+$/, ''), baseUrl);
|
||||
if (!url) continue;
|
||||
const hostIsImageCdn = /(?:alicdn|taobaocdn|tmallcdn)\.com$/i.test(new URL(url).hostname);
|
||||
const hasImageExt = /\.(?:avif|bmp|gif|jpe?g|png|webp)(?:[?#]|$)/i.test(url);
|
||||
if (hostIsImageCdn || hasImageExt) addUrl(urls, seen, url, baseUrl);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
/** 由 background service worker 跨域读取详情接口。 */
|
||||
export function fetchDetailText(url: string, referer: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage(
|
||||
{ action: 'fetchText', url, referer, credentials: true },
|
||||
(response: { ok?: boolean; text?: string; error?: string } | undefined) => {
|
||||
const runtimeError = chrome.runtime.lastError;
|
||||
if (runtimeError) return reject(new Error(runtimeError.message));
|
||||
if (!response?.ok || typeof response.text !== 'string') {
|
||||
return reject(new Error(response?.error || '详情接口未返回内容'));
|
||||
}
|
||||
resolve(response.text);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function detailUrlsToMaterials(urls: string[], profile: SiteProfile): ImageMaterial[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ImageMaterial[] = [];
|
||||
for (const rawUrl of urls) {
|
||||
const url = toOriginalUrl(rawUrl, profile.originalUrlRules);
|
||||
const key = dedupeKey(url, profile.originalUrlRules);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push({
|
||||
key: `detail-${String(result.length + 1).padStart(3, '0')}`,
|
||||
groupKey: 'detail', groupName: '详情图', url, thumbUrl: rawUrl,
|
||||
index: result.length, type: 'img',
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -53,6 +53,25 @@ export function queryAllDeep(selectors: string[]): Element[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 淘宝等页面会把图文详情放在延迟挂载的标签页中;仅滚动页面不会触发该标签的内容加载。
|
||||
* 只匹配明确的详情标签,避免误点商品页中的普通链接或推荐内容。
|
||||
*/
|
||||
export async function activateDetailTab(): Promise<void> {
|
||||
const labels = new Set(['图文详情', '商品详情', '详情']);
|
||||
const candidates = Array.from(document.querySelectorAll<HTMLElement>('[role="tab"], button, [class*="tab"]'));
|
||||
const tab = candidates.find((el) => {
|
||||
const label = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
const rect = el.getBoundingClientRect();
|
||||
return labels.has(label) && rect.width > 0 && rect.height > 0 && el.getAttribute('aria-selected') !== 'true';
|
||||
});
|
||||
if (!tab) return;
|
||||
|
||||
tab.click();
|
||||
// 给懒加载容器一次渲染机会,再由后续滚动加载实际图片。
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动滚动到页面底部,触发懒加载(详情图在页面尾部,不滚不加载)。
|
||||
* 有界滚动:小步分段 + 随机延迟(模拟人工浏览节奏,避免"一滚到底"的机器人特征),
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/** 小型 MD5 实现,仅用于淘宝公开 H5 接口的请求签名。 */
|
||||
function add32(a: number, b: number): number { return (a + b) & 0xffffffff; }
|
||||
function rol(num: number, cnt: number): number { return (num << cnt) | (num >>> (32 - cnt)); }
|
||||
function cmn(q: number, a: number, b: number, x: number, s: number, t: number): number {
|
||||
return add32(rol(add32(add32(a, q), add32(x, t)), s), b);
|
||||
}
|
||||
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
|
||||
return cmn((b & c) | (~b & d), a, b, x, s, t);
|
||||
}
|
||||
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
|
||||
return cmn((b & d) | (c & ~d), a, b, x, s, t);
|
||||
}
|
||||
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
|
||||
return cmn(b ^ c ^ d, a, b, x, s, t);
|
||||
}
|
||||
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
|
||||
return cmn(c ^ (b | ~d), a, b, x, s, t);
|
||||
}
|
||||
|
||||
function cycle(state: number[], block: number[]): void {
|
||||
let [a, b, c, d] = state;
|
||||
a = ff(a, b, c, d, block[0], 7, -680876936); d = ff(d, a, b, c, block[1], 12, -389564586);
|
||||
c = ff(c, d, a, b, block[2], 17, 606105819); b = ff(b, c, d, a, block[3], 22, -1044525330);
|
||||
a = ff(a, b, c, d, block[4], 7, -176418897); d = ff(d, a, b, c, block[5], 12, 1200080426);
|
||||
c = ff(c, d, a, b, block[6], 17, -1473231341); b = ff(b, c, d, a, block[7], 22, -45705983);
|
||||
a = ff(a, b, c, d, block[8], 7, 1770035416); d = ff(d, a, b, c, block[9], 12, -1958414417);
|
||||
c = ff(c, d, a, b, block[10], 17, -42063); b = ff(b, c, d, a, block[11], 22, -1990404162);
|
||||
a = ff(a, b, c, d, block[12], 7, 1804603682); d = ff(d, a, b, c, block[13], 12, -40341101);
|
||||
c = ff(c, d, a, b, block[14], 17, -1502002290); b = ff(b, c, d, a, block[15], 22, 1236535329);
|
||||
a = gg(a, b, c, d, block[1], 5, -165796510); d = gg(d, a, b, c, block[6], 9, -1069501632);
|
||||
c = gg(c, d, a, b, block[11], 14, 643717713); b = gg(b, c, d, a, block[0], 20, -373897302);
|
||||
a = gg(a, b, c, d, block[5], 5, -701558691); d = gg(d, a, b, c, block[10], 9, 38016083);
|
||||
c = gg(c, d, a, b, block[15], 14, -660478335); b = gg(b, c, d, a, block[4], 20, -405537848);
|
||||
a = gg(a, b, c, d, block[9], 5, 568446438); d = gg(d, a, b, c, block[14], 9, -1019803690);
|
||||
c = gg(c, d, a, b, block[3], 14, -187363961); b = gg(b, c, d, a, block[8], 20, 1163531501);
|
||||
a = gg(a, b, c, d, block[13], 5, -1444681467); d = gg(d, a, b, c, block[2], 9, -51403784);
|
||||
c = gg(c, d, a, b, block[7], 14, 1735328473); b = gg(b, c, d, a, block[12], 20, -1926607734);
|
||||
a = hh(a, b, c, d, block[5], 4, -378558); d = hh(d, a, b, c, block[8], 11, -2022574463);
|
||||
c = hh(c, d, a, b, block[11], 16, 1839030562); b = hh(b, c, d, a, block[14], 23, -35309556);
|
||||
a = hh(a, b, c, d, block[1], 4, -1530992060); d = hh(d, a, b, c, block[4], 11, 1272893353);
|
||||
c = hh(c, d, a, b, block[7], 16, -155497632); b = hh(b, c, d, a, block[10], 23, -1094730640);
|
||||
a = hh(a, b, c, d, block[13], 4, 681279174); d = hh(d, a, b, c, block[0], 11, -358537222);
|
||||
c = hh(c, d, a, b, block[3], 16, -722521979); b = hh(b, c, d, a, block[6], 23, 76029189);
|
||||
a = hh(a, b, c, d, block[9], 4, -640364487); d = hh(d, a, b, c, block[12], 11, -421815835);
|
||||
c = hh(c, d, a, b, block[15], 16, 530742520); b = hh(b, c, d, a, block[2], 23, -995338651);
|
||||
a = ii(a, b, c, d, block[0], 6, -198630844); d = ii(d, a, b, c, block[7], 10, 1126891415);
|
||||
c = ii(c, d, a, b, block[14], 15, -1416354905); b = ii(b, c, d, a, block[5], 21, -57434055);
|
||||
a = ii(a, b, c, d, block[12], 6, 1700485571); d = ii(d, a, b, c, block[3], 10, -1894986606);
|
||||
c = ii(c, d, a, b, block[10], 15, -1051523); b = ii(b, c, d, a, block[1], 21, -2054922799);
|
||||
a = ii(a, b, c, d, block[8], 6, 1873313359); d = ii(d, a, b, c, block[15], 10, -30611744);
|
||||
c = ii(c, d, a, b, block[6], 15, -1560198380); b = ii(b, c, d, a, block[13], 21, 1309151649);
|
||||
a = ii(a, b, c, d, block[4], 6, -145523070); d = ii(d, a, b, c, block[11], 10, -1120210379);
|
||||
c = ii(c, d, a, b, block[2], 15, 718787259); b = ii(b, c, d, a, block[9], 21, -343485551);
|
||||
state[0] = add32(a, state[0]); state[1] = add32(b, state[1]);
|
||||
state[2] = add32(c, state[2]); state[3] = add32(d, state[3]);
|
||||
}
|
||||
|
||||
function block(input: string): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < 64; i += 4) {
|
||||
out[i >> 2] = input.charCodeAt(i) + (input.charCodeAt(i + 1) << 8)
|
||||
+ (input.charCodeAt(i + 2) << 16) + (input.charCodeAt(i + 3) << 24);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 返回 UTF-8 字符串的 32 位小写 MD5。 */
|
||||
export function md5(input: string): string {
|
||||
const text = unescape(encodeURIComponent(input));
|
||||
const state = [1732584193, -271733879, -1732584194, 271733878];
|
||||
let i = 64;
|
||||
for (; i <= text.length; i += 64) cycle(state, block(text.slice(i - 64, i)));
|
||||
const tail = new Array<number>(16).fill(0);
|
||||
const rest = text.slice(i - 64);
|
||||
for (let j = 0; j < rest.length; j++) tail[j >> 2] |= rest.charCodeAt(j) << ((j % 4) << 3);
|
||||
tail[rest.length >> 2] |= 0x80 << ((rest.length % 4) << 3);
|
||||
if (rest.length > 55) cycle(state, tail);
|
||||
tail.fill(0, rest.length > 55 ? 0 : 15);
|
||||
tail[14] = text.length * 8;
|
||||
cycle(state, tail);
|
||||
return state.map((word) => {
|
||||
let out = '';
|
||||
for (let j = 0; j < 4; j++) out += ((word >> (j * 8)) & 0xff).toString(16).padStart(2, '0');
|
||||
return out;
|
||||
}).join('');
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export function finalize(
|
||||
const warnings: string[] = [];
|
||||
if (!texts.some((t) => t.kind === 'title')) warnings.push('未采集到标题');
|
||||
if (images.length === 0) warnings.push('未扫描到任何图片/视频');
|
||||
if ((stats.detail ?? 0) === 0) warnings.push('详情图为 0 张,请滚动到页面底部后重新采集');
|
||||
if ((stats.detail ?? 0) === 0) warnings.push('详情图为 0 张:页面未提供可读取的图文详情数据');
|
||||
|
||||
return {
|
||||
platform: profile.id,
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
* 1688 采集编排(平台文件):
|
||||
* ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径
|
||||
* —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺
|
||||
* ② DOM 兜底 + 补充:#productAttributes 参数表(antd Descriptions)、#detail 详情图
|
||||
* 说明:不再直调 description.detailUrl 数据端点(与淘宝 mtop 同样的风控考虑),
|
||||
* 详情图改为滚动加载后由 DOM 采集补齐。
|
||||
* ② description.detailUrl:直接读取图文详情,不操作用户页面
|
||||
* ③ DOM 兜底 + 补充当前已挂载内容
|
||||
*/
|
||||
import { autoScrollToBottom, waitForAny } from '../dom';
|
||||
import { waitForAny } from '../dom';
|
||||
import { collectImages, type ImageMaterial } from '../image';
|
||||
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
|
||||
import { toOriginalUrl } from '../url';
|
||||
import { readWindowKeys } from '../../bridge/read-window';
|
||||
import { extract1688State, type State1688 } from '../1688-state';
|
||||
import { detailUrlsToMaterials, extractDetailImageUrls, fetchDetailText } from '../detail-images';
|
||||
import { finalize, mergeImages, type ScanResult } from '../merge';
|
||||
import type { SiteProfile } from '../../profiles/types';
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro
|
||||
let primaryImages: ImageMaterial[] = [];
|
||||
let source: ScanResult['source'] = 'dom';
|
||||
let st: State1688 | null = null;
|
||||
const detailWarnings: string[] = [];
|
||||
|
||||
// ① 桥读 window.context
|
||||
const keys = await readWindowKeys(['context']);
|
||||
@@ -75,11 +76,22 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro
|
||||
});
|
||||
});
|
||||
source = 'state';
|
||||
|
||||
if (st.detailUrl) {
|
||||
try {
|
||||
const rawDetail = await fetchDetailText(st.detailUrl, location.href);
|
||||
const detailImages = detailUrlsToMaterials(extractDetailImageUrls(rawDetail, st.detailUrl), profile);
|
||||
primaryImages.push(...detailImages);
|
||||
if (detailImages.length === 0) detailWarnings.push('详情接口未解析到图片');
|
||||
} catch (error) {
|
||||
detailWarnings.push(`详情接口读取失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
} else {
|
||||
detailWarnings.push('页面未提供详情接口地址');
|
||||
}
|
||||
}
|
||||
|
||||
// ② DOM 兜底 + 补充(参数表 #productAttributes、详情图 #detail 在这里进结果)
|
||||
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
||||
await autoScrollToBottom();
|
||||
// ③ DOM 兜底:只读取当前已经挂载的内容,不点击标签、不滚动页面。
|
||||
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
|
||||
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
|
||||
const { materials: domTexts, missingRequired } = collectTexts(profile);
|
||||
@@ -90,6 +102,7 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro
|
||||
const texts = mergeTexts(primaryTexts, domTexts);
|
||||
const images = mergeImages(primaryImages, domImages, profile);
|
||||
const result = finalize(profile, itemId, texts, images, [], source);
|
||||
if ((result.stats.detail ?? 0) === 0) result.warnings.push(...detailWarnings);
|
||||
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* ③ 站内页 JSON API(entrypoint-api.bx)
|
||||
* ④ DOM data-widget 选择器兜底 + 详情图补充
|
||||
*/
|
||||
import { autoScrollToBottom, waitForAny } from '../dom';
|
||||
import { activateDetailTab, autoScrollToBottom, waitForAny } from '../dom';
|
||||
import { collectImages, type ImageMaterial } from '../image';
|
||||
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
|
||||
import { toOriginalUrl, toThumbUrl } from '../url';
|
||||
@@ -148,6 +148,7 @@ export async function scanOzon(profile: SiteProfile, itemId: string | null): Pro
|
||||
|
||||
// ── 路径④:DOM 采集(兜底 + 详情图补充)──
|
||||
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
||||
await activateDetailTab();
|
||||
await autoScrollToBottom();
|
||||
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000);
|
||||
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
* ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径
|
||||
* (isolated world 读不到 window 变量,v1 直读是无效的)
|
||||
* ② DOM 兜底 + 补充
|
||||
* 说明:不再直调 mtop 签名接口(h5api.m.taobao.com / h5api.m.tmall.com),
|
||||
* 仅读取页面已加载数据(SSR 全局 + DOM),避免触发平台风控。
|
||||
* 说明:图文详情通过页面已有登录态请求 mtop 描述接口;不点击标签、不滚动页面。
|
||||
*/
|
||||
import { autoScrollToBottom, waitForAny } from '../dom';
|
||||
import { waitForAny } from '../dom';
|
||||
import { collectImages, type ImageMaterial } from '../image';
|
||||
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
|
||||
import { readWindowKeys } from '../../bridge/read-window';
|
||||
import { buildFromSSR } from '../ssr-builder';
|
||||
import { taobaoStateFromBridge } from '../taobao-state';
|
||||
import { collectTaobaoDetailImages } from '../taobao-detail';
|
||||
import { finalize, mergeImages, type ScanResult } from '../merge';
|
||||
import type { SiteProfile } from '../../profiles/types';
|
||||
|
||||
@@ -19,6 +19,7 @@ export async function scanTaobao(profile: SiteProfile, _itemId: string | null):
|
||||
let primaryTexts: TextMaterial[] = [];
|
||||
let primaryImages: ImageMaterial[] = [];
|
||||
let source: ScanResult['source'] = 'dom';
|
||||
const detailWarnings: string[] = [];
|
||||
|
||||
// ① 桥读页面全局
|
||||
const keys = await readWindowKeys(['__ICE_APP_CONTEXT__', '__general_skupanel_cache_data', '__ICE_DATA_LOADER__']);
|
||||
@@ -31,9 +32,20 @@ export async function scanTaobao(profile: SiteProfile, _itemId: string | null):
|
||||
source = 'ssr';
|
||||
}
|
||||
|
||||
// ② DOM 兜底 + 补充
|
||||
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
||||
await autoScrollToBottom();
|
||||
// ② 图文详情 API:使用页面已有登录态签名请求,不点击标签、不滚动页面。
|
||||
if (_itemId) {
|
||||
try {
|
||||
const detailImages = await collectTaobaoDetailImages(_itemId, profile);
|
||||
primaryImages.push(...detailImages);
|
||||
if (detailImages.length === 0) detailWarnings.push('详情接口未解析到图片');
|
||||
} catch (error) {
|
||||
detailWarnings.push('详情接口读取失败: ' + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
} else {
|
||||
detailWarnings.push('未识别到商品 ID');
|
||||
}
|
||||
|
||||
// ③ DOM 兜底:只读取首屏已挂载内容,不点击详情标签、不滚动页面。
|
||||
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
|
||||
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
|
||||
const { materials: domTexts, missingRequired } = collectTexts(profile);
|
||||
@@ -44,6 +56,7 @@ export async function scanTaobao(profile: SiteProfile, _itemId: string | null):
|
||||
const texts = mergeTexts(primaryTexts, domTexts);
|
||||
const images = mergeImages(primaryImages, domImages, profile);
|
||||
const result = finalize(profile, _itemId, texts, images, [], source);
|
||||
if ((result.stats.detail ?? 0) === 0) result.warnings.push(...detailWarnings);
|
||||
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -139,9 +139,9 @@ export function buildFromSSR(data: SSRData, profile: SiteProfile): ScanResult {
|
||||
if (images.length === 0) {
|
||||
warnings.push('未扫描到任何图片/视频');
|
||||
}
|
||||
// SSR 数据里没有详情图,需要 DOM 补充
|
||||
// SSR 数据里没有详情图;由平台接口或当前已挂载 DOM 补充。
|
||||
if (stats.detail === undefined) {
|
||||
warnings.push('详情图需 DOM 补充:请滚动到页面底部后重新采集');
|
||||
warnings.push('详情图需补充:页面未提供可读取的图文详情数据');
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { detailUrlsToMaterials, extractDetailImageUrls, fetchDetailText } from './detail-images';
|
||||
import { md5 } from './md5';
|
||||
import type { ImageMaterial } from './image';
|
||||
import type { SiteProfile } from '../profiles/types';
|
||||
|
||||
function getMtopToken(): string | null {
|
||||
const match = document.cookie.match(/(?:^|;\s*)_m_h5_tk=([^;]+)/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
return decodeURIComponent(match[1]).split('_')[0] || null;
|
||||
} catch {
|
||||
return match[1].split('_')[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
function mtopUrl(itemId: string, token: string): string {
|
||||
const now = Date.now().toString();
|
||||
const current = new URL(location.href);
|
||||
const data = JSON.stringify({
|
||||
abbucket: current.searchParams.get('abbucket') || '',
|
||||
id: itemId,
|
||||
ns: current.searchParams.get('ns') || '',
|
||||
spm: current.searchParams.get('spm') || '',
|
||||
detail_v: '3.3.0',
|
||||
preferWireless: 'true',
|
||||
});
|
||||
const host = current.hostname === 'detail.tmall.com' ? 'h5api.m.tmall.com' : 'h5api.m.taobao.com';
|
||||
const url = new URL('https://' + host + '/h5/mtop.taobao.detail.getdesc/7.0/');
|
||||
url.search = new URLSearchParams({
|
||||
jsv: '2.7.0', appKey: '12574478', t: now, sign: md5(token + '&' + now + '&12574478&' + data),
|
||||
api: 'mtop.taobao.detail.getdesc', v: '7.0', isSec: '0', ecode: '0',
|
||||
AntiFlood: 'true', AntiCreep: 'true', H5Request: 'true', ttid: '2022@tmall_litepc_9.17.0',
|
||||
type: 'json', dataType: 'json', data,
|
||||
}).toString();
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/** 无滚动读取淘宝/天猫图文详情。仅使用浏览器现有登录态,不会改动页面。 */
|
||||
export async function collectTaobaoDetailImages(itemId: string, profile: SiteProfile): Promise<ImageMaterial[]> {
|
||||
const token = getMtopToken();
|
||||
if (!token) throw new Error('未找到淘宝登录态;请登录后重新采集');
|
||||
const raw = await fetchDetailText(mtopUrl(itemId, token), location.href);
|
||||
let response: any;
|
||||
try {
|
||||
response = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error('详情接口返回了无效数据');
|
||||
}
|
||||
if (!response?.data?.components?.componentData) {
|
||||
throw new Error(response?.ret?.[0] || '详情接口未返回图文详情');
|
||||
}
|
||||
const components = response.data.components.componentData;
|
||||
const richText = components.desc_richtext_pc?.model?.text;
|
||||
const source = typeof richText === 'string' ? richText : JSON.stringify(components);
|
||||
return detailUrlsToMaterials(extractDetailImageUrls(source, location.href), profile);
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export default defineConfig({
|
||||
'https://detail.1688.com/*',
|
||||
'https://item.taobao.com/*',
|
||||
'https://detail.tmall.com/*',
|
||||
'https://h5api.m.taobao.com/*',
|
||||
'https://h5api.m.tmall.com/*',
|
||||
'https://*.alicdn.com/*',
|
||||
// 1688 详情数据 CDN(description.detailUrl)
|
||||
'https://itemcdn.tmall.com/*',
|
||||
|
||||
Reference in New Issue
Block a user