@bytebrand/export-cars 1.26.44 → 1.26.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/configs/marktde.config.d.ts +14 -0
  2. package/dist/configs/marktde.config.js +35 -2
  3. package/dist/configs/marktde.config.js.map +1 -1
  4. package/dist/constants/fb.constants.js +1 -1
  5. package/dist/constants/fb.constants.js.map +1 -1
  6. package/dist/export.schema.d.ts +2 -2
  7. package/dist/export.schema.js +1 -1
  8. package/dist/export.schema.js.map +1 -1
  9. package/dist/exporters/index.d.ts +1 -0
  10. package/dist/exporters/index.js +1 -0
  11. package/dist/exporters/index.js.map +1 -1
  12. package/dist/exporters/marktde.exporter.d.ts +13 -0
  13. package/dist/exporters/marktde.exporter.js +84 -0
  14. package/dist/exporters/marktde.exporter.js.map +1 -0
  15. package/dist/interfaces/params.interface.d.ts +3 -0
  16. package/dist/services/marktde-feed-run.d.ts +87 -0
  17. package/dist/services/marktde-feed-run.js +166 -0
  18. package/dist/services/marktde-feed-run.js.map +1 -0
  19. package/dist/transformers/marktde.transformer.d.ts +58 -0
  20. package/dist/transformers/marktde.transformer.js +158 -0
  21. package/dist/transformers/marktde.transformer.js.map +1 -0
  22. package/dist/transformers/mobile.transformer.js +1 -10
  23. package/dist/transformers/mobile.transformer.js.map +1 -1
  24. package/dist/tsconfig.build.tsbuildinfo +1 -1
  25. package/docs/marktde/AUDIT.md +84 -0
  26. package/docs/marktde/PARTNER-QUESTIONS.md +49 -0
  27. package/docs/marktde/RUNBOOK.md +58 -0
  28. package/docs/marktde/TRACKING.md +69 -0
  29. package/package.json +2 -2
  30. package/src/configs/marktde.config.ts +54 -2
  31. package/src/constants/fb.constants.ts +34 -34
  32. package/src/export.schema.ts +2 -1
  33. package/src/exporters/index.ts +1 -0
  34. package/src/exporters/marktde.exporter.ts +78 -0
  35. package/src/interfaces/params.interface.ts +5 -0
  36. package/src/services/marktde-feed-run.ts +233 -0
  37. package/src/transformers/marktde.transformer.ts +255 -0
  38. package/src/transformers/mobile.transformer.ts +4 -13
  39. package/test/marktde-feed-run.test.ts +263 -0
  40. package/test/marktde-mapping.test.ts +280 -0
