Expand seller store functionality
This commit is contained in:
Vendored
+1
@@ -7,6 +7,7 @@ declare module "wxt/browser" {
|
||||
| "/"
|
||||
| "/background.js"
|
||||
| "/content-scripts/collector.js"
|
||||
| "/content-scripts/ozon-page-json.js"
|
||||
type HtmlPublicPath = Extract<PublicPath, `${string}.html`>
|
||||
export interface WxtRuntime {
|
||||
getURL(path: PublicPath): string;
|
||||
|
||||
@@ -1,15 +1,151 @@
|
||||
import { collectCurrentProduct, supportedProductPlatform } from '../lib/source-parsers.js';
|
||||
import {
|
||||
collectCurrentProduct,
|
||||
supportedProductPlatform,
|
||||
} from '../lib/source-parsers.js';
|
||||
import type { OzonPageJsonFetcher } from '../lib/ozon-parser.js';
|
||||
|
||||
export default defineContentScript({ matches: ['*://www.ozon.ru/*', '*://www.ozon.kz/*', '*://www.ozon.by/*', '*://detail.1688.com/*', '*://item.taobao.com/*', '*://detail.tmall.com/*'], runAt: 'document_idle', main() {
|
||||
let mount: HTMLDivElement | null = null; let shadow: ShadowRoot | null = null; let observedUrl = location.href;
|
||||
const render = async () => {
|
||||
if (!supportedProductPlatform()) { mount?.remove(); mount = null; shadow = null; return; }
|
||||
if (!mount) { mount = document.createElement('div'); mount.style.cssText = 'position:fixed;right:22px;bottom:80px;z-index:2147483647'; document.documentElement.append(mount); shadow = mount.attachShadow({ mode: 'open' }); }
|
||||
const root = shadow!; const session = await browser.runtime.sendMessage({ 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)));
|
||||
};
|
||||
const action = async (kind: string, shadow: ShadowRoot) => { const hint = shadow.querySelector<HTMLElement>('[data-hint]')!; try { if (kind === 'login') { hint.textContent = '正在登录…'; await browser.runtime.sendMessage({ type: 'atlas.login' }); await render(); return; } if (kind === 'erp') { await browser.runtime.sendMessage({ type: 'atlas.open', path: '/' }); return; } hint.textContent = '正在采集…'; const payload = collectCurrentProduct(); const response = await browser.runtime.sendMessage({ type: 'atlas.collect', payload }); if (kind === 'edit') await browser.runtime.sendMessage({ type: 'atlas.open', path: response.result.data.editor_entry_path }); hint.textContent = kind === 'edit' ? '已打开采集数据测试页' : '已进入采集箱'; } catch (error) { hint.textContent = error instanceof Error ? error.message : '采集失败,请重试'; } };
|
||||
void render(); setInterval(() => { if (location.href !== observedUrl) { observedUrl = location.href; void render(); } }, 700);
|
||||
} });
|
||||
export default defineContentScript({
|
||||
matches: [
|
||||
'*://www.ozon.ru/*',
|
||||
'*://www.ozon.kz/*',
|
||||
'*://www.ozon.by/*',
|
||||
'*://detail.1688.com/*',
|
||||
'*://item.taobao.com/*',
|
||||
'*://detail.tmall.com/*',
|
||||
],
|
||||
runAt: 'document_idle',
|
||||
main() {
|
||||
let mount: HTMLDivElement | null = null;
|
||||
let shadow: ShadowRoot | null = null;
|
||||
let observedUrl = location.href;
|
||||
|
||||
const render = async () => {
|
||||
if (!supportedProductPlatform()) {
|
||||
mount?.remove();
|
||||
mount = null;
|
||||
shadow = null;
|
||||
return;
|
||||
}
|
||||
if (!mount) {
|
||||
mount = document.createElement('div');
|
||||
mount.style.cssText =
|
||||
'position:fixed;right:22px;bottom:80px;z-index:2147483647';
|
||||
document.documentElement.append(mount);
|
||||
shadow = mount.attachShadow({ mode: 'open' });
|
||||
}
|
||||
const root = shadow!;
|
||||
const session = await browser.runtime
|
||||
.sendMessage({ 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),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const action = async (kind: string, root: ShadowRoot) => {
|
||||
const hint = root.querySelector<HTMLElement>('[data-hint]')!;
|
||||
try {
|
||||
if (kind === 'login') {
|
||||
hint.textContent = '正在登录…';
|
||||
await browser.runtime.sendMessage({ type: 'atlas.login' });
|
||||
await render();
|
||||
return;
|
||||
}
|
||||
if (kind === 'erp') {
|
||||
await browser.runtime.sendMessage({ type: 'atlas.open', path: '/' });
|
||||
return;
|
||||
}
|
||||
hint.textContent = '正在采集多 SKU…';
|
||||
const payload = await collectCurrentProduct(
|
||||
document,
|
||||
location.href,
|
||||
supportedProductPlatform() === 'ozon'
|
||||
? requestOzonPageJson
|
||||
: undefined,
|
||||
);
|
||||
const response = await browser.runtime.sendMessage({
|
||||
type: 'atlas.collect',
|
||||
payload,
|
||||
});
|
||||
if (kind === 'edit') {
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'atlas.open',
|
||||
path: response.result.data.editor_entry_path,
|
||||
});
|
||||
}
|
||||
hint.textContent =
|
||||
kind === 'edit' ? '已打开采集数据测试页' : '已进入采集箱';
|
||||
} catch (error) {
|
||||
hint.textContent =
|
||||
error instanceof Error ? error.message : '采集失败,请重试';
|
||||
}
|
||||
};
|
||||
|
||||
void render();
|
||||
setInterval(() => {
|
||||
if (location.href !== observedUrl) {
|
||||
observedUrl = location.href;
|
||||
void render();
|
||||
}
|
||||
}, 700);
|
||||
},
|
||||
});
|
||||
|
||||
const requestOzonPageJson: OzonPageJsonFetcher = ({ kind, sku }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const requestId = crypto.randomUUID();
|
||||
const timeout = window.setTimeout(() => {
|
||||
window.removeEventListener('message', receive);
|
||||
reject(new Error('Ozon 页面状态读取超时'));
|
||||
}, 15_000);
|
||||
const receive = (event: MessageEvent<unknown>) => {
|
||||
if (event.source !== window || !isBridgeResponse(event.data, requestId)) return;
|
||||
window.clearTimeout(timeout);
|
||||
window.removeEventListener('message', receive);
|
||||
if (event.data.ok) resolve(event.data.data);
|
||||
else reject(new Error(event.data.error));
|
||||
};
|
||||
window.addEventListener('message', receive);
|
||||
window.postMessage(
|
||||
{
|
||||
source: 'atlas-collector',
|
||||
type: 'ozon-page-json.request',
|
||||
requestId,
|
||||
kind,
|
||||
sku,
|
||||
},
|
||||
location.origin,
|
||||
);
|
||||
});
|
||||
|
||||
function isBridgeResponse(
|
||||
value: unknown,
|
||||
requestId: string,
|
||||
): value is {
|
||||
source: 'atlas-collector';
|
||||
type: 'ozon-page-json.response';
|
||||
requestId: string;
|
||||
ok: boolean;
|
||||
data?: unknown;
|
||||
error: string;
|
||||
} {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const message = value as Record<string, unknown>;
|
||||
return (
|
||||
message.source === 'atlas-collector' &&
|
||||
message.type === 'ozon-page-json.response' &&
|
||||
message.requestId === requestId &&
|
||||
typeof message.ok === 'boolean' &&
|
||||
(message.ok || typeof message.error === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Runs in Ozon's MAIN world so the request has exactly the same origin and
|
||||
* session context as the page. It accepts only Atlas' two fixed, numeric-SKU
|
||||
* routes and returns a small widget-state allowlist; it never exposes cookies,
|
||||
* request headers or arbitrary fetch capability to the isolated script.
|
||||
*/
|
||||
export default defineContentScript({
|
||||
matches: ['*://www.ozon.ru/*', '*://www.ozon.kz/*', '*://www.ozon.by/*'],
|
||||
world: 'MAIN',
|
||||
runAt: 'document_idle',
|
||||
main() {
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.source !== window || !isRequest(event.data)) return;
|
||||
void respond(event.data);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
type Request = {
|
||||
source: 'atlas-collector';
|
||||
type: 'ozon-page-json.request';
|
||||
requestId: string;
|
||||
kind: 'modal-aspects' | 'product' | 'product-detail';
|
||||
sku: string;
|
||||
};
|
||||
|
||||
function isRequest(value: unknown): value is Request {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
return false;
|
||||
const data = value as Record<string, unknown>;
|
||||
return (
|
||||
data.source === 'atlas-collector' &&
|
||||
data.type === 'ozon-page-json.request' &&
|
||||
typeof data.requestId === 'string' &&
|
||||
/^[a-zA-Z0-9-]{16,120}$/.test(data.requestId) &&
|
||||
(data.kind === 'modal-aspects' ||
|
||||
data.kind === 'product' ||
|
||||
data.kind === 'product-detail') &&
|
||||
typeof data.sku === 'string' &&
|
||||
/^\d{4,}$/.test(data.sku)
|
||||
);
|
||||
}
|
||||
|
||||
async function respond(request: Request) {
|
||||
try {
|
||||
const pagePath =
|
||||
request.kind === 'modal-aspects'
|
||||
? `/modal/aspectsNew?product_id=${request.sku}`
|
||||
: request.kind === 'product-detail'
|
||||
? `/product/${request.sku}/?layout_container=pdpPage2column&layout_page_index=2`
|
||||
: `/product/${request.sku}/`;
|
||||
const response = await fetch(
|
||||
`/api/entrypoint-api.bx/page/json/v2?url=${encodeURIComponent(pagePath)}`,
|
||||
{ credentials: 'same-origin' },
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(`Ozon 页面状态请求失败(HTTP ${response.status})`);
|
||||
const value = await response.json();
|
||||
window.postMessage(
|
||||
{
|
||||
source: 'atlas-collector',
|
||||
type: 'ozon-page-json.response',
|
||||
requestId: request.requestId,
|
||||
ok: true,
|
||||
data: allowlistedPageState(value),
|
||||
},
|
||||
location.origin,
|
||||
);
|
||||
} catch (error) {
|
||||
window.postMessage(
|
||||
{
|
||||
source: 'atlas-collector',
|
||||
type: 'ozon-page-json.response',
|
||||
requestId: request.requestId,
|
||||
ok: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Ozon 页面状态请求失败',
|
||||
},
|
||||
location.origin,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function allowlistedPageState(value: unknown) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
return { widgetStates: {} };
|
||||
const states = (value as { widgetStates?: unknown }).widgetStates;
|
||||
if (!states || typeof states !== 'object' || Array.isArray(states))
|
||||
return { widgetStates: {} };
|
||||
const widgetStates: Record<string, unknown> = {};
|
||||
for (const [name, state] of Object.entries(
|
||||
states as Record<string, unknown>,
|
||||
)) {
|
||||
if (
|
||||
/^web(Aspects|AspectsModal|ProductHeading|Price|Gallery|Description|RichContent|Features|ShortCharacteristics|DetailedCharacteristics|Characteristics)-/.test(
|
||||
name,
|
||||
)
|
||||
) {
|
||||
widgetStates[name] = state;
|
||||
}
|
||||
}
|
||||
return { widgetStates };
|
||||
}
|
||||
+64
-20
@@ -2,13 +2,11 @@ import type { CollectedProductPayload } from '@atlas/contracts';
|
||||
|
||||
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';
|
||||
const clientId = (import.meta.env.ATLAS_EXTENSION_CLIENT_ID as string | undefined) ?? 'atlas-collector';
|
||||
type Tokens = { access_token: string; expires_in: number; refresh_token: string; refresh_token_expires_in: number; token_type: 'Bearer' };
|
||||
type Session = { authenticated: true; workspace: { id: string; available: boolean }; can_collect: boolean };
|
||||
const storageKey = 'atlas.extension.tokens.v1';
|
||||
const storageKey = 'atlas.extension.web-token.v1';
|
||||
|
||||
async function loadTokens() { return (await browser.storage.local.get(storageKey))[storageKey] as Tokens | undefined; }
|
||||
async function saveTokens(tokens: Tokens) { await browser.storage.local.set({ [storageKey]: tokens }); }
|
||||
async function loadToken() { return (await browser.storage.local.get(storageKey))[storageKey] as string | undefined; }
|
||||
async function saveToken(token: string) { await browser.storage.local.set({ [storageKey]: token }); }
|
||||
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}` } : {}) } });
|
||||
@@ -16,24 +14,70 @@ async function request(path: string, options: RequestInit = {}, token?: string)
|
||||
return response.status === 204 ? undefined : response.json() as Promise<{ data: unknown }>;
|
||||
}
|
||||
async function activeAccessToken() {
|
||||
let tokens = await loadTokens(); if (!tokens) return null;
|
||||
try { await request('/extension/session', {}, tokens.access_token); return tokens.access_token; }
|
||||
catch (error) {
|
||||
if ((error as { status?: number }).status !== 401) throw error;
|
||||
try { const refreshed = await request('/extension/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: tokens.refresh_token }) }) as { data: Tokens }; tokens = refreshed.data; await saveTokens(tokens); return tokens.access_token; }
|
||||
catch { await clearTokens(); return null; }
|
||||
}
|
||||
const cached = await loadToken();
|
||||
const usableCached = await validateAccessToken(cached);
|
||||
if (usableCached) return usableCached;
|
||||
|
||||
// 用户可能先在 Atlas Web 登录、再打开来源商品页。此时插件还没有自己的
|
||||
// 缓存,仍应直接从已打开的 Atlas 标签页同步,而不是错误地展示“登录”。
|
||||
const synchronized = await syncAccessTokenFromWebTabs();
|
||||
return validateAccessToken(synchronized);
|
||||
}
|
||||
export async function getSession(): Promise<Session | null> { const token = await activeAccessToken(); if (!token) return null; return (await request('/extension/session', {}, token) as { data: Session }).data; }
|
||||
export async function login() {
|
||||
const verifier = crypto.getRandomValues(new Uint8Array(64)); const verifierText = base64url(verifier); const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifierText))));
|
||||
const state = base64url(crypto.getRandomValues(new Uint8Array(32))); const redirectUri = browser.identity.getRedirectURL('callback');
|
||||
const authorize = new URL(`${apiOrigin}/api/extension/authorize`); authorize.search = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, state, code_challenge: challenge, code_challenge_method: 'S256' }).toString();
|
||||
const callback = await browser.identity.launchWebAuthFlow({ url: authorize.toString(), interactive: true });
|
||||
if (!callback) throw new Error('未完成 Atlas ERP 登录'); const result = new URL(callback); if (result.searchParams.get('state') !== state || !result.searchParams.get('code')) throw new Error('扩展授权状态校验失败');
|
||||
const exchanged = await request('/extension/auth/exchange', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: clientId, code: result.searchParams.get('code'), redirect_uri: redirectUri, code_verifier: verifierText }) }) as { data: Tokens };
|
||||
await saveTokens(exchanged.data); return getSession();
|
||||
const currentSession = await getSession();
|
||||
if (currentSession) return currentSession;
|
||||
|
||||
const tab = await browser.tabs.create({ url: webOrigin, active: true });
|
||||
if (tab.id === undefined) throw new Error('无法打开 Atlas ERP 登录页');
|
||||
const deadline = Date.now() + 3 * 60 * 1_000;
|
||||
while (Date.now() < deadline) {
|
||||
const token = await readWebAccessToken(tab.id);
|
||||
if (token) {
|
||||
await saveToken(token);
|
||||
const session = await getSession();
|
||||
if (session) return session;
|
||||
await clearTokens();
|
||||
}
|
||||
await delay(2_000);
|
||||
}
|
||||
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 openErp(path = '/') { await browser.tabs.create({ url: new URL(path, webOrigin).toString() }); }
|
||||
function base64url(bytes: Uint8Array) { let value = ''; for (const byte of bytes) value += String.fromCharCode(byte); return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
|
||||
async function readWebAccessToken(tabId: number) {
|
||||
try {
|
||||
const result = await browser.scripting.executeScript({ target: { tabId }, func: () => localStorage.getItem('atlas.extension.web-access') });
|
||||
const raw = result[0]?.result;
|
||||
if (typeof raw !== 'string') return null;
|
||||
const parsed = JSON.parse(raw) as { accessToken?: unknown };
|
||||
return typeof parsed.accessToken === 'string' && parsed.accessToken.length > 0 ? parsed.accessToken : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function syncAccessTokenFromWebTabs() {
|
||||
const tabs = await browser.tabs.query({ url: `${webOrigin}/*` });
|
||||
// tabs.query 返回的顺序未承诺。倒序优先尝试最近的标签页(Chrome 通常最后返回它)。
|
||||
for (const tab of tabs.reverse()) {
|
||||
if (tab.id === undefined) continue;
|
||||
const token = await readWebAccessToken(tab.id);
|
||||
if (token) {
|
||||
await saveToken(token);
|
||||
return token;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async function validateAccessToken(token: string | undefined | null) {
|
||||
if (!token) return null;
|
||||
try {
|
||||
await request('/extension/session', {}, token);
|
||||
return token;
|
||||
} catch (error) {
|
||||
if ((error as { status?: number }).status === 401) {
|
||||
await clearTokens();
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function delay(milliseconds: number) { return new Promise<void>((resolve) => setTimeout(resolve, milliseconds)); }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,336 @@
|
||||
import type { CollectedProductPayload } from '@atlas/contracts';
|
||||
|
||||
type SupportedMediaPlatform = 'ozon' | '1688' | 'taobao' | 'tmall';
|
||||
type DomMediaPlatform = Exclude<SupportedMediaPlatform, 'ozon'>;
|
||||
type Asset = CollectedProductPayload['assets'][number];
|
||||
|
||||
type ImageGroup = {
|
||||
role: Asset['role'];
|
||||
relation: Asset['relation'];
|
||||
selectors: string[];
|
||||
sourceProps?: Array<
|
||||
| 'data-lazyload-src'
|
||||
| 'data-src'
|
||||
| 'srcset'
|
||||
| 'currentSrc'
|
||||
| 'src'
|
||||
| 'backgroundImage'
|
||||
>;
|
||||
excludeWithin?: string[];
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* These profiles are ported from the proven collector extension. The crucial
|
||||
* invariant is that images always come from a product widget, never from a
|
||||
* page-wide `document.images` scan (which also includes logos, ads and
|
||||
* recommendation cards).
|
||||
*/
|
||||
const DOM_IMAGE_PROFILES: Record<DomMediaPlatform, ImageGroup[]> = {
|
||||
'1688': [
|
||||
{
|
||||
role: 'source_main',
|
||||
relation: 'inherited_product_default',
|
||||
selectors: [
|
||||
'[data-module="od_picture_gallery"] img',
|
||||
'.module-od-picture-gallery img',
|
||||
'#recyclerview .detail-gallery-turn-wrapper .detail-gallery-img',
|
||||
'#screen .od-gallery-turn-item-wrapper .od-gallery-img',
|
||||
'#content .od-scroller-item .v-image-cover',
|
||||
'#content .od-picture-gallery-list .v-image-cover',
|
||||
'#dt-tab img',
|
||||
'.detail-gallery-turn img.detail-gallery-img',
|
||||
'.img-list-wrapper img.od-gallery-img',
|
||||
],
|
||||
minWidth: 200,
|
||||
minHeight: 200,
|
||||
},
|
||||
{
|
||||
role: 'source_sku',
|
||||
relation: 'axis_value',
|
||||
selectors: [
|
||||
'[data-module="od_sku_selection"] img',
|
||||
'.module-od-sku-selection img',
|
||||
'.pc-sku-wrapper .prop-item-inner-wrapper',
|
||||
'.sku-item-wrapper',
|
||||
'.specification-cell',
|
||||
'.sku-filter-button',
|
||||
'.expand-view-item',
|
||||
'.feature-item img',
|
||||
],
|
||||
sourceProps: ['backgroundImage'],
|
||||
minWidth: 20,
|
||||
minHeight: 20,
|
||||
},
|
||||
{
|
||||
role: 'source_detail',
|
||||
relation: 'product_candidate',
|
||||
selectors: [
|
||||
'#detail img',
|
||||
'.de-description-detail img',
|
||||
'#detailContentContainer img',
|
||||
'.html-description img',
|
||||
],
|
||||
minWidth: 300,
|
||||
minHeight: 100,
|
||||
},
|
||||
],
|
||||
taobao: [
|
||||
{
|
||||
role: 'source_main',
|
||||
relation: 'inherited_product_default',
|
||||
selectors: [
|
||||
'[class*="picGallery--"] img',
|
||||
'#picGalleryEle img',
|
||||
'[class*="thumbnailPic--"]',
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'source_sku',
|
||||
relation: 'axis_value',
|
||||
selectors: [
|
||||
'[class*="valueItem--"]',
|
||||
'[class*="valueItemImgWrap--"]',
|
||||
],
|
||||
minWidth: 20,
|
||||
minHeight: 20,
|
||||
},
|
||||
taobaoDetailProfile(),
|
||||
],
|
||||
tmall: [
|
||||
{
|
||||
role: 'source_main',
|
||||
relation: 'inherited_product_default',
|
||||
selectors: [
|
||||
'[class*="picGallery--"] img',
|
||||
'#picGalleryEle img',
|
||||
'[class*="thumbnailPic--"]',
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'source_sku',
|
||||
relation: 'axis_value',
|
||||
selectors: [
|
||||
'[class*="valueItem--"]',
|
||||
'[class*="valueItemImgWrap--"]',
|
||||
],
|
||||
minWidth: 20,
|
||||
minHeight: 20,
|
||||
},
|
||||
taobaoDetailProfile(),
|
||||
],
|
||||
};
|
||||
|
||||
function taobaoDetailProfile(): ImageGroup {
|
||||
return {
|
||||
role: 'source_detail',
|
||||
relation: 'product_candidate',
|
||||
selectors: [
|
||||
'[class*="tabDetailWrap--"] img',
|
||||
'[class*="detailInfo--"] img',
|
||||
],
|
||||
// The old collector verified that review photos and recommendation
|
||||
// cards can be large enough to pass a size-only filter.
|
||||
excludeWithin: [
|
||||
'[class*="Comment--"]',
|
||||
'[class*="comments--"]',
|
||||
'[class*="userInfo--"]',
|
||||
'[class*="rate"]',
|
||||
'[class*="RecommendInfo--"]',
|
||||
'[data-spm="recommends"]',
|
||||
'[class*="recommend--"]',
|
||||
'[class*="cardPic--"]',
|
||||
],
|
||||
minWidth: 300,
|
||||
minHeight: 100,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectPlatformDomAssets(
|
||||
platform: DomMediaPlatform,
|
||||
root: Document,
|
||||
pageUrl: string,
|
||||
): Asset[] {
|
||||
const assets: Asset[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const group of DOM_IMAGE_PROFILES[platform]) {
|
||||
for (const selector of group.selectors) {
|
||||
for (const element of Array.from(
|
||||
root.querySelectorAll<HTMLElement>(selector),
|
||||
)) {
|
||||
if (
|
||||
group.excludeWithin?.some((excluded) =>
|
||||
element.closest(excluded),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
if (!passesDimensions(element, group)) continue;
|
||||
const url = imageUrlFromElement(
|
||||
element,
|
||||
group.sourceProps,
|
||||
pageUrl,
|
||||
platform,
|
||||
);
|
||||
if (!url) continue;
|
||||
// Keep the old collector's group-level de-duplication: an
|
||||
// aspect cover may legitimately also be a gallery image.
|
||||
const key = `${group.role}:${dedupeImageUrl(url, platform)}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
assets.push({
|
||||
url,
|
||||
role: group.role,
|
||||
relation: group.relation,
|
||||
sortOrder: assets.length,
|
||||
evidenceRef: `${platform}:dom:${group.role}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
/** Restores the original CDN URL before payload storage and de-duplication. */
|
||||
export function normaliseImageUrl(
|
||||
raw: string,
|
||||
pageUrl: string,
|
||||
platform: SupportedMediaPlatform,
|
||||
): string | undefined {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw, pageUrl);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
|
||||
|
||||
if (platform === 'ozon') {
|
||||
// Ozon thumbnails use /wc50/, /wc100/, /c600/, etc. The same media
|
||||
// without this segment is its original CDN resource.
|
||||
url.pathname = url.pathname
|
||||
.replace(/\/(?:wc|c)\d+\//i, '/')
|
||||
.replace(/\/{2,}/g, '/');
|
||||
} else {
|
||||
// Alibaba image URLs commonly append a transform after the file
|
||||
// extension (e.g. image.jpg_400x400.jpg). Keep the original image.
|
||||
const original = url.pathname.match(
|
||||
/^(.+?\.(?:jpe?g|png|gif|bmp|heic|webp|avif))_/i,
|
||||
);
|
||||
if (original) url.pathname = original[1];
|
||||
}
|
||||
for (const parameter of ['width', 'height', 'size', 'quality', 'w', 'h']) {
|
||||
url.searchParams.delete(parameter);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function dedupeImageUrl(url: string, platform: SupportedMediaPlatform) {
|
||||
const normalised = normaliseImageUrl(url, url, platform) ?? url;
|
||||
try {
|
||||
const parsed = new URL(normalised);
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return normalised;
|
||||
}
|
||||
}
|
||||
|
||||
function imageUrlFromElement(
|
||||
element: HTMLElement,
|
||||
sourceProps: ImageGroup['sourceProps'] = [
|
||||
'data-lazyload-src',
|
||||
'data-src',
|
||||
'srcset',
|
||||
'currentSrc',
|
||||
'src',
|
||||
'backgroundImage',
|
||||
],
|
||||
pageUrl: string,
|
||||
platform: SupportedMediaPlatform,
|
||||
) {
|
||||
const nestedTarget = element.matches('img, source')
|
||||
? null
|
||||
: element.querySelector<HTMLElement>('img, source');
|
||||
const imageTarget = element.matches('img, source') ? element : nestedTarget;
|
||||
for (const prop of sourceProps) {
|
||||
// 1688 uses background images on the selected SKU option container.
|
||||
// For every other property, prefer the actual nested image/source.
|
||||
const target = prop === 'backgroundImage' ? element : imageTarget;
|
||||
if (!target) continue;
|
||||
let raw = '';
|
||||
if (prop === 'srcset')
|
||||
raw = bestSrcset(target.getAttribute('srcset') ?? '');
|
||||
else if (prop === 'currentSrc')
|
||||
raw = (target as HTMLImageElement).currentSrc ?? '';
|
||||
else if (prop === 'backgroundImage') {
|
||||
const style =
|
||||
target.ownerDocument.defaultView?.getComputedStyle(target)
|
||||
.backgroundImage ?? target.style.backgroundImage;
|
||||
raw = style.match(/url\(["']?(.*?)["']?\)/i)?.[1] ?? '';
|
||||
// Some 1688 SKU selectors directly match an <img> despite the
|
||||
// group being background-image-first.
|
||||
if (!raw && target.tagName === 'IMG') {
|
||||
raw =
|
||||
(target as HTMLImageElement).currentSrc ||
|
||||
target.getAttribute('src') ||
|
||||
'';
|
||||
}
|
||||
if (!raw && nestedTarget && nestedTarget !== target) {
|
||||
const nestedStyle =
|
||||
nestedTarget.ownerDocument.defaultView?.getComputedStyle(
|
||||
nestedTarget,
|
||||
).backgroundImage ?? nestedTarget.style.backgroundImage;
|
||||
raw =
|
||||
nestedStyle.match(/url\(["']?(.*?)["']?\)/i)?.[1] ??
|
||||
((nestedTarget as HTMLImageElement).currentSrc ||
|
||||
nestedTarget.getAttribute('src') ||
|
||||
'');
|
||||
}
|
||||
} else raw = target.getAttribute(prop) ?? '';
|
||||
const url = raw && normaliseImageUrl(raw, pageUrl, platform);
|
||||
if (url) return url;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function passesDimensions(element: HTMLElement, group: ImageGroup) {
|
||||
if (!group.minWidth && !group.minHeight) return true;
|
||||
const image = element.matches('img')
|
||||
? (element as HTMLImageElement)
|
||||
: element.querySelector<HTMLImageElement>('img');
|
||||
const measured = image ?? element;
|
||||
const width = image?.naturalWidth || measured.offsetWidth || 0;
|
||||
const height = image?.naturalHeight || measured.offsetHeight || 0;
|
||||
// Browser snapshots and unrendered lazy images often report 0. As in the
|
||||
// old collector, only reject when a real measured size proves it is tiny.
|
||||
return !(
|
||||
width > 0 &&
|
||||
height > 0 &&
|
||||
(width < (group.minWidth ?? 0) || height < (group.minHeight ?? 0))
|
||||
);
|
||||
}
|
||||
|
||||
function bestSrcset(srcset: string) {
|
||||
let best = '';
|
||||
let bestSize = -1;
|
||||
for (const part of srcset
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)) {
|
||||
const [url, descriptor = ''] = part.split(/\s+/, 2);
|
||||
const width = descriptor.match(/^(\d+)w$/)?.[1];
|
||||
const density = descriptor.match(/^(\d+(?:\.\d+)?)x$/)?.[1];
|
||||
const size = width
|
||||
? Number(width)
|
||||
: density
|
||||
? Number(density) * 1_000
|
||||
: 0;
|
||||
if (size >= bestSize) {
|
||||
best = url;
|
||||
bestSize = size;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -1,32 +1,62 @@
|
||||
import type { CollectedProductPayload } from '@atlas/contracts';
|
||||
import { collectOzonProduct, isOzonProductPage } from './ozon-parser.js';
|
||||
import {
|
||||
collectOzonProduct,
|
||||
isOzonProductPage,
|
||||
type OzonPageJsonFetcher,
|
||||
} from './ozon-parser.js';
|
||||
import { collectPlatformDomAssets } from './product-media.js';
|
||||
|
||||
type SourcePlatform = '1688' | 'taobao' | 'tmall' | 'ozon';
|
||||
type Json = Record<string, unknown>;
|
||||
type Axis = CollectedProductPayload['variantAxes'][number];
|
||||
type Variant = CollectedProductPayload['variants'][number];
|
||||
|
||||
export function supportedProductPlatform(url = location.href): SourcePlatform | null {
|
||||
export function supportedProductPlatform(
|
||||
url = location.href,
|
||||
): SourcePlatform | null {
|
||||
if (isOzonProductPage(url)) return 'ozon';
|
||||
if (/^https:\/\/detail\.1688\.com\/offer\/\d+\.html/.test(url)) return '1688';
|
||||
if (/^https:\/\/detail\.1688\.com\/offer\/\d+\.html/.test(url))
|
||||
return '1688';
|
||||
if (/^https:\/\/item\.taobao\.com\/item\.htm/.test(url)) return 'taobao';
|
||||
if (/^https:\/\/detail\.tmall\.com\/item\.htm/.test(url)) return 'tmall';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function collectCurrentProduct(root: Document = document, pageUrl = location.href): CollectedProductPayload {
|
||||
export async function collectCurrentProduct(
|
||||
root: Document = document,
|
||||
pageUrl = location.href,
|
||||
fetchOzonPageJson?: OzonPageJsonFetcher,
|
||||
): Promise<CollectedProductPayload> {
|
||||
const platform = supportedProductPlatform(pageUrl);
|
||||
if (platform === 'ozon') return collectOzonProduct(root, pageUrl);
|
||||
if (platform === 'ozon')
|
||||
return collectOzonProduct(root, pageUrl, fetchOzonPageJson);
|
||||
if (!platform) throw new Error('当前页面不是支持的商品详情页');
|
||||
return platform === '1688' ? collect1688(root, pageUrl) : collectTaobao(root, pageUrl, platform);
|
||||
return platform === '1688'
|
||||
? collect1688(root, pageUrl)
|
||||
: collectTaobao(root, pageUrl, platform);
|
||||
}
|
||||
|
||||
function collect1688(root: Document, pageUrl: string): CollectedProductPayload {
|
||||
const state = pageState(root); const skuMap = findFirst(state, 'skuMapOriginal'); const skuProps = findFirst(state, 'skuProps');
|
||||
const axes = parse1688Axes(skuProps); const evidence: CollectedProductPayload['evidence'] = [{ ref: 'page:url', kind: 'page', path: 'location.href', raw_value: pageUrl }];
|
||||
const state = pageState(root);
|
||||
const skuMap = findFirst(state, 'skuMapOriginal');
|
||||
const skuProps = findFirst(state, 'skuProps');
|
||||
const axes = parse1688Axes(skuProps);
|
||||
const evidence: CollectedProductPayload['evidence'] = [
|
||||
{
|
||||
ref: 'page:url',
|
||||
kind: 'page',
|
||||
path: 'location.href',
|
||||
raw_value: pageUrl,
|
||||
},
|
||||
];
|
||||
const variants: Variant[] = [];
|
||||
if (isRecord(skuMap)) {
|
||||
evidence.push({ ref: '1688:sku-map', kind: 'state', path: 'skuMapOriginal', raw_value: `组合记录 ${Object.keys(skuMap).length} 条` });
|
||||
evidence.push({
|
||||
ref: '1688:sku-map',
|
||||
kind: 'state',
|
||||
path: 'skuMapOriginal',
|
||||
raw_value: `组合记录 ${Object.keys(skuMap).length} 条`,
|
||||
});
|
||||
for (const [combination, row] of Object.entries(skuMap)) {
|
||||
if (!isRecord(row)) continue;
|
||||
const sourceSku = stringAt(row, ['skuId', 'sku_id', 'skuCode']);
|
||||
@@ -34,50 +64,391 @@ function collect1688(root: Document, pageUrl: string): CollectedProductPayload {
|
||||
// record. Never expand it with the Cartesian product of skuProps.
|
||||
const sourceVariantKey = sourceSku ?? `1688:${combination}`;
|
||||
if (!combination || combination === 'undefined') continue;
|
||||
variants.push({ sourceVariantKey, ...(sourceSku ? { sourceSku } : {}), displayName: selectionsForCombination(combination, axes).map((item) => item.valueName).join(' / ') || `1688 SKU ${sourceSku ?? combination}`, selections: selectionsForCombination(combination, axes), ...(moneyAt(row, ['discountPrice', 'price', 'salePrice']) ? { sourcePrice: { amount: moneyAt(row, ['discountPrice', 'price', 'salePrice'])!, currencyCode: 'CNY' } } : {}), confidence: 'verified', evidenceRefs: ['1688:sku-map'] });
|
||||
variants.push({
|
||||
sourceVariantKey,
|
||||
...(sourceSku ? { sourceSku } : {}),
|
||||
displayName:
|
||||
selectionsForCombination(combination, axes)
|
||||
.map((item) => item.valueName)
|
||||
.join(' / ') || `1688 SKU ${sourceSku ?? combination}`,
|
||||
selections: selectionsForCombination(combination, axes),
|
||||
...(moneyAt(row, ['discountPrice', 'price', 'salePrice'])
|
||||
? {
|
||||
sourcePrice: {
|
||||
amount: moneyAt(row, [
|
||||
'discountPrice',
|
||||
'price',
|
||||
'salePrice',
|
||||
])!,
|
||||
currencyCode: 'CNY',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
confidence: 'verified',
|
||||
evidenceRefs: ['1688:sku-map'],
|
||||
});
|
||||
}
|
||||
}
|
||||
return basePayload('1688', offerId(pageUrl), root, pageUrl, axes, uniqueVariants(variants), evidence, '1688-state-1.0.0');
|
||||
return basePayload(
|
||||
'1688',
|
||||
offerId(pageUrl),
|
||||
root,
|
||||
pageUrl,
|
||||
axes,
|
||||
uniqueVariants(variants),
|
||||
evidence,
|
||||
'1688-state-1.0.0',
|
||||
);
|
||||
}
|
||||
|
||||
function collectTaobao(root: Document, pageUrl: string, platform: 'taobao' | 'tmall'): CollectedProductPayload {
|
||||
const state = pageState(root); const skuBase = findFirst(state, 'skuBase'); const skuCore = findFirst(state, 'skuCore');
|
||||
const axes = parseTaobaoAxes(isRecord(skuBase) ? skuBase.props : undefined); const evidence: CollectedProductPayload['evidence'] = [{ ref: 'page:url', kind: 'page', path: 'location.href', raw_value: pageUrl }];
|
||||
function collectTaobao(
|
||||
root: Document,
|
||||
pageUrl: string,
|
||||
platform: 'taobao' | 'tmall',
|
||||
): CollectedProductPayload {
|
||||
const state = pageState(root);
|
||||
const skuBase = findFirst(state, 'skuBase');
|
||||
const skuCore = findFirst(state, 'skuCore');
|
||||
const axes = parseTaobaoAxes(isRecord(skuBase) ? skuBase.props : undefined);
|
||||
const evidence: CollectedProductPayload['evidence'] = [
|
||||
{
|
||||
ref: 'page:url',
|
||||
kind: 'page',
|
||||
path: 'location.href',
|
||||
raw_value: pageUrl,
|
||||
},
|
||||
];
|
||||
const variants: Variant[] = [];
|
||||
const skus = isRecord(skuBase) && Array.isArray(skuBase.skus) ? skuBase.skus : [];
|
||||
const sku2info = isRecord(skuCore) && isRecord(skuCore.sku2info) ? skuCore.sku2info : {};
|
||||
if (skus.length) evidence.push({ ref: 'taobao:sku-base', kind: 'state', path: 'skuBase.skus', raw_value: `组合记录 ${skus.length} 条` });
|
||||
const skus =
|
||||
isRecord(skuBase) && Array.isArray(skuBase.skus) ? skuBase.skus : [];
|
||||
const sku2info =
|
||||
isRecord(skuCore) && isRecord(skuCore.sku2info) ? skuCore.sku2info : {};
|
||||
if (skus.length)
|
||||
evidence.push({
|
||||
ref: 'taobao:sku-base',
|
||||
kind: 'state',
|
||||
path: 'skuBase.skus',
|
||||
raw_value: `组合记录 ${skus.length} 条`,
|
||||
});
|
||||
for (const sku of skus) {
|
||||
if (!isRecord(sku)) continue;
|
||||
const skuId = stringAt(sku, ['skuId', 'sku_id']); const propPath = stringAt(sku, ['propPath', 'prop_path']);
|
||||
const skuId = stringAt(sku, ['skuId', 'sku_id']);
|
||||
const propPath = stringAt(sku, ['propPath', 'prop_path']);
|
||||
// skuId + propPath is the required combination proof. Props alone only
|
||||
// become axes and cause a single default SKU on the server.
|
||||
if (!skuId || !propPath) continue;
|
||||
const info = isRecord(sku2info[skuId]) ? sku2info[skuId] : {};
|
||||
const selections = selectionsForPropPath(propPath, axes);
|
||||
variants.push({ sourceVariantKey: skuId, sourceSku: skuId, displayName: selections.map((item) => item.valueName).join(' / ') || `SKU ${skuId}`, selections, ...(moneyAt(info, ['price', 'promotionPrice', 'discountPrice']) ? { sourcePrice: { amount: moneyAt(info, ['price', 'promotionPrice', 'discountPrice'])!, currencyCode: 'CNY' } } : {}), confidence: 'verified', evidenceRefs: ['taobao:sku-base'] });
|
||||
variants.push({
|
||||
sourceVariantKey: skuId,
|
||||
sourceSku: skuId,
|
||||
displayName:
|
||||
selections.map((item) => item.valueName).join(' / ') ||
|
||||
`SKU ${skuId}`,
|
||||
selections,
|
||||
...(moneyAt(info, ['price', 'promotionPrice', 'discountPrice'])
|
||||
? {
|
||||
sourcePrice: {
|
||||
amount: moneyAt(info, [
|
||||
'price',
|
||||
'promotionPrice',
|
||||
'discountPrice',
|
||||
])!,
|
||||
currencyCode: 'CNY',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
confidence: 'verified',
|
||||
evidenceRefs: ['taobao:sku-base'],
|
||||
});
|
||||
}
|
||||
return basePayload(platform, itemId(pageUrl), root, pageUrl, axes, uniqueVariants(variants), evidence, `${platform}-state-1.0.0`);
|
||||
return basePayload(
|
||||
platform,
|
||||
itemId(pageUrl),
|
||||
root,
|
||||
pageUrl,
|
||||
axes,
|
||||
uniqueVariants(variants),
|
||||
evidence,
|
||||
`${platform}-state-1.0.0`,
|
||||
);
|
||||
}
|
||||
|
||||
function basePayload(platform: Exclude<SourcePlatform, 'ozon'>, itemKey: string, root: Document, pageUrl: string, axes: Axis[], variants: Variant[], evidence: CollectedProductPayload['evidence'], parserVersion: string): CollectedProductPayload {
|
||||
const canonical = root.querySelector<HTMLLinkElement>('link[rel="canonical"]')?.href ?? pageUrl.split('#')[0]; const title = root.querySelector('h1')?.textContent?.replace(/\s+/g, ' ').trim() || root.querySelector<HTMLMetaElement>('meta[property="og:title"]')?.content?.trim();
|
||||
const assets = Array.from(root.images).map((image) => image.currentSrc || image.src).filter((url) => /^https:\/\//.test(url)).filter((url, index, all) => all.indexOf(url) === index).slice(0, 40).map((url, sortOrder) => ({ url, role: 'source_main' as const, relation: 'product_candidate' as const, sortOrder, evidenceRef: 'page:gallery' }));
|
||||
return { source: { platform, itemKey, canonicalUrl: canonical, collectedAt: new Date().toISOString(), parserVersion }, common: { ...(title ? { title: { value: title, raw_value: title, confidence: 'verified' as const } } : {}), attributes: [], categories: [] }, variantAxes: axes, variants, assets, evidence };
|
||||
function basePayload(
|
||||
platform: Exclude<SourcePlatform, 'ozon'>,
|
||||
itemKey: string,
|
||||
root: Document,
|
||||
pageUrl: string,
|
||||
axes: Axis[],
|
||||
variants: Variant[],
|
||||
evidence: CollectedProductPayload['evidence'],
|
||||
parserVersion: string,
|
||||
): CollectedProductPayload {
|
||||
const canonical =
|
||||
root.querySelector<HTMLLinkElement>('link[rel="canonical"]')?.href ??
|
||||
pageUrl.split('#')[0];
|
||||
const title =
|
||||
root.querySelector('h1')?.textContent?.replace(/\s+/g, ' ').trim() ||
|
||||
root
|
||||
.querySelector<HTMLMetaElement>('meta[property="og:title"]')
|
||||
?.content?.trim();
|
||||
const assets = collectPlatformDomAssets(platform, root, canonical);
|
||||
evidence.push({
|
||||
ref: `${platform}:dom:image-profile`,
|
||||
kind: 'dom',
|
||||
path: 'platform-scoped gallery / sku / detail selectors',
|
||||
raw_value: `采集 ${assets.length} 张受限商品区域图片`,
|
||||
});
|
||||
return {
|
||||
source: {
|
||||
platform,
|
||||
itemKey,
|
||||
canonicalUrl: canonical,
|
||||
collectedAt: new Date().toISOString(),
|
||||
parserVersion,
|
||||
},
|
||||
common: {
|
||||
...(title
|
||||
? {
|
||||
title: {
|
||||
value: title,
|
||||
raw_value: title,
|
||||
confidence: 'verified' as const,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
attributes: [],
|
||||
categories: [],
|
||||
},
|
||||
variantAxes: axes,
|
||||
variants,
|
||||
assets,
|
||||
evidence,
|
||||
};
|
||||
}
|
||||
|
||||
function pageState(root: Document) { const all: unknown[] = []; for (const script of Array.from(root.scripts)) { const text = script.textContent?.trim() ?? ''; if (text.length < 2 || text.length > 5_000_000) continue; if (script.type === 'application/ld+json') { try { all.push(JSON.parse(text)); } catch { /* ignored evidence */ } } for (const marker of ['skuMapOriginal', 'skuBase', 'skuCore', 'skuProps']) { const object = objectAfterMarker(text, marker); if (object) all.push(object); } } 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; const open = text[first]; const close = open === '{' ? '}' : ']'; let depth = 0; let quote = ''; let escaped = false; for (let index = first; index < text.length && index - first < 2_000_000; index += 1) { const char = text[index]; if (quote) { if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === quote) quote = ''; continue; } if (char === '"' || char === "'") { quote = char; continue; } if (char === open) depth += 1; if (char === close && --depth === 0) { try { return JSON.parse(text.slice(first, index + 1)); } catch { return null; } } } return null; }
|
||||
function findFirst(states: unknown[], key: string): unknown { for (const state of states) { const found = findKey(state, key); if (found !== undefined) return found; } return undefined; }
|
||||
function findKey(value: unknown, key: string, seen = new Set<unknown>()): unknown { if (!value || typeof value !== 'object' || seen.has(value)) return undefined; seen.add(value); if (isRecord(value) && key in value) return value[key]; for (const child of Array.isArray(value) ? value : Object.values(value)) { const found = findKey(child, key, seen); if (found !== undefined) return found; } return undefined; }
|
||||
function parse1688Axes(value: unknown): Axis[] { const entries = Array.isArray(value) ? value : isRecord(value) && Array.isArray(value.skuProps) ? value.skuProps : []; return entries.flatMap((entry, axisIndex) => { if (!isRecord(entry)) return []; const axisKey = stringAt(entry, ['prop', 'propId', 'id']) ?? `axis:${axisIndex}`; const name = stringAt(entry, ['name', 'propName']) ?? axisKey; const values = (Array.isArray(entry.value) ? entry.value : Array.isArray(entry.values) ? entry.values : []).flatMap((item, valueIndex) => isRecord(item) ? [{ key: stringAt(item, ['valueId', 'vid', 'id']) ?? `${axisKey}:${valueIndex}`, name: stringAt(item, ['name', 'valueName']) ?? `${valueIndex + 1}`, ...(urlAt(item, ['imageUrl', 'image', 'imageUrlLarge']) ? { image_url: urlAt(item, ['imageUrl', 'image', 'imageUrlLarge'])! } : {}) }] : []); return values.length ? [{ key: axisKey, name, values }] : []; }); }
|
||||
function parseTaobaoAxes(value: unknown): Axis[] { return (Array.isArray(value) ? value : []).flatMap((entry, index) => { if (!isRecord(entry)) return []; const key = stringAt(entry, ['pid', 'propId']) ?? `axis:${index}`; const name = stringAt(entry, ['name', 'propName']) ?? key; const values = (Array.isArray(entry.values) ? entry.values : []).flatMap((item, valueIndex) => isRecord(item) ? [{ key: stringAt(item, ['vid', 'valueId']) ?? `${key}:${valueIndex}`, name: stringAt(item, ['name', 'valueName']) ?? `${valueIndex + 1}`, ...(urlAt(item, ['image', 'imageUrl']) ? { image_url: urlAt(item, ['image', 'imageUrl'])! } : {}) }] : []); return values.length ? [{ key, name, values }] : []; }); }
|
||||
function selectionsForPropPath(path: string, axes: Axis[]) { return path.split(/[;,]/).flatMap((pair) => { const [axisKey, valueKey] = pair.split(':'); const axis = axes.find((item) => item.key === axisKey); const value = axis?.values.find((item) => item.key === valueKey); return axis && value ? [{ axisKey, valueKey, valueName: value.name }] : []; }); }
|
||||
function selectionsForCombination(combination: string, axes: Axis[]) { const ids = combination.split(/[;,&]/).map((value) => value.trim()).filter(Boolean); return axes.flatMap((axis) => { const value = axis.values.find((item) => ids.includes(item.key)); return value ? [{ axisKey: axis.key, valueKey: value.key, valueName: value.name }] : []; }); }
|
||||
function stringAt(value: Json, keys: string[]) { for (const key of keys) if (typeof value[key] === 'string' || typeof value[key] === 'number') return String(value[key]); return undefined; }
|
||||
function moneyAt(value: Json, keys: string[]) { const raw = stringAt(value, keys)?.replace(/,/g, '.'); return raw && /^\d+(\.\d{1,4})?$/.test(raw) ? raw : undefined; }
|
||||
function urlAt(value: Json, keys: string[]) { const raw = stringAt(value, keys); return raw && /^https:\/\//.test(raw) ? raw : undefined; }
|
||||
function uniqueVariants(variants: Variant[]) { const seen = new Set<string>(); return variants.filter((variant) => !seen.has(variant.sourceVariantKey) && !!seen.add(variant.sourceVariantKey)); }
|
||||
function offerId(url: string) { return url.match(/\/offer\/(\d+)\.html/)?.[1] ?? `1688:${new URL(url).pathname}`; }
|
||||
function itemId(url: string) { return new URL(url).searchParams.get('id') ?? new URL(url).pathname; }
|
||||
function isRecord(value: unknown): value is Json { return !!value && typeof value === 'object' && !Array.isArray(value); }
|
||||
function pageState(root: Document) {
|
||||
const all: unknown[] = [];
|
||||
for (const script of Array.from(root.scripts)) {
|
||||
const text = script.textContent?.trim() ?? '';
|
||||
if (text.length < 2 || text.length > 5_000_000) continue;
|
||||
if (script.type === 'application/ld+json') {
|
||||
try {
|
||||
all.push(JSON.parse(text));
|
||||
} catch {
|
||||
/* ignored evidence */
|
||||
}
|
||||
}
|
||||
for (const marker of [
|
||||
'skuMapOriginal',
|
||||
'skuBase',
|
||||
'skuCore',
|
||||
'skuProps',
|
||||
]) {
|
||||
const object = objectAfterMarker(text, marker);
|
||||
if (object) all.push(object);
|
||||
}
|
||||
}
|
||||
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;
|
||||
const open = text[first];
|
||||
const close = open === '{' ? '}' : ']';
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
for (
|
||||
let index = first;
|
||||
index < text.length && index - first < 2_000_000;
|
||||
index += 1
|
||||
) {
|
||||
const char = text[index];
|
||||
if (quote) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === '\\') escaped = true;
|
||||
else if (char === quote) quote = '';
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === open) depth += 1;
|
||||
if (char === close && --depth === 0) {
|
||||
try {
|
||||
return JSON.parse(text.slice(first, index + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function findFirst(states: unknown[], key: string): unknown {
|
||||
for (const state of states) {
|
||||
const found = findKey(state, key);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function findKey(
|
||||
value: unknown,
|
||||
key: string,
|
||||
seen = new Set<unknown>(),
|
||||
): unknown {
|
||||
if (!value || typeof value !== 'object' || seen.has(value))
|
||||
return undefined;
|
||||
seen.add(value);
|
||||
if (isRecord(value) && key in value) return value[key];
|
||||
for (const child of Array.isArray(value) ? value : Object.values(value)) {
|
||||
const found = findKey(child, key, seen);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function parse1688Axes(value: unknown): Axis[] {
|
||||
const entries = Array.isArray(value)
|
||||
? value
|
||||
: isRecord(value) && Array.isArray(value.skuProps)
|
||||
? value.skuProps
|
||||
: [];
|
||||
return entries.flatMap((entry, axisIndex) => {
|
||||
if (!isRecord(entry)) return [];
|
||||
const axisKey =
|
||||
stringAt(entry, ['prop', 'propId', 'id']) ?? `axis:${axisIndex}`;
|
||||
const name = stringAt(entry, ['name', 'propName']) ?? axisKey;
|
||||
const values = (
|
||||
Array.isArray(entry.value)
|
||||
? entry.value
|
||||
: Array.isArray(entry.values)
|
||||
? entry.values
|
||||
: []
|
||||
).flatMap((item, valueIndex) =>
|
||||
isRecord(item)
|
||||
? [
|
||||
{
|
||||
key:
|
||||
stringAt(item, ['valueId', 'vid', 'id']) ??
|
||||
`${axisKey}:${valueIndex}`,
|
||||
name:
|
||||
stringAt(item, ['name', 'valueName']) ??
|
||||
`${valueIndex + 1}`,
|
||||
...(urlAt(item, [
|
||||
'imageUrl',
|
||||
'image',
|
||||
'imageUrlLarge',
|
||||
])
|
||||
? {
|
||||
image_url: urlAt(item, [
|
||||
'imageUrl',
|
||||
'image',
|
||||
'imageUrlLarge',
|
||||
])!,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
return values.length ? [{ key: axisKey, name, values }] : [];
|
||||
});
|
||||
}
|
||||
function parseTaobaoAxes(value: unknown): Axis[] {
|
||||
return (Array.isArray(value) ? value : []).flatMap((entry, index) => {
|
||||
if (!isRecord(entry)) return [];
|
||||
const key = stringAt(entry, ['pid', 'propId']) ?? `axis:${index}`;
|
||||
const name = stringAt(entry, ['name', 'propName']) ?? key;
|
||||
const values = (
|
||||
Array.isArray(entry.values) ? entry.values : []
|
||||
).flatMap((item, valueIndex) =>
|
||||
isRecord(item)
|
||||
? [
|
||||
{
|
||||
key:
|
||||
stringAt(item, ['vid', 'valueId']) ??
|
||||
`${key}:${valueIndex}`,
|
||||
name:
|
||||
stringAt(item, ['name', 'valueName']) ??
|
||||
`${valueIndex + 1}`,
|
||||
...(urlAt(item, ['image', 'imageUrl'])
|
||||
? {
|
||||
image_url: urlAt(item, [
|
||||
'image',
|
||||
'imageUrl',
|
||||
])!,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
return values.length ? [{ key, name, values }] : [];
|
||||
});
|
||||
}
|
||||
function selectionsForPropPath(path: string, axes: Axis[]) {
|
||||
return path.split(/[;,]/).flatMap((pair) => {
|
||||
const [axisKey, valueKey] = pair.split(':');
|
||||
const axis = axes.find((item) => item.key === axisKey);
|
||||
const value = axis?.values.find((item) => item.key === valueKey);
|
||||
return axis && value
|
||||
? [{ axisKey, valueKey, valueName: value.name }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
function selectionsForCombination(combination: string, axes: Axis[]) {
|
||||
const ids = combination
|
||||
.split(/[;,&]/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
return axes.flatMap((axis) => {
|
||||
const value = axis.values.find((item) => ids.includes(item.key));
|
||||
return value
|
||||
? [
|
||||
{
|
||||
axisKey: axis.key,
|
||||
valueKey: value.key,
|
||||
valueName: value.name,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
function stringAt(value: Json, keys: string[]) {
|
||||
for (const key of keys)
|
||||
if (typeof value[key] === 'string' || typeof value[key] === 'number')
|
||||
return String(value[key]);
|
||||
return undefined;
|
||||
}
|
||||
function moneyAt(value: Json, keys: string[]) {
|
||||
const raw = stringAt(value, keys)?.replace(/,/g, '.');
|
||||
return raw && /^\d+(\.\d{1,4})?$/.test(raw) ? raw : undefined;
|
||||
}
|
||||
function urlAt(value: Json, keys: string[]) {
|
||||
const raw = stringAt(value, keys);
|
||||
return raw && /^https:\/\//.test(raw) ? raw : undefined;
|
||||
}
|
||||
function uniqueVariants(variants: Variant[]) {
|
||||
const seen = new Set<string>();
|
||||
return variants.filter(
|
||||
(variant) =>
|
||||
!seen.has(variant.sourceVariantKey) &&
|
||||
!!seen.add(variant.sourceVariantKey),
|
||||
);
|
||||
}
|
||||
function offerId(url: string) {
|
||||
return (
|
||||
url.match(/\/offer\/(\d+)\.html/)?.[1] ??
|
||||
`1688:${new URL(url).pathname}`
|
||||
);
|
||||
}
|
||||
function itemId(url: string) {
|
||||
return new URL(url).searchParams.get('id') ?? new URL(url).pathname;
|
||||
}
|
||||
function isRecord(value: unknown): value is Json {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"build": "wxt build",
|
||||
"dev": "wxt",
|
||||
"typecheck": "wxt prepare && tsc -p tsconfig.json --noEmit"
|
||||
"typecheck": "wxt prepare && tsc -p tsconfig.json --noEmit",
|
||||
"test": "tsx --test test/*.test.mts",
|
||||
"test:parsers": "tsx --test test/*.test.mts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlas/contracts": "workspace:*",
|
||||
@@ -17,6 +19,7 @@
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "7.0.2",
|
||||
"wxt": "0.21.4"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
extractOzonCharacteristics,
|
||||
extractOzonDescription,
|
||||
extractOzonGalleryImageUrls,
|
||||
} from '../lib/ozon-parser.ts';
|
||||
import { dedupeImageUrl, normaliseImageUrl } from '../lib/product-media.ts';
|
||||
|
||||
test('Ozon gallery fixture only returns product-gallery images', async () => {
|
||||
const fixture = await readFile(
|
||||
new URL('../../../packages/reference/ozon1.html', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const state = fixture.match(
|
||||
/id="state-webGallery-[^"]+"\s+data-state='([^']+)'/,
|
||||
)?.[1];
|
||||
assert.ok(state, 'Ozon gallery widget state must exist in the fixture');
|
||||
|
||||
const urls = extractOzonGalleryImageUrls(
|
||||
JSON.parse(state),
|
||||
'https://www.ozon.ru/product/ryukzak-5387005639/',
|
||||
);
|
||||
assert.equal(urls.length, 11);
|
||||
assert.ok(urls.every((url) => /\/s3\/multimedia-/.test(url)));
|
||||
assert.ok(
|
||||
urls.every(
|
||||
(url) => !/logo_ozon|payments-cdn|recommend|review/i.test(url),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('Ozon short characteristics fixture returns its product parameters', async () => {
|
||||
const fixture = await readFile(
|
||||
new URL('../../../packages/reference/ozon1.html', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const state = fixture.match(
|
||||
/id="state-webShortCharacteristics-[^"]+"\s+data-state='([^']+)'/,
|
||||
)?.[1];
|
||||
assert.ok(state, 'Ozon short characteristics widget state must exist');
|
||||
|
||||
const attributes = extractOzonCharacteristics([
|
||||
{
|
||||
...JSON.parse(state),
|
||||
__atlasWidgetName: 'webShortCharacteristics-fixture',
|
||||
},
|
||||
]);
|
||||
assert.equal(attributes.length, 5);
|
||||
assert.deepEqual(
|
||||
attributes.slice(0, 3).map(({ name, value }) => ({ name, value })),
|
||||
[
|
||||
{ name: '类型', value: '背包' },
|
||||
{ name: '原产国', value: '中国' },
|
||||
{ name: '材料', value: 'Cordura 1000D' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('Ozon rich annotation converts to description text without images', () => {
|
||||
const description = extractOzonDescription([
|
||||
{
|
||||
__atlasWidgetName: 'webDescription-fixture',
|
||||
richAnnotationJson: JSON.stringify([
|
||||
{ type: 'text', content: '耐用的背包' },
|
||||
{ type: 'image', src: 'https://ir.ozone.ru/s3/example.jpg' },
|
||||
{ type: 'text', text: '适合旅行' },
|
||||
]),
|
||||
},
|
||||
]);
|
||||
assert.equal(description?.value, '耐用的背包\n适合旅行');
|
||||
});
|
||||
|
||||
test('Ozon thumbnail variants normalise to one original URL', () => {
|
||||
const original = 'https://ir.ozone.ru/s3/multimedia-1-9/13597160877.jpg';
|
||||
const thumbnail =
|
||||
'https://ir.ozone.ru/s3/multimedia-1-9/wc100/13597160877.jpg?width=100&quality=80';
|
||||
assert.equal(normaliseImageUrl(thumbnail, original, 'ozon'), original);
|
||||
assert.equal(
|
||||
dedupeImageUrl(thumbnail, 'ozon'),
|
||||
dedupeImageUrl(original, 'ozon'),
|
||||
);
|
||||
});
|
||||
|
||||
test('Alibaba transformed image URLs normalise before de-duplication', () => {
|
||||
const original = 'https://cbu01.alicdn.com/img/ibank/O1CN01.jpg';
|
||||
const transformed = `${original}_400x400.jpg?width=400`;
|
||||
assert.equal(normaliseImageUrl(transformed, original, '1688'), original);
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { defineConfig } from 'wxt';
|
||||
|
||||
const atlasWebOrigin = new URL(process.env.ATLAS_WEB_ORIGIN ?? 'http://localhost:8100').origin;
|
||||
|
||||
export default defineConfig({
|
||||
modules: ['@wxt-dev/module-react'],
|
||||
// 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突
|
||||
@@ -12,9 +14,14 @@ export default defineConfig({
|
||||
manifest: {
|
||||
name: 'Atlas Seller Kit Collector',
|
||||
description: '将已确认的来源商品采集到 Atlas 工作空间。',
|
||||
permissions: ['storage', 'identity'],
|
||||
// Only the public key is committed. Chrome derives a stable extension ID
|
||||
// from it for unpacked and self-hosted builds; never put a private key here.
|
||||
key: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsPGP9lPew7Xf+xWAc7yIAp+eLjC5SxkqzMXV/9dyn/HtMsskSbVGuYbmg0MWJdi7g3fLe/sa16Q+xp59C+yt11vKGO3a9vho6SfKDnXzkiLBvxKILTBvymnNtk4U/O5LMPWU838TnOZNRIxICJdvp1rF0EO2V8mgOY6cBenXNha1WMYpjfl17ehaKxgrKTFOT63ynTgT6NVbf3sFwi0hFCDH59TfTu5w8iQbYpm+2C+N9cmWfCcnmxtz3TxL+PCtW+RQhfEH1AFCxKZe8JkWqe2XrxLvQHjBlmM4dSvrazA454NhvGkQD41hQqLkfNAooSxLby33t2LWkY7dJmVJDQIDAQAB',
|
||||
// `tabs` 只用于查询已打开的 Atlas 标签页以同步登录态;不读取其他网页内容。
|
||||
permissions: ['storage', 'scripting', 'tabs'],
|
||||
host_permissions: [
|
||||
'http://localhost:8200/*',
|
||||
`${atlasWebOrigin}/*`,
|
||||
'*://*.ozon.ru/*',
|
||||
'*://*.ozon.kz/*',
|
||||
'*://*.ozon.by/*',
|
||||
|
||||
Reference in New Issue
Block a user