@bytebrand/export-cars 1.26.21 → 1.26.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/export-cars",
3
- "version": "1.26.21",
3
+ "version": "1.26.23",
4
4
  "description": "",
5
5
  "author": "bytebrand",
6
6
  "license": "UNLICENSED",
@@ -10,7 +10,8 @@
10
10
  "prepublishOnly": "npm run build",
11
11
  "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
12
12
  "lint": "eslint -c .eslintrc.js --ext .ts src/",
13
- "start:dev": "nest start --watch"
13
+ "start:dev": "nest start --watch",
14
+ "test": "ts-node --transpile-only test/google-feed-envkv.test.ts"
14
15
  },
15
16
  "dependencies": {
16
17
  "@bytebrand/autode-request": "^1.19.2",
@@ -199,11 +199,11 @@ export interface GoogleXmlItem {
199
199
  doors?: number;
200
200
  capacity?: number;
201
201
  date_first_registered: string,
202
- fuel_consumption: string,
202
+ fuel_consumption?: string,
203
203
  energy_consumption?: string,
204
204
  electric_range?: string,
205
205
  fuel_consumption_discharged_battery?: string,
206
- co2_emissions: string,
206
+ co2_emissions?: string,
207
207
  identifier_exists: string,
208
208
  ads_redirect?: string;
209
209
  "certified_pre-owned"?: boolean;
@@ -0,0 +1,92 @@
1
+ import { get } from 'lodash';
2
+ import { DataSchemes } from '@bytebrand/car-schema';
3
+
4
+ // Google vehicle ads `engine`: gasoline, diesel, electric, hybrid, lpg, methane, natural_gas,
5
+ // plug-in_hybrid, other (https://support.google.com/merchants/answer/14156068). Any other value
6
+ // is rejected as "Missing attribute [engine]" — CarGas / NaturalGas did that for ~290 items.
7
+ const GOOGLE_ENGINE: Record<string, string> = {
8
+ selector_fuel_petrol: 'Gasoline',
9
+ selector_fuel_diesel: 'Diesel',
10
+ selector_fuel_electrical: 'Electric',
11
+ selector_fuel_hybridElectricalPetrol: 'Hybrid',
12
+ selector_fuel_hybridElectricalDiesel: 'Hybrid',
13
+ selector_fuel_carGas: 'lpg',
14
+ selector_fuel_naturalGas: 'natural_gas',
15
+ selector_fuel_ethanol: 'other',
16
+ selector_fuel_hydrogen: 'other',
17
+ selector_unknown: 'other',
18
+ };
19
+
20
+ const HYBRID_FUELS = ['selector_fuel_hybridElectricalPetrol', 'selector_fuel_hybridElectricalDiesel'];
21
+
22
+ const positive = (value: unknown): number | undefined => {
23
+ const n = Number(value);
24
+ return Number.isFinite(n) && n > 0 ? n : undefined;
25
+ };
26
+
27
+ const isBev = (car: DataSchemes.Car): boolean => get(car, 'consumption.fuel') === 'selector_fuel_electrical';
28
+ const isPlugIn = (car: DataSchemes.Car): boolean =>
29
+ HYBRID_FUELS.includes(get(car, 'consumption.fuel')) && !!get(car, 'engineData.hybridPlugin');
30
+
31
+ export function googleEngine(car: DataSchemes.Car): string {
32
+ const engine = GOOGLE_ENGINE[get(car, 'consumption.fuel', 'selector_unknown')] || 'other';
33
+ return engine === 'Hybrid' && get(car, 'engineData.hybridPlugin') ? 'plug-in_hybrid' : engine;
34
+ }
35
+
36
+ // BEV: combined WLTP range (CoC 49.2). Plug-in: the EAER (`wltpTotalRange`, CoC 49.5) when present,
37
+ // else the range the dealer delivered. City ranges are never used; 0 counts as missing.
38
+ export function googleElectricRange(car: DataSchemes.Car): string | undefined {
39
+ let range: number | undefined;
40
+ if (isBev(car)) {
41
+ range = positive(get(car, 'battery.wltpRange')) ?? positive(get(car, 'battery.batteryRangeElectric'));
42
+ } else if (isPlugIn(car)) {
43
+ range = positive(get(car, 'battery.wltpTotalRange'))
44
+ ?? positive(get(car, 'battery.wltpRange'))
45
+ ?? positive(get(car, 'battery.batteryRangeElectric'));
46
+ }
47
+ return range ? `${range}km` : undefined;
48
+ }
49
+
50
+ // Plug-in hybrids: Google and Pkw-EnVKV (CoC 49.4) want the WEIGHTED combined values for fuel,
51
+ // electricity and CO2, and the fuel consumption with a DISCHARGED battery separately. In CBD those
52
+ // are wltpWeightedCombined, wltpWeightedPowerCombined, wltpCo2 (weighted for a plug-in) and
53
+ // wltpCombined (discharged for a plug-in); the legacy consumptionCombined / co2 are only a fallback.
54
+
55
+ // Missing or 0 is omitted instead of sending "0l/100km". BEVs carry no fuel consumption
56
+ // (CBD mirrors kWh into consumptionCombined for them).
57
+ export function googleFuelConsumption(car: DataSchemes.Car): string | undefined {
58
+ if (isBev(car)) return undefined;
59
+ const value = isPlugIn(car)
60
+ ? positive(get(car, 'consumption.wltpWeightedCombined')) ?? positive(get(car, 'consumption.consumptionCombined'))
61
+ : positive(get(car, 'consumption.consumptionCombined'));
62
+ return value ? `${value}l/100km` : undefined;
63
+ }
64
+
65
+ // Plug-in only. No fallback: the legacy combined value is (close to) the weighted one, sending it
66
+ // here would repeat the weighted figure as the discharged one.
67
+ export function googleFuelConsumptionDischarged(car: DataSchemes.Car): string | undefined {
68
+ if (!isPlugIn(car)) return undefined;
69
+ const value = positive(get(car, 'consumption.wltpCombined'));
70
+ return value ? `${value}l/100km` : undefined;
71
+ }
72
+
73
+ // BEV: combined consumption. Plug-in: weighted combined. Nothing for other cars.
74
+ export function googleEnergyConsumption(car: DataSchemes.Car): string | undefined {
75
+ let value: number | undefined;
76
+ if (isBev(car)) {
77
+ value = positive(get(car, 'consumption.wltpPowerCombined')) ?? positive(get(car, 'consumption.consumptionPowerCombined'));
78
+ } else if (isPlugIn(car)) {
79
+ value = positive(get(car, 'consumption.wltpWeightedPowerCombined'));
80
+ }
81
+ return value ? `${value}kWh/100km` : undefined;
82
+ }
83
+
84
+ // A BEV emits 0 g/km by definition; a plug-in sends the weighted WLTP CO2; for every other car the
85
+ // stored value. A missing or 0 value is omitted.
86
+ export function googleCo2(car: DataSchemes.Car): string | undefined {
87
+ if (isBev(car)) return '0g/km';
88
+ const value = isPlugIn(car)
89
+ ? positive(get(car, 'environmentEmissions.wltpCo2')) ?? positive(get(car, 'environmentEmissions.co2'))
90
+ : positive(get(car, 'environmentEmissions.co2'));
91
+ return value ? `${value}g/km` : undefined;
92
+ }
@@ -8,6 +8,14 @@ import { Injectable } from '@nestjs/common';
8
8
  import { ConfigService, ImagesService, MappingService, TransformerService, UserService } from '../services';
9
9
  import { GoogleXmlItem } from '../interfaces';
10
10
  import { isValidVin } from '../utils';
11
+ import {
12
+ googleCo2,
13
+ googleElectricRange,
14
+ googleEnergyConsumption,
15
+ googleEngine,
16
+ googleFuelConsumption,
17
+ googleFuelConsumptionDischarged,
18
+ } from './google-feed-envkv';
11
19
  import {
12
20
  META_PUSH_PRIORITY_ALLOWED_VALUES,
13
21
  META_PUSH_PRIORITY_STANDARD,
@@ -85,17 +93,17 @@ export class GoogleTransformer {
85
93
  color: this.getColor(car),
86
94
  vehicle_option: this.getExtrasField(car),
87
95
  body_style: this.mapBodyStyle(car),
88
- engine: this.mapFuel(car),
96
+ engine: googleEngine(car),
89
97
  transmission: this.mapTransmission(car),
90
98
  drive: this.mapDrive(car),
91
99
  doors: this.getDoors(car),
92
100
  capacity: this.getCapacity(car),
93
101
  date_first_registered: this.getDateFirstRegistered(car),
94
- fuel_consumption: `${this.transformService.fuelConsumption(car)}l/100km`,
95
- energy_consumption: this.getEnergyConsumption(car),
96
- electric_range: this.getElectricRange(car),
97
- fuel_consumption_discharged_battery: this.getFuelConsumptionDischargedBattery(car),
98
- co2_emissions: `${this.transformService.cO2Emission(car)}g/km`,
102
+ fuel_consumption: googleFuelConsumption(car),
103
+ energy_consumption: googleEnergyConsumption(car),
104
+ electric_range: googleElectricRange(car),
105
+ fuel_consumption_discharged_battery: googleFuelConsumptionDischarged(car),
106
+ co2_emissions: googleCo2(car),
99
107
  description: this.processText((await this.getDescription(car))),
100
108
  identifier_exists: 'yes',
101
109
  availability: 'in_stock',
@@ -121,6 +129,8 @@ export class GoogleTransformer {
121
129
  'certification',
122
130
  'energy_consumption',
123
131
  'electric_range',
132
+ 'fuel_consumption',
133
+ 'co2_emissions',
124
134
  'fuel_consumption_discharged_battery',
125
135
  'installment',
126
136
  'body_style',
@@ -329,30 +339,8 @@ export class GoogleTransformer {
329
339
  return certifications.length ? certifications : undefined;
330
340
  }
331
341
 
332
- private getEnergyConsumption(car: DataSchemes.Car): string | undefined {
333
- const fuel = get(car, 'consumption.fuel');
334
- const isElectricOrHybrid =
335
- ['selector_fuel_electrical', 'selector_fuel_hybridElectricalPetrol', 'selector_fuel_hybridElectricalDiesel'].includes(fuel) ||
336
- !!get(car, 'engineData.hybridPlugin');
337
- if (!isElectricOrHybrid) {
338
- return undefined;
339
- }
340
- const power = get(car, 'consumption.wltpPowerCombined') || get(car, 'consumption.consumptionPowerCombined');
341
- return power ? `${power}kWh/100km` : undefined;
342
- }
343
342
 
344
- private getElectricRange(car: DataSchemes.Car): string | undefined {
345
- const range = get(car, 'battery.batteryRangeElectric') || get(car, 'battery.wltpRange');
346
- return range ? `${range}km` : undefined;
347
- }
348
343
 
349
- private getFuelConsumptionDischargedBattery(car: DataSchemes.Car): string | undefined {
350
- if (!get(car, 'engineData.hybridPlugin')) {
351
- return undefined;
352
- }
353
- const consumption = get(car, 'consumption.consumptionCombined') || get(car, 'consumption.wltpCombined');
354
- return consumption ? `${consumption}l/100km` : undefined;
355
- }
356
344
 
357
345
  private getInstallment(car: DataSchemes.Car) {
358
346
  const monthlyInstallment = round(get(car, 'rate.monthlyInstallment', null));
@@ -386,27 +374,6 @@ export class GoogleTransformer {
386
374
  }
387
375
  }
388
376
 
389
- private mapFuel(car: DataSchemes.Car): string {
390
- const available = {
391
- selector_fuel_petrol: 'Gasoline',
392
- selector_fuel_diesel: 'Diesel',
393
- selector_fuel_electrical: 'Electric',
394
- selector_fuel_hybridElectricalPetrol: 'Hybrid',
395
- selector_fuel_hybridElectricalDiesel: 'Hybrid',
396
- selector_fuel_carGas: 'CarGas',
397
- selector_fuel_naturalGas: 'NaturalGas',
398
- selector_fuel_ethanol: 'Ethanol',
399
- selector_fuel_hydrogen: 'Hydrogen',
400
- selector_unknown: 'Other',
401
- };
402
- const fuel = get(car, 'consumption.fuel', 'selector_unknown');
403
- let engine = get(available, fuel, 'Other')
404
- const hybridPlugin = car?.engineData?.hybridPlugin;
405
- if (engine === 'Hybrid' && hybridPlugin)
406
- engine = 'plug-in_hybrid';
407
-
408
- return engine;
409
- }
410
377
 
411
378
  private mapBodyStyle(car: DataSchemes.Car): string {
412
379
  const available = {
@@ -0,0 +1,114 @@
1
+ // Run: npm test (ts-node, no test framework needed)
2
+ import * as assert from 'node:assert/strict';
3
+ import {
4
+ googleCo2,
5
+ googleElectricRange,
6
+ googleEnergyConsumption,
7
+ googleEngine,
8
+ googleFuelConsumption,
9
+ googleFuelConsumptionDischarged,
10
+ } from '../src/transformers/google-feed-envkv';
11
+
12
+ const car = (fuel: string, extra: Record<string, unknown> = {}): any => ({
13
+ consumption: { fuel, ...(extra.consumption as object) },
14
+ engineData: { hybridPlugin: extra.hybridPlugin },
15
+ battery: extra.battery || {},
16
+ environmentEmissions: extra.environmentEmissions || {},
17
+ });
18
+
19
+ const cases: Array<[string, () => void]> = [
20
+ ['LPG and CNG get the values Google accepts', () => {
21
+ assert.equal(googleEngine(car('selector_fuel_carGas')), 'lpg');
22
+ assert.equal(googleEngine(car('selector_fuel_naturalGas')), 'natural_gas');
23
+ }],
24
+ ['ethanol, hydrogen and unknown fuels become other', () => {
25
+ assert.equal(googleEngine(car('selector_fuel_ethanol')), 'other');
26
+ assert.equal(googleEngine(car('selector_fuel_hydrogen')), 'other');
27
+ assert.equal(googleEngine(car('selector_fuel_somethingNew')), 'other');
28
+ }],
29
+ ['existing engine values are unchanged', () => {
30
+ assert.equal(googleEngine(car('selector_fuel_petrol')), 'Gasoline');
31
+ assert.equal(googleEngine(car('selector_fuel_diesel')), 'Diesel');
32
+ assert.equal(googleEngine(car('selector_fuel_electrical')), 'Electric');
33
+ assert.equal(googleEngine(car('selector_fuel_hybridElectricalPetrol')), 'Hybrid');
34
+ assert.equal(googleEngine(car('selector_fuel_hybridElectricalPetrol', { hybridPlugin: true })), 'plug-in_hybrid');
35
+ }],
36
+ ['BEV range is the combined WLTP range, never the city range', () => {
37
+ assert.equal(googleElectricRange(car('selector_fuel_electrical', { battery: { wltpRange: 451, wltpRangeCity: 590 } })), '451km');
38
+ assert.equal(googleElectricRange(car('selector_fuel_electrical', { battery: { wltpRangeCity: 590 } })), undefined);
39
+ }],
40
+ ['plug-in range prefers the EAER, then the delivered range', () => {
41
+ const phev = (battery: object) => car('selector_fuel_hybridElectricalPetrol', { hybridPlugin: true, battery });
42
+ assert.equal(googleElectricRange(phev({ wltpTotalRange: 62, wltpRange: 59 })), '62km');
43
+ assert.equal(googleElectricRange(phev({ wltpTotalRange: 0, wltpRange: 59 })), '59km');
44
+ assert.equal(googleElectricRange(phev({ wltpTotalRangeCity: 70 })), undefined);
45
+ }],
46
+ ['a range of 0 counts as missing', () => {
47
+ assert.equal(googleElectricRange(car('selector_fuel_electrical', { battery: { wltpRange: 0 } })), undefined);
48
+ }],
49
+ ['no range for combustion cars or non-plug-in hybrids', () => {
50
+ assert.equal(googleElectricRange(car('selector_fuel_petrol', { battery: { wltpRange: 50 } })), undefined);
51
+ assert.equal(googleElectricRange(car('selector_fuel_hybridElectricalPetrol', { battery: { wltpRange: 50 } })), undefined);
52
+ }],
53
+ ['missing or 0 fuel consumption is omitted, never "0l/100km"', () => {
54
+ assert.equal(googleFuelConsumption(car('selector_fuel_petrol', { consumption: { consumptionCombined: 0 } })), undefined);
55
+ assert.equal(googleFuelConsumption(car('selector_fuel_petrol')), undefined);
56
+ assert.equal(googleFuelConsumption(car('selector_fuel_petrol', { consumption: { consumptionCombined: 6.3 } })), '6.3l/100km');
57
+ }],
58
+ ['BEVs send no fuel consumption (CBD mirrors kWh into it)', () => {
59
+ assert.equal(googleFuelConsumption(car('selector_fuel_electrical', { consumption: { consumptionCombined: 18 } })), undefined);
60
+ }],
61
+ ['CO2: BEV is 0 g/km, other cars omit a missing or 0 value', () => {
62
+ assert.equal(googleCo2(car('selector_fuel_electrical')), '0g/km');
63
+ assert.equal(googleCo2(car('selector_fuel_petrol', { environmentEmissions: { co2: 0 } })), undefined);
64
+ assert.equal(googleCo2(car('selector_fuel_petrol', { environmentEmissions: { co2: 144 } })), '144g/km');
65
+ }],
66
+ ['plug-in: weighted fuel, discharged fuel, weighted electricity and weighted CO2', () => {
67
+ const phev = car('selector_fuel_hybridElectricalPetrol', {
68
+ hybridPlugin: true,
69
+ consumption: { consumptionCombined: 1.4, wltpCombined: 6.3, wltpWeightedCombined: 1.2, wltpPowerCombined: 19.3, wltpWeightedPowerCombined: 17.3 },
70
+ environmentEmissions: { co2: 121, wltpCo2: 28 },
71
+ });
72
+ assert.equal(googleFuelConsumption(phev), '1.2l/100km');
73
+ assert.equal(googleFuelConsumptionDischarged(phev), '6.3l/100km');
74
+ assert.equal(googleEnergyConsumption(phev), '17.3kWh/100km');
75
+ assert.equal(googleCo2(phev), '28g/km');
76
+ }],
77
+ ['plug-in without weighted values falls back to the legacy combined ones, never the pure-electric value', () => {
78
+ const phev = car('selector_fuel_hybridElectricalPetrol', {
79
+ hybridPlugin: true,
80
+ consumption: { consumptionCombined: 1.4, wltpPowerCombined: 19.3 },
81
+ environmentEmissions: { co2: 32 },
82
+ });
83
+ assert.equal(googleFuelConsumption(phev), '1.4l/100km');
84
+ assert.equal(googleFuelConsumptionDischarged(phev), undefined);
85
+ assert.equal(googleEnergyConsumption(phev), undefined);
86
+ assert.equal(googleCo2(phev), '32g/km');
87
+ }],
88
+ ['non-plug-in hybrids and combustion cars keep the stored values and send no electricity', () => {
89
+ const hev = car('selector_fuel_hybridElectricalPetrol', {
90
+ consumption: { consumptionCombined: 5.1, wltpCombined: 5.3, wltpWeightedCombined: 2.3, wltpWeightedPowerCombined: 21.5 },
91
+ environmentEmissions: { co2: 116, wltpCo2: 119 },
92
+ });
93
+ assert.equal(googleFuelConsumption(hev), '5.1l/100km');
94
+ assert.equal(googleFuelConsumptionDischarged(hev), undefined);
95
+ assert.equal(googleEnergyConsumption(hev), undefined);
96
+ assert.equal(googleCo2(hev), '116g/km');
97
+ }],
98
+ ['BEV energy consumption is the combined value', () => {
99
+ assert.equal(googleEnergyConsumption(car('selector_fuel_electrical', { consumption: { wltpPowerCombined: 16.5 } })), '16.5kWh/100km');
100
+ assert.equal(googleEnergyConsumption(car('selector_fuel_electrical', { consumption: { consumptionPowerCombined: 18 } })), '18kWh/100km');
101
+ }],
102
+ ];
103
+
104
+ let failed = 0;
105
+ for (const [name, fn] of cases) {
106
+ try {
107
+ fn();
108
+ console.log(`ok ${name}`);
109
+ } catch (e) {
110
+ failed++;
111
+ console.log(`FAIL ${name}\n ${(e as Error).message}`);
112
+ }
113
+ }
114
+ if (failed) process.exit(1);