feat(edit): 编辑上报细节优化
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user