Files

1199 lines
41 KiB
TypeScript

import type { CollectedProductPayload } from '@atlas/contracts';
import { dedupeImageUrl, normaliseImageUrl } from './product-media.js';
import { formatCollectedDescription } from './description-format.js';
export type OzonPageJsonRequest = {
kind: 'modal-aspects' | 'product' | 'product-detail';
sku: string;
};
export type OzonPageJsonFetcher = (
request: OzonPageJsonRequest,
) => Promise<unknown>;
type Category = CollectedProductPayload['common']['categories'][number];
type Axis = CollectedProductPayload['variantAxes'][number];
type Variant = CollectedProductPayload['variants'][number];
type Asset = CollectedProductPayload['assets'][number];
type JsonRecord = Record<string, unknown>;
type Candidate = {
sku: string;
title?: string;
price?: string;
selections: Variant['selections'];
evidenceRefs: Set<string>;
hydrated: boolean;
};
export function isOzonProductPage(url = location.href) {
return /^https:\/\/www\.ozon\.(ru|kz|by)\/product\//.test(url);
}
/**
* Ozon does not expose a stable public SPU / variant-group identifier. The
* public contract is the set of real variant SKUs in webAspects. We retain the
* entered SKU as provenance and let the API merge re-collections by SKU-set
* overlap instead of inventing a group ID.
*/
export async function collectOzonProduct(
documentRoot: Document = document,
pageUrl = location.href,
fetchPageJson?: OzonPageJsonFetcher,
): Promise<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 evidence: CollectedProductPayload['evidence'] = [
{
ref: 'page:url',
kind: 'page',
path: 'location.href',
raw_value: canonical,
},
];
const candidates = new Map<string, Candidate>();
const axes = new Map<string, Axis>();
const assets: Asset[] = [];
const assetKeys = new Set<string>();
const localStates = readLocalWidgetStates(documentRoot);
let attributes = extractOzonCharacteristics(localStates);
let description = extractOzonDescription(localStates);
const jsonLdDescription = extractOzonJsonLdDescription(documentRoot);
const heading = firstState(localStates, 'webProductHeading');
const priceState = firstState(localStates, 'webPrice');
const title =
stringAt(heading, ['title']) ??
text(documentRoot.querySelector('h1')) ??
documentRoot
.querySelector<HTMLMetaElement>('meta[property="og:title"]')
?.content?.trim();
const commonPrice =
money(stringAt(priceState, ['price'])) ?? parsePrice(documentRoot);
const currency = currencyFor(documentRoot, stringAt(priceState, ['price']));
addCandidate(candidates, currentSku, {
title,
price: commonPrice,
evidenceRef: 'page:url',
});
const pageVariants = discoverVariants(
localStates,
candidates,
axes,
'ozon:aspects:page',
canonical,
);
if (pageVariants) {
evidence.push({
ref: 'ozon:aspects:page',
kind: 'state',
path: 'state-webAspects-*.data-state.aspects[].variants[]',
raw_value: `发现 ${pageVariants} 个不同的公开 SKU`,
});
}
addGalleryAssets(
localStates,
currentSku,
assets,
assetKeys,
'ozon:gallery:page',
canonical,
);
addGalleryVideos(
localStates,
currentSku,
assets,
assetKeys,
'ozon:gallery:page',
canonical,
);
if (attributes.length) {
evidence.push({
ref: 'ozon:characteristics:page',
kind: 'state',
path: 'webShortCharacteristics|webDetailedCharacteristics|webCharacteristics.characteristics[]',
raw_value: `发现 ${attributes.length} 项商品特征`,
});
}
if (jsonLdDescription) {
evidence.push({
ref: 'ozon:json-ld:product.description',
kind: 'json_ld',
path: 'script[type="application/ld+json"] Product.description',
raw_value: `JSON-LD 商品描述 ${jsonLdDescription.value.length} 个字符`,
});
}
const categories = collectCategories(documentRoot, canonical);
if (categories.length) {
evidence.push({
ref: 'ozon:breadcrumb',
kind: 'json_ld',
path: 'BreadcrumbList/itemListElement',
raw_value: categories.map((category) => category.name).join(' > '),
});
}
if (fetchPageJson) {
try {
const modalStates = widgetStatesFromPageJson(
await fetchPageJson({ kind: 'modal-aspects', sku: currentSku }),
);
const modalVariants = discoverVariants(
modalStates,
candidates,
axes,
'ozon:aspects:modal',
canonical,
);
evidence.push({
ref: 'ozon:aspects:modal',
kind: 'state',
path: 'webAspectsModal-*.aspects[].variants[]',
raw_value: `发现 ${modalVariants} 个不同的公开 SKU`,
});
} catch (error) {
evidence.push(
fetchFailureEvidence('ozon:aspects:modal', currentSku, error),
);
}
// Rich content is a separate, fixed Ozon page segment. Ask for it
// once for the entered SKU; never scrape arbitrary images after
// scrolling through recommendations or buyer reviews.
try {
const detailStates = widgetStatesFromPageJson(
await fetchPageJson({
kind: 'product-detail',
sku: currentSku,
}),
);
const added = addDetailAssets(
detailStates,
assets,
assetKeys,
'ozon:detail:page',
canonical,
);
description = description ?? extractOzonDescription(detailStates);
const detailAttributes = extractOzonCharacteristics(detailStates);
attributes = mergeOzonCharacteristics(attributes, detailAttributes);
evidence.push({
ref: 'ozon:detail:page',
kind: 'state',
path: 'webDescription|webRichContent|webFeatures image fields',
raw_value: `发现 ${added} 张商品详情图`,
});
if (detailAttributes.length) {
evidence.push({
ref: 'ozon:characteristics:detail',
kind: 'state',
path: 'webShortCharacteristics|webDetailedCharacteristics|webCharacteristics.characteristics[]',
raw_value: `详情段补全 ${detailAttributes.length} 项商品特征`,
});
}
} catch (error) {
evidence.push(
fetchFailureEvidence('ozon:detail:page', currentSku, error),
);
}
// A detail page can expose a SKU omitted by the initial modal. Append
// new real SKUs to this bounded queue; never construct combinations.
const pending = [...candidates.keys()];
const queued = new Set(pending);
const hydrated = new Set<string>();
while (pending.length && hydrated.size < 100) {
const batch = pending.splice(0, 3);
const results = await Promise.all(
batch.map(async (sku) => {
try {
return {
sku,
states: widgetStatesFromPageJson(
await fetchPageJson({ kind: 'product', sku }),
),
};
} catch (error) {
return { sku, error };
}
}),
);
for (const result of results) {
hydrated.add(result.sku);
if ('error' in result) {
evidence.push(
fetchFailureEvidence(
`ozon:hydrate:${result.sku}`,
result.sku,
result.error,
),
);
continue;
}
const detail = firstState(result.states, 'webProductHeading');
const detailPrice = firstState(result.states, 'webPrice');
addCandidate(candidates, result.sku, {
title: stringAt(detail, ['title']),
price: money(stringAt(detailPrice, ['price'])),
evidenceRef: `ozon:hydrate:${result.sku}`,
hydrated: true,
});
discoverVariants(
result.states,
candidates,
axes,
`ozon:hydrate:${result.sku}`,
canonical,
);
addGalleryAssets(
result.states,
result.sku,
assets,
assetKeys,
`ozon:gallery:${result.sku}`,
canonical,
);
addGalleryVideos(
result.states,
result.sku,
assets,
assetKeys,
`ozon:gallery:${result.sku}`,
canonical,
);
if (result.sku === currentSku) {
const detailedAttributes = extractOzonCharacteristics(
result.states,
);
attributes = mergeOzonCharacteristics(
attributes,
detailedAttributes,
);
if (detailedAttributes.length) {
evidence.push({
ref: 'ozon:characteristics:current',
kind: 'state',
path: 'webCharacteristics-*.characteristics[]',
raw_value: `当前 SKU 补全 ${detailedAttributes.length} 项商品特征`,
});
}
}
for (const sku of candidates.keys()) {
if (!queued.has(sku) && queued.size < 100) {
queued.add(sku);
pending.push(sku);
}
}
}
}
evidence.push({
ref: 'ozon:variant-hydration',
kind: 'state',
path: 'page/json/v2 product queue',
raw_value: `发现 ${candidates.size} 个 SKU,已尝试补全 ${hydrated.size} 个`,
});
} else {
evidence.push({
ref: 'ozon:variant-hydration',
kind: 'state',
path: 'page/json/v2 product queue',
raw_value: '未连接 Ozon 页面桥接;仅保存当前页面已渲染的变体状态',
});
}
// Ozon's rich widget is preferred because it is the dedicated description
// segment. JSON-LD is the proven old-collector fallback for pages where
// Ozon only places the seller description in the initial document.
description = description ?? jsonLdDescription;
addAspectCoverAssets(axes, assets, assetKeys, canonical);
const variants = [...candidates.values()]
.slice(0, 100)
.map<Variant>((candidate) => ({
sourceVariantKey: candidate.sku,
sourceSku: candidate.sku,
displayName: candidate.title ?? `Ozon SKU ${candidate.sku}`,
selections: candidate.selections,
...(candidate.price
? {
sourcePrice: {
amount: candidate.price,
currencyCode: currency,
},
}
: {}),
confidence:
candidate.hydrated || candidate.sku === currentSku
? 'verified'
: 'partial',
evidenceRefs: [...candidate.evidenceRefs],
}));
return {
source: {
platform: 'ozon',
// Entered SKU remains audit provenance. The API reuses by sourceSku overlap.
itemKey: currentSku,
canonicalUrl: canonical,
collectedAt: new Date().toISOString(),
parserVersion: 'ozon-public-2.3.1',
},
common: {
...(title
? {
title: {
value: title,
raw_value: title,
confidence: 'verified' as const,
},
}
: {}),
...(commonPrice
? {
sourcePrice: {
amount: commonPrice,
currencyCode: currency,
raw_value: commonPrice,
},
}
: {}),
...(description
? {
description: {
value: description.value,
raw_value: description.rawValue,
confidence: 'verified' as const,
},
}
: {}),
attributes,
categories,
},
variantAxes: [...axes.values()].slice(0, 20).map((axis) => ({
...axis,
values: axis.values.slice(0, 200),
})),
variants,
assets,
evidence,
};
}
function addCandidate(
candidates: Map<string, Candidate>,
sku: string,
input: {
title?: string;
price?: string;
selection?: Variant['selections'][number];
evidenceRef: string;
hydrated?: boolean;
},
) {
const candidate = candidates.get(sku) ?? {
sku,
selections: [],
evidenceRefs: new Set<string>(),
hydrated: false,
};
candidate.title = input.title ?? candidate.title;
candidate.price = input.price ?? candidate.price;
candidate.hydrated = candidate.hydrated || input.hydrated === true;
candidate.evidenceRefs.add(input.evidenceRef);
if (
input.selection &&
!candidate.selections.some(
(selection) =>
selection.axisKey === input.selection?.axisKey &&
selection.valueKey === input.selection?.valueKey,
)
) {
candidate.selections.push(input.selection);
}
candidates.set(sku, candidate);
}
function discoverVariants(
states: JsonRecord[],
candidates: Map<string, Candidate>,
axes: Map<string, Axis>,
evidenceRef: string,
pageUrl: string,
) {
const discovered = new Set<string>();
for (const state of states) {
if (!Array.isArray(state.aspects)) continue;
state.aspects.forEach((rawAspect, index) => {
if (!isRecord(rawAspect) || !Array.isArray(rawAspect.variants))
return;
const axisKey =
stringAt(rawAspect, ['aspectKey', 'key', 'id']) ??
`ozon:aspect:${index}`;
const axisName =
stringAt(rawAspect, ['aspectName', 'name']) ?? axisKey;
const axis = axes.get(axisKey) ?? {
key: axisKey,
name: axisName,
values: [],
};
for (const rawVariant of rawAspect.variants) {
if (!isRecord(rawVariant)) continue;
const sku = stringAt(rawVariant, ['sku']);
if (!sku) continue;
discovered.add(sku);
if (!candidates.has(sku) && candidates.size >= 100) continue;
const data = isRecord(rawVariant.data) ? rawVariant.data : {};
const valueName =
stringAt(data, ['searchableText', 'title']) ?? `SKU ${sku}`;
const valueKey = `${axisKey}:${valueName.toLocaleLowerCase()}`;
if (!axis.values.some((value) => value.key === valueKey)) {
const cover = ozonImageUrl(
stringAt(data, ['coverImage']),
pageUrl,
);
axis.values.push({
key: valueKey,
name: valueName,
...(cover ? { image_url: cover } : {}),
});
}
addCandidate(candidates, sku, {
title: stringAt(data, ['title', 'searchableText']),
price: money(stringAt(data, ['price', 'cardPrice'])),
selection: { axisKey, valueKey, valueName },
evidenceRef,
});
}
if (axis.values.length) axes.set(axisKey, axis);
});
}
return discovered.size;
}
/** Adds variant-axis covers without pretending that they identify one SKU. */
function addAspectCoverAssets(
axes: Map<string, Axis>,
assets: Asset[],
assetKeys: Set<string>,
pageUrl: string,
) {
for (const axis of axes.values()) {
for (const value of axis.values) {
const url = ozonImageUrl(value.image_url, pageUrl);
if (!url || assets.length >= 500) continue;
value.image_url = url;
const key = `sku:${axis.key}:${value.key}:${dedupeImageUrl(url, 'ozon')}`;
if (assetKeys.has(key)) continue;
assetKeys.add(key);
assets.push({
url,
mediaType: 'image',
role: 'source_sku',
relation: 'axis_value',
sortOrder: assets.length,
evidenceRef: `ozon:aspect-cover:${axis.key}:${value.key}`,
});
}
}
}
/**
* Detail widgets are allowlisted by the bridge. Reading image-shaped fields
* from only these widgets is safe; scanning page-wide image nodes is not.
*/
function addDetailAssets(
states: JsonRecord[],
assets: Asset[],
assetKeys: Set<string>,
evidenceRef: string,
pageUrl: string,
) {
const urls = new Set<string>();
for (const state of states) {
const widget = String(state.__atlasWidgetName ?? '').replace(
/^state-/,
'',
);
if (!/^web(?:Description|RichContent|Features)-/.test(widget)) continue;
collectDetailImageUrls(state, urls);
}
let added = 0;
for (const rawUrl of urls) {
if (assets.length >= 500) break;
const url = ozonImageUrl(rawUrl, pageUrl);
if (!url) continue;
const key = `detail:${dedupeImageUrl(url, 'ozon')}`;
if (assetKeys.has(key)) continue;
assetKeys.add(key);
assets.push({
url,
mediaType: 'image',
role: 'source_detail',
relation: 'product_candidate',
sortOrder: added++,
evidenceRef,
});
}
return added;
}
function collectDetailImageUrls(
value: unknown,
urls: Set<string>,
visited = new Set<unknown>(),
) {
if (!value || typeof value !== 'object' || visited.has(value)) return;
visited.add(value);
if (Array.isArray(value)) {
value.forEach((entry) => collectDetailImageUrls(entry, urls, visited));
return;
}
for (const [key, child] of Object.entries(value as JsonRecord)) {
if (
typeof child === 'string' &&
/^(src|url|image|imageurl|coverimage)$/i.test(key) &&
/^https?:\/\//.test(child)
) {
urls.add(child);
} else if (child && typeof child === 'object') {
collectDetailImageUrls(child, urls, visited);
}
}
}
type OzonCharacteristic =
CollectedProductPayload['common']['attributes'][number];
/**
* Ozon renders a short parameter list in the initial document and exposes the
* complete list in the normal product page state. Keep this reader limited to
* those named widgets so arbitrary page copy never turns into product params.
*/
export function extractOzonCharacteristics(
states: JsonRecord[],
): OzonCharacteristic[] {
const attributes: OzonCharacteristic[] = [];
for (const state of states) {
const widget = String(state.__atlasWidgetName ?? '').replace(
/^state-/,
'',
);
if (
!/^web(?:ShortCharacteristics|DetailedCharacteristics|Characteristics)-/.test(
widget,
) ||
!Array.isArray(state.characteristics)
)
continue;
for (const [index, entry] of state.characteristics.entries()) {
if (!isRecord(entry)) continue;
const name = richText(entry.title);
const value = Array.isArray(entry.values)
? entry.values.map(richText).filter(Boolean).join(', ')
: richText(entry.value);
if (!name || !value) continue;
const rawId = stringAt(entry, ['id']);
attributes.push({
key: `ozon:${rawId ?? `characteristic:${normaliseKey(name, index)}`}`.slice(
0,
200,
),
name: name.slice(0, 300),
value: value.slice(0, 2_000),
raw_value: value.slice(0, 4_000),
});
}
}
return mergeOzonCharacteristics([], attributes);
}
function mergeOzonCharacteristics(
base: OzonCharacteristic[],
additions: OzonCharacteristic[],
) {
const merged = [...base];
const seen = new Set(
base.map((attribute) => `${attribute.name}\u0000${attribute.value}`),
);
for (const attribute of additions) {
if (merged.length >= 500) break;
const identity = `${attribute.name}\u0000${attribute.value}`;
if (seen.has(identity)) continue;
seen.add(identity);
merged.push(attribute);
}
return merged;
}
type OzonDescription = { value: string; rawValue: string };
/** Extracts only Ozon's rich-description payload, converting it to reviewable text. */
export function extractOzonDescription(
states: JsonRecord[],
): OzonDescription | undefined {
for (const state of states) {
const widget = String(state.__atlasWidgetName ?? '').replace(
/^state-/,
'',
);
if (!/^web(?:Description|RichContent)-/.test(widget)) continue;
const raw = findRichAnnotationJson(state);
if (!raw) continue;
let value = raw;
try {
value = richDescriptionText(JSON.parse(raw));
} catch {
// Some older page responses already contain plain text here.
}
value = formatCollectedDescription(value);
if (!value) continue;
return {
value: value.slice(0, 100_000),
rawValue: raw.slice(0, 120_000),
};
}
return undefined;
}
/** Reads only schema.org Product.description as a fallback, never arbitrary
* JSON-LD page copy. This mirrors the mature collector's JSON-LD path. */
export function extractOzonJsonLdDescription(
root: Document,
): OzonDescription | undefined {
for (const script of Array.from(root.scripts)) {
if (script.type !== 'application/ld+json' || !script.textContent?.trim())
continue;
try {
const product = findJsonLdProduct(JSON.parse(script.textContent));
const raw = product && typeof product.description === 'string'
? product.description.trim()
: '';
if (!raw) continue;
return {
value: formatCollectedDescription(raw).slice(0, 100_000),
rawValue: raw.slice(0, 120_000),
};
} catch {
// A malformed analytics JSON-LD block is not product evidence.
}
}
return undefined;
}
function findJsonLdProduct(
value: unknown,
visited = new Set<unknown>(),
): JsonRecord | undefined {
if (!value || typeof value !== 'object' || visited.has(value)) return undefined;
visited.add(value);
if (Array.isArray(value)) {
for (const child of value) {
const product = findJsonLdProduct(child, visited);
if (product) return product;
}
return undefined;
}
const record = value as JsonRecord;
const type = record['@type'];
if (type === 'Product' || (Array.isArray(type) && type.includes('Product')))
return record;
if (Array.isArray(record['@graph'])) {
const product = findJsonLdProduct(record['@graph'], visited);
if (product) return product;
}
return undefined;
}
function findRichAnnotationJson(
value: unknown,
visited = new Set<unknown>(),
): string | undefined {
if (!value || typeof value !== 'object' || visited.has(value))
return undefined;
visited.add(value);
if (Array.isArray(value)) {
for (const child of value) {
const found = findRichAnnotationJson(child, visited);
if (found) return found;
}
return undefined;
}
const record = value as JsonRecord;
if (
typeof record.richAnnotationJson === 'string' &&
record.richAnnotationJson.trim()
)
return record.richAnnotationJson;
for (const child of Object.values(record)) {
const found = findRichAnnotationJson(child, visited);
if (found) return found;
}
return undefined;
}
function richDescriptionText(
value: unknown,
visited = new Set<unknown>(),
): string {
if (typeof value === 'string') return value.trim();
if (!value || typeof value !== 'object' || visited.has(value)) return '';
visited.add(value);
if (Array.isArray(value))
return value
.map((child) => richDescriptionText(child, visited))
.filter(Boolean)
.join('\n');
const record = value as JsonRecord;
const direct =
typeof record.text === 'string'
? record.text
: typeof record.content === 'string'
? record.content
: '';
const children = Object.entries(record)
.filter(
([key]) =>
!['text', 'content', 'type', 'src', 'url', 'image'].includes(
key,
),
)
.map(([, child]) => richDescriptionText(child, visited))
.filter(Boolean);
return [direct, ...children].filter(Boolean).join('\n');
}
function richText(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number')
return String(value).replace(/\s+/g, ' ').trim();
if (Array.isArray(value))
return value.map(richText).filter(Boolean).join(' ').trim();
if (!isRecord(value)) return '';
return richText(value.textRs ?? value.text ?? value.content);
}
function normaliseKey(value: string, index: number) {
return (
value
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 150) || String(index)
);
}
function addGalleryAssets(
states: JsonRecord[],
sku: string,
assets: Asset[],
assetKeys: Set<string>,
evidenceRef: string,
pageUrl: string,
) {
const gallery = firstState(states, 'webGallery');
if (!gallery) return;
const urls = extractOzonGalleryImageUrls(gallery, pageUrl);
for (const [index, url] of urls.entries()) {
if (assets.length >= 500) return;
const key = `main:${sku}:${url ? dedupeImageUrl(url, 'ozon') : ''}`;
if (assetKeys.has(key)) continue;
assetKeys.add(key);
assets.push({
url,
mediaType: 'image',
// A gallery is product photography for this real SKU. It is not
// a SKU-option thumbnail merely because it is the second image.
role: 'source_main',
variantSourceKey: sku,
relation: 'variant_direct',
sortOrder: index,
evidenceRef,
});
}
}
/** Adds only Ozon's named product-gallery videos. Page-wide <video> nodes can
* belong to reviews or recommendations and are intentionally never considered. */
function addGalleryVideos(
states: JsonRecord[],
sku: string,
assets: Asset[],
assetKeys: Set<string>,
evidenceRef: string,
pageUrl: string,
) {
const galleries = states.filter((state) => {
const widget = String(state.__atlasWidgetName ?? '').replace(/^state-/, '');
return /^web(?:Gallery|Video)-/.test(widget);
});
for (const gallery of galleries) {
for (const video of extractOzonGalleryVideos(gallery, pageUrl)) {
if (assets.length >= 500) return;
const key = `video:${sku}:${video.url}`;
if (assetKeys.has(key)) continue;
assetKeys.add(key);
assets.push({
url: video.url,
mediaType: 'video',
...(video.posterUrl ? { posterUrl: video.posterUrl } : {}),
role: 'source_main',
variantSourceKey: sku,
relation: 'variant_direct',
sortOrder: assets.length,
evidenceRef,
});
}
}
}
/** Pure helper for the strictly named Ozon gallery/video state. HLS, blobs,
* and non-HTTPS URLs stay out of the collection contract in this first phase. */
export function extractOzonGalleryVideos(gallery: JsonRecord, pageUrl: string) {
const videos: Array<{ url: string; posterUrl?: string }> = [];
const seen = new Set<string>();
// Ozon changes whether the direct file lives in videos[], video, or a
// nested gallery payload. The old collector walked this *one allowlisted
// widget* for direct video extensions; retaining that scope is critical.
for (const { value, parent } of nestedVideoValues(gallery)) {
const url = directVideoUrl(
typeof value === 'string'
? value
: stringAt(value, ['url', 'videoUrl', 'src']),
pageUrl,
);
if (!url) continue;
const posterUrl = ozonImageUrl(
(typeof value === 'string'
? undefined
: stringAt(value, ['coverImage', 'coverUrl', 'poster', 'previewImage']))
?? stringAt(parent, ['coverImage', 'coverUrl', 'poster', 'previewImage']),
pageUrl,
);
if (seen.has(url)) {
const existing = videos.find((video) => video.url === url);
if (existing && !existing.posterUrl && posterUrl)
existing.posterUrl = posterUrl;
continue;
}
seen.add(url);
videos.push({ url, ...(posterUrl ? { posterUrl } : {}) });
}
return videos;
}
function nestedVideoValues(root: JsonRecord) {
const values: Array<{ value: string | JsonRecord; parent?: JsonRecord }> = [];
const pending: Array<{ value: unknown; parent?: JsonRecord }> = [{ value: root }];
const seen = new Set<object>();
while (pending.length) {
const current = pending.pop()!;
if (typeof current.value === 'string') {
if (/\.(mp4|webm)(?:$|[?#])/i.test(current.value))
values.push({ value: current.value, parent: current.parent });
continue;
}
if (!current.value || typeof current.value !== 'object') continue;
if (seen.has(current.value)) continue;
seen.add(current.value);
if (Array.isArray(current.value)) {
for (const child of current.value)
pending.push({ value: child, parent: current.parent });
continue;
}
const record = current.value as JsonRecord;
if (stringAt(record, ['url', 'videoUrl', 'src']))
values.push({ value: record, parent: current.parent });
for (const child of Object.values(record))
pending.push({ value: child, parent: record });
}
return values;
}
/** Pure helper used by fixture tests and the structured Ozon gallery reader. */
export function extractOzonGalleryImageUrls(
gallery: JsonRecord,
pageUrl: string,
) {
const urls: string[] = [];
const seen = new Set<string>();
for (const image of [
gallery.coverImage,
...(Array.isArray(gallery.images) ? gallery.images : []),
]) {
const url = ozonImageUrl(
typeof image === 'string'
? image
: isRecord(image)
? stringAt(image, ['src', 'url'])
: undefined,
pageUrl,
);
if (!url) continue;
const key = dedupeImageUrl(url, 'ozon');
if (seen.has(key)) continue;
seen.add(key);
urls.push(url);
}
return urls;
}
function readLocalWidgetStates(root: Document): JsonRecord[] {
const states: JsonRecord[] = [];
for (const element of Array.from(
root.querySelectorAll<HTMLElement>('[id^="state-web"][data-state]'),
)) {
const raw = element.getAttribute('data-state');
if (!raw?.trim()) continue;
try {
const state = JSON.parse(raw);
if (isRecord(state)) states.push(withWidgetName(state, element.id));
} catch {
// Partially updated state must not fail the whole collection.
}
}
return states;
}
function widgetStatesFromPageJson(value: unknown): JsonRecord[] {
if (!isRecord(value) || !isRecord(value.widgetStates)) return [];
const states: JsonRecord[] = [];
for (const [widgetName, raw] of Object.entries(value.widgetStates)) {
if (
!/^web(Aspects|AspectsModal|ProductHeading|Price|Gallery|Video|Description|RichContent|Features|ShortCharacteristics|DetailedCharacteristics|Characteristics)-/.test(
widgetName,
)
)
continue;
try {
const state = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (isRecord(state)) states.push(withWidgetName(state, widgetName));
} catch {
// One malformed Ozon widget only loses that evidence.
}
}
return states;
}
function withWidgetName(state: JsonRecord, widgetName: string): JsonRecord {
return { ...state, __atlasWidgetName: widgetName };
}
function firstState(
states: JsonRecord[],
prefix: string,
): JsonRecord | undefined {
return states.find((state) =>
String(state.__atlasWidgetName ?? '')
.replace(/^state-/, '')
.startsWith(prefix),
);
}
function fetchFailureEvidence(ref: string, sku: string, error: unknown) {
const message = error instanceof Error ? error.message : '请求失败';
return {
ref,
kind: 'state' as const,
path: `page/json/v2 product ${sku}`,
raw_value: `SKU ${sku} 补全失败:${message.slice(0, 500)}`,
};
}
/** JSON-LD 面包屑优先;Ozon 不输出时读取可见面包屑。 */
function collectCategories(root: Document, pageUrl: string): Category[] {
for (const script of Array.from(root.scripts)) {
if (
script.type !== 'application/ld+json' ||
!script.textContent?.trim()
)
continue;
try {
const categories = categoriesFromJsonLd(
JSON.parse(script.textContent),
pageUrl,
);
if (categories.length) return categories;
} catch {
// Invalid dynamic JSON-LD does not affect collection.
}
}
const links = Array.from(
root.querySelectorAll<HTMLAnchorElement>(
'[data-widget*="breadCrumbs"] a, [data-widget*="breadcrumb"] a, nav[aria-label*="хлеб"] a, nav[aria-label*="bread"] a',
),
);
return uniqueCategories(
links.map((link) =>
category(link.textContent, link.getAttribute('href'), pageUrl),
),
);
}
function categoriesFromJsonLd(value: unknown, pageUrl: string): Category[] {
const list = findBreadcrumbList(value);
if (!list || !Array.isArray(list.itemListElement)) return [];
return uniqueCategories(
list.itemListElement.map((entry) => {
if (!isRecord(entry)) return null;
const item = entry.item;
const itemUrl =
typeof item === 'string'
? item
: isRecord(item)
? stringAt(item, ['@id', 'url'])
: undefined;
return category(
stringAt(entry, ['name']) ??
(isRecord(item) ? stringAt(item, ['name']) : undefined),
itemUrl,
pageUrl,
);
}),
);
}
function findBreadcrumbList(
value: unknown,
visited = new Set<unknown>(),
): JsonRecord | null {
if (!isRecord(value) || visited.has(value)) return null;
visited.add(value);
const type = value['@type'];
if (
(typeof type === 'string' && type === 'BreadcrumbList') ||
(Array.isArray(type) && type.includes('BreadcrumbList'))
)
return value;
for (const child of Object.values(value)) {
if (Array.isArray(child)) {
for (const entry of child) {
const found = findBreadcrumbList(entry, visited);
if (found) return found;
}
} else {
const found = findBreadcrumbList(child, visited);
if (found) return found;
}
}
return null;
}
function category(
rawName: unknown,
rawUrl: unknown,
pageUrl: string,
): Category | null {
const name =
typeof rawName === 'string' ? rawName.replace(/\s+/g, ' ').trim() : '';
if (
!name ||
/^(ozon|главная|home|首页|全部分类|все категории)$/i.test(name)
)
return null;
let key = name.toLowerCase();
if (typeof rawUrl === 'string' && rawUrl) {
try {
const url = new URL(rawUrl, pageUrl);
key = `${url.hostname}${url.pathname.replace(/\/$/, '')}${url.search}`;
} catch {
// Use the normalised name as a stable fallback key.
}
}
return { key: `ozon:${key}`, name };
}
function uniqueCategories(items: Array<Category | null>): Category[] {
const seen = new Set<string>();
return items.filter(
(item): item is Category =>
!!item && !seen.has(item.key) && !!seen.add(item.key),
);
}
function isRecord(value: unknown): value is JsonRecord {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function stringAt(value: JsonRecord | undefined, keys: string[]) {
if (!value) return undefined;
for (const key of keys) {
const item = value[key];
if (
(typeof item === 'string' || typeof item === 'number') &&
String(item).trim()
)
return String(item).trim();
}
return undefined;
}
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 money(raw: string | undefined) {
if (!raw) return undefined;
const amount = raw.replace(/[^\d,\.]/g, '').replace(',', '.');
return /^\d+(\.\d{1,4})?$/.test(amount) ? amount : 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));
return money(candidate);
}
function currencyFor(root: Document, price: string | undefined) {
const meta = root
.querySelector<HTMLMetaElement>(
'meta[property="product:price:currency"]',
)
?.content?.toUpperCase();
if (meta && /^[A-Z]{3}$/.test(meta)) return meta;
if (price?.includes('¥')) return 'CNY';
if (price?.includes('₸')) return 'KZT';
if (price?.includes('Br')) return 'BYN';
return 'RUB';
}
function httpsUrl(value: string | undefined) {
return value && /^https:\/\//.test(value) ? value : undefined;
}
function ozonImageUrl(value: string | undefined, pageUrl: string) {
if (!value) return undefined;
const url = normaliseImageUrl(value, pageUrl, 'ozon');
return url?.startsWith('https://') ? url : undefined;
}
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;
}
}