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
+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;