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
+65
View File
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import test from 'node:test';
import { collectedProductPayloadSchema } from '@atlas/contracts';
import { collectPlatformDomAssets, normaliseImageUrl } from '../lib/product-media.ts';
import { collectCurrentProduct } from '../lib/source-parsers.ts';
// 使用 WXT 已有的 HTML 解析依赖构建离线 DOM,不加载样本里的远程媒体。
const require = createRequire(import.meta.url);
const wxtRequire = createRequire(require.resolve('wxt'));
const { parseHTML } = wxtRequire('linkedom');
const pageUrl = 'https://detail.1688.com/offer/1012823100668.html';
const videoUrl = 'https://cloud.video.taobao.com/qa-product.mp4';
const poster = 'https://cbu01.alicdn.com/img/ibank/O1CN01lCwIgP1UjKGylKwAN_!!2563422553-0-cib.jpg';
async function fixture(extra = ''): Promise<Document> {
const html = await readFile(new URL('./fixtures/1688-gallery-background.html', import.meta.url), 'utf8');
const { document, window } = parseHTML(`<html><body>${html}${extra}</body></html>`);
window.getComputedStyle = (element: HTMLElement) => ({ backgroundImage: element.style.backgroundImage });
Object.defineProperty(document, 'scripts', { get: () => document.querySelectorAll('script') });
for (const element of document.querySelectorAll('.v-image-cover')) {
Object.defineProperty(element, 'offsetWidth', { value: 60 });
Object.defineProperty(element, 'offsetHeight', { value: 60 });
}
return document;
}
test('用户画廊样本的 15 张背景缩略图完整采集、还原原图,排除参数与视频控件', async () => {
const assets = collectPlatformDomAssets('1688', await fixture(), pageUrl);
assert.equal(assets.length, 15);
assert.ok(assets.every((asset) => asset.mediaType === 'image' && asset.role === 'source_main'));
assert.equal(assets[0].url, poster);
assert.ok(assets.every((asset) => asset.url.endsWith('.jpg') && !asset.url.includes('_b.jpg')));
assert.ok(assets.every((asset) => !/undefined|\.svg/.test(asset.url)));
assert.equal(normaliseImageUrl('undefined', pageUrl, '1688'), undefined);
});
test('画廊播放器可采集真实视频及封面,忽略推荐视频、blob 和 HLS', async () => {
const root = await fixture(`<div class="od-picture-gallery"><video src="${videoUrl}"></video><video src="blob:https://detail.1688.com/opaque"></video><video src="https://cloud.video.taobao.com/stream.m3u8"></video></div><aside><video src="https://cloud.video.taobao.com/recommend.mp4"></video></aside>`);
const assets = collectPlatformDomAssets('1688', root, pageUrl);
const videos = assets.filter((asset) => asset.mediaType === 'video');
assert.equal(videos.length, 1);
assert.equal(videos[0].url, videoUrl);
assert.equal(videos[0].posterUrl, poster);
assert.equal(assets.filter((asset) => asset.mediaType === 'image').length, 15);
});
test('脚本画廊数据与 DOM 视频去重,完整采集载荷符合 API 契约', async () => {
const gallery = { fields: { video: { url: videoUrl, videoThumbnailURL: poster } } };
const root = await fixture(`<div class="od-picture-gallery"><video><source src="${videoUrl}"></video></div><script>window.context=${JSON.stringify({ gallery })};</script>`);
const payload = await collectCurrentProduct(root, pageUrl);
assert.equal(payload.assets.filter((asset) => asset.mediaType === 'video').length, 1);
assert.equal(payload.assets.filter((asset) => asset.mediaType === 'image').length, 15);
assert.equal(collectedProductPayloadSchema.safeParse(payload).success, true);
});
test('尚未挂载播放器时仍可从明确的 gallery 字面值读取视频直链', async () => {
const gallery = { fields: { video: { url: '', videoAddress: videoUrl, videoThumbnailURL: poster } } };
const payload = await collectCurrentProduct(await fixture(`<script>window.context=${JSON.stringify({ gallery })};</script>`), pageUrl);
const videos = payload.assets.filter((asset) => asset.mediaType === 'video');
assert.equal(videos.length, 1);
assert.equal(videos[0].url, videoUrl);
assert.equal(videos[0].posterUrl, poster);
});
+54
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { collectCurrentProduct } from '../lib/source-parsers.ts';
import { collectedProductPayloadSchema } from '@atlas/contracts';
function fixtureSkuModel(html: string) {
const marker = '"skuModel": ';
@@ -39,6 +40,7 @@ test('1688 真实样本按 skuInfoMap 的 price 和规格图片生成逐 SKU 数
async () => ({ skuModel }),
);
assert.equal(payload.variants.length, Object.keys(skuModel.skuInfoMap).length);
assert.equal(collectedProductPayloadSchema.safeParse(payload).success, true);
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}`);
@@ -60,3 +62,55 @@ test('1688 真实样本按 skuInfoMap 的 price 和规格图片生成逐 SKU 数
assert.equal(withoutBridge.variants.length, payload.variants.length);
assert.equal(withoutBridge.assets.filter((asset) => asset.variantSourceKey).length, payload.variants.length);
});
test('1688 超过 100 条真实可售记录完整解析,不按旧上限裁剪', async () => {
const root = { scripts: [], querySelector: () => null, querySelectorAll: () => [] } as unknown as Document;
const skuInfoMap = Object.fromEntries(Array.from({ length: 200 }, (_, index) => [
`组合 ${index}`, { skuId: String(8_000_000_000_000 + index), price: '20.00' },
]));
const payload = await collectCurrentProduct(root, 'https://detail.1688.com/offer/123456789.html', undefined,
async () => ({ skuModel: { skuInfoMap } }));
assert.equal(payload.variants.length, 200);
assert.equal(payload.variants.at(-1)?.sourceSku, String(8_000_000_000_199));
assert.equal(collectedProductPayloadSchema.safeParse(payload).success, true);
});
test('字段名字符串与后续 150 个无关对象不会被误采为 SKU', async () => {
const unrelated = Object.fromEntries(Array.from({ length: 150 }, (_, index) => [
`metadata${index}`, { label: '推荐数据' },
]));
const model = {
skuInfoMap: { 白色: { skuId: '1001', price: '20.00' }, 蓝色: { skuId: '1002', price: '20.00' } },
skuProps: [{ prop: 'color', name: '颜色', value: [{ valueId: 'white', name: '白色' }, { valueId: 'blue', name: '蓝色' }] }],
};
const script = `const key="skuInfoMap";const unrelated=${JSON.stringify(unrelated)};window.context=${JSON.stringify({ skuModel: model })};`;
const root = { scripts: [{ type: 'text/javascript', textContent: script }], querySelector: () => null, querySelectorAll: () => [] } as unknown as Document;
const payload = await collectCurrentProduct(root, 'https://detail.1688.com/offer/123456789.html');
assert.deepEqual(payload.variants.map((variant) => variant.sourceSku), ['1001', '1002']);
assert.deepEqual(payload.variants.map((variant) => variant.displayName), ['白色', '蓝色']);
});
test('skuInfoMap 为 null 时不跳读后续对象,仍读取明确的原始可售表', async () => {
const script = `window.context={"skuInfoMap":null,"unrelated":{"bad":{"title":"不是 SKU"}},"skuMapOriginal":[{"skuId":"1001","specAttrs":"白色","price":"20.00"}]};`;
const root = { scripts: [{ type: 'text/javascript', textContent: script }], querySelector: () => null, querySelectorAll: () => [] } as unknown as Document;
const payload = await collectCurrentProduct(root, 'https://detail.1688.com/offer/123456789.html');
assert.equal(payload.variants.length, 1);
assert.equal(payload.variants[0].sourceSku, '1001');
});
test('真实规格表内的元数据不生成 SKU,无 ID 的完整可售组合仍保留', async () => {
const root = { scripts: [], querySelector: () => null, querySelectorAll: () => [] } as unknown as Document;
const model = {
skuInfoMap: {
metadata: { label: '元数据' },
未匹配颜色: { price: '30.00' },
白色: { price: '20.00', canBookCount: 0 },
},
skuProps: [{ prop: 'color', name: '颜色', value: [{ valueId: 'white', name: '白色' }] }],
};
const payload = await collectCurrentProduct(root, 'https://detail.1688.com/offer/123456789.html', undefined,
async () => ({ skuModel: model }));
assert.equal(payload.variants.length, 1);
assert.equal(payload.variants[0].sourceVariantKey, '1688:白色');
assert.equal(payload.variants[0].selections[0].valueKey, 'white');
});
@@ -0,0 +1,21 @@
<div class="od-picture-gallery">
<ul class="od-picture-gallery-list">
<li class="v-image-cover video-image-cover tag-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01lCwIgP1UjKGylKwAN_!!2563422553-0-cib.jpg_b.jpg')"><span class="video-type-name">视频</span></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01lCwIgP1UjKGylKwAN_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover image-item-active" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01gb1fgp1UjKGxwxP2i_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01aF0MUa1UjKGyhzb5T_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01v0hY3O1UjKGxb6l8V_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01p7D3oc1UjKGyxpfs6_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('undefined')"><img class="cpv-icon" src="https://img.alicdn.com/imgextra/i2/O1CN01pa4yFv245mEaNULYJ_!!6000000007340-55-tps-24-24.svg"><span class="cpv-tag-name">参数</span></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01yJR26t1UjKINnDDxH_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01jpXmyT1UjKINnG6rY_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN010SAH2j1UjKINnJKfy_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01QYAYN91UjKIPWV6Z4_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01zhfBE81UjKIO5c0qt_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01Ddm4iM1UjKIOXEug1_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01UEg1Qk1UjKIPWn1rT_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01aEAp2k1UjKINoYpQ1_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01PdBVY31UjKIjUplsf_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01DBpghm1UjKIjVh3Ru_!!2563422553-0-cib.jpg_b.jpg')"></li>
</ul>
</div>
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
backgroundError,
requestError,
unwrapBackgroundResponse,
} from '../lib/request-errors.ts';
import { validateCollectedPayload } from '../lib/collection-payload-validation.ts';
import { PRODUCT_VARIANT_LIMIT, productPublishPreflightRequestSchema } from '@atlas/contracts';
const payload = {
source: {
platform: '1688', itemKey: '123456789',
canonicalUrl: 'https://detail.1688.com/offer/123456789.html',
collectedAt: '2026-10-05T10:00:00.000Z', parserVersion: 'test',
},
common: { attributes: [], categories: [] },
variantAxes: [], variants: [], assets: [], evidence: [],
};
test('采集支持超过 100 个 SKU,上限与草稿一致,超限时不静默截断', () => {
const variants = Array.from({ length: PRODUCT_VARIANT_LIMIT + 1 }, (_, index) => ({
sourceVariantKey: String(index), displayName: `SKU ${index}`,
selections: [], confidence: 'verified', evidenceRefs: [],
}));
assert.throws(() => validateCollectedPayload({ ...payload, variants }), /采集数据不符合要求.*variants/);
assert.equal(variants.length, PRODUCT_VARIANT_LIMIT + 1);
assert.equal(validateCollectedPayload({ ...payload, variants: variants.slice(0, 101) }).variants.length, 101);
assert.equal(validateCollectedPayload({ ...payload, variants: variants.slice(0, PRODUCT_VARIANT_LIMIT) }).variants.length, PRODUCT_VARIANT_LIMIT);
assert.equal(validateCollectedPayload(payload).source.itemKey, '123456789');
});
test('提高采集容量不放宽每批 50 个 SKU 的首次上架约束', () => {
const ids = Array.from({ length: 51 }, () => crypto.randomUUID());
assert.equal(productPublishPreflightRequestSchema.safeParse({ variant_ids: ids.slice(0, 50) }).success, true);
assert.equal(productPublishPreflightRequestSchema.safeParse({ variant_ids: ids }).success, false);
});
test('采集字段路径错误可经 Chrome 可序列化响应返回浮窗', () => {
const error = requestError({ error: {
message: '请求参数不符合要求',
details: [{ field: 'variantAxes.0.values.0.name', message: 'Too small: expected string to have >=1 characters' }],
} }, 422);
assert.equal(error.status, 422);
const response = JSON.parse(JSON.stringify(backgroundError(error)));
assert.throws(() => unwrapBackgroundResponse(response), /variantAxes\.0\.values\.0\.name/);
assert.throws(() => unwrapBackgroundResponse(response), /请求参数不符合要求/);
});
test('通用 HTTP 错误及空后台响应仍可操作,成功响应保持原有结构', () => {
assert.equal(requestError(null, 503).message, '请求失败(HTTP 503)');
assert.equal(requestError({ error: { message: '登录过期', details: [null, { token: '不应展示' }] } }, 401).message, '登录过期');
assert.throws(() => unwrapBackgroundResponse(undefined), /请重载扩展并刷新商品页/);
const success = { result: { data: { editor_entry_path: '/products/test/editor' } } };
assert.strictEqual(unwrapBackgroundResponse(success), success);
});