fix: Volvo parser now correctly decodes VIN with vin-group.action

- Changed VIN search URL from vehicle.action to vin-group.action
- Updated selectors for Volvo's specific HTML structure (vinInfoTable)
- Added parseVolvoInfoTable() to handle multiple label/value pairs per row
- Fixed brand detection to use WMI codes (YV1/YV4 = Volvo)
- Updated category parsing to use nav-group1-table
- Added LEGACY_VOLVO to architectures using vin-group.action

Test VIN YV1MV845BF2240557 now returns:
- Brand: Volvo
- Model: V40 (13-)
- Year: 2015
- Categories: 7

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-01-30 08:21:52 +01:00
parent fdb78383c3
commit 42175731cf
2 changed files with 93 additions and 32 deletions

View File

@@ -29,17 +29,19 @@ export class VolvoParser extends BaseLegacyParser {
readonly brands = ['Volvo', 'Polestar'];
readonly urlPatterns: LegacyUrlPattern = {
vinSearch: '{basePath}/vehicle.action?mode=A0LW0TRTR&lang={lang}&vin={vin}&startup=false',
// Volvo uses vin-group.action for VIN decode (similar to Hyundai/Opel)
vinSearch: '{basePath}/vin-group.action?mode=A0LW0TRTR&lang={lang}&vin={vin}&openVinDialog=true',
vehiclePage: '{basePath}/vehicle.action',
categoriesPage: '{basePath}/main-groups.action',
categoriesPage: '{basePath}/vin-group.action',
subGroupsPage: '{basePath}/sub-groups.action',
partsPage: '{basePath}/parts.action',
partsPage: '{basePath}/vin-image-board.action',
imagePage: '{basePath}/image.action',
};
readonly selectors: LegacySelectors = {
vehicleInfo: {
container: '#vehicleInfo, .vehicle-data, #vinData',
// Volvo uses vinInfoTable inside vinDialog
container: '#vinDialog, #vinTabsGeneral, .vinInfoTable',
brand: '.brand, #brand',
model: '.model, #model, .vehicle-model',
year: '.year, #year, .model-year',
@@ -49,16 +51,18 @@ export class VolvoParser extends BaseLegacyParser {
transmission: '.gearbox, #transmission',
colorCode: '.color, #colorCode',
productionDate: '.prodDate, #productionDate',
dataTable: 'table.vin-info, #vinTable, .vehicle-table',
// Volvo vehicle info table
dataTable: 'table.vinInfoTable',
dataRow: 'tr',
dataLabel: 'td.label, th, td:first-child',
dataValue: 'td.value, td:last-child',
dataLabel: 'td.caption',
dataValue: 'td:not(.caption)',
},
categories: {
container: '#mainGroupList, .main-groups, #mgList',
item: '.main-group, .mg-item, li.mg',
code: '.mg-code, .code',
name: '.mg-name, .name, a',
// Volvo uses nav-group1-table for main categories
container: '#nav-group1-table tbody, #nav-group1-table-container',
item: 'tr.tc-data-row',
code: '.group1',
name: '.group1 a, td.group1 a',
icon: 'img.mg-icon',
link: 'a[href]',
},
@@ -110,27 +114,75 @@ export class VolvoParser extends BaseLegacyParser {
throw new Error(`VIN not found: ${error}`);
}
const rawData = this.parseDataTable(doc);
// Volvo uses vinInfoTable with multiple label/value pairs per row
const rawData = this.parseVolvoInfoTable(doc);
const brand = this.detectBrand(doc, vin);
return {
brand,
model: rawData['model'] || rawData['vehicle'] || '',
year: this.extractYear(rawData['year'] || rawData['model_year'] || ''),
model: rawData['model'] || rawData['model_yili'] || '',
year: this.extractYear(rawData['model_yili'] || rawData['model'] || ''),
vin,
series: rawData['version'] || rawData['trim'] || rawData['variant'] || undefined,
bodyType: rawData['body'] || rawData['body_type'] || undefined,
engineCode: rawData['engine'] || rawData['engine_code'] || undefined,
engineType: rawData['fuel'] || rawData['fuel_type'] || undefined,
engineVolume: rawData['displacement'] || rawData['cc'] || undefined,
transmission: rawData['gearbox'] || rawData['transmission'] || undefined,
driveType: rawData['drive'] || rawData['drivetrain'] || undefined,
colorCode: rawData['color'] || rawData['exterior_color'] || undefined,
productionDate: rawData['production'] || rawData['mfg_date'] || undefined,
series: rawData['turu'] || rawData['satis_tipi'] || undefined,
bodyType: rawData['kaporta_stili'] || rawData['karoseri_tipi_kodu'] || undefined,
engineCode: rawData['motor'] || rawData['motor_kodu'] || undefined,
engineType: rawData['yakit'] || undefined,
engineVolume: undefined,
transmission: rawData['sanziman'] || rawData['sanziman_kodu'] || undefined,
driveType: undefined,
colorCode: rawData['dis_rengi'] || undefined,
productionDate: rawData['uretim_haftasi'] || undefined,
raw: rawData,
};
}
/**
* Parse Volvo's vinInfoTable which has multiple label/value pairs per row
*/
private parseVolvoInfoTable(doc: Document): Record<string, string> {
const data: Record<string, string> = {};
// Find the vinInfoTable
const table = this.htmlParser.querySelector(doc, 'table.vinInfoTable');
if (!table) return data;
const rows = this.htmlParser.querySelectorAll(table, 'tr');
for (const row of rows) {
const cells = this.htmlParser.querySelectorAll(row, 'td');
// Process pairs of cells (caption, value, caption, value...)
for (let i = 0; i < cells.length - 1; i += 2) {
const captionCell = cells[i];
const valueCell = cells[i + 1];
if (!captionCell || !valueCell) continue;
// Check if this is a caption cell
const captionClass = this.htmlParser.getAttribute(captionCell, 'class') || '';
if (!captionClass.includes('caption')) continue;
const label = this.htmlParser.getTextContent(captionCell)
.toLowerCase()
.replace(/[:\s]+/g, '_')
.replace(/_+$/, '')
.replace(/ı/g, 'i')
.replace(/ö/g, 'o')
.replace(/ü/g, 'u')
.replace(/ş/g, 's')
.replace(/ç/g, 'c')
.replace(/ğ/g, 'g');
const value = this.htmlParser.getTextContent(valueCell).trim();
if (label && value && value !== '&nbsp;' && value !== ' ') {
data[label] = value;
}
}
}
return data;
}
parseCategoriesPage(html: string): LegacyCategoryData[] {
const doc = this.htmlParser.parseHtml(html);
const categories: LegacyCategoryData[] = [];
@@ -210,21 +262,30 @@ export class VolvoParser extends BaseLegacyParser {
// ==================== PRIVATE METHODS ====================
private detectBrand(doc: Document, vin: string): string {
// Check VIN prefix
// Check VIN prefix - most reliable method
const wmi = vin.substring(0, 3).toUpperCase();
// Polestar WMIs
// Polestar WMIs (specific to Polestar vehicles)
if (['LP7', 'YSM'].includes(wmi)) return 'Polestar';
// Check page content
const html = this.htmlParser.getTextContent(doc).toLowerCase();
if (html.includes('polestar')) return 'Polestar';
// YV1, YV4 are Volvo WMIs - always return Volvo for these
if (['YV1', 'YV4'].includes(wmi)) return 'Volvo';
// Check page title for brand indication
const titleEl = this.htmlParser.querySelector(doc, 'title');
const title = titleEl ? this.htmlParser.getTextContent(titleEl).toLowerCase() : '';
if (title.includes('polestar') && !title.includes('volvo')) return 'Polestar';
return 'Volvo';
}
private findCategoryLinks(doc: Document): import('domhandler').Element[] {
return this.htmlParser.querySelectorAll(doc, 'a[href*="maingroup"], a[href*="mg="]');
// Volvo uses nav-group1-table with tr.tc-data-row items
const rows = this.htmlParser.querySelectorAll(doc, '#nav-group1-table tbody tr.tc-data-row');
if (rows.length > 0) return rows;
// Fallback to generic selectors
return this.htmlParser.querySelectorAll(doc, 'a[href*="maingroup"], a[href*="mg="], a[href*="group1="]');
}
private findSubGroupLinks(doc: Document): import('domhandler').Element[] {

View File

@@ -160,9 +160,9 @@ export class PL24LegacyScraperService {
vehicleId: cleanVin,
catalogPath: config.basePath,
baseUrl: this.baseUrl,
// Hyundai and Opel use vin-group.action for categories, others use main-groups.action
mainGroupsPath: (config.architecture === 'LEGACY_HYUNDAI_KIA' || config.architecture === 'LEGACY_OPEL')
? `${config.basePath}/vin-group.action?mode=A0LW0TRTR&lang=${this.language}&vin=${cleanVin}&openVinDialog=false`
// Hyundai, Opel, and Volvo use vin-group.action for categories
mainGroupsPath: (config.architecture === 'LEGACY_HYUNDAI_KIA' || config.architecture === 'LEGACY_OPEL' || config.architecture === 'LEGACY_VOLVO')
? `${config.basePath}/vin-group.action?mode=A0LW0TRTR&lang=${this.language}&vin=${cleanVin}&openVinDialog=true`
: `${config.basePath}/main-groups.action`,
},
categories,