@bytebrand/export-cars 1.26.39 → 1.26.40
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/criteo.config.d.ts +10 -0
- package/dist/configs/criteo.config.js +26 -1
- package/dist/configs/criteo.config.js.map +1 -1
- package/dist/exporters/criteo.exporter.js +27 -23
- package/dist/exporters/criteo.exporter.js.map +1 -1
- package/dist/services/criteo-feed-run.d.ts +76 -0
- package/dist/services/criteo-feed-run.js +186 -0
- package/dist/services/criteo-feed-run.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/criteo/RUNBOOK.md +86 -0
- package/package.json +2 -2
- package/src/configs/criteo.config.ts +54 -17
- package/src/exporters/criteo.exporter.ts +63 -51
- package/src/services/criteo-feed-run.ts +253 -0
- package/test/criteo-feed-run.test.ts +365 -0
- package/test/fixtures/criteo-20-cases.json +582 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Criteo catalog feed — runbook (ADV-319)
|
|
2
|
+
|
|
3
|
+
Status 06.11.2026, `@bytebrand/export-cars` 1.26.40. DEV only; PROD events `4.0` / `4.1` are disabled
|
|
4
|
+
and no Criteo account, contract or importer test exists yet. Nothing here allows a live upload.
|
|
5
|
+
|
|
6
|
+
## What the job does
|
|
7
|
+
|
|
8
|
+
`4.0 export criteo - part 1` (`exportType=criteo`) reads the base export snapshot
|
|
9
|
+
(`resources/cars-export/cars.<n>.json`) and writes the legacy Criteo RSS/XML file
|
|
10
|
+
`resources/criteo-export/api_export_cars_criteo.xml`. The chained `4.1 Export criteo - part 2` uploads that
|
|
11
|
+
file. Format, enums and field order are the legacy ones (decision 20.10.2026: restore as before).
|
|
12
|
+
|
|
13
|
+
Per car, in this order (first match wins, counted in `excluded`):
|
|
14
|
+
|
|
15
|
+
| Reason | Rule |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `no_id` | empty `_id` |
|
|
18
|
+
| `duplicate_id` | `_id` already seen in this run (first one is kept) |
|
|
19
|
+
| `filtered` | shared partner filter: `metaData.imagesCount > 0` and the Cronicle rule params |
|
|
20
|
+
| `reserved_or_sold` | `offer.offerAvailability` is reserved or sold (Criteo 16.10: only marketable stock) |
|
|
21
|
+
| `no_valid_price` | `price.currentSalesPrice` missing, not a number or ≤ 0 |
|
|
22
|
+
| `no_marketing_image` | no generated `criteo-<id>.jpg`; no noimage placeholder is ever sent |
|
|
23
|
+
|
|
24
|
+
A car that went offline is not in the snapshot, so it is missing from the next full file; Criteo's next
|
|
25
|
+
full import removes it. `published-ids.json` keeps the ids of the last published file, and the report
|
|
26
|
+
lists `ids.added` / `ids.removed` / `ids.kept` (+ up to 20 removed ids) for each run.
|
|
27
|
+
|
|
28
|
+
Item fields: `installment` and `custom_label_0` only when the car has a real rate (months and amount > 0);
|
|
29
|
+
`energy_efficiency_class` only for a known WLTP class.
|
|
30
|
+
|
|
31
|
+
## Fail-closed guard
|
|
32
|
+
|
|
33
|
+
Shared `FeedGuard` (same as meinestadt / AutoUncle / OpenAI). The new XML is written to
|
|
34
|
+
`api_export_cars_criteo.xml.tmp` and renamed over the published file only after all checks passed; the
|
|
35
|
+
previous file is kept as `.prev`. A blocked run keeps the published file, moves the temp file to
|
|
36
|
+
`.rejected`, and **fails the job** (Cronicle code 1), so `4.1` does not upload anything.
|
|
37
|
+
|
|
38
|
+
| Code | Meaning | Action |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `source_empty` | no snapshot chunk files (base export failed / wiped) | fix the base export, re-run |
|
|
41
|
+
| `source_partial` | chunk files with gaps | re-run the base export |
|
|
42
|
+
| `source_stale` | newest chunk older than `feedGuardMaxSourceAgeHours` (26) | base export did not run |
|
|
43
|
+
| `source_too_small` | snapshot below `feedGuardMinSourceItems` (1000) | check the base export |
|
|
44
|
+
| `published_too_small` | fewer items than `feedGuardMinPublishedItems` (500), e.g. all reserved/sold | check stock / images |
|
|
45
|
+
| `count_drop` | source or items dropped by more than `feedGuardMaxDropPercent` (20 %) vs last good | if the stock really dropped: re-run once with `feedGuardAcceptDrop=true` |
|
|
46
|
+
| `locked` | another run holds `resources/criteo-state/run.lock` (stale after 6 h) | wait / check the other job |
|
|
47
|
+
|
|
48
|
+
Stale fallback: each report carries `lastGoodAgeHours`; after `feedGuardMaxStaleHours` (48) without a
|
|
49
|
+
successful run, `stale: true` and the log line `criteo feed STALE`. The job itself already failed on every
|
|
50
|
+
blocked run, which is the alert. Whether Criteo should then get an empty file (to stop advertising
|
|
51
|
+
old stock) is an open partner question, not built.
|
|
52
|
+
|
|
53
|
+
All thresholds are Cronicle params of the `4.0` event (`feedGuardMinSourceItems`,
|
|
54
|
+
`feedGuardMinPublishedItems`, `feedGuardMaxDropPercent`, `feedGuardMaxSourceAgeHours`,
|
|
55
|
+
`feedGuardMaxStaleHours`, `feedGuardAcceptDrop`).
|
|
56
|
+
|
|
57
|
+
## Files
|
|
58
|
+
|
|
59
|
+
| Path (relative to `/opt/cronicle`) | Content |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `resources/criteo-export/api_export_cars_criteo.xml` | published file (what `4.1` uploads) |
|
|
62
|
+
| `resources/criteo-export/api_export_cars_criteo.xml.prev` | file before the last publish |
|
|
63
|
+
| `resources/criteo-export/api_export_cars_criteo.xml.rejected` | output of the last blocked run |
|
|
64
|
+
| `resources/criteo-state/last-good.json` | counts + time of the last publish |
|
|
65
|
+
| `resources/criteo-state/last-run-report.json` | report of the last run (also printed as `criteo feed report: {...}`) |
|
|
66
|
+
| `resources/criteo-state/published-ids.json` | ids of the published file |
|
|
67
|
+
|
|
68
|
+
Report metrics: source chunk age (`source.ageHours`), run time (`durationMs`), source cars, items,
|
|
69
|
+
exclusions by reason, items with a rate, items without energy class, id changes, last good age.
|
|
70
|
+
Criteo's accepted / rejected records come from the Criteo importer, not from this job.
|
|
71
|
+
|
|
72
|
+
## Rollback
|
|
73
|
+
|
|
74
|
+
1. Code: `sudo npm --prefix /opt/cronicle install @bytebrand/export-cars@<previous> --prefer-online`
|
|
75
|
+
(DEV before this release: 1.26.39). Only the export-cars package changes; back up
|
|
76
|
+
`/opt/cronicle/package.json` and `package-lock.json` first.
|
|
77
|
+
2. File: `cp resources/criteo-export/api_export_cars_criteo.xml.prev resources/criteo-export/api_export_cars_criteo.xml`.
|
|
78
|
+
3. State: `resources/criteo-state/` can be deleted; the next run then has no last good counts (no drop check
|
|
79
|
+
once) and reports all ids as added.
|
|
80
|
+
|
|
81
|
+
## Tests
|
|
82
|
+
|
|
83
|
+
`test/criteo-mapping.test.ts` (item mapping) and `test/criteo-feed-run.test.ts`: 20 anonymized DEV cars
|
|
84
|
+
(`test/fixtures/criteo-20-cases.json`, synthetic ids, mapping fields only) plus sold, reserved, offline,
|
|
85
|
+
no image, price change, invalid price, duplicate id, missing id, source outage, partial / stale snapshot,
|
|
86
|
+
count drop + acceptDrop, empty stock, lock, stale last good, atomic publish. Run: `npm test`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytebrand/export-cars",
|
|
3
|
-
"version": "1.26.
|
|
3
|
+
"version": "1.26.40",
|
|
4
4
|
"description": "",
|
|
5
5
|
"author": "bytebrand",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
12
12
|
"lint": "eslint -c .eslintrc.js --ext .ts src/",
|
|
13
13
|
"start:dev": "nest start --watch",
|
|
14
|
-
"test": "ts-node --transpile-only test/google-feed-envkv.test.ts && ts-node --transpile-only test/google-feed-item-row.test.ts && ts-node --transpile-only test/publication-gate.test.ts && ts-node --transpile-only test/images-regression.test.ts && ts-node --transpile-only test/clean-images.test.ts && ts-node --transpile-only test/image-url-validator.test.ts && ts-node --transpile-only test/openai-image-fields.test.ts && ts-node --transpile-only test/openai-ads-mapping.test.ts && ts-node --transpile-only test/openai-ads-feed-run.test.ts && ts-node --transpile-only test/openai-ads-finance.test.ts && ts-node --transpile-only test/openai-ads-price-cap.test.ts && ts-node --transpile-only test/meinstadt-mapping.test.ts && ts-node --transpile-only test/meinstadt-feed-run.test.ts && ts-node --transpile-only test/autouncle-mapping.test.ts && ts-node --transpile-only test/autouncle-feed-run.test.ts && ts-node --transpile-only test/criteo-images.test.ts && ts-node --transpile-only test/criteo-mapping.test.ts"
|
|
14
|
+
"test": "ts-node --transpile-only test/google-feed-envkv.test.ts && ts-node --transpile-only test/google-feed-item-row.test.ts && ts-node --transpile-only test/publication-gate.test.ts && ts-node --transpile-only test/images-regression.test.ts && ts-node --transpile-only test/clean-images.test.ts && ts-node --transpile-only test/image-url-validator.test.ts && ts-node --transpile-only test/openai-image-fields.test.ts && ts-node --transpile-only test/openai-ads-mapping.test.ts && ts-node --transpile-only test/openai-ads-feed-run.test.ts && ts-node --transpile-only test/openai-ads-finance.test.ts && ts-node --transpile-only test/openai-ads-price-cap.test.ts && ts-node --transpile-only test/meinstadt-mapping.test.ts && ts-node --transpile-only test/meinstadt-feed-run.test.ts && ts-node --transpile-only test/autouncle-mapping.test.ts && ts-node --transpile-only test/autouncle-feed-run.test.ts && ts-node --transpile-only test/criteo-images.test.ts && ts-node --transpile-only test/criteo-mapping.test.ts && ts-node --transpile-only test/criteo-feed-run.test.ts"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@bytebrand/autode-request": "^1.19.2",
|
|
@@ -1,17 +1,54 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
1
|
+
import { toNumber } from 'lodash';
|
|
2
|
+
|
|
3
|
+
import { ExportType, FileType, ImageType } from 'src/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
|
+
// Same guard defaults as the meinestadt / OpenAI feeds; a job can override them via the
|
|
16
|
+
// feedGuard* Cronicle params.
|
|
17
|
+
export const CRITEO_DEFAULTS = {
|
|
18
|
+
minSourceItems: 1000,
|
|
19
|
+
minPublishedItems: 500,
|
|
20
|
+
maxDropPercent: 20,
|
|
21
|
+
maxSourceAgeHours: 26,
|
|
22
|
+
maxStaleHours: 48,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Criteo catalog (ADV-319): legacy RSS/XML file at the same path as before, which the chained
|
|
27
|
+
* `4.1 Export criteo - part 2` step uploads. Guard state lives in resources/criteo-state.
|
|
28
|
+
*/
|
|
29
|
+
export class CriteoConfig extends Config {
|
|
30
|
+
stateDir: string;
|
|
31
|
+
feedGuard: FeedGuardOptions;
|
|
32
|
+
|
|
33
|
+
constructor(params: Params) {
|
|
34
|
+
super(params);
|
|
35
|
+
this.campaign = ExportType.CRITEO;
|
|
36
|
+
this.pathesConfig.localConvertedDirectory = `resources/${ExportType.CRITEO}-export`;
|
|
37
|
+
this.stateDir = `resources/${ExportType.CRITEO}-state`;
|
|
38
|
+
this.imageType = ImageType.marketing;
|
|
39
|
+
this.marketingImageConfigId = this.environmentConfig[params.environment].criteoMarketingImagesConfigId;
|
|
40
|
+
this.feedGuard = {
|
|
41
|
+
stateDir: this.stateDir,
|
|
42
|
+
minSourceItems: positiveNumber(params.feedGuardMinSourceItems, CRITEO_DEFAULTS.minSourceItems),
|
|
43
|
+
minPublishedItems: positiveNumber(params.feedGuardMinPublishedItems, CRITEO_DEFAULTS.minPublishedItems),
|
|
44
|
+
maxDropPercent: positiveNumber(params.feedGuardMaxDropPercent, CRITEO_DEFAULTS.maxDropPercent),
|
|
45
|
+
maxSourceAgeHours: positiveNumber(params.feedGuardMaxSourceAgeHours, CRITEO_DEFAULTS.maxSourceAgeHours),
|
|
46
|
+
maxStaleHours: positiveNumber(params.feedGuardMaxStaleHours, CRITEO_DEFAULTS.maxStaleHours),
|
|
47
|
+
acceptDrop: text(params.feedGuardAcceptDrop).toLowerCase() === 'true',
|
|
48
|
+
};
|
|
49
|
+
this.outputFiles = [
|
|
50
|
+
`${this.pathesConfig.localConvertedDirectory}/api_export_cars_${ExportType.CRITEO}.${FileType.XML}`,
|
|
51
|
+
];
|
|
52
|
+
this.isCriteo = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -1,51 +1,63 @@
|
|
|
1
|
-
import { Injectable } from '@nestjs/common';
|
|
2
|
-
import { ModuleRef } from '@nestjs/core';
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import { CarService, ConfigService, LoaderService, RulesService } from '../services';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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, ConfigService, LoaderService, RulesService } from '../services';
|
|
8
|
+
import { FeedGuard } from '../services/feed-guard';
|
|
9
|
+
import { CriteoFeedRun, CriteoSourceCar } from '../services/criteo-feed-run';
|
|
10
|
+
import { Exporter } from '../interfaces/exporter.interface';
|
|
11
|
+
import { CriteoConfig } from '../configs/criteo.config';
|
|
12
|
+
import { CriteoTransformer } from 'src/transformers/criteo.transformer';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ExportType.CRITEO (ADV-319). Wires the Cronicle/Nest services into CriteoFeedRun, which holds the
|
|
16
|
+
* logic. The output directory is no longer emptied: the new XML replaces the old one only after the
|
|
17
|
+
* fail-closed guard passed.
|
|
18
|
+
*/
|
|
19
|
+
@Injectable()
|
|
20
|
+
export class CriteoExporter implements Exporter {
|
|
21
|
+
constructor(
|
|
22
|
+
private carService: CarService,
|
|
23
|
+
private configService: ConfigService,
|
|
24
|
+
private rulesService: RulesService,
|
|
25
|
+
private loaderService: LoaderService,
|
|
26
|
+
private moduleRef: ModuleRef,
|
|
27
|
+
) { }
|
|
28
|
+
|
|
29
|
+
async export(): Promise<void> {
|
|
30
|
+
const config = this.configService.config as CriteoConfig;
|
|
31
|
+
const [xmlFile] = config.outputFiles;
|
|
32
|
+
const transformer = await this.moduleRef.create(CriteoTransformer);
|
|
33
|
+
|
|
34
|
+
const run = new CriteoFeedRun(
|
|
35
|
+
{
|
|
36
|
+
forEachChunk: fn => this.loaderService.process((chunk: FsChunkInterface) => fn(chunk.data as CriteoSourceCar[])),
|
|
37
|
+
filterCar: source => this.carService.filterCar(source, this.rulesService.rules),
|
|
38
|
+
transform: source => transformer.transform(source),
|
|
39
|
+
guard: new FeedGuard(config.feedGuard),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
runId: config.runId,
|
|
43
|
+
domain: config.domain,
|
|
44
|
+
sourceDirectory: config.pathesConfig.localDirectory,
|
|
45
|
+
sourceBaseFileName: config.pathesConfig.baseFileName,
|
|
46
|
+
xmlFile,
|
|
47
|
+
stateDir: config.stateDir,
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const report = await run.run();
|
|
53
|
+
if (report.stale) {
|
|
54
|
+
console.log(`criteo feed STALE: last good file ${report.lastGoodAgeHours?.toFixed(1)} h old`);
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
// main.ts would report a rejected run as {"complete":1,"code":0}; fail the job so the chained
|
|
58
|
+
// upload step does not run (report + lock already handled by the run).
|
|
59
|
+
LoggerService.exitError(`criteo feed not published: ${error?.message ?? error}`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { get, toNumber, toString } from 'lodash';
|
|
4
|
+
import { ImboImageResponseInterface } from '@bytebrand/chunkable';
|
|
5
|
+
import { DataSchemes } from '@bytebrand/car-schema';
|
|
6
|
+
|
|
7
|
+
import { GoogleXmlBuilder } from '../formats/google-xml.builder';
|
|
8
|
+
import { CriteoXmlItem } from '../interfaces';
|
|
9
|
+
import { FeedGuard, FeedGuardError, LastGood, inspectSourceSnapshot } from './feed-guard';
|
|
10
|
+
|
|
11
|
+
export interface CriteoSourceCar {
|
|
12
|
+
car: DataSchemes.Car;
|
|
13
|
+
images: ImboImageResponseInterface[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export enum CriteoExclusion {
|
|
17
|
+
noId = 'no_id',
|
|
18
|
+
duplicateId = 'duplicate_id',
|
|
19
|
+
filtered = 'filtered',
|
|
20
|
+
notAvailable = 'reserved_or_sold',
|
|
21
|
+
noPrice = 'no_valid_price',
|
|
22
|
+
noImage = 'no_marketing_image',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Online cars flagged like this are not sellable stock (same rule as the AutoUncle feed). Criteo
|
|
26
|
+
// (16.10.2026): only currently marketable stock goes into the full file; a car that leaves it is
|
|
27
|
+
// removed by the next full import.
|
|
28
|
+
const NOT_AVAILABLE = ['selector_offerAvailability_reserved', 'selector_offerAvailability_sold'];
|
|
29
|
+
|
|
30
|
+
// Everything the run needs from the outside world — the Nest exporter wires the real services,
|
|
31
|
+
// tests pass fakes.
|
|
32
|
+
export interface CriteoRunDeps {
|
|
33
|
+
// iterates the base export snapshot chunk by chunk (LoaderService.process)
|
|
34
|
+
forEachChunk: (fn: (cars: CriteoSourceCar[]) => Promise<void>) => Promise<unknown>;
|
|
35
|
+
// existing shared partner filter (images count, Cronicle rules)
|
|
36
|
+
filterCar: (source: CriteoSourceCar) => boolean;
|
|
37
|
+
// CriteoTransformer.transform: null = no real marketing image
|
|
38
|
+
transform: (source: CriteoSourceCar) => Promise<CriteoXmlItem | null>;
|
|
39
|
+
guard: FeedGuard;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CriteoRunOptions {
|
|
43
|
+
runId?: string;
|
|
44
|
+
domain: string;
|
|
45
|
+
sourceDirectory: string;
|
|
46
|
+
sourceBaseFileName: string;
|
|
47
|
+
xmlFile: string;
|
|
48
|
+
stateDir: string;
|
|
49
|
+
now?: () => Date;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CriteoRunReport {
|
|
53
|
+
runId?: string;
|
|
54
|
+
startedAt: string;
|
|
55
|
+
finishedAt?: string;
|
|
56
|
+
durationMs?: number;
|
|
57
|
+
status: 'published' | 'blocked' | 'failed';
|
|
58
|
+
guard?: { code: string; message: string };
|
|
59
|
+
error?: string;
|
|
60
|
+
xmlFile: string;
|
|
61
|
+
source: { files: number; newestChunkAt: string | null; ageHours: number | null; cars: number };
|
|
62
|
+
items: number;
|
|
63
|
+
excluded: Record<string, number>;
|
|
64
|
+
withRate: number;
|
|
65
|
+
withoutEnergyClass: number;
|
|
66
|
+
// against the ids of the last published file; removed ids are dropped by Criteo's next full import
|
|
67
|
+
ids: { added: number; removed: number; kept: number; removedSample: string[] } | null;
|
|
68
|
+
lastGood: LastGood | null;
|
|
69
|
+
lastGoodAgeHours: number | null;
|
|
70
|
+
stale: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
74
|
+
const PRICE = /^\d+(\.\d+)? EUR$/;
|
|
75
|
+
|
|
76
|
+
const increment = (counter: Record<string, number>, key: string) => {
|
|
77
|
+
counter[key] = (counter[key] ?? 0) + 1;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One Criteo catalog run (ADV-319): source check -> map -> temp XML -> count check -> atomic
|
|
82
|
+
* publish -> report. Nothing outside the output directory and `stateDir` is written. A blocked run
|
|
83
|
+
* leaves the last good XML in place and fails the job, so the chained upload step does not run.
|
|
84
|
+
* The XML format, enums and field order stay the legacy Criteo ones.
|
|
85
|
+
*/
|
|
86
|
+
export class CriteoFeedRun {
|
|
87
|
+
constructor(private readonly deps: CriteoRunDeps, private readonly options: CriteoRunOptions) { }
|
|
88
|
+
|
|
89
|
+
private now(): Date {
|
|
90
|
+
return this.options.now ? this.options.now() : new Date();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private get idsFile(): string {
|
|
94
|
+
return join(this.options.stateDir, 'published-ids.json');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async run(): Promise<CriteoRunReport> {
|
|
98
|
+
const { guard } = this.deps;
|
|
99
|
+
const { xmlFile, runId } = this.options;
|
|
100
|
+
const startedAt = this.now();
|
|
101
|
+
const lastGood = await guard.readLastGood();
|
|
102
|
+
const report: CriteoRunReport = {
|
|
103
|
+
runId,
|
|
104
|
+
startedAt: startedAt.toISOString(),
|
|
105
|
+
status: 'failed',
|
|
106
|
+
xmlFile,
|
|
107
|
+
source: { files: 0, newestChunkAt: null, ageHours: null, cars: 0 },
|
|
108
|
+
items: 0,
|
|
109
|
+
excluded: {},
|
|
110
|
+
withRate: 0,
|
|
111
|
+
withoutEnergyClass: 0,
|
|
112
|
+
ids: null,
|
|
113
|
+
lastGood,
|
|
114
|
+
lastGoodAgeHours: guard.staleHours(lastGood, startedAt),
|
|
115
|
+
stale: guard.isStale(lastGood, startedAt),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
await guard.acquireLock(runId ?? 'local', startedAt);
|
|
119
|
+
const tmpFile = `${xmlFile}.tmp`;
|
|
120
|
+
try {
|
|
121
|
+
const snapshot = await inspectSourceSnapshot(this.options.sourceDirectory, this.options.sourceBaseFileName);
|
|
122
|
+
report.source.files = snapshot.files;
|
|
123
|
+
report.source.newestChunkAt = snapshot.newestMtime?.toISOString() ?? null;
|
|
124
|
+
report.source.ageHours = snapshot.newestMtime
|
|
125
|
+
? Number(((startedAt.getTime() - snapshot.newestMtime.getTime()) / HOUR_MS).toFixed(2))
|
|
126
|
+
: null;
|
|
127
|
+
guard.checkSource(snapshot, startedAt);
|
|
128
|
+
|
|
129
|
+
await fs.mkdir(dirname(xmlFile), { recursive: true });
|
|
130
|
+
// the builder appends — never onto a leftover temp file
|
|
131
|
+
await fs.rm(tmpFile, { force: true });
|
|
132
|
+
const builder = new GoogleXmlBuilder(tmpFile);
|
|
133
|
+
await builder.buildStart(this.options.domain);
|
|
134
|
+
|
|
135
|
+
const seen = new Set<string>();
|
|
136
|
+
const published: string[] = [];
|
|
137
|
+
await this.deps.forEachChunk(async (cars) => {
|
|
138
|
+
for (const source of cars) {
|
|
139
|
+
report.source.cars += 1;
|
|
140
|
+
const reason = this.exclusion(source, seen);
|
|
141
|
+
if (reason) {
|
|
142
|
+
increment(report.excluded, reason);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const item = await this.deps.transform(source);
|
|
146
|
+
if (!item) {
|
|
147
|
+
increment(report.excluded, CriteoExclusion.noImage);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// the transformer formats the price; a value it could not format is not sent
|
|
151
|
+
if (!PRICE.test(item.price) || !PRICE.test(item.sale_price)) {
|
|
152
|
+
increment(report.excluded, CriteoExclusion.noPrice);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
await builder.addItem(item);
|
|
156
|
+
published.push(item.id);
|
|
157
|
+
report.items += 1;
|
|
158
|
+
if (item.installment) {
|
|
159
|
+
report.withRate += 1;
|
|
160
|
+
}
|
|
161
|
+
if (!item.energy_efficiency_class) {
|
|
162
|
+
report.withoutEnergyClass += 1;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
await builder.buildEnd();
|
|
167
|
+
|
|
168
|
+
const counts = { sourceItems: report.source.cars, publishedItems: report.items };
|
|
169
|
+
guard.checkCounts(counts, lastGood);
|
|
170
|
+
|
|
171
|
+
const previousIds = await this.readPublishedIds();
|
|
172
|
+
report.lastGood = await guard.publish(tmpFile, xmlFile, counts, this.now(), runId);
|
|
173
|
+
await this.writePublishedIds(published);
|
|
174
|
+
report.ids = this.diffIds(previousIds, published);
|
|
175
|
+
report.status = 'published';
|
|
176
|
+
report.lastGoodAgeHours = 0;
|
|
177
|
+
report.stale = false;
|
|
178
|
+
return report;
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (error instanceof FeedGuardError) {
|
|
181
|
+
report.status = 'blocked';
|
|
182
|
+
report.guard = { code: error.code, message: error.message };
|
|
183
|
+
} else {
|
|
184
|
+
report.error = error?.message ?? String(error);
|
|
185
|
+
}
|
|
186
|
+
// keep the rejected file for inspection; the published file stays untouched
|
|
187
|
+
await fs.rename(tmpFile, `${xmlFile}.rejected`).catch(() => undefined);
|
|
188
|
+
throw error;
|
|
189
|
+
} finally {
|
|
190
|
+
await this.writeReport(report, startedAt);
|
|
191
|
+
await guard.releaseLock();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private exclusion(source: CriteoSourceCar, seen: Set<string>): CriteoExclusion | null {
|
|
196
|
+
const id = toString(get(source, 'car._id', ''));
|
|
197
|
+
if (!id) {
|
|
198
|
+
return CriteoExclusion.noId;
|
|
199
|
+
}
|
|
200
|
+
if (seen.has(id)) {
|
|
201
|
+
return CriteoExclusion.duplicateId;
|
|
202
|
+
}
|
|
203
|
+
seen.add(id);
|
|
204
|
+
if (!this.deps.filterCar(source)) {
|
|
205
|
+
return CriteoExclusion.filtered;
|
|
206
|
+
}
|
|
207
|
+
if (NOT_AVAILABLE.includes(toString(get(source.car, 'offer.offerAvailability', '')))) {
|
|
208
|
+
return CriteoExclusion.notAvailable;
|
|
209
|
+
}
|
|
210
|
+
const price = toNumber(get(source.car, 'price.currentSalesPrice', null));
|
|
211
|
+
if (!Number.isFinite(price) || price <= 0) {
|
|
212
|
+
return CriteoExclusion.noPrice;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private async readPublishedIds(): Promise<string[] | null> {
|
|
218
|
+
try {
|
|
219
|
+
return JSON.parse(await fs.readFile(this.idsFile, 'utf8')) as string[];
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error?.code === 'ENOENT') {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private async writePublishedIds(ids: string[]): Promise<void> {
|
|
229
|
+
await fs.mkdir(this.options.stateDir, { recursive: true });
|
|
230
|
+
await fs.writeFile(`${this.idsFile}.tmp`, JSON.stringify(ids), 'utf8');
|
|
231
|
+
await fs.rename(`${this.idsFile}.tmp`, this.idsFile);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private diffIds(previous: string[] | null, current: string[]): CriteoRunReport['ids'] {
|
|
235
|
+
if (!previous) {
|
|
236
|
+
return { added: current.length, removed: 0, kept: 0, removedSample: [] };
|
|
237
|
+
}
|
|
238
|
+
const before = new Set(previous);
|
|
239
|
+
const now = new Set(current);
|
|
240
|
+
const removed = previous.filter(id => !now.has(id));
|
|
241
|
+
const kept = current.filter(id => before.has(id)).length;
|
|
242
|
+
return { added: current.length - kept, removed: removed.length, kept, removedSample: removed.slice(0, 20) };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private async writeReport(report: CriteoRunReport, startedAt: Date): Promise<void> {
|
|
246
|
+
const finishedAt = this.now();
|
|
247
|
+
report.finishedAt = finishedAt.toISOString();
|
|
248
|
+
report.durationMs = finishedAt.getTime() - startedAt.getTime();
|
|
249
|
+
await fs.mkdir(this.options.stateDir, { recursive: true });
|
|
250
|
+
await fs.writeFile(join(this.options.stateDir, 'last-run-report.json'), JSON.stringify(report, null, 2), 'utf8');
|
|
251
|
+
console.log(`criteo feed report: ${JSON.stringify(report)}`);
|
|
252
|
+
}
|
|
253
|
+
}
|