@bytebrand/export-cars 1.26.47 → 1.26.48

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 (52) hide show
  1. package/dist/configs/ooyyo.config.d.ts +14 -0
  2. package/dist/configs/ooyyo.config.js +40 -6
  3. package/dist/configs/ooyyo.config.js.map +1 -1
  4. package/dist/exporters/ooyyo.exporter.d.ts +5 -4
  5. package/dist/exporters/ooyyo.exporter.js +45 -25
  6. package/dist/exporters/ooyyo.exporter.js.map +1 -1
  7. package/dist/formats/ooyyo-xml.builder.d.ts +7 -2
  8. package/dist/formats/ooyyo-xml.builder.js +59 -36
  9. package/dist/formats/ooyyo-xml.builder.js.map +1 -1
  10. package/dist/interfaces/export-item.interface.d.ts +30 -2
  11. package/dist/interfaces/params.interface.d.ts +3 -0
  12. package/dist/services/ooyyo-feed-run.d.ts +81 -0
  13. package/dist/services/ooyyo-feed-run.js +183 -0
  14. package/dist/services/ooyyo-feed-run.js.map +1 -0
  15. package/dist/services/ooyyo-feed-verify.d.ts +13 -0
  16. package/dist/services/ooyyo-feed-verify.js +104 -0
  17. package/dist/services/ooyyo-feed-verify.js.map +1 -0
  18. package/dist/transformers/ooyyo.transformer.d.ts +51 -11
  19. package/dist/transformers/ooyyo.transformer.js +268 -95
  20. package/dist/transformers/ooyyo.transformer.js.map +1 -1
  21. package/dist/tsconfig.build.tsbuildinfo +1 -1
  22. package/docs/marktde/AUDIT.md +84 -84
  23. package/docs/marktde/PARTNER-QUESTIONS.md +49 -49
  24. package/docs/marktde/RUNBOOK.md +58 -58
  25. package/docs/marktde/TRACKING.md +75 -69
  26. package/docs/meinestadt/PARTNER-QUESTIONS.md +17 -0
  27. package/docs/meinestadt/TRACKING.md +107 -0
  28. package/docs/ooyyo/AUDIT.md +81 -0
  29. package/docs/ooyyo/PARTNER-QUESTIONS.md +32 -0
  30. package/docs/ooyyo/RUNBOOK.md +67 -0
  31. package/docs/ooyyo/TRACKING.md +54 -0
  32. package/package.json +66 -65
  33. package/src/configs/marktde.config.ts +70 -70
  34. package/src/configs/ooyyo.config.ts +65 -15
  35. package/src/exporters/marktde.exporter.ts +78 -78
  36. package/src/exporters/ooyyo.exporter.ts +75 -48
  37. package/src/formats/ooyyo-xml.builder.ts +87 -48
  38. package/src/interfaces/export-item.interface.ts +43 -2
  39. package/src/interfaces/params.interface.ts +5 -0
  40. package/src/services/marktde-feed-run.ts +233 -233
  41. package/src/services/ooyyo-feed-run.ts +265 -0
  42. package/src/services/ooyyo-feed-verify.ts +131 -0
  43. package/src/transformers/marktde.transformer.ts +255 -255
  44. package/src/transformers/ooyyo.transformer.ts +378 -109
  45. package/test/fixtures/ooyyo/OoyyoFormat.xml +125 -0
  46. package/test/fixtures/ooyyo/OoyyoFormat.xsd +116 -0
  47. package/test/fixtures/ooyyo.fixture.ts +53 -0
  48. package/test/mapping-csv.test.ts +65 -65
  49. package/test/marktde-feed-run.test.ts +263 -263
  50. package/test/marktde-mapping.test.ts +280 -280
  51. package/test/ooyyo-feed-run.test.ts +293 -0
  52. package/test/ooyyo-mapping.test.ts +276 -0
