feat(catalog): Ford de variant-drill — parse json-sub-group → image-board parts

Ford parts are licensed only on the de account. The de drill chain is:
group.action (engine/transmission restriction nodes, HTML) → json-main-group.action
(maingroups JSON) → json-sub-group.action (subgroups JSON) → image-board.action (parts).
The existing json-sub-group handler only knew the Nissan shape ({caption,code,url});
Ford de subgroups are {caption,id,subheader,jsonUrl:null,url:null} — leaves carry no
URL. Add parseFordSubGroupsJson: skip subheader rows, and for each leaf construct the
illustration URL (subGroupId = mainGroupId+subheaderId, bomId = subGroupId+leafId →
image-board.action?bomId=…), which drills to parts via the existing image-board path.
Route Ford (fordp/fordt) to de alongside Hyundai/Kia/Nissan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 17:36:54 +03:00
parent 74d2c9d480
commit c991c3846a

View File

@@ -49,7 +49,7 @@ export interface P4BrandHooks {
// Brands whose parts are licensed only on the de (account 2) PL24 subscription —
// the tr account serves a NOT_LOGGED_IN_DEMO page for these. All P4 fetches for
// these services are routed to de (see fetchP4Page + resolveAccount).
const HYUNDAI_KIA_NISSAN_DE = new Set([
const LEGACY_DE_SERVICES = new Set([
"hyundai_parts",
"kia_parts",
"nissan_parts",
@@ -263,13 +263,18 @@ export class PL24FordLegacyService {
// Nissan/Infiniti/Opel: json-sub-group.action
if (linkPath.includes("json-sub-group.action")) {
const basePath = linkPath.substring(0, linkPath.lastIndexOf("/") + 1);
// Nissan format: { subgroups: [...] }
// Nissan format: { subgroups: [{caption, code, url}] }
// Opel format: may use same { subgroups } or { vCfgData }
let groups = this.parseNissanSubGroupJson(html, basePath);
if (groups.length === 0) {
// Try Opel vCfgData format
groups = this.parseOpelVCfgDataJson(html, basePath);
}
if (groups.length === 0) {
// Ford de format: { subgroups: [{caption, id, subheader, jsonUrl:null}] } →
// construct image-board.action leaves (bomId = mainGroupId+subheaderId+leafId).
groups = this.parseFordSubGroupsJson(html, linkPath, basePath);
}
if (groups.length === 0) {
// Debug: log the response
this.logger.log(
@@ -1802,59 +1807,6 @@ export class PL24FordLegacyService {
`Ford: ${groups.length} groups for ${serviceName}/${familyId} catCode=${catCode ?? "none"} arch=${arch}`,
);
// TEMP DEBUG: map the de Ford variant-tree → drill structure.
if (arch === "LEGACY_FORD" && groups.length > 0) {
this.logger.warn(
`FORD-MAP nodes: ${JSON.stringify(groups.slice(0, 14).map((g) => ({ n: g.nameTr?.slice(0, 28), lp: g.linkPath?.slice(0, 90) })))}`,
);
// Follow the full Ford de JSON chain: maingroup → subgroup → (parts?)
const eng = groups.find((x) => /Diesel|Sigma|Zetec|GTDI|Fox/i.test(x.nameTr || "")) || groups[0];
if (eng?.linkPath) {
const mg = await this.fetchP4Page(eng.linkPath, serviceName, false);
let mgj: any = null;
try {
mgj = JSON.parse(mg as string);
} catch {}
const mgs = mgj?.maingroups || [];
this.logger.warn(`FORD-MAP L2 maingroups=${mgs.length}: ${JSON.stringify(mgs.slice(0, 4).map((x: any) => ({ c: x.caption, u: (x.jsonUrl || "").slice(0, 60) })))}`);
if (mgs[0]?.jsonUrl) {
const sg = await this.fetchP4Page(mgs[0].jsonUrl, serviceName, false);
let sgj: any = null;
try {
sgj = JSON.parse(sg as string);
} catch {}
const subs = sgj?.subgroups || [];
const realLeaf = subs.find((x: any) => x.subheader !== true && x.id) || subs[0];
this.logger.warn(`FORD-MAP L3 subs=${subs.length} realLeaf id=${realLeaf?.id} cap="${realLeaf?.caption}" subheader=${realLeaf?.subheader}`);
// probe candidate parts endpoints for the leaf, derived from the json-sub-group URL
if (realLeaf?.id) {
const base = mgs[0].jsonUrl as string;
const sel = await this.fetchP4Page(`${base}&subGroupId=${realLeaf.id}`, serviceName, false);
let selj: any = null;
try {
selj = JSON.parse(sel as string);
} catch {}
if (selj) {
const keys = Object.keys(selj);
const arrays = keys.filter((k) => Array.isArray(selj[k])).map((k) => `${k}[${selj[k].length}]`);
this.logger.warn(`FORD-MAP sel keys=${keys.join(",")} arrays=${arrays.join(",")}`);
// dump first item of any non-subgroups array (potential parts)
for (const k of keys) {
if (k !== "subgroups" && Array.isArray(selj[k]) && selj[k].length) {
this.logger.warn(`FORD-MAP sel.${k}[0]=${JSON.stringify(selj[k][0]).slice(0, 300)}`);
}
}
// does the SELECTED subgroup object now carry parts/url?
const selSub = (selj.subgroups || []).find((x: any) => x.id === realLeaf.id);
this.logger.warn(`FORD-MAP selSub keys=${selSub ? Object.keys(selSub).join(",") : "none"} url=${selSub?.url} jsonUrl=${selSub?.jsonUrl}`);
} else {
this.logger.warn(`FORD-MAP sel not-json len=${sel?.length} head=${sel?.slice(0, 120).replace(/\s+/g, " ")}`);
}
}
}
}
}
if (groups.length > 0) {
await this.redis.setJson(cacheKey, groups, 7200);
}
@@ -1955,7 +1907,7 @@ export class PL24FordLegacyService {
if (serviceName && ["fiatp_parts", "fiatt_parts"].includes(serviceName)) {
return "de";
}
if (serviceName && HYUNDAI_KIA_NISSAN_DE.has(serviceName)) {
if (serviceName && LEGACY_DE_SERVICES.has(serviceName)) {
return "de";
}
@@ -2694,6 +2646,67 @@ export class PL24FordLegacyService {
}
}
/**
* Parse Ford (de account) json-sub-group.action response into leaf illustrations.
*
* Ford de subgroups are a flat list of { caption, id, subheader, jsonUrl:null, url:null }.
* `subheader:true` rows are section labels (skipped); the leaves below them are the
* actual part illustrations but carry NO url. The illustration (BOM) URL is
* constructed: subGroupId = mainGroupId + subheaderId, bomId = subGroupId + leafId,
* leaf → image-board.action?bomId=… (same parts endpoint as Nissan/Hyundai). Verified
* on dev: the selected-subgroup response exposes exactly this image-board url.
*/
private parseFordSubGroupsJson(
jsonStr: string,
linkPath: string,
basePath: string,
): PL24MainGroup[] {
try {
const data = JSON.parse(jsonStr) as {
subgroups?: {
caption: string;
id: string;
subheader?: boolean;
gray?: boolean;
jsonUrl?: string | null;
url?: string | null;
}[];
};
const subs = data.subgroups ?? [];
const q = (k: string) => linkPath.match(new RegExp(`[?&]${k}=([^&]+)`))?.[1] ?? "";
const catCode = q("catCode");
const mainGroupId = q("mainGroupId");
const sharedCatCode = q("sharedCatCode");
const modelFamily = q("modelFamily");
const mode = q("mode");
const upds = q("upds");
if (!mainGroupId) return [];
const out: PL24MainGroup[] = [];
const seen = new Set<string>();
let currentSubheaderId = "";
for (const sg of subs) {
if (sg.gray) continue;
if (sg.subheader) {
currentSubheaderId = sg.id || currentSubheaderId;
continue;
}
if (!sg.id) continue;
const subGroupId = `${mainGroupId}${currentSubheaderId}`;
const bomId = `${subGroupId}${sg.id}`;
if (seen.has(bomId)) continue;
seen.add(bomId);
const leafLink =
`${basePath}image-board.action?bomId=${bomId}&catCode=${catCode}&lang=${this.language}` +
`&mainGroupId=${mainGroupId}&modelFamily=${modelFamily}&sharedCatCode=${sharedCatCode}` +
`&startup=false&subGroupId=${subGroupId}&mode=${mode}&upds=${upds}`;
out.push({ id: bomId, code: bomId, name: sg.caption, linkPath: leafLink });
}
return out;
} catch {
return [];
}
}
/**
* Parse Ford json-main-group.action response.
* Ford uses { maingroups: [{ caption, databaseKey, identifier, gray, subheader, jsonUrl, url }] } format.
@@ -3279,7 +3292,7 @@ export class PL24FordLegacyService {
// fetch for these brands to de (which carries the Korean/Japanese license),
// and ensure the de service token is authorized. Verified on dev: de yields
// 266 Hyundai / 169 Kia models, non-demo, with real drill content.
if (HYUNDAI_KIA_NISSAN_DE.has(serviceName)) {
if (LEGACY_DE_SERVICES.has(serviceName)) {
account = "de";
await this.authService.authorizeServiceForAccount(serviceName, "de");
}