feat(edit): 编辑上报细节优化

This commit is contained in:
2026-10-06 18:30:05 +08:00
parent e20cd7547f
commit 79b310efe7
92 changed files with 2764 additions and 368 deletions
+36 -26
View File
@@ -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;