@bytebrand/export-cars 1.25.0 → 1.26.1

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.25.0",
3
+ "version": "1.26.1",
4
4
  "description": "",
5
5
  "author": "bytebrand",
6
6
  "license": "UNLICENSED",
@@ -8,12 +8,17 @@ export const TIMESTAMP_FIRST_YEAR = 1970;
8
8
  export const META_PUSH_PRIORITY_STANDARD = 'standard';
9
9
  export const META_PUSH_PRIORITY_ALLOWED_VALUES = ['dealer_priority_1', 'dealer_priority_2'];
10
10
 
11
+ // Values must match Meta's Automotive Model Ads offer_type enum exactly (lease/finance/cash) —
12
+ // the old 'financing'/'leasing'/'buy' values are not recognized by the AMA Vehicle Offers Feed.
11
13
  export enum OfferType {
12
- financing = 'financing',
13
- leasing = 'leasing',
14
- buy = 'buy',
14
+ financing = 'finance',
15
+ leasing = 'lease',
16
+ buy = 'cash',
15
17
  }
16
18
 
19
+ export const AMOUNT_QUALIFIER_APR = 'APR';
20
+ export const TERM_QUALIFIER_MONTHS = 'months';
21
+
17
22
  export const OFFER_DISCLAIMER = 'Für Ihre Finanzierungswünsche stellen wir eine Bonitätsanfrage. Bonität ' +
18
23
  'vorausgesetzt, ist dies ein repräsentatives Berechnungsbeispiel gem. der Angaben, welches 2/3 aller Kunden, ' +
19
24
  'im Sinne des § 17a Abs. 4 PAngV, erhalten. Dieses freibleibende Angebot der Santander Consumer Bank AG, ' +
@@ -1,5 +1,6 @@
1
1
  import { Injectable } from '@nestjs/common';
2
2
  import { ModuleRef } from '@nestjs/core';
3
+ import { basename } from 'path';
3
4
 
4
5
  import { CronicleUtils } from '@bytebrand/cronicle-helper';
5
6
  import { FsChunkInterface } from '@bytebrand/chunkable';
@@ -24,8 +25,9 @@ export class FacebookShopExporter implements Exporter {
24
25
  await CronicleUtils.ensureDir(this.configService.config.pathesConfig.localConvertedDirectory);
25
26
  await CronicleUtils.emptyDir(this.configService.config.pathesConfig.localConvertedDirectory);
26
27
 
27
- const facebookShopXmkBuilder = new FacebookShopXmlBuilder(fileXml);
28
- await facebookShopXmkBuilder.buildStart();
28
+ const selfUrl = `https://ftp.${this.configService.config.domain}/feeds/${basename(fileXml)}`;
29
+ const facebookShopXmlBuilder = new FacebookShopXmlBuilder(fileXml);
30
+ await facebookShopXmlBuilder.buildStart(selfUrl);
29
31
 
30
32
  const facebookShopTransformer = await this.moduleRef.create(FacebookShopTransformer);
31
33
 
@@ -45,13 +47,13 @@ export class FacebookShopExporter implements Exporter {
45
47
  }
46
48
 
47
49
  for (const item of items) {
48
- await facebookShopXmkBuilder.addItem(item);
50
+ await facebookShopXmlBuilder.addItem(item);
49
51
  }
50
52
  this.carService.counter.countCar(data.images);
51
53
  }
52
54
  });
53
55
 
54
- await facebookShopXmkBuilder.buildEnd();
56
+ await facebookShopXmlBuilder.buildEnd();
55
57
 
56
58
  console.log(this.carService.counter.getCountedCars());
57
59
  }
