feat: complete dashcam catalog ingestion demo
Master Production Deploy / production (push) Failing after 49s
Master Production Deploy / production (push) Failing after 49s
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeYandexMarketProduct } from '../normalizer.ts';
|
||||
import {
|
||||
buildCookieHeader,
|
||||
discoverMvideoProductIds,
|
||||
extractMvideoBffBasePath,
|
||||
extractMvideoSearchQuery,
|
||||
parseMvideoProduct,
|
||||
} from './mvideo.ts';
|
||||
|
||||
describe('MVideo parser', () => {
|
||||
it('extracts search query and BFF path from category HTML', () => {
|
||||
const html = `<script>window.MVID_CONFIG={basePath:'https://www.mvideo.ru/bff'};</script>`;
|
||||
|
||||
expect(extractMvideoBffBasePath(html)).toBe('https://www.mvideo.ru/bff');
|
||||
expect(
|
||||
extractMvideoSearchQuery(
|
||||
'https://www.mvideo.ru/product-list-page?q=%D0%B2%D0%B8%D0%B4%D0%B5%D0%BE%D1%80%D0%B5%D0%B3%D0%B8%D1%81%D1%82%D1%80%D0%B0%D1%82%D0%BE%D1%80',
|
||||
),
|
||||
).toBe('видеорегистратор');
|
||||
expect(extractMvideoSearchQuery('https://www.mvideo.ru/category/unknown')).toBe('видеорегистраторы');
|
||||
});
|
||||
|
||||
it('builds cookie header and discovers stable product ids from search response', () => {
|
||||
const cookieHeader = buildCookieHeader([
|
||||
'MVID_CITY_ID=CityCZ_975; Path=/; Secure',
|
||||
'MVID_REGION_ID=1; Path=/; Secure',
|
||||
'MVID_CITY_ID=CityCZ_975; Path=/; Secure',
|
||||
]);
|
||||
|
||||
expect(cookieHeader).toContain('MVID_CITY_ID=CityCZ_975');
|
||||
expect(cookieHeader).toContain('MVID_REGION_ID=1');
|
||||
expect(
|
||||
discoverMvideoProductIds({
|
||||
body: {
|
||||
products: ['4254724', 4254724, '400481258', 'bad-id'],
|
||||
},
|
||||
}),
|
||||
).toEqual(['4254724', '400481258']);
|
||||
expect(
|
||||
discoverMvideoProductIds({
|
||||
body: {
|
||||
type: 'redirect',
|
||||
url: '/products/videoregistrator-navitel-ar202-nv-400484046',
|
||||
},
|
||||
}),
|
||||
).toEqual(['400484046']);
|
||||
});
|
||||
|
||||
it('maps product details to parsed and normalized shape with full raw specs', () => {
|
||||
const parsed = parseMvideoProduct({
|
||||
body: {
|
||||
productId: '4254724',
|
||||
name: 'Видеорегистратор Navitel R9 DUAL',
|
||||
nameTranslit: 'videoregistrator-navitel-r9-dual',
|
||||
brandName: 'Navitel',
|
||||
modelName: 'R9 DUAL',
|
||||
images: ['Big/4254724bb.jpg', '/Big/4254724bb1.jpg'],
|
||||
rating: {
|
||||
score: 4.8,
|
||||
total: 17,
|
||||
},
|
||||
properties: {
|
||||
all: [
|
||||
{
|
||||
name: 'Основные характеристики',
|
||||
properties: [
|
||||
{
|
||||
name: 'Поддержка Wi-Fi',
|
||||
value: 'Да',
|
||||
},
|
||||
{
|
||||
name: 'Качество видеосъемки/ FPS',
|
||||
value: 'FullHD (1920x1080 Пикс) 30 кадр/сек',
|
||||
},
|
||||
{
|
||||
name: 'Частота при макс. разрешении камеры заднего вида',
|
||||
value: '30 кадр/сек',
|
||||
},
|
||||
{
|
||||
name: 'Макс. разрешение видеосъемки',
|
||||
value: '2560x1440',
|
||||
},
|
||||
{
|
||||
name: 'База камер',
|
||||
value: 'Да',
|
||||
},
|
||||
{
|
||||
name: 'Запись звука',
|
||||
value: 'Да',
|
||||
},
|
||||
{
|
||||
name: 'Резервный источник питания',
|
||||
value: 'аккумулятор',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Оптика',
|
||||
properties: [
|
||||
{
|
||||
name: 'Угол обзора (основная камера)',
|
||||
value: '170',
|
||||
measure: '°',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalized = normalizeYandexMarketProduct(parsed);
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
source: 'mvideo',
|
||||
sourceProductId: '4254724',
|
||||
sourceUrl: 'https://www.mvideo.ru/products/videoregistrator-navitel-r9-dual-4254724',
|
||||
brand: 'Navitel',
|
||||
model: 'R9 DUAL',
|
||||
title: 'Видеорегистратор Navitel R9 DUAL',
|
||||
aggregateRating: {
|
||||
value: 4.8,
|
||||
scale: 5,
|
||||
ratingCount: 17,
|
||||
},
|
||||
});
|
||||
expect(parsed.images).toEqual([
|
||||
'https://img.mvideo.ru/Big/4254724bb.jpg',
|
||||
'https://www.mvideo.ru/Big/4254724bb1.jpg',
|
||||
]);
|
||||
expect(parsed.rawSpecs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
group: 'Основные характеристики',
|
||||
label: 'Поддержка Wi-Fi',
|
||||
value: 'Да',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
group: 'Оптика',
|
||||
label: 'Угол обзора (основная камера)',
|
||||
value: '170 °',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(normalized.specs).toEqual(
|
||||
expect.objectContaining({
|
||||
wifi: true,
|
||||
speedcam: true,
|
||||
microphone: true,
|
||||
battery_power: 'battery',
|
||||
resolution_front: '1920x1080',
|
||||
frame_rate_max_fps: 30,
|
||||
view_angle_degrees: 170,
|
||||
}),
|
||||
);
|
||||
expect(normalized.rawSpecs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
label: 'Поддержка Wi-Fi',
|
||||
normalizedKey: 'wifi',
|
||||
normalizedValue: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
label: 'Макс. разрешение видеосъемки',
|
||||
normalizedKey: 'resolution_max',
|
||||
normalizedValue: '2560x1440',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { normalizeWhitespace, uniqueStable } from '../html.ts';
|
||||
import type { ParsedMarketProduct, ParsedTechnicalSpec } from '../types.ts';
|
||||
|
||||
export const mvideoOrigin = 'https://www.mvideo.ru';
|
||||
const productIdPattern = /^\d+$/;
|
||||
|
||||
type ProductDetailsProperty = {
|
||||
name?: unknown;
|
||||
value?: unknown;
|
||||
measure?: unknown;
|
||||
};
|
||||
|
||||
type ProductDetailsPropertyGroup = {
|
||||
name?: unknown;
|
||||
properties?: unknown;
|
||||
};
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
|
||||
const normalized = normalizeWhitespace(value);
|
||||
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeSourceImageUrl(url: string) {
|
||||
const trimmed = url.trim();
|
||||
|
||||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed;
|
||||
if (trimmed.startsWith('//')) return `https:${trimmed}`;
|
||||
if (trimmed.startsWith('/')) return `${mvideoOrigin}${trimmed}`;
|
||||
|
||||
return `https://img.mvideo.ru/${trimmed.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
function createFallbackProductId(sourceUrl: string, title: string) {
|
||||
return createHash('sha1').update(`${sourceUrl}:${title}`).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function parseSpecValue(value: unknown, measure: unknown): string | number | boolean | undefined {
|
||||
if (typeof value === 'boolean' || typeof value === 'number') return value;
|
||||
|
||||
const normalizedValue = stringValue(value);
|
||||
|
||||
if (!normalizedValue) return undefined;
|
||||
|
||||
const normalizedMeasure = stringValue(measure);
|
||||
|
||||
if (!normalizedMeasure) return normalizedValue;
|
||||
if (normalizedValue.toLowerCase().includes(normalizedMeasure.toLowerCase())) return normalizedValue;
|
||||
|
||||
return `${normalizedValue} ${normalizedMeasure}`;
|
||||
}
|
||||
|
||||
function specsRecord(technicalSpecs: ParsedTechnicalSpec[]) {
|
||||
const record: Record<string, string | number | boolean> = {};
|
||||
|
||||
for (const spec of technicalSpecs) {
|
||||
record[spec.label] = spec.value;
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
export function extractMvideoBffBasePath(html: string) {
|
||||
const directMatch = html.match(/basePath\s*:\s*['"]([^'"]+)['"]/i)?.[1];
|
||||
const jsonMatch = html.match(/"basePath"\s*:\s*"([^"]+)"/i)?.[1];
|
||||
const value = directMatch ?? jsonMatch;
|
||||
|
||||
if (!value) return `${mvideoOrigin}/bff`;
|
||||
if (value.startsWith('http://') || value.startsWith('https://')) return value;
|
||||
if (value.startsWith('/')) return `${mvideoOrigin}${value}`;
|
||||
|
||||
return `${mvideoOrigin}/${value.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
export function extractMvideoSearchQuery(categoryUrl: string) {
|
||||
try {
|
||||
const url = new URL(categoryUrl);
|
||||
const query = url.searchParams.get('q') ?? url.searchParams.get('query');
|
||||
|
||||
if (query?.trim()) return query.trim();
|
||||
} catch {
|
||||
// Fall through to default query.
|
||||
}
|
||||
|
||||
return 'видеорегистраторы';
|
||||
}
|
||||
|
||||
export function buildMvideoProductUrl(productId: string) {
|
||||
return `${mvideoOrigin}/product/${encodeURIComponent(productId)}`;
|
||||
}
|
||||
|
||||
export function buildMvideoBffUrl(basePath: string, endpoint: string) {
|
||||
const normalizedBasePath = basePath.endsWith('/') ? basePath : `${basePath}/`;
|
||||
|
||||
return new URL(endpoint.replace(/^\/+/, ''), normalizedBasePath);
|
||||
}
|
||||
|
||||
export function buildCookieHeader(setCookieHeaders: string[]) {
|
||||
const cookies = new Map<string, string>();
|
||||
|
||||
for (const header of setCookieHeaders) {
|
||||
const cookiePart = header.split(';')[0]?.trim();
|
||||
|
||||
if (!cookiePart) continue;
|
||||
|
||||
const separatorIndex = cookiePart.indexOf('=');
|
||||
|
||||
if (separatorIndex <= 0) continue;
|
||||
|
||||
const name = cookiePart.slice(0, separatorIndex).trim();
|
||||
const value = cookiePart.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (!name || !value) continue;
|
||||
|
||||
cookies.set(name, `${name}=${value}`);
|
||||
}
|
||||
|
||||
return [...cookies.values()].join('; ');
|
||||
}
|
||||
|
||||
export function discoverMvideoProductIds(searchPayload: unknown) {
|
||||
if (!searchPayload || typeof searchPayload !== 'object') {
|
||||
throw new Error('MVideo search payload is not an object');
|
||||
}
|
||||
|
||||
const body = (searchPayload as { body?: unknown }).body;
|
||||
const products = body && typeof body === 'object' ? (body as { products?: unknown }).products : undefined;
|
||||
|
||||
const productIds = Array.isArray(products)
|
||||
? products
|
||||
.map((value) => (typeof value === 'number' || typeof value === 'string' ? String(value).trim() : ''))
|
||||
.filter((value): value is string => productIdPattern.test(value))
|
||||
: [];
|
||||
|
||||
if (productIds.length > 0) return uniqueStable(productIds);
|
||||
|
||||
// MVideo converts exact one-result searches into a redirect instead of a
|
||||
// one-element products array. The destination still carries the public ID.
|
||||
const redirectUrl =
|
||||
typeof (body as { url?: unknown }).url === 'string'
|
||||
? (body as { url: string }).url
|
||||
: typeof (body as { redirectUrl?: unknown }).redirectUrl === 'string'
|
||||
? (body as { redirectUrl: string }).redirectUrl
|
||||
: undefined;
|
||||
const redirectProductId = redirectUrl?.match(/(\d{6,13})(?:[/?#]|$)/)?.[1];
|
||||
|
||||
if (redirectProductId) return [redirectProductId];
|
||||
|
||||
throw new Error('MVideo search payload does not include product identifiers');
|
||||
}
|
||||
|
||||
export function parseMvideoProduct(detailsPayload: unknown): ParsedMarketProduct {
|
||||
if (!detailsPayload || typeof detailsPayload !== 'object') {
|
||||
throw new Error('MVideo product payload is not an object');
|
||||
}
|
||||
|
||||
const body = (detailsPayload as { body?: unknown }).body;
|
||||
|
||||
if (!body || typeof body !== 'object') {
|
||||
throw new Error('MVideo product payload has no body');
|
||||
}
|
||||
|
||||
const product = body as Record<string, unknown>;
|
||||
const title = stringValue(product.name);
|
||||
|
||||
if (!title) {
|
||||
throw new Error('MVideo product title was not found');
|
||||
}
|
||||
|
||||
const sourceProductIdValue = product.productId;
|
||||
const sourceProductId =
|
||||
(typeof sourceProductIdValue === 'number' || typeof sourceProductIdValue === 'string'
|
||||
? String(sourceProductIdValue).trim()
|
||||
: undefined) ?? undefined;
|
||||
const slug = stringValue(product.nameTranslit);
|
||||
const sourceUrl = slug && sourceProductId
|
||||
? `${mvideoOrigin}/products/${slug}-${sourceProductId}`
|
||||
: sourceProductId
|
||||
? `${mvideoOrigin}/product/${sourceProductId}`
|
||||
: mvideoOrigin;
|
||||
const brand = stringValue(product.brandName);
|
||||
const model = stringValue(product.modelName);
|
||||
const images = uniqueStable(
|
||||
(Array.isArray(product.images) ? product.images : [])
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map(normalizeSourceImageUrl),
|
||||
);
|
||||
const rawSpecs: ParsedTechnicalSpec[] = [];
|
||||
const rawGroups = (product.properties as { all?: unknown } | undefined)?.all;
|
||||
|
||||
if (Array.isArray(rawGroups)) {
|
||||
for (const rawGroup of rawGroups as ProductDetailsPropertyGroup[]) {
|
||||
const groupName = stringValue(rawGroup.name);
|
||||
const properties = Array.isArray(rawGroup.properties) ? rawGroup.properties : [];
|
||||
|
||||
for (const rawSpec of properties as ProductDetailsProperty[]) {
|
||||
const label = stringValue(rawSpec.name);
|
||||
const value = parseSpecValue(rawSpec.value, rawSpec.measure);
|
||||
|
||||
if (!label || value === undefined) continue;
|
||||
|
||||
rawSpecs.push({
|
||||
...(groupName ? { group: groupName } : {}),
|
||||
label,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ratingRaw = product.rating;
|
||||
const aggregateRating =
|
||||
ratingRaw && typeof ratingRaw === 'object' && Number.isFinite(Number((ratingRaw as { score?: unknown }).score))
|
||||
? {
|
||||
value: Number((ratingRaw as { score: number | string }).score),
|
||||
scale: 5,
|
||||
...(Number.isFinite(Number((ratingRaw as { total?: unknown }).total))
|
||||
? { ratingCount: Number((ratingRaw as { total: number | string }).total) }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
source: 'mvideo',
|
||||
sourceProductId: sourceProductId ?? createFallbackProductId(sourceUrl, title),
|
||||
title,
|
||||
...(brand ? { brand } : {}),
|
||||
...(model ? { model } : {}),
|
||||
images,
|
||||
specs: specsRecord(rawSpecs),
|
||||
rawSpecs,
|
||||
sourceUrl,
|
||||
aggregateRating,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeYandexMarketProduct } from '../normalizer';
|
||||
import { createStableContentHash } from '../stableHash';
|
||||
import { discoverYandexMarketProductsFromCategory, parseYandexMarketProduct } from './yandexMarket';
|
||||
|
||||
describe('Yandex Market parser', () => {
|
||||
it('discovers and canonicalizes card and product URLs from category HTML', () => {
|
||||
const discovered = discoverYandexMarketProductsFromCategory(
|
||||
`
|
||||
<a href="/card/ibox-f5-wifi-videoregistrator-s-signaturnym-radar-detektorom/5197387201?do-waremd5=x&cpc=y">F5</a>
|
||||
<script>{"url":"https://market.yandex.ru/product/312774508?offerid=secret&sku=103274083526"}</script>
|
||||
`,
|
||||
'https://market.yandex.ru/catalog--avtomobilnye-videoregistratory/82798275/list?hid=82798269',
|
||||
'2026-05-16T20:00:00.000Z',
|
||||
);
|
||||
|
||||
expect(discovered).toMatchObject([
|
||||
{
|
||||
productUrl:
|
||||
'https://market.yandex.ru/card/ibox-f5-wifi-videoregistrator-s-signaturnym-radar-detektorom/5197387201',
|
||||
sourceProductId: '5197387201',
|
||||
},
|
||||
{
|
||||
productUrl: 'https://market.yandex.ru/product/312774508',
|
||||
sourceProductId: '312774508',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps product JSON-LD and DOM specs without prices, sellers or reviews', () => {
|
||||
const html = `
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
"brand": "iBOX",
|
||||
"image": "https://avatars.mds.yandex.net/get-mpic/18787582/2a000/orig",
|
||||
"name": "Видеорегистратор для автомобиля iBOX F5 + WiFi с радар-детектором",
|
||||
"offers": { "price": "17816", "availability": "https://schema.org/InStock" },
|
||||
"aggregateRating": { "ratingValue": "4.8", "ratingCount": "17" },
|
||||
"url": "https://market.yandex.ru/card/ibox-f5-wifi-videoregistrator-s-signaturnym-radar-detektorom/5197387201"
|
||||
}
|
||||
</script>
|
||||
<label class="_3J2L5">
|
||||
<div><span>Экран</span></div>
|
||||
<div class="_2yz50"><span>да</span></div>
|
||||
</label>
|
||||
<div>
|
||||
<span data-auto="product-spec">Количество камер</span>
|
||||
<div class="eXP5k"><div><div class="ds-text"><span>1</span></div></div></div>
|
||||
<span data-auto="product-spec">Разрешение видеозаписи (при макс. частоте)</span>
|
||||
<div class="eXP5k"><div><div class="ds-text"><span>2304x1296</span></div></div></div>
|
||||
<span data-auto="product-spec">Wi-Fi</span>
|
||||
<div class="eXP5k"><div><div class="ds-text"><span>да</span></div></div></div>
|
||||
<span data-auto="product-spec">С радар-детектором</span>
|
||||
<div class="eXP5k"><div><div class="ds-text"><span>нет</span></div></div></div>
|
||||
<label for="group-collapse-Камера"><span>Камера</span></label>
|
||||
<span data-auto="product-spec">Угол обзора (диагональ)</span>
|
||||
<div class="eXP5k"><div><div class="ds-text"><span>170°</span></div></div></div>
|
||||
<span data-auto="product-spec">Матрица</span>
|
||||
<div class="eXP5k"><div><div class="ds-text"><span>CMOS</span></div></div></div>
|
||||
</div>
|
||||
<span class="ds-text ds-text_weight_med">Основные характеристики</span>
|
||||
<div data-apiary-widget-name=@card/AboutSpecV2>
|
||||
<div><span data-auto="product-spec">Режим записи</span></div>
|
||||
<div class="ds-flex uA8Xn"><span>циклическая</span></div>
|
||||
</div>
|
||||
`;
|
||||
const parsed = parseYandexMarketProduct(
|
||||
html,
|
||||
'https://market.yandex.ru/card/ibox-f5-wifi-videoregistrator-s-signaturnym-radar-detektorom/5197387201',
|
||||
);
|
||||
const normalized = normalizeYandexMarketProduct(parsed);
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
source: 'yandex_market',
|
||||
sourceProductId: '5197387201',
|
||||
brand: 'iBOX',
|
||||
title: 'Видеорегистратор для автомобиля iBOX F5 + WiFi с радар-детектором',
|
||||
aggregateRating: {
|
||||
value: 4.8,
|
||||
ratingCount: 17,
|
||||
},
|
||||
});
|
||||
expect(normalized.specs).toEqual({
|
||||
channels: 1,
|
||||
resolution_front: '2304x1296',
|
||||
wifi: true,
|
||||
radar_detector: false,
|
||||
view_angle_degrees: 170,
|
||||
sensor_type: 'CMOS',
|
||||
recording_mode: 'циклическая',
|
||||
screen: true,
|
||||
});
|
||||
expect(normalized.canonicalModel).toBe('F5 + Wi-Fi');
|
||||
expect(normalized.canonicalName).toBe('iBOX F5 + Wi-Fi');
|
||||
expect(normalized.rawSpecs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
group: 'Камера',
|
||||
label: 'Матрица',
|
||||
value: 'CMOS',
|
||||
normalizedKey: 'sensor_type',
|
||||
normalizedValue: 'CMOS',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(parsed.rawSpecs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
group: 'Основные характеристики',
|
||||
label: 'Режим записи',
|
||||
value: 'циклическая',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(createStableContentHash(normalized)).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(JSON.stringify(normalized)).not.toContain('17816');
|
||||
});
|
||||
|
||||
it('keeps stable model attributes and removes feature tails from canonical model', () => {
|
||||
const normalized = normalizeYandexMarketProduct({
|
||||
source: 'yandex_market',
|
||||
sourceProductId: '5247127917',
|
||||
sourceUrl:
|
||||
'https://market.yandex.ru/card/videoregistrator-avtomobilnyy-12-mp-1296p-s-dvumya-kamerami-2k-spawnson-full-hd-antiblikovyy-obyektiv/5247127917',
|
||||
title: 'Видеорегистратор SPAWNSON "Spawnson", 24МП, Wi-Fi, ночной режим',
|
||||
brand: 'SPAWNSON',
|
||||
images: [],
|
||||
specs: {},
|
||||
rawSpecs: [],
|
||||
});
|
||||
|
||||
expect(normalized.model).toBe('24МП Wi-Fi ночной режим');
|
||||
expect(normalized.canonicalModel).toBe('24МП Wi-Fi');
|
||||
expect(normalized.canonicalName).toBe('SPAWNSON 24МП Wi-Fi');
|
||||
});
|
||||
|
||||
it('removes mirror form factor and automotive descriptor tails from canonical model', () => {
|
||||
const normalized = normalizeYandexMarketProduct({
|
||||
source: 'yandex_market',
|
||||
sourceProductId: 'mirror-1',
|
||||
sourceUrl: 'https://market.yandex.ru/product/mirror-1',
|
||||
title: 'Видеорегистратор-зеркало iBOX Rover 2 автомобильный с базой камер',
|
||||
brand: 'iBOX',
|
||||
images: [],
|
||||
specs: {},
|
||||
rawSpecs: [],
|
||||
});
|
||||
|
||||
expect(normalized.canonicalModel).toBe('Rover 2');
|
||||
expect(normalized.canonicalName).toBe('iBOX Rover 2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { decodeHtml, extractJsonLdObjects, getObjectString, normalizeWhitespace, readMetaContent, stripTags, uniqueStable } from '../html.ts';
|
||||
import type { DiscoveredProduct, ParsedMarketProduct, ParsedTechnicalSpec } from '../types.ts';
|
||||
|
||||
const marketOrigin = 'https://market.yandex.ru';
|
||||
const productUrlPattern = /(?:https?:\/\/market\.yandex\.ru)?\/(?:card|product)\/[^\s"'<>\\]+/gi;
|
||||
|
||||
function getJsonLdType(object: Record<string, unknown>) {
|
||||
const type = object['@type'];
|
||||
|
||||
if (Array.isArray(type)) return type.map(String);
|
||||
if (typeof type === 'string') return [type];
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function isJsonLdProduct(object: unknown): object is Record<string, unknown> {
|
||||
return !!object && typeof object === 'object' && getJsonLdType(object as Record<string, unknown>).includes('Product');
|
||||
}
|
||||
|
||||
function normalizeSourceImageUrl(url: string) {
|
||||
const decoded = decodeHtml(url).trim();
|
||||
|
||||
if (decoded.startsWith('//')) return `https:${decoded}`;
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function canonicalizeMarketUrl(rawUrl: string) {
|
||||
const decoded = decodeHtml(rawUrl).replace(/,$/, '');
|
||||
const url = new URL(decoded, marketOrigin);
|
||||
|
||||
if (url.hostname !== 'market.yandex.ru') {
|
||||
throw new Error(`Unsupported Yandex Market host: ${url.hostname}`);
|
||||
}
|
||||
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
const type = parts[0];
|
||||
|
||||
if (type === 'product' && parts[1]) {
|
||||
return `${marketOrigin}/product/${parts[1]}`;
|
||||
}
|
||||
|
||||
if (type === 'card' && parts[1] && parts[2]) {
|
||||
return `${marketOrigin}/card/${parts[1]}/${parts[2]}`;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Yandex Market product URL: ${rawUrl}`);
|
||||
}
|
||||
|
||||
function sourceProductIdFromUrl(url: string) {
|
||||
const parsed = new URL(url);
|
||||
const parts = parsed.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (parts[0] === 'product') return parts[1];
|
||||
if (parts[0] === 'card') return parts[2];
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function titleFromCardSlug(url: string) {
|
||||
const parsed = new URL(url);
|
||||
const parts = parsed.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (parts[0] !== 'card') return undefined;
|
||||
|
||||
return parts[1]
|
||||
?.split('-')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function normalizeProductUrlCandidate(rawUrl: string) {
|
||||
try {
|
||||
const url = canonicalizeMarketUrl(rawUrl);
|
||||
|
||||
return {
|
||||
productUrl: url,
|
||||
sourceProductId: sourceProductIdFromUrl(url),
|
||||
titlePreview: titleFromCardSlug(url),
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractRating(value: unknown): ParsedMarketProduct['aggregateRating'] {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
|
||||
const object = value as Record<string, unknown>;
|
||||
const ratingValue = Number(object.ratingValue);
|
||||
const ratingCount = Number(object.ratingCount ?? object.reviewCount);
|
||||
const bestRating = Number(object.bestRating);
|
||||
|
||||
if (!Number.isFinite(ratingValue)) return undefined;
|
||||
|
||||
return {
|
||||
value: ratingValue,
|
||||
...(Number.isFinite(bestRating) ? { scale: bestRating } : {}),
|
||||
...(Number.isFinite(ratingCount) ? { ratingCount } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function extractJsonLdProduct(html: string) {
|
||||
return extractJsonLdObjects(html).find(isJsonLdProduct);
|
||||
}
|
||||
|
||||
function extractJsonLdImages(product: Record<string, unknown> | undefined) {
|
||||
const image = product?.image;
|
||||
const values = Array.isArray(image) ? image : image ? [image] : [];
|
||||
|
||||
return values.filter((value): value is string => typeof value === 'string').map(normalizeSourceImageUrl);
|
||||
}
|
||||
|
||||
function extractMetaImages(html: string) {
|
||||
return [readMetaContent(html, 'og:image')]
|
||||
.filter((value): value is string => !!value)
|
||||
.map(normalizeSourceImageUrl);
|
||||
}
|
||||
|
||||
function extractSpecGroup(htmlBeforeSpec: string) {
|
||||
const matches = [
|
||||
...htmlBeforeSpec.matchAll(/for=["']group-collapse-([^"']+)["'][\s\S]{0,500}?<span[^>]*>([\s\S]*?)<\/span>/gi),
|
||||
...htmlBeforeSpec.matchAll(
|
||||
/<span[^>]*class=["'][^"']*ds-text_weight_med[^"']*["'][^>]*>([\s\S]*?)<\/span>/gi,
|
||||
),
|
||||
];
|
||||
const match = matches
|
||||
.filter((candidate) => candidate.index !== undefined)
|
||||
.sort((left, right) => (left.index ?? 0) - (right.index ?? 0))
|
||||
.at(-1);
|
||||
|
||||
return match ? stripTags(match[2] || match[1]) : undefined;
|
||||
}
|
||||
|
||||
function extractTechnicalSpecsFromDom(html: string): ParsedTechnicalSpec[] {
|
||||
const specs: ParsedTechnicalSpec[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
const legacySpecPattern =
|
||||
/<span[^>]*data-auto=["']product-spec["'][^>]*>([\s\S]*?)<\/span>[\s\S]{0,1400}?<div[^>]+class=["'][^"']*ds-text[^"']*["'][^>]*>\s*<span>([\s\S]*?)<\/span>/gi;
|
||||
const currentSpecPattern =
|
||||
/data-apiary-widget-name=@card\/AboutSpecV2[^>]*>[\s\S]*?<span[^>]*data-auto=["']product-spec["'][^>]*>([\s\S]*?)<\/span>[\s\S]*?<div[^>]*class=["'][^"']*(?:uA8Xn|_2yz50)[^"']*["'][^>]*>\s*<span[^>]*>([\s\S]*?)<\/span>/gi;
|
||||
const compactSpecPattern =
|
||||
/<label[^>]*>[\s\S]*?<span[^>]*>([\s\S]*?)<\/span>[\s\S]*?<div[^>]*class=["'][^"']*_2yz50[^"']*["'][^>]*>\s*<span[^>]*>([\s\S]*?)<\/span>[\s\S]*?<\/label>/gi;
|
||||
|
||||
function addSpec(match: RegExpMatchArray) {
|
||||
const rawName = match[1];
|
||||
const rawValue = match[2];
|
||||
const label = stripTags(rawName);
|
||||
const value = stripTags(rawValue);
|
||||
|
||||
if (!label || !value || label === value) return;
|
||||
|
||||
const group = extractSpecGroup(html.slice(Math.max(0, (match.index ?? 0) - 8_000), match.index));
|
||||
const dedupeKey = `${label}:${value}`;
|
||||
const existingIndex = seen.get(dedupeKey);
|
||||
|
||||
if (existingIndex !== undefined) {
|
||||
if (group && !specs[existingIndex]?.group) {
|
||||
specs[existingIndex] = { ...specs[existingIndex], group };
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
seen.set(dedupeKey, specs.length);
|
||||
specs.push({
|
||||
...(group ? { group } : {}),
|
||||
label,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
for (const match of html.matchAll(currentSpecPattern)) {
|
||||
addSpec(match);
|
||||
}
|
||||
|
||||
for (const match of html.matchAll(legacySpecPattern)) {
|
||||
addSpec(match);
|
||||
}
|
||||
|
||||
for (const match of html.matchAll(compactSpecPattern)) {
|
||||
addSpec(match);
|
||||
}
|
||||
|
||||
return specs;
|
||||
}
|
||||
|
||||
function specsRecord(technicalSpecs: ParsedTechnicalSpec[]) {
|
||||
const record: Record<string, string | number | boolean> = {};
|
||||
|
||||
for (const spec of technicalSpecs) {
|
||||
record[spec.label] = spec.value;
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
function extractTitle(html: string, product?: Record<string, unknown>) {
|
||||
const jsonTitle = getObjectString(product?.name);
|
||||
const ogTitle = readMetaContent(html, 'og:title');
|
||||
const titleTag = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1];
|
||||
|
||||
return jsonTitle ?? ogTitle ?? (titleTag ? stripTags(titleTag) : undefined);
|
||||
}
|
||||
|
||||
function createFallbackProductId(sourceUrl: string, title: string) {
|
||||
return createHash('sha1').update(`${sourceUrl}:${title}`).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
export function discoverYandexMarketProductsFromCategory(
|
||||
html: string,
|
||||
categoryUrl: string,
|
||||
discoveredAt = new Date().toISOString(),
|
||||
): DiscoveredProduct[] {
|
||||
const products = [...html.matchAll(productUrlPattern)]
|
||||
.map((match) => normalizeProductUrlCandidate(match[0]))
|
||||
.filter((value): value is NonNullable<ReturnType<typeof normalizeProductUrlCandidate>> => !!value)
|
||||
.map((value) => ({
|
||||
source: 'yandex_market' as const,
|
||||
categoryUrl,
|
||||
productUrl: value.productUrl,
|
||||
sourceProductId: value.sourceProductId,
|
||||
titlePreview: value.titlePreview,
|
||||
discoveredAt,
|
||||
}));
|
||||
|
||||
const byUrl = new Map<string, DiscoveredProduct>();
|
||||
|
||||
for (const product of products) {
|
||||
byUrl.set(product.productUrl, product);
|
||||
}
|
||||
|
||||
return [...byUrl.values()];
|
||||
}
|
||||
|
||||
export function parseYandexMarketProduct(html: string, sourceUrl: string): ParsedMarketProduct {
|
||||
const product = extractJsonLdProduct(html);
|
||||
const title = extractTitle(html, product);
|
||||
|
||||
if (!title) {
|
||||
throw new Error('Yandex Market product title was not found');
|
||||
}
|
||||
|
||||
const canonicalUrl = normalizeProductUrlCandidate(getObjectString(product?.url) ?? sourceUrl)?.productUrl ?? sourceUrl;
|
||||
const sourceProductId =
|
||||
sourceProductIdFromUrl(canonicalUrl) ??
|
||||
(typeof product?.sku === 'string' ? product.sku : undefined) ??
|
||||
createFallbackProductId(canonicalUrl, title);
|
||||
const brand = getObjectString(product?.brand);
|
||||
const images = uniqueStable([...extractJsonLdImages(product), ...extractMetaImages(html)]).filter((url) =>
|
||||
url.includes('/get-mpic/'),
|
||||
);
|
||||
const rawSpecs = extractTechnicalSpecsFromDom(html);
|
||||
|
||||
return {
|
||||
source: 'yandex_market',
|
||||
sourceProductId,
|
||||
title: normalizeWhitespace(title),
|
||||
...(brand ? { brand } : {}),
|
||||
images,
|
||||
specs: specsRecord(rawSpecs),
|
||||
rawSpecs,
|
||||
sourceUrl: canonicalUrl,
|
||||
aggregateRating: extractRating(product?.aggregateRating),
|
||||
};
|
||||
}
|
||||
|
||||
export function canonicalizeYandexMarketProductUrl(rawUrl: string) {
|
||||
return canonicalizeMarketUrl(rawUrl);
|
||||
}
|
||||
Reference in New Issue
Block a user