108 lines
3.0 KiB
TypeScript
108 lines
3.0 KiB
TypeScript
/**
|
||
* JSON-LD 提取器(schema.org/Product)
|
||
*
|
||
* Ozon 是 SSR 站点,商品页 HTML 里带 application/ld+json,
|
||
* 是 DOM 之外最稳定的结构化来源(比哈希类名稳定一个数量级)。
|
||
*
|
||
* 参考实现(毛子ERP)也解析 application/ld+json 取 description / offers.url。
|
||
*/
|
||
|
||
export interface JsonLdProduct {
|
||
title?: string;
|
||
description?: string;
|
||
brand?: string;
|
||
sku?: string;
|
||
price?: string;
|
||
currency?: string;
|
||
images: string[];
|
||
rating?: string;
|
||
reviewCount?: string;
|
||
}
|
||
|
||
function asString(v: unknown): string | undefined {
|
||
if (typeof v === 'string') return v;
|
||
if (typeof v === 'number') return String(v);
|
||
return undefined;
|
||
}
|
||
|
||
function findProduct(node: unknown): any | null {
|
||
if (Array.isArray(node)) {
|
||
for (const item of node) {
|
||
const r = findProduct(item);
|
||
if (r) return r;
|
||
}
|
||
return null;
|
||
}
|
||
if (!node || typeof node !== 'object') return null;
|
||
|
||
const obj = node as Record<string, unknown>;
|
||
const type = obj['@type'];
|
||
const types = Array.isArray(type) ? type : [type];
|
||
if (types.some((t) => t === 'Product')) return obj;
|
||
|
||
// @graph 包裹
|
||
if (Array.isArray(obj['@graph'])) {
|
||
for (const g of obj['@graph']) {
|
||
const r = findProduct(g);
|
||
if (r) return r;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function collectImages(node: unknown, out: string[]): void {
|
||
if (!node) return;
|
||
if (typeof node === 'string') {
|
||
if (/^(https?:)?\/\/.+/i.test(node) && !out.includes(node)) out.push(node);
|
||
return;
|
||
}
|
||
if (Array.isArray(node)) {
|
||
node.forEach((n) => collectImages(n, out));
|
||
return;
|
||
}
|
||
if (typeof node === 'object') {
|
||
for (const v of Object.values(node as Record<string, unknown>)) {
|
||
collectImages(v, out);
|
||
}
|
||
}
|
||
}
|
||
|
||
export function extractJsonLd(): JsonLdProduct | null {
|
||
try {
|
||
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
|
||
for (const script of Array.from(scripts)) {
|
||
const text = script.textContent?.trim();
|
||
if (!text) continue;
|
||
let data: unknown;
|
||
try {
|
||
data = JSON.parse(text);
|
||
} catch {
|
||
continue;
|
||
}
|
||
const product = findProduct(data);
|
||
if (!product) continue;
|
||
|
||
const offers = Array.isArray(product.offers) ? product.offers[0] : product.offers;
|
||
const brandName = product.brand?.name ?? (typeof product.brand === 'string' ? product.brand : undefined);
|
||
|
||
const images: string[] = [];
|
||
if (product.image) collectImages(product.image, images);
|
||
|
||
return {
|
||
title: asString(product.name),
|
||
description: asString(product.description),
|
||
brand: asString(brandName),
|
||
sku: asString(product.sku),
|
||
price: asString(offers?.price),
|
||
currency: asString(offers?.priceCurrency),
|
||
images,
|
||
rating: asString(product.aggregateRating?.ratingValue),
|
||
reviewCount: asString(product.aggregateRating?.reviewCount),
|
||
};
|
||
}
|
||
} catch (err) {
|
||
console.warn('[JSON-LD] 提取失败:', err);
|
||
}
|
||
return null;
|
||
}
|