Parts catalogs don’t have APIs. They have HTML — and every brand does it differently. Opel has two navigation levels and JSON subgroups. Volvo has four levels and mixes HTML with JSON. Nissan has a two-phase BOM where the HTML page only lists part categories and a second request returns the actual part numbers.
When you need to pull data from a catalog like this programmatically, you have two options: write one big parser that branches by brand, or design an architecture where each brand handles its own specifics. The first option works up to the third brand. After that, it becomes code nobody wants to read.
One interface, one parser per brand
The foundation is the StrutsBrandParser interface. It defines what every brand must implement:
- parse subgroups from JSON
- parse BOM (parts list) from HTML
- recognize a valid VIN page URL
- extract the session key from redirect parameters
Optionally, a brand can override vehicle info, main group, and subgroup parsing from HTML — critical for brands with non-standard layouts.
The generic client (struts-client.ts) handles navigation: following redirects, cookie auth, fetching each level. It delegates parsing to the brand-specific parser. When Volvo returns HTML where Opel returns JSON, the client doesn’t need to know — it calls parseSubgroupsHtml and gets back normalized VCfgEntry[] it already knows how to handle.
Volvo: four levels, no JSON at the start
Volvo is the deepest. The navigation looks like this:
decode_vin
→ vin-group.action (level 1 — HTML, main groups)
→ vin-group.action?group1=X (level 2 — HTML, subgroups)
→ json-vin-groups3.action (level 3 — JSON, illustrations)
→ vin-image-board.action (level 4 — HTML, BOM)
Levels 1 and 2 are HTML tables rendered directly into the page. There is no JSON endpoint for main groups — the parser reads #nav-group1-table and #nav-group2-table straight from the HTML.
Level 2 is interesting: each table row carries a jsonurl attribute pointing to level 3. The parser returns VCfgEntry[] with jsonUrl populated, and the generic client makes a follow-up fetch that goes through parseSubgroupsJson. Same logic as brands that have JSON from the start — just one extra step at the beginning.
Level 4 (BOM) has its quirks. Rows with valid="false" are parts that can’t be ordered separately — the parser skips them. Part names can contain <br> between the base name and a left/right suffix, so cheerio’s .text() isn’t enough — you need to rewrite <br> to a space before extracting text.
The session token is hintstoken= (UUID), not catId= like Opel. Small detail, but auth doesn’t work without it.
Nissan: BOM in two phases
Nissan has a different problem. The BOM page (x-vin-image-board.action) doesn’t contain actual part numbers — only categories (PNC, part-number category). Only a second request to x-json-vin-bom-detail.action?pnc=X returns the real parts for that category.
That’s why the interface has an optional parseBomPartsAsync — an async version of the BOM parser. Nissan implements it; other brands don’t. The client checks whether the parser has the async version and routes accordingly.
Detail fetches are capped at 6 concurrent requests — partly to be polite to the server, partly to stay under the rate limit.
Tests against captured fixtures
The parser isn’t tested against the live catalog. That would be a flaky test — the catalog changes, sessions expire, the network is unreliable.
Instead, captured HTML and JSON fixtures from real requests are used. The Volvo parser has 17 unit tests against fixtures from VIN YV1UZK5VCM1743449 (XC60, brakes, level 2 → 51 Wheel brake → 416001). The tests cover all four levels: level 2 HTML, level 3 JSON, level 4 BOM.
When the catalog structure changes, the test fails with a clear error — not a timeout or an empty result.
The takeaway
A parser architecture for parts catalogs isn’t about writing the cleverest scraper. It’s about designing an interface that absorbs the differences between brands without the generic client having to care whether it’s reading HTML or JSON.
Volvo has four levels. Nissan has a two-phase BOM. Opel has JSON from the start. All three return the same types — and the client calling them knows nothing about the differences.
A good abstraction isn’t one that hides complexity. It’s one that isolates it in a single place.