245 lines
9.5 KiB
TypeScript
245 lines
9.5 KiB
TypeScript
/**
|
||
* Ozon SSR widget state 提取器(主路径)
|
||
*
|
||
* Ozon 页面把每个 widget 的 JSON state 内嵌在 DOM 里:
|
||
* <div id="state-webGallery-3311626-default-1" data-state='{...}'>
|
||
* 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<string, unknown>)) {
|
||
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<string, unknown>;
|
||
// 结构 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<string, unknown>).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<string>();
|
||
|
||
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<string, unknown>;
|
||
|
||
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<string, unknown>).src === 'string'
|
||
? (img as Record<string, unknown>).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<string, unknown>;
|
||
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<string, unknown>;
|
||
if (!Array.isArray(a.variants)) continue;
|
||
for (const v of a.variants) {
|
||
const rec = v as Record<string, unknown>;
|
||
const d = rec.data as Record<string, unknown> | 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<string, unknown>;
|
||
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;
|
||
}
|