@bytebrand/export-cars 1.26.32 → 1.26.34

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 (31) hide show
  1. package/dist/configs/openai-ads.config.js +2 -2
  2. package/dist/configs/openai-ads.config.js.map +1 -1
  3. package/dist/exporters/openai-ads.exporter.d.ts +3 -0
  4. package/dist/exporters/openai-ads.exporter.js +13 -2
  5. package/dist/exporters/openai-ads.exporter.js.map +1 -1
  6. package/dist/interfaces/openai-ads.interface.d.ts +3 -1
  7. package/dist/interfaces/openai-ads.interface.js +2 -0
  8. package/dist/interfaces/openai-ads.interface.js.map +1 -1
  9. package/dist/services/openai-ads-feed-run.d.ts +3 -1
  10. package/dist/services/openai-ads-feed-run.js +7 -2
  11. package/dist/services/openai-ads-feed-run.js.map +1 -1
  12. package/dist/transformers/openai-ads-finance.d.ts +2 -0
  13. package/dist/transformers/openai-ads-finance.js +39 -0
  14. package/dist/transformers/openai-ads-finance.js.map +1 -0
  15. package/dist/transformers/openai-ads.transformer.d.ts +9 -1
  16. package/dist/transformers/openai-ads.transformer.js +25 -5
  17. package/dist/transformers/openai-ads.transformer.js.map +1 -1
  18. package/dist/tsconfig.build.tsbuildinfo +1 -1
  19. package/docs/openai-ads/AUDIT.md +5 -4
  20. package/docs/openai-ads/RUNBOOK.md +36 -3
  21. package/package.json +2 -2
  22. package/src/configs/openai-ads.config.ts +4 -4
  23. package/src/exporters/openai-ads.exporter.ts +18 -1
  24. package/src/interfaces/openai-ads.interface.ts +4 -0
  25. package/src/services/openai-ads-feed-run.ts +12 -4
  26. package/src/transformers/openai-ads-finance.ts +60 -0
  27. package/src/transformers/openai-ads.transformer.ts +32 -6
  28. package/test/fixtures/openai-ads-finance-titles.json +325 -0
  29. package/test/openai-ads-feed-run.test.ts +20 -1
  30. package/test/openai-ads-finance.test.ts +109 -0
  31. package/test/openai-ads-price-cap.test.ts +69 -0
@@ -147,7 +147,7 @@ Null policy "EXCLUDE" = row not written + counted in the run report under the re
147
147
  Delta (deferred, only with API access): `products[].id = item_id`, price `{"amount": round(currentSalesPrice*100), "currency":"EUR"}`.
148
148
 
149
149
  Filters before mapping (all reasons counted in the run report):
150
- `imagesCount > 0` (existing `filterCar`), price valid, optional price cap (Q9), dealer `active && !blacklist` via `CarService.getOwner` (cached per dealer, failure → EXCLUDE `dealer_unknown`), title/brand/image present.
150
+ `imagesCount > 0` (existing `filterCar`), price valid, €80k price cap (same as Google/Meta, Q9 closed 14.10; `price_cap`), dealer `active && !blacklist` via `CarService.getOwner` (cached per dealer, failure → EXCLUDE `dealer_unknown`), title/brand/image present.
151
151
 
152
152
  ---
153
153
 
@@ -177,11 +177,12 @@ Filters before mapping (all reasons counted in the run report):
177
177
  | Q6 | Description: reuse dealer summary templates (same as Google) — do they contain finance claims? Or plain generated German text only? | PM/Legal | description |
178
178
  | Q7 | `ads_metadata` keys configured in Ads Manager (if any) | Marketing | optional |
179
179
  | Q8 | `product_category` wanted? | Marketing | optional |
180
- | Q9 | Apply the 80k price cap of Google/Meta? | Marketing | filter |
180
+ | Q9 | Apply the 80k price cap of Google/Meta? — **closed 14.10: yes**, same cap and resolver (1.26.34) | Marketing | done |
181
181
  | Q10 | Import method available in the account: hosted URL (needs fetch IP/auth info) or SFTP (credentials into secret store); API access (delta, upload status)? | Marketing | transport |
