@bytebrand/export-cars 1.26.39 → 1.26.41

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 (40) hide show
  1. package/dist/configs/autouncle.config.js +22 -0
  2. package/dist/configs/autouncle.config.js.map +1 -1
  3. package/dist/configs/criteo.config.d.ts +10 -0
  4. package/dist/configs/criteo.config.js +26 -1
  5. package/dist/configs/criteo.config.js.map +1 -1
  6. package/dist/exporters/autouncle.exporter.js +10 -0
  7. package/dist/exporters/autouncle.exporter.js.map +1 -1
  8. package/dist/exporters/criteo.exporter.js +27 -23
  9. package/dist/exporters/criteo.exporter.js.map +1 -1
  10. package/dist/services/autouncle-feed-run.d.ts +6 -0
  11. package/dist/services/autouncle-feed-run.js +10 -1
  12. package/dist/services/autouncle-feed-run.js.map +1 -1
  13. package/dist/services/criteo-feed-run.d.ts +76 -0
  14. package/dist/services/criteo-feed-run.js +186 -0
  15. package/dist/services/criteo-feed-run.js.map +1 -0
  16. package/dist/transformers/autouncle.transformer.d.ts +3 -0
  17. package/dist/transformers/autouncle.transformer.js +9 -5
  18. package/dist/transformers/autouncle.transformer.js.map +1 -1
  19. package/dist/transformers/google-feed-envkv.d.ts +2 -0
  20. package/dist/transformers/google-feed-envkv.js +9 -1
  21. package/dist/transformers/google-feed-envkv.js.map +1 -1
  22. package/dist/tsconfig.build.tsbuildinfo +1 -1
  23. package/docs/autouncle/AUDIT.md +7 -3
  24. package/docs/autouncle/PARTNER-QUESTIONS.md +3 -1
  25. package/docs/autouncle/RUNBOOK.md +2 -0
  26. package/docs/criteo/RUNBOOK.md +86 -0
  27. package/package.json +2 -2
  28. package/src/configs/autouncle.config.ts +26 -1
  29. package/src/configs/criteo.config.ts +37 -0
  30. package/src/exporters/autouncle.exporter.ts +12 -0
  31. package/src/exporters/criteo.exporter.ts +40 -28
  32. package/src/services/autouncle-feed-run.ts +15 -1
  33. package/src/services/criteo-feed-run.ts +253 -0
  34. package/src/transformers/autouncle.transformer.ts +17 -4
  35. package/src/transformers/google-feed-envkv.ts +10 -0
  36. package/test/autouncle-feed-run.test.ts +25 -2
  37. package/test/autouncle-mapping.test.ts +41 -2
  38. package/test/criteo-feed-run.test.ts +365 -0
  39. package/test/criteo-mapping.test.ts +122 -122
  40. package/test/fixtures/criteo-20-cases.json +582 -0
@@ -41,7 +41,7 @@ Dry-run is the default and writes `resources/autouncle-dryrun/`, which no Cronic
41
41
 
42
42
  | Key | Source | Before | Now | Open |
43
43
  |---|---|---|---|---|
44
- | back_link | CBD `_id` | `https://www.<domain>/search/vehicle/<_id>?source=classifieds&medium=cpc&campaign=autouncle` | same; stable across runs | UTM pattern = Confluence 2.7.2; others existed (ACF-3650/6172) → Q8 |
44
+ | back_link | CBD `_id` | `https://www.<domain>/search/vehicle/<_id>?source=classifieds&medium=cpc&campaign=autouncle` | stable across runs; since 1.26.41 (ADV-324) `?source=autouncle&medium=listing&campaign=listing&utm_source=autouncle&utm_medium=listing&utm_campaign=listing` | pending AutoUncle approval → Q8 |
45
45
  | make / model_name / model_variant | mainData.make / model / subModel | raw | same | `SONSTIGE` placeholder models are exported (data) → Q9 |
46
46
  | fuel / body / gear_type | consumption.fuel / mainData.category / driveSuspension.gearbox | fixed maps; an unmapped or null selector made the key **disappear** from JSON | unmapped/null → `Other` / `other` | enum values → Q6 |
47
47
  | doors | mainData.doors | string | same | |