@@ -1,52 +1,59 @@
1
- import { escape, isNumber, isString } from 'lodash';
2
- import { XMLBuilderBase } from '@bytebrand/cronicle-helper';
3
- import { FacebookShopItem, FacebookShopTag } from '../interfaces';
4
-
5
- export class FacebookShopXmlBuilder extends XMLBuilderBase {
6
- async buildStart(): Promise<void> {
7
- await this.write(
8
- '<?xml version="1.0" encoding="utf-8"?>\n<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0" xmlns:atom="http://www.w3.org/2005/Atom">' +
9
- '\n<channel>\n<title>Produkt für auto.de</title>\n<description>Produkt für auto.de</description> \n<link>https://www.auto.de/</link>\n' +
10
- '<atom:link href="https://www.mydealsshop.foo/pages/test-feed" rel="self" type="application/rss+xml" />\n',
11
- );
12
- }
13
-
14
- async addItem(car: FacebookShopItem): Promise<void> {
15
- let item = '\n<item>\n';
16
-
17
- const tags = Object.keys(car);
18
-
19
- for (const tag of tags) {
20
- if (tag === 'image' && Array.isArray(car[tag])) {
21
- item += (car[tag] as unknown as string[])
22
- .map((url: string) => `<image><url>${escape(url)}</url></image>\n`)
23
- .join('');
24
- continue;
25
- }
26
- item += this.buildTagElement(tag, car[tag]);
27
- }
28
- item += '</item>';
29
-
30
- await this.write(item);
31
- }
32
-
33
- buildTagElement(tag: string, params: FacebookShopTag) {
34
- // getElement() treats falsy values as "missing" — stringify numbers first so a
35
- // legitimate 0 (e.g. down_payment/apr) still renders instead of an empty tag.
36
- let value = params.value;
37
- if (isString(value)) {
38
- value = escape(value);
39
- } else if (isNumber(value)) {
40
- value = String(value);
41
- }
42
- if (params.isGoogle) {
43
- return this.getElement(`g:${tag}`, value);
44
- }
45
- return this.getElement(tag, value);
46
- }
47
-
48
- async buildEnd(): Promise<void> {
49
- await this.write('\n</channel>\n</rss>\n');
50
- await this.end();
51
- }
52
- }
1
+ import { escape, isNil, isNumber, isString } from 'lodash';
2
+ import { XMLBuilderBase } from '@bytebrand/cronicle-helper';
3
+ import { FacebookShopItem } from '../interfaces';
4
+
5
+ const MAX_IMAGES = 2;
6
+
7
+ export class FacebookShopXmlBuilder extends XMLBuilderBase {
8
+ // No xmlns:g / g: prefixes here — that namespace belongs to the old Google Merchant /
9
+ // Facebook Shop product-catalog schema. Meta's Automotive Model Ads Vehicle Offers Feed
10
+ // uses plain, undecorated tag names (see facebook-shop.builder field reference above).
11
+ async buildStart(selfUrl?: string): Promise<void> {
12
+ await this.write(
13
+ '<?xml version="1.0" encoding="utf-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">' +
14
+ '\n<channel>\n<title>auto.de Vehicle Offers Feed</title>' +
15
+ '\n<description>Automotive Model Ads Vehicle Offers Feed für auto.de</description>' +
16
+ '\n<link>https://www.auto.de/</link>' +
17
+ `\n<atom:link href="${escape(selfUrl)}" rel="self" type="application/rss+xml" />\n`,
18
+ );
19
+ }
20
+
21
+ async addItem(car: FacebookShopItem): Promise<void> {
22
+ let item = '\n<item>\n';
23
+
24
+ const { image, ...tags } = car;
25
+
26
+ for (const [tag, value] of Object.entries(tags)) {
27
+ item += this.buildTagElement(tag, value);
28
+ }
29
+
30
+ item += image
31
+ .slice(0, MAX_IMAGES)
32
+ .map((url: string) => `<image><url>${escape(url)}</url></image>\n`)
33
+ .join('');
34
+
35
+ item += '</item>';
36
+
37
+ await this.write(item);
38
+ }
39
+
40
+ private buildTagElement(tag: string, value: unknown): string {
41
+ if (isNil(value) || value === '') {
42
+ return '';
43
+ }
44
+ // getElement() treats falsy values as "missing" — stringify numbers first so a
45
+ // legitimate 0 (e.g. downpayment/term_length) still renders instead of an empty tag.
46
+ if (isString(value)) {
47
+ return this.getElement(tag, escape(value));
48
+ }
49
+ if (isNumber(value)) {
50
+ return this.getElement(tag, String(value));
51
+ }
52
+ return this.getElement(tag, value);
53
+ }
54
+
55
+ async buildEnd(): Promise<void> {
56
+ await this.write('\n</channel>\n</rss>\n');
57
+ await this.end();
58
+ }
59
+ }
@@ -346,45 +346,33 @@ export interface dealerItem {
346
346
  };
347
347
  }
348
348
 
