feat(edit): 编辑上报细节优化
This commit is contained in:
@@ -4,7 +4,7 @@ import {
|
||||
isOzonProductPage,
|
||||
type OzonPageJsonFetcher,
|
||||
} from './ozon-parser.js';
|
||||
import { collectPlatformDomAssets, normaliseImageUrl } from './product-media.js';
|
||||
import { collectPlatformDomAssets, normaliseImageUrl, normaliseVideoUrl } from './product-media.js';
|
||||
import { formatCollectedDescription } from './description-format.js';
|
||||
|
||||
type SourcePlatform = '1688' | 'taobao' | 'tmall' | 'ozon';
|
||||
@@ -49,11 +49,12 @@ export async function collectCurrentProduct(
|
||||
|
||||
function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): CollectedProductPayload {
|
||||
const state = [bridgedState, ...pageState(root)];
|
||||
const skuInfoMap = findFirst(state, 'skuInfoMap');
|
||||
const skuModel = findFirst(state, 'skuModel');
|
||||
const skuInfoMap = isRecord(skuModel) ? skuModel.skuInfoMap : findFirst(state, 'skuInfoMap');
|
||||
const skuMap = isRecord(skuInfoMap) && Object.keys(skuInfoMap).length
|
||||
? skuInfoMap
|
||||
: findFirst(state, 'skuMapOriginal');
|
||||
const skuProps = findFirst(state, 'skuProps');
|
||||
const skuProps = isRecord(skuModel) ? skuModel.skuProps : findFirst(state, 'skuProps');
|
||||
const axes = parse1688Axes(skuProps);
|
||||
const evidence: CollectedProductPayload['evidence'] = [
|
||||
{
|
||||
@@ -78,9 +79,14 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C
|
||||
});
|
||||
for (const [combination, row] of skuRows) {
|
||||
if (!isRecord(row)) continue;
|
||||
const sourceSku = stringAt(row, ['skuId', 'sku_id', 'skuCode']);
|
||||
const sku = stringAt(row, ['skuId', 'sku_id', 'skuCode'])?.trim();
|
||||
const sourceSku = sku && !/^(0|null|undefined)$/.test(sku) ? sku : undefined;
|
||||
const specAttrs = stringAt(row, ['specAttrs', 'propPath']) ?? combination;
|
||||
const selections = selectionsFor1688Combination(specAttrs, axes);
|
||||
// 元数据对象不是 SKU;无来源 ID 时须同时具备完整规格匹配和可售记录证据。
|
||||
const hasSaleEvidence = moneyAt(row, ['price']) !== undefined ||
|
||||
(typeof row.canBookCount === 'number' && Number.isFinite(row.canBookCount) && row.canBookCount >= 0);
|
||||
if (!sourceSku && !(axes.length > 0 && selections.length === axes.length && hasSaleEvidence)) continue;
|
||||
// 来源记录本身证明可售组合,不能用规格轴的笛卡尔积扩充 SKU。
|
||||
const sourceVariantKey = sourceSku ?? `1688:${combination}`;
|
||||
if (!combination || combination === 'undefined') continue;
|
||||
@@ -239,7 +245,7 @@ function basePayload(
|
||||
ref: `${platform}:dom:image-profile`,
|
||||
kind: 'dom',
|
||||
path: 'platform-scoped gallery / sku / detail selectors',
|
||||
raw_value: `采集 ${assets.length} 张受限商品区域图片`,
|
||||
raw_value: `采集 ${assets.filter((asset) => asset.mediaType === 'image').length} 张受限商品区域图片、${assets.filter((asset) => asset.mediaType === 'video').length} 个画廊视频`,
|
||||
});
|
||||
const videoCount = addMarketplaceVideos(platform, states, assets, canonical);
|
||||
if (videoCount) evidence.push({
|
||||
@@ -280,8 +286,7 @@ function basePayload(
|
||||
};
|
||||
}
|
||||
|
||||
/** The old collector's proven video sources are structured product state. Do
|
||||
* not broaden this to page video elements: they also contain feeds and reviews. */
|
||||
/** 结构化画廊数据补充公开视频地址,与受限画廊 DOM 去重,不读取推荐或评价视频。 */
|
||||
function addMarketplaceVideos(
|
||||
platform: Exclude<SourcePlatform, 'ozon'>,
|
||||
states: unknown[],
|
||||
@@ -306,16 +311,21 @@ function addMarketplaceVideos(
|
||||
for (const entry of raw) {
|
||||
if (assets.length >= 500) break;
|
||||
const record = isRecord(entry) ? entry : undefined;
|
||||
const url = directVideoUrl(
|
||||
typeof entry === 'string' ? entry : stringAt(record ?? {}, ['url', 'videoUrl', 'src']),
|
||||
pageUrl,
|
||||
);
|
||||
if (!url || seen.has(`video:${url}`)) continue;
|
||||
seen.add(`video:${url}`);
|
||||
const candidates = typeof entry === 'string' ? [entry]
|
||||
: ['url', 'videoUrl', 'videoURL', 'videoAddress', 'address', 'src', 'playUrl']
|
||||
.map((key) => stringAt(record ?? {}, [key]));
|
||||
const url = candidates.map((candidate) => directVideoUrl(candidate, pageUrl)).find(Boolean);
|
||||
if (!url) continue;
|
||||
const posterUrl = imageUrl(
|
||||
stringAt(record ?? {}, ['videoThumbnailURL', 'thumbnailUrl', 'coverUrl', 'coverImage', 'poster']),
|
||||
pageUrl,
|
||||
);
|
||||
if (seen.has(`video:${url}`)) {
|
||||
const existing = assets.find((asset) => asset.mediaType === 'video' && asset.url === url);
|
||||
if (existing && !existing.posterUrl && posterUrl) existing.posterUrl = posterUrl;
|
||||
continue;
|
||||
}
|
||||
seen.add(`video:${url}`);
|
||||
assets.push({
|
||||
url,
|
||||
mediaType: 'video',
|
||||
@@ -331,14 +341,7 @@ function addMarketplaceVideos(
|
||||
}
|
||||
|
||||
function directVideoUrl(value: string | undefined, pageUrl: string) {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const url = new URL(value, pageUrl);
|
||||
if (url.protocol !== 'https:' || /\.m3u8(?:$|[?#])/i.test(url.pathname)) return undefined;
|
||||
return /\.(mp4|webm)(?:$|[?#])/i.test(url.pathname) ? url.href : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return value ? normaliseVideoUrl(value, pageUrl) : undefined;
|
||||
}
|
||||
|
||||
function imageUrl(value: string | undefined, pageUrl: string) {
|
||||
@@ -431,6 +434,8 @@ function pageState(root: Document) {
|
||||
}
|
||||
}
|
||||
for (const marker of [
|
||||
'gallery',
|
||||
'skuModel',
|
||||
'skuInfoMap',
|
||||
'skuMapOriginal',
|
||||
'skuBase',
|
||||
@@ -444,11 +449,16 @@ function pageState(root: Document) {
|
||||
return all;
|
||||
}
|
||||
function objectAfterMarker(text: string, marker: string): unknown | null {
|
||||
const position = text.indexOf(marker);
|
||||
if (position < 0) return null;
|
||||
const start = text.slice(position).search(/[\[{]/);
|
||||
if (start < 0) return null;
|
||||
const first = position + start;
|
||||
// 只读取对象属性的直接字面值,避免从字段名字符串或 null 跳到后续无关对象。
|
||||
const property = new RegExp(`(?:^|[,{])\\s*(?:"${marker}"|'${marker}'|${marker})\\s*:\\s*(?=[\\[{])`, 'g');
|
||||
for (const match of text.matchAll(property)) {
|
||||
const first = match.index! + match[0].length;
|
||||
const parsed = objectLiteralAt(text, first);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function objectLiteralAt(text: string, first: number): unknown | null {
|
||||
const open = text[first];
|
||||
const close = open === '{' ? '}' : ']';
|
||||
let depth = 0;
|
||||
|
||||
Reference in New Issue
Block a user