@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
@@ -1,48 +1,75 @@
1
- import { Injectable } from '@nestjs/common';
2
- import { CarService, LoaderService, ConfigService, RulesService } from '../services';
3
- import { FsChunkInterface } from '@bytebrand/chunkable';
4
- import { Exporter } from '../interfaces/exporter.interface';
5
- import { CronicleUtils } from '@bytebrand/cronicle-helper';
6
- import { ModuleRef } from '@nestjs/core';
7
- import { Zipper } from '@bytebrand/zipper';
8
- import { OoyyoXmlBuilder } from '../formats/ooyyo-xml.builder';
9
- import { OoyyoTransformer } from '../transformers/ooyyo.transformer';
10
-
11
- @Injectable()
12
- export class OoyyoExporter implements Exporter {
13
- constructor(
14
- private carService: CarService,
15
- private configService: ConfigService,
16
- private loaderService: LoaderService,
17
- private moduleRef: ModuleRef,
18
- private rulesService: RulesService,
19
- ) {}
20
-
21
- async export(): Promise<void> {
22
- const [fileXml, fileGZ] = this.configService.config.outputFiles;
23
- await CronicleUtils.ensureDir(this.configService.config.pathesConfig.localConvertedDirectory);
24
- await CronicleUtils.emptyDir(this.configService.config.pathesConfig.localConvertedDirectory);
25
-
26
- const ooyyoXmlBuilder = new OoyyoXmlBuilder(fileXml);
27
- await ooyyoXmlBuilder.buildStart();
28
-
29
- const ooyyoTransformer = await this.moduleRef.create(OoyyoTransformer);
30
-
31
- await this.loaderService.process(async (chunk: FsChunkInterface) => {
32
- const chunkData = chunk.data.filter(car => this.carService.filterCar(car, this.rulesService.rules));
33
-
34
- for (let i = 0; i < chunkData.length; i += 1) {
35
- const { car, images } = chunkData[i];
36
- // console.time(`Transform ${car._id}`);
37
- await ooyyoXmlBuilder.addItem(await ooyyoTransformer.transform(car, images));
38
- // console.timeEnd(`Transform ${car._id}`);
39
- this.carService.counter.countCar(images);
40
- }
41
- });
42
-
43
- await ooyyoXmlBuilder.buildEnd();
44
- console.log(this.carService.counter.getCountedCars());
45
-
46
- fileGZ && await Zipper.tar({ dest: fileGZ, fileList: [fileXml] });
47
- }
48
- }
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ModuleRef } from '@nestjs/core';
3
+
4
+ import { FsChunkInterface } from '@bytebrand/chunkable';
5
+ import { LoggerService } from '@bytebrand/cronicle-helper';
6
+
7
+ import { CarService, RulesService, LoaderService, ConfigService, ImagesService } from '../services';
8
+ import { ImageUrlValidator } from '../services/image-url-validator.service';
9
+ import { FeedGuard } from '../services/feed-guard';
10
+ import { OoyyoFeedRun, OoyyoSourceCar } from '../services/ooyyo-feed-run';
11
+ import { OoyyoTransformer, toOoyyoImages } from '../transformers/ooyyo.transformer';
12
+ import { resolveOpenAiImageFields } from '../transformers/openai-image-fields';
13
+ import { OoyyoConfig } from '../configs/ooyyo.config';
14
+ import { Exporter } from '../interfaces/exporter.interface';
15
+
16
+ /**
17
+ * ExportType.OOYYO (ADV-331). Wires the Cronicle/Nest services into OoyyoFeedRun, which holds the
18
+ * logic. The output directory is no longer emptied: the new XML / XML.gz replace the old ones only
19
+ * after the snapshot, the written file and the counts passed.
20
+ */
21
+ @Injectable()
22
+ export class OoyyoExporter implements Exporter {
23
+ constructor(
24
+ private carService: CarService,
25
+ private configService: ConfigService,
26
+ private loaderService: LoaderService,
27
+ private rulesService: RulesService,
28
+ private imagesService: ImagesService,
29
+ private moduleRef: ModuleRef,
30
+ ) {}
31
+
32
+ async export(): Promise<void> {
33
+ const config = this.configService.config as OoyyoConfig;
34
+ const [xmlFile, gzFile] = config.outputFiles;
35
+ const validator = new ImageUrlValidator({ concurrency: config.imageConcurrency });
36
+ const transformer = await this.moduleRef.create(OoyyoTransformer);
37
+
38
+ const run = new OoyyoFeedRun(
39
+ {
40
+ forEachChunk: fn => this.loaderService.process((chunk: FsChunkInterface) => fn(chunk.data as OoyyoSourceCar[])),
41
+ filterCar: source => this.carService.filterCar(source, this.rulesService.rules),
42
+ resolveImages: async ({ car, images }) => toOoyyoImages(
43
+ await resolveOpenAiImageFields(this.imagesService.getCleanImages(car._id, images), validator),
44
+ config.maxImages,
45
+ ),
46
+ translate: selector => transformer.translate(selector),
47
+ region: (postcode, country) => transformer.region(postcode, country),
48
+ imageStats: () => validator.stats,
49
+ guard: new FeedGuard(config.feedGuard),
50
+ },
51
+ {
52
+ runId: config.runId,
53
+ dryRun: config.dryRun,
54
+ domain: config.domain,
55
+ sourceDirectory: config.pathesConfig.localDirectory,
56
+ sourceBaseFileName: config.pathesConfig.baseFileName,
57
+ xmlFile,
58
+ gzFile,
59
+ stateDir: config.stateDir,
60
+ },
61
+ );
62
+
63
+ try {
64
+ const report = await run.run();
65
+ if (report.stale) {
66
+ console.log(`ooyyo feed STALE: last good file ${report.lastGoodAgeHours?.toFixed(1)} h old`);
67
+ }
68
+ } catch (error) {
69
+ // main.ts would report a rejected run as {"complete":1,"code":0}; fail the job so the chained
70
+ // `6.1 … part 2` step does not run (report + lock already handled by the run).
71
+ LoggerService.exitError(`ooyyo feed not published: ${error?.message ?? error}`);
72
+ process.exit(1);
73
+ }
74
+ }
75
+ }
@@ -1,48 +1,87 @@
1
- import { XMLBuilderBase } from '@bytebrand/cronicle-helper';
2
- import { OoyyoXmlItem } from '../interfaces';
3
-
4
- export class OoyyoXmlBuilder extends XMLBuilderBase {
5
- async buildStart(): Promise<void> {
6
- await this.write(`<?xml version="1.0" encoding="UTF-8"?>
7
- <ooyyo xsi:noNamespaceSchemaLocation="OoyyoFormat.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">`);
8
- }
9
-
10
- async addItem(car: OoyyoXmlItem): Promise<void> {
11
- let item = '';
12
-
13
- item += '\n<ad>\n';
14
- item += this.getElement('url', car.url, true);
15
- item += this.getElement('title', car.title, true);
16
- item += this.getElement('content', car.content, true);
17
- item += this.getElement('price', car.price, true, { unit: 'euro' });
18
- item += this.getElement('make', car.make, true);
19
- item += this.getElement('model', car.model, true);
20
- item += this.getElement('trim', car.trim, true);
21
- item += this.getElement('engine_size', car.engine_size, true, { unit: 'cc' });
22
- item += this.getElement('body_type', car.body_type, true);
23
- item += this.getElement('color', car.color, true);
24
- item += this.getElement('year', car.year, true);
25
- item += this.getElement('mileage', car.mileage, true, { unit: 'km' });
26
- item += this.getElement('fuel_type', car.fuel_type, true);
27
- item += this.getElement('transmission', car.transmission, true);
28
- item += this.getElement('euronorm', car.euronorm, true);
29
- item += this.getElement('power', car.power, true, { unit: 'kw' });
30
- item += this.getElement('doors', car.doors, true);
31
- item += this.getElement('seats', car.seats, true);
32
- item += this.getElement('gears', car.gears, true);
33
- item += this.getElementsTree('location', car.location, true);
34
- item += this.getElementsList('pictures', 'picture', car.pictures, true);
35
- item += this.getElement('vin', car.vin, true);
36
- item += this.getElement('is_new', car.is_new, true);
37
- item += this.getElement('date', car.date, true);
38
- item += this.getElement('time', car.time, true);
39
- item += '</ad>';
40
-
41
- await this.write(item);
42
- }
43
-
44
- async buildEnd(): Promise<void> {
45
- await this.write('\n</ooyyo>');
46
- await this.end();
47
- }
48
- }
1
+ import { createWriteStream, WriteStream } from 'fs';
2
+ import { OoyyoXmlItem } from '../interfaces';
3
+
4
+ // Characters XML 1.0 does not allow at all (a single one makes the whole file unreadable).
5
+ // eslint-disable-next-line no-control-regex
6
+ const INVALID_XML_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F￾￿]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
7
+
8
+ export const stripInvalidXmlChars = (value: string): string => value.replace(INVALID_XML_CHARS, '');
9
+
10
+ // CDATA cannot contain "]]>": split it across two sections. Everything else goes in verbatim
11
+ // (UTF-8 umlauts, "&" in URLs), exactly as the partner sample wraps every value in CDATA.
12
+ export const cdata = (value: string): string =>
13
+ `<![CDATA[${stripInvalidXmlChars(value).replace(/]]>/g, ']]]]><![CDATA[>')}]]>`;
14
+
15
+ /**
16
+ * Writes the OOYYO XML (ADV-331): UTF-8, root/namespace exactly as OoyyoFormat.xml, element order as
17
+ * in the sample, every value in CDATA. Empty optional elements are left out — the legacy builder
18
+ * wrote `<engine_size/>` / `<power/>` without the unit attribute the XSD requires, so such a file was
19
+ * not schema-valid. `price` always carries unit="EUR".
20
+ */
21
+ export class OoyyoXmlBuilder {
22
+ private stream: WriteStream;
23
+
24
+ constructor(fileName: string) {
25
+ this.stream = createWriteStream(fileName, { flags: 'w', encoding: 'utf8' });
26
+ }
27
+
28
+ private write(chunk: string): Promise<void> {
29
+ return new Promise((resolve, reject) => this.stream.write(chunk, error => (error ? reject(error) : resolve())));
30
+ }
31
+
32
+ async buildStart(): Promise<void> {
33
+ await this.write('<?xml version="1.0" encoding="UTF-8"?>\n'
34
+ + '<ooyyo xsi:noNamespaceSchemaLocation="OoyyoFormat.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">\n');
35
+ }
36
+
37
+ async addItem(car: OoyyoXmlItem): Promise<void> {
38
+ await this.write(OoyyoXmlBuilder.toXml(car));
39
+ }
40
+
41
+ static toXml(car: OoyyoXmlItem): string {
42
+ const el = (tag: string, value: string, unit?: string, indent = ' '): string =>
43
+ (value ? `${indent}<${tag}${unit ? ` unit="${unit}"` : ''}>${cdata(value)}</${tag}>\n` : '');
44
+ const location = [
45
+ el('city', car.location?.city, undefined, ' '),
46
+ el('postcode', car.location?.postcode, undefined, ' '),
47
+ el('region', car.location?.region, undefined, ' '),
48
+ ].join('');
49
+ const pictures = (car.pictures ?? []).map(url => el('picture', url, undefined, ' ')).join('');
50
+
51
+ return ' <ad>\n'
52
+ + el('url', car.url)
53
+ + el('title', car.title)
54
+ + el('content', car.content)
55
+ + el('price', car.price, car.priceUnit)
56
+ + el('make', car.make)
57
+ + el('model', car.model)
58
+ + el('engine_size', car.engine_size, 'cc')
59
+ + el('trim', car.trim)
60
+ + el('body_type', car.body_type)
61
+ + el('color', car.color)
62
+ + el('year', car.year)
63
+ + el('mileage', car.mileage, 'km')
64
+ + el('fuel_type', car.fuel_type)
65
+ + el('transmission', car.transmission)
66
+ + el('euronorm', car.euronorm)
67
+ + el('power', car.power, 'kw')
68
+ + el('doors', car.doors)
69
+ + el('seats', car.seats)
70
+ + el('gears', car.gears)
71
+ + (location ? ` <location>\n${location} </location>\n` : '')
72
+ + (pictures ? ` <pictures>\n${pictures} </pictures>\n` : '')
73
+ + el('vin', car.vin)
74
+ + el('is_new', car.is_new)
75
+ + el('date', car.date)
76
+ + el('time', car.time)
77
+ + ' </ad>\n';
78
+ }
79
+
80
+ async buildEnd(): Promise<void> {
81
+ await this.write('</ooyyo>\n');
82
+ await new Promise<void>((resolve, reject) => {
83
+ this.stream.on('error', reject);
84
+ this.stream.end(() => resolve());
85
+ });
86
+ }
87
+ }
@@ -62,9 +62,50 @@ export interface Auto24XmlItem {
62
62
  dealer_phone: string;
63
63
  }