349
+ // Field set matches Meta's Automotive Model Ads "Vehicle Offers Feed File" reference
350
+ // (https://developers.facebook.com/documentation/ads-commerce/marketing-api/auto-ads/reference#vehicle-offers-feed-file).
351
+ // custom_label_4 is not part of that reference but is kept per ADV-159/161 (dealer push
352
+ // priority) — Meta ignores catalog fields it doesn't recognize rather than rejecting the feed.
349
353
  export interface FacebookShopItem {
350
- id: FacebookShopTag;
351
- vehicle_id: FacebookShopTag;
352
- title: FacebookShopTag;
353
- description: FacebookShopTag;
354
- availability: FacebookShopTag;
355
- state_of_vehicle: FacebookShopTag;
356
- price: FacebookShopTag;
357
- link: FacebookShopTag;
354
+ vehicle_offer_id: string;
355
+ make: string;
356
+ model: string;
357
+ year: string;
358
+ offer_type: string;
359
+ title: string;
360
+ offer_description: string;
361
+ url: string;
362
+ offer_disclaimer?: string;
358
363
  image: string[];
359
- make: FacebookShopTag;
360
- model: FacebookShopTag;
361
- year: FacebookShopTag;
362
-
363
- user_id: FacebookShopTag;
364
- custom_label_4: FacebookShopTag;
365
- dealer_privacy_policy_url: FacebookShopTag;
366
364
 
367
- offer_type: FacebookShopTag;
368
- offer_disclaimer?: FacebookShopTag;
369
- monthly_payment?: FacebookShopTag;
370
- down_payment?: FacebookShopTag;
371
- term_length?: FacebookShopTag;
372
- apr?: FacebookShopTag;
373
- final_payment?: FacebookShopTag;
374
- total_amount?: FacebookShopTag;
375
- net_loan_amount?: FacebookShopTag;
376
-
377
- quantity_to_sell_on_facebook: FacebookShopTag;
378
- google_product_category: FacebookShopTag;
379
- sale_price: FacebookShopTag;
380
- status: FacebookShopTag;
381
- color: FacebookShopTag;
382
- product_type: FacebookShopTag;
383
- }
365
+ price: string;
366
+ availability: string;
367
+ exterior_color: string;
368
+ custom_label_4: string;
384
369
 
385
- export interface FacebookShopTag {
386
- isGoogle: boolean;
387
- value: any;
370
+ amount_price?: string;
371
+ amount_percentage?: string;
372
+ amount_qualifier?: string;
373
+ term_length?: number;
374
+ term_qualifier?: string;
375
+ downpayment?: string;
388
376
  }
389
377
 
390
378
  export interface TrovitXmlItem {
@@ -9,13 +9,15 @@ import { TranslatorExtended } from '@bytebrand/schema-tools';
9
9
  import { DataSchemes } from '@bytebrand/car-schema';
10
10
  import { getComputedFinancing, normalizeRateObject } from '@bytebrand/fe-financing';
11
11
 
12
- import { FacebookShopItem, FacebookShopTag } from '../interfaces';
12
+ import { FacebookShopItem } from '../interfaces';
13
13
  import { ConfigService, ImagesService, MappingService } from '../services';
14
14
  import {
15
+ AMOUNT_QUALIFIER_APR,
15
16
  META_PUSH_PRIORITY_ALLOWED_VALUES,
16
17
  META_PUSH_PRIORITY_STANDARD,
17
18
  OFFER_DISCLAIMER,
18
19
  OfferType,
20
+ TERM_QUALIFIER_MONTHS,
19
21
  } from '../constants/fb.constants';
20
22
 
21
23
  interface OfferFinancials {
@@ -77,35 +79,26 @@ export class FacebookShopTransformer {
77
79
  }
78
80
 
79
81
  const base = {
80
- vehicle_id: this.tag(vehicleId, true),
81
- availability: this.tag(this.getAvailability(car), false),
82
- state_of_vehicle: this.tag(this.getCondition(car), false),
83
- price: this.tag(`${price} EUR`, true),
84
- sale_price: this.tag(`${price} EUR`, true),
85
- link: this.tag(this.getCarLink(car), true),
82
+ make,
83
+ model,
84
+ year: this.getYear(car),
85
+ url: this.getCarLink(car),
86
86
  image: imageLinks.slice(0, 2), // main + 2nd image, per ticket
87
- make: this.tag(make, false),
88
- model: this.tag(model, false),
89
- year: this.tag(this.getYear(car), false),
90
- user_id: this.tag(ownerId, false),
91
- custom_label_4: this.tag(customLabel4, false),
92
- dealer_privacy_policy_url: this.tag('https://www.auto.de/einwilligungserklaerung/', false),
93
- quantity_to_sell_on_facebook: this.tag(1, true),
94
- google_product_category: this.tag('916', true),
95
- status: this.tag('active', true),
96
- color: this.tag(this.getColor(car), false),
97
- product_type: this.tag('Vehicles & Parts > Vehicles > Motor Vehicles > Cars, Trucks & Vans', true),
87
+ price: `${price} EUR`,
88
+ availability: this.getAvailability(car),
89
+ exterior_color: this.getColor(car),
90
+ custom_label_4: customLabel4,
98
91
  };
99
92
 
100
93
  return offers.map((offer): FacebookShopItem => ({
101
94
  ...base,
102
- id: this.tag(`${vehicleId}_${offer.offerType}`, true),
103
- title: this.tag(offer.title, true),
104
- description: this.tag(offer.description, true),
105
- offer_type: this.tag(offer.offerType, false),
106
- ...(offer.disclaimer ? { offer_disclaimer: this.tag(offer.disclaimer, false) } : {}),
95
+ vehicle_offer_id: `${vehicleId}_${offer.offerType}`,
96
+ title: offer.title,
97
+ offer_description: offer.description,
98
+ offer_type: offer.offerType,
99
+ ...(offer.disclaimer ? { offer_disclaimer: offer.disclaimer } : {}),
107
100
  ...(offer.financials ? this.financialTags(offer.financials) : {}),
108
- } as FacebookShopItem));
101
+ }));
109
102
  }
