@bytebrand/export-cars 1.26.31 → 1.26.33
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/autouncle.config.d.ts +14 -0
- package/dist/configs/autouncle.config.js +36 -3
- package/dist/configs/autouncle.config.js.map +1 -1
- package/dist/configs/meinstadt.config.d.ts +15 -0
- package/dist/configs/meinstadt.config.js +42 -3
- package/dist/configs/meinstadt.config.js.map +1 -1
- package/dist/constants/autouncle.contants.d.ts +1 -3
- package/dist/constants/autouncle.contants.js +2 -4
- package/dist/constants/autouncle.contants.js.map +1 -1
- package/dist/exporters/autouncle.exporter.d.ts +3 -2
- package/dist/exporters/autouncle.exporter.js +45 -27
- package/dist/exporters/autouncle.exporter.js.map +1 -1
- package/dist/exporters/meinstadt.exporter.d.ts +3 -2
- package/dist/exporters/meinstadt.exporter.js +40 -23
- package/dist/exporters/meinstadt.exporter.js.map +1 -1
- package/dist/formats/autouncle-json.builder.js +1 -4
- package/dist/formats/autouncle-json.builder.js.map +1 -1
- package/dist/interfaces/export-item.interface.d.ts +8 -12
- package/dist/interfaces/openai-ads.interface.d.ts +2 -1
- package/dist/interfaces/openai-ads.interface.js +1 -0
- package/dist/interfaces/openai-ads.interface.js.map +1 -1
- package/dist/interfaces/params.interface.d.ts +6 -0
- package/dist/services/autouncle-feed-run.d.ts +66 -0
- package/dist/services/autouncle-feed-run.js +135 -0
- package/dist/services/autouncle-feed-run.js.map +1 -0
- package/dist/services/meinstadt-feed-run.d.ts +81 -0
- package/dist/services/meinstadt-feed-run.js +166 -0
- package/dist/services/meinstadt-feed-run.js.map +1 -0
- package/dist/services/openai-ads-feed-run.d.ts +1 -0
- package/dist/services/openai-ads-feed-run.js +4 -0
- package/dist/services/openai-ads-feed-run.js.map +1 -1
- package/dist/transformers/autouncle.transformer.d.ts +38 -22
- package/dist/transformers/autouncle.transformer.js +156 -144
- package/dist/transformers/autouncle.transformer.js.map +1 -1
- package/dist/transformers/meinstadt.transformer.d.ts +17 -13
- package/dist/transformers/meinstadt.transformer.js +88 -61
- package/dist/transformers/meinstadt.transformer.js.map +1 -1
- package/dist/transformers/openai-ads-finance.d.ts +2 -0
- package/dist/transformers/openai-ads-finance.js +39 -0
- package/dist/transformers/openai-ads-finance.js.map +1 -0
- package/dist/transformers/openai-ads.transformer.d.ts +9 -1
- package/dist/transformers/openai-ads.transformer.js +25 -5
- package/dist/transformers/openai-ads.transformer.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/autouncle/AUDIT.md +97 -0
- package/docs/autouncle/PARTNER-QUESTIONS.md +53 -0
- package/docs/autouncle/RUNBOOK.md +64 -0
- package/docs/meinestadt/AUDIT.md +146 -0
- package/docs/meinestadt/PARTNER-QUESTIONS.md +54 -0
- package/docs/meinestadt/RUNBOOK.md +64 -0
- package/docs/openai-ads/AUDIT.md +2 -1
- package/docs/openai-ads/RUNBOOK.md +26 -2
- package/package.json +3 -2
- package/src/configs/autouncle.config.ts +51 -3
- package/src/configs/meinstadt.config.ts +57 -2
- package/src/constants/autouncle.contants.ts +2 -3
- package/src/exporters/autouncle.exporter.ts +59 -37
- package/src/exporters/meinstadt.exporter.ts +53 -32
- package/src/formats/autouncle-json.builder.ts +2 -4
- package/src/interfaces/export-item.interface.ts +15 -12
- package/src/interfaces/openai-ads.interface.ts +2 -0
- package/src/interfaces/params.interface.ts +10 -0
- package/src/services/autouncle-feed-run.ts +197 -0
- package/src/services/meinstadt-feed-run.ts +240 -0
- package/src/services/openai-ads-feed-run.ts +6 -0
- package/src/transformers/autouncle.transformer.ts +217 -175
- package/src/transformers/meinstadt.transformer.ts +114 -68
- package/src/transformers/openai-ads-finance.ts +60 -0
- package/src/transformers/openai-ads.transformer.ts +32 -6
- package/test/autouncle-feed-run.test.ts +256 -0
- package/test/autouncle-mapping.test.ts +225 -0
- package/test/fixtures/meinstadt.fixture.ts +87 -0
- package/test/fixtures/openai-ads-finance-titles.json +325 -0
- package/test/meinstadt-feed-run.test.ts +288 -0
- package/test/meinstadt-mapping.test.ts +215 -0
- package/test/openai-ads-finance.test.ts +109 -0
|
@@ -1,181 +1,259 @@
|
|
|
1
1
|
import { TranslatorExtended } from '@bytebrand/schema-tools';
|
|
2
2
|
import { de, deVehicleDetailsPage, deVehicleProps } from '@bytebrand/i18n-dictionaries';
|
|
3
|
-
import { get, toString, isEmpty, toSafeInteger } from 'lodash';
|
|
3
|
+
import { get, toString, isEmpty, toNumber, toSafeInteger } from 'lodash';
|
|
4
4
|
import * as moment from 'moment';
|
|
5
5
|
import { DataSchemes } from '@bytebrand/car-schema';
|
|
6
6
|
import { DePostalCodesService } from '@bytebrand/car-basic-data-scope';
|
|
7
7
|
import { DePostalCodesEntryInterface } from '@bytebrand/car-basic-data-scope/dist/interfaces/DePostalCodesInterface';
|
|
8
8
|
|
|
9
|
-
import { ConfigService, ImagesService } from '../services';
|
|
10
9
|
import { Injectable } from '@nestjs/common';
|
|
11
|
-
import {
|
|
10
|
+
import { DEFAULT_REGION, MIN_MILEAGES } from '../constants/autouncle.contants';
|
|
12
11
|
import { AutouncleJsonItem } from '../interfaces';
|
|
13
|
-
import {
|
|
12
|
+
import { OpenAiImageSelection } from './openai-image-fields';
|
|
13
|
+
import { toUnixSeconds } from './meinstadt.transformer';
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
export enum AutouncleExclusion {
|
|
16
|
+
duplicateId = 'duplicate_id',
|
|
17
|
+
filtered = 'filtered',
|
|
18
|
+
noId = 'no_id',
|
|
19
|
+
noDealer = 'no_dealer',
|
|
20
|
+
notAvailable = 'reserved_or_sold',
|
|
21
|
+
noPrice = 'no_valid_price',
|
|
22
|
+
noImage = 'no_valid_image',
|
|
23
|
+
}
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
// Online cars flagged like this are not sellable stock; the partner gets the file without them.
|
|
26
|
+
const NOT_AVAILABLE = ['selector_offerAvailability_reserved', 'selector_offerAvailability_sold'];
|
|
27
|
+
|
|
28
|
+
export interface AutouncleAddress {
|
|
29
|
+
city: string;
|
|
30
|
+
addr1: string;
|
|
31
|
+
region: string;
|
|
32
|
+
postal_code: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AutouncleMappingContext {
|
|
36
|
+
domain: string;
|
|
37
|
+
address: AutouncleAddress;
|
|
38
|
+
// final, validated image URLs, hero first
|
|
39
|
+
images: string[];
|
|
40
|
+
// German label of a selector (TranslatorExtended.translateVal)
|
|
41
|
+
translate: (key: string) => string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type AutouncleMapResult = { ok: true; item: AutouncleJsonItem } | { ok: false; reason: AutouncleExclusion };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The AutoUncle `images` list (ADV-323) from the shared validated clean selection (ADV-336, same
|
|
48
|
+
* rules as the OpenAI and meinestadt feeds): valid 3/4 hero first, then every other valid image in
|
|
49
|
+
* clean-profile order. No valid hero => [] (car is skipped, never a placeholder). maxImages > 0
|
|
50
|
+
* cuts the list — no partner limit is known, so the default is no cut.
|
|
51
|
+
*/
|
|
52
|
+
export const toAutouncleImages = (selection: OpenAiImageSelection, maxImages = 0): string[] => {
|
|
53
|
+
if (!selection?.fields) {
|
|
54
|
+
return [];
|
|
24
55
|
}
|
|
56
|
+
const urls = [selection.fields.image_url, ...selection.fields.additional_image_urls];
|
|
57
|
+
return maxImages > 0 ? urls.slice(0, maxImages) : urls;
|
|
58
|
+
};
|
|
25
59
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Year of the first registration. Missing, 0, null or unparsable => '' (unknown), never the Unix
|
|
62
|
+
* epoch year 1970 (ACF-3717/3725: `get(car, path, 0)` returned null for a null value and
|
|
63
|
+
* moment(0) made it 1970). A year outside 1900..next year is a unit error (ms instead of s) => ''.
|
|
64
|
+
*/
|
|
65
|
+
export const getAutouncleYear = (firstRegistration: unknown, now: Date = new Date()): string => {
|
|
66
|
+
const seconds = toUnixSeconds(firstRegistration);
|
|
67
|
+
if (seconds === null) {
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
const year = toSafeInteger(moment(seconds * 1000).format('YYYY'));
|
|
71
|
+
return year >= 1900 && year <= now.getFullYear() + 1 ? toString(year) : '';
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Unchanged rule: a car without a first registration is new.
|
|
75
|
+
export const getAutouncleStatus = (firstRegistration: unknown): 'Neuwagen' | 'Gebrauchtwagen' =>
|
|
76
|
+
toUnixSeconds(firstRegistration) === null ? 'Neuwagen' : 'Gebrauchtwagen';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* End-customer purchase price, `EUR <amount>` as the feed always had it. Only price.currentSalesPrice —
|
|
80
|
+
* never a monthly financing/leasing rate, never price.purchasePrice (dealer cost). null = no valid
|
|
81
|
+
* positive price; the car is not exported (it was sent as "EUR " before).
|
|
82
|
+
*/
|
|
83
|
+
export const getAutounclePrice = (value: unknown): string | null => {
|
|
84
|
+
if (value === null || value === undefined || value === '' || typeof value === 'boolean') {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const amount = toNumber(value);
|
|
88
|
+
return Number.isFinite(amount) && amount > 0 ? `EUR ${value}` : null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const withUnit = (value: unknown, unit: string): string => {
|
|
92
|
+
const text = toString(value).trim();
|
|
93
|
+
return text ? `${text}${unit}` : '';
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const BODY_STYLES = {
|
|
97
|
+
selector_unknown: 'Other',
|
|
98
|
+
selector_category_combi: 'Wagon',
|
|
99
|
+
selector_category_sedan: 'Sedan',
|
|
100
|
+
selector_category_compact: 'Small_car',
|
|
101
|
+
selector_category_coupe: 'Coupe',
|
|
102
|
+
selector_category_convertible: 'Convertible',
|
|
103
|
+
selector_category_suv: 'Suv',
|
|
104
|
+
selector_category_van: 'Van',
|
|
105
|
+
selector_category_transporter: 'Truck',
|
|
106
|
+
selector_category_minibus: 'Minivan',
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const FUELS = {
|
|
110
|
+
selector_fuel_petrol: 'Gasoline',
|
|
111
|
+
selector_fuel_diesel: 'Diesel',
|
|
112
|
+
selector_fuel_electrical: 'Electric',
|
|
113
|
+
selector_fuel_hybridElectricalPetrol: 'Hybrid',
|
|
114
|
+
selector_fuel_hybridElectricalDiesel: 'Hybrid',
|
|
115
|
+
selector_fuel_carGas: 'CarGas',
|
|
116
|
+
selector_fuel_naturalGas: 'NaturalGas',
|
|
117
|
+
selector_fuel_ethanol: 'Ethanol',
|
|
118
|
+
selector_fuel_hydrogen: 'Hydrogen',
|
|
119
|
+
selector_unknown: 'Other',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const TRANSMISSIONS = {
|
|
123
|
+
selector_gearbox_manualShift: 'manual',
|
|
124
|
+
selector_gearbox_semiAutomatic: 'automatic',
|
|
125
|
+
selector_gearbox_automatic: 'automatic',
|
|
126
|
+
selector_unknown: 'other',
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Unmapped, null or missing selector -> the map's selector_unknown value; the key never drops out
|
|
130
|
+
// of the JSON (JSON.stringify omits undefined).
|
|
131
|
+
const fromSelector = (map: Record<string, string>, selector: unknown): string =>
|
|
132
|
+
(typeof selector === 'string' && map[selector]) || map.selector_unknown;
|
|
133
|
+
|
|
134
|
+
const getMileage = (car: DataSchemes.Car): number => {
|
|
135
|
+
const mileage = get(car, 'mainData.mileage', 0);
|
|
136
|
+
return mileage <= MIN_MILEAGES ? MIN_MILEAGES + 1 : mileage;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const getExteriorColor = (car: DataSchemes.Car, translate: (key: string) => string): string => {
|
|
140
|
+
let colorSelection = get(car, 'exterior.exteriorColor', 'other');
|
|
141
|
+
if (!colorSelection || colorSelection === 'selector_unknown') {
|
|
142
|
+
colorSelection = 'other';
|
|
143
|
+
}
|
|
144
|
+
return translate(colorSelection);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Car-level exclusions that need no dealer lookup and no image probe; null = exportable.
|
|
148
|
+
export const getAutouncleExclusion = (car: DataSchemes.Car): AutouncleExclusion | null => {
|
|
149
|
+
if (!toString(get(car, '_id', '')).trim()) {
|
|
150
|
+
return AutouncleExclusion.noId;
|
|
151
|
+
}
|
|
152
|
+
if (!toString(get(car, 'metaData.ownerId', '')).trim()) {
|
|
153
|
+
return AutouncleExclusion.noDealer;
|
|
154
|
+
}
|
|
155
|
+
if (NOT_AVAILABLE.includes(toString(get(car, 'offer.offerAvailability', '')))) {
|
|
156
|
+
return AutouncleExclusion.notAvailable;
|
|
157
|
+
}
|
|
158
|
+
if (!getAutounclePrice(get(car, 'price.currentSalesPrice', null))) {
|
|
159
|
+
return AutouncleExclusion.noPrice;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One `ads[]` object. Keys, their order and the historic spellings (co2_effiency_class,
|
|
166
|
+
* enviromental_sticker) are kept until AutoUncle confirms a current schema.
|
|
167
|
+
* Identity: back_link carries the stable CBD car `_id`; seller_id is the DEALER (metaData.ownerId),
|
|
168
|
+
* the same for every car of one dealer.
|
|
169
|
+
*/
|
|
170
|
+
export const mapAutouncleItem = (car: DataSchemes.Car, ctx: AutouncleMappingContext): AutouncleMapResult => {
|
|
171
|
+
const excluded = getAutouncleExclusion(car);
|
|
172
|
+
if (excluded) {
|
|
173
|
+
return { ok: false, reason: excluded };
|
|
174
|
+
}
|
|
175
|
+
if (!ctx.images.length) {
|
|
176
|
+
return { ok: false, reason: AutouncleExclusion.noImage };
|
|
177
|
+
}
|
|
178
|
+
const autodeId = toString(get(car, '_id', '')).trim();
|
|
179
|
+
const sellerId = toString(get(car, 'metaData.ownerId', '')).trim();
|
|
180
|
+
const price = getAutounclePrice(get(car, 'price.currentSalesPrice', null));
|
|
181
|
+
const firstRegistration = get(car, 'mainData.firstRegistration', null);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
item: {
|
|
186
|
+
back_link: `https://www.${ctx.domain}/search/vehicle/${autodeId}?source=classifieds&medium=cpc&campaign=autouncle`,
|
|
44
187
|
make: toString(get(car, 'mainData.make', '')),
|
|
45
188
|
model_name: toString(get(car, 'mainData.model', '')),
|
|
46
189
|
model_variant: toString(get(car, 'mainData.subModel', '')),
|
|
47
|
-
fuel:
|
|
48
|
-
body:
|
|
190
|
+
fuel: fromSelector(FUELS, get(car, 'consumption.fuel', null)),
|
|
191
|
+
body: fromSelector(BODY_STYLES, get(car, 'mainData.category', null)),
|
|
49
192
|
doors: toString(get(car, 'mainData.doors', '')),
|
|
50
|
-
price
|
|
51
|
-
year:
|
|
52
|
-
status:
|
|
53
|
-
mileage: toString(
|
|
54
|
-
leasing:
|
|
55
|
-
color:
|
|
56
|
-
|
|
193
|
+
price,
|
|
194
|
+
year: getAutouncleYear(firstRegistration),
|
|
195
|
+
status: getAutouncleStatus(firstRegistration),
|
|
196
|
+
mileage: toString(getMileage(car)) + 'km',
|
|
197
|
+
leasing: '',
|
|
198
|
+
color: getExteriorColor(car, ctx.translate),
|
|
199
|
+
// no unit without a value (was "ccm")
|
|
200
|
+
engine_size: withUnit(get(car, 'engineData.cubicCapacity', ''), 'ccm'),
|
|
57
201
|
power: toString(get(car, 'engineData.powerKW', '')),
|
|
58
|
-
gear_type:
|
|
59
|
-
has_4wd:
|
|
202
|
+
gear_type: fromSelector(TRANSMISSIONS, get(car, 'driveSuspension.gearbox', null)),
|
|
203
|
+
has_4wd: '',
|
|
60
204
|
seller_type: 'Dealer',
|
|
61
|
-
seller_region:
|
|
62
|
-
seller_city:
|
|
63
|
-
seller_street_address:
|
|
64
|
-
seller_postcode:
|
|
205
|
+
seller_region: ctx.address.region,
|
|
206
|
+
seller_city: ctx.address.city,
|
|
207
|
+
seller_street_address: ctx.address.addr1,
|
|
208
|
+
seller_postcode: ctx.address.postal_code,
|
|
65
209
|
seller_name: 'auto.de',
|
|
66
|
-
seller_id:
|
|
210
|
+
seller_id: sellerId,
|
|
67
211
|
description: toString(get(car, 'offer.offerDescription', '')),
|
|
68
|
-
images:
|
|
212
|
+
images: ctx.images.map(url => ({ url })),
|
|
69
213
|
fuel_economy: toString(get(car, 'consumption.consumptionCombined', '')),
|
|
70
214
|
co2_emission: toString(get(car, 'environmentEmissions.co2', '')),
|
|
71
215
|
// ADV-187: canonical CO2-derived WLTP class, not the unvalidated legacy NEFZ class.
|
|
72
216
|
co2_effiency_class: toString(get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', '')),
|
|
73
217
|
euro_emission_class: toString(get(car, 'environmentEmissions.emissionClass', '')),
|
|
74
218
|
enviromental_sticker: toString(get(car, 'environmentEmissions.emissionSticker', '')),
|
|
75
|
-
}
|
|
76
|
-
}
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
};
|
|
77
222
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return 'Gebrauchtwagen';
|
|
83
|
-
}
|
|
223
|
+
@Injectable()
|
|
224
|
+
export class AutouncleTransformer {
|
|
225
|
+
private translator: TranslatorExtended;
|
|
226
|
+
private postalCodesService: DePostalCodesService;
|
|
84
227
|
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
return '';
|
|
228
|
+
constructor() {
|
|
229
|
+
const dict = { ...de, ...deVehicleProps, ...deVehicleDetailsPage };
|
|
230
|
+
this.translator = new TranslatorExtended(dict);
|
|
231
|
+
this.postalCodesService = DePostalCodesService.getInstance();
|
|
91
232
|
}
|
|
92
233
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
selector_unknown: 'Other',
|
|
96
|
-
selector_category_combi: 'Wagon',
|
|
97
|
-
selector_category_sedan: 'Sedan',
|
|
98
|
-
selector_category_compact: 'Small_car',
|
|
99
|
-
selector_category_coupe: 'Coupe',
|
|
100
|
-
selector_category_convertible: 'Convertible',
|
|
101
|
-
selector_category_suv: 'Suv',
|
|
102
|
-
selector_category_van: 'Van',
|
|
103
|
-
selector_category_transporter: 'Truck',
|
|
104
|
-
selector_category_minibus: 'Minivan',
|
|
105
|
-
};
|
|
106
|
-
const category = get(car, 'mainData.category', 'selector_unknown');
|
|
107
|
-
return get(available, category);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
private mapFuel(car: DataSchemes.Car): string {
|
|
111
|
-
const available = {
|
|
112
|
-
selector_fuel_petrol: 'Gasoline',
|
|
113
|
-
selector_fuel_diesel: 'Diesel',
|
|
114
|
-
selector_fuel_electrical: 'Electric',
|
|
115
|
-
selector_fuel_hybridElectricalPetrol: 'Hybrid',
|
|
116
|
-
selector_fuel_hybridElectricalDiesel: 'Hybrid',
|
|
117
|
-
selector_fuel_carGas: 'CarGas',
|
|
118
|
-
selector_fuel_naturalGas: 'NaturalGas',
|
|
119
|
-
selector_fuel_ethanol: 'Ethanol',
|
|
120
|
-
selector_fuel_hydrogen: 'Hydrogen',
|
|
121
|
-
selector_unknown: 'Other',
|
|
122
|
-
};
|
|
123
|
-
const fuel = get(car, 'consumption.fuel', 'selector_unknown');
|
|
124
|
-
return get(available, fuel);
|
|
234
|
+
translate(key: string): string {
|
|
235
|
+
return this.translator.translateVal(key);
|
|
125
236
|
}
|
|
126
237
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
};
|
|
134
|
-
const transmission = get(car, 'driveSuspension.gearbox', 'selector_unknown');
|
|
135
|
-
return get(available, transmission);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
private getPrice(car: DataSchemes.Car): string {
|
|
139
|
-
const price = get(car, 'price.currentSalesPrice', '');
|
|
140
|
-
return `EUR ${price}`;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
private async getAddress(
|
|
144
|
-
car: DataSchemes.Car,
|
|
145
|
-
owner: unknown,
|
|
146
|
-
): Promise<{
|
|
147
|
-
city: string;
|
|
148
|
-
addr1: unknown;
|
|
149
|
-
region: string;
|
|
150
|
-
country: string;
|
|
151
|
-
postal_code: string;
|
|
152
|
-
latitude: number;
|
|
153
|
-
longitude: number;
|
|
154
|
-
}> {
|
|
238
|
+
/**
|
|
239
|
+
* Dealer address from the UPA profile (businessAddress, else mainAddress). The postal code table
|
|
240
|
+
* only adds the region; when it does not know the place the dealer's own street/city/zip stay
|
|
241
|
+
* (they were blanked before) and the region is "Deutschland".
|
|
242
|
+
*/
|
|
243
|
+
async getAddress(owner: unknown): Promise<AutouncleAddress> {
|
|
155
244
|
let addressPath = 'businessAddress';
|
|
156
245
|
if (this.isEmptyAddress(get(owner, addressPath, {}))) {
|
|
157
246
|
addressPath = 'mainAddress';
|
|
158
247
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (this.isEmptyLocation(locationEntity)) {
|
|
165
|
-
addr1 = DEFAULT_ADDR1;
|
|
166
|
-
city = DEFAULT_CITY;
|
|
167
|
-
zip = DEFAULT_ZIP;
|
|
168
|
-
locationEntity = await this.getLocationByInfo({ city, zip: toSafeInteger(zip) });
|
|
169
|
-
}
|
|
248
|
+
const city = toString(get(owner, `${addressPath}.city`, ''));
|
|
249
|
+
const zip = toString(get(owner, `${addressPath}.zip`, ''));
|
|
250
|
+
const addr1 = toString(get(owner, `${addressPath}.street`, ''));
|
|
251
|
+
const location = await this.getLocationByInfo({ city, zip: toSafeInteger(zip) });
|
|
170
252
|
|
|
171
|
-
const coords = await this.getCoords(car, locationEntity);
|
|
172
|
-
const country: string = get(owner, `${addressPath}.country.name`, '');
|
|
173
253
|
return {
|
|
174
|
-
...coords,
|
|
175
254
|
city,
|
|
176
255
|
addr1,
|
|
177
|
-
region:
|
|
178
|
-
country: country || 'Deutschland',
|
|
256
|
+
region: this.isEmptyLocation(location) ? DEFAULT_REGION : toString(location['Place Name']),
|
|
179
257
|
postal_code: zip,
|
|
180
258
|
};
|
|
181
259
|
}
|
|
@@ -190,48 +268,12 @@ export class AutouncleTransformer {
|
|
|
190
268
|
return ['city', 'zip', 'country'].every((key: string) => isEmpty(address[key]));
|
|
191
269
|
}
|
|
192
270
|
|
|
193
|
-
private async getCoords(
|
|
194
|
-
car: DataSchemes.Car,
|
|
195
|
-
locationEntry: DePostalCodesEntryInterface,
|
|
196
|
-
): Promise<Pick<DataSchemes.Location, 'latitude' | 'longitude'>> {
|
|
197
|
-
const data: Pick<DataSchemes.Location, 'latitude' | 'longitude'> = {
|
|
198
|
-
latitude: get(car, 'location.latitude'),
|
|
199
|
-
longitude: get(car, 'location.longitude'),
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
if (data.latitude && data.longitude) {
|
|
203
|
-
return data;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return {
|
|
207
|
-
longitude: get(locationEntry, 'Longitude'),
|
|
208
|
-
latitude: get(locationEntry, 'Latitude'),
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
271
|
private async getLocationByInfo(info: { zip: number; city: string }): Promise<DePostalCodesEntryInterface> {
|
|
213
|
-
let entry: DePostalCodesEntryInterface = await this.postalCodesService.getLocationByPostalCode(info.zip);
|
|
272
|
+
let entry: DePostalCodesEntryInterface = info.zip ? await this.postalCodesService.getLocationByPostalCode(info.zip) : undefined;
|
|
214
273
|
|
|
215
|
-
if (!entry) {
|
|
274
|
+
if (!entry && info.city) {
|
|
216
275
|
entry = await this.postalCodesService.getLocationByCity(info.city);
|
|
217
276
|
}
|
|
218
277
|
return entry;
|
|
219
278
|
}
|
|
220
|
-
|
|
221
|
-
private getExteriorColor(car: DataSchemes.Car): string {
|
|
222
|
-
let colorSelection = get(car, 'exterior.exteriorColor', 'other');
|
|
223
|
-
if (!colorSelection || colorSelection === 'selector_unknown') {
|
|
224
|
-
colorSelection = 'other';
|
|
225
|
-
}
|
|
226
|
-
return this.translator.translateVal(colorSelection);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
private getMileage(car: DataSchemes.Car): number {
|
|
230
|
-
const mileage = get(car, 'mainData.mileage', 0);
|
|
231
|
-
return mileage <= MIN_MILEAGES ? MIN_MILEAGES + 1 : mileage;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
private has4WD(): string {
|
|
235
|
-
return '';
|
|
236
|
-
}
|
|
237
279
|
}
|
|
@@ -3,90 +3,136 @@ import { get, toString } from 'lodash';
|
|
|
3
3
|
import { Injectable } from '@nestjs/common';
|
|
4
4
|
|
|
5
5
|
import { CarDescriptionFactory, DescriptionTypeEnum } from '@bytebrand/car-description';
|
|
6
|
-
import { ImboImageResponseInterface } from '@bytebrand/chunkable';
|
|
7
6
|
import { DataSchemes } from '@bytebrand/car-schema';
|
|
8
7
|
import { de } from '@bytebrand/i18n-dictionaries';
|
|
9
8
|
|
|
10
|
-
import {
|
|
9
|
+
import { UserService } from '../services';
|
|
11
10
|
import { MeinstadtJsonItem } from '../interfaces';
|
|
11
|
+
import { OpenAiImageSelection } from './openai-image-fields';
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}): Promise<Partial<MeinstadtJsonItem>> {
|
|
28
|
-
const carImages = await this.imageService.getImages(car, images, this.configService.config.imageType, false);
|
|
13
|
+
/**
|
|
14
|
+
* The meinestadt `images` list from the shared validated clean selection (ADV-336, same rules as
|
|
15
|
+
* the OpenAI feed): valid 3/4 hero first, then every other valid image in clean-profile order.
|
|
16
|
+
* No valid hero => [] (car is skipped). maxImages > 0 cuts the exported list only; the gallery
|
|
17
|
+
* itself is untouched. Another image profile (e.g. the planned marketing designs) plugs in by
|
|
18
|
+
* producing a different selection — imagesCount always follows the list exported here.
|
|
19
|
+
*/
|
|
20
|
+
export const toMeinstadtImages = (selection: OpenAiImageSelection, maxImages = 0): string[] => {
|
|
21
|
+
if (!selection?.fields) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const urls = [selection.fields.image_url, ...selection.fields.additional_image_urls];
|
|
25
|
+
return maxImages > 0 ? urls.slice(0, maxImages) : urls;
|
|
26
|
+
};
|
|
29
27
|
|
|
30
|
-
|
|
28
|
+
export interface MeinstadtMappingContext {
|
|
29
|
+
domain: string;
|
|
30
|
+
// validated tariff (parseMeinstadtCpc) or null
|
|
31
|
+
cpc: string | null;
|
|
32
|
+
equipment: string;
|
|
33
|
+
// final, validated image URLs — imagesCount is derived from exactly this list
|
|
34
|
+
images: string[];
|
|
35
|
+
translate?: (key: string) => string | undefined;
|
|
36
|
+
}
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
mileage: get(car, 'mainData.mileage', null),
|
|
41
|
-
initialRegistration: get(car, 'mainData.firstRegistration', null),
|
|
42
|
-
offerCreationDate: get(car, 'metaData.creationDate', null),
|
|
43
|
-
numberOfDoors: get(car, 'mainData.doors', null),
|
|
44
|
-
numberOfSeats: get(car, 'mainData.seats', null),
|
|
45
|
-
numberOfPreviousOwners: get(car, 'mainData.numberOfPreviousOwners', null),
|
|
46
|
-
price: get(car, 'price.currentSalesPrice', null),
|
|
47
|
-
power: get(car, 'engineData.powerPS', null),
|
|
48
|
-
co2Emissions: get(car, 'environmentEmissions.co2', null),
|
|
49
|
-
fuelConsumptionCombined: get(car, 'consumption.consumptionCombined', null),
|
|
50
|
-
equipment: await this.getDescription(car),
|
|
51
|
-
color: this.translate(get(car, 'exterior.exteriorColor', null)),
|
|
52
|
-
bodyType: this.translate(get(car, 'mainData.category', null)),
|
|
53
|
-
emissionClass: this.translate(get(car, 'environmentEmissions.emissionClass', null)),
|
|
54
|
-
emissionSticker: this.translate(get(car, 'environmentEmissions.emissionSticker', null)),
|
|
55
|
-
fuelType: this.translate(get(car, 'consumption.fuel', null)),
|
|
56
|
-
'transmissionType/transmission': this.translate(get(car, 'driveSuspension.gearbox', null)),
|
|
57
|
-
imagesCount: carImages.length,
|
|
58
|
-
'customerType/sellerType': 'Händler',
|
|
59
|
-
usageState: this.translate(this.getUsageState(car)),
|
|
60
|
-
images: carImages[0],
|
|
61
|
-
};
|
|
38
|
+
/**
|
|
39
|
+
* mainData.accidentDamaged is a DAMAGE flag ("Unfallschaden"), accidentFree its negation.
|
|
40
|
+
* Only an explicit `false` makes a car accident-free; missing/null (no damage history) stays
|
|
41
|
+
* unknown and is sent as null, never as accident-free.
|
|
42
|
+
*/
|
|
43
|
+
export const toAccidentFree = (accidentDamaged: unknown): boolean | null => {
|
|
44
|
+
if (accidentDamaged === false) {
|
|
45
|
+
return true;
|
|
62
46
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return `${car.mainData.make} ${car.mainData.model} ${car.mainData.subModel}`;
|
|
47
|
+
if (accidentDamaged === true) {
|
|
48
|
+
return false;
|
|
66
49
|
}
|
|
50
|
+
return null;
|
|
51
|
+
};
|
|
67
52
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
53
|
+
/**
|
|
54
|
+
* CBD dates (firstRegistration, creationDate) as Unix seconds, the format the feed always had.
|
|
55
|
+
* Date objects / date strings are converted; 0, negative, unparsable or missing values are null —
|
|
56
|
+
* never a Unix-zero (1970) date.
|
|
57
|
+
*/
|
|
58
|
+
export const toUnixSeconds = (value: unknown): number | null => {
|
|
59
|
+
let seconds: number;
|
|
60
|
+
if (typeof value === 'number') {
|
|
61
|
+
seconds = value;
|
|
62
|
+
} else if (value instanceof Date) {
|
|
63
|
+
seconds = value.getTime() / 1000;
|
|
64
|
+
} else if (typeof value === 'string' && value.trim()) {
|
|
65
|
+
seconds = Date.parse(value) / 1000;
|
|
66
|
+
} else {
|
|
71
67
|
return null;
|
|
72
68
|
}
|
|
69
|
+
return Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : null;
|
|
70
|
+
};
|
|
73
71
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
72
|
+
// German label of a selector; unknown/unmapped selectors are null, not "Unbekannt".
|
|
73
|
+
export const translateSelector = (val: string | null, translate: (key: string) => string | undefined = key => de[key]): string | null => {
|
|
74
|
+
if (!val || val === 'selector_unknown') return null;
|
|
75
|
+
return translate(val) || null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const getUsageState = (car: DataSchemes.Car): string => {
|
|
79
|
+
const condition = get(car, 'mainData.condition', null);
|
|
80
|
+
if (!condition || condition === 'selector_unknown') {
|
|
81
|
+
const mileage = get(car, 'mainData.mileage', 0);
|
|
82
|
+
if (mileage >= 100) {
|
|
83
|
+
return 'selector_condition_used';
|
|
82
84
|
}
|
|
83
|
-
return
|
|
85
|
+
return 'selector_condition_new';
|
|
84
86
|
}
|
|
87
|
+
return condition;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const mapMeinstadtItem = (car: DataSchemes.Car, ctx: MeinstadtMappingContext): MeinstadtJsonItem => {
|
|
91
|
+
const carId = toString(get(car, '_id', null));
|
|
92
|
+
const translate = (val: string | null) => translateSelector(val, ctx.translate);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
id: get(car, 'metaData.vehicleId', null),
|
|
96
|
+
make: get(car, 'mainData.make', null),
|
|
97
|
+
model: get(car, 'mainData.model', null),
|
|
98
|
+
cpc: ctx.cpc,
|
|
99
|
+
listingUrl: `https://www.${ctx.domain}/search/vehicle/${carId}?source=classifieds&medium=listing&campaign=meinestadt`,
|
|
100
|
+
title: `${car.mainData.make} ${car.mainData.model} ${car.mainData.subModel}`,
|
|
101
|
+
accidentFree: toAccidentFree(get(car, 'mainData.accidentDamaged', null)),
|
|
102
|
+
mileage: get(car, 'mainData.mileage', null),
|
|
103
|
+
initialRegistration: toUnixSeconds(get(car, 'mainData.firstRegistration', null)),
|
|
104
|
+
offerCreationDate: toUnixSeconds(get(car, 'metaData.creationDate', null)),
|
|
105
|
+
numberOfDoors: get(car, 'mainData.doors', null),
|
|
106
|
+
numberOfSeats: get(car, 'mainData.seats', null),
|
|
107
|
+
numberOfPreviousOwners: get(car, 'mainData.numberOfPreviousOwners', null),
|
|
108
|
+
// end-customer purchase price (EUR) — never a rate, never price.purchasePrice (dealer cost)
|
|
109
|
+
price: get(car, 'price.currentSalesPrice', null),
|
|
110
|
+
// PS, not kW (workbook: "only put the value for PS")
|
|
111
|
+
power: get(car, 'engineData.powerPS', null),
|
|
112
|
+
co2Emissions: get(car, 'environmentEmissions.co2', null),
|
|
113
|
+
fuelConsumptionCombined: get(car, 'consumption.consumptionCombined', null),
|
|
114
|
+
equipment: ctx.equipment,
|
|
115
|
+
color: translate(get(car, 'exterior.exteriorColor', null)),
|
|
116
|
+
bodyType: translate(get(car, 'mainData.category', null)),
|
|
117
|
+
emissionClass: translate(get(car, 'environmentEmissions.emissionClass', null)),
|
|
118
|
+
emissionSticker: translate(get(car, 'environmentEmissions.emissionSticker', null)),
|
|
119
|
+
fuelType: translate(get(car, 'consumption.fuel', null)),
|
|
120
|
+
'transmissionType/transmission': translate(get(car, 'driveSuspension.gearbox', null)),
|
|
121
|
+
imagesCount: ctx.images.length,
|
|
122
|
+
'customerType/sellerType': 'Händler',
|
|
123
|
+
usageState: translate(getUsageState(car)),
|
|
124
|
+
images: [...ctx.images],
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
@Injectable()
|
|
129
|
+
export class MeinstadtTransformer {
|
|
130
|
+
constructor(private userService: UserService) {}
|
|
85
131
|
|
|
86
|
-
|
|
132
|
+
async getDescription(car: DataSchemes.Car): Promise<string> {
|
|
87
133
|
const dealerId = get(car, 'metaData.ownerId');
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
134
|
+
const descriptionSchema = await this.userService.getDefaultSummaryByDealerId(dealerId);
|
|
135
|
+
const descriptionBuilder = CarDescriptionFactory.create(DescriptionTypeEnum.Mobile, car);
|
|
136
|
+
return descriptionBuilder.transform(descriptionSchema);
|
|
91
137
|
}
|
|
92
138
|
}
|