@bytebrand/export-cars 1.26.46 → 1.26.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/configs/ooyyo.config.d.ts +14 -0
  2. package/dist/configs/ooyyo.config.js +40 -6
  3. package/dist/configs/ooyyo.config.js.map +1 -1
  4. package/dist/exporters/ooyyo.exporter.d.ts +5 -4
  5. package/dist/exporters/ooyyo.exporter.js +45 -25
  6. package/dist/exporters/ooyyo.exporter.js.map +1 -1
  7. package/dist/formats/ooyyo-xml.builder.d.ts +7 -2
  8. package/dist/formats/ooyyo-xml.builder.js +59 -36
  9. package/dist/formats/ooyyo-xml.builder.js.map +1 -1
  10. package/dist/interfaces/export-item.interface.d.ts +30 -2
  11. package/dist/interfaces/params.interface.d.ts +3 -0
  12. package/dist/services/mapping.service.d.ts +2 -0
  13. package/dist/services/mapping.service.js +33 -13
  14. package/dist/services/mapping.service.js.map +1 -1
  15. package/dist/services/ooyyo-feed-run.d.ts +81 -0
  16. package/dist/services/ooyyo-feed-run.js +183 -0
  17. package/dist/services/ooyyo-feed-run.js.map +1 -0
  18. package/dist/services/ooyyo-feed-verify.d.ts +13 -0
  19. package/dist/services/ooyyo-feed-verify.js +104 -0
  20. package/dist/services/ooyyo-feed-verify.js.map +1 -0
  21. package/dist/transformers/ooyyo.transformer.d.ts +51 -11
  22. package/dist/transformers/ooyyo.transformer.js +268 -95
  23. package/dist/transformers/ooyyo.transformer.js.map +1 -1
  24. package/dist/tsconfig.build.tsbuildinfo +1 -1
  25. package/docs/marktde/AUDIT.md +1 -1
  26. package/docs/marktde/RUNBOOK.md +3 -3
  27. package/docs/marktde/TRACKING.md +8 -2
  28. package/docs/meinestadt/PARTNER-QUESTIONS.md +17 -0
  29. package/docs/meinestadt/TRACKING.md +107 -0
  30. package/docs/ooyyo/AUDIT.md +81 -0
  31. package/docs/ooyyo/PARTNER-QUESTIONS.md +32 -0
  32. package/docs/ooyyo/RUNBOOK.md +67 -0
  33. package/docs/ooyyo/TRACKING.md +54 -0
  34. package/package.json +3 -2
  35. package/src/configs/ooyyo.config.ts +65 -15
  36. package/src/constants/fb.constants.ts +34 -34
  37. package/src/exporters/ooyyo.exporter.ts +75 -48
  38. package/src/formats/ooyyo-xml.builder.ts +87 -48
  39. package/src/interfaces/export-item.interface.ts +43 -2
  40. package/src/interfaces/params.interface.ts +5 -0
  41. package/src/services/mapping.service.ts +54 -13
  42. package/src/services/ooyyo-feed-run.ts +265 -0
  43. package/src/services/ooyyo-feed-verify.ts +131 -0
  44. package/src/transformers/ooyyo.transformer.ts +378 -109
  45. package/test/fixtures/ooyyo/OoyyoFormat.xml +125 -0
  46. package/test/fixtures/ooyyo/OoyyoFormat.xsd +116 -0
  47. package/test/fixtures/ooyyo.fixture.ts +53 -0
  48. package/test/mapping-csv.test.ts +65 -0
  49. package/test/ooyyo-feed-run.test.ts +293 -0
  50. package/test/ooyyo-mapping.test.ts +276 -0
