/** * 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; }