@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.
- package/dist/configs/ooyyo.config.d.ts +14 -0
- package/dist/configs/ooyyo.config.js +40 -6
- package/dist/configs/ooyyo.config.js.map +1 -1
- package/dist/exporters/ooyyo.exporter.d.ts +5 -4
- package/dist/exporters/ooyyo.exporter.js +45 -25
- package/dist/exporters/ooyyo.exporter.js.map +1 -1
- package/dist/formats/ooyyo-xml.builder.d.ts +7 -2
- package/dist/formats/ooyyo-xml.builder.js +59 -36
- package/dist/formats/ooyyo-xml.builder.js.map +1 -1
- package/dist/interfaces/export-item.interface.d.ts +30 -2
- package/dist/interfaces/params.interface.d.ts +3 -0
- package/dist/services/ooyyo-feed-run.d.ts +81 -0
- package/dist/services/ooyyo-feed-run.js +183 -0
- package/dist/services/ooyyo-feed-run.js.map +1 -0
- package/dist/services/ooyyo-feed-verify.d.ts +13 -0
- package/dist/services/ooyyo-feed-verify.js +104 -0
- package/dist/services/ooyyo-feed-verify.js.map +1 -0
- package/dist/transformers/ooyyo.transformer.d.ts +51 -11
- package/dist/transformers/ooyyo.transformer.js +268 -95
- package/dist/transformers/ooyyo.transformer.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/marktde/AUDIT.md +84 -84
- package/docs/marktde/PARTNER-QUESTIONS.md +49 -49
- package/docs/marktde/RUNBOOK.md +58 -58
- package/docs/marktde/TRACKING.md +75 -69
- package/docs/meinestadt/PARTNER-QUESTIONS.md +17 -0
- package/docs/meinestadt/TRACKING.md +107 -0
- package/docs/ooyyo/AUDIT.md +81 -0
- package/docs/ooyyo/PARTNER-QUESTIONS.md +32 -0
- package/docs/ooyyo/RUNBOOK.md +67 -0
- package/docs/ooyyo/TRACKING.md +54 -0
- package/package.json +66 -65
- package/src/configs/marktde.config.ts +70 -70
- package/src/configs/ooyyo.config.ts +65 -15
- package/src/exporters/marktde.exporter.ts +78 -78
- package/src/exporters/ooyyo.exporter.ts +75 -48
- package/src/formats/ooyyo-xml.builder.ts +87 -48
- package/src/interfaces/export-item.interface.ts +43 -2
- package/src/interfaces/params.interface.ts +5 -0
- package/src/services/marktde-feed-run.ts +233 -233
- package/src/services/ooyyo-feed-run.ts +265 -0
- package/src/services/ooyyo-feed-verify.ts +131 -0
- package/src/transformers/marktde.transformer.ts +255 -255
- package/src/transformers/ooyyo.transformer.ts +378 -109
- package/test/fixtures/ooyyo/OoyyoFormat.xml +125 -0
- package/test/fixtures/ooyyo/OoyyoFormat.xsd +116 -0
- package/test/fixtures/ooyyo.fixture.ts +53 -0
- package/test/mapping-csv.test.ts +65 -65
- package/test/marktde-feed-run.test.ts +263 -263
- package/test/marktde-mapping.test.ts +280 -280
- package/test/ooyyo-feed-run.test.ts +293 -0
- package/test/ooyyo-mapping.test.ts +276 -0
|
@@ -1,15 +1,65 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { ExportType,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 {
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 {
|
|
2
|
-
import { OoyyoXmlItem } from '../interfaces';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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.
|