@bytebrand/export-cars 1.26.47 → 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 (52) 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/ooyyo-feed-run.d.ts +81 -0
  13. package/dist/services/ooyyo-feed-run.js +183 -0
  14. package/dist/services/ooyyo-feed-run.js.map +1 -0
  15. package/dist/services/ooyyo-feed-verify.d.ts +13 -0
  16. package/dist/services/ooyyo-feed-verify.js +104 -0
  17. package/dist/services/ooyyo-feed-verify.js.map +1 -0
  18. package/dist/transformers/ooyyo.transformer.d.ts +51 -11
  19. package/dist/transformers/ooyyo.transformer.js +268 -95
  20. package/dist/transformers/ooyyo.transformer.js.map +1 -1
  21. package/dist/tsconfig.build.tsbuildinfo +1 -1
  22. package/docs/marktde/AUDIT.md +84 -84
  23. package/docs/marktde/PARTNER-QUESTIONS.md +49 -49
  24. package/docs/marktde/RUNBOOK.md +58 -58
  25. package/docs/marktde/TRACKING.md +75 -69
  26. package/docs/meinestadt/PARTNER-QUESTIONS.md +17 -0
  27. package/docs/meinestadt/TRACKING.md +107 -0
  28. package/docs/ooyyo/AUDIT.md +81 -0
  29. package/docs/ooyyo/PARTNER-QUESTIONS.md +32 -0
  30. package/docs/ooyyo/RUNBOOK.md +67 -0
  31. package/docs/ooyyo/TRACKING.md +54 -0
  32. package/package.json +66 -65
  33. package/src/configs/marktde.config.ts +70 -70
  34. package/src/configs/ooyyo.config.ts +65 -15
  35. package/src/exporters/marktde.exporter.ts +78 -78
  36. package/src/exporters/ooyyo.exporter.ts +75 -48
  37. package/src/formats/ooyyo-xml.builder.ts +87 -48
  38. package/src/interfaces/export-item.interface.ts +43 -2
  39. package/src/interfaces/params.interface.ts +5 -0
  40. package/src/services/marktde-feed-run.ts +233 -233
  41. package/src/services/ooyyo-feed-run.ts +265 -0
  42. package/src/services/ooyyo-feed-verify.ts +131 -0
  43. package/src/transformers/marktde.transformer.ts +255 -255
  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 -65
  49. package/test/marktde-feed-run.test.ts +263 -263
  50. package/test/marktde-mapping.test.ts +280 -280
  51. package/test/ooyyo-feed-run.test.ts +293 -0
  52. package/test/ooyyo-mapping.test.ts +276 -0
