@bytebrand/export-cars 1.26.46 → 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.
- package/dist/configs/ooyyo.config.d.ts +14 -0
- package/dist/configs/ooyyo.config.js +40 -6
- package/dist/configs/ooyyo.config.js.map +1 -1
- package/dist/exporters/ooyyo.exporter.d.ts +5 -4
- package/dist/exporters/ooyyo.exporter.js +45 -25
- package/dist/exporters/ooyyo.exporter.js.map +1 -1
- package/dist/formats/ooyyo-xml.builder.d.ts +7 -2
- package/dist/formats/ooyyo-xml.builder.js +59 -36
- package/dist/formats/ooyyo-xml.builder.js.map +1 -1
- package/dist/interfaces/export-item.interface.d.ts +30 -2
- package/dist/interfaces/params.interface.d.ts +3 -0
- package/dist/services/mapping.service.d.ts +2 -0
- package/dist/services/mapping.service.js +33 -13
- package/dist/services/mapping.service.js.map +1 -1
- package/dist/services/ooyyo-feed-run.d.ts +81 -0
- package/dist/services/ooyyo-feed-run.js +183 -0
- package/dist/services/ooyyo-feed-run.js.map +1 -0
- package/dist/services/ooyyo-feed-verify.d.ts +13 -0
- package/dist/services/ooyyo-feed-verify.js +104 -0
- package/dist/services/ooyyo-feed-verify.js.map +1 -0
- package/dist/transformers/ooyyo.transformer.d.ts +51 -11
- package/dist/transformers/ooyyo.transformer.js +268 -95
- package/dist/transformers/ooyyo.transformer.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/marktde/AUDIT.md +1 -1
- package/docs/marktde/RUNBOOK.md +3 -3
- package/docs/marktde/TRACKING.md +8 -2
- package/docs/meinestadt/PARTNER-QUESTIONS.md +17 -0
- package/docs/meinestadt/TRACKING.md +107 -0
- package/docs/ooyyo/AUDIT.md +81 -0
- package/docs/ooyyo/PARTNER-QUESTIONS.md +32 -0
- package/docs/ooyyo/RUNBOOK.md +67 -0
- package/docs/ooyyo/TRACKING.md +54 -0
- package/package.json +3 -2
- package/src/configs/ooyyo.config.ts +65 -15
- package/src/constants/fb.constants.ts +34 -34
- package/src/exporters/ooyyo.exporter.ts +75 -48
- package/src/formats/ooyyo-xml.builder.ts +87 -48
- package/src/interfaces/export-item.interface.ts +43 -2
- package/src/interfaces/params.interface.ts +5 -0
- package/src/services/mapping.service.ts +54 -13
- package/src/services/ooyyo-feed-run.ts +265 -0
- package/src/services/ooyyo-feed-verify.ts +131 -0
- package/src/transformers/ooyyo.transformer.ts +378 -109
- package/test/fixtures/ooyyo/OoyyoFormat.xml +125 -0
- package/test/fixtures/ooyyo/OoyyoFormat.xsd +116 -0
- package/test/fixtures/ooyyo.fixture.ts +53 -0
- package/test/mapping-csv.test.ts +65 -0
- package/test/ooyyo-feed-run.test.ts +293 -0
- package/test/ooyyo-mapping.test.ts +276 -0
|
@@ -1,109 +1,378 @@
|
|
|
1
|
-
import { Translator } from '@bytebrand/schema-tools';
|
|
2
|
-
import { de } from '@bytebrand/i18n-dictionaries';
|
|
3
|
-
import { get,
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
1
|
+
import { Translator } from '@bytebrand/schema-tools';
|
|
2
|
+
import { de } from '@bytebrand/i18n-dictionaries';
|
|
3
|
+
import { get, toNumber, toString } from 'lodash';
|
|
4
|
+
import { DataSchemes } from '@bytebrand/car-schema';
|
|
5
|
+
import { DePostalCodesService } from '@bytebrand/car-basic-data-scope';
|
|
6
|
+
import { Injectable } from '@nestjs/common';
|
|
7
|
+
|
|
8
|
+
import { OoyyoXmlItem } from '../interfaces';
|
|
9
|
+
import { OpenAiImageSelection } from './openai-image-fields';
|
|
10
|
+
import { toUnixSeconds } from './meinstadt.transformer';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* OOYYO vehicle XML (ADV-331), against the official OoyyoFormat.xsd / OoyyoFormat.xml
|
|
14
|
+
* (test/fixtures/ooyyo, the same files OOYYO attached on 16.10.2026) and the importer rules
|
|
15
|
+
* Aleksandar Ciric confirmed on 19.10.2026:
|
|
16
|
+
* - price needs unit="EUR" exactly (the old sample's unit="euro" is rejected), integer amount;
|
|
17
|
+
* - a listing without photo, year or price, or with a make / model OOYYO cannot recognise, is rejected;
|
|
18
|
+
* - vehicle URL and every picture URL <= 1,500 characters, pictures reachable over HTTPS.
|
|
19
|
+
* The XSD itself is permissive (every element optional, units free strings), so these rules live
|
|
20
|
+
* here (validateOoyyoItem) and are applied to every item before it is written and again to the
|
|
21
|
+
* finished file before it is published. Pure functions, no I/O.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Clickout parameters (ADV-333, same scheme as AutoUncle ADV-324 / meinestadt ADV-327 / markt.de
|
|
25
|
+
// ADV-330): the partner in `source`, so the reporting views, the CRM source and GA4 (utm_*) all see
|
|
26
|
+
// "ooyyo". Legacy and utm pairs carry identical values (no parameterConflict). OOYYO links directly
|
|
27
|
+
// to this URL without a redirect and keeps the parameters unchanged (partner, 19.10.2026). No OOYYO
|
|
28
|
+
// click id exists; none is invented.
|
|
29
|
+
export const OOYYO_LINK_PARAMS =
|
|
30
|
+
'source=ooyyo&medium=listing&campaign=listing&utm_source=ooyyo&utm_medium=listing&utm_campaign=listing';
|
|
31
|
+
|
|
32
|
+
export const OOYYO_MAX_URL_LENGTH = 1500;
|
|
33
|
+
export const OOYYO_PRICE_UNIT = 'EUR';
|
|
34
|
+
export const OOYYO_TIME_ZONE = 'Europe/Berlin';
|
|
35
|
+
|
|
36
|
+
export const getOoyyoListingUrl = (domain: string, carId: string): string =>
|
|
37
|
+
`https://www.${domain}/search/vehicle/${encodeURIComponent(carId)}?${OOYYO_LINK_PARAMS}`;
|
|
38
|
+
|
|
39
|
+
export enum OoyyoExclusion {
|
|
40
|
+
duplicateId = 'duplicate_id',
|
|
41
|
+
filtered = 'filtered',
|
|
42
|
+
noId = 'no_id',
|
|
43
|
+
notAvailable = 'reserved_or_sold',
|
|
44
|
+
noPrice = 'no_valid_price',
|
|
45
|
+
noMake = 'no_make',
|
|
46
|
+
noModel = 'no_recognisable_model',
|
|
47
|
+
noYear = 'no_valid_year',
|
|
48
|
+
noImage = 'no_valid_image',
|
|
49
|
+
urlTooLong = 'url_too_long',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Online cars flagged like this are not sellable stock; the partner gets the file without them.
|
|
53
|
+
const NOT_AVAILABLE = ['selector_offerAvailability_reserved', 'selector_offerAvailability_sold'];
|
|
54
|
+
|
|
55
|
+
// Placeholder make / model values OOYYO cannot match to a vehicle ("SONSTIGE" is the CBD fallback
|
|
56
|
+
// model when the dealer's model could not be mapped).
|
|
57
|
+
const UNRECOGNISABLE = new Set(['', 'sonstige', 'sonstiges', 'andere', 'other', 'others', 'unknown', 'unbekannt', 'selector_unknown', '-']);
|
|
58
|
+
|
|
59
|
+
const text = (value: unknown): string => (typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '');
|
|
60
|
+
|
|
61
|
+
const isRecognisable = (value: string): boolean => !UNRECOGNISABLE.has(value.toLowerCase());
|
|
62
|
+
|
|
63
|
+
const positiveNumber = (value: unknown): number | null => {
|
|
64
|
+
if (value === null || value === undefined || value === '' || typeof value === 'boolean') {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const n = toNumber(value);
|
|
68
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whole-euro consumer purchase price from price.currentSalesPrice only — never a monthly leasing /
|
|
73
|
+
* financing rate, never price.purchasePrice (dealer cost). The partner sample asks for an integer;
|
|
74
|
+
* a fractional amount is rounded to the nearest euro (DEV/PROD prices are integers). null = no price.
|
|
75
|
+
*/
|
|
76
|
+
export const getOoyyoPrice = (value: unknown): string | null => {
|
|
77
|
+
const amount = positiveNumber(value);
|
|
78
|
+
if (amount === null) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const rounded = Math.round(amount);
|
|
82
|
+
return rounded > 0 ? String(rounded) : null;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Year of the car: year of the first registration, else the stored construction year (new cars
|
|
87
|
+
* often have no registration yet). Both must be real values in 1900..next year. Nothing is
|
|
88
|
+
* defaulted: the legacy exporter invented "now - 2 months" for new cars without a year — such a
|
|
89
|
+
* car is now excluded (no_valid_year), as OOYYO rejects listings without a year anyway.
|
|
90
|
+
*/
|
|
91
|
+
export const getOoyyoYear = (car: DataSchemes.Car, now: Date = new Date()): string => {
|
|
92
|
+
const max = now.getFullYear() + 1;
|
|
93
|
+
const seconds = toUnixSeconds(get(car, 'mainData.firstRegistration', null));
|
|
94
|
+
if (seconds !== null) {
|
|
95
|
+
const year = new Date(seconds * 1000).getUTCFullYear();
|
|
96
|
+
if (year >= 1900 && year <= max) {
|
|
97
|
+
return String(year);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const construction = Math.trunc(toNumber(get(car, 'mainData.constructionYear', null)));
|
|
101
|
+
return Number.isFinite(construction) && construction >= 1900 && construction <= max ? String(construction) : '';
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Postcode as a STRING. CBD stores location.zipCode as a number, so German postcodes with a leading
|
|
106
|
+
* zero lose it (04103 Leipzig -> 4103; 415 of 3,094 DEV cars). For a German location a 4-digit value
|
|
107
|
+
* gets its leading zero back; anything that is still not a valid German postcode (01001..99998) is
|
|
108
|
+
* left out. Other countries: the stored value as it is.
|
|
109
|
+
*/
|
|
110
|
+
export const getOoyyoPostcode = (zip: unknown, countryCode: unknown): string => {
|
|
111
|
+
const raw = text(zip);
|
|
112
|
+
if (!raw) {
|
|
113
|
+
return '';
|
|
114
|
+
}
|
|
115
|
+
const country = text(countryCode).toUpperCase();
|
|
116
|
+
if (country && country !== 'DE') {
|
|
117
|
+
return raw;
|
|
118
|
+
}
|
|
119
|
+
const padded = /^\d{4}$/.test(raw) ? `0${raw}` : raw;
|
|
120
|
+
return /^\d{5}$/.test(padded) && Number(padded) >= 1001 ? padded : '';
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const EURONORM: Record<string, string> = {
|
|
124
|
+
selector_emissionClass_euro1: '1',
|
|
125
|
+
selector_emissionClass_euro2: '2',
|
|
126
|
+
selector_emissionClass_euro3: '3',
|
|
127
|
+
selector_emissionClass_euro4: '4',
|
|
128
|
+
selector_emissionClass_euro5: '5',
|
|
129
|
+
selector_emissionClass_euro6: '6',
|
|
130
|
+
selector_emissionClass_euro6b: '6b',
|
|
131
|
+
selector_emissionClass_euro6c: '6c',
|
|
132
|
+
selector_emissionClass_euro6d: '6d',
|
|
133
|
+
selector_emissionClass_euro6d_temp: '6d-TEMP',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Legacy values kept; an unknown class is left out (the legacy exporter wrote the word "default").
|
|
137
|
+
export const getOoyyoEuronorm = (selector: unknown): string => EURONORM[text(selector)] ?? '';
|
|
138
|
+
|
|
139
|
+
const berlinParts = (seconds: number): Record<string, string> => {
|
|
140
|
+
const parts = new Intl.DateTimeFormat('en-GB', {
|
|
141
|
+
timeZone: OOYYO_TIME_ZONE,
|
|
142
|
+
year: 'numeric',
|
|
143
|
+
month: '2-digit',
|
|
144
|
+
day: '2-digit',
|
|
145
|
+
hour: '2-digit',
|
|
146
|
+
minute: '2-digit',
|
|
147
|
+
hourCycle: 'h23',
|
|
148
|
+
}).formatToParts(new Date(seconds * 1000));
|
|
149
|
+
return Object.fromEntries(parts.map(p => [p.type, p.value]));
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Ad date / time as the partner sample asks: DD/MM/YYYY and HH:MM (24 h), German local time of the
|
|
154
|
+
* listing's creation. The legacy exporter wrote hh:mm:ss (12-hour clock, host time zone).
|
|
155
|
+
*/
|
|
156
|
+
export const getOoyyoDateTime = (creationDate: unknown): { date: string; time: string } => {
|
|
157
|
+
const seconds = toUnixSeconds(creationDate);
|
|
158
|
+
if (seconds === null) {
|
|
159
|
+
return { date: '', time: '' };
|
|
160
|
+
}
|
|
161
|
+
const p = berlinParts(seconds);
|
|
162
|
+
return { date: `${p.day}/${p.month}/${p.year}`, time: `${p.hour}:${p.minute}` };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* German consumer description from offer.offerDescription (the dealer's listing text, the same text
|
|
167
|
+
* the vehicle page shows) as plain text: the mobile.de markup is resolved (`\\` = line break,
|
|
168
|
+
* `**bold**` markers dropped) — OOYYO shows plain text. Nothing is added or invented; an empty
|
|
169
|
+
* description stays empty (the element is then left out).
|
|
170
|
+
*/
|
|
171
|
+
export const getOoyyoContent = (description: unknown): string => {
|
|
172
|
+
const raw = typeof description === 'string' ? description : '';
|
|
173
|
+
if (!raw.trim()) {
|
|
174
|
+
return '';
|
|
175
|
+
}
|
|
176
|
+
return raw
|
|
177
|
+
.replace(/\\\\/g, '\n')
|
|
178
|
+
.replace(/\*\*/g, '')
|
|
179
|
+
.split('\n')
|
|
180
|
+
.map(line => line.replace(/[ \t]+$/g, ''))
|
|
181
|
+
.join('\n')
|
|
182
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
183
|
+
.trim();
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// make, model, subModel joined; missing parts are left out (same as the meinestadt title).
|
|
187
|
+
export const getOoyyoTitle = (car: DataSchemes.Car): string =>
|
|
188
|
+
['mainData.make', 'mainData.model', 'mainData.subModel']
|
|
189
|
+
.map(path => text(get(car, path, null)))
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
.join(' ');
|
|
192
|
+
|
|
193
|
+
// ISO 3779: 17 characters, digits and capital letters without I, O, Q.
|
|
194
|
+
const VIN_PATTERN = /^[A-HJ-NPR-Z0-9]{17}$/;
|
|
195
|
+
|
|
196
|
+
// The car's own VIN when it is a well-formed 17-character VIN, otherwise empty (never invented).
|
|
197
|
+
export const getOoyyoVin = (car: DataSchemes.Car): string => {
|
|
198
|
+
const vin = text(get(car, 'metaData.vin', '')).toUpperCase();
|
|
199
|
+
return VIN_PATTERN.test(vin) ? vin : '';
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Positive whole number as text, else '' (0 / missing doors, seats, gears, kW, ccm are left out).
|
|
203
|
+
const positiveInt = (value: unknown): string => {
|
|
204
|
+
const n = positiveNumber(value);
|
|
205
|
+
return n === null ? '' : String(Math.round(n));
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The OOYYO picture list from the shared validated clean selection (ADV-336, same rules as the
|
|
210
|
+
* OpenAI / meinestadt / AutoUncle / markt.de feeds): valid 3/4 hero first, then every other valid
|
|
211
|
+
* image. Real photos only — no marketing / rate overlays, no "noimage" placeholders (the legacy
|
|
212
|
+
* exporter sent 5,001 of them on DEV, which OOYYO's sample explicitly forbids). No valid hero => []
|
|
213
|
+
* (car is skipped). No partner maximum: 20 is OOYYO's ranking recommendation, all are accepted.
|
|
214
|
+
*/
|
|
215
|
+
export const toOoyyoImages = (selection: OpenAiImageSelection, maxImages = 0): string[] => {
|
|
216
|
+
if (!selection?.fields) {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
const urls = [selection.fields.image_url, ...selection.fields.additional_image_urls];
|
|
220
|
+
return maxImages > 0 ? urls.slice(0, maxImages) : urls;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** Semantic acceptance rules of the OOYYO importer (19.10.2026) for one item. null = acceptable. */
|
|
224
|
+
export const validateOoyyoItem = (item: Partial<OoyyoXmlItem>): OoyyoExclusion | null => {
|
|
225
|
+
if (!item.url || !/^https:\/\//.test(item.url)) {
|
|
226
|
+
return OoyyoExclusion.noId;
|
|
227
|
+
}
|
|
228
|
+
if (item.url.length > OOYYO_MAX_URL_LENGTH) {
|
|
229
|
+
return OoyyoExclusion.urlTooLong;
|
|
230
|
+
}
|
|
231
|
+
if (!item.price || !/^[1-9]\d*$/.test(item.price) || item.priceUnit !== OOYYO_PRICE_UNIT) {
|
|
232
|
+
return OoyyoExclusion.noPrice;
|
|
233
|
+
}
|
|
234
|
+
if (!item.make || !isRecognisable(item.make)) {
|
|
235
|
+
return OoyyoExclusion.noMake;
|
|
236
|
+
}
|
|
237
|
+
if (!item.model || !isRecognisable(item.model)) {
|
|
238
|
+
return OoyyoExclusion.noModel;
|
|
239
|
+
}
|
|
240
|
+
if (!item.year || !/^(19|20)\d{2}$/.test(item.year)) {
|
|
241
|
+
return OoyyoExclusion.noYear;
|
|
242
|
+
}
|
|
243
|
+
const pictures = item.pictures ?? [];
|
|
244
|
+
if (!pictures.length || pictures.some(p => !/^https:\/\//.test(p))) {
|
|
245
|
+
return OoyyoExclusion.noImage;
|
|
246
|
+
}
|
|
247
|
+
if (pictures.some(p => p.length > OOYYO_MAX_URL_LENGTH)) {
|
|
248
|
+
return OoyyoExclusion.urlTooLong;
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// Car-level checks before any image probe or postcode lookup. null = go on.
|
|
254
|
+
export const getOoyyoExclusion = (car: DataSchemes.Car, now: Date = new Date()): OoyyoExclusion | null => {
|
|
255
|
+
if (!text(get(car, '_id', ''))) {
|
|
256
|
+
return OoyyoExclusion.noId;
|
|
257
|
+
}
|
|
258
|
+
if (NOT_AVAILABLE.includes(text(get(car, 'offer.offerAvailability', '')))) {
|
|
259
|
+
return OoyyoExclusion.notAvailable;
|
|
260
|
+
}
|
|
261
|
+
if (!getOoyyoPrice(get(car, 'price.currentSalesPrice', null))) {
|
|
262
|
+
return OoyyoExclusion.noPrice;
|
|
263
|
+
}
|
|
264
|
+
if (!isRecognisable(text(get(car, 'mainData.make', '')))) {
|
|
265
|
+
return OoyyoExclusion.noMake;
|
|
266
|
+
}
|
|
267
|
+
if (!isRecognisable(text(get(car, 'mainData.model', '')))) {
|
|
268
|
+
return OoyyoExclusion.noModel;
|
|
269
|
+
}
|
|
270
|
+
if (!getOoyyoYear(car, now)) {
|
|
271
|
+
return OoyyoExclusion.noYear;
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export interface OoyyoMappingContext {
|
|
277
|
+
domain: string;
|
|
278
|
+
// final, validated image URLs, hero first
|
|
279
|
+
images: string[];
|
|
280
|
+
// German label of a selector; '' for unknown
|
|
281
|
+
translate: (selector: string) => string;
|
|
282
|
+
// Bundesland of a German postcode, '' when unknown
|
|
283
|
+
region: string;
|
|
284
|
+
now?: Date;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export type OoyyoMapResult = { ok: true; item: OoyyoXmlItem } | { ok: false; reason: OoyyoExclusion };
|
|
288
|
+
|
|
289
|
+
const getMileage = (car: DataSchemes.Car): string => {
|
|
290
|
+
const raw = get(car, 'mainData.mileage', null);
|
|
291
|
+
if (raw === null || raw === undefined || raw === '') {
|
|
292
|
+
return '';
|
|
293
|
+
}
|
|
294
|
+
const km = toNumber(raw);
|
|
295
|
+
// 0 km is a real value for a new car; negative / unparsable is left out
|
|
296
|
+
return Number.isFinite(km) && km >= 0 ? String(Math.round(km)) : '';
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const getIsNew = (car: DataSchemes.Car): string => {
|
|
300
|
+
const condition = text(get(car, 'mainData.condition', ''));
|
|
301
|
+
if (condition === 'selector_condition_new') {
|
|
302
|
+
return '1';
|
|
303
|
+
}
|
|
304
|
+
return condition === 'selector_condition_used' ? '0' : '';
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/** One `<ad>`. Empty optional fields are left out of the XML by the builder. */
|
|
308
|
+
export const mapOoyyoItem = (car: DataSchemes.Car, ctx: OoyyoMappingContext): OoyyoMapResult => {
|
|
309
|
+
const excluded = getOoyyoExclusion(car, ctx.now);
|
|
310
|
+
if (excluded) {
|
|
311
|
+
return { ok: false, reason: excluded };
|
|
312
|
+
}
|
|
313
|
+
const { date, time } = getOoyyoDateTime(get(car, 'metaData.creationDate', null));
|
|
314
|
+
const item: OoyyoXmlItem = {
|
|
315
|
+
url: getOoyyoListingUrl(ctx.domain, text(get(car, '_id', ''))),
|
|
316
|
+
title: getOoyyoTitle(car),
|
|
317
|
+
content: getOoyyoContent(get(car, 'offer.offerDescription', '')),
|
|
318
|
+
price: getOoyyoPrice(get(car, 'price.currentSalesPrice', null)),
|
|
319
|
+
priceUnit: OOYYO_PRICE_UNIT,
|
|
320
|
+
make: text(get(car, 'mainData.make', '')),
|
|
321
|
+
model: text(get(car, 'mainData.model', '')),
|
|
322
|
+
engine_size: positiveInt(get(car, 'engineData.cubicCapacity', null)),
|
|
323
|
+
trim: text(get(car, 'mainData.subModel', '')),
|
|
324
|
+
body_type: ctx.translate(text(get(car, 'mainData.category', ''))),
|
|
325
|
+
color: ctx.translate(text(get(car, 'exterior.exteriorColor', ''))).replace(/^Außenfarbe\s+/, ''),
|
|
326
|
+
year: getOoyyoYear(car, ctx.now),
|
|
327
|
+
mileage: getMileage(car),
|
|
328
|
+
fuel_type: ctx.translate(text(get(car, 'consumption.fuel', ''))),
|
|
329
|
+
transmission: ctx.translate(text(get(car, 'driveSuspension.gearbox', ''))),
|
|
330
|
+
euronorm: getOoyyoEuronorm(get(car, 'environmentEmissions.emissionClass', '')),
|
|
331
|
+
power: positiveInt(get(car, 'engineData.powerKW', null)),
|
|
332
|
+
doors: positiveInt(get(car, 'mainData.doors', null)),
|
|
333
|
+
seats: positiveInt(get(car, 'mainData.seats', null)),
|
|
334
|
+
gears: positiveInt(get(car, 'driveSuspension.gears', null)),
|
|
335
|
+
location: {
|
|
336
|
+
city: text(get(car, 'location.city', '')),
|
|
337
|
+
postcode: getOoyyoPostcode(get(car, 'location.zipCode', null), get(car, 'location.countryCode', null)),
|
|
338
|
+
region: ctx.region,
|
|
339
|
+
},
|
|
340
|
+
pictures: ctx.images,
|
|
341
|
+
vin: getOoyyoVin(car),
|
|
342
|
+
is_new: getIsNew(car),
|
|
343
|
+
date,
|
|
344
|
+
time,
|
|
345
|
+
};
|
|
346
|
+
const invalid = validateOoyyoItem(item);
|
|
347
|
+
return invalid ? { ok: false, reason: invalid } : { ok: true, item };
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
@Injectable()
|
|
351
|
+
export class OoyyoTransformer {
|
|
352
|
+
private translator = new Translator(de);
|
|
353
|
+
private postalCodes = DePostalCodesService.getInstance();
|
|
354
|
+
private regions = new Map<string, Promise<string>>();
|
|
355
|
+
|
|
356
|
+
translate(selector: string): string {
|
|
357
|
+
if (!selector || selector === 'selector_unknown') {
|
|
358
|
+
return '';
|
|
359
|
+
}
|
|
360
|
+
const label = toString(this.translator.translateVal(selector) ?? '').trim();
|
|
361
|
+
// an unknown selector must not leak as "selector_…" into German consumer copy
|
|
362
|
+
return label.startsWith('selector_') ? '' : label;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Bundesland of a valid German postcode from the shared postal code table; '' when unknown. */
|
|
366
|
+
region(postcode: string, countryCode: unknown): Promise<string> {
|
|
367
|
+
const country = text(countryCode).toUpperCase();
|
|
368
|
+
if (!/^\d{5}$/.test(postcode) || (country && country !== 'DE')) {
|
|
369
|
+
return Promise.resolve('');
|
|
370
|
+
}
|
|
371
|
+
if (!this.regions.has(postcode)) {
|
|
372
|
+
this.regions.set(postcode, Promise.resolve(this.postalCodes.getLocationByPostalCode(Number(postcode)))
|
|
373
|
+
.then(entry => text(entry?.State))
|
|
374
|
+
.catch(() => ''));
|
|
375
|
+
}
|
|
376
|
+
return this.regions.get(postcode);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<ooyyo xsi:noNamespaceSchemaLocation="OoyyoFormat.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
|
3
|
+
<ad>
|
|
4
|
+
<!-- URL of the ad on your website -->
|
|
5
|
+
<url><![CDATA[]]></url>
|
|
6
|
+
<!-- title of the ad -->
|
|
7
|
+
<title><![CDATA[]]></title>
|
|
8
|
+
<!-- description of the car -->
|
|
9
|
+
<content><![CDATA[]]></content>
|
|
10
|
+
<!-- price of the car (integer value) / set the "unit" to currency of your country -->
|
|
11
|
+
<price unit="euro"><![CDATA[]]></price>
|
|
12
|
+
<!-- make of the car -->
|
|
13
|
+
<make><![CDATA[]]></make>
|
|
14
|
+
<!-- model of the car -->
|
|
15
|
+
<model><![CDATA[]]></model>
|
|
16
|
+
<!-- engine size of the car / set the "unit" to cc (cubic centimeter) or l (litre) -->
|
|
17
|
+
<engine_size unit="l"><![CDATA[]]></engine_size>
|
|
18
|
+
<!-- trim of the car -->
|
|
19
|
+
<trim><![CDATA[]]></trim>
|
|
20
|
+
<!-- body type of the vehicle / estate, hatchback, 4x4, etc -->
|
|
21
|
+
<body_type><![CDATA[]]></body_type>
|
|
22
|
+
<!-- colour of the car -->
|
|
23
|
+
<color><![CDATA[]]></color>
|
|
24
|
+
<!-- year of the car -->
|
|
25
|
+
<year><![CDATA[]]></year>
|
|
26
|
+
<!-- mileage / set "unit" to km (kilometers) or mi (miles) -->
|
|
27
|
+
<mileage unit="km"><![CDATA[]]></mileage>
|
|
28
|
+
<!-- fuel type (i.e. petrol, diesel, etc) -->
|
|
29
|
+
<fuel_type><![CDATA[]]></fuel_type>
|
|
30
|
+
<!-- transmission of the car (manual/automatic) -->
|
|
31
|
+
<transmission><![CDATA[]]></transmission>
|
|
32
|
+
<!-- euronorm -->
|
|
33
|
+
<euronorm><![CDATA[]]></euronorm>
|
|
34
|
+
<!-- power of the car / set the "unit" to kw (kilowatt) or hp (horsepower) -->
|
|
35
|
+
<power unit="kw"><![CDATA[]]></power>
|
|
36
|
+
<!-- number of doors -->
|
|
37
|
+
<doors><![CDATA[]]></doors>
|
|
38
|
+
<!-- number of seats in the car -->
|
|
39
|
+
<seats><![CDATA[]]></seats>
|
|
40
|
+
<!-- number of gears -->
|
|
41
|
+
<gears><![CDATA[]]></gears>
|
|
42
|
+
<!-- location of the car -->
|
|
43
|
+
<location>
|
|
44
|
+
<!-- city name -->
|
|
45
|
+
<city><![CDATA[]]></city>
|
|
46
|
+
<!-- postcode -->
|
|
47
|
+
<postcode><![CDATA[]]></postcode>
|
|
48
|
+
<!-- county/state name -->
|
|
49
|
+
<region><![CDATA[]]></region>
|
|
50
|
+
</location>
|
|
51
|
+
<!-- URLs of the pictures for this ad / please do not include corporate images with information such as "No image available" -->
|
|
52
|
+
<pictures>
|
|
53
|
+
<picture><![CDATA[]]></picture>
|
|
54
|
+
<picture><![CDATA[]]></picture>
|
|
55
|
+
</pictures>
|
|
56
|
+
<!-- vehicle identification number -->
|
|
57
|
+
<vin><![CDATA[]]></vin>
|
|
58
|
+
<!-- 0 if it is a used car, 1 if it is a new car -->
|
|
59
|
+
<is_new><![CDATA[]]></is_new>
|
|
60
|
+
<!-- date of the ad in the following format DD/MM/YYYY -->
|
|
61
|
+
<date><![CDATA[]]></date>
|
|
62
|
+
<!-- time of the ad in the following format HH:MM -->
|
|
63
|
+
<time><![CDATA[]]></time>
|
|
64
|
+
</ad>
|
|
65
|
+
<ad>
|
|
66
|
+
<!-- URL of the ad on your website -->
|
|
67
|
+
<url><![CDATA[]]></url>
|
|
68
|
+
<!-- title of the ad -->
|
|
69
|
+
<title><![CDATA[]]></title>
|
|
70
|
+
<!-- description of the car -->
|
|
71
|
+
<content><![CDATA[]]></content>
|
|
72
|
+
<!-- price of the car (integer value) / set the "unit" to currency of your country -->
|
|
73
|
+
<price unit="euro"><![CDATA[]]></price>
|
|
74
|
+
<!-- make of the car -->
|
|
75
|
+
<make><![CDATA[]]></make>
|
|
76
|
+
<!-- model of the car -->
|
|
77
|
+
<model><![CDATA[]]></model>
|
|
78
|
+
<!-- engine size of the car / set the "unit" to cc (cubic centimeter) or l (litre) -->
|
|
79
|
+
<engine_size unit="l"><![CDATA[]]></engine_size>
|
|
80
|
+
<!-- trim of the car -->
|
|
81
|
+
<trim><![CDATA[]]></trim>
|
|
82
|
+
<!-- body type of the vehicle / estate, hatchback, 4x4, etc -->
|
|
83
|
+
<body_type><![CDATA[]]></body_type>
|
|
84
|
+
<!-- colour of the car -->
|
|
85
|
+
<color><![CDATA[]]></color>
|
|
86
|
+
<!-- year of the car -->
|
|
87
|
+
<year><![CDATA[]]></year>
|
|
88
|
+
<!-- mileage / set "unit" to km (kilometers) or mi (miles) -->
|
|
89
|
+
<mileage unit="km"><![CDATA[]]></mileage>
|
|
90
|
+
<!-- fuel type (i.e. petrol, diesel, etc) -->
|
|
91
|
+
<fuel_type><![CDATA[]]></fuel_type>
|
|
92
|
+
<!-- transmission of the car (manual/automatic) -->
|
|
93
|
+
<transmission><![CDATA[]]></transmission>
|
|
94
|
+
<!-- power of the car / set the "unit" to kw (kilowatt) or hp (horsepower) -->
|
|
95
|
+
<power unit="kw"><![CDATA[]]></power>
|
|
96
|
+
<!-- number of doors -->
|
|
97
|
+
<doors><![CDATA[]]></doors>
|
|
98
|
+
<!-- number of seats in the car -->
|
|
99
|
+
<seats><![CDATA[]]></seats>
|
|
100
|
+
<!-- number of gears -->
|
|
101
|
+
<gears><![CDATA[]]></gears>
|
|
102
|
+
<!-- location of the car -->
|
|
103
|
+
<location>
|
|
104
|
+
<!-- city name -->
|
|
105
|
+
<city><![CDATA[]]></city>
|
|
106
|
+
<!-- postcode -->
|
|
107
|
+
<postcode><![CDATA[]]></postcode>
|
|
108
|
+
<!-- county/state name -->
|
|
109
|
+
<region><![CDATA[]]></region>
|
|
110
|
+
</location>
|
|
111
|
+
<!-- URLs of the pictures for this ad / please do not include corporate images with information such as "No image available" -->
|
|
112
|
+
<pictures>
|
|
113
|
+
<picture><![CDATA[]]></picture>
|
|
114
|
+
<picture><![CDATA[]]></picture>
|
|
115
|
+
</pictures>
|
|
116
|
+
<!-- vehicle identification number -->
|
|
117
|
+
<vin><![CDATA[]]></vin>
|
|
118
|
+
<!-- 0 if it is a used car, 1 if it is a new car -->
|
|
119
|
+
<is_new><![CDATA[]]></is_new>
|
|
120
|
+
<!-- date of the ad in the following format DD/MM/YYYY -->
|
|
121
|
+
<date><![CDATA[]]></date>
|
|
122
|
+
<!-- time of the ad in the following format HH:MM -->
|
|
123
|
+
<time><![CDATA[]]></time>
|
|
124
|
+
</ad>
|
|
125
|
+
</ooyyo>
|