182
182
  | Q11 | OAI-AdsBot / OAI-SearchBot reachability of `www.auto.de` VDP and `images.auto.de` behind Cloudflare (js_challenge rules from ADV-193). Test only, no CF change without approval. | Eng + approval | go-live |
183
183
  | Q12 | Image: ADV-336 clean profile is used (no overlay). Approval of plain photos + `ext_lr_main` question open in ADV-336 | ADV-336 | image_url |
184
184
  | Q13 | Is a "Stable" vs "Draft" schema selector visible in your docs/account? The public page shows none on 09.10. | Marketing | — |
185
+ | Q15 | Finance claims: closed 13.10 — no finance wording in the feed (`finance_claim` rule, RUNBOOK). Same dealer titles in Google/Meta feeds and on the CDP: separate ticket candidate. | — | done (1.26.33) |
185
186
  | Q14 | Make mapping at import (out of feed scope): DEV car titled "Jeep SWM G01a" — model SONSTIGE, variant names SWM (a different manufacturer), so the feed sends `brand=Jeep` for a non-Jeep car. Fix belongs to the import make/model mapping (sheet / CBD), not to the exporter. | Import owner | brand correctness |
186
187
 
187
188
  ---
@@ -221,7 +222,7 @@ retention 15 days; reinstatement keeps the id), run report, `RUNBOOK.md`, 23 tes
221
222
  Open questions decided in code for now (each reversible by a param or a one-line change):
222
223
  Q3 `seller_name` = `auto.de` (param); Q4 reserved/sold flag → out_of_stock, and never advertised when not sent before;
223
224
  Q5 unknown condition → `used` only with a past first registration; Q6 description from structured data only;
224
- Q9 no price cap unless `maxCurrentSalesPrice` is set; Q7/Q8 `ads_metadata` / `product_category` not sent.
225
+ Q9 (closed 14.10) €80k cap as Google/Meta; Q7/Q8 `ads_metadata` / `product_category` not sent.
225
226
 
226
227
  Found while running the compiled job (`node dist/main.js` with a stdin job): **export-cars reports every failed
227
228
  run as success.** nest-commander's default `serviceErrorHandler` only prints the error, and `main.ts` then
@@ -230,7 +231,7 @@ fails the job itself (`exitError` + exit 1). The global fix is a separate task.
230
231
 
231
232
  Not done (next steps / human):
232
233
  - transport: hosted URL or SFTP, after Q10;
233
- - Cronicle event: DEV dry-run first;
234
+ - Cronicle event: DEV event `emv4ycd4g2y` exists (dry-run, manual runs; no schedule / failure e-mail on DEV by decision, closed 12.10); PROD event not created;
234
235
  - a real DEV/PROD dry-run plus the 20-car reconciliation;
235
236
  - delta PATCH: deferred, needs API access;
236
237
  - crawler access, per ADV-336: robots.txt has `Disallow: /search/` for OAI-AdsBot, plus the Cloudflare rules and the SPA shell without SSR on `/search*`.
