@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.
- package/dist/configs/openai-ads.config.js +2 -2
- package/dist/configs/openai-ads.config.js.map +1 -1
- package/dist/exporters/openai-ads.exporter.d.ts +3 -0
- package/dist/exporters/openai-ads.exporter.js +13 -2
- package/dist/exporters/openai-ads.exporter.js.map +1 -1
- package/dist/interfaces/openai-ads.interface.d.ts +3 -1
- package/dist/interfaces/openai-ads.interface.js +2 -0
- package/dist/interfaces/openai-ads.interface.js.map +1 -1
- package/dist/services/openai-ads-feed-run.d.ts +3 -1
- package/dist/services/openai-ads-feed-run.js +7 -2
- package/dist/services/openai-ads-feed-run.js.map +1 -1
- package/dist/transformers/openai-ads-finance.d.ts +2 -0
- package/dist/transformers/openai-ads-finance.js +39 -0
- package/dist/transformers/openai-ads-finance.js.map +1 -0
- package/dist/transformers/openai-ads.transformer.d.ts +9 -1
- package/dist/transformers/openai-ads.transformer.js +25 -5
- package/dist/transformers/openai-ads.transformer.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/openai-ads/AUDIT.md +5 -4
- package/docs/openai-ads/RUNBOOK.md +36 -3
- package/package.json +2 -2
- package/src/configs/openai-ads.config.ts +4 -4
- package/src/exporters/openai-ads.exporter.ts +18 -1
- package/src/interfaces/openai-ads.interface.ts +4 -0
- package/src/services/openai-ads-feed-run.ts +12 -4
- package/src/transformers/openai-ads-finance.ts +60 -0
- package/src/transformers/openai-ads.transformer.ts +32 -6
- package/test/fixtures/openai-ads-finance-titles.json +325 -0
- package/test/openai-ads-feed-run.test.ts +20 -1
- package/test/openai-ads-finance.test.ts +109 -0
- package/test/openai-ads-price-cap.test.ts +69 -0
package/docs/openai-ads/AUDIT.md
CHANGED
|
@@ -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,
|
|
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 |
|
|
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
|
|
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
|
|
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
|
|
37
|
+
## Cronicle event
|
|
38
38
|
|
|
39
|
-
|
|
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` |
|
|
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.
|
|
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
|
-
//
|
|
54
|
-
// maxCurrentSalesPrice param
|
|
55
|
-
|
|
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 =>
|
|
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,
|
|
34
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 '';
|