@@ -58,8 +58,9 @@ Dry-run is the default and writes `resources/autouncle-dryrun/`, which no Cronic
58
58
  | seller_region / city / street_address / postcode | UPA businessAddress, else mainAddress; region from the postal code table | if the table did not know the place, street/city/zip were **blanked** (664/1044 DEV cars) | dealer's own values kept, region `Deutschland` | |
59
59
  | description | offer.offerDescription | raw | same; 192/1002 empty on DEV, some contain literal `\\` line breaks (dealer data) | → Q10 |
60
60
  | images | Imbo images | default profile: `/large` (format negotiated, can be WebP), padded with **3 noimage placeholders** below 17 photos (2820 placeholder URLs on DEV) | `[{url}]`, ADV-336 clean profile, explicit `large-cached.jpeg`, every URL checked (200 + image/jpeg|png); no valid 3/4 hero → car not sent | limits → ADV-323 gate |
61
- | fuel_economy / co2_emission | consumption.consumptionCombined / environmentEmissions.co2 | raw | same | |
62
- | co2_effiency_class / euro_emission_class / enviromental_sticker | environmentEmissions.* | raw selector keys (`selector_energyEfficiencyClass_d`) | same (no schema to translate to) | → Q6 |
61
+ | fuel_economy / co2_emission | consumption.consumptionCombined / environmentEmissions.co2 | raw legacy fields; a BEV sent its kWh (CBD mirrors it into consumptionCombined) as fuel economy | since 1.26.41: the Google feed's EnVKV picks as plain numbers (WLTP first, legacy only as fallback; confirmed plug-in weighted); BEV: fuel `""`, CO₂ `0`; estimated values withheld by the publication gate (enforce) | units → Q6 |
62
+ | co2_effiency_class | environmentEmissions.wltpEnergyEfficiencyClass (ADV-187) | raw | same; `""` when the CO₂ value is withheld (the class is derived from CO₂) | → Q6 |
63
+ | euro_emission_class / enviromental_sticker | environmentEmissions.* | raw selector keys | same (no schema to translate to) | → Q6 |
63
64
 
64
65
  Car-level exclusions (new, counted in the report): `duplicate_id`, `filtered`, `no_id`, `no_dealer`,
65
66
  `reserved_or_sold` (offer.offerAvailability reserved/sold), `no_valid_price`, `no_valid_image`.