@@ -34,10 +34,15 @@ State dir: `ledger.json` (items sent in_stock, for out_of_stock), `last-good.jso
34
34
  No transport is implemented yet. Nothing leaves the server until a hosted-URL location or an SFTP upload is set
35
35
  up for the **live** output only (separate, approved step).
36
36
 
37
- ## Cronicle event (to be created by an admin — not done)
37
+ ## Cronicle event
38
38
 
39
- - Plugin "Export cars", chained after "1.0 Base export" (same as the Google/Meta exports), daily.
39
+ **DEV (exists, closed 12.10):** event `openai_ads` = `emv4ycd4g2y`, dry-run with the default params, manual runs
40
+ only. By user decision it has no schedule and no failure e-mail — nothing to configure on DEV.
41
+
42
+ **PROD (not created):** to be set up by an admin once the go-live gate is passed:
43
+ - Plugin "Export cars", run daily after "1.0 Base export" (own timing about 1 h after it, or chained).
40
44
  Items expire at OpenAI after 2 weeks without refresh — never rely on manual uploads.
45
+ - Failure notification (e-mail) on — a blocked run is the only signal that the feed is not refreshed.
41
46
  - Concurrency 1 (the job also takes `run.lock`).
42
47
  - Params (all text; unset = default):
43
48
 
@@ -48,7 +53,7 @@ up for the **live** output only (separate, approved step).
48
53
  | `openAiAdsSellerName` | `auto.de` | `seller_name` (open question Q3) |
49
54
  | `openAiAdsRetentionDays` | 15 | days an item stays as out_of_stock after it was last in stock |
50
55
  | `openAiAdsImageConcurrency` | 16 | parallel image HEAD checks against images.auto.de |
51
- | `maxCurrentSalesPrice` | none | optional price cap (no 80k default here, Q9) |
56
+ | `maxCurrentSalesPrice` | 80000 | €80k selling-price cap, same as Google/Meta (decision 14.10); a valid positive value overrides, empty/invalid = 80000 |
52
57
  | `feedGuardMinSourceItems` | 1000 | DEV: set ~50 |
53
58
  | `feedGuardMinPublishedItems` | 500 | DEV: set ~20 |
54
59
  | `feedGuardMaxDropPercent` | 20 | vs the last good run |
@@ -89,6 +94,34 @@ running after a failure as before.
89
94
  Only `openai_ads` is protected: the feed guard keeps the last good file, and the job exits with code 1
90
95
  (`exitError` + exit 1 in `openai-ads.exporter.ts`). It has no chain, so this stops nothing else.
91
96
 
97
+ ## €80k price cap (`price_cap`, since 1.26.34)
98
+
99
+ Decision 14.10: the OpenAI feed uses the same selling-price cap as Google/Meta. `OpenAiAdsConfig` resolves
100
+ `maxCurrentSalesPrice` with the shared `resolveMaxFeedSalesPrice` (default `MAX_FEED_SALES_PRICE_EUR` = 80,000), and the
101
+ shared gate `CarService.filterCar` applies it: price must be finite, > 0 and ≤ cap (80,000.00 included, 80,000.01 not).
102
+ Cars above the cap are counted as `excluded.price_cap`. A car that was advertised and then crosses the cap stays in the
103
+ file as out_of_stock (same id, last advertised data) via the ledger — it does not just disappear.
104
+ ⚠️ DEV google/fb/facebookshop events have carried a 20000 value in the past; check the param of any new event.
105
+
106
+ ## No finance claims (`finance_claim`, since 1.26.33)
107
+
108
+ OpenAI's ad policy lists "Auto loans and leasing" outside the US as "Restricted to Select Approved Advertisers"
109
+ (category table in help.openai.com/en/articles/20001534-troubleshooting-common-onboarding-and-policy-issues;
110
+ openai.com/policies/ad-policies). The ADV-335/336 launch gate (13.10) therefore allows no finance wording in the feed.
111
+
112
+ - Source: only the dealer variant text (`mainData.subModel`), e.g. "GLB 200 4,49%Finz./Progressive",
113
+ "Tourneo Custom L1 0% Zins". The description is generated from structured fields and only repeats the title.
114
+ - `transformers/openai-ads-finance.ts`: before the title is normalised, rates (`x,xx %`, `0 %`) and finance words
115
+ (Finz./Finanzierung/finanzier*, Zins, Rate(n), Leasing, mtl./monatlich, `€/Monat`, Anzahlung, Schlussrate, Sollzins,
116
+ effektiver Jahreszins, Kredit, Darlehen, Santander) are cut. A percentage next to SoH/Akku/Batterie/elektrisch/
117
+ Schaden is kept ("99% SoH", "0% Schaden").
118
+ - Fail-closed: if a broader check still finds finance wording in title or description, the car is not sent
119
+ (`excluded.finance_claim`). Cleaned in_stock rows are counted as `cleaned.finance_terms_removed`.
120
+ - Rows re-sent from the ledger as out_of_stock are cleaned the same way (brand only if wording is left).
121
+ - DEV 12.10 (1.26.31): 64 of 1,002 titles had such wording (60 Mercedes "4,49%"/"5,99%"/"Finz.", 4 Ford "0% Zins").
122
+ - Out of scope, candidate for a separate ticket: the same dealer titles go into the Google/Meta feeds and are shown
123
+ on the CDP.
124
+
92
125
  ## Known source-data issues (not fixed by the feed)
93
126
 
94
127
  - Wrong make from the import mapping: e.g. DEV "Jeep SWM G01a" (model SONSTIGE, the variant names another
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/export-cars",
3
- "version": "1.26.32",
3
+ "version": "1.26.34",
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/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"
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"
15
15
  },
16
16
  "dependencies": {
17
17
  "@bytebrand/autode-request": "^1.19.2",
@@ -1,6 +1,7 @@
1
1
  import { toNumber } from 'lodash';
2
2
 
3
3
  import { ExportType, FileType, ImageType } from '../enums';
4
+ import { resolveMaxFeedSalesPrice } from '../constants/feed-price.constants';
4
5
  import { Params } from '../interfaces/params.interface';
5
6
  import { FeedGuardOptions } from '../services/feed-guard';
6
7
  import { Config } from './config';
@@ -50,10 +51,9 @@ export class OpenAiAdsConfig extends Config {
50
51
  this.sellerName = text(params.openAiAdsSellerName) || OPENAI_ADS_DEFAULTS.sellerName;
51
52
  this.retentionDays = positiveNumber(params.openAiAdsRetentionDays, OPENAI_ADS_DEFAULTS.retentionDays);
52
53
  this.imageConcurrency = positiveNumber(params.openAiAdsImageConcurrency, OPENAI_ADS_DEFAULTS.imageConcurrency);
53
- // Shared price cap stays opt-in here (open question to Marketing): only an explicit
54
- // maxCurrentSalesPrice param applies it, no 80k default as for Google/Meta.
55
- const cap = positiveNumber(params.maxCurrentSalesPrice, 0);
56
- this.maxCurrentSalesPrice = cap || undefined;
54
+ // Same €80k selling-price cap as Google/Meta (decision 14.10): a valid positive
55
+ // maxCurrentSalesPrice param wins, else MAX_FEED_SALES_PRICE_EUR. Applied by CarService.filterCar.
56
+ this.maxCurrentSalesPrice = resolveMaxFeedSalesPrice(params.maxCurrentSalesPrice);
57
57
  this.feedGuard = {
58
58
  stateDir: this.stateDir,
59
59
  minSourceItems: positiveNumber(params.feedGuardMinSourceItems, OPENAI_ADS_DEFAULTS.minSourceItems),
@@ -1,4 +1,5 @@
1
1
  import { Injectable } from '@nestjs/common';
2
+ import { get, toNumber } from 'lodash';
2
3
  import { FsChunkInterface } from '@bytebrand/chunkable';
3
4
  import { LoggerService } from '@bytebrand/cronicle-helper';
4
5
  import { TranslatorExtended } from '@bytebrand/schema-tools';
@@ -11,6 +12,20 @@ import { DealerStatus, OpenAiAdsFeedRun, SourceCar } from '../services/openai-ad
11
12
  import { resolveOpenAiImageFields } from '../transformers/openai-image-fields';
12
13
  import { OpenAiAdsConfig } from '../configs/openai-ads.config';
13
14
  import { Exporter } from '../interfaces/exporter.interface';
15
+ import { OpenAiAdsExclusion } from '../interfaces/openai-ads.interface';
16
+
17
+ /**
18
+ * CarService.filterCar decides (images, €80k cap, Cronicle rules); this only names the reason for the
19
+ * run report: a rejected car with a valid price above the cap counts as price_cap, else filtered.
20
+ */
21
+ export const classifyPartnerFilter = (passed: boolean, source: SourceCar, cap: number | undefined): OpenAiAdsExclusion | null => {
22
+ if (passed) {
23
+ return null;
24
+ }
25
+ const price = toNumber(get(source, 'car.price.currentSalesPrice', null));
26
+ const hasImages = (source?.car?.metaData?.imagesCount ?? 0) > 0;
27
+ return cap && hasImages && Number.isFinite(price) && price > cap ? OpenAiAdsExclusion.priceCap : OpenAiAdsExclusion.filtered;
28
+ };
14
29
 
15
30
  /**
16
31
  * ExportType.OPENAI_ADS (ADV-335). Wires the Cronicle/Nest services into OpenAiAdsFeedRun, which
@@ -35,7 +50,9 @@ export class OpenAiAdsExporter implements Exporter {
35
50
  const run = new OpenAiAdsFeedRun(
36
51
  {
37
52
  forEachChunk: fn => this.loaderService.process((chunk: FsChunkInterface) => fn(chunk.data as SourceCar[])),
38
- filterCar: source => this.carService.filterCar(source, this.rulesService.rules),
53
+ filterCar: source => classifyPartnerFilter(
54
+ this.carService.filterCar(source, this.rulesService.rules), source, config.maxCurrentSalesPrice,
55
+ ),
39
56
  dealerStatus: ownerId => this.dealerStatus(ownerId),
40
57
  resolveImages: ({ car, images }) =>
41
58
  resolveOpenAiImageFields(this.imagesService.getCleanImages(car._id, images), validator),
@@ -46,6 +46,8 @@ export enum OpenAiAdsExclusion {
46
46
  noId = 'no_id',
47
47
  duplicateId = 'duplicate_id',
48
48
  filtered = 'filtered',
49
+ // price.currentSalesPrice above the feed cap (€80k default, maxCurrentSalesPrice param)
50
+ priceCap = 'price_cap',
49
51
  invalidPrice = 'invalid_price',
50
52
  noTitle = 'no_title',
51
53
  noBrand = 'no_brand',
@@ -53,6 +55,8 @@ export enum OpenAiAdsExclusion {
53
55
  dealerInactive = 'dealer_inactive',
54
56
  dealerUnknown = 'dealer_unknown',
55
57
  noImage = 'no_image',
58
+ // finance wording left after stripping (rates, Zins, Leasing, mtl., …) — ADV-335/336 launch gate
59
+ financeClaim = 'finance_claim',
56
60
  }
57
61
 
58
62
  // Why an item is sent as out_of_stock.
@@ -30,8 +30,9 @@ export type DealerStatus = 'active' | 'inactive' | 'unknown';
30
30
  export interface OpenAiAdsRunDeps {
31
31
  // iterates the base export snapshot chunk by chunk (LoaderService.process)
32
32
  forEachChunk: (fn: (cars: SourceCar[]) => Promise<void>) => Promise<unknown>;
33
- // existing shared partner filter (images count, optional price cap, Cronicle rules)
34
- filterCar: (source: SourceCar) => boolean;
33
+ // existing shared partner filter (images count, €80k price cap, Cronicle rules): null = passes,
34
+ // else the exclusion reason (price_cap or filtered)
35
+ filterCar: (source: SourceCar) => OpenAiAdsExclusion | null;
35
36
  dealerStatus: (ownerId: string) => Promise<DealerStatus>;
36
37
  // ADV-336 clean images, validated
37
38
  resolveImages: (source: SourceCar) => Promise<OpenAiImageSelection>;
@@ -63,6 +64,8 @@ export interface OpenAiAdsRunReport {
63
64
  rows: { inStock: number; outOfStock: number; total: number };
64
65
  outOfStock: Record<string, number>;
65
66
  excluded: Record<string, number>;
67
+ // in_stock rows whose title had finance wording cut (finance_terms_removed)
68
+ cleaned: Record<string, number>;
66
69
  reservedNeverSent: number;
67
70
  ledger: { before: number; after: number; expired: number };
68
71
  price: { min: number | null; max: number | null };
@@ -103,6 +106,7 @@ export class OpenAiAdsFeedRun {
103
106
  rows: { inStock: 0, outOfStock: 0, total: 0 },
104
107
  outOfStock: {},
105
108
  excluded: {},
109
+ cleaned: {},
106
110
  reservedNeverSent: 0,
107
111
  ledger: { before: 0, after: 0, expired: 0 },
108
112
  price: { min: null, max: null },
@@ -143,8 +147,9 @@ export class OpenAiAdsFeedRun {
143
147
  continue;
144
148
  }
145
149
  seen.add(id);
146
- if (!this.deps.filterCar(source)) {
147
- increment(report.excluded, OpenAiAdsExclusion.filtered);
150
+ const filtered = this.deps.filterCar(source);
151
+ if (filtered) {
152
+ increment(report.excluded, filtered);
148
153
  continue;
149
154
  }
150
155
  const mapped = mapOpenAiAdsVehicle(source.car, mapping);
@@ -176,6 +181,9 @@ export class OpenAiAdsFeedRun {
176
181
  continue;
177
182
  }
178
183
  await builder.addItem(row);
184
+ if (mapped.financeTermsRemoved) {
185
+ increment(report.cleaned, 'finance_terms_removed');
186
+ }
179
187
  inStockIds.add(row.item_id);
180
188
  ledger.markInStock(row, startedAt);
181
189
  report.rows.inStock += 1;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * No finance claims in the OpenAI Ads feed (ADV-335/336 launch gate, 13.10). OpenAI's ads policy
3
+ * lists "Auto loans and leasing" outside the US as "Restricted to Select Approved Advertisers", so
4
+ * the feed must not carry rates, interest, leasing or monthly-payment wording at all.
5
+ *
6
+ * The only source today is the dealer's variant text (mainData.subModel), e.g. "GLB 200
7
+ * 4,49%Finz./Progressive" or "Tourneo Custom L1 0% Zins" — the description is generated from
8
+ * structured fields and repeats the title. Two layers:
9
+ * - stripFinanceClaims: removes finance tokens (word-bounded) before the title is normalised;
10
+ * - hasFinanceClaim: a broader substring check on the final title/description; a match keeps the
11
+ * car out of the feed (exclusion `finance_claim`), so anything the stripping misses fails closed.
12
+ */
13
+
14
+ // A percentage next to these words is a battery/damage figure, not an interest rate:
15
+ // "99% SoH", "Akku 95%", "100% elektrisch", "0% Schaden".
16
+ const NON_FINANCE_PERCENT_CONTEXT = /soh|akku|batter|kapazit|elektr|schaden|unfall|reichweite|ladung/i;
17
+ const PERCENT = /\d{1,3}(?:[.,]\d{1,2})?\s*%-?/g;
18
+
19
+ const FINANCE_TOKENS: RegExp[] = [
20
+ // monthly amounts: "ab 199 € mtl.", "199€/Monat", "mtl. 199 €", "pro Monat"
21
+ /(?:\bab\s+)?\d[\d.,]*\s*(?:€|\beur\b|\beuro\b)?\s*(?:\/\s*|pro\s+|im\s+)?(?:\bmonat\b|\bmtl\b\.?|\bmonatl\b\.?|\bmonatlich\b)/gi,
22
+ /(?:\bmtl\b\.?|\bmonatl\b\.?|\bmonatlich\w*)\s*(?:ab\s+)?(?:\d[\d.,]*\s*(?:€|\beur\b|\beuro\b)?)?/gi,
23
+ /(?:€|\beur\b|\beuro\b)?\s*(?:\/\s*|\bpro\s+|\bim\s+)monat\b/gi,
24
+ // finance words
25
+ /\beffektiv(?:er|en)?\s+jahreszins\w*/gi,
26
+ /\b(?:finz\b\.?|finanz\w*|ballonfinanz\w*|zins\w*|sollzins\w*|jahreszins\w*|schlussrate\w*|monatsrate\w*|raten?|leasing\w*|leasen|anzahlung\w*|kredit\w*|darlehen\w*|santander)\b\.?/gi,
27
+ ];
28
+
29
+ // Broader than the stripping patterns (substrings, glued words like "AMGFinanzierung").
30
+ const FINANCE_CLAIM = /finanz|\bfinz\b|zins|leasing|leasen|santander|darlehen|kredit|anzahlung|schlussrate|monatsrate|\bmtl\b|monatl|\braten?\b|\/\s*monat|pro\s+monat|im\s+monat/i;
31
+
32
+ const CUT = '\u0000';
33
+ const SEPARATORS = '[\\s/,;|+&\\-–]*';
34
+ const CUT_WITH_SEPARATORS = new RegExp(`${SEPARATORS}${CUT}(?:${SEPARATORS}${CUT})*${SEPARATORS}`, 'g');
35
+
36
+ // Only the word right before the number or right after the "%" counts ("Akku 95%", "99% SoH"), so
37
+ // "Elektromotor V 0,0%" is still a rate.
38
+ const isFinancePercent = (match: string, offset: number, text: string): boolean => {
39
+ const before = /([^\s/,;|]*)[\s/,;|]*$/.exec(text.slice(0, offset))[1];
40
+ const after = /^[\s/,;|]*([^\s/,;|]*)/.exec(text.slice(offset + match.length))[1];
41
+ return !NON_FINANCE_PERCENT_CONTEXT.test(before) && !NON_FINANCE_PERCENT_CONTEXT.test(after);
42
+ };
43
+
44
+ // Finance tokens out, together with the separators around them ("SB 4,49%/AMG" -> "SB AMG").
45
+ export const stripFinanceClaims = (text: string): string => {
46
+ let out = text || '';
47
+ for (const pattern of FINANCE_TOKENS) {
48
+ out = out.replace(pattern, CUT);
49
+ }
50
+ out = out.replace(PERCENT, (match: string, offset: number, whole: string) => (isFinancePercent(match, offset, whole) ? CUT : match));
51
+ return out.includes(CUT) ? out.replace(CUT_WITH_SEPARATORS, ' ').replace(/\s+/g, ' ').trim() : text || '';
52
+ };
53
+
54
+ export const hasFinanceClaim = (text: string): boolean => {
55
+ const value = text || '';
56
+ if (FINANCE_CLAIM.test(value)) {
57
+ return true;
58
+ }
59
+ return [...value.matchAll(PERCENT)].some(match => isFinancePercent(match[0], match.index, value));
60
+ };
@@ -9,6 +9,7 @@ import {
9
9
  OpenAiAdsRow,
10
10
  } from '../interfaces/openai-ads.interface';
11
11
  import { OpenAiImageFields, encodeCommasInUrl } from './openai-image-fields';
12
+ import { hasFinanceClaim, stripFinanceClaims } from './openai-ads-finance';
12
13
 
13
14
  // OpenAI product file limits (guidance): title ≤ 150, description ≤ 5,000 characters.
14
15
  export const OPENAI_TITLE_MAX_LENGTH = 150;
@@ -51,7 +52,8 @@ export interface OpenAiAdsMappingContext {
51
52
  export type OpenAiAdsBaseRow = Omit<OpenAiAdsRow, 'image_url' | 'additional_image_urls'>;
52
53
 
53
54
  export type OpenAiAdsMapResult =
54
- | { ok: true; row: OpenAiAdsBaseRow; outOfStockReason?: OpenAiAdsOutOfStockReason }
55
+ // financeTermsRemoved: the dealer variant text carried finance wording that was cut from the title
56
+ | { ok: true; row: OpenAiAdsBaseRow; outOfStockReason?: OpenAiAdsOutOfStockReason; financeTermsRemoved?: boolean }
55
57
  | { ok: false; reason: OpenAiAdsExclusion };
56
58
 
57
59
  /**
@@ -98,8 +100,19 @@ export const mapOpenAiAdsVehicle = (car: DataSchemes.Car, ctx: OpenAiAdsMappingC
98
100
  is_ads_eligible: true,
99
101
  };
100
102
 
103
+ // fail-closed: whatever finance wording survived the stripping keeps the car out of the feed
104
+ if (hasFinanceClaim(row.title) || hasFinanceClaim(row.description)) {
105
+ return { ok: false, reason: OpenAiAdsExclusion.financeClaim };
106
+ }
107
+
101
108
  const outOfStockReason = OFFER_AVAILABILITY_OUT_OF_STOCK[toString(get(car, 'offer.offerAvailability', ''))];
102
- return outOfStockReason ? { ok: true, row, outOfStockReason } : { ok: true, row };
109
+ const financeTermsRemoved = title !== buildOpenAiTitle(car, { keepFinanceTerms: true });
110
+ return {
111
+ ok: true,
112
+ row,
113
+ ...(outOfStockReason ? { outOfStockReason } : {}),
114
+ ...(financeTermsRemoved ? { financeTermsRemoved } : {}),
115
+ };
103
116
  };
104
117
 
105
118
  export const withOpenAiImages = (row: OpenAiAdsBaseRow, images: OpenAiImageFields): OpenAiAdsRow => ({
@@ -112,11 +125,22 @@ export const withOpenAiImages = (row: OpenAiAdsBaseRow, images: OpenAiImageField
112
125
  // The same vehicle, switched off: kept in the file so OpenAI stops delivering it instead of
113
126
  // relying on the row disappearing (not documented as a removal).
114
127
  export const asOutOfStock = (row: OpenAiAdsRow): OpenAiAdsRow => ({
115
- ...row,
128
+ ...withoutFinanceClaims(row),
116
129
  availability: 'out_of_stock',
117
130
  is_ads_eligible: false,
118
131
  });
119
132
 
133
+ // Rows re-sent from the ledger may predate the finance rule: strip again, and if wording is still
134
+ // left, fall back to the brand alone (the row only switches the item off).
135
+ export const withoutFinanceClaims = <T extends { title: string; description: string; brand: string }>(row: T): T => {
136
+ const title = stripFinanceClaims(row.title);
137
+ const description = stripFinanceClaims(row.description);
138
+ if (!hasFinanceClaim(title) && !hasFinanceClaim(description)) {
139
+ return title === row.title && description === row.description ? row : { ...row, title, description };
140
+ }
141
+ return { ...row, title: row.brand, description: row.brand };
142
+ };
143
+
120
144
  // Purchase price (price.currentSalesPrice, EUR gross) in major units with exactly two decimals and
121
145
  // no thousands separator: 17990 -> "17990.00 EUR". Never a monthly financing/leasing rate.
122
146
  // null = not a valid positive price; the car is not exported.
@@ -140,10 +164,12 @@ export const toOpenAiMinorUnits = (value: unknown): number | null => {
140
164
  // "<make> <model> <variant>" as delivered (German market names), dealer junk punctuation removed
141
165
  // with the same normalizer as the Meta feed, cut at a word boundary. No HTML escaping: CSV quoting
142
166
  // handles every character. A placeholder model (SONSTIGE) is left out ("MG 1.5 Hybrid Luxury");
143
- // with neither a real model nor a variant there is no title and the car is not sent.
144
- export const buildOpenAiTitle = (car: DataSchemes.Car): string => {
167
+ // with neither a real model nor a variant there is no title and the car is not sent. Finance wording
168
+ // in the dealer text ("4,49%Finz.", "0% Zins") is cut first (openai-ads-finance.ts).
169
+ export const buildOpenAiTitle = (car: DataSchemes.Car, options: { keepFinanceTerms?: boolean } = {}): string => {
145
170
  const [make, model, subModel] = ['mainData.make', 'mainData.model', 'mainData.subModel']
146
- .map(path => repairMojibake(trim(toString(get(car, path, '')))));
171
+ .map(path => repairMojibake(trim(toString(get(car, path, '')))))
172
+ .map(part => (options.keepFinanceTerms ? part : stripFinanceClaims(part)));
147
173
  const realModel = PLACEHOLDER_NAMES.test(model) ? '' : model;
148
174
  if (!normalizeFeedText(`${realModel} ${subModel}`)) {
149
175
  return '';