feat(ai-image): AI生图开发
This commit is contained in:
@@ -4,7 +4,7 @@ import {
|
||||
isOzonProductPage,
|
||||
type OzonPageJsonFetcher,
|
||||
} from './ozon-parser.js';
|
||||
import { collectPlatformDomAssets } from './product-media.js';
|
||||
import { collectPlatformDomAssets, normaliseImageUrl } from './product-media.js';
|
||||
import { formatCollectedDescription } from './description-format.js';
|
||||
|
||||
type SourcePlatform = '1688' | 'taobao' | 'tmall' | 'ozon';
|
||||
@@ -49,7 +49,10 @@ export async function collectCurrentProduct(
|
||||
|
||||
function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): CollectedProductPayload {
|
||||
const state = [bridgedState, ...pageState(root)];
|
||||
const skuMap = findFirst(state, 'skuMapOriginal');
|
||||
const skuInfoMap = findFirst(state, 'skuInfoMap');
|
||||
const skuMap = isRecord(skuInfoMap) && Object.keys(skuInfoMap).length
|
||||
? skuInfoMap
|
||||
: findFirst(state, 'skuMapOriginal');
|
||||
const skuProps = findFirst(state, 'skuProps');
|
||||
const axes = parse1688Axes(skuProps);
|
||||
const evidence: CollectedProductPayload['evidence'] = [
|
||||
@@ -70,32 +73,29 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C
|
||||
evidence.push({
|
||||
ref: '1688:sku-map',
|
||||
kind: 'state',
|
||||
path: 'skuMapOriginal',
|
||||
path: isRecord(skuInfoMap) ? 'skuModel.skuInfoMap' : 'skuMapOriginal',
|
||||
raw_value: `组合记录 ${skuRows.length} 条`,
|
||||
});
|
||||
for (const [combination, row] of skuRows) {
|
||||
if (!isRecord(row)) continue;
|
||||
const sourceSku = stringAt(row, ['skuId', 'sku_id', 'skuCode']);
|
||||
// A skuMapOriginal entry itself is a source-provided sellable-combination
|
||||
// record. Never expand it with the Cartesian product of skuProps.
|
||||
const specAttrs = stringAt(row, ['specAttrs', 'propPath']) ?? combination;
|
||||
const selections = selectionsFor1688Combination(specAttrs, axes);
|
||||
// 来源记录本身证明可售组合,不能用规格轴的笛卡尔积扩充 SKU。
|
||||
const sourceVariantKey = sourceSku ?? `1688:${combination}`;
|
||||
if (!combination || combination === 'undefined') continue;
|
||||
variants.push({
|
||||
sourceVariantKey,
|
||||
...(sourceSku ? { sourceSku } : {}),
|
||||
displayName:
|
||||
selectionsForCombination(combination, axes)
|
||||
selections
|
||||
.map((item) => item.valueName)
|
||||
.join(' / ') || `1688 SKU ${sourceSku ?? combination}`,
|
||||
selections: selectionsForCombination(combination, axes),
|
||||
...(moneyAt(row, ['discountPrice', 'price', 'salePrice'])
|
||||
.join(' / ') || specAttrs,
|
||||
selections,
|
||||
...(moneyAt(row, ['price'])
|
||||
? {
|
||||
sourcePrice: {
|
||||
amount: moneyAt(row, [
|
||||
'discountPrice',
|
||||
'price',
|
||||
'salePrice',
|
||||
])!,
|
||||
amount: moneyAt(row, ['price'])!,
|
||||
currencyCode: 'CNY',
|
||||
},
|
||||
}
|
||||
@@ -105,7 +105,7 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C
|
||||
});
|
||||
}
|
||||
}
|
||||
return basePayload(
|
||||
const payload = basePayload(
|
||||
'1688',
|
||||
offerId(pageUrl),
|
||||
root,
|
||||
@@ -113,9 +113,29 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C
|
||||
axes,
|
||||
uniqueVariants(variants),
|
||||
evidence,
|
||||
'1688-structured-2.0.1',
|
||||
'1688-structured-2.1.0',
|
||||
state,
|
||||
);
|
||||
// SKU 图片由规格值和真实可售组合关联,独立于页面当时选中的 DOM 状态。
|
||||
for (const variant of payload.variants) {
|
||||
for (const selection of variant.selections) {
|
||||
const rawUrl = axes.find((axis) => axis.key === selection.axisKey)
|
||||
?.values.find((value) => value.key === selection.valueKey)?.image_url;
|
||||
const url = rawUrl && normaliseImageUrl(rawUrl, pageUrl, '1688');
|
||||
if (!url || payload.assets.length >= 500) continue;
|
||||
if (payload.assets.some((asset) => asset.variantSourceKey === variant.sourceVariantKey && asset.url === url)) continue;
|
||||
payload.assets.push({
|
||||
url,
|
||||
mediaType: 'image',
|
||||
role: 'source_sku',
|
||||
relation: 'axis_value',
|
||||
variantSourceKey: variant.sourceVariantKey,
|
||||
sortOrder: payload.assets.length,
|
||||
evidenceRef: '1688:sku-map',
|
||||
});
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function collectTaobao(
|
||||
@@ -411,13 +431,14 @@ function pageState(root: Document) {
|
||||
}
|
||||
}
|
||||
for (const marker of [
|
||||
'skuInfoMap',
|
||||
'skuMapOriginal',
|
||||
'skuBase',
|
||||
'skuCore',
|
||||
'skuProps',
|
||||
]) {
|
||||
const object = objectAfterMarker(text, marker);
|
||||
if (object) all.push(object);
|
||||
if (object) all.push({ [marker]: object });
|
||||
}
|
||||
}
|
||||
return all;
|
||||
@@ -589,6 +610,19 @@ function selectionsForCombination(combination: string, axes: Axis[]) {
|
||||
: [];
|
||||
});
|
||||
}
|
||||
function selectionsFor1688Combination(combination: string, axes: Axis[]) {
|
||||
const selected = selectionsForCombination(combination, axes);
|
||||
if (selected.length === axes.length) return selected;
|
||||
const parts = combination.split(/[;,&|]/).map((part) => part.trim()).filter(Boolean);
|
||||
return axes.flatMap((axis) => {
|
||||
const existing = selected.find((item) => item.axisKey === axis.key);
|
||||
if (existing) return [existing];
|
||||
const value = axis.values.find((item) =>
|
||||
parts.some((part) => part === item.name || part === `${axis.name}:${item.name}`)
|
||||
);
|
||||
return value ? [{ axisKey: axis.key, valueKey: value.key, valueName: value.name }] : [];
|
||||
});
|
||||
}
|
||||
function stringAt(value: Json, keys: string[]) {
|
||||
for (const key of keys)
|
||||
if (typeof value[key] === 'string' || typeof value[key] === 'number')
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { collectCurrentProduct } from '../lib/source-parsers.ts';
|
||||
|
||||
function fixtureSkuModel(html: string) {
|
||||
const marker = '"skuModel": ';
|
||||
const start = html.indexOf(marker);
|
||||
assert.ok(start >= 0);
|
||||
const source = html.slice(start + marker.length);
|
||||
let depth = 0;
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
if (quoted) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === '\\') escaped = true;
|
||||
else if (char === '"') quoted = false;
|
||||
} else if (char === '"') quoted = true;
|
||||
else if (char === '{') depth += 1;
|
||||
else if (char === '}' && --depth === 0) return JSON.parse(source.slice(0, index + 1));
|
||||
}
|
||||
throw new Error('1688 样本缺少完整 skuModel');
|
||||
}
|
||||
|
||||
test('1688 真实样本按 skuInfoMap 的 price 和规格图片生成逐 SKU 数据', async () => {
|
||||
const html = await readFile(new URL('../../../packages/reference/1688.html', import.meta.url), 'utf8');
|
||||
const skuModel = fixtureSkuModel(html);
|
||||
const root = {
|
||||
scripts: [],
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
} as unknown as Document;
|
||||
const payload = await collectCurrentProduct(
|
||||
root,
|
||||
'https://detail.1688.com/offer/123456789.html',
|
||||
undefined,
|
||||
async () => ({ skuModel }),
|
||||
);
|
||||
assert.equal(payload.variants.length, Object.keys(skuModel.skuInfoMap).length);
|
||||
for (const [name, row] of Object.entries<any>(skuModel.skuInfoMap)) {
|
||||
const variant = payload.variants.find((item) => item.sourceSku === String(row.skuId));
|
||||
assert.ok(variant, `缺少 ${name}`);
|
||||
assert.equal(variant.displayName, name);
|
||||
assert.equal(variant.sourcePrice?.amount, row.price);
|
||||
const image = skuModel.skuProps[0].value.find((item: any) => item.name === name)?.imageUrl;
|
||||
assert.ok(payload.assets.some((asset) =>
|
||||
asset.variantSourceKey === variant.sourceVariantKey && asset.url === image
|
||||
), `${name} 未关联规格图片`);
|
||||
}
|
||||
const scriptRoot = {
|
||||
...root,
|
||||
scripts: [{ type: 'text/javascript', textContent: html }],
|
||||
} as unknown as Document;
|
||||
const withoutBridge = await collectCurrentProduct(
|
||||
scriptRoot,
|
||||
'https://detail.1688.com/offer/123456789.html',
|
||||
);
|
||||
assert.equal(withoutBridge.variants.length, payload.variants.length);
|
||||
assert.equal(withoutBridge.assets.filter((asset) => asset.variantSourceKey).length, payload.variants.length);
|
||||
});
|
||||
Reference in New Issue
Block a user