@@ -0,0 +1,84 @@
1
+ # markt.de vehicle feed — audit (ADV-328)
2
+
3
+ Status 12.11.2026: DEV only, dry-run by default, nothing is sent to markt.de. No current markt.de
4
+ feed specification exists (partner contacted 15.10.2026, `PARTNER-QUESTIONS.md`), so the legacy
5
+ ACF-4370 format is kept as it is and only clear defects are fixed. Old Jira / Confluence column
6
+ letters are code evidence, not a contract.
7
+
8
+ ## 1. What exists (code + runtime)
9
+
10
+ | Item | Evidence |
11
+ |---|---|
12
+ | Exporter | `export-cars` `exportType=marktde`. Until 1.26.44 the shared `MobileExporter` (also eauto / yescar / evium); from 1.26.45 `MarktdeExporter` → `MarktdeFeedRun` |
13
+ | Row format | `MobileTransformer` + Google sheet `147qz9sT…` tab `mobile_yescar` (165 rules, ACF-4370 reused the YesCar / mobile.de CSV). `;` separated, every cell quoted, no header row, UTF-8, 517–528 columns per row |
14
+ | Files | `api_export_cars_marktde.csv` + `.zip` (was `resources/marktde-export/`) |
15
+ | DEV Cronicle | `9c.0 Export markt.de - part 1` `ekpy4pqg21u` (rule `metaData.ownerId:xo1ag9JooQebXhnfSclw3NhZyG63`) → `9c.1 … part 2` `ekyvib2rm06` (plain `ftp -pinv 185.15.211.90`, uploads `develop_marktde.zip/.csv`, credentials in the script) → `9d.0 facebook shop`. Triggered by the chain after `9b.1 Export Focus`. No run in the job history |
16
+ | DEV runtime defects found | (a) the Google Sheets API key in `9c.0` is **expired** — the mapping sheet cannot load, every row-building run fails; (b) the filtered dealer has 0 cars in the DEV snapshot; (c) the host has no `zip` binary — the legacy run wrote the CSV, failed the ZIP and still reported `code 0`; (d) `9c.1` uploads names the exporter never writes; (e) 185.15.211.90 is the legacy Jelastic FTP (port 21 closed, see ADV-319 4.1) |
17
+ | PROD | not checked (DEV-only task). Old contract / account belong to the former auto.de Media GmbH — not transferable (Daniel, 16.10) |
18
+
19
+ ## 2. Source → target matrix (columns the feed relies on)
20
+
21
+ Column = 0-based sheet `importField` (letter in brackets). "Changed" = 1.26.45 vs 1.26.44 on the same
22
+ DEV snapshot (11.11 15:00, 2,453 common rows).
23
+
24
+ | Col | Field | Source | 1.26.44 | 1.26.45 | Changed rows |
25
+ |---|---|---|---|---|---|
26
+ | 0 (A) | customer no. | constant `04109003` | same | same (legacy markt.de account → new entity needs its own, Q4) | 0 |
27
+ | 1 (B) | vehicle id | `metaData.vehicleId` | same | same; a car without it is excluded | 0 |
28
+ | 3 / 4 (D / E) | make / model | `mainData.make`, `model + subModel` | same | same | 0 |
29
+ | 10 (K) | price | `price.currentSalesPrice` | net if deductible, **but gross when `vatRate` was null while AD said 19** | net = gross·100/(100+rate) with the same rate as AD | **329** (13 %: legacy would have shown +19 %) |
30
+ | 11 (L) | VAT flag | `!price.vatDeductible` | 0 / 1 | same | 0 |
31
+ | 14 (O) | VIN | `metaData.vin` | own VIN, else `marketing.fakeVIN`, else a **random generated VIN** | own VIN if ISO-3779-valid, else empty | **1,291** (53 % fake VINs gone) |
32
+ | 25 (Z) | description | dealer default summary (header / body / footer, `car-description` Mobile) | as built | contact data removed (§4) | 1,531 |
33
+ | 26 (AA) | images | Imbo | default profile incl. **noimage placeholders** | validated clean ADV-336 list, hero first, `;` | 2,453 |
34
+ | 29 (AD) | VAT rate | `price.vatRate` | `vatRate‖19` | same rule, now also used for K | 0 |
35
+ | 98 (CU) | fuel combined | legacy `consumptionCombined` | raw (BEV kWh, estimated) | Google EnVKV pick (WLTP first), BEV empty, decimal comma | 959 |
36
+ | 99 (CV) | CO2 | `environmentEmissions.co2` | raw, `"undefined"` possible | Google EnVKV pick, BEV 0, nothing usable → empty | 928 |
37
+ | 402 (OM) | clickout | `_id` | `https://www.auto.de<id>?source=classifieds…` — **no slash, no path: broken link** | `/search/vehicle/<id>?source=marktde&medium=listing&campaign=listing` + same `utm_*` | 2,453 |
38
+
39
+ No other column changed (column-by-column diff). eauto / yescar / evium keep `MobileExporter`; the only
40
+ shared-code change is the removed marktde-only fake-VIN branch.
41
+
42
+ ## 3. Price / VAT semantics (kept from ACF-4370)
43
+
44
+ `currentSalesPrice` is the gross consumer price. VAT-deductible: K = net, L = 0, AD = rate → the partner
45
+ shows K·(1+AD/100) = the gross price again. Not deductible (margin scheme, private, exempt): K = gross,
46
+ L = 1, AD = 0 — VAT is never added to an already gross price. DEV: 2,433 deductible / 20 not;
47
+ 329 deductible cars without a stored rate get 19 % for both K and AD (`vat.rateDefaulted`). Whether
48
+ markt.de still reads K as net is open (Q9); a net price must not be shown as the consumer price.
49
+
50
+ ## 4. Marketplace terms in the description
51
+
52
+ markt.de's public terms forbid phone numbers, addresses, website links and QR codes in the description
53
+ and images. `sanitizeMarktdeDescription` removes: links with scheme / `www.`, other bare domains,
54
+ e-mails, phone numbers (8–16 digits, label included), street + number, postcode + town; the label left
55
+ before a cut ("Tel.:") goes too; nothing else in the text changes. Kept: `auto.de` / `Auto.de` as a word
56
+ (brand and legal entity auto.de GmbH — commercial identification), dates, prices, mileage, model names.
57
+ Real DEV text (1,701 descriptions): only the template's contact block (Tel / WhatsApp / SMS / E-Mail,
58
+ twice per text) and the `www.auto.de/z/<id>` short link are cut; one false positive ("Touring 3.0" as a
59
+ street) was found and fixed. Images: clean profile = the car's own photos, no marketing overlays.
60
+ Not detectable here: text inside photos (dealer watermarks / QR) — image ticket.
61
+
62
+ ## 5. Run safety (same as AutoUncle / meinestadt)
63
+
64
+ Fail-closed `FeedGuard`: complete and fresh base snapshot, minimum / drop checks, lock, temp file →
65
+ atomic rename, `.prev`, `.rejected`, report in `resources/marktde-state/<mode>/last-run-report.json`.
66
+ Excluded: duplicate id, Cronicle rules / no images, no `_id`, no vehicle id, reserved / sold, no valid
67
+ price, no valid 3/4 hero image. ZIP in-process (adm-zip, as meinestadt). Removal = the car is missing
68
+ from the next full file (markt.de delta / deletion semantics: Q12).
69
+
70
+ ## 6. DEV dry-run 12.11.2026 (host, harness — see RUNBOOK)
71
+
72
+ Snapshot 3,094 cars → 2,453 rows, 370 dealers; excluded: 471 by rules (no images), 170 without a valid
73
+ hero image (the legacy file sent them with placeholders). Images 12,197/12,197 OK, 1–28 per car.
74
+ VIN sent 1,162 / empty 1,291. Fuel empty 153 (BEV / nothing usable), CO2 never empty. Event params as
75
+ configured (one dealer) → 0 cars → blocked `published_too_small`, nothing written.
76
+
77
+ ## 7. Known, not changed (need a decision or the partner spec)
78
+
79
+ - Description body quality comes from `car-description` (English labels "Manufacturer", "Basic data",
80
+ `Erstzulassung: <unix seconds>`): shared library, also mobile.de — separate ticket.
81
+ - Rate columns 150 / 293 (monthly installment, `"0"` when none) and financing columns 160–166 from the
82
+ sheet: a rate claim needs the PAngV disclosure; markt.de has to confirm whether they are read at all.
83
+ - Seller identity: all rows under one customer number, dealer not in a column of its own — Q5.
84
+ - Column count differs per row (517–528, trailing empty cells omitted) — legacy, fine for the old importer.
@@ -0,0 +1,49 @@
1
+ # markt.de — open partner questions (ADV-328 / ADV-330)
2
+
3
+ Drafts for the user's review only — nothing is sent by engineering. Contact: online-marketing@markt.de
4
+ (Alexandra Roth's auto-reply, 15.10.2026). Contracting party: auto.de GmbH, Max-Planck-Straße 19,
5
+ 06796 Sandersdorf-Brehna (imprint); the former auto.de Media GmbH account / contract is not assumed
6
+ to carry over.
7
+
8
+ ## Commercial / account
9
+
10
+ 1. Current partnership model for nationwide vehicle inventory: free listing, CPC, CPM, CPS, fixed fee,
11
+ insertion fees, caps, minimums, credits for invalid clicks, termination, invoice workflow?
12
+ 2. Business account for auto.de GmbH: new account or transfer? Commercial seller role shown how?
13
+ 3. Inventory upload rights: may auto.de list dealer vehicles (marketplace / agent role), or only own stock?
14
+ 4. Customer / account number for the feed (the legacy file sends `04109003` in column A).
15
+ 5. Seller identity per listing: one auto.de account, or per-dealer identity (name / location fields)?
16
+
17
+ ## Feed specification
18
+
19
+ 6. Current official automotive feed spec and a reference file: transport (CSV / XML / JSON), schema,
20
+ required / optional fields, enum values and language, header row, field order, encoding, delimiter,
21
+ quoting, decimal format, ZIP packaging.
22
+ 7. Is the legacy mobile.de-style CSV (ACF-4370, ~528 columns, no header) still accepted?
23
+ 8. Transport: pickup URL, SFTP / FTPS endpoint, credentials, schedule, max file size.
24
+ 9. Price: is column K read as a net price with L (VAT flag) and AD (VAT rate), or should the feed send
25
+ the gross consumer price? Margin-scheme / exempt cars?
26
+ 10. Images: number, minimum resolution, aspect ratio, file size, URL vs. files in the ZIP, ordering;
27
+ which logos / overlays are allowed; rules for AI-edited images.
28
+ 11. Listing limits, category conditions (cars only? vans, motorbikes?), new vs. used.
29
+ 12. Updates / deletions: full file each run (car missing = removed) or delta with explicit deletes?
30
+ How fast is a sold car taken offline?
31
+ 13. Importer diagnostics: error report per row, test account / staging importer.
32
+
33
+ ## Clickout, enquiries, reporting (ADV-330)
34
+
35
+ 14. Is a structured clickout field (link to the auto.de vehicle page) allowed for an enterprise
36
+ partner? Which field? (The public terms forbid links in description and images; we will not put
37
+ any there.) May the link carry `source=marktde&medium=listing&campaign=listing` + `utm_*`?
38
+ 15. Does markt.de append its own click / listing / campaign id to the clickout? Name and format?
39
+ 16. Or do buyers contact the seller on markt.de (messages / calls)? If so: lead delivery path
40
+ (e-mail, API, export), fields, lead id, listing id, timestamp, consent / privacy terms (DPA).
41
+ 17. Reporting: clicks / impressions / enquiries per listing and day? Format, delivery, time zone,
42
+ restatements, invoice file with credits.
43
+ 18. Any postback / pixel / server API offered (we will not build one without a written spec)?
44
+ 19. Consent: is a clickout from markt.de covered by our own CMP on landing only (current state)?
45
+
46
+ ## Compliance
47
+
48
+ 20. EnVKV / Pkw-EnVKV: which fields does markt.de show (consumption, CO2, class, electric range)?
49
+ 21. Financing rate columns (monthly installment): shown at all? Required PAngV disclosure fields?
@@ -0,0 +1,58 @@
1
+ # markt.de feed — runbook (ADV-328)
2
+
3
+ ## Parameters (Cronicle event, export-cars plugin, `exportType=marktde`)
4
+
5
+ | Param | Default | Meaning |
6
+ |---|---|---|
7
+ | `apikey` | — | Google Sheets API key for the mapping sheet `mobile_yescar`. **DEV 9c.0: expired (12.11.2026)** |
8
+ | `String` / `Number` / `Date` | — | Cronicle filter rules (DEV 9c.0: one dealer, 0 cars today) |
9
+ | `marktdeDryRun` | dry-run | only the exact text `false` makes a live run |
10
+ | `marktdeMaxImages` | no cut | cut the image list (no markt.de limit known) |
11
+ | `marktdeImageConcurrency` | 16 | parallel image URL checks |
12
+ | `feedGuardMinSourceItems` | 1000 | DEV snapshot ~3,100 cars |
13
+ | `feedGuardMinPublishedItems` | 1 | set a real minimum per job once the dealer scope is known |
14
+ | `feedGuardMaxDropPercent` | 20 | max drop of source / rows against the last good run |
15
+ | `feedGuardMaxSourceAgeHours` | 26 | newest base export chunk must be younger |
16
+ | `feedGuardMaxStaleHours` | 48 | last good file reported STALE after this |
17
+ | `feedGuardAcceptDrop` | off | `true` for ONE run after a real stock drop |
18
+
19
+ No new parameter is needed for a run: the defaults give a safe dry-run.
20
+
21
+ ## Files
22
+
23
+ | Mode | Output | State |
24
+ |---|---|---|
25
+ | dry-run | `resources/marktde-dryrun/api_export_cars_marktde.{csv,zip}` | `resources/marktde-state/dryrun/` |
26
+ | live | `resources/marktde-export/api_export_cars_marktde.{csv,zip}` | `resources/marktde-state/export/` |
27
+
28
+ State dir: `last-good.json`, `last-run-report.json` (every run: guard code, exclusions, VIN / VAT /
29
+ EnVKV / description-cut counts, image stats), `run.lock`. A blocked run leaves `<csv>.rejected`; the
30
+ published CSV/ZIP and `<csv>.prev` stay.
31
+
32
+ ## Exit codes and the chain
33
+
34
+ Blocked or failed runs exit 1, so the chained `9c.1 … part 2` FTP step does not run (and with it the
35
+ rest of the DEV chain: `9d.0 facebook shop …`). A dry-run exits 0 and `9c.1` then tries to upload
36
+ `develop_marktde.*`, which does not exist, to the legacy Jelastic FTP. To run 9c.0 alone, start it via
37
+ the API with `chain: ""` (see below) or from the UI with the chain removed for that run.
38
+
39
+ ## One-off DEV dry-run without the event (what was done 12.11.2026)
40
+
41
+ ```
42
+ D=/home/deploy/export-cars-<ver>-dryrun/<name> # job.json (chmod 600) + credentials + resources/cars-export symlinks
43
+ NODE_PATH=<pkg>/dist:/opt/cronicle/node_modules node <pkg>/dist/main.js < job.json > run.log
44
+ ```
45
+ While the event's Sheets key is expired: `node harness.js <pkg> < job.json` — the harness runs the same
46
+ `main.js` but loads the mapping rules from the public CSV of the same sheet tab (gviz) with the same
47
+ post-processing as `MappingService`.
48
+
49
+ ## Before anything goes live (not done; each step needs explicit OK)
50
+
51
+ 1. Signed contract / account for auto.de GmbH, current markt.de feed spec + test importer
52
+ (`PARTNER-QUESTIONS.md`); written acceptance of a test import of the 20-row sample.
53
+ 2. New Sheets API key in the 9c.0 params (or move the mapping out of Google Sheets).
54
+ 3. Dealer scope (the rule in 9c.0) and `feedGuardMinPublishedItems` from a PROD dry-run.
55
+ 4. Replace `9c.1`: transport and file names from the spec, credentials out of the script; never the
56
+ legacy Jelastic host. Only then `marktdeDryRun=false`.
57
+ 5. Customer number (col A `04109003`) for the new entity; clickout field permission (col OM).
58
+ 6. Tracking: `web-funnel-tracking-health` ≥ 1.5.6 (CLASSIFIEDS_MARKTDE) on the target Cronicle.
@@ -0,0 +1,69 @@
1
+ # markt.de — measurement design (ADV-330)
2
+
3
+ Status 12.11.2026: DEV only. Implemented: the clickout link (export-cars 1.26.45) and the reporting
4
+ channel `CLASSIFIEDS_MARKTDE` (web-funnel-tracking-health 1.5.6). Not implemented, by design, until
5
+ markt.de confirms a path in writing: partner click ids, hosted-enquiry ingestion, cost import.
6
+
7
+ ## Evidence
8
+
9
+ - ACF-4370 link (legacy feed, column OM): `?source=classifieds&medium=listing&campaign=marktde` —
10
+ and the URL itself was broken (`https://www.auto.de<id>`, no `/search/vehicle/`), so no historic
11
+ markt.de session could be expected from it.
12
+ - ADV-260: fe-main stores `source/medium/campaign/utm_*` + click ids in the marketing cookie and sends
13
+ them with every lead (REQ → Odoo `crm_source`, BigQuery event / journey views).
14
+ - Classification before 1.5.6: `source=classifieds` without a known campaign → generic classifieds /
15
+ UTM rules; partner identity was lost (same finding as AutoUncle ADV-324).
16
+ - No markt.de click id, postback, pixel or reporting API is known. Public terms forbid links in the
17
+ description and images.
18
+
19
+ ## Path A — clickout to auto.de (only if markt.de allows a structured link field)
20
+
21
+ ```
22
+ markt.de listing ──(structured field OM)──► https://www.auto.de/search/vehicle/<car _id>
23
+ ?source=marktde&medium=listing&campaign=listing&utm_source=marktde&utm_medium=listing&utm_campaign=listing
24
+ → fe-main marketing cookie (ADV-260) → visitor session, landing_url keeps the raw query
25
+ → views: reporting_channel CLASSIFIEDS_MARKTDE, family Classifieds, detail markt.de, publisher MARKTDE
26
+ → form → REQ → Odoo crm_source "marktde" (lower-case source, no SQL change needed)
27
+ → rpt_episode_touchpoint → canonical 90-day episode (original episode_start_date) → sale / CM
28
+ ```
29
+
30
+ - Grain: session (visitor_id + session). No click id → aggregate reconciliation only (platform
31
+ `marktde` in the click-reconciliation view, used only where `fact_marketing_media` has markt.de rows).
32
+ - Raw and normalized are separate: `source / medium / campaign / utm_*` stay as received in the event
33
+ rows; the channel is derived. Legacy `classifieds + campaign=marktde` and the spelling `markt.de`
34
+ resolve to the same channel. Any medium; not `PAID_*` (paid / free is a contract attribute), so
35
+ the paid click-id health rates are not affected.
36
+ - Same car id = feed row → VDP → lead car id, so listing-level joins work without a partner id.
37
+ - If markt.de appends its own click / listing id: add it to the ADV-260 capture list and the
38
+ `CLICK_ID_COLUMNS` only after the name / format is confirmed (Q15). Never manufacture one.
39
+ - Bots: KNOWN_CRAWLER / BOT_SUSPECT rules apply before attribution as for every channel.
40
+
41
+ ## Path B — enquiries on markt.de (no visit to auto.de)
42
+
43
+ Only if markt.de offers a lead delivery with a contract (Q16): separate ingestion keyed by partner lead
44
+ id + listing id (= vehicle id / car id) + created timestamp, **no website session is created**. REQ
45
+ duplicate check by partner lead id; GDPR: only the fields needed for the enquiry, consent text from the
46
+ partner, DPA first. Nothing is built before the schema is known; no scraping of partner messages.
47
+
48
+ ## Costs
49
+
50
+ When a tariff exists: invoice / partner report rows into `fact_marketing_media` (existing table, no new
51
+ collection or table) with `source_system = marktde_invoice`, date, account, channel `marktde`,
52
+ listing / campaign id when supplied, currency, billed clicks, fees, credits as negative rows, contract
53
+ version, file hash for idempotent upsert / replay. No estimated CPC as actual cost. A CPS / fixed fee
54
+ is marketing cost only, never a second CM deduction. Tabs 03 / 07 / 13 / 91 and the cost guardrails
55
+ stay unchanged (live report layout changes need approval).
56
+
57
+ ## QA
58
+
59
+ | Case | Expected | Status |
60
+ |---|---|---|
61
+ | link with `source=marktde` + utm (consent given) | CLASSIFIEDS_MARKTDE session + event rows | unit test ✓, DEV run see below |
62
+ | legacy `source=classifieds&campaign=marktde` | CLASSIFIEDS_MARKTDE | unit test ✓ |
63
+ | `utm_source=markt.de`, other medium (`cpc`) | CLASSIFIEDS_MARKTDE, not PAID_OTHER | unit test ✓ |
64
+ | Google click id + markt.de utm | click id wins (PAID_SEARCH_GOOGLE) | rule order test ✓ |
65
+ | no consent / revoked | no marketing cookie → lead without source (unchanged behaviour) | not run (needs browser) |
66
+ | lead → REQ → episode → sale | crm_source marktde, episode keeps original start | not run (no markt.de traffic) |
67
+ | partner id, hosted leads, invoice import, credits, double import | — | blocked on partner spec |
68
+
69
+ Launch blockers: contract (auto.de GmbH), clickout permission, spec, legal / data QA, feed + image tests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/export-cars",
3
- "version": "1.26.44",
3
+ "version": "1.26.46",
4
4
  "description": "",
5
5
  "author": "bytebrand",
6
6
  "license": "UNLICENSED",
@@ -11,7 +11,7 @@
11
11
  "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
12
12
  "lint": "eslint -c .eslintrc.js --ext .ts src/",
13
13
  "start:dev": "nest start --watch",
14
- "test": "ts-node --transpile-only test/google-feed-envkv.test.ts && ts-node --transpile-only test/google-feed-item-row.test.ts && ts-node --transpile-only test/publication-gate.test.ts && ts-node --transpile-only test/images-regression.test.ts && ts-node --transpile-only test/clean-images.test.ts && ts-node --transpile-only test/image-url-validator.test.ts && ts-node --transpile-only test/openai-image-fields.test.ts && ts-node --transpile-only test/openai-ads-mapping.test.ts && ts-node --transpile-only test/openai-ads-feed-run.test.ts && ts-node --transpile-only test/openai-ads-finance.test.ts && ts-node --transpile-only test/openai-ads-price-cap.test.ts && ts-node --transpile-only test/meinstadt-mapping.test.ts && ts-node --transpile-only test/meinstadt-feed-run.test.ts && ts-node --transpile-only test/autouncle-mapping.test.ts && ts-node --transpile-only test/autouncle-feed-run.test.ts && ts-node --transpile-only test/criteo-images.test.ts && ts-node --transpile-only test/criteo-mapping.test.ts && ts-node --transpile-only test/criteo-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 && ts-node --transpile-only test/marktde-mapping.test.ts && ts-node --transpile-only test/marktde-feed-run.test.ts && ts-node --transpile-only test/criteo-images.test.ts && ts-node --transpile-only test/criteo-mapping.test.ts && ts-node --transpile-only test/criteo-feed-run.test.ts"
15
15
  },
