/**
* Ozon SSR widget state 提取器(主路径)
*
* Ozon 页面把每个 widget 的 JSON state 内嵌在 DOM 里:
*
* content script 直接读 data-state 即可,无需访问页面 JS(main world)。
*
* 结构已在真实页面实测(reference/ozon1.html、ozon2.html):
* - webGallery: coverImage / images[{src,alt}](原图)/ videos[{url,coverUrl}]
* - webPrice: price / originalPrice / cardPrice(如 "108,26 ¥")
* - webProductHeading: title
* - webShortCharacteristics / webDetailedCharacteristics: characteristics[]
* - webAspects: aspects[].variants[].data.{searchableText, coverImage}(SKU 变体)
* - webReviewProductScore: totalScore / reviewsCount
*
* ★ 白名单机制:只读上面这几个 widget 的 state。
* 绝不遍历全页 —— "为您推荐 / 一起购买" 等其它商品 carousel 的 state
* (webRecommendedProducts / webCarousel / 类似 widget)根本不会被读到。
*/
import { toAbsoluteUrl } from './url';
export interface OzonVariant {
name: string;
image?: string; // 可能为 undefined(纯文字规格,如尺码)
}
export interface BreadcrumbItem {
name: string; // 类目名称(如"扑满"、"儿童房")
href: string; // 原始链接(/category/kopilki-15056/ 或 ?category=7041)
searchCategoryId?: number; // Ozon 搜索类目 ID(从 ?category=xxx 解析)
slug?: string; // URL slug(从 /category/xxx-123/ 解析,含数字 ID)
}
export interface OzonStateData {
title?: string;
price?: string;
originalPrice?: string;
rating?: string;
reviewCount?: string;
galleryImages: string[]; // 原图(无尺寸标记)
videos: string[];
videoCovers: string[];
skuVariants: OzonVariant[];
characteristics: Array<{ key: string; value: string }>;
breadcrumbs: BreadcrumbItem[]; // 面包屑类目路径
}
/** 允许读取的 widget 前缀白名单 */
const ALLOWED_WIDGETS = [
'webGallery-',
'webPrice-',
'webProductHeading-',
'webShortCharacteristics-',
'webDetailedCharacteristics-',
'webCharacteristics-',
'webAspects-',
'webReviewProductScore-',
'breadCrumbs-', // 面包屑类目路径
];
function pushUnique(arr: string[], v: string): void {
const abs = toAbsoluteUrl(v);
if (abs && !arr.includes(abs)) arr.push(abs);
}
function readTextRs(node: unknown): string {
// 提取 textRs / descriptionRs 里的展示文本。
// 规则:content/text 字段的值收进文本;递归进入数组/对象找嵌套的 content/text;
// 跳过 type/font/color/id/href 等样式与元数据字段(type=newLine 除外)。
if (node == null) return '';
if (typeof node === 'string') return node.trim();
if (typeof node !== 'object') return '';
const texts: string[] = [];
const walk = (n: unknown): void => {
if (!n) return;
if (typeof n === 'string') {
texts.push(n);
return;
}
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
if (typeof n === 'object') {
for (const [k, v] of Object.entries(n as Record)) {
if (k === 'type' && (v === 'newLine' || v === 'lineBreak')) {
texts.push('\n');
} else if (k === 'content' || k === 'text') {
walk(v);
} else if (v && typeof v === 'object') {
walk(v);
}
// 其它原始值(font/color/id/type='text' 等)直接跳过
}
}
};
walk(node);
return texts.join('').trim();
}
function parseCharacteristics(chars: unknown): Array<{ key: string; value: string }> {
if (!Array.isArray(chars)) return [];
const out: Array<{ key: string; value: string }> = [];
for (const c of chars) {
if (!c || typeof c !== 'object') continue;
const row = c as Record;
// 结构 A:{ title: { textRs: [...] }, values: [{ text: ... }] }(实测)
const key = readTextRs(row.title);
if (Array.isArray(row.values)) {
const vals = row.values
.map((v) => (v && typeof v === 'object' ? readTextRs((v as Record).text) : ''))
.map((t) => t.replace(/,\s*$/, '')) // 源数据值自带尾逗号(如 "音乐, ")
.filter(Boolean);
if (key && vals.length) out.push({ key, value: vals.join(', ') });
continue;
}
// 结构 B:{ key, value } / { name, value } / { title, text }
const k2 = (row.key ?? row.name ?? row.title) as string | undefined;
const v2 = (row.value ?? row.text) as string | undefined;
if (typeof k2 === 'string' && k2 && typeof v2 === 'string' && v2) {
out.push({ key: k2, value: v2 });
}
}
return out;
}
export function extractOzonState(): OzonStateData {
const data: OzonStateData = {
galleryImages: [],
videos: [],
videoCovers: [],
skuVariants: [],
characteristics: [],
breadcrumbs: [],
};
const seenChars = new Set();
const els = document.querySelectorAll('div[id^="state-"]');
for (const el of Array.from(els)) {
const id = el.id.slice('state-'.length);
if (!ALLOWED_WIDGETS.some((p) => id.startsWith(p))) continue;
const raw = el.getAttribute('data-state');
if (!raw) continue;
let state: unknown;
try {
state = JSON.parse(raw);
} catch {
continue;
}
if (!state || typeof state !== 'object') continue;
const s = state as Record;
if (id.startsWith('webGallery-')) {
if (typeof s.coverImage === 'string') pushUnique(data.galleryImages, s.coverImage);
if (Array.isArray(s.images)) {
for (const img of s.images) {
const src = img && typeof (img as Record).src === 'string'
? (img as Record).src as string
: undefined;
if (src) pushUnique(data.galleryImages, src);
}
}
if (Array.isArray(s.videos)) {
for (const v of s.videos) {
const rec = v as Record;
if (typeof rec.url === 'string') pushUnique(data.videos, rec.url);
if (typeof rec.coverUrl === 'string') pushUnique(data.videoCovers, rec.coverUrl);
}
}
} else if (id.startsWith('webPrice-')) {
if (typeof s.price === 'string') data.price = s.price;
if (typeof s.originalPrice === 'string') data.originalPrice = s.originalPrice;
if (!data.price && typeof s.cardPrice === 'string') data.price = s.cardPrice;
} else if (id.startsWith('webProductHeading-')) {
if (typeof s.title === 'string') data.title = s.title;
} else if (
id.startsWith('webShortCharacteristics-') ||
id.startsWith('webDetailedCharacteristics-') ||
id.startsWith('webCharacteristics-')
) {
for (const c of parseCharacteristics(s.characteristics)) {
const k = `${c.key}::${c.value}`;
if (!seenChars.has(k)) {
seenChars.add(k);
data.characteristics.push(c);
}
}
} else if (id.startsWith('webAspects-')) {
if (Array.isArray(s.aspects)) {
for (const aspect of s.aspects) {
const a = aspect as Record;
if (!Array.isArray(a.variants)) continue;
for (const v of a.variants) {
const rec = v as Record;
const d = rec.data as Record | undefined;
const name = typeof d?.searchableText === 'string' ? d.searchableText
: typeof d?.title === 'string' ? d.title : '';
const image = typeof d?.coverImage === 'string' ? d.coverImage : undefined;
if (name) data.skuVariants.push({ name, image });
}
}
}
} else if (id.startsWith('webReviewProductScore-')) {
if (typeof s.totalScore === 'number') data.rating = String(s.totalScore);
if (typeof s.reviewsCount === 'number') data.reviewCount = String(s.reviewsCount);
} else if (id.startsWith('breadCrumbs-')) {
// breadCrumbs widget state: { breadcrumbs: [{text, link, crumbType}] }
if (Array.isArray(s.breadcrumbs) && data.breadcrumbs.length === 0) {
for (const crumb of s.breadcrumbs) {
const c = crumb as Record;
const name = typeof c.text === 'string' ? c.text.trim() : '';
const href = typeof c.link === 'string' ? c.link : '';
if (!name || !href) continue;
// 解析 ?category=7041(highlight 样式链接)
const catMatch = href.match(/[?&]category=(\d+)/);
const searchCategoryId = catMatch ? Number(catMatch[1]) : undefined;
// 解析 /category/kopilki-15056/(末尾带数字 ID 的 slug)
const slugMatch = href.match(/\/category\/([^/?]+)/);
const slug = slugMatch ? slugMatch[1] : undefined;
data.breadcrumbs.push({ name, href, searchCategoryId, slug });
}
}
}
}
// 如果 widget state 没有面包屑(旧版页面),尝试读 DOM 渲染的 ol
if (data.breadcrumbs.length === 0) {
const ol = document.querySelector('[class*="breadCrumbs"] ol, nav ol, ol[class*="breadcrumb"]');
if (ol) {
for (const a of Array.from(ol.querySelectorAll('a[href]'))) {
const href = a.getAttribute('href') ?? '';
const name = a.textContent?.trim() ?? '';
if (!name) continue;
const catMatch = href.match(/[?&]category=(\d+)/);
const searchCategoryId = catMatch ? Number(catMatch[1]) : undefined;
const slugMatch = href.match(/\/category\/([^/?]+)/);
const slug = slugMatch ? slugMatch[1] : undefined;
data.breadcrumbs.push({ name, href, searchCategoryId, slug });
}
}
}
return data;
}