@bytebrand/export-cars 1.26.28 → 1.26.30
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/README.md +100 -0
- package/dist/configs/index.d.ts +1 -0
- package/dist/configs/index.js +1 -0
- package/dist/configs/index.js.map +1 -1
- package/dist/configs/openai-ads.config.d.ts +22 -0
- package/dist/configs/openai-ads.config.js +56 -0
- package/dist/configs/openai-ads.config.js.map +1 -0
- package/dist/enums/export-type.enum.d.ts +2 -1
- package/dist/enums/export-type.enum.js +1 -0
- package/dist/enums/export-type.enum.js.map +1 -1
- package/dist/enums/image-type.enum.d.ts +2 -1
- package/dist/enums/image-type.enum.js +1 -0
- package/dist/enums/image-type.enum.js.map +1 -1
- package/dist/export.schema.d.ts +6 -2
- package/dist/export.schema.js +4 -0
- package/dist/export.schema.js.map +1 -1
- package/dist/exporters/index.d.ts +1 -0
- package/dist/exporters/index.js +1 -0
- package/dist/exporters/index.js.map +1 -1
- package/dist/exporters/openai-ads.exporter.d.ts +13 -0
- package/dist/exporters/openai-ads.exporter.js +91 -0
- package/dist/exporters/openai-ads.exporter.js.map +1 -0
- package/dist/formats/openai-ads-csv.builder.d.ts +15 -0
- package/dist/formats/openai-ads-csv.builder.js +48 -0
- package/dist/formats/openai-ads-csv.builder.js.map +1 -0
- package/dist/interfaces/openai-ads.interface.d.ts +35 -0
- package/dist/interfaces/openai-ads.interface.js +37 -0
- package/dist/interfaces/openai-ads.interface.js.map +1 -0
- package/dist/interfaces/params.interface.d.ts +10 -0
- package/dist/services/feed-guard.d.ts +44 -0
- package/dist/services/feed-guard.js +156 -0
- package/dist/services/feed-guard.js.map +1 -0
- package/dist/services/image-url-validator.service.d.ts +45 -0
- package/dist/services/image-url-validator.service.js +141 -0
- package/dist/services/image-url-validator.service.js.map +1 -0
- package/dist/services/images.service.d.ts +12 -0
- package/dist/services/images.service.js +46 -1
- package/dist/services/images.service.js.map +1 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/openai-ads-feed-run.d.ts +74 -0
- package/dist/services/openai-ads-feed-run.js +151 -0
- package/dist/services/openai-ads-feed-run.js.map +1 -0
- package/dist/services/openai-ads-ledger.d.ts +19 -0
- package/dist/services/openai-ads-ledger.js +72 -0
- package/dist/services/openai-ads-ledger.js.map +1 -0
- package/dist/services/publication-gate.js +8 -8
- package/dist/transformers/openai-ads.transformer.d.ts +29 -0
- package/dist/transformers/openai-ads.transformer.js +166 -0
- package/dist/transformers/openai-ads.transformer.js.map +1 -0
- package/dist/transformers/openai-image-fields.d.ts +16 -0
- package/dist/transformers/openai-image-fields.js +34 -0
- package/dist/transformers/openai-image-fields.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/openai-ads/AUDIT.md +235 -0
- package/docs/openai-ads/RUNBOOK.md +94 -0
- package/package.json +64 -64
- package/schema/feed-items.json +134 -134
- package/src/configs/config.ts +99 -99
- package/src/configs/index.ts +1 -0
- package/src/configs/openai-ads.config.ts +70 -0
- package/src/enums/export-type.enum.ts +2 -0
- package/src/enums/image-type.enum.ts +5 -0
- package/src/export.schema.ts +6 -0
- package/src/exporters/index.ts +1 -0
- package/src/exporters/openai-ads.exporter.ts +93 -0
- package/src/formats/openai-ads-csv.builder.ts +55 -0
- package/src/interfaces/openai-ads.interface.ts +64 -0
- package/src/interfaces/params.interface.ts +13 -0
- package/src/services/feed-guard.ts +219 -0
- package/src/services/image-url-validator.service.ts +181 -0
- package/src/services/images.service.ts +72 -0
- package/src/services/index.ts +1 -0
- package/src/services/openai-ads-feed-run.ts +226 -0
- package/src/services/openai-ads-ledger.ts +116 -0
- package/src/services/publication-gate.ts +119 -119
- package/src/transformers/openai-ads.transformer.ts +243 -0
- package/src/transformers/openai-image-fields.ts +60 -0
- package/test/clean-images.test.ts +86 -0
- package/test/fixtures/images-service.harness.ts +32 -0
- package/test/fixtures/images.golden.json +218 -0
- package/test/fixtures/imbo-images.fixture.ts +77 -0
- package/test/fixtures/openai-ads.fixture.ts +89 -0
- package/test/image-url-validator.test.ts +110 -0
- package/test/images-regression.test.ts +23 -0
- package/test/openai-ads-feed-run.test.ts +278 -0
- package/test/openai-ads-mapping.test.ts +157 -0
- package/test/openai-image-fields.test.ts +99 -0
- package/test/publication-gate.test.ts +86 -86
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# ADV-335 — OpenAI Ads vehicle product feed: codebase audit + field mapping
|
|
2
|
+
|
|
3
|
+
Status: audit 2026-10-09; first implementation 2026-10-12 on branch `feat/adv-335-openai-ads-feed` (stacked on
|
|
4
|
+
`feat/adv-336-openai-images`), see §7. Nothing uploaded, no Cronicle/Cloudflare/server change, not published.
|
|
5
|
+
Owner of this pass: Volodymyr. Review: Viktoriia. Sibling tickets: ADV-336 (image_url), ADV-337 (tracking `contents[].id`).
|
|
6
|
+
|
|
7
|
+
Paths are repo-relative. `export-cars` = `cronicle/cronicle-plugins/packages/export-cars`.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 0. Base branch — read this first
|
|
12
|
+
|
|
13
|
+
| Branch | export-cars version | Has |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `origin/deploy_prod` (a189de13, 06.10) | 1.26.19 | no test runner |
|
|
16
|
+
| `origin/deploy_develop` (4221698b, 09.10) | **1.26.28** | `npm test` (ts-node), `publication-gate.ts`, `feed-items-bigquery.writer.ts`, EnVKV helpers |
|
|
17
|
+
| `origin/autobahn` (ec9f115d, 17.09) | 1.26.21 | stale |
|
|
18
|
+
|
|
19
|
+
npm has one version stream. The OpenAI exporter must be built on `deploy_develop` (next version 1.26.29), otherwise a publish
|
|
20
|
+
silently reverts the EnVKV/Google work (same failure mode as the 1.26.10 AIA-image incident). Line numbers below refer to
|
|
21
|
+
`deploy_develop` unless stated.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 1. Official OpenAI specs (read 2026-10-09)
|
|
26
|
+
|
|
27
|
+
Sources: developers.openai.com/commerce/specs/file-upload/products, developers.openai.com/ads/product-feeds,
|
|
28
|
+
help.openai.com/en/articles/20001268. The products page shows **no Stable/Draft label** on 09.10; it has two profiles:
|
|
29
|
+
"OpenAI format" and "Google-compatible" (the latter only after OpenAI confirms it for the registered feed).
|
|
30
|
+
|
|
31
|
+
Facts that drive the design:
|
|
32
|
+
|
|
33
|
+
- Required per row (OpenAI format): `item_id, title (≤150), description (≤5000, plain text), url, brand, seller_name, image_url, availability, price`.
|
|
34
|
+
- `availability` ∈ `in_stock | out_of_stock | pre_order | backorder | unknown`; empty/unknown value **rejects the row**.
|
|
35
|
+
- `price` = `"<amount> <ISO4217>"`, major units, decimal point, no thousands separator → `17990.00 EUR`.
|
|
36
|
+
- `condition` ∈ `new | refurbished | used`; **omitted may be treated as `new`** → must always be sent for used cars.
|
|
37
|
+
- `is_ads_eligible`: Ads Manager feeds are ads-eligible by default unless a row says `false`.
|
|
38
|
+
- `additional_image_urls`: CSV = one quoted comma-separated cell, commas inside URLs as `%2C`.
|
|
39
|
+
- `ads_metadata`: object of strings, keys must match the Ads integration setup ("don't invent them"); JSON-serialised in a CSV cell.
|
|
40
|
+
- `gtin` / `mpn`: optional, never invent. No VIN column exists.
|
|
41
|
+
- CSV: UTF-8, header row, quote cells with comma/quote/newline, double embedded quotes. Placeholder strings (`null`, `n/a`) forbidden.
|
|
42
|
+
- **Markets: "Standard uploads use `["US"]` regardless"; `target_countries` shared format currently `US, CA, MX`; `store_country` = `US`.**
|
|
43
|
+
Campaign geo targeting is country-level. → **DE/EUR is not supported by the documented standard path. Gate = BLOCKED until OpenAI confirms in writing.**
|
|
44
|
+
- Import: Ads Manager "Upload CSV or TXT" / "Hosted URL" (stable HTTPS) / "SFTP". **Items expire after 2 weeks.**
|
|
45
|
+
- SFTP (developer guide): files in SFTP **root** only (`invalid_sftp_directory_layout` otherwise); password or `ssh_key`;
|
|
46
|
+
transfer success ≠ processed; one product once across all files. Atomic-upload behaviour not documented.
|
|
47
|
+
- Sold/removed: send `availability=out_of_stock` (full upload or delta). Docs do **not** say that omitting a row removes it.
|
|
48
|
+
- Delta: `PATCH https://api.ads.openai.com/v1/feeds/{feed_id}/products`, Advertiser API key (Bearer, account-scoped),
|
|
49
|
+
price as `{"amount": 1799000, "currency": "EUR"}` (minor units), availability `{"status": "out_of_stock"}`.
|
|
50
|
+
- Monitoring API: `GET /v1/feeds/uploads` (`status`, `rows_accepted`, `rows_rejected`, `rows_ads_eligible`, `diagnostics[]`),
|
|
51
|
+
`GET /v1/feeds?include[]=product_count`. Needs API access — otherwise Upload History in the UI only.
|
|
52
|
+
- Crawlers: OAI-AdsBot must reach the landing page (VDP), OAI-SearchBot must reach catalog images.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 2. Current code paths (what runs today)
|
|
57
|
+
|
|
58
|
+
### 2.1 Chain
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
Cronicle "1.0 Base export" (base-cars-export) daily, prod ~16:00
|
|
62
|
+
└─ src/index.ts:20 emptyDir(resources/cars-export) <- wiped BEFORE fetch
|
|
63
|
+
└─ CarBasicDataCarsLoader (libs/packages/chunkable/src/chunkable/loader/CarBasicDataCarsLoader.ts:47-73)
|
|
64
|
+
GET car-basic-data /api/v1/car?limit&offset (5 retries) -> CBD CarService.getCars: query {'state.isOnline': true}
|
|
65
|
+
per car: image-imbo getCleanedCarImages (only images with image_code_predicted; error -> [] silently)
|
|
66
|
+
writes resources/cars-export/cars.{n}.json = [{car, images}] (page 400)
|
|
67
|
+
└─ chained exports (export-cars, one Cronicle event per exportType)
|
|
68
|
+
src/export.command.ts:43-46 exportSchema[params.exportType] -> config + exporter
|
|
69
|
+
LoaderService -> FsCarsLoader (reads cars.*.json; empty dir = 0 cars, NOT an error)
|
|
70
|
+
Exporter: ensureDir + emptyDir(output dir) -> filterCar -> transformer -> builder
|
|
71
|
+
└─ "part 2" upload / nginx `feeds` site on ftp.auto.de (server-side, not in repo)
|
|
72
|
+
https://ftp.auto.de/feeds/api_export_cars_{fb,facebookshop}.xml (nginx alias to /opt/cronicle/resources/*-export)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 2.2 Source of truth per concept
|
|
76
|
+
|
|
77
|
+
| Concept | Where | Notes |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| Inventory included | CBD `state.isOnline === true` (`services/car-basic-data/src/services/car/CarService.ts:110-113`) | only filter of the base export |
|
|
80
|
+
| Forced offline | `CarService.calculateIsOnline` (`:1271-1297`): blacklisted dealer, mileage > 80k, price < min, damaged, EZ > 5 y | skipped when validation disabled |
|
|
81
|
+
| Sold (CRM) | odoo-sync `updateCarOnWon` → `isOnline:false`, `offer.offerSource=selector_offerSource_won`; lost → online again | `services/odoo-sync/src/leads/car.service.ts:73-134` |
|
|
82
|
+
| Reserved / sold flag on an online car | `offer.offerAvailability` ∈ `_reserved, _sold, _arrived, _inPreparation, _new, unknown` | display-only today, no filter anywhere |
|
|
83
|
+
| Deleted | `CarService.deleteCar` hard-deletes from `cars`, copies to car archive | disappears from base export |
|
|
84
|
+
| Dealer permission | none at export time; CBD offlines blacklisted dealers' cars; UPA profile has `active`, `blacklist` (`libs/packages/autode-request/src/interfaces/DealerProfileInterface.ts:27-28`) | `CarService.getOwner` exists (cached, retried) |
|
|
85
|
+
| Price | `price.currentSalesPrice` (EUR gross, number) | Google/Meta cap 80k via `maxCurrentSalesPrice` (`car.service.ts` filterCar) |
|
|
86
|
+
| Monthly rate | `rate.*`, `leasing.*`, fe-financing | **must never reach `price`** |
|
|
87
|
+
| Canonical ID | `car._id` (UUID, VDP id). ADV-200 made fb/facebookshop use it; GA4 `item_id = car._id` (`services/fe-main/source/services/GoogleTagManager.ts:195-221`) | short `metaData.vehicleId` is a different id |
|
|
88
|
+
| VDP URL | fe-main route `/search/vehicle/:vehicleID` (`services/fe-main/source/AppRouter.tsx:54`); public CBD GET returns 404 for offline cars | builders inconsistent: `www.` in google/fb, none in facebook-shop/`TransformerService.link` |
|
|
89
|
+
| Photos | `ImagesService.generateImageLinks` → `https://images.auto.de/carimage/{_id}/{imageIdentifier}/{large|small}` (`images.service.ts:172-175`), no HEAD check | Google uses `small`; sort order `IMAGES_SORT_ORDER` |
|
|
90
|
+
| Marketing images | Meta AIA `https://images.auto.de/generated_images/{a}/{b}/{c}/{_id}/meta-aia-financing-{imageId}.jpg`, HEAD-checked (`images.service.ts:220-259`) | **carries a financing overlay → not usable for OpenAI without approval** |
|
|
91
|
+
|
|
92
|
+
### 2.3 Existing exporters used as reference
|
|
93
|
+
|
|
94
|
+
| | Google (`google.transformer.ts`) | Meta AMA (`facebook-shop.transformer.ts`) | Meta AIA (`fb.transformer.ts`) |
|
|
95
|
+
|---|---|---|---|
|
|
96
|
+
| id | `car._id` | `car._id` (ADV-200) | `car._id` (ADV-200) |
|
|
97
|
+
| title | brand(+Mercedes→"Mercedes", VW→"VW") model year subModel, `lodash.escape`, 100 chars | finance offer text ("… finanzieren – z. B. ab … €/Monat") | year make model subModel, `normalizeFeedText` |
|
|
98
|
+
| price | `` `${round(p,2)} EUR` `` → `17990 EUR` (no `.00`) | same | `` `${p} EUR` `` |
|
|
99
|
+
| availability | hardcoded `in_stock` | `state.isOnline` → always AVAILABLE (only online cars in source) | same |
|
|
100
|
+
| condition | new/used, unknown→Used | — | unknown→Used |
|
|
101
|
+
| VIN | `metaData.vin` else **`marketing.fakeVIN`** | — | same |
|
|
102
|
+
| description | dealer summary template via `car-description` (HTML `<p>`) | offer text | structured blocks |
|
|
103
|
+
| image | real photos (`small`) | Meta AIA overlay | Meta AIA overlay |
|
|
104
|
+
| price cap | 80k | 80k | 80k |
|
|
105
|
+
|
|
106
|
+
### 2.4 CSV builders (asked to be reused) — do not reuse
|
|
107
|
+
|
|
108
|
+
- `formats/awin-csv.builder.ts:7-9` joins values with `","` **without escaping** embedded `"` or newlines.
|
|
109
|
+
- `formats/santander-csv.builder.ts:7-17` uses `;`, quotes only when a comma occurs, escapes quotes only then.
|
|
110
|
+
- `utils/converter.utils.ts` `convertToCSV` uses `;`.
|
|
111
|
+
Neither is RFC 4180. OpenAI needs a new, small, tested writer (always quote, double `"`, keep `\n` inside quotes, UTF-8 without BOM — BOM to be confirmed).
|
|
112
|
+
|
|
113
|
+
### 2.5 Guards that do NOT exist (all exporters)
|
|
114
|
+
|
|
115
|
+
- base export empties `resources/cars-export` first; on CBD outage the dir is empty/partial and downstream exports
|
|
116
|
+
write an empty feed (24.09 incident: 169-byte Google feed fetched by Google at 14:00).
|
|
117
|
+
- `FsCarsLoader`: empty dir = success with 0 cars.
|
|
118
|
+
- `CarBasicDataCarsLoader`: malformed page (`pagination` missing) ends the run as success.
|
|
119
|
+
- every exporter `emptyDir`s its output dir before writing (non-atomic), no count threshold, no last-good, no lock,
|
|
120
|
+
no freshness metadata. No SFTP client anywhere (`basic-ftp` only, no temp-name/rename).
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## 3. Field mapping: source → OpenAI (OpenAI format, CSV)
|
|
125
|
+
|
|
126
|
+
Null policy "EXCLUDE" = row not written + counted in the run report under the reason code.
|
|
127
|
+
|
|
128
|
+
| OpenAI field | Req. | Source path | Transform / format | Null / invalid policy | Test case |
|
|
129
|
+
|---|---|---|---|---|---|
|
|
130
|
+
| `item_id` | R | `car._id` | string as-is (UUID) | missing → EXCLUDE `no_id`; duplicate in run → keep first, count `duplicate_id` | dup id; id equals GA4 `item_id` |
|
|
131
|
+
| `title` | R | `mainData.make`, `mainData.model`, `mainData.subModel` | `normalizeFeedText(make model subModel)`, German as delivered, ≤150 at word boundary, no HTML escape; placeholder model `SONSTIGE` left out; UTF-8-read-as-Latin-1/cp1252 (`ü`) repaired (1.26.30) | neither a real model nor a variant → EXCLUDE `no_title` | umlauts, `*AHK*Leder*`, `&`, 200-char subModel |
|
|
132
|
+
| `description` | R | structured fields only — NOT the dealer summary templates of the Google feed (free text, finance claims unverifiable, Q6) | `Gebrauchtwagen: <title>. Erstzulassung: 03/2021 · Kilometerstand: 45.000 km · Kraftstoff: Benzin · Getriebe: Manuell · Leistung: 110 kW (150 PS) · Karosserie · Außenfarbe`, ≤5000, real mileage (no 901/1001 floors) | facts without a German label are left out | sparse car, no `selector_*` leaks |
|
|
133
|
+
| `url` | R | `car._id` | `https://www.{domain}/search/vehicle/{_id}` (same as Google/fb; no UTM — tracking via ADV-337) | always set | url host per env |
|
|
134
|
+
| `brand` | R | `mainData.make` | raw manufacturer name (NOT Google's "Mercedes"/"VW" rewrite) | empty → EXCLUDE | Mercedes-Benz stays Mercedes-Benz |
|
|
135
|
+
| `seller_name` | R | config (Q3) | default `auto.de` (or legal `auto.de Handels GmbH`); option dealer name from UPA | never empty | — |
|
|
136
|
+
| `image_url` | R | ADV-336 clean profile: `https://images.auto.de/carimage/{_id}/{imageIdentifier}/large-cached.jpeg` (explicit JPEG; bare `/large` may negotiate WebP) | 3/4 hero (`ext_f_right`, else `ext_f_left`), every URL HEAD-checked (200 + jpeg/png) | no valid 3/4 hero → EXCLUDE `no_image` (no placeholders) | broken hero, interior-only car |
|
|
137
|
+
| `availability` | R | `state.isOnline` (= in source), `offer.offerAvailability`, ledger of previously sent ids | in source + not reserved/sold → `in_stock`; in source + `_reserved`/`_sold` → `out_of_stock`; sent before but missing now → `out_of_stock` for the retention window (≥14 days) | never `unknown`, never empty | sold, reserved, deleted, reinstated |
|
|
138
|
+
| `price` | R | `price.currentSalesPrice` | `toFixed(2) + ' EUR'` → `17990.00 EUR`; never `rate.*`/`leasing.*` | not finite or ≤0 → EXCLUDE `invalid_price` (out_of_stock rows reuse the last sent price) | 0, null, "17990", 17990.5 |
|
|
139
|
+
| `condition` | O (send always) | `mainData.condition` | `selector_condition_new`→`new`, `_used`→`used`; unknown → `used` only with a past first registration | else EXCLUDE `condition_unknown` (Q5 still open) | unknown condition |
|
|
140
|
+
| `is_ads_eligible` | C | computed | `true` for `in_stock`, `false` for `out_of_stock` | — | — |
|
|
141
|
+
| `additional_image_urls` | O | ADV-336: all other valid ext + int photos, no count limit | comma-joined in one cell, `,`→`%2C` inside URL | empty cell when none | 0/1/15 photos |
|
|
142
|
+
| `ads_metadata` | O | only keys configured in Ads Manager (Q7), e.g. `make`, `model`, `body_type`, `price_band` | JSON object of strings in one cell | off by default (job param) | JSON quoting in CSV |
|
|
143
|
+
| `product_category` | O | constant | e.g. `Fahrzeuge > Pkw > Gebrauchtwagen` (Q8, may be omitted) | — | — |
|
|
144
|
+
| `color` | O | `exterior.exteriorColor` | not a column in v1 (colour is in the description) | — | — |
|
|
145
|
+
| `gtin`, `mpn`, VIN, `sale_price`, shipping, returns, reviews, `group_id`, `target_countries`, `store_country` | — | — | **not sent** (no `fakeVIN`, no invented ids; markets via account setup) | — | header contains none of them |
|
|
146
|
+
|
|
147
|
+
Delta (deferred, only with API access): `products[].id = item_id`, price `{"amount": round(currentSalesPrice*100), "currency":"EUR"}`.
|
|
148
|
+
|
|
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.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 4. Gaps vs. ticket requirements
|
|
155
|
+
|
|
156
|
+
| Requirement | Today | Needed |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| explicit `out_of_stock` for sold/removed | source only has online cars; sold/deleted cars vanish | ledger of sent `item_id`s (+ last row) kept in the job's state dir; missing ids re-emitted as `out_of_stock` for N days, reinstated id → `in_stock` with same id |
|
|
159
|
+
| reserved | `offer.offerAvailability` ignored everywhere | map `_reserved`/`_sold` → `out_of_stock` (decision Q4) |
|
|
160
|
+
| fail-closed | none | shared guard: source freshness (max age of `cars.*.json`), source count vs last-good (max drop %), absolute minimum, write to temp + rename, keep last-good, run report JSON |
|
|
161
|
+
| CSV correctness | Awin/Santander writers not RFC 4180 | new writer + tests |
|
|
162
|
+
| transport | nginx `feeds` site (server, not in repo) serves 2 Meta XMLs; no SFTP | hosted: add one `location =` for the CSV (server change, approval) or SFTP: new dep `ssh2-sftp-client`, root upload, key from secret store |
|
|
163
|
+
| monitoring | stdout counters only | run report (counts per availability/exclusion reason, duplicates, max price, freshness) + optional BQ rows via existing writer pattern; upload status only with API access |
|
|
164
|
+
| dry-run | n/a | `openAiAdsDryRun` (default `true`): file written, nothing published |
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## 5. Blockers / open questions (owner)
|
|
169
|
+
|
|
170
|
+
| # | Question | Owner | Blocks |
|
|
171
|
+
|---|---|---|---|
|
|
172
|
+
| Q1 | **DE market + EUR**: docs say standard uploads are US-only (`target_countries` US/CA/MX). Written confirmation that the auto.de account can run DE/EUR feed campaigns. | Marketing → OpenAI | go-live (BLOCKED) |
|
|
173
|
+
| Q2 | Used-car / automotive inventory allowed in the account (docs mention retail only)? | Marketing → OpenAI | go-live (BLOCKED) |
|
|
174
|
+
| Q3 | `seller_name`: `auto.de`, `auto.de Handels GmbH` or the dealer? Who is the contractual seller in checkout? | Marketing/Legal | first test file |
|
|
175
|
+
| Q4 | `reserved` (and `sold` flag on still-online cars) → `out_of_stock`? | PM | availability logic |
|
|
176
|
+
| Q5 | `condition` unknown: send `used` (as Google/Meta do) or exclude? | PM | mapping |
|
|
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
|
+
| Q7 | `ads_metadata` keys configured in Ads Manager (if any) | Marketing | optional |
|
|
179
|
+
| Q8 | `product_category` wanted? | Marketing | optional |
|
|
180
|
+
| Q9 | Apply the 80k price cap of Google/Meta? | Marketing | filter |
|
|
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
|
+
| 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
|
+
| 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
|
+
| Q13 | Is a "Stable" vs "Draft" schema selector visible in your docs/account? The public page shows none on 09.10. | Marketing | — |
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## 6. Proposed implementation (after OK on this audit)
|
|
189
|
+
|
|
190
|
+
Branch `feat/adv-335-openai-ads-feed` from `origin/deploy_develop`, export-cars 1.26.29. All new files, existing exporters untouched
|
|
191
|
+
except one enum value + one `export.schema.ts` entry.
|
|
192
|
+
|
|
193
|
+
- `src/enums/export-type.enum.ts`: `OPENAI_ADS = 'openai_ads'`
|
|
194
|
+
- `src/configs/openai-ads.config.ts`: output `resources/openai_ads-export/api_export_cars_openai_ads.csv`, state dir, guard thresholds, dry-run flag
|
|
195
|
+
- `src/transformers/openai-ads.transformer.ts`: pure mapping functions (testable without Nest)
|
|
196
|
+
- `src/formats/openai-ads-csv.builder.ts`: RFC 4180 writer, fixed header
|
|
197
|
+
- `src/services/feed-guard.ts` (shared, opt-in): freshness, min count, max drop vs last-good, temp+rename, last-good copy, run report
|
|
198
|
+
- `src/services/openai-ads-ledger.ts`: sent-id ledger, out_of_stock retention, reinstatement
|
|
199
|
+
- `src/exporters/openai-ads.exporter.ts`
|
|
200
|
+
- `test/openai-ads-*.test.ts`: escaping/umlauts, price format, condition, availability (sold/reserved/deleted/reinstated), duplicate id, missing image, guard (outage, partial source, legit drop, recovery)
|
|
201
|
+
- `docs/openai-ads/RUNBOOK.md`
|
|
202
|
+
- Transport (SFTP or hosted) in a separate step after Q10. Delta PATCH deferred.
|
|
203
|
+
|
|
204
|
+
Remaining human steps (not engineering): Q1–Q13 answers, Cronicle event creation (dev first, dry-run), nginx/SFTP setup on prod Cronicle,
|
|
205
|
+
first manual test upload of 20 cars in Ads Manager, Upload History reconciliation.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## 7. Implementation state (2026-10-12)
|
|
210
|
+
|
|
211
|
+
Branch `feat/adv-335-openai-ads-feed`, stacked on `feat/adv-336-openai-images` (ADV-336 library fdc1d5aa, itself
|
|
212
|
+
based on `deploy_develop`). Local only: not pushed, not published. The export-cars version bump and the publish
|
|
213
|
+
belong to ADV-335 only (one bump for both tickets).
|
|
214
|
+
|
|
215
|
+
Done: `ExportType.OPENAI_ADS`, `OpenAiAdsConfig` (dry-run by default), mapping (§3), RFC 4180 writer, shared
|
|
216
|
+
`FeedGuard` (source empty/partial/stale, minimums, max drop vs last good, temp + rename, `.prev`, lock), ledger
|
|
217
|
+
for out_of_stock (items sent before that are sold/offline/deleted/now excluded, or online but reserved/sold-flag;
|
|
218
|
+
retention 15 days; reinstatement keeps the id), run report, `RUNBOOK.md`, 23 tests (`npm test`).
|
|
219
|
+
|
|
220
|
+
Open questions decided in code for now (each reversible by a param or a one-line change):
|
|
221
|
+
Q3 `seller_name` = `auto.de` (param); Q4 reserved/sold flag → out_of_stock, and never advertised when not sent before;
|
|
222
|
+
Q5 unknown condition → `used` only with a past first registration; Q6 description from structured data only;
|
|
223
|
+
Q9 no price cap unless `maxCurrentSalesPrice` is set; Q7/Q8 `ads_metadata` / `product_category` not sent.
|
|
224
|
+
|
|
225
|
+
Found while running the compiled job (`node dist/main.js` with a stdin job): **export-cars reports every failed
|
|
226
|
+
run as success.** nest-commander's default `serviceErrorHandler` only prints the error, and `main.ts` then
|
|
227
|
+
writes `{"complete":1,"code":0}`. This applies to all export types, Google and Meta included. The OpenAI exporter
|
|
228
|
+
fails the job itself (`exitError` + exit 1). The global fix is a separate task.
|
|
229
|
+
|
|
230
|
+
Not done (next steps / human):
|
|
231
|
+
- transport: hosted URL or SFTP, after Q10;
|
|
232
|
+
- Cronicle event: DEV dry-run first;
|
|
233
|
+
- a real DEV/PROD dry-run plus the 20-car reconciliation;
|
|
234
|
+
- delta PATCH: deferred, needs API access;
|
|
235
|
+
- 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*`.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# OpenAI Ads vehicle feed (`exportType=openai_ads`) — runbook
|
|
2
|
+
|
|
3
|
+
ADV-335. Contract version: **openai-ads-csv v1** (columns in `src/interfaces/openai-ads.interface.ts`).
|
|
4
|
+
Go-live is BLOCKED until the account gate (DE market, EUR, used-car inventory, import method) is confirmed in
|
|
5
|
+
writing — see `AUDIT.md` §5.
|
|
6
|
+
|
|
7
|
+
## What the job does
|
|
8
|
+
|
|
9
|
+
1. Reads the base export snapshot `resources/cars-export/cars.<n>.json` (written by "1.0 Base export";
|
|
10
|
+
only `state.isOnline` cars).
|
|
11
|
+
2. **Source guard** (before anything is written): chunk files exist, no gap in `0..n-1`, newest chunk not older
|
|
12
|
+
than `feedGuardMaxSourceAgeHours`.
|
|
13
|
+
3. Maps every car (`transformers/openai-ads.transformer.ts`), checks the dealer (UPA `active`/`blacklist`),
|
|
14
|
+
picks clean images (ADV-336: `large-cached.jpeg`, 3/4 hero first, every URL HEAD-checked).
|
|
15
|
+
4. Writes `<output>.tmp`: in_stock rows, then out_of_stock rows for items sent before (ledger).
|
|
16
|
+
5. **Count guard**: source and in_stock counts ≥ minimums and not more than `feedGuardMaxDropPercent` below the
|
|
17
|
+
last good run.
|
|
18
|
+
6. Publishes by rename (`<output>.prev` = previous file), saves the ledger and `last-good.json`.
|
|
19
|
+
7. Writes `last-run-report.json` and prints it as one `OpenAI ads feed report: {…}` log line.
|
|
20
|
+
|
|
21
|
+
A blocked or failed run exits with code 1 (`{"complete":1,"code":1,"description":…}`); the last good file
|
|
22
|
+
stays in place and `<output>.rejected` holds the file that was not published.
|
|
23
|
+
|
|
24
|
+
## Files
|
|
25
|
+
|
|
26
|
+
| Mode | Output (what a transport may publish) | State |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| dry-run (default) | `resources/openai_ads-dryrun/api_export_cars_openai_ads.csv` | `resources/openai_ads-state/dryrun/` |
|
|
29
|
+
| live (`openAiAdsDryRun=false`) | `resources/openai_ads-export/api_export_cars_openai_ads.csv` | `resources/openai_ads-state/export/` |
|
|
30
|
+
|
|
31
|
+
State dir: `ledger.json` (items sent in_stock, for out_of_stock), `last-good.json`, `last-run-report.json`,
|
|
32
|
+
`run.lock`. Dry-run and live never share state.
|
|
33
|
+
|
|
34
|
+
No transport is implemented yet. Nothing leaves the server until a hosted-URL location or an SFTP upload is set
|
|
35
|
+
up for the **live** output only (separate, approved step).
|
|
36
|
+
|
|
37
|
+
## Cronicle event (to be created by an admin — not done)
|
|
38
|
+
|
|
39
|
+
- Plugin "Export cars", chained after "1.0 Base export" (same as the Google/Meta exports), daily.
|
|
40
|
+
Items expire at OpenAI after 2 weeks without refresh — never rely on manual uploads.
|
|
41
|
+
- Concurrency 1 (the job also takes `run.lock`).
|
|
42
|
+
- Params (all text; unset = default):
|
|
43
|
+
|
|
44
|
+
| Param | Default | Meaning |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `exportType` | — | `openai_ads` |
|
|
47
|
+
| `openAiAdsDryRun` | dry-run | `false` = write the live output |
|
|
48
|
+
| `openAiAdsSellerName` | `auto.de` | `seller_name` (open question Q3) |
|
|
49
|
+
| `openAiAdsRetentionDays` | 15 | days an item stays as out_of_stock after it was last in stock |
|
|
50
|
+
| `openAiAdsImageConcurrency` | 16 | parallel image HEAD checks against images.auto.de |
|
|
51
|
+
| `maxCurrentSalesPrice` | none | optional price cap (no 80k default here, Q9) |
|
|
52
|
+
| `feedGuardMinSourceItems` | 1000 | DEV: set ~50 |
|
|
53
|
+
| `feedGuardMinPublishedItems` | 500 | DEV: set ~20 |
|
|
54
|
+
| `feedGuardMaxDropPercent` | 20 | vs the last good run |
|
|
55
|
+
| `feedGuardMaxSourceAgeHours` | 26 | base export runs daily |
|
|
56
|
+
| `feedGuardMaxStaleHours` | 48 | report flags `stale` after this |
|
|
57
|
+
| `feedGuardAcceptDrop` | false | `true` for ONE run after a confirmed real drop; remove afterwards |
|
|
58
|
+
| `String`, `Number`, `Date` | — | the usual rule params of the plugin (empty is fine) |
|
|
59
|
+
|
|
60
|
+
## Monitoring
|
|
61
|
+
|
|
62
|
+
- Cronicle job failure = feed not published (guard code in the description). Alert on failed job.
|
|
63
|
+
- `last-run-report.json`: `rows.inStock/outOfStock`, `excluded{reason:count}`, `outOfStock{reason:count}`,
|
|
64
|
+
`images{ok|http_status|…}`, `price.min/max`, `source.newestChunkAt`, `lastGood`, `stale`.
|
|
65
|
+
Watch for: `excluded.no_image` or `dealer_unknown` spikes, `price.max` jumps, `stale: true`.
|
|
66
|
+
- OpenAI side (manual until API access): Ads Manager → Tools → Feeds → Upload History (accepted / rejected /
|
|
67
|
+
diagnostics) and the ad-group product count. **Transfer success ≠ processed ≠ ads-eligible.**
|
|
68
|
+
The "Products" reporting tab is not an inventory view.
|
|
69
|
+
|
|
70
|
+
## Guard codes
|
|
71
|
+
|
|
72
|
+
| Code | Meaning | Action |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `source_empty` / `source_partial` | base export failed or still running | check "1.0 Base export", re-run it, then this job |
|
|
75
|
+
| `source_stale` | base export did not run | same |
|
|
76
|
+
| `source_too_small` / `published_too_small` | below the absolute minimum | check CBD / UPA / images.auto.de; never lower the minimum to "make it pass" |
|
|
77
|
+
| `count_drop` | > N % fewer cars than the last good run | confirm the drop is real (CBD online count, dealer offboarding), then ONE run with `feedGuardAcceptDrop=true` |
|
|
78
|
+
| `locked` | another run holds `run.lock` (< 6 h) | wait; a lock older than 6 h is taken over automatically |
|
|
79
|
+
|
|
80
|
+
## Rollback
|
|
81
|
+
|
|
82
|
+
- Bad content published: `cp <output>.prev <output>` (the previous good file), then disable the Cronicle event.
|
|
83
|
+
Note: the ledger already contains the bad run's in_stock items — they will be sent as out_of_stock later,
|
|
84
|
+
which is the safe direction.
|
|
85
|
+
- Stop advertising everything: disable the transport (hosted location / SFTP). Items expire at OpenAI after
|
|
86
|
+
2 weeks; to stop sooner, pause the campaigns in Ads Manager (Marketing).
|
|
87
|
+
- Code: the exporter is isolated (`ExportType.OPENAI_ADS` only); reverting the export-cars version does not touch
|
|
88
|
+
Google/Meta outputs.
|
|
89
|
+
|
|
90
|
+
## Tests
|
|
91
|
+
|
|
92
|
+
`npm test` in the package: `test/openai-ads-mapping.test.ts` (price, identity, title, condition, German
|
|
93
|
+
description, CSV quoting) and `test/openai-ads-feed-run.test.ts` (real files: first run, sold/reserved/reinstated,
|
|
94
|
+
retention, empty/partial/stale source, count drop + acceptDrop, recovery, lock, broken hero image, UTF-8).
|
package/package.json
CHANGED
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@bytebrand/export-cars",
|
|
3
|
-
"version": "1.26.
|
|
4
|
-
"description": "",
|
|
5
|
-
"author": "bytebrand",
|
|
6
|
-
"license": "UNLICENSED",
|
|
7
|
-
"scripts": {
|
|
8
|
-
"prebuild": "rimraf dist",
|
|
9
|
-
"build": "nest build",
|
|
10
|
-
"prepublishOnly": "npm run build",
|
|
11
|
-
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
12
|
-
"lint": "eslint -c .eslintrc.js --ext .ts src/",
|
|
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"
|
|
15
|
-
},
|
|
16
|
-
"dependencies": {
|
|
17
|
-
"@bytebrand/autode-request": "^1.19.2",
|
|
18
|
-
"@bytebrand/bigquery-logger": "^2.1.2",
|
|
19
|
-
"@bytebrand/car-basic-data-scope": "^9.11.24",
|
|
20
|
-
"@bytebrand/car-description": "^1.17.20",
|
|
21
|
-
"@bytebrand/car-schema": "^3.14.15",
|
|
22
|
-
"@bytebrand/chunkable": "^5.19.8",
|
|
23
|
-
"@bytebrand/cronicle-helper": "^1.21.8",
|
|
24
|
-
"@bytebrand/fe-financing": "^5.14.19",
|
|
25
|
-
"@bytebrand/i18n-dictionaries": "^1.5.0",
|
|
26
|
-
"@bytebrand/import-helper": "^0.45.0",
|
|
27
|
-
"@bytebrand/schema-tools": "^3.18.8",
|
|
28
|
-
"@bytebrand/zipper": "^2.2.0",
|
|
29
|
-
"@nestjs/cache-manager": "^2.1.0",
|
|
30
|
-
"@nestjs/common": "^10.2.7",
|
|
31
|
-
"@nestjs/core": "^10.2.7",
|
|
32
|
-
"axios": "^1.5.1",
|
|
33
|
-
"bottleneck": "^2.19.5",
|
|
34
|
-
"cache-manager": "^5.2.4",
|
|
35
|
-
"csv-write-stream": "^2.0.0",
|
|
36
|
-
"google-spreadsheet": "^3.3.0",
|
|
37
|
-
"lodash": "^4.17.21",
|
|
38
|
-
"moment": "^2.29.4",
|
|
39
|
-
"nest-commander": "~3.15.0",
|
|
40
|
-
"reflect-metadata": "^0.1.13",
|
|
41
|
-
"vin-generator": "^1.0.1"
|
|
42
|
-
},
|
|
43
|
-
"devDependencies": {
|
|
44
|
-
"@nestjs/cli": "^10.1.18",
|
|
45
|
-
"@types/google-spreadsheet": "^3.3.0",
|
|
46
|
-
"@types/inquirer": "^8.2.4",
|
|
47
|
-
"@types/lodash": "^4.14.186",
|
|
48
|
-
"@types/node": "^16.18.58",
|
|
49
|
-
"@typescript-eslint/eslint-plugin": "^6.8.0",
|
|
50
|
-
"@typescript-eslint/parser": "^6.8.0",
|
|
51
|
-
"eslint": "^8.0.1",
|
|
52
|
-
"eslint-config-prettier": "^8.3.0",
|
|
53
|
-
"eslint-plugin-prettier": "^4.0.0",
|
|
54
|
-
"gts": "^3.1.1",
|
|
55
|
-
"prettier": "^2.3.2",
|
|
56
|
-
"ts-node": "^10.0.0",
|
|
57
|
-
"tsconfig-paths": "4.1.0",
|
|
58
|
-
"typescript": "^5.2.2"
|
|
59
|
-
},
|
|
60
|
-
"engines": {
|
|
61
|
-
"node": ">=14.0.0"
|
|
62
|
-
},
|
|
63
|
-
"gitHead": "e827f35865032821bf3a081f03a026d3d3f8a540"
|
|
64
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@bytebrand/export-cars",
|
|
3
|
+
"version": "1.26.30",
|
|
4
|
+
"description": "",
|
|
5
|
+
"author": "bytebrand",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"prebuild": "rimraf dist",
|
|
9
|
+
"build": "nest build",
|
|
10
|
+
"prepublishOnly": "npm run build",
|
|
11
|
+
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
12
|
+
"lint": "eslint -c .eslintrc.js --ext .ts src/",
|
|
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"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@bytebrand/autode-request": "^1.19.2",
|
|
18
|
+
"@bytebrand/bigquery-logger": "^2.1.2",
|
|
19
|
+
"@bytebrand/car-basic-data-scope": "^9.11.24",
|
|
20
|
+
"@bytebrand/car-description": "^1.17.20",
|
|
21
|
+
"@bytebrand/car-schema": "^3.14.15",
|
|
22
|
+
"@bytebrand/chunkable": "^5.19.8",
|
|
23
|
+
"@bytebrand/cronicle-helper": "^1.21.8",
|
|
24
|
+
"@bytebrand/fe-financing": "^5.14.19",
|
|
25
|
+
"@bytebrand/i18n-dictionaries": "^1.5.0",
|
|
26
|
+
"@bytebrand/import-helper": "^0.45.0",
|
|
27
|
+
"@bytebrand/schema-tools": "^3.18.8",
|
|
28
|
+
"@bytebrand/zipper": "^2.2.0",
|
|
29
|
+
"@nestjs/cache-manager": "^2.1.0",
|
|
30
|
+
"@nestjs/common": "^10.2.7",
|
|
31
|
+
"@nestjs/core": "^10.2.7",
|
|
32
|
+
"axios": "^1.5.1",
|
|
33
|
+
"bottleneck": "^2.19.5",
|
|
34
|
+
"cache-manager": "^5.2.4",
|
|
35
|
+
"csv-write-stream": "^2.0.0",
|
|
36
|
+
"google-spreadsheet": "^3.3.0",
|
|
37
|
+
"lodash": "^4.17.21",
|
|
38
|
+
"moment": "^2.29.4",
|
|
39
|
+
"nest-commander": "~3.15.0",
|
|
40
|
+
"reflect-metadata": "^0.1.13",
|
|
41
|
+
"vin-generator": "^1.0.1"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@nestjs/cli": "^10.1.18",
|
|
45
|
+
"@types/google-spreadsheet": "^3.3.0",
|
|
46
|
+
"@types/inquirer": "^8.2.4",
|
|
47
|
+
"@types/lodash": "^4.14.186",
|
|
48
|
+
"@types/node": "^16.18.58",
|
|
49
|
+
"@typescript-eslint/eslint-plugin": "^6.8.0",
|
|
50
|
+
"@typescript-eslint/parser": "^6.8.0",
|
|
51
|
+
"eslint": "^8.0.1",
|
|
52
|
+
"eslint-config-prettier": "^8.3.0",
|
|
53
|
+
"eslint-plugin-prettier": "^4.0.0",
|
|
54
|
+
"gts": "^3.1.1",
|
|
55
|
+
"prettier": "^2.3.2",
|
|
56
|
+
"ts-node": "^10.0.0",
|
|
57
|
+
"tsconfig-paths": "4.1.0",
|
|
58
|
+
"typescript": "^5.2.2"
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=14.0.0"
|
|
62
|
+
},
|
|
63
|
+
"gitHead": "e827f35865032821bf3a081f03a026d3d3f8a540"
|
|
64
|
+
}
|