16
16
  "dependencies": {
17
17
  "@bytebrand/autode-request": "^1.19.2",
@@ -1,15 +1,67 @@
1
- import { ExportType, FileType, ImageType } from 'src/enums';
1
+ import { toNumber } from 'lodash';
2
+
3
+ import { ExportType, FileType, ImageType } from '../enums';
2
4
  import { Params } from '../interfaces/params.interface';
5
+ import { FeedGuardOptions } from '../services/feed-guard';
3
6
  import { Config } from './config';
4
7
 
8
+ const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
9
+
10
+ const positiveNumber = (value: unknown, fallback: number): number => {
11
+ const parsed = toNumber(text(value));
12
+ return text(value) && Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
13
+ };
14
+
15
+ // Guard defaults (same as AutoUncle). The DEV job exports one dealer via the Cronicle rules, so the
16
+ // published count is far below the base snapshot: minPublishedItems only forbids an empty file.
17
+ export const MARKTDE_DEFAULTS = {
18
+ imageConcurrency: 16,
19
+ minSourceItems: 1000,
20
+ minPublishedItems: 1,
21
+ maxDropPercent: 20,
22
+ maxSourceAgeHours: 26,
23
+ maxStaleHours: 48,
24
+ };
25
+
26
+ /**
27
+ * markt.de vehicle feed (ADV-328): the legacy mobile.de-style CSV (mapping sheet `mobile_yescar`,
28
+ * ACF-4370) plus a ZIP of it, with clean ADV-336 images. There is no current markt.de spec yet, so
29
+ * the format (columns, file names, no header row, `;` separator, UTF-8) stays as it was.
30
+ * Dry-run is the default: the files go to resources/marktde-dryrun, which no Cronicle step reads.
31
+ * Only marktdeDryRun=false writes resources/marktde-export (the directory `9c.1 … part 2` uploads
32
+ * from). Each mode has its own guard state.
33
+ */
5
34
  export class MarktdeConfig extends Config {
35
+ dryRun: boolean;
36
+ imageConcurrency: number;
37
+ // 0 = no limit (no partner limit known)
38
+ maxImages: number;
39
+ stateDir: string;
40
+ feedGuard: FeedGuardOptions;
41
+
6
42
  constructor(params: Params) {
7
43
  super(params);
8
44
  this.campaign = ExportType.MARKTDE;
9
- this.pathesConfig.localConvertedDirectory = `resources/${ExportType.MARKTDE}-export`;
45
+ this.dryRun = text(params.marktdeDryRun).toLowerCase() !== 'false';
46
+ const mode = this.dryRun ? 'dryrun' : 'export';
47
+ this.pathesConfig.localConvertedDirectory = `resources/${ExportType.MARKTDE}-${mode}`;
48
+ this.stateDir = `resources/${ExportType.MARKTDE}-state/${mode}`;
10
49
  this.imageUrl = `https://images.${this.domain}/carimage`;
11
50
  this.mappingSpreadsheetTitle = 'mobile_yescar';
51
+ // MobileTransformer still builds its legacy `image` column; marktde.transformer replaces it with
52
+ // the validated clean list.
12
53
  this.imageType = ImageType.default;
54
+ this.imageConcurrency = positiveNumber(params.marktdeImageConcurrency, MARKTDE_DEFAULTS.imageConcurrency);
55
+ this.maxImages = Math.floor(positiveNumber(params.marktdeMaxImages, 0));
56
+ this.feedGuard = {
57
+ stateDir: this.stateDir,
58
+ minSourceItems: positiveNumber(params.feedGuardMinSourceItems, MARKTDE_DEFAULTS.minSourceItems),
59
+ minPublishedItems: positiveNumber(params.feedGuardMinPublishedItems, MARKTDE_DEFAULTS.minPublishedItems),
60
+ maxDropPercent: positiveNumber(params.feedGuardMaxDropPercent, MARKTDE_DEFAULTS.maxDropPercent),
61
+ maxSourceAgeHours: positiveNumber(params.feedGuardMaxSourceAgeHours, MARKTDE_DEFAULTS.maxSourceAgeHours),
62
+ maxStaleHours: positiveNumber(params.feedGuardMaxStaleHours, MARKTDE_DEFAULTS.maxStaleHours),
63
+ acceptDrop: text(params.feedGuardAcceptDrop).toLowerCase() === 'true',
64
+ };
13
65
  this.outputFiles = [
14
66
  `${this.pathesConfig.localConvertedDirectory}/api_export_cars_${ExportType.MARKTDE}.${FileType.CSV}`,
15
67
  `${this.pathesConfig.localConvertedDirectory}/api_export_cars_${ExportType.MARKTDE}.${FileType.ZIP}`,
@@ -1,34 +1,34 @@
1
- export const FB_MAX_TRIM_LENGTH = 50;
2
- export const FB_MAX_IMAGE_COUNT_FOR_DEAFULT = 47;
3
- export const FB_MIN_MILEAGES = 900;
4
- export const DEFAULT_ZIP = '06796';
5
- export const DEFAULT_CITY = 'Sandersdorf-Brehna';
6
- export const DEFAULT_ADDR1 = 'Max-Planck-Str. 19';
7
- export const TIMESTAMP_FIRST_YEAR = 1970;
8
- export const META_PUSH_PRIORITY_STANDARD = 'standard';
9
- export const META_PUSH_PRIORITY_ALLOWED_VALUES = ['dealer_priority_1', 'dealer_priority_2'];
10
-
11
- // MarketingImageExportConfig channel code for the Meta AIA overlay images (image-generator's
12
- // MetaAiaImagesService.CHANNEL_CODE) — distinct from this feed's now-disabled legacy "fb" config.
13
- export const META_AIA_CHANNEL_CODE = 'meta_aia';
14
- // Only the financing variant exists so far; leasing/other modes will be added once image-generator
15
- // renders them too (see MetaAiaImagesService's MetaAiaMode).
16
- export const META_AIA_MODE_FINANCING = 'financing';
17
-
18
- // Values must match Meta's Automotive Model Ads offer_type enum exactly (lease/finance/cash) —
19
- // the old 'financing'/'leasing'/'buy' values are not recognized by the AMA Vehicle Offers Feed.
20
- export enum OfferType {
21
- financing = 'finance',
22
- leasing = 'lease',
23
- buy = 'cash',
24
- }
25
-
26
- export const AMOUNT_QUALIFIER_APR = 'APR';
27
- export const TERM_QUALIFIER_MONTHS = 'months';
28
-
29
- export const OFFER_DISCLAIMER = 'Für Ihre Finanzierungswünsche stellen wir eine Bonitätsanfrage. Bonität ' +
30
- 'vorausgesetzt, ist dies ein repräsentatives Berechnungsbeispiel gem. der Angaben, welches 2/3 aller Kunden, ' +
31
- 'im Sinne des § 17a Abs. 4 PAngV, erhalten. Dieses freibleibende Angebot der Santander Consumer Bank AG, ' +
32
- 'Santander-Platz 1, 41061 Mönchengladbach wird vermittelt durch die auto.de GmbH, Max-Planck-Str. 19, 06796 ' +
33
- 'Sandersdorf-Brehna, die als ungebundener Vermittler nicht beratend tätig ist. Irrtümer vorbehalten. Preise ' +
34
- 'ggf. inkl. MwSt.';
1
+ export const FB_MAX_TRIM_LENGTH = 50;
2
+ export const FB_MAX_IMAGE_COUNT_FOR_DEAFULT = 47;
3
+ export const FB_MIN_MILEAGES = 900;
4
+ export const DEFAULT_ZIP = '06796';
5
+ export const DEFAULT_CITY = 'Sandersdorf-Brehna';
6
+ export const DEFAULT_ADDR1 = 'Max-Planck-Str. 19';
7
+ export const TIMESTAMP_FIRST_YEAR = 1970;
8
+ export const META_PUSH_PRIORITY_STANDARD = 'standard';
9
+ export const META_PUSH_PRIORITY_ALLOWED_VALUES = ['dealer_priority_1', 'dealer_priority_2'];
10
+
11
+ // MarketingImageExportConfig channel code for the Meta AIA overlay images (image-generator's
12
+ // MetaAiaImagesService.CHANNEL_CODE) — distinct from this feed's now-disabled legacy "fb" config.
13
+ export const META_AIA_CHANNEL_CODE = 'meta_aia';
14
+ // Only the financing variant exists so far; leasing/other modes will be added once image-generator
15
+ // renders them too (see MetaAiaImagesService's MetaAiaMode).
16
+ export const META_AIA_MODE_FINANCING = 'financing';
17
+
18
+ // Values must match Meta's Automotive Model Ads offer_type enum exactly (lease/finance/cash) —
19
+ // the old 'financing'/'leasing'/'buy' values are not recognized by the AMA Vehicle Offers Feed.
20
+ export enum OfferType {
21
+ financing = 'finance',
22
+ leasing = 'lease',
23
+ buy = 'cash',
24
+ }
25
+
26
+ export const AMOUNT_QUALIFIER_APR = 'APR';
27
+ export const TERM_QUALIFIER_MONTHS = 'months';
28
+
29
+ export const OFFER_DISCLAIMER = 'Für Ihre Finanzierungswünsche stellen wir eine Bonitätsanfrage. Bonität ' +
30
+ 'vorausgesetzt, ist dies ein repräsentatives Berechnungsbeispiel gem. der Angaben, welches 2/3 aller Kunden, ' +
31
+ 'im Sinne des § 17a Abs. 4 PAngV, erhalten. Dieses freibleibende Angebot der Openbank Deutschland AG, ' +
32
+ 'Santander-Platz 1, 41061 Mönchengladbach wird vermittelt durch die auto.de GmbH, Max-Planck-Str. 19, 06796 ' +
33
+ 'Sandersdorf-Brehna, die als ungebundener Vermittler nicht beratend tätig ist. Irrtümer vorbehalten. Preise ' +
34
+ 'ggf. inkl. MwSt.';
@@ -32,6 +32,7 @@ import {
32
32
  ByOwnerExporter,
33
33
  FbExporter,
34
34
  GoogleExporter,
35
+ MarktdeExporter,
35
36
  MeinstadtExporter,
36
37
  MobileExporter,
37
38
  OoyyoExporter,
@@ -56,7 +57,7 @@ export const exportSchema = {
56
57
  },
57
58
  [ExportType.MARKTDE]: {
58
59
  config: MarktdeConfig,
59
- exporter: MobileExporter,
60
+ exporter: MarktdeExporter,
60
61
  },
61
62
  [ExportType.GOOGLE]: {
62
63
  config: GoogleConfig,
@@ -5,6 +5,7 @@ export * from './by-owner.exporter';
5
5
  export * from './criteo.exporter';
6
6
  export * from './fb.exporter';
7
7
  export * from './google.exporter';
8
+ export * from './marktde.exporter';
8
9
  export * from './meinstadt.exporter';
9
10
  export * from './mobile.exporter';
10
11
  export * from './modix.exporter';
@@ -0,0 +1,78 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ModuleRef } from '@nestjs/core';
3
+
4
+ import { FsChunkInterface } from '@bytebrand/chunkable';
5
+ import { LoggerService } from '@bytebrand/cronicle-helper';
6
+
7
+ import { CarService, RulesService, LoaderService, ConfigService, ImagesService } from '../services';
8
+ import { ImageUrlValidator } from '../services/image-url-validator.service';
9
+ import { FeedGuard } from '../services/feed-guard';
10
+ import { MarktdeFeedRun, MarktdeSourceCar } from '../services/marktde-feed-run';
11
+ import { zipSingleFile } from '../services/meinstadt-feed-run';
12
+ import { MobileTransformer } from '../transformers/mobile.transformer';
13
+ import { toMarktdeImages } from '../transformers/marktde.transformer';
14
+ import { resolveOpenAiImageFields } from '../transformers/openai-image-fields';
15
+ import { MarktdeConfig } from '../configs/marktde.config';
16
+ import { Exporter } from '../interfaces/exporter.interface';
17
+
18
+ /**
19
+ * ExportType.MARKTDE (ADV-328). Was the shared MobileExporter; now wires the Cronicle/Nest services
20
+ * into MarktdeFeedRun, which holds the logic. The row itself still comes from MobileTransformer (same
21
+ * mapping sheet, same columns); only the markt.de columns are replaced. EAUTO / YESCAR / EVIUM keep
22
+ * the MobileExporter unchanged.
23
+ */
24
+ @Injectable()
25
+ export class MarktdeExporter implements Exporter {
26
+ constructor(
27
+ private carService: CarService,
28
+ private configService: ConfigService,
29
+ private rulesService: RulesService,
30
+ private loaderService: LoaderService,
31
+ private imagesService: ImagesService,
32
+ private moduleRef: ModuleRef,
33
+ ) {}
34
+
35
+ async export(): Promise<void> {
36
+ const config = this.configService.config as MarktdeConfig;
37
+ const [csvFile, zipFile] = config.outputFiles;
38
+ const validator = new ImageUrlValidator({ concurrency: config.imageConcurrency });
39
+ const transformer = await this.moduleRef.create(MobileTransformer);
40
+
41
+ const run = new MarktdeFeedRun(
42
+ {
43
+ forEachChunk: fn => this.loaderService.process((chunk: FsChunkInterface) => fn(chunk.data as MarktdeSourceCar[])),
44
+ filterCar: source => this.carService.filterCar(source, this.rulesService.rules),
45
+ resolveImages: async ({ car, images }) => toMarktdeImages(
46
+ await resolveOpenAiImageFields(this.imagesService.getCleanImages(car._id, images), validator),
47
+ config.maxImages,
48
+ ),
49
+ legacyRow: async ({ car, images }) => (await transformer.transform(car, images)) as Record<string, unknown>,
50
+ imageStats: () => validator.stats,
51
+ zip: zipSingleFile,
52
+ guard: new FeedGuard(config.feedGuard),
53
+ },
54
+ {
55
+ runId: config.runId,
56
+ dryRun: config.dryRun,
57
+ domain: config.domain,
58
+ sourceDirectory: config.pathesConfig.localDirectory,
59
+ sourceBaseFileName: config.pathesConfig.baseFileName,
60
+ csvFile,
61
+ zipFile,
62
+ stateDir: config.stateDir,
63
+ },
64
+ );
65
+
66
+ try {
67
+ const report = await run.run();
68
+ if (report.stale) {
69
+ console.log(`marktde feed STALE: last good file ${report.lastGoodAgeHours?.toFixed(1)} h old`);
70
+ }
71
+ } catch (error) {
72
+ // main.ts would report a rejected run as {"complete":1,"code":0}; fail the job so the chained
73
+ // upload step does not run (report + lock already handled by the run).
74
+ LoggerService.exitError(`marktde feed not published: ${error?.message ?? error}`);
75
+ process.exit(1);
76
+ }
77
+ }
78
+ }
@@ -48,6 +48,11 @@ export interface Params {
48
48
  autouncleDryRun?: string;
49
49
  autouncleImageConcurrency?: string;
50
50
  autouncleMaxImages?: string;
51
+ // markt.de feed only (ADV-328), Cronicle text params; see configs/marktde.config.ts.
52
+ // Live output needs marktdeDryRun=false explicitly.
53
+ marktdeDryRun?: string;
54
+ marktdeImageConcurrency?: string;
55
+ marktdeMaxImages?: string;
51
56
  }
52
57
 
53
58
  // Parsed from feedItemsBq* by GoogleConfig.