@@ -76,6 +77,9 @@ Car-level exclusions (new, counted in the report): `duplicate_id`, `filtered`, `
76
77
  8. Fail-closed: no `emptyDir`; empty / partial / stale snapshot, empty result or a drop > 20 % against the
77
78
  last good run → run blocked (exit 1, `3.1` copy does not run), last good file stays. Lock per state dir.
78
79
  9. Dry-run default; live only with `autouncleDryRun=false`.
80
+ 10. (1.26.41, 10.11) EnVKV: WLTP picks + EnVKV C8 publication gate (same params as the Google job). A live run
81
+ refuses to start without `publicationGate=enforce`. The run report has `envkv.estimated` per attribute.
82
+ 11. (1.26.41, 10.11) `back_link` parameters per ADV-324 (partner in `source`, identical utm pair).
79
83
 
80
84
  ## 5. Evidence
81
85
 
@@ -33,7 +33,9 @@ ingestion contract and is not used as one.
33
33
  registration date "new"?
34
34
  8. **Unique key and links.** Which field is the unique ad key on your side — `back_link`, or should we add a
35
35
  stable vehicle id? Which UTM parameters do you want on `back_link` (historic:
36
- `source=classifieds&medium=cpc&campaign=autouncle`)? Do you need a click id passed through?
36
+ `source=classifieds&medium=cpc&campaign=autouncle`; proposed and on DEV since 1.26.41:
37
+ `source=autouncle&medium=listing&campaign=listing` plus the same values as `utm_*`)? Do you need a click id
38
+ passed through?
37
39
  9. How do you treat ads whose model is unknown (our placeholder "SONSTIGE")? Exclude or send?
38
40
  10. Optional fields: do you use `leasing`, `has_4wd`, `description` (German text), and do you support
39
41
  structured financing offers (rate, duration, deposit, balloon, mileage, APR with legal disclosures) instead
@@ -15,6 +15,8 @@ Not activated. Nothing below has been done on any Cronicle host.
15
15
  | `feedGuardMaxSourceAgeHours` | 26 | newest base export chunk must be younger |
16
16
  | `feedGuardMaxStaleHours` | 48 | last good file reported STALE after this |
17
17
  | `feedGuardAcceptDrop` | off | `true` for ONE run after a real stock drop |
18
+ | `publicationGate` | off | `audit` counts estimated EnVKV values, `enforce` sends them empty. **A live run needs `enforce`** (config error otherwise). enforce fails the run if the enrichment log cannot be read |
19
+ | `publicationGateBqProject` / `…Dataset` / `…Table` | — | CBD enrichment log table, same values as the Google job; required for audit / enforce |
18
20
 
19
21
  `String` / `Number` rules stay as they are (PROD: dealer allow-list + `metaData.imagesCount:2`).
20
22
 
@@ -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.39",
3
+ "version": "1.26.41",
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,7 +1,7 @@
1
1
  import { toNumber } from 'lodash';
2
2
 
3
3
  import { ExportType, FileType, ImageType } from '../enums';
4
- import { Params } from '../interfaces/params.interface';
4
+ import { Params, PublicationGateConfig } from '../interfaces/params.interface';
5
5
  import { FeedGuardOptions } from '../services/feed-guard';
6
6
  import { Config } from './config';
7
7
 
@@ -55,8 +55,33 @@ export class AutouncleConfig extends Config {
55
55
  maxStaleHours: positiveNumber(params.feedGuardMaxStaleHours, AUTOUNCLE_DEFAULTS.maxStaleHours),
56
56
  acceptDrop: text(params.feedGuardAcceptDrop).toLowerCase() === 'true',
57
57
  };
58
+ this.publicationGate = parsePublicationGate(params);
59
+ // ADV-322 "do not guess emissions": a live file never goes out with unchecked EnVKV estimates.
60
+ if (!this.dryRun && this.publicationGate?.mode !== 'enforce') {
61
+ throw new Error('a live AutoUncle run (autouncleDryRun=false) needs publicationGate=enforce');
62
+ }
58
63
  this.outputFiles = [
59
64
  `${this.pathesConfig.localConvertedDirectory}/api_export_cars_${ExportType.AUTOUNCLE}.${FileType.JSON}`,
60
65
  ];
61
66
  }
62
67
  }
68
+
69
+ // Same params and rules as the Google job (GoogleConfig): off by default; audit / enforce need the
70
+ // enrichment log table (publicationGateBqProject / Dataset / Table).
71
+ function parsePublicationGate(params: Params): PublicationGateConfig | undefined {
72
+ const mode = text(params.publicationGate).toLowerCase();
73
+ if (!mode || mode === 'off') return undefined;
74
+ if (mode !== 'audit' && mode !== 'enforce') {
75
+ throw new Error(`publicationGate must be off, audit or enforce, got "${mode}"`);
76
+ }
77
+ const gate = {
78
+ mode: mode as PublicationGateConfig['mode'],
79
+ project: text(params.publicationGateBqProject),
80
+ dataset: text(params.publicationGateBqDataset),
81
+ table: text(params.publicationGateBqTable),
82
+ };
83
+ if (!gate.project || !gate.dataset || !gate.table) {
84
+ throw new Error(`publicationGate=${mode} needs publicationGateBqProject, publicationGateBqDataset and publicationGateBqTable`);
85
+ }
86
+ return gate;
87
+ }
@@ -1,14 +1,51 @@
1
+ import { toNumber } from 'lodash';
2
+
1
3
  import { ExportType, FileType, ImageType } from 'src/enums';
2
4
  import { Params } from '../interfaces/params.interface';
5
+ import { FeedGuardOptions } from '../services/feed-guard';
3
6
  import { Config } from './config';
4
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
+ */
5
29
  export class CriteoConfig extends Config {
30
+ stateDir: string;
31
+ feedGuard: FeedGuardOptions;
32
+
6
33
  constructor(params: Params) {
7
34
  super(params);
8
35
  this.campaign = ExportType.CRITEO;
9
36
  this.pathesConfig.localConvertedDirectory = `resources/${ExportType.CRITEO}-export`;
37
+ this.stateDir = `resources/${ExportType.CRITEO}-state`;
10
38
  this.imageType = ImageType.marketing;
11
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
+ };
12
49
  this.outputFiles = [
13
50
  `${this.pathesConfig.localConvertedDirectory}/api_export_cars_${ExportType.CRITEO}.${FileType.XML}`,
14
51
  ];
@@ -9,6 +9,7 @@ import { resolveOpenAiImageFields } from '../transformers/openai-image-fields';
9
9
  import { CarService, RulesService, LoaderService, ConfigService, ImagesService } from '../services';
10
10
  import { ImageUrlValidator } from '../services/image-url-validator.service';
11
11
  import { FeedGuard } from '../services/feed-guard';
12
+ import { PublicationGate, loadPublicationGate } from '../services/publication-gate';
12
13
  import { AutouncleFeedRun, AutouncleSourceCar } from '../services/autouncle-feed-run';
13
14
  import { AutouncleConfig } from '../configs/autouncle.config';
14
15
  import { Exporter } from '../interfaces/exporter.interface';
@@ -36,6 +37,16 @@ export class AutouncleExporter implements Exporter {
36
37
  const transformer = await this.moduleRef.create(AutouncleTransformer);
37
38
  const addresses = new Map<string, Promise<AutouncleAddress | null>>();
38
39
 
40
+ // EnVKV C8 (ADV-322): estimated values are withheld (enforce) or only counted (audit). enforce fails
41
+ // the job when the enrichment log cannot be read, so the last published file stays.
42
+ let gate: PublicationGate;
43
+ try {
44
+ gate = await loadPublicationGate(config.publicationGate);
45
+ } catch (error) {
46
+ LoggerService.exitError(`autouncle feed not published: ${error?.message ?? error}`);
47
+ process.exit(1);
48
+ }
49
+
39
50
  const run = new AutouncleFeedRun(
40
51
  {
41
52
  forEachChunk: fn => this.loaderService.process((chunk: FsChunkInterface) => fn(chunk.data as AutouncleSourceCar[])),
@@ -55,6 +66,7 @@ export class AutouncleExporter implements Exporter {
55
66
  imageStats: () => validator.stats,
56
67
  translate: key => transformer.translate(key),
57
68
  guard: new FeedGuard(config.feedGuard),
69
+ gate,
58
70
  },
59
71
  {
60
72
  runId: config.runId,
@@ -1,14 +1,21 @@
1
1
  import { Injectable } from '@nestjs/common';
2
2
  import { ModuleRef } from '@nestjs/core';
3
3
 
4
- import { CronicleUtils } from '@bytebrand/cronicle-helper';
5
4
  import { FsChunkInterface } from '@bytebrand/chunkable';
5
+ import { LoggerService } from '@bytebrand/cronicle-helper';
6
6
 
7
7
  import { CarService, ConfigService, LoaderService, RulesService } from '../services';
8
- import { GoogleXmlBuilder } from '../formats/google-xml.builder';
8
+ import { FeedGuard } from '../services/feed-guard';
9
+ import { CriteoFeedRun, CriteoSourceCar } from '../services/criteo-feed-run';
9
10
  import { Exporter } from '../interfaces/exporter.interface';
11
+ import { CriteoConfig } from '../configs/criteo.config';
10
12
  import { CriteoTransformer } from 'src/transformers/criteo.transformer';
11
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
+ */
12
19
  @Injectable()
13
20
  export class CriteoExporter implements Exporter {
14
21
  constructor(
@@ -20,32 +27,37 @@ export class CriteoExporter implements Exporter {
20
27
  ) { }
21
28
 
22
29
  async export(): Promise<void> {
23
- const [fileName] = this.configService.config.outputFiles;
24
- await CronicleUtils.ensureDir(this.configService.config.pathesConfig.localConvertedDirectory);
25
- await CronicleUtils.emptyDir(this.configService.config.pathesConfig.localConvertedDirectory);
26
-
27
- const googleXmlBuilder = new GoogleXmlBuilder(fileName);
28
- await googleXmlBuilder.buildStart(this.configService.config.domain);
29
-
30
- const criteoTransformer = await this.moduleRef.create(CriteoTransformer);
31
- let withoutImage = 0;
32
-
33
- await this.loaderService.process(async (chunk: FsChunkInterface) => {
34
- chunk.data = chunk.data.filter(car => this.carService.filterCar(car, this.rulesService.rules));
35
-
36
- for (const data of chunk.data) {
37
- const car = await criteoTransformer.transform(data);
38
- if (car) {
39
- await googleXmlBuilder.addItem(car);
40
- } else {
41
- withoutImage += 1;
42
- }
43
- this.carService.counter.countCar(data.images);
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`);
44
55
  }
