Update seller store experience
This commit is contained in:
@@ -61,6 +61,7 @@ export async function collectOzonProduct(
|
||||
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 =
|
||||
@@ -101,6 +102,14 @@ export async function collectOzonProduct(
|
||||
'ozon:gallery:page',
|
||||
canonical,
|
||||
);
|
||||
addGalleryVideos(
|
||||
localStates,
|
||||
currentSku,
|
||||
assets,
|
||||
assetKeys,
|
||||
'ozon:gallery:page',
|
||||
canonical,
|
||||
);
|
||||
if (attributes.length) {
|
||||
evidence.push({
|
||||
ref: 'ozon:characteristics:page',
|
||||
@@ -109,6 +118,14 @@ export async function collectOzonProduct(
|
||||
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) {
|
||||
@@ -162,12 +179,22 @@ export async function collectOzonProduct(
|
||||
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),
|
||||
@@ -230,6 +257,14 @@ export async function collectOzonProduct(
|
||||
`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,
|
||||
@@ -270,6 +305,11 @@ export async function collectOzonProduct(
|
||||
});
|
||||
}
|
||||
|
||||
// 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()]
|
||||
@@ -301,7 +341,7 @@ export async function collectOzonProduct(
|
||||
itemKey: currentSku,
|
||||
canonicalUrl: canonical,
|
||||
collectedAt: new Date().toISOString(),
|
||||
parserVersion: 'ozon-public-2.2.0',
|
||||
parserVersion: 'ozon-public-2.3.0',
|
||||
},
|
||||
common: {
|
||||
...(title
|
||||
@@ -452,6 +492,7 @@ function addAspectCoverAssets(
|
||||
assetKeys.add(key);
|
||||
assets.push({
|
||||
url,
|
||||
mediaType: 'image',
|
||||
role: 'source_sku',
|
||||
relation: 'axis_value',
|
||||
sortOrder: assets.length,
|
||||
@@ -491,6 +532,7 @@ function addDetailAssets(
|
||||
assetKeys.add(key);
|
||||
assets.push({
|
||||
url,
|
||||
mediaType: 'image',
|
||||
role: 'source_detail',
|
||||
relation: 'product_candidate',
|
||||
sortOrder: added++,
|
||||
@@ -621,6 +663,55 @@ export function extractOzonDescription(
|
||||
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: raw.replace(/\s+\n/g, '\n').replace(/\n{3,}/g, '\n\n').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>(),
|
||||
@@ -716,6 +807,7 @@ function addGalleryAssets(
|
||||
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',
|
||||
@@ -727,6 +819,103 @@ function addGalleryAssets(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -777,7 +966,7 @@ function widgetStatesFromPageJson(value: unknown): JsonRecord[] {
|
||||
const states: JsonRecord[] = [];
|
||||
for (const [widgetName, raw] of Object.entries(value.widgetStates)) {
|
||||
if (
|
||||
!/^web(Aspects|AspectsModal|ProductHeading|Price|Gallery|Description|RichContent|Features|ShortCharacteristics|DetailedCharacteristics|Characteristics)-/.test(
|
||||
!/^web(Aspects|AspectsModal|ProductHeading|Price|Gallery|Video|Description|RichContent|Features|ShortCharacteristics|DetailedCharacteristics|Characteristics)-/.test(
|
||||
widgetName,
|
||||
)
|
||||
)
|
||||
@@ -997,3 +1186,15 @@ function ozonImageUrl(value: string | undefined, pageUrl: string) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user