@bytebrand/export-cars 1.26.0 → 1.26.2

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.0",
3
+ "version": "1.26.2",
4
4
  "description": "",
5
5
  "author": "bytebrand",
6
6
  "license": "UNLICENSED",
@@ -19,7 +19,7 @@
19
19
  "@bytebrand/car-schema": "^3.14.15",
20
20
  "@bytebrand/chunkable": "^5.19.8",
21
21
  "@bytebrand/cronicle-helper": "^1.21.8",
22
- "@bytebrand/fe-financing": "^5.14.10",
22
+ "@bytebrand/fe-financing": "^5.14.12",
23
23
  "@bytebrand/i18n-dictionaries": "^1.5.0",
24
24
  "@bytebrand/import-helper": "^0.45.0",
25
25
  "@bytebrand/schema-tools": "^3.18.8",
@@ -16,6 +16,7 @@ export class Config {
16
16
  isNewCar: boolean;
17
17
  isCriteo: boolean;
18
18
  exportType: ExportType;
19
+ environment: Environment;
19
20
  domain: string;
20
21
  input: {
21
22
  stringIN: string;
@@ -59,6 +60,7 @@ export class Config {
59
60
  constructor(params: Params) {
60
61
  this.apikey = params.apikey;
61
62
  this.exportType = params.exportType;
63
+ this.environment = params.environment;
62
64
  this.cpc = params.cpc;
63
65
  this.domain = this.environmentConfig[params.environment].domain;
64
66
  this.baseImageUrl = this.environmentConfig[params.environment].baseImageUrl;
@@ -42,7 +42,7 @@ export class FbExporter implements Exporter {
42
42
  console.timeEnd(`Transform ${data.car._id}`);
43
43
 
44
44
  if (!car) {
45
- console.log(`Skip car ${data.car._id} because it has no real images`);
45
+ console.log(`Skip car ${data.car._id} — no real images, or dealer ${ownerId} not in the priority list`);
46
46
  continue;
47
47
  }
48
48
 
@@ -1,59 +1,59 @@
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
- }
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
+ }
@@ -189,9 +189,15 @@ export class ImagesService {
189
189
  const imageCode = marketingImage.marketingImageCodes[i];
190
190
  const imageId = transformedImages[imageCode];
191
191
  if (imageId) {
192
+ const url = this.getImagePathWithExtension(imageUrl, carId, imageId, config);
193
+ // meta_aia cards are composited async and may not exist yet for a given car —
194
+ // verify the file is real before publishing the link, not just the placeholder.
195
+ if (config.channelCode === 'meta_aia' && !(await this.imageExists(url))) {
196
+ break;
197
+ }
192
198
  imageWithUrls.push({
193
199
  imageCodePredicted: imageCode,
194
- url: this.getImagePathWithExtension(imageUrl, carId, imageId, config)
200
+ url,
195
201
  });
196
202
  break;
197
203
  }
@@ -208,6 +214,22 @@ export class ImagesService {
208
214
  return imageWithUrls.map(i => i.url);
209
215
  }
210
216
 
217
+ // Imbo serves a 400x300 JPEG placeholder (measured 7996 bytes on both auto.de and
218
+ // autode-dev.de) with a 404 status when a transformation isn't ready yet. nginx in front of
219
+ // Imbo has been seen caching that placeholder body regardless of status code (see
220
+ // nginx-image-cache-negative-caching), so check Content-Length rather than trusting the
221
+ // HTTP status alone.
222
+ private readonly PLACEHOLDER_MAX_BYTES = 8000;
223
+
224
+ private async imageExists(url: string): Promise<boolean> {
225
+ try {
226
+ const { status, headers } = await axios.head(url, { timeout: 5000 });
227
+ return status === 200 && Number(headers['content-length'] ?? 0) > this.PLACEHOLDER_MAX_BYTES;
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
232
+
211
233
  private transformImagesArrayIntoObject(images: ImboImageResponseInterface[]): { [imageCode: string]: string } {
212
234
  return images.reduce((result, image) => {
213
235
  const imageCode = image?.imageCodePredicted?.split('-')[0];
@@ -224,7 +246,11 @@ export class ImagesService {
224
246
 
225
247
  protected getImagePathWithExtension(imageUrl: string, carId: string, imageId: string, config: MarketingImageConfigsInterface): string {
226
248
  const { channelCode, imageExtension } = config;
227
- return `${this.getChannelPath(imageUrl, carId)}/${channelCode.replace(/ /g, '-')}-${imageId}.${imageExtension}`;
249
+ // image-generator's MetaAiaImagesService writes files as `meta-aia-financing-{imageId}` /
250
+ // `meta-aia-leasing-{imageId}` (hyphenated, mode baked into the name) — not derived from
251
+ // channelCode the way other channels are. FB only wants the financing variant.
252
+ const filenamePrefix = channelCode === 'meta_aia' ? 'meta-aia-financing' : channelCode.replace(/ /g, '-');
253
+ return `${this.getChannelPath(imageUrl, carId)}/${filenamePrefix}-${imageId}.${imageExtension}`;
228
254
  }
229
255
 
230
256
  sortImages(images: ImboImageResponseInterface[]): ImboImageResponseInterface[] {