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