110
103
 
111
104
  private buildOffers(car: DataSchemes.Car, common: { make: string; model: string; price: number }): Offer[] {
@@ -212,20 +205,15 @@ export class FacebookShopTransformer {
212
205
 
213
206
  private financialTags(financials: OfferFinancials): Partial<FacebookShopItem> {
214
207
  return {
215
- monthly_payment: this.tag(financials.monthlyPayment, false),
216
- down_payment: this.tag(financials.downPayment, false),
217
- term_length: this.tag(financials.termLength, false),
218
- apr: this.tag(financials.apr, false),
219
- final_payment: this.tag(financials.finalPayment, false),
220
- total_amount: this.tag(financials.totalAmount, false),
221
- net_loan_amount: this.tag(financials.netLoanAmount, false),
208
+ amount_price: `${financials.monthlyPayment} EUR`,
209
+ amount_percentage: String(financials.apr),
210
+ amount_qualifier: AMOUNT_QUALIFIER_APR,
211
+ term_length: financials.termLength,
212
+ term_qualifier: TERM_QUALIFIER_MONTHS,
213
+ downpayment: `${financials.downPayment} EUR`,
222
214
  };
223
215
  }
224
216
 
225
- private tag(value: any, isGoogle: boolean): FacebookShopTag {
226
- return { value, isGoogle };
227
- }
228
-
229
217
  // lodash's get() only falls back to the default for an *undefined* path — an explicit
230
218
  // `null` stored on the Car document (a common Mongo pattern for "not set") passes
231
219
  // straight through and ends up rendering as an empty XML tag. Coalesce it here instead.
@@ -249,18 +237,8 @@ export class FacebookShopTransformer {
249
237
  return firstRegistration ? moment(firstRegistration * 1000).format('YYYY') : moment().format('YYYY');
250
238
  }
251
239
 
252
- private getCondition(car: DataSchemes.Car): string {
253
- const available = {
254
- selector_condition_new: 'New',
255
- selector_condition_used: 'Used',
256
- selector_unknown: 'Used',
257
- };
258
- const condition = get(car, 'mainData.condition', 'selector_unknown');
259
- return get(available, condition);
260
- }
261
-
262
240
  private getAvailability(car: DataSchemes.Car): string {
263
- return get(car, 'state.isOnline', false) ? 'available' : 'not_available';
241
+ return get(car, 'state.isOnline', false) ? 'AVAILABLE' : 'NOT_AVAILABLE';
264
242
  }
265
243
 
266
244
  private getCarLink(car: DataSchemes.Car): string {
@@ -102,7 +102,8 @@ export class FbTransformer {
102
102
  features: this.getHighlights(car),
103
103
  description: this.getDescription(car), // be careful it changes car object
104
104
  url: `https://www.${this.configService.config.domain}/search/vehicle/${autodeId}`,
105
- dealer_url: `https://www.${this.configService.config.domain}/search/vehicle/${autodeId}?source=FACEBOOK&medium=direkt&campaign=marketplace_link`,
105
+ // ADV-173: UTM params removed — Meta now stores these at the account level instead.
106
+ dealer_url: `https://www.${this.configService.config.domain}/search/vehicle/${autodeId}`,
106
107
  make: toString(get(car, 'mainData.make', '')),
107
108
  model: toString(get(car, 'mainData.model', '')),
108
109
  trim: this.getTrim(car),