@@ -1,15 +1,65 @@
1
- import { Params } from '../interfaces/params.interface';
2
- import { Config } from './config';
3
- import { ExportType, FileType, ImageType } from '../enums';
4
-
5
- export class OoyyoConfig extends Config {
6
- constructor(params: Params) {
7
- super(params);
8
- this.pathesConfig.localConvertedDirectory = `resources/${ExportType.OOYYO}-export`;
9
- this.imageType = ImageType.default;
10
- this.outputFiles = [
11
- `${this.pathesConfig.localConvertedDirectory}/api_export_cars_${ExportType.OOYYO}.${FileType.XML}`,
12
- `${this.pathesConfig.localConvertedDirectory}/autode-${ExportType.OOYYO}.${FileType.TAR_GZ}`,
13
- ];
14
- }
15
- }
1
+ import { toNumber } from 'lodash';
2
+
3
+ import { ExportType, ImageType } from '../enums';
4
+ import { Params } from '../interfaces/params.interface';
5
+ import { FeedGuardOptions } from '../services/feed-guard';
6
+ import { Config } from './config';
7
+
8
+ const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
9
+
10
+ const positiveNumber = (value: unknown, fallback: number): number => {
11
+ const parsed = toNumber(text(value));
12
+ return text(value) && Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
13
+ };
14
+
15
+ // Guard defaults (same as AutoUncle / meinestadt / markt.de). minPublishedItems only forbids an
16
+ // empty file; before a live run set it from a PROD dry-run (see docs/ooyyo/RUNBOOK.md).
17
+ export const OOYYO_DEFAULTS = {
18
+ imageConcurrency: 16,
19
+ minSourceItems: 1000,
20
+ minPublishedItems: 1,
21
+ maxDropPercent: 20,
22
+ maxSourceAgeHours: 26,
23
+ maxStaleHours: 48,
24
+ };
25
+
26
+ /**
27
+ * OOYYO vehicle XML (ADV-331): one full-inventory XML + the same file gzipped (`.xml.gz`), the two
28
+ * forms OOYYO accepts from a fixed HTTPS URL (partner, 16.10.2026; FTP not recommended).
29
+ * Dry-run is the default: the files go to resources/ooyyo-dryrun, which no Cronicle step reads and
30
+ * nothing serves. Only ooyyoDryRun=false writes resources/ooyyo-export. The legacy
31
+ * autode-ooyyo.tar.gz (FTP package) is no longer produced. Each mode has its own guard state.
32
+ */
33
+ export class OoyyoConfig extends Config {
34
+ dryRun: boolean;
35
+ imageConcurrency: number;
36
+ // 0 = no limit (OOYYO accepts all pictures; 20 is only a ranking recommendation)
37
+ maxImages: number;
38
+ stateDir: string;
39
+ feedGuard: FeedGuardOptions;
40
+
41
+ constructor(params: Params) {
42
+ super(params);
43
+ this.campaign = ExportType.OOYYO;
44
+ this.dryRun = text(params.ooyyoDryRun).toLowerCase() !== 'false';
45
+ const mode = this.dryRun ? 'dryrun' : 'export';
46
+ this.pathesConfig.localConvertedDirectory = `resources/${ExportType.OOYYO}-${mode}`;
47
+ this.stateDir = `resources/${ExportType.OOYYO}-state/${mode}`;
48
+ this.imageType = ImageType.clean;
49
+ this.imageConcurrency = positiveNumber(params.ooyyoImageConcurrency, OOYYO_DEFAULTS.imageConcurrency);
50
+ this.maxImages = Math.floor(positiveNumber(params.ooyyoMaxImages, 0));
51
+ this.feedGuard = {
52
+ stateDir: this.stateDir,
53
+ minSourceItems: positiveNumber(params.feedGuardMinSourceItems, OOYYO_DEFAULTS.minSourceItems),
54
+ minPublishedItems: positiveNumber(params.feedGuardMinPublishedItems, OOYYO_DEFAULTS.minPublishedItems),
55
+ maxDropPercent: positiveNumber(params.feedGuardMaxDropPercent, OOYYO_DEFAULTS.maxDropPercent),
56
+ maxSourceAgeHours: positiveNumber(params.feedGuardMaxSourceAgeHours, OOYYO_DEFAULTS.maxSourceAgeHours),
57
+ maxStaleHours: positiveNumber(params.feedGuardMaxStaleHours, OOYYO_DEFAULTS.maxStaleHours),
58
+ acceptDrop: text(params.feedGuardAcceptDrop).toLowerCase() === 'true',
59
+ };
60
+ this.outputFiles = [
61
+ `${this.pathesConfig.localConvertedDirectory}/${ExportType.OOYYO}.xml`,
62
+ `${this.pathesConfig.localConvertedDirectory}/${ExportType.OOYYO}.xml.gz`,
63
+ ];
64
+ }
65
+ }
@@ -1,78 +1,78 @@
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 { MarktdeFeedRun, MarktdeSourceCar } from '../services/marktde-feed-run';
11
- import { zipSingleFile } from '../services/meinstadt-feed-run';
12
- import { MobileTransformer } from '../transformers/mobile.transformer';
13
- import { toMarktdeImages } from '../transformers/marktde.transformer';
14
- import { resolveOpenAiImageFields } from '../transformers/openai-image-fields';
15
- import { MarktdeConfig } from '../configs/marktde.config';
16
- import { Exporter } from '../interfaces/exporter.interface';
17
-
18
- /**
19
- * ExportType.MARKTDE (ADV-328). Was the shared MobileExporter; now wires the Cronicle/Nest services
20
- * into MarktdeFeedRun, which holds the logic. The row itself still comes from MobileTransformer (same
21
- * mapping sheet, same columns); only the markt.de columns are replaced. EAUTO / YESCAR / EVIUM keep
22
- * the MobileExporter unchanged.
23
- */
24
- @Injectable()
25
- export class MarktdeExporter implements Exporter {
26
- constructor(
27
- private carService: CarService,
28
- private configService: ConfigService,
29
- private rulesService: RulesService,
30
- private loaderService: LoaderService,
31
- private imagesService: ImagesService,
32
- private moduleRef: ModuleRef,
33
- ) {}
34
-
35
- async export(): Promise<void> {
36
- const config = this.configService.config as MarktdeConfig;
37
- const [csvFile, zipFile] = config.outputFiles;
38
- const validator = new ImageUrlValidator({ concurrency: config.imageConcurrency });
39
- const transformer = await this.moduleRef.create(MobileTransformer);
40
-
41
- const run = new MarktdeFeedRun(
42
- {
43
- forEachChunk: fn => this.loaderService.process((chunk: FsChunkInterface) => fn(chunk.data as MarktdeSourceCar[])),
44
- filterCar: source => this.carService.filterCar(source, this.rulesService.rules),
45
- resolveImages: async ({ car, images }) => toMarktdeImages(
46
- await resolveOpenAiImageFields(this.imagesService.getCleanImages(car._id, images), validator),
47
- config.maxImages,
48
- ),
49
- legacyRow: async ({ car, images }) => (await transformer.transform(car, images)) as Record<string, unknown>,
50
- imageStats: () => validator.stats,
51
- zip: zipSingleFile,
52
- guard: new FeedGuard(config.feedGuard),
53
- },
54
- {
55
- runId: config.runId,
56
- dryRun: config.dryRun,
57
- domain: config.domain,
58
- sourceDirectory: config.pathesConfig.localDirectory,
59
- sourceBaseFileName: config.pathesConfig.baseFileName,
60
- csvFile,
61
- zipFile,
62
- stateDir: config.stateDir,
63
- },
64
- );
65
-
66
- try {
67
- const report = await run.run();
68
- if (report.stale) {
69
- console.log(`marktde feed STALE: last good file ${report.lastGoodAgeHours?.toFixed(1)} h old`);
70
- }
71
- } catch (error) {
72
- // main.ts would report a rejected run as {"complete":1,"code":0}; fail the job so the chained
73
- // upload step does not run (report + lock already handled by the run).
74
- LoggerService.exitError(`marktde feed not published: ${error?.message ?? error}`);
75
- process.exit(1);
76
- }
77
- }
78
- }
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 { MarktdeFeedRun, MarktdeSourceCar } from '../services/marktde-feed-run';
11
+ import { zipSingleFile } from '../services/meinstadt-feed-run';
12
+ import { MobileTransformer } from '../transformers/mobile.transformer';
13
+ import { toMarktdeImages } from '../transformers/marktde.transformer';
14
+ import { resolveOpenAiImageFields } from '../transformers/openai-image-fields';
15
+ import { MarktdeConfig } from '../configs/marktde.config';
16
+ import { Exporter } from '../interfaces/exporter.interface';
17
+
18
+ /**
19
+ * ExportType.MARKTDE (ADV-328). Was the shared MobileExporter; now wires the Cronicle/Nest services
20
+ * into MarktdeFeedRun, which holds the logic. The row itself still comes from MobileTransformer (same
21
+ * mapping sheet, same columns); only the markt.de columns are replaced. EAUTO / YESCAR / EVIUM keep
22
+ * the MobileExporter unchanged.
23
+ */
24
+ @Injectable()
25
+ export class MarktdeExporter implements Exporter {
26
+ constructor(
27
+ private carService: CarService,
28
+ private configService: ConfigService,
29
+ private rulesService: RulesService,
30
+ private loaderService: LoaderService,
31
+ private imagesService: ImagesService,
32
+ private moduleRef: ModuleRef,
33
+ ) {}
34
+
35
+ async export(): Promise<void> {
36
+ const config = this.configService.config as MarktdeConfig;
37
+ const [csvFile, zipFile] = config.outputFiles;
38
+ const validator = new ImageUrlValidator({ concurrency: config.imageConcurrency });
39
+ const transformer = await this.moduleRef.create(MobileTransformer);
40
+
41
+ const run = new MarktdeFeedRun(
42
+ {
43
+ forEachChunk: fn => this.loaderService.process((chunk: FsChunkInterface) => fn(chunk.data as MarktdeSourceCar[])),
44
+ filterCar: source => this.carService.filterCar(source, this.rulesService.rules),
45
+ resolveImages: async ({ car, images }) => toMarktdeImages(
46
+ await resolveOpenAiImageFields(this.imagesService.getCleanImages(car._id, images), validator),
47
+ config.maxImages,
48
+ ),
49
+ legacyRow: async ({ car, images }) => (await transformer.transform(car, images)) as Record<string, unknown>,
50
+ imageStats: () => validator.stats,
51
+ zip: zipSingleFile,
52
+ guard: new FeedGuard(config.feedGuard),
53
+ },
54
+ {
55
+ runId: config.runId,
56
+ dryRun: config.dryRun,
57
+ domain: config.domain,
58
+ sourceDirectory: config.pathesConfig.localDirectory,
59
+ sourceBaseFileName: config.pathesConfig.baseFileName,
60
+ csvFile,
61
+ zipFile,
62
+ stateDir: config.stateDir,
63
+ },
64
+ );
65
+
66
+ try {
67
+ const report = await run.run();
68
+ if (report.stale) {
69
+ console.log(`marktde feed STALE: last good file ${report.lastGoodAgeHours?.toFixed(1)} h old`);
70
+ }
71
+ } catch (error) {
72
+ // main.ts would report a rejected run as {"complete":1,"code":0}; fail the job so the chained
73
+ // upload step does not run (report + lock already handled by the run).
74
+ LoggerService.exitError(`marktde feed not published: ${error?.message ?? error}`);
75
+ process.exit(1);
76
+ }
77
+ }
78
+ }
@@ -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.