@bytebrand/export-cars 1.22.26 → 1.22.27

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.
@@ -1,584 +1,584 @@
1
- import { escape, get, isNumber, join, round, toSafeInteger, toString, trim, truncate } from 'lodash';
2
- import { CarDescriptionFactory, DescriptionTypeEnum } from '@bytebrand/car-description';
3
- import { DataSchemes } from '@bytebrand/car-schema';
4
- import { TranslatorExtended } from '@bytebrand/schema-tools';
5
- import { de, deVehicleDetailsPage, deVehicleProps } from '@bytebrand/i18n-dictionaries';
6
- import { ImboImageResponseInterface } from '@bytebrand/chunkable';
7
- import { Injectable } from '@nestjs/common';
8
- import { ConfigService, ImagesService, TransformerService, UserService } from '../services';
9
- import { GoogleXmlItem } from '../interfaces';
10
- import { isValidVin } from '../utils';
11
-
12
- const GOOGLE_MAX_TRIM_LENGTH = 50;
13
- const TIMESTAMP_FIRST_YEAR = 1970;
14
-
15
- @Injectable()
16
- export class GoogleTransformer {
17
- private translator: TranslatorExtended;
18
-
19
- constructor(
20
- private userService: UserService,
21
- private configService: ConfigService,
22
- private imageService: ImagesService,
23
- private readonly transformService: TransformerService,
24
- ) {
25
- const dict = { ...de, ...deVehicleProps, ...deVehicleDetailsPage };
26
- this.translator = new TranslatorExtended(dict);
27
- }
28
-
29
- async transform({ car, images }: { car: DataSchemes.Car; images: Array<any> }): Promise<GoogleXmlItem> {
30
- if (!this.checkCarValidity(car, images))
31
- return null
32
-
33
- const carId = toString(get(car, '_id', ''));
34
- const transformedImages = await this.imageService.getExtIntDefaultImages(
35
- car,
36
- images,
37
- this.configService.config.imageType,
38
- false,
39
- );
40
-
41
- if (!transformedImages.extMain.length) {
42
- console.log(`Car ${car._id} has no extMain image`);
43
- return null
44
- }
45
-
46
- const allImages = [
47
- ...transformedImages.extMain,
48
- ...transformedImages.ext,
49
- ...transformedImages.int,
50
- ...transformedImages.feat
51
- ]
52
-
53
- const item: GoogleXmlItem = {
54
- google_product_category: '916',
55
- vehicle_fulfillment: {
56
- option: 'online',
57
- store_code: 'Online_shop'
58
- },
59
- certification: this.getCertification(car),
60
- title: this.getTitle(car),
61
- product_type: this.getCategory(car),
62
- VIN: this.getVin(car),
63
- id: carId,
64
- image_link: allImages[0],
65
- additional_image_link: allImages.slice(1, 10),
66
- ads_redirect: this.getLink(car),
67
- link: this.getLink(car),
68
- link_template: `${this.getLink(car)}?store={store_code}`,
69
- condition: this.mapState(car),
70
- price: this.getPrice(car),
71
- vehicle_msrp: this.getVehicleMsrp(car),
72
- // certified_pre-owned: '',
73
- brand: this.transformService.make(car),
74
- model: this.transformService.model(car),
75
- trim: this.getTrim(car),
76
- year: this.getYear(car),
77
- mileage: `${this.transformService.mileage(car)}km`,
78
- color: this.getColor(car),
79
- vehicle_option: this.getExtrasField(car),
80
- body_style: this.mapBodyStyle(car),
81
- engine: this.mapFuel(car),
82
- transmission: this.mapTransmission(car),
83
- drive: this.mapDrive(car),
84
- doors: this.getDoors(car),
85
- capacity: this.getCapacity(car),
86
- date_first_registered: this.getDateFirstRegistered(car),
87
- fuel_consumption: `${this.transformService.fuelConsumption(car)}l/100km`,
88
- energy_consumption: this.getEnergyConsumption(car),
89
- electric_range: this.getElectricRange(car),
90
- fuel_consumption_discharged_battery: this.getFuelConsumptionDischargedBattery(car),
91
- co2_emissions: `${this.transformService.cO2Emission(car)}g/km`,
92
- description: this.processText((await this.getDescription(car))),
93
- identifier_exists: 'yes',
94
- availability: 'in_stock',
95
- // custom_label_0: '',
96
- // custom_label_1: '',
97
- // custom_label_2: '',
98
- // custom_label_3: '',
99
- // custom_label_4: '',
100
-
101
- // availability: this.getAvailability(car),
102
- // sale_price: this.getPrice(car),
103
- // energy_efficiency_class: this.getEnergyClass(car),
104
- installment: this.getInstallment(car),
105
- // custom_label_0: `${this.getInstallment(car)} EUR mtl.`,
106
- };
107
-
108
- // Google rejects empty/partial values — getElement() emits `<tag/>` for falsy
109
- // values, which Merchant treats as "missing". Drop optional attributes that have
110
- // no data (certification when CO2 class unknown, EV-only metrics on non-EV cars,
111
- // installment when APR is missing) instead of emitting empty tags.
112
- const optionalKeys: Array<keyof GoogleXmlItem> = [
113
- 'certification',
114
- 'energy_consumption',
115
- 'electric_range',
116
- 'fuel_consumption_discharged_battery',
117
- 'installment',
118
- 'body_style',
119
- 'vehicle_msrp',
120
- 'transmission',
121
- 'drive',
122
- 'doors',
123
- 'capacity',
124
- ];
125
- for (const key of optionalKeys) {
126
- if (!item[key]) {
127
- delete item[key];
128
- }
129
- }
130
-
131
- return item;
132
- }
133
-
134
- private checkCarValidity(car: DataSchemes.Car, images: ImboImageResponseInterface[]) {
135
- if (images.length === 0)
136
- return false
137
-
138
- return true
139
- }
140
-
141
- private getVin(car: DataSchemes.Car): string {
142
- const vin = get(car, 'metaData.vin', '');
143
- return isValidVin(vin) ? vin : get(car, 'marketing.fakeVIN', '');
144
- }
145
-
146
- private getTitle(car: DataSchemes.Car): string {
147
- const model = toString(get(car, 'mainData.model', ''));
148
- const brand = trim(this.getBrand(car));
149
- const subModel = trim(this.getTrim(car));
150
- const year = this.getYear(car);
151
-
152
- const titleParts = [brand, model, year, subModel].filter(Boolean);
153
-
154
- return truncate(trim(escape(join(titleParts, ' '))), { length: 100, separator: ' ' });
155
- }
156
-
157
- private processText(input: string) {
158
- return input
159
- .replace(/\*\*\\/g, '</strong></p>|||')
160
- .replace(/\*\*/g, '<p><strong>')
161
- .split('|||')
162
- .map(item => item.trim())
163
- .flatMap(str =>
164
- str.split('\\')
165
- .map(item => item.trim())
166
- .filter(Boolean)
167
- )
168
- .map(item =>
169
- item.startsWith('* ') ? item.slice(2) : item
170
- )
171
- .map(item =>
172
- item.includes('<p>') ? item : `<p>${item}</p>`
173
- )
174
- .join('');
175
- }
176
-
177
- private async getDescription(car: DataSchemes.Car): Promise<string> {
178
- const dealerId = get(car, 'metaData.ownerId');
179
- const descriptionSchema = await this.userService.getDefaultSummaryByDealerId(dealerId);
180
- const descriptionBuilder = CarDescriptionFactory.create(DescriptionTypeEnum.Mobile, car);
181
-
182
- const transformedBody = descriptionBuilder.transformSeparately(descriptionSchema)
183
-
184
- return transformedBody.body;
185
- }
186
-
187
- private getCategory(car: DataSchemes.Car): string {
188
- return this.translator.translateVal(get(car, 'mainData.category', ''));
189
- }
190
-
191
- private getBreadcrumbs(car: DataSchemes.Car): string {
192
- const make = get(car, 'mainData.make', '');
193
- const model = get(car, 'mainData.model', '');
194
-
195
- return `${make} ${model}`;
196
- }
197
-
198
- private getLink(car: DataSchemes.Car): string {
199
- const autodeId = get(car, '_id', '');
200
- if (this.configService.config.isCriteo) {
201
- return `https://www.${this.configService.config.domain}/search/vehicle/${autodeId}?source=criteo&medium=cpc&campaign=retargeting`;
202
- }
203
- return `https://www.${this.configService.config.domain}/search/vehicle/${autodeId}`;
204
- }
205
-
206
- protected getWLTP(car: any): any {
207
- const envkvCompliant = get(car, 'consumption.envkvCompliant');
208
- const hybridPlagin = get(car, 'engineData.hybridPlugin');
209
-
210
- if (envkvCompliant && hybridPlagin) {
211
- const powerCombined = get(car, 'consumption.consumptionPowerCombined');
212
- return `${powerCombined}kWt/100km`;
213
- }
214
- }
215
-
216
- private getVehicleMsrp(car: DataSchemes.Car): string | undefined {
217
- const retailPrice = get(car, 'price.recomendedRetailPrice');
218
- const salesPrice = get(car, 'price.currentSalesPrice', 0);
219
- if (!retailPrice || round(retailPrice, 2) === round(salesPrice, 2)) {
220
- return undefined;
221
- }
222
- return `${round(retailPrice, 2)} EUR`;
223
- }
224
-
225
- private getPrice(car: DataSchemes.Car): string {
226
- const price = get(car, 'price.currentSalesPrice', 0);
227
- return `${round(price, 2)} EUR`;
228
- }
229
-
230
- private getColor(car: DataSchemes.Car): string {
231
- let colorSelection = get(car, 'exterior.exteriorColor', 'other');
232
- if (!colorSelection || colorSelection === 'selector_unknown') {
233
- colorSelection = 'other';
234
- }
235
- return this.translator.translateVal(colorSelection);
236
- }
237
-
238
- private mapState(car: DataSchemes.Car): string {
239
- const available = {
240
- selector_condition_new: 'New',
241
- selector_condition_used: 'Used',
242
- selector_unknown: 'Used',
243
- };
244
- const condition = get(car, 'mainData.condition', 'selector_unknown');
245
- return get(available, condition);
246
- }
247
-
248
- private getAvailability(car: DataSchemes.Car): string {
249
- const available = {
250
- selector_availabilityMode_fromDate: 'Ab einem bestimmten Datum',
251
- selector_availabilityMode_unknown: 'Ab einem bestimmten Datum',
252
- selector_availabilityMode_always: 'Sofort',
253
- };
254
- const availability = get(car, 'offer.availabilityMode', 'selector_availabilityMode_unknown');
255
- return get(available, availability);
256
- }
257
-
258
- private mapEnergyClass(selector: string): string {
259
- const available = {
260
- selector_energyEfficiencyClass_a: 'A',
261
- selector_energyEfficiencyClass_ap: 'A+',
262
- selector_energyEfficiencyClass_app: 'A++',
263
- selector_energyEfficiencyClass_appp: 'A+++',
264
- selector_energyEfficiencyClass_b: 'B',
265
- selector_energyEfficiencyClass_c: 'C',
266
- selector_energyEfficiencyClass_d: 'D',
267
- selector_energyEfficiencyClass_e: 'E',
268
- selector_energyEfficiencyClass_f: 'F',
269
- selector_energyEfficiencyClass_g: 'G',
270
- selector_unknown: 'default',
271
- };
272
- const value = get(available, selector);
273
- return value ? value.replace(/\+/g, '') : '';
274
- }
275
-
276
- private getEnergyClass(car: DataSchemes.Car): string {
277
- return this.mapEnergyClass(get(car, 'environmentEmissions.energyEfficiencyClass', 'selector_unknown'));
278
- }
279
-
280
- private getDischargedEnergyClass(car: DataSchemes.Car): string {
281
- return this.mapEnergyClass(get(car, 'environmentEmissions.wltpDischargedEnergyEfficiencyClass', 'selector_unknown'));
282
- }
283
-
284
- private getCertification(car: DataSchemes.Car) {
285
- const certifications: Array<{ certification_authority: string; certification_name: string; certification_value: string }> = [];
286
-
287
- const co2Class = this.getEnergyClass(car);
288
- if (co2Class && co2Class !== 'default') {
289
- certifications.push({
290
- certification_authority: 'BMWK',
291
- certification_name: 'Vehicle_CO2_Class',
292
- certification_value: co2Class,
293
- });
294
- }
295
-
296
- if (get(car, 'engineData.hybridPlugin')) {
297
- const dischargedClass = this.getDischargedEnergyClass(car);
298
- if (dischargedClass && dischargedClass !== 'default') {
299
- certifications.push({
300
- certification_authority: 'BMWK',
301
- certification_name: 'Vehicle_CO2_Class_Discharged_Battery',
302
- certification_value: dischargedClass,
303
- });
304
- }
305
- }
306
-
307
- return certifications.length ? certifications : undefined;
308
- }
309
-
310
- private getEnergyConsumption(car: DataSchemes.Car): string | undefined {
311
- const fuel = get(car, 'consumption.fuel');
312
- const isElectricOrHybrid =
313
- ['selector_fuel_electrical', 'selector_fuel_hybridElectricalPetrol', 'selector_fuel_hybridElectricalDiesel'].includes(fuel) ||
314
- !!get(car, 'engineData.hybridPlugin');
315
- if (!isElectricOrHybrid) {
316
- return undefined;
317
- }
318
- const power = get(car, 'consumption.wltpPowerCombined') || get(car, 'consumption.consumptionPowerCombined');
319
- return power ? `${power}kWh/100km` : undefined;
320
- }
321
-
322
- private getElectricRange(car: DataSchemes.Car): string | undefined {
323
- const range = get(car, 'battery.batteryRangeElectric') || get(car, 'battery.wltpRange');
324
- return range ? `${range}km` : undefined;
325
- }
326
-
327
- private getFuelConsumptionDischargedBattery(car: DataSchemes.Car): string | undefined {
328
- if (!get(car, 'engineData.hybridPlugin')) {
329
- return undefined;
330
- }
331
- const consumption = get(car, 'consumption.consumptionCombined') || get(car, 'consumption.wltpCombined');
332
- return consumption ? `${consumption}l/100km` : undefined;
333
- }
334
-
335
- private getInstallment(car: DataSchemes.Car) {
336
- const monthlyInstallment = round(get(car, 'rate.monthlyInstallment', null));
337
- const paybackPeriod = round(get(car, 'rate.paybackPeriod', null));
338
- const apr = toString(get(car, 'financing.annualPercentageRate', '')).trim();
339
-
340
- // Google requires `apr` inside the installment block; a partial installment
341
- // block (without apr) makes Google reject the whole attribute. If financing
342
- // data is incomplete, omit installment entirely so the rest of the item stays valid.
343
- if (!monthlyInstallment || !paybackPeriod || !apr) {
344
- return undefined;
345
- }
346
-
347
- return {
348
- paybackPeriod,
349
- monthlyInstallment,
350
- apr,
351
- };
352
- }
353
-
354
- private getBrand(car: DataSchemes.Car): string {
355
- const make = get(car, 'mainData.make', '');
356
-
357
- switch (make) {
358
- case 'Mercedes-Benz':
359
- return 'Mercedes';
360
- case 'Volkswagen':
361
- return 'VW';
362
- default:
363
- return make;
364
- }
365
- }
366
-
367
- private mapFuel(car: DataSchemes.Car): string {
368
- const available = {
369
- selector_fuel_petrol: 'Gasoline',
370
- selector_fuel_diesel: 'Diesel',
371
- selector_fuel_electrical: 'Electric',
372
- selector_fuel_hybridElectricalPetrol: 'Hybrid',
373
- selector_fuel_hybridElectricalDiesel: 'Hybrid',
374
- selector_fuel_carGas: 'CarGas',
375
- selector_fuel_naturalGas: 'NaturalGas',
376
- selector_fuel_ethanol: 'Ethanol',
377
- selector_fuel_hydrogen: 'Hydrogen',
378
- selector_unknown: 'Other',
379
- };
380
- const fuel = get(car, 'consumption.fuel', 'selector_unknown');
381
- let engine = get(available, fuel, 'Other')
382
- const hybridPlugin = car?.engineData?.hybridPlugin;
383
- if (engine === 'Hybrid' && hybridPlugin)
384
- engine = 'plug-in_hybrid';
385
-
386
- return engine;
387
- }
388
-
389
- private mapBodyStyle(car: DataSchemes.Car): string {
390
- const available = {
391
- selector_category_combi: 'station wagon',
392
- selector_category_sedan: 'sedan',
393
- selector_category_coupe: 'coupe',
394
- selector_category_convertible: 'convertible',
395
- selector_category_suv: 'suv',
396
- selector_category_van: 'full size van',
397
- selector_category_transporter: 'truck',
398
- selector_category_minibus: 'minivan',
399
- };
400
- const category = get(car, 'mainData.category', 'selector_unknown');
401
- return get(available, category);
402
- }
403
-
404
- private mapTransmission(car: DataSchemes.Car): string | undefined {
405
- const available = {
406
- selector_gearbox_manualShift: 'MANUAL',
407
- selector_gearbox_semiAutomatic: 'AUTOMATIC',
408
- selector_gearbox_automatic: 'AUTOMATIC',
409
- };
410
- return get(available, get(car, 'driveSuspension.gearbox'));
411
- }
412
-
413
- private mapDrive(car: DataSchemes.Car): string | undefined {
414
- const available = {
415
- selector_driveType_frontWheelDrive: 'FWD',
416
- selector_driveType_rearWheelDrive: 'RWD',
417
- selector_driveType_allWheelDrive: 'AWD',
418
- };
419
- return get(available, get(car, 'driveSuspension.driveType'));
420
- }
421
-
422
- private getDoors(car: DataSchemes.Car): number | undefined {
423
- return toSafeInteger(get(car, 'mainData.doors')) || undefined;
424
- }
425
-
426
- private getCapacity(car: DataSchemes.Car): number | undefined {
427
- return toSafeInteger(get(car, 'engineData.cubicCapacity')) || undefined;
428
- }
429
-
430
- private getExtrasField(car: DataSchemes.Car): string[] {
431
- const booleanFields: string[] = [
432
- 'engineData.startStopSystem',
433
- 'window.panoramicGlassRoof',
434
- 'assistants.automaticRainSensor',
435
- 'assistants.cruiseControl',
436
- 'assistants.headUpDisplay',
437
- 'assistants.abs',
438
- 'assistants.esp',
439
- 'assistants.collisionAvoidance',
440
- 'assistants.trafficSignRecognition',
441
- 'lights.xenonHeadlights',
442
- 'lights.ledHeadlights',
443
- 'lights.laserHeadlights',
444
- 'lights.lightSensor',
445
- 'wheel.multifunctionalWheel',
446
- 'heatingCooling.auxiliaryHeating',
447
- 'trailerCoupling.trailerCouplingFix',
448
- 'trailerCoupling.trailerCouplingRemovable',
449
- 'trailerCoupling.trailerCouplingRotatable',
450
- 'multimedia.navigationSystem',
451
- 'multimedia.bluetooth',
452
- 'multimedia.handsFreePhoneSystem',
453
- ];
454
-
455
- const selectorFields: string[] = [
456
- 'driveSuspension.driveType',
457
- 'interior.interiorType',
458
- 'heatingCooling.climatisation',
459
- ];
460
-
461
- const selectorListFields: string[] = [
462
- 'seats.driverSeats',
463
- 'seats.coDriverSeats',
464
- 'seats.backSeats',
465
- 'assistants.parkingAssistants',
466
- 'airbags.airbag',
467
- ];
468
-
469
- const filteredBooleanFields = booleanFields.filter((field: string) => {
470
- return get(car, field, false);
471
- });
472
- const filteredSelectorFields = selectorFields.filter((field: string) => {
473
- const value = get(car, field);
474
- return value && value !== 'selector_unknown';
475
- });
476
- const filteredSelectorListFields = selectorListFields.filter((field: string) => {
477
- const list: string[] = get(car, field);
478
- if (this.isNotEmptyArray(list)) {
479
- const filtered: string[] = list.filter((selector: string) => {
480
- return selector && selector !== 'selector_unknown';
481
- });
482
- return filtered.length;
483
- }
484
- return false;
485
- });
486
- return filteredBooleanFields
487
- .concat(filteredSelectorFields)
488
- .concat(filteredSelectorListFields)
489
- .slice(0, 200)
490
- .map((field: string) => {
491
- return this.translator.translateVal(field.replace('.', '_'));
492
- })
493
- .map((field: string) => {
494
- return field.substring(0, 200)
495
- });
496
- }
497
-
498
- private getTrim(car: DataSchemes.Car): string {
499
- const trim = toString(get(car, 'mainData.subModel', ''));
500
- return truncate(trim, { length: GOOGLE_MAX_TRIM_LENGTH, separator: ' ' });
501
- }
502
-
503
- private convertToSeconds(fieldValue: string | number | Date | null): any {
504
- if (fieldValue === null) {
505
- return null;
506
- }
507
-
508
- let timestamp = 0;
509
-
510
- if (typeof fieldValue === 'number') {
511
- if (fieldValue > 1e12) {
512
- timestamp = fieldValue;
513
- } else {
514
- timestamp = fieldValue * 1000;
515
- }
516
- }
517
-
518
- return Math.floor(timestamp);
519
- }
520
-
521
- private monthDiffLess(car: DataSchemes.Car, month: number) {
522
- const fieldDate = new Date(this.convertToSeconds(car.mainData.firstRegistration)).getTime();
523
- const conditionDate = new Date().getTime();
524
- if (!isNumber(fieldDate)) {
525
- return false;
526
- }
527
-
528
- const monthsDifference = (fieldDate - conditionDate) / (1000 * 60 * 60 * 24 * 30);
529
-
530
- return Math.round(Math.abs(monthsDifference)) <= month;
531
- }
532
-
533
- private getMileage(car: DataSchemes.Car): number {
534
- let mileage = get(car, 'mainData.mileage', 0);
535
-
536
- if (mileage < 1000)
537
- mileage = 1001
538
-
539
- return !mileage ? 10 : mileage;
540
- }
541
-
542
- private getYear(car: DataSchemes.Car): number {
543
- // mainData.firstModelsProductionDate is already a 4-digit year (mobile.de "vintage").
544
- // constructionYear/firstRegistration/metaData.creationDate are unix timestamps in
545
- // seconds (constructionYear is stored via legacy dateToUnix, not a bare year — see
546
- // services/scrapper/src/ai/car-bridge.ts:174). Not every car has firstModelsProductionDate
547
- // (e.g. dealer FTP imports), so fall through to other date sources — `year` is a
548
- // mandatory Google feed field.
549
- const firstModelsProductionYear = toSafeInteger(get(car, 'mainData.firstModelsProductionDate', 0));
550
- const constructionYear = new Date(+get(car, 'mainData.constructionYear', 0) * 1000).getUTCFullYear();
551
- const firstRegistrationYear = new Date(+get(car, 'mainData.firstRegistration', 0) * 1000).getUTCFullYear();
552
- const metaCreationYear = new Date(+get(car, 'metaData.creationDate', 0) * 1000).getUTCFullYear();
553
- const currentYear = new Date().getUTCFullYear();
554
-
555
- return [firstModelsProductionYear, constructionYear, firstRegistrationYear, metaCreationYear, currentYear]
556
- .find(year => year > TIMESTAMP_FIRST_YEAR);
557
- }
558
-
559
- private getDateFirstRegistered(car: DataSchemes.Car): string {
560
- let timestamp = +get(car, 'mainData.firstRegistration', '');
561
-
562
- if (!timestamp) {
563
- timestamp = Math.round(new Date().getTime() / 1000);
564
- }
565
-
566
- if (!car.mainData.firstRegistration || this.monthDiffLess(car, 8)) {
567
- const currentDate = new Date();
568
- currentDate.setMonth(currentDate.getMonth() - 9);
569
- timestamp = Math.round(currentDate.getTime() / 1000);
570
- }
571
-
572
- const date = new Date(timestamp * 1000);
573
- const month = date.getUTCMonth() + 1;
574
- const year = date.getUTCFullYear();
575
-
576
- const formattedMonth = month < 10 ? `0${month}` : month;
577
-
578
- return `${year}-${formattedMonth}`;
579
- }
580
-
581
- private isNotEmptyArray(array: any[]): boolean {
582
- return array && Array.isArray(array) && !!array.length;
583
- }
584
- }
1
+ import { escape, get, isNumber, join, round, toSafeInteger, toString, trim, truncate } from 'lodash';
2
+ import { CarDescriptionFactory, DescriptionTypeEnum } from '@bytebrand/car-description';
3
+ import { DataSchemes } from '@bytebrand/car-schema';
4
+ import { TranslatorExtended } from '@bytebrand/schema-tools';
5
+ import { de, deVehicleDetailsPage, deVehicleProps } from '@bytebrand/i18n-dictionaries';
6
+ import { ImboImageResponseInterface } from '@bytebrand/chunkable';
7
+ import { Injectable } from '@nestjs/common';
8
+ import { ConfigService, ImagesService, TransformerService, UserService } from '../services';
9
+ import { GoogleXmlItem } from '../interfaces';
10
+ import { isValidVin } from '../utils';
11
+
12
+ const GOOGLE_MAX_TRIM_LENGTH = 50;
13
+ const TIMESTAMP_FIRST_YEAR = 1970;
14
+
15
+ @Injectable()
16
+ export class GoogleTransformer {
17
+ private translator: TranslatorExtended;
18
+
19
+ constructor(
20
+ private userService: UserService,
21
+ private configService: ConfigService,
22
+ private imageService: ImagesService,
23
+ private readonly transformService: TransformerService,
24
+ ) {
25
+ const dict = { ...de, ...deVehicleProps, ...deVehicleDetailsPage };
26
+ this.translator = new TranslatorExtended(dict);
27
+ }
28
+
29
+ async transform({ car, images }: { car: DataSchemes.Car; images: Array<any> }): Promise<GoogleXmlItem> {
30
+ if (!this.checkCarValidity(car, images))
31
+ return null
32
+
33
+ const carId = toString(get(car, '_id', ''));
34
+ const transformedImages = await this.imageService.getExtIntDefaultImages(
35
+ car,
36
+ images,
37
+ this.configService.config.imageType,
38
+ false,
39
+ );
40
+
41
+ if (!transformedImages.extMain.length) {
42
+ console.log(`Car ${car._id} has no extMain image`);
43
+ return null
44
+ }
45
+
46
+ const allImages = [
47
+ ...transformedImages.extMain,
48
+ ...transformedImages.ext,
49
+ ...transformedImages.int,
50
+ ...transformedImages.feat
51
+ ]
52
+
53
+ const item: GoogleXmlItem = {
54
+ google_product_category: '916',
55
+ vehicle_fulfillment: {
56
+ option: 'online',
57
+ store_code: 'Online_shop'
58
+ },
59
+ certification: this.getCertification(car),
60
+ title: this.getTitle(car),
61
+ product_type: this.getCategory(car),
62
+ VIN: this.getVin(car),
63
+ id: carId,
64
+ image_link: allImages[0],
65
+ additional_image_link: allImages.slice(1, 10),
66
+ ads_redirect: this.getLink(car),
67
+ link: this.getLink(car),
68
+ link_template: `${this.getLink(car)}?store={store_code}`,
69
+ condition: this.mapState(car),
70
+ price: this.getPrice(car),
71
+ vehicle_msrp: this.getVehicleMsrp(car),
72
+ // certified_pre-owned: '',
73
+ brand: this.transformService.make(car),
74
+ model: this.transformService.model(car),
75
+ trim: this.getTrim(car),
76
+ year: this.getYear(car),
77
+ mileage: `${this.transformService.mileage(car)}km`,
78
+ color: this.getColor(car),
79
+ vehicle_option: this.getExtrasField(car),
80
+ body_style: this.mapBodyStyle(car),
81
+ engine: this.mapFuel(car),
82
+ transmission: this.mapTransmission(car),
83
+ drive: this.mapDrive(car),
84
+ doors: this.getDoors(car),
85
+ capacity: this.getCapacity(car),
86
+ date_first_registered: this.getDateFirstRegistered(car),
87
+ fuel_consumption: `${this.transformService.fuelConsumption(car)}l/100km`,
88
+ energy_consumption: this.getEnergyConsumption(car),
89
+ electric_range: this.getElectricRange(car),
90
+ fuel_consumption_discharged_battery: this.getFuelConsumptionDischargedBattery(car),
91
+ co2_emissions: `${this.transformService.cO2Emission(car)}g/km`,
92
+ description: this.processText((await this.getDescription(car))),
93
+ identifier_exists: 'yes',
94
+ availability: 'in_stock',
95
+ // custom_label_0: '',
96
+ // custom_label_1: '',
97
+ // custom_label_2: '',
98
+ // custom_label_3: '',
99
+ // custom_label_4: '',
100
+
101
+ // availability: this.getAvailability(car),
102
+ // sale_price: this.getPrice(car),
103
+ // energy_efficiency_class: this.getEnergyClass(car),
104
+ installment: this.getInstallment(car),
105
+ // custom_label_0: `${this.getInstallment(car)} EUR mtl.`,
106
+ };
107
+
108
+ // Google rejects empty/partial values — getElement() emits `<tag/>` for falsy
109
+ // values, which Merchant treats as "missing". Drop optional attributes that have
110
+ // no data (certification when CO2 class unknown, EV-only metrics on non-EV cars,
111
+ // installment when APR is missing) instead of emitting empty tags.
112
+ const optionalKeys: Array<keyof GoogleXmlItem> = [
113
+ 'certification',
114
+ 'energy_consumption',
115
+ 'electric_range',
116
+ 'fuel_consumption_discharged_battery',
117
+ 'installment',
118
+ 'body_style',
119
+ 'vehicle_msrp',
120
+ 'transmission',
121
+ 'drive',
122
+ 'doors',
123
+ 'capacity',
124
+ ];
125
+ for (const key of optionalKeys) {
126
+ if (!item[key]) {
127
+ delete item[key];
128
+ }
129
+ }
130
+
131
+ return item;
132
+ }
133
+
134
+ private checkCarValidity(car: DataSchemes.Car, images: ImboImageResponseInterface[]) {
135
+ if (images.length === 0)
136
+ return false
137
+
138
+ return true
139
+ }
140
+
141
+ private getVin(car: DataSchemes.Car): string {
142
+ const vin = get(car, 'metaData.vin', '');
143
+ return isValidVin(vin) ? vin : get(car, 'marketing.fakeVIN', '');
144
+ }
145
+
146
+ private getTitle(car: DataSchemes.Car): string {
147
+ const model = toString(get(car, 'mainData.model', ''));
148
+ const brand = trim(this.getBrand(car));
149
+ const subModel = trim(this.getTrim(car));
150
+ const year = this.getYear(car);
151
+
152
+ const titleParts = [brand, model, year, subModel].filter(Boolean);
153
+
154
+ return truncate(trim(escape(join(titleParts, ' '))), { length: 100, separator: ' ' });
155
+ }
156
+
157
+ private processText(input: string) {
158
+ return input
159
+ .replace(/\*\*\\/g, '</strong></p>|||')
160
+ .replace(/\*\*/g, '<p><strong>')
161
+ .split('|||')
162
+ .map(item => item.trim())
163
+ .flatMap(str =>
164
+ str.split('\\')
165
+ .map(item => item.trim())
166
+ .filter(Boolean)
167
+ )
168
+ .map(item =>
169
+ item.startsWith('* ') ? item.slice(2) : item
170
+ )
171
+ .map(item =>
172
+ item.includes('<p>') ? item : `<p>${item}</p>`
173
+ )
174
+ .join('');
175
+ }
176
+
177
+ private async getDescription(car: DataSchemes.Car): Promise<string> {
178
+ const dealerId = get(car, 'metaData.ownerId');
179
+ const descriptionSchema = await this.userService.getDefaultSummaryByDealerId(dealerId);
180
+ const descriptionBuilder = CarDescriptionFactory.create(DescriptionTypeEnum.Mobile, car);
181
+
182
+ const transformedBody = descriptionBuilder.transformSeparately(descriptionSchema)
183
+
184
+ return transformedBody.body;
185
+ }
186
+
187
+ private getCategory(car: DataSchemes.Car): string {
188
+ return this.translator.translateVal(get(car, 'mainData.category', ''));
189
+ }
190
+
191
+ private getBreadcrumbs(car: DataSchemes.Car): string {
192
+ const make = get(car, 'mainData.make', '');
193
+ const model = get(car, 'mainData.model', '');
194
+
195
+ return `${make} ${model}`;
196
+ }
197
+
198
+ private getLink(car: DataSchemes.Car): string {
199
+ const autodeId = get(car, '_id', '');
200
+ if (this.configService.config.isCriteo) {
201
+ return `https://www.${this.configService.config.domain}/search/vehicle/${autodeId}?source=criteo&medium=cpc&campaign=retargeting`;
202
+ }
203
+ return `https://www.${this.configService.config.domain}/search/vehicle/${autodeId}`;
204
+ }
205
+
206
+ protected getWLTP(car: any): any {
207
+ const envkvCompliant = get(car, 'consumption.envkvCompliant');
208
+ const hybridPlagin = get(car, 'engineData.hybridPlugin');
209
+
210
+ if (envkvCompliant && hybridPlagin) {
211
+ const powerCombined = get(car, 'consumption.consumptionPowerCombined');
212
+ return `${powerCombined}kWt/100km`;
213
+ }
214
+ }
215
+
216
+ private getVehicleMsrp(car: DataSchemes.Car): string | undefined {
217
+ const retailPrice = get(car, 'price.recomendedRetailPrice');
218
+ const salesPrice = get(car, 'price.currentSalesPrice', 0);
219
+ if (!retailPrice || round(retailPrice, 2) === round(salesPrice, 2)) {
220
+ return undefined;
221
+ }
222
+ return `${round(retailPrice, 2)} EUR`;
223
+ }
224
+
225
+ private getPrice(car: DataSchemes.Car): string {
226
+ const price = get(car, 'price.currentSalesPrice', 0);
227
+ return `${round(price, 2)} EUR`;
228
+ }
229
+
230
+ private getColor(car: DataSchemes.Car): string {
231
+ let colorSelection = get(car, 'exterior.exteriorColor', 'other');
232
+ if (!colorSelection || colorSelection === 'selector_unknown') {
233
+ colorSelection = 'other';
234
+ }
235
+ return this.translator.translateVal(colorSelection);
236
+ }
237
+
238
+ private mapState(car: DataSchemes.Car): string {
239
+ const available = {
240
+ selector_condition_new: 'New',
241
+ selector_condition_used: 'Used',
242
+ selector_unknown: 'Used',
243
+ };
244
+ const condition = get(car, 'mainData.condition', 'selector_unknown');
245
+ return get(available, condition);
246
+ }
247
+
248
+ private getAvailability(car: DataSchemes.Car): string {
249
+ const available = {
250
+ selector_availabilityMode_fromDate: 'Ab einem bestimmten Datum',
251
+ selector_availabilityMode_unknown: 'Ab einem bestimmten Datum',
252
+ selector_availabilityMode_always: 'Sofort',
253
+ };
254
+ const availability = get(car, 'offer.availabilityMode', 'selector_availabilityMode_unknown');
255
+ return get(available, availability);
256
+ }
257
+
258
+ private mapEnergyClass(selector: string): string {
259
+ const available = {
260
+ selector_energyEfficiencyClass_a: 'A',
261
+ selector_energyEfficiencyClass_ap: 'A+',
262
+ selector_energyEfficiencyClass_app: 'A++',
263
+ selector_energyEfficiencyClass_appp: 'A+++',
264
+ selector_energyEfficiencyClass_b: 'B',
265
+ selector_energyEfficiencyClass_c: 'C',
266
+ selector_energyEfficiencyClass_d: 'D',
267
+ selector_energyEfficiencyClass_e: 'E',
268
+ selector_energyEfficiencyClass_f: 'F',
269
+ selector_energyEfficiencyClass_g: 'G',
270
+ selector_unknown: 'default',
271
+ };
272
+ const value = get(available, selector);
273
+ return value ? value.replace(/\+/g, '') : '';
274
+ }
275
+
276
+ private getEnergyClass(car: DataSchemes.Car): string {
277
+ return this.mapEnergyClass(get(car, 'environmentEmissions.energyEfficiencyClass', 'selector_unknown'));
278
+ }
279
+
280
+ private getDischargedEnergyClass(car: DataSchemes.Car): string {
281
+ return this.mapEnergyClass(get(car, 'environmentEmissions.wltpDischargedEnergyEfficiencyClass', 'selector_unknown'));
282
+ }
283
+
284
+ private getCertification(car: DataSchemes.Car) {
285
+ const certifications: Array<{ certification_authority: string; certification_name: string; certification_value: string }> = [];
286
+
287
+ const co2Class = this.getEnergyClass(car);
288
+ if (co2Class && co2Class !== 'default') {
289
+ certifications.push({
290
+ certification_authority: 'BMWK',
291
+ certification_name: 'Vehicle_CO2_Class',
292
+ certification_value: co2Class,
293
+ });
294
+ }
295
+
296
+ if (get(car, 'engineData.hybridPlugin')) {
297
+ const dischargedClass = this.getDischargedEnergyClass(car);
298
+ if (dischargedClass && dischargedClass !== 'default') {
299
+ certifications.push({
300
+ certification_authority: 'BMWK',
301
+ certification_name: 'Vehicle_CO2_Class_Discharged_Battery',
302
+ certification_value: dischargedClass,
303
+ });
304
+ }
305
+ }
306
+
307
+ return certifications.length ? certifications : undefined;
308
+ }
309
+
310
+ private getEnergyConsumption(car: DataSchemes.Car): string | undefined {
311
+ const fuel = get(car, 'consumption.fuel');
312
+ const isElectricOrHybrid =
313
+ ['selector_fuel_electrical', 'selector_fuel_hybridElectricalPetrol', 'selector_fuel_hybridElectricalDiesel'].includes(fuel) ||
314
+ !!get(car, 'engineData.hybridPlugin');
315
+ if (!isElectricOrHybrid) {
316
+ return undefined;
317
+ }
318
+ const power = get(car, 'consumption.wltpPowerCombined') || get(car, 'consumption.consumptionPowerCombined');
319
+ return power ? `${power}kWh/100km` : undefined;
320
+ }
321
+
322
+ private getElectricRange(car: DataSchemes.Car): string | undefined {
323
+ const range = get(car, 'battery.batteryRangeElectric') || get(car, 'battery.wltpRange');
324
+ return range ? `${range}km` : undefined;
325
+ }
326
+
327
+ private getFuelConsumptionDischargedBattery(car: DataSchemes.Car): string | undefined {
328
+ if (!get(car, 'engineData.hybridPlugin')) {
329
+ return undefined;
330
+ }
331
+ const consumption = get(car, 'consumption.consumptionCombined') || get(car, 'consumption.wltpCombined');
332
+ return consumption ? `${consumption}l/100km` : undefined;
333
+ }
334
+
335
+ private getInstallment(car: DataSchemes.Car) {
336
+ const monthlyInstallment = round(get(car, 'rate.monthlyInstallment', null));
337
+ const paybackPeriod = round(get(car, 'rate.paybackPeriod', null));
338
+ const apr = toString(get(car, 'financing.annualPercentageRate', '')).trim();
339
+
340
+ // Google requires `apr` inside the installment block; a partial installment
341
+ // block (without apr) makes Google reject the whole attribute. If financing
342
+ // data is incomplete, omit installment entirely so the rest of the item stays valid.
343
+ if (!monthlyInstallment || !paybackPeriod || !apr) {
344
+ return undefined;
345
+ }
346
+
347
+ return {
348
+ paybackPeriod,
349
+ monthlyInstallment,
350
+ apr,
351
+ };
352
+ }
353
+
354
+ private getBrand(car: DataSchemes.Car): string {
355
+ const make = get(car, 'mainData.make', '');
356
+
357
+ switch (make) {
358
+ case 'Mercedes-Benz':
359
+ return 'Mercedes';
360
+ case 'Volkswagen':
361
+ return 'VW';
362
+ default:
363
+ return make;
364
+ }
365
+ }
366
+
367
+ private mapFuel(car: DataSchemes.Car): string {
368
+ const available = {
369
+ selector_fuel_petrol: 'Gasoline',
370
+ selector_fuel_diesel: 'Diesel',
371
+ selector_fuel_electrical: 'Electric',
372
+ selector_fuel_hybridElectricalPetrol: 'Hybrid',
373
+ selector_fuel_hybridElectricalDiesel: 'Hybrid',
374
+ selector_fuel_carGas: 'CarGas',
375
+ selector_fuel_naturalGas: 'NaturalGas',
376
+ selector_fuel_ethanol: 'Ethanol',
377
+ selector_fuel_hydrogen: 'Hydrogen',
378
+ selector_unknown: 'Other',
379
+ };
380
+ const fuel = get(car, 'consumption.fuel', 'selector_unknown');
381
+ let engine = get(available, fuel, 'Other')
382
+ const hybridPlugin = car?.engineData?.hybridPlugin;
383
+ if (engine === 'Hybrid' && hybridPlugin)
384
+ engine = 'plug-in_hybrid';
385
+
386
+ return engine;
387
+ }
388
+
389
+ private mapBodyStyle(car: DataSchemes.Car): string {
390
+ const available = {
391
+ selector_category_combi: 'station wagon',
392
+ selector_category_sedan: 'sedan',
393
+ selector_category_coupe: 'coupe',
394
+ selector_category_convertible: 'convertible',
395
+ selector_category_suv: 'suv',
396
+ selector_category_van: 'full size van',
397
+ selector_category_transporter: 'truck',
398
+ selector_category_minibus: 'minivan',
399
+ };
400
+ const category = get(car, 'mainData.category', 'selector_unknown');
401
+ return get(available, category);
402
+ }
403
+
404
+ private mapTransmission(car: DataSchemes.Car): string | undefined {
405
+ const available = {
406
+ selector_gearbox_manualShift: 'MANUAL',
407
+ selector_gearbox_semiAutomatic: 'AUTOMATIC',
408
+ selector_gearbox_automatic: 'AUTOMATIC',
409
+ };
410
+ return get(available, get(car, 'driveSuspension.gearbox'));
411
+ }
412
+
413
+ private mapDrive(car: DataSchemes.Car): string | undefined {
414
+ const available = {
415
+ selector_driveType_frontWheelDrive: 'FWD',
416
+ selector_driveType_rearWheelDrive: 'RWD',
417
+ selector_driveType_allWheelDrive: 'AWD',
418
+ };
419
+ return get(available, get(car, 'driveSuspension.driveType'));
420
+ }
421
+
422
+ private getDoors(car: DataSchemes.Car): number | undefined {
423
+ return toSafeInteger(get(car, 'mainData.doors')) || undefined;
424
+ }
425
+
426
+ private getCapacity(car: DataSchemes.Car): number | undefined {
427
+ return toSafeInteger(get(car, 'engineData.cubicCapacity')) || undefined;
428
+ }
429
+
430
+ private getExtrasField(car: DataSchemes.Car): string[] {
431
+ const booleanFields: string[] = [
432
+ 'engineData.startStopSystem',
433
+ 'window.panoramicGlassRoof',
434
+ 'assistants.automaticRainSensor',
435
+ 'assistants.cruiseControl',
436
+ 'assistants.headUpDisplay',
437
+ 'assistants.abs',
438
+ 'assistants.esp',
439
+ 'assistants.collisionAvoidance',
440
+ 'assistants.trafficSignRecognition',
441
+ 'lights.xenonHeadlights',
442
+ 'lights.ledHeadlights',
443
+ 'lights.laserHeadlights',
444
+ 'lights.lightSensor',
445
+ 'wheel.multifunctionalWheel',
446
+ 'heatingCooling.auxiliaryHeating',
447
+ 'trailerCoupling.trailerCouplingFix',
448
+ 'trailerCoupling.trailerCouplingRemovable',
449
+ 'trailerCoupling.trailerCouplingRotatable',
450
+ 'multimedia.navigationSystem',
451
+ 'multimedia.bluetooth',
452
+ 'multimedia.handsFreePhoneSystem',
453
+ ];
454
+
455
+ const selectorFields: string[] = [
456
+ 'driveSuspension.driveType',
457
+ 'interior.interiorType',
458
+ 'heatingCooling.climatisation',
459
+ ];
460
+
461
+ const selectorListFields: string[] = [
462
+ 'seats.driverSeats',
463
+ 'seats.coDriverSeats',
464
+ 'seats.backSeats',
465
+ 'assistants.parkingAssistants',
466
+ 'airbags.airbag',
467
+ ];
468
+
469
+ const filteredBooleanFields = booleanFields.filter((field: string) => {
470
+ return get(car, field, false);
471
+ });
472
+ const filteredSelectorFields = selectorFields.filter((field: string) => {
473
+ const value = get(car, field);
474
+ return value && value !== 'selector_unknown';
475
+ });
476
+ const filteredSelectorListFields = selectorListFields.filter((field: string) => {
477
+ const list: string[] = get(car, field);
478
+ if (this.isNotEmptyArray(list)) {
479
+ const filtered: string[] = list.filter((selector: string) => {
480
+ return selector && selector !== 'selector_unknown';
481
+ });
482
+ return filtered.length;
483
+ }
484
+ return false;
485
+ });
486
+ return filteredBooleanFields
487
+ .concat(filteredSelectorFields)
488
+ .concat(filteredSelectorListFields)
489
+ .slice(0, 200)
490
+ .map((field: string) => {
491
+ return this.translator.translateVal(field.replace('.', '_'));
492
+ })
493
+ .map((field: string) => {
494
+ return field.substring(0, 200)
495
+ });
496
+ }
497
+
498
+ private getTrim(car: DataSchemes.Car): string {
499
+ const trim = toString(get(car, 'mainData.subModel', ''));
500
+ return truncate(trim, { length: GOOGLE_MAX_TRIM_LENGTH, separator: ' ' });
501
+ }
502
+
503
+ private convertToSeconds(fieldValue: string | number | Date | null): any {
504
+ if (fieldValue === null) {
505
+ return null;
506
+ }
507
+
508
+ let timestamp = 0;
509
+
510
+ if (typeof fieldValue === 'number') {
511
+ if (fieldValue > 1e12) {
512
+ timestamp = fieldValue;
513
+ } else {
514
+ timestamp = fieldValue * 1000;
515
+ }
516
+ }
517
+
518
+ return Math.floor(timestamp);
519
+ }
520
+
521
+ private monthDiffLess(car: DataSchemes.Car, month: number) {
522
+ const fieldDate = new Date(this.convertToSeconds(car.mainData.firstRegistration)).getTime();
523
+ const conditionDate = new Date().getTime();
524
+ if (!isNumber(fieldDate)) {
525
+ return false;
526
+ }
527
+
528
+ const monthsDifference = (fieldDate - conditionDate) / (1000 * 60 * 60 * 24 * 30);
529
+
530
+ return Math.round(Math.abs(monthsDifference)) <= month;
531
+ }
532
+
533
+ private getMileage(car: DataSchemes.Car): number {
534
+ let mileage = get(car, 'mainData.mileage', 0);
535
+
536
+ if (mileage < 1000)
537
+ mileage = 1001
538
+
539
+ return !mileage ? 10 : mileage;
540
+ }
541
+
542
+ private getYear(car: DataSchemes.Car): number {
543
+ // mainData.firstModelsProductionDate is already a 4-digit year (mobile.de "vintage").
544
+ // constructionYear/firstRegistration/metaData.creationDate are unix timestamps in
545
+ // seconds (constructionYear is stored via legacy dateToUnix, not a bare year — see
546
+ // services/scrapper/src/ai/car-bridge.ts:174). Not every car has firstModelsProductionDate
547
+ // (e.g. dealer FTP imports), so fall through to other date sources — `year` is a
548
+ // mandatory Google feed field.
549
+ const firstModelsProductionYear = toSafeInteger(get(car, 'mainData.firstModelsProductionDate', 0));
550
+ const constructionYear = new Date(+get(car, 'mainData.constructionYear', 0) * 1000).getUTCFullYear();
551
+ const firstRegistrationYear = new Date(+get(car, 'mainData.firstRegistration', 0) * 1000).getUTCFullYear();
552
+ const metaCreationYear = new Date(+get(car, 'metaData.creationDate', 0) * 1000).getUTCFullYear();
553
+ const currentYear = new Date().getUTCFullYear();
554
+
555
+ return [firstModelsProductionYear, constructionYear, firstRegistrationYear, metaCreationYear, currentYear]
556
+ .find(year => year > TIMESTAMP_FIRST_YEAR);
557
+ }
558
+
559
+ private getDateFirstRegistered(car: DataSchemes.Car): string {
560
+ let timestamp = +get(car, 'mainData.firstRegistration', '');
561
+
562
+ if (!timestamp) {
563
+ timestamp = Math.round(new Date().getTime() / 1000);
564
+ }
565
+
566
+ if (!car.mainData.firstRegistration || this.monthDiffLess(car, 8)) {
567
+ const currentDate = new Date();
568
+ currentDate.setMonth(currentDate.getMonth() - 9);
569
+ timestamp = Math.round(currentDate.getTime() / 1000);
570
+ }
571
+
572
+ const date = new Date(timestamp * 1000);
573
+ const month = date.getUTCMonth() + 1;
574
+ const year = date.getUTCFullYear();
575
+
576
+ const formattedMonth = month < 10 ? `0${month}` : month;
577
+
578
+ return `${year}-${formattedMonth}`;
579
+ }
580
+
581
+ private isNotEmptyArray(array: any[]): boolean {
582
+ return array && Array.isArray(array) && !!array.length;
583
+ }
584
+ }