45
- });
46
-
47
- await googleXmlBuilder.buildEnd();
48
- console.log(this.carService.counter.getCountedCars());
49
- console.log(`criteo: ${withoutImage} cars skipped without a marketing image`);
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
+ }
50
62
  }
51
63
  }
@@ -6,6 +6,7 @@ import { DataSchemes } from '@bytebrand/car-schema';
6
6
  import { AutouncleJsonBuilder } from '../formats/autouncle-json.builder';
7
7
  import { AutouncleAddress, AutouncleExclusion, getAutouncleExclusion, mapAutouncleItem } from '../transformers/autouncle.transformer';
8
8
  import { FeedGuard, FeedGuardError, LastGood, inspectSourceSnapshot } from './feed-guard';
9
+ import { PublicationGate } from './publication-gate';
9
10
 
10
11
  export interface AutouncleSourceCar {
11
12
  car: DataSchemes.Car;
@@ -26,6 +27,8 @@ export interface AutouncleRunDeps {
26
27
  imageStats: () => Record<string, number>;
27
28
  translate: (key: string) => string;
28
29
  guard: FeedGuard;
30
+ // EnVKV C8 publication gate (CBD enrichment log); absent = off
31
+ gate?: Pick<PublicationGate, 'mode' | 'estimatedAttributes'>;
29
32
  }
30
33
 
31
34
  export interface AutouncleRunOptions {
@@ -54,6 +57,8 @@ export interface AutouncleRunReport {
54
57
  excluded: Record<string, number>;
55
58
  imagesPerItem: { min: number | null; max: number | null; total: number };
56
59
  images: Record<string, number>;
60
+ // published items with an estimated EnVKV value per attribute; with gate enforce those were sent empty
61
+ envkv: { gate: 'off' | 'audit' | 'enforce'; estimated: Record<string, number> };
57
62
  lastGood: LastGood | null;
58
63
  lastGoodAgeHours: number | null;
59
64
  stale: boolean;
@@ -93,6 +98,7 @@ export class AutouncleFeedRun {
93
98
  excluded: {},
94
99
  imagesPerItem: { min: null, max: null, total: 0 },
95
100
  images: {},
101
+ envkv: { gate: this.deps.gate?.mode ?? 'off', estimated: {} },
96
102
  lastGood,
97
103
  lastGoodAgeHours: guard.staleHours(lastGood, startedAt),
98
104
  stale: guard.isStale(lastGood, startedAt),
@@ -141,11 +147,19 @@ export class AutouncleFeedRun {
141
147
  continue;
142
148
  }
143
149
  const images = await this.deps.resolveImages(source);
144
- const mapped = mapAutouncleItem(source.car, { domain: this.options.domain, address, images, translate: this.deps.translate });
150
+ const estimated = this.deps.gate ? this.deps.gate.estimatedAttributes(source.car) : [];
151
+ const mapped = mapAutouncleItem(source.car, {
152
+ domain: this.options.domain,
153
+ address,
154
+ images,
155
+ translate: this.deps.translate,
156
+ withheldEnvkv: this.deps.gate?.mode === 'enforce' ? estimated : [],
157
+ });
145
158
  if (mapped.ok === false) {
146
159
  increment(report.excluded, mapped.reason);
147
160
  continue;
148
161
  }
162
+ estimated.forEach(attribute => increment(report.envkv.estimated, attribute));
149
163
  await builder.addItem(mapped.item, first);
150
164
  first = false;
151
165
  dealers.add(ownerId);