@@ -0,0 +1,265 @@
1
+ import { createReadStream, createWriteStream, promises as fs } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { pipeline } from 'stream/promises';
4
+ import { createGzip, gunzipSync } from 'zlib';
5
+ import { createHash } from 'crypto';
6
+ import { ImboImageResponseInterface } from '@bytebrand/chunkable';
7
+ import { DataSchemes } from '@bytebrand/car-schema';
8
+
9
+ import { OoyyoXmlItem } from '../interfaces';
10
+ import { OoyyoXmlBuilder } from '../formats/ooyyo-xml.builder';
11
+ import { OoyyoExclusion, getOoyyoExclusion, getOoyyoPostcode, mapOoyyoItem } from '../transformers/ooyyo.transformer';
12
+ import { FeedGuard, FeedGuardError, LastGood, inspectSourceSnapshot } from './feed-guard';
13
+ import { verifyOoyyoXml } from './ooyyo-feed-verify';
14
+
15
+ export interface OoyyoSourceCar {
16
+ car: DataSchemes.Car;
17
+ images: ImboImageResponseInterface[];
18
+ }
19
+
20
+ // Everything the run needs from the outside world — the Nest exporter wires the real services,
21
+ // tests pass fakes.
22
+ export interface OoyyoRunDeps {
23
+ // iterates the base export snapshot chunk by chunk (LoaderService.process)
24
+ forEachChunk: (fn: (cars: OoyyoSourceCar[]) => Promise<void>) => Promise<unknown>;
25
+ // existing shared partner filter (images count, Cronicle rules)
26
+ filterCar: (source: OoyyoSourceCar) => boolean;
27
+ // final validated image URLs for the car, hero first; [] = no usable image
28
+ resolveImages: (source: OoyyoSourceCar) => Promise<string[]>;
29
+ // German label of a selector, '' when unknown
30
+ translate: (selector: string) => string;
31
+ // Bundesland of a German postcode, '' when unknown
32
+ region: (postcode: string, countryCode: unknown) => Promise<string>;
33
+ imageStats: () => Record<string, number>;
34
+ guard: FeedGuard;
35
+ }
36
+
37
+ export interface OoyyoRunOptions {
38
+ runId?: string;
39
+ dryRun: boolean;
40
+ domain: string;
41
+ sourceDirectory: string;
42
+ sourceBaseFileName: string;
43
+ xmlFile: string;
44
+ gzFile: string;
45
+ stateDir: string;
46
+ now?: () => Date;
47
+ }
48
+
49
+ export interface OoyyoRunReport {
50
+ runId?: string;
51
+ mode: 'dry-run' | 'live';
52
+ startedAt: string;
53
+ finishedAt?: string;
54
+ status: 'published' | 'blocked' | 'failed';
55
+ guard?: { code: string; message: string };
56
+ error?: string;
57
+ xmlFile: string;
58
+ gzFile: string;
59
+ source: { files: number; newestChunkAt: string | null; cars: number };
60
+ items: number;
61
+ excluded: Record<string, number>;
62
+ // optional fields left out because the source had no (valid) value
63
+ missing: Record<string, number>;
64
+ postcodePadded: number;
65
+ verify?: { ads: number; issues: number; firstIssues: Array<{ ad: number; problem: string }> };
66
+ bytes?: { xml: number; gz: number; sha256: string };
67
+ imagesPerItem: { min: number | null; max: number | null; total: number };
68
+ images: Record<string, number>;
69
+ lastGood: LastGood | null;
70
+ lastGoodAgeHours: number | null;
71
+ stale: boolean;
72
+ }
73
+
74
+ const increment = (counter: Record<string, number>, key: string) => {
75
+ counter[key] = (counter[key] ?? 0) + 1;
76
+ };
77
+
78
+ const OPTIONAL_FIELDS = ['content', 'engine_size', 'body_type', 'color', 'mileage', 'fuel_type', 'transmission', 'euronorm',
79
+ 'power', 'doors', 'seats', 'gears', 'vin', 'is_new', 'date'] as const;
80
+
81
+ const exists = (file: string) => fs.stat(file).then(() => true, () => false);
82
+
83
+ /**
84
+ * One OOYYO feed run (ADV-331): source check -> map + semantic partner rules + validated images ->
85
+ * temp XML -> re-read + verify the whole temp file -> count check -> gzip + verify -> atomic publish
86
+ * of .xml.gz and .xml -> report.
87
+ *
88
+ * OOYYO pulls the file once a day and REPLACES its whole inventory with whatever it can read; a
89
+ * partly valid file shrinks the live listing to that part (partner, 19.10.2026). So nothing is
90
+ * published unless the snapshot is complete and fresh (FeedGuard.checkSource), every written ad
91
+ * passes the partner rules when read back (verifyOoyyoXml), and the count did not collapse against the
92
+ * last good run (FeedGuard.checkCounts). A blocked run leaves the last good files in place (OOYYO keeps
93
+ * reading them) and fails the Cronicle job. Nothing outside the output directory and `stateDir` is
94
+ * written; the legacy flow emptied the output directory first and tarred with the `zip` binary.
95
+ */
96
+ export class OoyyoFeedRun {
97
+ constructor(private readonly deps: OoyyoRunDeps, private readonly options: OoyyoRunOptions) { }
98
+
99
+ private now(): Date {
100
+ return this.options.now ? this.options.now() : new Date();
101
+ }
102
+
103
+ async run(): Promise<OoyyoRunReport> {
104
+ const { guard } = this.deps;
105
+ const { xmlFile, gzFile, runId, dryRun } = this.options;
106
+ const startedAt = this.now();
107
+ const lastGood = await guard.readLastGood();
108
+ const report: OoyyoRunReport = {
109
+ runId,
110
+ mode: dryRun ? 'dry-run' : 'live',
111
+ startedAt: startedAt.toISOString(),
112
+ status: 'failed',
113
+ xmlFile,
114
+ gzFile,
115
+ source: { files: 0, newestChunkAt: null, cars: 0 },
116
+ items: 0,
117
+ excluded: {},
118
+ missing: {},
119
+ postcodePadded: 0,
120
+ imagesPerItem: { min: null, max: null, total: 0 },
121
+ images: {},
122
+ lastGood,
123
+ lastGoodAgeHours: guard.staleHours(lastGood, startedAt),
124
+ stale: guard.isStale(lastGood, startedAt),
125
+ };
126
+
127
+ await guard.acquireLock(runId ?? 'local', startedAt);
128
+ const tmpXml = `${xmlFile}.tmp`;
129
+ const tmpGz = gzFile.replace(/\.gz$/, '.tmp.gz');
130
+ try {
131
+ const snapshot = await inspectSourceSnapshot(this.options.sourceDirectory, this.options.sourceBaseFileName);
132
+ report.source.files = snapshot.files;
133
+ report.source.newestChunkAt = snapshot.newestMtime?.toISOString() ?? null;
134
+ guard.checkSource(snapshot, startedAt);
135
+
136
+ await fs.mkdir(dirname(xmlFile), { recursive: true });
137
+ await fs.rm(tmpXml, { force: true });
138
+ await fs.rm(tmpGz, { force: true });
139
+ const builder = new OoyyoXmlBuilder(tmpXml);
140
+ await builder.buildStart();
141
+
142
+ const seen = new Set<string>();
143
+ try {
144
+ await this.deps.forEachChunk(async (cars) => {
145
+ for (const source of cars) {
146
+ report.source.cars += 1;
147
+ const id = String(source?.car?._id ?? '');
148
+ if (id && seen.has(id)) {
149
+ increment(report.excluded, OoyyoExclusion.duplicateId);
150
+ continue;
151
+ }
152
+ seen.add(id);
153
+ if (!this.deps.filterCar(source)) {
154
+ increment(report.excluded, OoyyoExclusion.filtered);
155
+ continue;
156
+ }
157
+ // cheap checks first: no image probe for a car that is excluded anyway
158
+ const excluded = getOoyyoExclusion(source.car, startedAt);
159
+ if (excluded) {
160
+ increment(report.excluded, excluded);
161
+ continue;
162
+ }
163
+ const images = await this.deps.resolveImages(source);
164
+ if (!images.length) {
165
+ increment(report.excluded, OoyyoExclusion.noImage);
166
+ continue;
167
+ }
168
+ const country = source.car?.location?.countryCode;
169
+ const postcode = getOoyyoPostcode(source.car?.location?.zipCode, country);
170
+ const mapped = mapOoyyoItem(source.car, {
171
+ domain: this.options.domain,
172
+ images,
173
+ translate: this.deps.translate,
174
+ region: await this.deps.region(postcode, country),
175
+ now: startedAt,
176
+ });
177
+ if (mapped.ok === false) {
178
+ increment(report.excluded, mapped.reason);
179
+ continue;
180
+ }
181
+ await builder.addItem(mapped.item);
182
+ this.count(report, mapped.item, String(source.car?.location?.zipCode ?? ''));
183
+ }
184
+ });
185
+ } finally {
186
+ await builder.buildEnd();
187
+ }
188
+ report.images = this.deps.imageStats();
189
+
190
+ // read the file back exactly as it will be published
191
+ const verify = verifyOoyyoXml(await fs.readFile(tmpXml, 'utf8'), report.items);
192
+ report.verify = { ads: verify.ads, issues: verify.issues.length, firstIssues: verify.issues.slice(0, 20) };
193
+ if (verify.issues.length) {
194
+ throw new FeedGuardError('verify_failed', `${verify.issues.length} problem(s) in the written XML, first: ${verify.issues[0].problem}`);
195
+ }
196
+
197
+ const counts = { sourceItems: report.source.cars, publishedItems: report.items };
198
+ guard.checkCounts(counts, lastGood);
199
+
200
+ const xmlBytes = await fs.readFile(tmpXml);
201
+ await pipeline(createReadStream(tmpXml), createGzip({ level: 9 }), createWriteStream(tmpGz));
202
+ const gzBytes = await fs.readFile(tmpGz);
203
+ if (!gunzipSync(gzBytes).equals(xmlBytes)) {
204
+ throw new Error('gzip round trip does not match the XML');
205
+ }
206
+ report.bytes = { xml: xmlBytes.length, gz: gzBytes.length, sha256: createHash('sha256').update(xmlBytes).digest('hex') };
207
+
208
+ // .gz first, then the .xml through the guard (which records last-good): both renames are atomic
209
+ // and milliseconds apart; whichever URL OOYYO reads always holds a complete, verified snapshot.
210
+ if (await exists(gzFile)) {
211
+ await fs.copyFile(gzFile, `${gzFile}.prev`);
212
+ }
213
+ await fs.rename(tmpGz, gzFile);
214
+ report.lastGood = await guard.publish(tmpXml, xmlFile, counts, this.now(), runId);
215
+ report.status = 'published';
216
+ report.lastGoodAgeHours = 0;
217
+ report.stale = false;
218
+ return report;
219
+ } catch (error) {
220
+ if (error instanceof FeedGuardError) {
221
+ report.status = 'blocked';
222
+ report.guard = { code: error.code, message: error.message };
223
+ } else {
224
+ report.error = error?.message ?? String(error);
225
+ }
226
+ // keep the rejected file for inspection; the published files stay untouched
227
+ await fs.rename(tmpXml, `${xmlFile}.rejected`).catch(() => undefined);
228
+ await fs.rm(tmpGz, { force: true }).catch(() => undefined);
229
+ throw error;
230
+ } finally {
231
+ await this.writeReport(report);
232
+ await guard.releaseLock();
233
+ }
234
+ }
235
+
236
+ private count(report: OoyyoRunReport, item: OoyyoXmlItem, rawZip: string): void {
237
+ report.items += 1;
238
+ for (const field of OPTIONAL_FIELDS) {
239
+ if (!item[field]) {
240
+ increment(report.missing, field);
241
+ }
242
+ }
243
+ if (!item.location.postcode) {
244
+ increment(report.missing, 'postcode');
245
+ }
246
+ if (!item.location.region) {
247
+ increment(report.missing, 'region');
248
+ }
249
+ if (item.location.postcode && item.location.postcode !== rawZip) {
250
+ report.postcodePadded += 1;
251
+ }
252
+ const images = item.pictures.length;
253
+ const stats = report.imagesPerItem;
254
+ stats.min = stats.min === null ? images : Math.min(stats.min, images);
255
+ stats.max = stats.max === null ? images : Math.max(stats.max, images);
256
+ stats.total += images;
257
+ }
258
+
259
+ private async writeReport(report: OoyyoRunReport): Promise<void> {
260
+ report.finishedAt = this.now().toISOString();
261
+ await fs.mkdir(this.options.stateDir, { recursive: true });
262
+ await fs.writeFile(join(this.options.stateDir, 'last-run-report.json'), JSON.stringify(report, null, 2), 'utf8');
263
+ console.log(`ooyyo feed report: ${JSON.stringify(report)}`);
264
+ }
265
+ }
@@ -0,0 +1,131 @@
1
+ import { XMLParser, XMLValidator } from 'fast-xml-parser';
2
+
3
+ import { OoyyoXmlItem } from '../interfaces';
4
+ import { OOYYO_PRICE_UNIT, validateOoyyoItem } from '../transformers/ooyyo.transformer';
5
+
6
+ /**
7
+ * Element names and unit attributes of the official OoyyoFormat.xsd (test/fixtures/ooyyo; the test
8
+ * suite re-derives both lists from the XSD file, so a schema change fails the build).
9
+ */
10
+ export const OOYYO_AD_ELEMENTS = [
11
+ 'url', 'title', 'content', 'price', 'make', 'model', 'engine_size', 'trim', 'body_type', 'color', 'year',
12
+ 'mileage', 'fuel_type', 'transmission', 'euronorm', 'power', 'doors', 'seats', 'gears', 'location',
13
+ 'pictures', 'vin', 'is_new', 'date', 'time',
14
+ ];
15
+ export const OOYYO_LOCATION_ELEMENTS = ['city', 'postcode', 'region'];
16
+ // elements whose `unit` attribute is use="required"
17
+ export const OOYYO_UNIT_ELEMENTS = ['price', 'engine_size', 'mileage', 'power'];
18
+ // unit values from the partner sample / partner mail (price: EUR only, 19.10.2026)
19
+ export const OOYYO_UNITS: Record<string, string[]> = {
20
+ price: [OOYYO_PRICE_UNIT],
21
+ engine_size: ['cc', 'l'],
22
+ mileage: ['km', 'mi'],
23
+ power: ['kw', 'hp'],
24
+ };
25
+
26
+ export interface OoyyoVerifyIssue {
27
+ ad: number;
28
+ problem: string;
29
+ }
30
+
31
+ export interface OoyyoVerifyResult {
32
+ ads: number;
33
+ issues: OoyyoVerifyIssue[];
34
+ }
35
+
36
+ const TEXT = '#text';
37
+
38
+ const valueOf = (node: unknown): string => {
39
+ if (node === null || node === undefined) {
40
+ return '';
41
+ }
42
+ if (typeof node === 'object') {
43
+ return String((node as Record<string, unknown>)[TEXT] ?? '');
44
+ }
45
+ return String(node);
46
+ };
47
+
48
+ const asList = (node: unknown): unknown[] => (Array.isArray(node) ? node : node === undefined ? [] : [node]);
49
+
50
+ /**
51
+ * Re-reads the finished XML (before it is published) the way an importer would and checks the whole
52
+ * snapshot: well-formed, root <ooyyo>, only XSD elements, required unit attributes with a known value,
53
+ * every <ad> passes the partner's semantic rules, no duplicate vehicle URL, and exactly `expectedAds`
54
+ * ads. A file that fails any of this is never published — OOYYO replaces its whole inventory with the
55
+ * readable subset of a partly valid file (partner, 19.10.2026).
56
+ */
57
+ export const verifyOoyyoXml = (xml: string, expectedAds: number): OoyyoVerifyResult => {
58
+ const issues: OoyyoVerifyIssue[] = [];
59
+ const wellFormed = XMLValidator.validate(xml);
60
+ if (wellFormed !== true) {
61
+ return { ads: 0, issues: [{ ad: -1, problem: `not well-formed: ${wellFormed.err.msg} (line ${wellFormed.err.line})` }] };
62
+ }
63
+ const parser = new XMLParser({
64
+ ignoreAttributes: false,
65
+ attributeNamePrefix: '@',
66
+ parseTagValue: false,
67
+ parseAttributeValue: false,
68
+ trimValues: false,
69
+ isArray: name => name === 'ad' || name === 'picture',
70
+ });
71
+ const doc = parser.parse(xml);
72
+ const rootNames = Object.keys(doc).filter(k => k !== '?xml');
73
+ if (rootNames.length !== 1 || rootNames[0] !== 'ooyyo') {
74
+ return { ads: 0, issues: [{ ad: -1, problem: `root element is ${rootNames.join(',') || 'missing'}, expected ooyyo` }] };
75
+ }
76
+ const ads = asList(doc.ooyyo?.ad) as Array<Record<string, unknown>>;
77
+ const urls = new Set<string>();
78
+
79
+ ads.forEach((ad, index) => {
80
+ for (const key of Object.keys(ad)) {
81
+ if (key.startsWith('@') || key === TEXT) {
82
+ continue;
83
+ }
84
+ if (!OOYYO_AD_ELEMENTS.includes(key)) {
85
+ issues.push({ ad: index, problem: `element <${key}> is not in OoyyoFormat.xsd` });
86
+ }
87
+ if (Array.isArray(ad[key])) {
88
+ issues.push({ ad: index, problem: `element <${key}> appears more than once` });
89
+ }
90
+ }
91
+ for (const tag of OOYYO_UNIT_ELEMENTS) {
92
+ if (ad[tag] === undefined) {
93
+ continue;
94
+ }
95
+ const unit = typeof ad[tag] === 'object' ? String((ad[tag] as Record<string, unknown>)['@unit'] ?? '') : '';
96
+ if (!OOYYO_UNITS[tag].includes(unit)) {
97
+ issues.push({ ad: index, problem: `<${tag}> unit="${unit}" (allowed: ${OOYYO_UNITS[tag].join(', ')})` });
98
+ }
99
+ }
100
+ const location = (ad.location ?? {}) as Record<string, unknown>;
101
+ for (const key of Object.keys(location)) {
102
+ if (!key.startsWith('@') && key !== TEXT && !OOYYO_LOCATION_ELEMENTS.includes(key)) {
103
+ issues.push({ ad: index, problem: `element <location><${key}> is not in OoyyoFormat.xsd` });
104
+ }
105
+ }
106
+ const pictures = asList((ad.pictures as Record<string, unknown> | undefined)?.picture).map(valueOf);
107
+ const priceUnit = typeof ad.price === 'object' ? (ad.price as Record<string, unknown>)['@unit'] : undefined;
108
+ const item: Partial<OoyyoXmlItem> = {
109
+ url: valueOf(ad.url),
110
+ price: valueOf(ad.price),
111
+ priceUnit: priceUnit as 'EUR',
112
+ make: valueOf(ad.make),
113
+ model: valueOf(ad.model),
114
+ year: valueOf(ad.year),
115
+ pictures,
116
+ };
117
+ const invalid = validateOoyyoItem(item);
118
+ if (invalid) {
119
+ issues.push({ ad: index, problem: `rejected by partner rules: ${invalid}` });
120
+ }
121
+ if (item.url && urls.has(item.url)) {
122
+ issues.push({ ad: index, problem: 'duplicate vehicle url' });
123
+ }
124
+ urls.add(item.url);
125
+ });
126
+
127
+ if (ads.length !== expectedAds) {
128
+ issues.push({ ad: -1, problem: `file holds ${ads.length} ads, the run wrote ${expectedAds}` });
129
+ }
130
+ return { ads: ads.length, issues };
131
+ };