64
64
 
65
- export interface OoyyoXmlItem extends FbXmlItem {
66
- euronorm: string;
65
+ // One OOYYO <ad> (ADV-331). '' = element left out (every element is optional in OoyyoFormat.xsd;
66
+ // the importer's mandatory fields are enforced by validateOoyyoItem).
67
+ export interface OoyyoXmlItem {
68
+ url: string;
69
+ title: string;
70
+ content: string;
71
+ // whole euros
72
+ price: string;
73
+ // always 'EUR' (partner, 19.10.2026)
74
+ priceUnit: 'EUR';
75
+ make: string;
76
+ model: string;
77
+ // ccm, unit="cc"
78
+ engine_size: string;
79
+ trim: string;
80
+ body_type: string;
81
+ color: string;
82
+ year: string;
83
+ // unit="km"
67
84
  mileage: string;
85
+ fuel_type: string;
86
+ transmission: string;
87
+ euronorm: string;
88
+ // unit="kw"
89
+ power: string;
90
+ doors: string;
91
+ seats: string;
92
+ gears: string;
93
+ location: {
94
+ city: string;
95
+ // string, leading zero kept
96
+ postcode: string;
97
+ // Bundesland
98
+ region: string;
99
+ };
100
+ // validated public HTTPS photo URLs, hero first
101
+ pictures: string[];
102
+ vin: string;
103
+ // '1' new, '0' used
104
+ is_new: string;
105
+ // DD/MM/YYYY, Europe/Berlin
106
+ date: string;
107
+ // HH:MM, Europe/Berlin
108
+ time: string;
68
109
  }
69
110
 
70
111
  // Keys and order as the meinestadt exporter emits them (see docs/meinestadt/AUDIT.md §3).
@@ -53,6 +53,11 @@ export interface Params {
53
53
  marktdeDryRun?: string;
54
54
  marktdeImageConcurrency?: string;
55
55
  marktdeMaxImages?: string;
56
+ // OOYYO feed only (ADV-331), Cronicle text params; see configs/ooyyo.config.ts.
57
+ // Live output needs ooyyoDryRun=false explicitly.
58
+ ooyyoDryRun?: string;
59
+ ooyyoImageConcurrency?: string;
60
+ ooyyoMaxImages?: string;
56
61
  }
57
62
 
58
63
  // Parsed from feedItemsBq* by GoogleConfig.
@@ -1,11 +1,22 @@
1
1
  import { Injectable } from '@nestjs/common';
2
2
  import axios from 'axios';
3
+ import { CronicleUtils } from '@bytebrand/cronicle-helper';
3
4
  import { MappingRules } from '../interfaces/mapping-rules.interface';
4
5
  import { GoogleSpreadsheet, GoogleSpreadsheetRow } from 'google-spreadsheet';
5
6
  import { ConfigService } from './config.service';
6
7
  import { DealersMapping, MetaPushPriorityMapping } from '../interfaces/dealers-mapping.interface';
7
8
  import { parseCsvToObjects } from '../utils';
8
9
 
10
+ // Public CSV of one tab of a Google sheet, addressed by the tab title — no API key. The sheet must stay
11
+ // readable by everyone with the link. (Checked 13.11.2026: gviz CSV = the raw export of the tabs
12
+ // mobile_yescar and mobile, cell for cell; gviz only adds trailing empty columns.)
13
+ export const mappingSheetCsvUrl = (spreadsheetId: string, title: string): string =>
14
+ `https://docs.google.com/spreadsheets/d/${spreadsheetId}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(title)}`;
15
+
16
+ // Columns every mapping tab read by getRules() has; missing ones mean the response is not the tab
17
+ // (e.g. Google's sign-in page once the sheet is no longer shared).
18
+ const REQUIRED_MAPPING_COLUMNS = ['importField', 'importFieldType', 'autodeField', 'autodeFieldType'];
19
+
9
20
  @Injectable()
10
21
  export class MappingService {
11
22
  constructor(private configService: ConfigService) {}
@@ -13,24 +24,54 @@ export class MappingService {
13
24
  private dealers: any;
14
25
  private metaPushPriorities: MetaPushPriorityMapping[];
15
26
 
27
+ /**
28
+ * Mapping rules of the export type's tab (mappingSpreadsheetTitle) of the mapping sheet. Read from the
29
+ * sheet's public CSV instead of the Google Sheets API: the API needed the event's `apikey`, and an
30
+ * expired key (DEV 9c.0, found 12.11.2026) silently broke every feed on the sheet. Fails the run
31
+ * when the tab cannot be read or yields no rule — never an export without a mapping.
32
+ */
16
33
  async getRules(): Promise<MappingRules[]> {
17
34
  if (!this.rules) {
18
- const spreadsheet = new GoogleSpreadsheet(this.configService.config.mappingSpreadsheetId);
19
- spreadsheet.useApiKey(this.configService.config.apikey);
20
- await spreadsheet.loadInfo();
21
- const rows = await spreadsheet.sheetsByTitle[this.configService.config.mappingSpreadsheetTitle].getRows();
22
- const mappingRules = this.rowsToRules(rows) as MappingRules[];
35
+ const { mappingSpreadsheetId, mappingSpreadsheetTitle } = this.configService.config;
36
+ const url = mappingSheetCsvUrl(mappingSpreadsheetId, mappingSpreadsheetTitle);
37
+ const { data } = await CronicleUtils.retry(
38
+ () => axios.get<string>(url, { responseType: 'text', timeout: 30_000 }),
39
+ 3,
40
+ 5_000,
41
+ true,
42
+ );
43
+ this.rules = this.rulesFromCsv(data, mappingSpreadsheetTitle);
44
+ console.log(`mapping: ${this.rules.length} rules from the public CSV of tab ${mappingSpreadsheetTitle}`);
45
+ }
46
+ return this.rules;
47
+ }
23
48
 
24
- mappingRules.map((rule, _i, selfRules) => {
25
- this.removeEmptyKeys(rule);
26
- if (rule.importFieldType && rule.importFieldType.includes('selector')) {
27
- this.groupSelectors(rule, selfRules);
28
- }
29
- });
49
+ // CSV text of a mapping tab -> rules, with the same post-processing the Sheets API rows had.
50
+ rulesFromCsv(csv: string, title: string): MappingRules[] {
51
+ const rows = parseCsvToObjects<Record<string, string>>(typeof csv === 'string' ? csv : '');
52
+ const columns = rows.length ? Object.keys(rows[0]) : [];
53
+ const missing = REQUIRED_MAPPING_COLUMNS.filter(column => !columns.includes(column));
54
+ if (missing.length) {
55
+ throw new Error(`mapping sheet tab "${title}" not readable as CSV (missing columns ${missing.join(', ')}) — is the sheet still shared with everyone who has the link?`);
56
+ }
57
+ // unnamed columns (notes next to the table) are not part of a rule
58
+ const mappingRules = rows.map((row) => {
59
+ delete row[''];
60
+ return row;
61
+ }) as unknown as MappingRules[];
62
+
63
+ mappingRules.forEach((rule, _i, selfRules) => {
64
+ this.removeEmptyKeys(rule);
65
+ if (rule.importFieldType && rule.importFieldType.includes('selector')) {
66
+ this.groupSelectors(rule, selfRules);
67
+ }
68
+ });
30
69
 
31
- this.rules = this.filterResultedRows(mappingRules);
70
+ const rules = this.filterResultedRows(mappingRules);
71
+ if (!rules.length) {
72
+ throw new Error(`mapping sheet tab "${title}" has no rule`);
32
73
  }
33
- return this.rules;
74
+ return rules;
34
75
  }
35
76
 
36
77
  async getDealersMapping(): Promise<DealersMapping[]> {