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
+10 -5
View File
@@ -1,10 +1,15 @@
import { getSession, login, openErp, submit } from '../lib/api.js';
import { backgroundError } from '../lib/request-errors.js';
export default defineBackground(() => {
browser.runtime.onMessage.addListener(async (message: { type?: string; payload?: unknown; path?: string }) => {
if (message?.type === 'atlas.session') return { session: await getSession() };
if (message?.type === 'atlas.login') return { session: await login() };
if (message?.type === 'atlas.collect') return { result: await submit(message.payload as import('@atlas/contracts').CollectedProductPayload) };
if (message?.type === 'atlas.open') { await openErp(message.path); return { ok: true }; }
return undefined;
try {
if (message?.type === 'atlas.session') return { session: await getSession() };
if (message?.type === 'atlas.login') return { session: await login() };
if (message?.type === 'atlas.collect') return { result: await submit(message.payload as import('@atlas/contracts').CollectedProductPayload) };
if (message?.type === 'atlas.open') { await openErp(message.path); return { ok: true }; }
return undefined;
} catch (error) {
return backgroundError(error);
}
});
});
+22 -18
View File
@@ -4,6 +4,8 @@ import {
} from '../lib/source-parsers.js';
import type { OzonPageJsonFetcher } from '../lib/ozon-parser.js';
import type { MarketplacePageStateFetcher } from '../lib/source-parsers.js';
import { renderCollectorPanel } from '../lib/collector-panel.js';
import { unwrapBackgroundResponse } from '../lib/request-errors.js';
export default defineContentScript({
matches: [
@@ -19,6 +21,7 @@ export default defineContentScript({
let mount: HTMLDivElement | null = null;
let shadow: ShadowRoot | null = null;
let observedUrl = location.href;
let minimized = false;
const render = async () => {
if (!supportedProductPlatform()) {
@@ -35,21 +38,17 @@ export default defineContentScript({
shadow = mount.attachShadow({ mode: 'open' });
}
const root = shadow!;
const session = await browser.runtime
.sendMessage({ type: 'atlas.session' })
const session = await sendBackgroundMessage<{ session?: unknown }>({ type: 'atlas.session' })
.then((value: { session?: unknown }) => value.session)
.catch(() => null);
root.innerHTML = `<style>:host{all:initial}.box{width:156px;padding:12px;background:#fff;border-radius:12px;box-shadow:0 8px 28px #0003;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2937}.head{display:flex;justify-content:space-between;align-items:center;font-weight:700;margin-bottom:10px}.dot{width:9px;height:9px;border-radius:50%;background:#ffd43b;border:0}.btn{display:block;width:100%;border:0;border-radius:18px;padding:9px 8px;margin:7px 0;background:#2878f0;color:#fff;font-weight:600;cursor:pointer}.btn.primary{background:#ff5353}.link{display:block;width:100%;border:0;background:transparent;color:#1668dc;cursor:pointer;padding:5px}.hint{font-size:12px;color:#6b7280;text-align:center;margin-top:6px}</style><div class="box"><div class="head"><span>Atlas ERP</span><button class="dot" aria-label="收起"></button></div>${session ? '<button class="btn primary" data-action="edit">编辑上架</button><button class="btn" data-action="collect">采集商品</button><button class="link" data-action="erp">进入 ERP</button>' : '<button class="btn primary" data-action="login">登录 Atlas ERP</button>'}<div class="hint" data-hint></div></div>`;
root.querySelector('.dot')?.addEventListener('click', () => {
mount?.remove();
mount = null;
shadow = null;
});
root.querySelectorAll<HTMLButtonElement>('[data-action]').forEach(
(button) =>
button.addEventListener('click', () =>
void action(button.dataset.action ?? '', root),
),
renderCollectorPanel(
root,
Boolean(session),
minimized,
(next) => {
minimized = next;
},
(kind) => void action(kind, root),
);
};
@@ -58,12 +57,12 @@ export default defineContentScript({
try {
if (kind === 'login') {
hint.textContent = '正在登录…';
await browser.runtime.sendMessage({ type: 'atlas.login' });
await sendBackgroundMessage({ type: 'atlas.login' });
await render();
return;
}
if (kind === 'erp') {
await browser.runtime.sendMessage({ type: 'atlas.open', path: '/' });
await sendBackgroundMessage({ type: 'atlas.open', path: '/' });
return;
}
hint.textContent = '正在采集多 SKU…';
@@ -77,18 +76,19 @@ export default defineContentScript({
? requestMarketplacePageState
: undefined,
);
const response = await browser.runtime.sendMessage({
hint.textContent = `已解析 ${payload.variants.length || 1} 个 SKU,正在入库…`;
const response = await sendBackgroundMessage<{ result: { data: { editor_entry_path: string } } }>({
type: 'atlas.collect',
payload,
});
if (kind === 'edit') {
await browser.runtime.sendMessage({
await sendBackgroundMessage({
type: 'atlas.open',
path: response.result.data.editor_entry_path,
});
}
hint.textContent =
kind === 'edit' ? '已打开采集数据测试页' : '已进入采集箱';
`${kind === 'edit' ? '已打开商品编辑页' : '已进入采集箱'}(${payload.variants.length || 1} 个 SKU)`;
} catch (error) {
hint.textContent =
error instanceof Error ? error.message : '采集失败,请重试';
@@ -105,6 +105,10 @@ export default defineContentScript({
},
});
async function sendBackgroundMessage<T>(message: { type: string; payload?: unknown; path?: string }) {
return unwrapBackgroundResponse<T>(await browser.runtime.sendMessage(message));
}
const requestOzonPageJson: OzonPageJsonFetcher = ({ kind, sku }) =>
new Promise((resolve, reject) => {
const requestId = crypto.randomUUID();
+16 -2
View File
@@ -1,4 +1,6 @@
import type { CollectedProductPayload } from '@atlas/contracts';
import { requestError } from './request-errors.js';
import { validateCollectedPayload } from './collection-payload-validation.js';
const apiOrigin = (import.meta.env.ATLAS_API_ORIGIN as string | undefined) ?? 'http://localhost:8200';
const webOrigin = (import.meta.env.ATLAS_WEB_ORIGIN as string | undefined) ?? 'http://localhost:8100';
@@ -10,7 +12,10 @@ async function saveToken(token: string) { await browser.storage.local.set({ [sto
async function clearTokens() { await browser.storage.local.remove(storageKey); }
async function request(path: string, options: RequestInit = {}, token?: string) {
const response = await fetch(`${apiOrigin}/api${path}`, { ...options, headers: { Accept: 'application/json', ...(options.headers ?? {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) } });
if (!response.ok) { const body = await response.json().catch(() => null) as { error?: { message?: string } } | null; throw Object.assign(new Error(body?.error?.message ?? `请求失败(HTTP ${response.status})`), { status: response.status }); }
if (!response.ok) {
const body: unknown = await response.json().catch(() => null);
throw requestError(body, response.status);
}
return response.status === 204 ? undefined : response.json() as Promise<{ data: unknown }>;
}
async function activeAccessToken() {
@@ -43,7 +48,16 @@ export async function login() {
}
throw new Error('登录超时,请在 Atlas ERP 网页完成登录后重试');
}
export async function submit(payload: CollectedProductPayload) { const token = await activeAccessToken(); if (!token) throw new Error('请先登录 Atlas ERP'); return request('/extension/materials', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() }, body: JSON.stringify(payload) }, token) as Promise<{ data: { editor_entry_path: string; product_id: string; collection_item: { id: string }; reused: boolean } }>; }
export async function submit(payload: CollectedProductPayload) {
const validated = validateCollectedPayload(payload);
const token = await activeAccessToken();
if (!token) throw new Error('请先登录 Atlas ERP');
return request('/extension/materials', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify(validated),
}, token) as Promise<{ data: { editor_entry_path: string; product_id: string; collection_item: { id: string }; reused: boolean } }>;
}
export async function openErp(path = '/') { await browser.tabs.create({ url: new URL(path, webOrigin).toString() }); }
async function readWebAccessToken(tabId: number) {
try {
@@ -0,0 +1,11 @@
import { collectedProductPayloadSchema } from '@atlas/contracts';
/** 先按共享契约校验采集载荷,失败时保留字段路径,不丢弃或截断真实 SKU。 */
export function validateCollectedPayload(payload: unknown) {
const result = collectedProductPayloadSchema.safeParse(payload);
if (result.success) return result.data;
const details = result.error.issues.slice(0, 3).map((issue) =>
`${issue.path.join('.') || '采集数据'}:${issue.message}`,
);
throw new Error(`采集数据不符合要求(${details.join(';')})`);
}
+58
View File
@@ -0,0 +1,58 @@
/** 采集浮窗只负责展示与折叠,登录、采集和页面跳转交由内容脚本处理。 */
export function renderCollectorPanel(
root: ShadowRoot,
authenticated: boolean,
minimized: boolean,
onToggle: (minimized: boolean) => void,
onAction: (kind: string) => void,
) {
root.innerHTML = `
<style>
:host{all:initial}
.collector{font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2937}
button{box-sizing:border-box;cursor:pointer}
button:focus-visible{outline:3px solid #93c5fd;outline-offset:3px}
.box{width:156px;padding:12px;background:#fff;border-radius:12px;box-shadow:0 8px 28px #0003}
.head{display:flex;justify-content:space-between;align-items:center;font-weight:700;margin-bottom:10px;gap:8px}
.minimize{display:inline-flex;align-items:center;justify-content:center;flex:0 0 24px;width:24px;height:24px;padding:0;border:0;border-radius:50%;background:#f3f4f6;color:#4b5563}
.minimize:hover{background:#e5e7eb;color:#1f2937}
.minimize svg{width:14px;height:14px;pointer-events:none}
.btn{display:block;width:100%;border:0;border-radius:18px;padding:9px 8px;margin:7px 0;background:#2878f0;color:#fff;font-weight:600}
.btn.primary{background:#ff5353}
.link{display:block;width:100%;border:0;background:transparent;color:#1668dc;padding:5px}
.hint{font-size:12px;color:#6b7280;text-align:center;margin-top:6px;overflow-wrap:anywhere}
.launcher{display:none;align-items:center;justify-content:center;width:40px;height:40px;padding:0;border:0;border-radius:50%;background:#2878f0;color:#fff;box-shadow:0 4px 16px #0003;font:700 20px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.launcher:hover{background:#1668dc}
.is-minimized .box{display:none}
.is-minimized .launcher{display:inline-flex}
</style>
<div class="collector${minimized ? ' is-minimized' : ''}">
<div class="box" id="atlas-collector-panel">
<div class="head">
<span>Atlas ERP</span>
<button class="minimize" type="button" aria-label="最小化采集窗口" title="最小化采集窗口">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
${authenticated
? '<button class="btn primary" type="button" data-action="edit">编辑上架</button><button class="btn" type="button" data-action="collect">采集商品</button><button class="link" type="button" data-action="erp">进入 ERP</button>'
: '<button class="btn primary" type="button" data-action="login">登录 Atlas ERP</button>'}
<div class="hint" data-hint></div>
</div>
<button class="launcher" type="button" aria-label="展开采集窗口" title="展开采集窗口" aria-controls="atlas-collector-panel" aria-expanded="${!minimized}">A</button>
</div>`;
const panel = root.querySelector<HTMLElement>('.collector')!;
const minimize = root.querySelector<HTMLButtonElement>('.minimize')!;
const launcher = root.querySelector<HTMLButtonElement>('.launcher')!;
const toggle = (next: boolean) => {
panel.classList.toggle('is-minimized', next);
launcher.setAttribute('aria-expanded', String(!next));
onToggle(next);
(next ? launcher : minimize).focus();
};
minimize.addEventListener('click', () => toggle(true));
launcher.addEventListener('click', () => toggle(false));
root.querySelectorAll<HTMLButtonElement>('[data-action]').forEach((button) =>
button.addEventListener('click', () => onAction(button.dataset.action ?? '')),
);
}
+55 -2
View File
@@ -29,6 +29,14 @@ type ImageGroup = {
*/
const DOM_IMAGE_PROFILES: Record<DomMediaPlatform, ImageGroup[]> = {
'1688': [
{
role: 'source_main',
relation: 'inherited_product_default',
// 画廊缩略项虽小,但背景地址指向商品原图;位置与控件类型比尺寸更可靠。
selectors: ['.od-picture-gallery-list > .v-image-cover'],
sourceProps: ['backgroundImage'],
excludeWithin: ['.video-image-cover', '.v-image-cover:has(.cpv-icon, .cpv-tag-name)'],
},
{
role: 'source_main',
relation: 'inherited_product_default',
@@ -43,6 +51,7 @@ const DOM_IMAGE_PROFILES: Record<DomMediaPlatform, ImageGroup[]> = {
'.detail-gallery-turn img.detail-gallery-img',
'.img-list-wrapper img.od-gallery-img',
],
excludeWithin: ['.video-image-cover', '.v-image-cover:has(.cpv-icon, .cpv-tag-name)'],
minWidth: 200,
minHeight: 200,
},
@@ -189,15 +198,58 @@ export function collectPlatformDomAssets(
}
}
}
if (platform === '1688') add1688GalleryVideos(root, pageUrl, assets);
return assets;
}
/** 只读取商品画廊内播放器的公开直链,不扫描全页视频或根据封面猜测地址。 */
function add1688GalleryVideos(root: Document, pageUrl: string, assets: Asset[]) {
const selectors = [
'[data-module="od_picture_gallery"] video',
'.module-od-picture-gallery video',
'.od-picture-gallery video',
'.od-picture-gallery-list video',
'.detail-gallery-turn video',
'.od-gallery-turn-item-wrapper video',
];
const seen = new Set<string>();
for (const selector of selectors) {
for (const video of Array.from(root.querySelectorAll<HTMLVideoElement>(selector))) {
const candidates = [video.currentSrc, video.getAttribute('src'),
...Array.from(video.querySelectorAll('source')).map((source) => source.getAttribute('src'))];
const url = candidates.map((candidate) => normaliseVideoUrl(candidate ?? '', pageUrl)).find(Boolean);
if (!url || seen.has(url) || assets.length >= 500) continue;
seen.add(url);
const cover = root.querySelector<HTMLElement>('.od-picture-gallery-list > .video-image-cover');
const poster = video.poster || video.getAttribute('poster');
const posterUrl = (poster ? normaliseImageUrl(poster, pageUrl, '1688') : undefined) ??
(cover ? imageUrlFromElement(cover, ['backgroundImage'], pageUrl, '1688') : undefined);
assets.push({
url, mediaType: 'video', role: 'source_main', relation: 'product_candidate',
...(posterUrl?.startsWith('https://') ? { posterUrl } : {}),
sortOrder: assets.length, evidenceRef: '1688:dom:source_video',
});
}
}
}
export function normaliseVideoUrl(raw: string, pageUrl: string): string | undefined {
if (!raw.trim()) return undefined;
try {
const url = new URL(raw, pageUrl);
return url.protocol === 'https:' && /\.(mp4|webm)$/i.test(url.pathname) ? url.href : undefined;
} catch {
return undefined;
}
}
/** Restores the original CDN URL before payload storage and de-duplication. */
export function normaliseImageUrl(
raw: string,
pageUrl: string,
platform: SupportedMediaPlatform,
): string | undefined {
if (!raw.trim() || /^(?:undefined|null|none)$/i.test(raw.trim())) return undefined;
let url: URL;
try {
url = new URL(raw, pageUrl);
@@ -266,9 +318,10 @@ function imageUrlFromElement(
else if (prop === 'currentSrc')
raw = (target as HTMLImageElement).currentSrc ?? '';
else if (prop === 'backgroundImage') {
// 优先使用内联地址,避免占位值 undefined 被计算样式解析成一个页面相对 URL。
const style =
target.ownerDocument.defaultView?.getComputedStyle(target)
.backgroundImage ?? target.style.backgroundImage;
target.style.backgroundImage ||
target.ownerDocument.defaultView?.getComputedStyle(target).backgroundImage || '';
raw = style.match(/url\(["']?(.*?)["']?\)/i)?.[1] ?? '';
// Some 1688 SKU selectors directly match an <img> despite the
// group being background-image-first.
+40
View File
@@ -0,0 +1,40 @@
type FieldIssue = { field: string; message: string };
function describeIssues(message: string, issues: FieldIssue[]) {
const details = issues.slice(0, 3).map((issue) => `${issue.field}:${issue.message}`);
return details.length ? `${message}(${details.join(';')})` : message;
}
/** 只展示服务端错误信封的业务说明,避免把请求或授权信息带入错误提示。 */
export function requestError(body: unknown, status: number) {
const error = body && typeof body === 'object' && 'error' in body
? body.error
: null;
const info = error && typeof error === 'object'
? error as { message?: unknown; details?: unknown }
: null;
const message = typeof info?.message === 'string'
? info.message
: `请求失败(HTTP ${status})`;
const issues = Array.isArray(info?.details)
? info.details.filter((item): item is FieldIssue =>
!!item && typeof item === 'object' &&
typeof item.field === 'string' && typeof item.message === 'string')
: [];
return Object.assign(new Error(describeIssues(message, issues)), { status });
}
/** Chrome 消息只返回可序列化错误,交给内容脚本显示,避免后台未处理异常。 */
export function backgroundError(error: unknown) {
return { error: { message: error instanceof Error ? error.message : '操作失败,请重试' } };
}
export function unwrapBackgroundResponse<T>(response: unknown): T {
if (!response || typeof response !== 'object')
throw new Error('插件后台未响应,请重载扩展并刷新商品页');
if ('error' in response && response.error && typeof response.error === 'object') {
const error = response.error as { message?: unknown };
throw new Error(typeof error.message === 'string' ? error.message : '操作失败,请重试');
}
return response as T;
}
+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;
+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);
});
+9
View File
@@ -1,9 +1,18 @@
import { defineConfig } from 'wxt';
import { fileURLToPath } from 'node:url';
const atlasWebOrigin = new URL(process.env.ATLAS_WEB_ORIGIN ?? 'http://localhost:8100').origin;
export default defineConfig({
modules: ['@wxt-dev/module-react'],
vite: () => ({
resolve: {
// 开发和正式构建均读取共享源码,防止开发包持续缓存旧的校验上限。
alias: {
'@atlas/contracts': fileURLToPath(new URL('../../packages/contracts/src/index.ts', import.meta.url)),
},
},
}),
// 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突
dev: {
server: {