feat(extension): 采集插件功能开发
This commit is contained in:
Vendored
+1
@@ -6,6 +6,7 @@ declare module "wxt/browser" {
|
||||
| ""
|
||||
| "/"
|
||||
| "/background.js"
|
||||
| "/content-scripts/collector.js"
|
||||
type HtmlPublicPath = Extract<PublicPath, `${string}.html`>
|
||||
export interface WxtRuntime {
|
||||
getURL(path: PublicPath): string;
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { getSession, login, openErp, submit } from '../lib/api.js';
|
||||
export default defineBackground(() => {
|
||||
console.info('Atlas collector background started.');
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { collectCurrentProduct, supportedProductPlatform } from '../lib/source-parsers.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);
|
||||
} });
|
||||
@@ -0,0 +1,39 @@
|
||||
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';
|
||||
|
||||
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 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 }); }
|
||||
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; }
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
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(/=+$/, ''); }
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { CollectedProductPayload } from '@atlas/contracts';
|
||||
|
||||
export function isOzonProductPage(url = location.href) { return /^https:\/\/www\.ozon\.(ru|kz|by)\/product\//.test(url); }
|
||||
export function collectOzonProduct(documentRoot: Document = document, pageUrl = location.href): CollectedProductPayload {
|
||||
const canonical = documentRoot.querySelector<HTMLLinkElement>('link[rel="canonical"]')?.href ?? pageUrl.split('?')[0];
|
||||
const currentSku = skuFromUrl(canonical); if (!currentSku) throw new Error('未识别到 Ozon 商品 SKU');
|
||||
const title = text(documentRoot.querySelector('h1')) ?? documentRoot.querySelector<HTMLMetaElement>('meta[property="og:title"]')?.content?.trim();
|
||||
const images = Array.from(documentRoot.images).map((image) => image.currentSrc || image.src).filter((url) => /^https:\/\//.test(url)).filter(unique).slice(0, 30);
|
||||
const evidence: CollectedProductPayload['evidence'] = [{ ref: 'page:url', kind: 'page', path: 'location.href', raw_value: canonical }];
|
||||
const variants = new Map<string, { sourceVariantKey: string; sourceSku?: string; displayName: string; selections: never[]; confidence: 'verified' | 'partial'; evidenceRefs: string[] }>();
|
||||
variants.set(currentSku, { sourceVariantKey: currentSku, sourceSku: currentSku, displayName: title ?? `Ozon SKU ${currentSku}`, selections: [], confidence: 'verified', evidenceRefs: ['page:url'] });
|
||||
for (const script of Array.from(documentRoot.scripts)) {
|
||||
const source = script.textContent ?? '';
|
||||
// Only inspect Ozon's variant-related state; never use generic page-wide
|
||||
// SKU hits because recommendation cards would create false variants.
|
||||
if (!/webProducts|webAspects|aspects/i.test(source)) continue;
|
||||
const matches = source.matchAll(/["']sku["']\s*[:=]\s*["']?(\d{4,})/g);
|
||||
let count = 0;
|
||||
for (const match of matches) { if (count++ >= 100) break; const sku = match[1]; if (!variants.has(sku)) variants.set(sku, { sourceVariantKey: sku, sourceSku: sku, displayName: `Ozon SKU ${sku}`, selections: [], confidence: 'partial', evidenceRefs: ['state:variant-sku'] }); }
|
||||
if (count) evidence.push({ ref: 'state:variant-sku', kind: 'state', path: 'webProducts/webAspects', raw_value: `发现 ${count} 个公开 SKU` });
|
||||
}
|
||||
const amount = parsePrice(documentRoot); const currency = documentRoot.querySelector<HTMLMetaElement>('meta[property="product:price:currency"]')?.content?.toUpperCase() ?? 'RUB';
|
||||
return {
|
||||
source: { platform: 'ozon', itemKey: currentSku, canonicalUrl: canonical, collectedAt: new Date().toISOString(), parserVersion: 'ozon-public-1.0.0' },
|
||||
common: { ...(title ? { title: { value: title, raw_value: title, confidence: 'verified' as const } } : {}), ...(amount ? { sourcePrice: { amount, currencyCode: currency, raw_value: amount } } : {}), attributes: [], categories: [] },
|
||||
variantAxes: [], variants: [...variants.values()], assets: images.map((url, index) => ({ url, role: 'source_main' as const, relation: 'product_candidate' as const, sortOrder: index, evidenceRef: 'page:gallery' })), evidence,
|
||||
};
|
||||
}
|
||||
function skuFromUrl(url: string) { return url.match(/-(\d+)(?:\/|$|\?)/)?.[1] ?? null; }
|
||||
function text(element: Element | null) { const value = element?.textContent?.replace(/\s+/g, ' ').trim(); return value || undefined; }
|
||||
function parsePrice(root: Document) { const meta = root.querySelector<HTMLMetaElement>('meta[property="product:price:amount"]')?.content; if (meta && /^\d+(\.\d+)?$/.test(meta)) return meta; const candidate = Array.from(root.querySelectorAll('[data-widget*="webPrice"], [data-widget*="price"]')).map((node) => node.textContent ?? '').find((value) => /\d/.test(value)); const digits = candidate?.replace(/[^\d,\.]/g, '').replace(',', '.'); return digits && /^\d+(\.\d+)?$/.test(digits) ? digits : undefined; }
|
||||
function unique(value: string, index: number, array: string[]) { return array.indexOf(value) === index; }
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { CollectedProductPayload } from '@atlas/contracts';
|
||||
import { collectOzonProduct, isOzonProductPage } from './ozon-parser.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 {
|
||||
if (isOzonProductPage(url)) return 'ozon';
|
||||
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 {
|
||||
const platform = supportedProductPlatform(pageUrl);
|
||||
if (platform === 'ozon') return collectOzonProduct(root, pageUrl);
|
||||
if (!platform) throw new Error('当前页面不是支持的商品详情页');
|
||||
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 variants: Variant[] = [];
|
||||
if (isRecord(skuMap)) {
|
||||
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']);
|
||||
// A skuMapOriginal entry itself is a source-provided sellable-combination
|
||||
// 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'] });
|
||||
}
|
||||
}
|
||||
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 }];
|
||||
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} 条` });
|
||||
for (const sku of skus) {
|
||||
if (!isRecord(sku)) continue;
|
||||
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'] });
|
||||
}
|
||||
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 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); }
|
||||
@@ -14,6 +14,7 @@ export default defineConfig({
|
||||
description: '将已确认的来源商品采集到 Atlas 工作空间。',
|
||||
permissions: ['storage', 'identity'],
|
||||
host_permissions: [
|
||||
'http://localhost:8200/*',
|
||||
'*://*.ozon.ru/*',
|
||||
'*://*.ozon.kz/*',
|
||||
'*://*.ozon.by/*',
|
||||
|
||||
Reference in New Issue
Block a user