@@ -1,255 +1,255 @@
1
- import { get, toNumber, toString } from 'lodash';
2
- import { DataSchemes } from '@bytebrand/car-schema';
3
-
4
- import { OpenAiImageSelection } from './openai-image-fields';
5
- import { envkvCo2Value, envkvFuelConsumptionValue } from './google-feed-envkv';
6
-
7
- /**
8
- * markt.de feed (ADV-328) on top of the legacy mobile.de-style CSV row that MobileTransformer builds
9
- * from the `mobile_yescar` mapping sheet (ACF-4370). Only the columns below are replaced; every other
10
- * column, the column order and the row format stay as they were. Pure functions, no I/O.
11
- *
12
- * Column indexes are 0-based (the sheet's importField): 1 = B, 10 = K, ... 402 = OM.
13
- */
14
- export const MARKTDE_COLUMNS = {
15
- vehicleId: '1', // B stable auto.de vehicle id (metaData.vehicleId)
16
- price: '10', // K price (net for VAT-deductible cars, see getMarktdePriceColumns)
17
- vatFlag: '11', // L 0 = VAT is added on top of K, 1 = no VAT shown separately
18
- vin: '14', // O VIN (optional)
19
- description: '25', // Z description (mobile.de markup)
20
- images: '26', // AA image URLs, `;` separated
21
- vatRate: '29', // AD VAT rate
22
- fuelCombined: '98', // CU combined fuel consumption, decimal comma
23
- co2: '99', // CV CO2 g/km
24
- listingUrl: '402', // OM clickout link to the auto.de vehicle page
25
- } as const;
26
-
27
- // Clickout parameters (ADV-330, same scheme as AutoUncle ADV-324 / meinestadt ADV-327): the partner in
28
- // `source`, so the reporting views, the CRM source and GA4 (utm_*) all see "marktde". Legacy and utm
29
- // pairs carry identical values; paid / free is a contract attribute and is not encoded in the medium.
30
- // Pending markt.de's OK that a structured clickout field is allowed at all.
31
- export const MARKTDE_LINK_PARAMS =
32
- 'source=marktde&medium=listing&campaign=listing&utm_source=marktde&utm_medium=listing&utm_campaign=listing';
33
-
34
- export const getMarktdeListingUrl = (domain: string, carId: string): string =>
35
- `https://www.${domain}/search/vehicle/${carId}?${MARKTDE_LINK_PARAMS}`;
36
-
37
- export enum MarktdeExclusion {
38
- duplicateId = 'duplicate_id',
39
- filtered = 'filtered',
40
- noId = 'no_id',
41
- noVehicleId = 'no_vehicle_id',
42
- notAvailable = 'reserved_or_sold',
43
- noPrice = 'no_valid_price',
44
- noImage = 'no_valid_image',
45
- }
46
-
47
- // Online cars flagged like this are not sellable stock; the partner gets the file without them.
48
- const NOT_AVAILABLE = ['selector_offerAvailability_reserved', 'selector_offerAvailability_sold'];
49
-
50
- const positiveAmount = (value: unknown): number | null => {
51
- if (value === null || value === undefined || value === '' || typeof value === 'boolean') {
52
- return null;
53
- }
54
- const amount = toNumber(value);
55
- return Number.isFinite(amount) && amount > 0 ? amount : null;
56
- };
57
-
58
- // Cheap checks before any image probe. null = the car can be exported.
59
- export const getMarktdeExclusion = (car: DataSchemes.Car): MarktdeExclusion | null => {
60
- if (!toString(get(car, '_id', '')).trim()) {
61
- return MarktdeExclusion.noId;
62
- }
63
- if (!toString(get(car, 'metaData.vehicleId', '')).trim()) {
64
- return MarktdeExclusion.noVehicleId;
65
- }
66
- if (NOT_AVAILABLE.includes(toString(get(car, 'offer.offerAvailability', '')))) {
67
- return MarktdeExclusion.notAvailable;
68
- }
69
- if (positiveAmount(get(car, 'price.currentSalesPrice', null)) === null) {
70
- return MarktdeExclusion.noPrice;
71
- }
72
- return null;
73
- };
74
-
75
- // ISO 3779: 17 characters, digits and capital letters without I, O, Q.
76
- const VIN_PATTERN = /^[A-HJ-NPR-Z0-9]{17}$/;
77
-
78
- /**
79
- * The car's own VIN when it is a well-formed 17-character VIN, otherwise empty. The legacy exporter
80
- * sent marketing.fakeVIN or a random generated VIN for markt.de when the VIN was missing — never again.
81
- */
82
- export const getMarktdeVin = (car: DataSchemes.Car): string => {
83
- const vin = toString(get(car, 'metaData.vin', '')).trim().toUpperCase();
84
- return VIN_PATTERN.test(vin) ? vin : '';
85
- };
86
-
87
- export interface MarktdePriceColumns {
88
- price: string;
89
- vatFlag: string;
90
- vatRate: string;
91
- deductible: boolean;
92
- // vatRate was missing / 0 on a VAT-deductible car and 19 was used
93
- rateDefaulted: boolean;
94
- }
95
-
96
- /**
97
- * K / L / AD with the semantics of the legacy feed (ACF-4370): price.currentSalesPrice is the gross
98
- * consumer price. VAT-deductible car: K = net, L = 0, AD = rate, so gross = K * (1 + AD/100) again.
99
- * Otherwise (margin scheme / private / exempt): K = the gross price as it is, L = 1, AD = 0 — no VAT
100
- * is ever added on top of an already gross price. Unlike the legacy code the net price and AD always
101
- * use the same rate (legacy: a null vatRate left K gross while AD said 19, i.e. +19 % on display).
102
- */
103
- export const getMarktdePriceColumns = (car: DataSchemes.Car): MarktdePriceColumns | null => {
104
- const raw = get(car, 'price.currentSalesPrice', null);
105
- const gross = positiveAmount(raw);
106
- if (gross === null) {
107
- return null;
108
- }
109
- const deductible = Boolean(get(car, 'price.vatDeductible', false));
110
- if (!deductible) {
111
- return { price: String(raw), vatFlag: '1', vatRate: '0', deductible, rateDefaulted: false };
112
- }
113
- const storedRate = positiveAmount(get(car, 'price.vatRate', null));
114
- const rate = storedRate ?? 19;
115
- const net = (gross * 100) / (100 + rate);
116
- return { price: net.toFixed(2), vatFlag: '0', vatRate: String(rate), deductible, rateDefaulted: storedRate === null };
117
- };
118
-
119
- /**
120
- * The markt.de image list from the shared validated clean selection (ADV-336, same rules as the
121
- * OpenAI / meinestadt / AutoUncle feeds): valid 3/4 hero first, then every other valid image. Real
122
- * photos only — no marketing overlays, no placeholders. No valid hero => [] (car is skipped).
123
- */
124
- export const toMarktdeImages = (selection: OpenAiImageSelection, maxImages = 0): string[] => {
125
- if (!selection?.fields) {
126
- return [];
127
- }
128
- const urls = [selection.fields.image_url, ...selection.fields.additional_image_urls];
129
- return maxImages > 0 ? urls.slice(0, maxImages) : urls;
130
- };
131
-
132
- export interface DescriptionRemovals {
133
- url: number;
134
- email: number;
135
- phone: number;
136
- address: number;
137
- }
138
-
139
- // mobile.de description markup: `\\` is a line break, `**…**` bold. Removed tokens never eat it.
140
- const URL_PATTERN = /\b(?:https?:\/\/|www\.)[^\s\\"'<>]+/gi;
141
- const EMAIL_PATTERN = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
142
- // Bare domains of other websites ("autohaus-muster.de"). Not the seller's own name: auto.de is the
143
- // brand and the legal entity (auto.de GmbH, the commercial identification the terms require), so
144
- // "auto.de", "Auto.de", "auto.de-ID" stay; only a link to it (scheme / www.) goes.
145
- const DOMAIN_PATTERN = /(?<![\w.-])(?!auto\.de\b)[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)*\.(?:de|com|net|org|eu|info|at|ch|biz|shop|online)\b(?:\/[^\s\\"'<>]*)?/gi;
146
- // Phone candidates: start with +, 00, 0 or (0, then digits and the usual separators; label optional.
147
- const PHONE_PATTERN = /(?:\b(?:Tel(?:efon|efax)?|Fon|Fax|SMS|Mobil(?:funk)?|Handy|WhatsApp|Hotline)\.?\s*:?\s*)?(?:\+|\b00|\(0|\b0)[\d \t/().-]{6,}\d/gi;
148
- const DATE_PATTERN = /^\d{1,2}\.\d{1,2}\.\d{2,4}$/;
149
- // Street + house number ("Max-Planck-Straße 19", "Hauptstr. 5a") and postal code + town ("06796 Brehna").
150
- const STREET_PATTERN = /\b[A-ZÄÖÜ][\wäöüß.-]*(?:[Ss]traße|[Ss]trasse|[Ss]tr\.|[Ww]eg|[Aa]llee|[Pp]latz|[Rr]ing|[Gg]asse|[Dd]amm|[Cc]haussee|[Uu]fer)\s*\d{1,4}(?![.,]?\d)\s?[a-z]?\b(?![.,]\d)/g;
151
- const POSTCODE_TOWN_PATTERN = /\b\d{5}\s+(?!(?:Euro|EUR|Kilometer|Km|KM|km|Stück|Kilo)\b)[A-ZÄÖÜ][a-zäöüß]+(?:[ -][A-ZÄÖÜ][a-zäöüß]+)?/g;
152
- // Marks a removed token until the tidy-up; never left in the output.
153
- const CUT = '\u0000';
154
- // Labels left behind right before a removed value ("Tel.: ", "E-Mail:").
155
- const DANGLING_LABEL = /\b(?:Tel(?:efon|efax)?|Fon|Fax|SMS|Mobil(?:funk)?|Handy|WhatsApp|Hotline|E-?Mail|Mail|Web(?:seite)?|Internet|Homepage|Adresse|Anschrift)\.?[ \t]*:?[ \t]*(?=\u0000)/gi;
156
-
157
- const digitCount = (value: string): number => (value.match(/\d/g) || []).length;
158
-
159
- /**
160
- * markt.de's terms (nutzungsbedingungen, checked 08.10.2026) forbid phone numbers, physical addresses,
161
- * website links and QR codes in the listing description. The dealer's free-text header / footer of the
162
- * mobile.de description often carries exactly that, so it is removed from the text; the vehicle facts
163
- * stay. Heuristic by nature: the run report counts every removal for review.
164
- */
165
- export const sanitizeMarktdeDescription = (text: string): { text: string; removed: DescriptionRemovals } => {
166
- const removed: DescriptionRemovals = { url: 0, email: 0, phone: 0, address: 0 };
167
- if (!text) {
168
- return { text, removed };
169
- }
170
- let out = text
171
- .replace(EMAIL_PATTERN, () => { removed.email += 1; return CUT; })
172
- .replace(URL_PATTERN, () => { removed.url += 1; return CUT; })
173
- .replace(DOMAIN_PATTERN, () => { removed.url += 1; return CUT; })
174
- .replace(PHONE_PATTERN, (match) => {
175
- const digits = digitCount(match);
176
- if (digits < 8 || digits > 16 || DATE_PATTERN.test(match.trim())) {
177
- return match;
178
- }
179
- removed.phone += 1;
180
- return CUT;
181
- })
182
- .replace(STREET_PATTERN, () => { removed.address += 1; return CUT; })
183
- .replace(POSTCODE_TOWN_PATTERN, () => { removed.address += 1; return CUT; });
184
- if (removed.url + removed.email + removed.phone + removed.address === 0) {
185
- return { text, removed };
186
- }
187
- // Tidy only where something was cut (CUT marks the spot): the label that introduced it, the spaces
188
- // around it; a cut at the start / end of a line leaves no space. The rest of the text is untouched.
189
- out = out
190
- .replace(DANGLING_LABEL, '')
191
- .replace(/[ \t]*(?:\u0000[ \t]*)+/g, '\u0001')
192
- .replace(/(^|\\\\)\u0001/g, '$1')
193
- .replace(/\u0001(?=\\\\|$)/g, '')
194
- .replace(/\u0001/g, ' ');
195
- return { text: out, removed };
196
- };
197
-
198
- export interface MarktdeRowContext {
199
- domain: string;
200
- // final, validated image URLs, hero first (non-empty: callers skip cars without images)
201
- images: string[];
202
- }
203
-
204
- export interface MarktdeRowFacts {
205
- vin: 'sent' | 'empty' | 'invalid_dropped';
206
- price: MarktdePriceColumns;
207
- descriptionRemovals: DescriptionRemovals | null;
208
- fuelEmpty: boolean;
209
- co2Empty: boolean;
210
- }
211
-
212
- const decimalComma = (value: number | undefined): string => (value === undefined || value === null ? '' : String(value).replace('.', ','));
213
-
214
- /**
215
- * Replaces the markt.de columns of a MobileTransformer row (object keyed by column index) and returns
216
- * what was decided, for the run report. The row is changed in place.
217
- */
218
- export const applyMarktdeColumns = (row: Record<string, unknown>, car: DataSchemes.Car, ctx: MarktdeRowContext): MarktdeRowFacts => {
219
- const c = MARKTDE_COLUMNS;
220
- const price = getMarktdePriceColumns(car);
221
- row[c.price] = price.price;
222
- row[c.vatFlag] = price.vatFlag;
223
- row[c.vatRate] = price.vatRate;
224
-
225
- const vin = getMarktdeVin(car);
226
- const storedVin = toString(get(car, 'metaData.vin', '')).trim();
227
- row[c.vin] = vin;
228
-
229
- let descriptionRemovals: DescriptionRemovals | null = null;
230
- const description = row[c.description];
231
- if (typeof description === 'string' && description) {
232
- const sanitized = sanitizeMarktdeDescription(description);
233
- row[c.description] = sanitized.text;
234
- descriptionRemovals = sanitized.removed;
235
- }
236
-
237
- row[c.images] = ctx.images.join(';');
238
-
239
- // EnVKV: the same values as the Google / Meta / AutoUncle / meinestadt feeds (WLTP first, legacy
240
- // only as fallback; BEV: no fuel consumption, CO2 0). Nothing usable -> empty (legacy: "undefined").
241
- const fuel = envkvFuelConsumptionValue(car);
242
- const co2 = envkvCo2Value(car);
243
- row[c.fuelCombined] = decimalComma(fuel);
244
- row[c.co2] = co2 === undefined || co2 === null ? '' : String(co2);
245
-
246
- row[c.listingUrl] = getMarktdeListingUrl(ctx.domain, toString(get(car, '_id', '')));
247
-
248
- return {
249
- vin: vin ? 'sent' : storedVin ? 'invalid_dropped' : 'empty',
250
- price,
251
- descriptionRemovals,
252
- fuelEmpty: row[c.fuelCombined] === '',
253
- co2Empty: row[c.co2] === '',
254
- };
255
- };
1
+ import { get, toNumber, toString } from 'lodash';
2
+ import { DataSchemes } from '@bytebrand/car-schema';
3
+
4
+ import { OpenAiImageSelection } from './openai-image-fields';
5
+ import { envkvCo2Value, envkvFuelConsumptionValue } from './google-feed-envkv';
6
+
7
+ /**
8
+ * markt.de feed (ADV-328) on top of the legacy mobile.de-style CSV row that MobileTransformer builds
9
+ * from the `mobile_yescar` mapping sheet (ACF-4370). Only the columns below are replaced; every other
10
+ * column, the column order and the row format stay as they were. Pure functions, no I/O.
11
+ *
12
+ * Column indexes are 0-based (the sheet's importField): 1 = B, 10 = K, ... 402 = OM.
13
+ */
14
+ export const MARKTDE_COLUMNS = {
15
+ vehicleId: '1', // B stable auto.de vehicle id (metaData.vehicleId)
16
+ price: '10', // K price (net for VAT-deductible cars, see getMarktdePriceColumns)
17
+ vatFlag: '11', // L 0 = VAT is added on top of K, 1 = no VAT shown separately
18
+ vin: '14', // O VIN (optional)
19
+ description: '25', // Z description (mobile.de markup)
20
+ images: '26', // AA image URLs, `;` separated
21
+ vatRate: '29', // AD VAT rate
22
+ fuelCombined: '98', // CU combined fuel consumption, decimal comma
23
+ co2: '99', // CV CO2 g/km
24
+ listingUrl: '402', // OM clickout link to the auto.de vehicle page
25
+ } as const;
26
+
27
+ // Clickout parameters (ADV-330, same scheme as AutoUncle ADV-324 / meinestadt ADV-327): the partner in
28
+ // `source`, so the reporting views, the CRM source and GA4 (utm_*) all see "marktde". Legacy and utm
29
+ // pairs carry identical values; paid / free is a contract attribute and is not encoded in the medium.
30
+ // Pending markt.de's OK that a structured clickout field is allowed at all.
31
+ export const MARKTDE_LINK_PARAMS =
32
+ 'source=marktde&medium=listing&campaign=listing&utm_source=marktde&utm_medium=listing&utm_campaign=listing';
33
+
34
+ export const getMarktdeListingUrl = (domain: string, carId: string): string =>
35
+ `https://www.${domain}/search/vehicle/${carId}?${MARKTDE_LINK_PARAMS}`;
36
+
37
+ export enum MarktdeExclusion {
38
+ duplicateId = 'duplicate_id',
39
+ filtered = 'filtered',
40
+ noId = 'no_id',
41
+ noVehicleId = 'no_vehicle_id',
42
+ notAvailable = 'reserved_or_sold',
43
+ noPrice = 'no_valid_price',
44
+ noImage = 'no_valid_image',
45
+ }
46
+
47
+ // Online cars flagged like this are not sellable stock; the partner gets the file without them.
48
+ const NOT_AVAILABLE = ['selector_offerAvailability_reserved', 'selector_offerAvailability_sold'];
49
+
50
+ const positiveAmount = (value: unknown): number | null => {
51
+ if (value === null || value === undefined || value === '' || typeof value === 'boolean') {
52
+ return null;
53
+ }
54
+ const amount = toNumber(value);
55
+ return Number.isFinite(amount) && amount > 0 ? amount : null;
56
+ };
57
+
58
+ // Cheap checks before any image probe. null = the car can be exported.
59
+ export const getMarktdeExclusion = (car: DataSchemes.Car): MarktdeExclusion | null => {
60
+ if (!toString(get(car, '_id', '')).trim()) {
61
+ return MarktdeExclusion.noId;
62
+ }
63
+ if (!toString(get(car, 'metaData.vehicleId', '')).trim()) {
64
+ return MarktdeExclusion.noVehicleId;
65
+ }
66
+ if (NOT_AVAILABLE.includes(toString(get(car, 'offer.offerAvailability', '')))) {
67
+ return MarktdeExclusion.notAvailable;
68
+ }
69
+ if (positiveAmount(get(car, 'price.currentSalesPrice', null)) === null) {
70
+ return MarktdeExclusion.noPrice;
71
+ }
72
+ return null;
73
+ };
74
+
75
+ // ISO 3779: 17 characters, digits and capital letters without I, O, Q.
76
+ const VIN_PATTERN = /^[A-HJ-NPR-Z0-9]{17}$/;
77
+
78
+ /**
79
+ * The car's own VIN when it is a well-formed 17-character VIN, otherwise empty. The legacy exporter
80
+ * sent marketing.fakeVIN or a random generated VIN for markt.de when the VIN was missing — never again.
81
+ */
82
+ export const getMarktdeVin = (car: DataSchemes.Car): string => {
83
+ const vin = toString(get(car, 'metaData.vin', '')).trim().toUpperCase();
84
+ return VIN_PATTERN.test(vin) ? vin : '';
85
+ };
86
+
87
+ export interface MarktdePriceColumns {
88
+ price: string;
89
+ vatFlag: string;
90
+ vatRate: string;
91
+ deductible: boolean;
92
+ // vatRate was missing / 0 on a VAT-deductible car and 19 was used
93
+ rateDefaulted: boolean;
94
+ }
95
+
96
+ /**
97
+ * K / L / AD with the semantics of the legacy feed (ACF-4370): price.currentSalesPrice is the gross
98
+ * consumer price. VAT-deductible car: K = net, L = 0, AD = rate, so gross = K * (1 + AD/100) again.
99
+ * Otherwise (margin scheme / private / exempt): K = the gross price as it is, L = 1, AD = 0 — no VAT
100
+ * is ever added on top of an already gross price. Unlike the legacy code the net price and AD always
101
+ * use the same rate (legacy: a null vatRate left K gross while AD said 19, i.e. +19 % on display).
102
+ */
103
+ export const getMarktdePriceColumns = (car: DataSchemes.Car): MarktdePriceColumns | null => {
104
+ const raw = get(car, 'price.currentSalesPrice', null);
105
+ const gross = positiveAmount(raw);
106
+ if (gross === null) {
107
+ return null;
108
+ }
109
+ const deductible = Boolean(get(car, 'price.vatDeductible', false));
110
+ if (!deductible) {
111
+ return { price: String(raw), vatFlag: '1', vatRate: '0', deductible, rateDefaulted: false };
112
+ }
113
+ const storedRate = positiveAmount(get(car, 'price.vatRate', null));
114
+ const rate = storedRate ?? 19;
115
+ const net = (gross * 100) / (100 + rate);
116
+ return { price: net.toFixed(2), vatFlag: '0', vatRate: String(rate), deductible, rateDefaulted: storedRate === null };
117
+ };
118
+
119
+ /**
120
+ * The markt.de image list from the shared validated clean selection (ADV-336, same rules as the
121
+ * OpenAI / meinestadt / AutoUncle feeds): valid 3/4 hero first, then every other valid image. Real
122
+ * photos only — no marketing overlays, no placeholders. No valid hero => [] (car is skipped).
123
+ */
124
+ export const toMarktdeImages = (selection: OpenAiImageSelection, maxImages = 0): string[] => {
125
+ if (!selection?.fields) {
126
+ return [];
127
+ }
128
+ const urls = [selection.fields.image_url, ...selection.fields.additional_image_urls];
129
+ return maxImages > 0 ? urls.slice(0, maxImages) : urls;
130
+ };
131
+
132
+ export interface DescriptionRemovals {
133
+ url: number;
134
+ email: number;
135
+ phone: number;
136
+ address: number;
137
+ }
138
+
139
+ // mobile.de description markup: `\\` is a line break, `**…**` bold. Removed tokens never eat it.
140
+ const URL_PATTERN = /\b(?:https?:\/\/|www\.)[^\s\\"'<>]+/gi;
141
+ const EMAIL_PATTERN = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
142
+ // Bare domains of other websites ("autohaus-muster.de"). Not the seller's own name: auto.de is the
143
+ // brand and the legal entity (auto.de GmbH, the commercial identification the terms require), so
144
+ // "auto.de", "Auto.de", "auto.de-ID" stay; only a link to it (scheme / www.) goes.
145
+ const DOMAIN_PATTERN = /(?<![\w.-])(?!auto\.de\b)[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)*\.(?:de|com|net|org|eu|info|at|ch|biz|shop|online)\b(?:\/[^\s\\"'<>]*)?/gi;
146
+ // Phone candidates: start with +, 00, 0 or (0, then digits and the usual separators; label optional.
147
+ const PHONE_PATTERN = /(?:\b(?:Tel(?:efon|efax)?|Fon|Fax|SMS|Mobil(?:funk)?|Handy|WhatsApp|Hotline)\.?\s*:?\s*)?(?:\+|\b00|\(0|\b0)[\d \t/().-]{6,}\d/gi;
148
+ const DATE_PATTERN = /^\d{1,2}\.\d{1,2}\.\d{2,4}$/;
149
+ // Street + house number ("Max-Planck-Straße 19", "Hauptstr. 5a") and postal code + town ("06796 Brehna").
150
+ const STREET_PATTERN = /\b[A-ZÄÖÜ][\wäöüß.-]*(?:[Ss]traße|[Ss]trasse|[Ss]tr\.|[Ww]eg|[Aa]llee|[Pp]latz|[Rr]ing|[Gg]asse|[Dd]amm|[Cc]haussee|[Uu]fer)\s*\d{1,4}(?![.,]?\d)\s?[a-z]?\b(?![.,]\d)/g;
151
+ const POSTCODE_TOWN_PATTERN = /\b\d{5}\s+(?!(?:Euro|EUR|Kilometer|Km|KM|km|Stück|Kilo)\b)[A-ZÄÖÜ][a-zäöüß]+(?:[ -][A-ZÄÖÜ][a-zäöüß]+)?/g;
152
+ // Marks a removed token until the tidy-up; never left in the output.
153
+ const CUT = '\u0000';
154
+ // Labels left behind right before a removed value ("Tel.: ", "E-Mail:").
155
+ const DANGLING_LABEL = /\b(?:Tel(?:efon|efax)?|Fon|Fax|SMS|Mobil(?:funk)?|Handy|WhatsApp|Hotline|E-?Mail|Mail|Web(?:seite)?|Internet|Homepage|Adresse|Anschrift)\.?[ \t]*:?[ \t]*(?=\u0000)/gi;
156
+
157
+ const digitCount = (value: string): number => (value.match(/\d/g) || []).length;
158
+
159
+ /**
160
+ * markt.de's terms (nutzungsbedingungen, checked 08.10.2026) forbid phone numbers, physical addresses,
161
+ * website links and QR codes in the listing description. The dealer's free-text header / footer of the
162
+ * mobile.de description often carries exactly that, so it is removed from the text; the vehicle facts
163
+ * stay. Heuristic by nature: the run report counts every removal for review.
164
+ */
165
+ export const sanitizeMarktdeDescription = (text: string): { text: string; removed: DescriptionRemovals } => {
166
+ const removed: DescriptionRemovals = { url: 0, email: 0, phone: 0, address: 0 };
167
+ if (!text) {
168
+ return { text, removed };
169
+ }
170
+ let out = text
171
+ .replace(EMAIL_PATTERN, () => { removed.email += 1; return CUT; })
172
+ .replace(URL_PATTERN, () => { removed.url += 1; return CUT; })
173
+ .replace(DOMAIN_PATTERN, () => { removed.url += 1; return CUT; })
174
+ .replace(PHONE_PATTERN, (match) => {
175
+ const digits = digitCount(match);
176
+ if (digits < 8 || digits > 16 || DATE_PATTERN.test(match.trim())) {
177
+ return match;
178
+ }
179
+ removed.phone += 1;
180
+ return CUT;
181
+ })
182
+ .replace(STREET_PATTERN, () => { removed.address += 1; return CUT; })
183
+ .replace(POSTCODE_TOWN_PATTERN, () => { removed.address += 1; return CUT; });
184
+ if (removed.url + removed.email + removed.phone + removed.address === 0) {
185
+ return { text, removed };
186
+ }
187
+ // Tidy only where something was cut (CUT marks the spot): the label that introduced it, the spaces
188
+ // around it; a cut at the start / end of a line leaves no space. The rest of the text is untouched.
189
+ out = out
190
+ .replace(DANGLING_LABEL, '')
191
+ .replace(/[ \t]*(?:\u0000[ \t]*)+/g, '\u0001')
192
+ .replace(/(^|\\\\)\u0001/g, '$1')
193
+ .replace(/\u0001(?=\\\\|$)/g, '')
194
+ .replace(/\u0001/g, ' ');
195
+ return { text: out, removed };
196
+ };
197
+
198
+ export interface MarktdeRowContext {
199
+ domain: string;
200
+ // final, validated image URLs, hero first (non-empty: callers skip cars without images)
201
+ images: string[];
202
+ }
203
+
204
+ export interface MarktdeRowFacts {
205
+ vin: 'sent' | 'empty' | 'invalid_dropped';
206
+ price: MarktdePriceColumns;
207
+ descriptionRemovals: DescriptionRemovals | null;
208
+ fuelEmpty: boolean;
209
+ co2Empty: boolean;
210
+ }
211
+
212
+ const decimalComma = (value: number | undefined): string => (value === undefined || value === null ? '' : String(value).replace('.', ','));
213
+
214
+ /**
215
+ * Replaces the markt.de columns of a MobileTransformer row (object keyed by column index) and returns
216
+ * what was decided, for the run report. The row is changed in place.
217
+ */
218
+ export const applyMarktdeColumns = (row: Record<string, unknown>, car: DataSchemes.Car, ctx: MarktdeRowContext): MarktdeRowFacts => {
219
+ const c = MARKTDE_COLUMNS;
220
+ const price = getMarktdePriceColumns(car);
221
+ row[c.price] = price.price;
222
+ row[c.vatFlag] = price.vatFlag;
223
+ row[c.vatRate] = price.vatRate;
224
+
225
+ const vin = getMarktdeVin(car);
226
+ const storedVin = toString(get(car, 'metaData.vin', '')).trim();
227
+ row[c.vin] = vin;
228
+
229
+ let descriptionRemovals: DescriptionRemovals | null = null;
230
+ const description = row[c.description];
231
+ if (typeof description === 'string' && description) {
232
+ const sanitized = sanitizeMarktdeDescription(description);
233
+ row[c.description] = sanitized.text;
234
+ descriptionRemovals = sanitized.removed;
235
+ }
236
+
237
+ row[c.images] = ctx.images.join(';');
238
+
239
+ // EnVKV: the same values as the Google / Meta / AutoUncle / meinestadt feeds (WLTP first, legacy
240
+ // only as fallback; BEV: no fuel consumption, CO2 0). Nothing usable -> empty (legacy: "undefined").
241
+ const fuel = envkvFuelConsumptionValue(car);
242
+ const co2 = envkvCo2Value(car);
243
+ row[c.fuelCombined] = decimalComma(fuel);
244
+ row[c.co2] = co2 === undefined || co2 === null ? '' : String(co2);
245
+
246
+ row[c.listingUrl] = getMarktdeListingUrl(ctx.domain, toString(get(car, '_id', '')));
247
+
248
+ return {
249
+ vin: vin ? 'sent' : storedVin ? 'invalid_dropped' : 'empty',
250
+ price,
251
+ descriptionRemovals,
252
+ fuelEmpty: row[c.fuelCombined] === '',
253
+ co2Empty: row[c.co2] === '',
254
+ };
255
+ };