feat: complete dashcam catalog ingestion demo
Master Production Deploy / production (push) Failing after 49s

This commit is contained in:
2026-07-31 02:01:58 +03:00
parent ae6c35145f
commit 2ce58fcbc0
62 changed files with 6341 additions and 178 deletions
+171
View File
@@ -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',
}),
]),
);
});
});
+238
View File
@@ -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,
};
}
+154
View File
@@ -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&amp;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');
});
});
+272
View File
@@ -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);
}