@bytebrand/export-cars 1.26.1 → 1.26.3

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.1",
3
+ "version": "1.26.3",
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
+ }
@@ -13,7 +13,7 @@ import { ConfigService } from './config.service';
13
13
  import { UserService } from './user.service';
14
14
  import { isNotEmptyArray } from '../utils';
15
15
  import { Config } from '../configs/config';
16
- import { ImageSize, ImageType } from '../enums';
16
+ import { ExportType, ImageSize, ImageType } from '../enums';
17
17
 
18
18
  @Injectable()
19
19
  export class ImagesService {
@@ -179,7 +179,18 @@ export class ImagesService {
179
179
  const imageUrl = this.configService.config.marketingImageUrl;
180
180
 
181
181
  const transformedImages = this.transformImagesArrayIntoObject(images);
182
- const config = await this.userService.getMarketingImageConfig(this.configService.config.marketingImageConfigId);
182
+ // fbMarketingImageId is a hardcoded per-environment id in config.ts and has drifted from
183
+ // the real meta_aia config at least once (dev's id pointed at a stale, never-populated "fb"
184
+ // channel) — the FB feed resolves its config by channelCode instead of trusting that id.
185
+ const config = this.configService.config.exportType === ExportType.FB
186
+ ? await this.userService.getMarketingImageConfigByChannelCode('meta_aia')
187
+ : await this.userService.getMarketingImageConfig(this.configService.config.marketingImageConfigId);
188
+
189
+ if (!config) {
190
+ console.log('getMarketingImages: no marketing image config found (channelCode meta_aia missing?)');
191
+ return ignoreDefImages ? [] : [...this.DEFAULT_IMAGES];
192
+ }
193
+
183
194
  const marketingImageConfigs = config.marketingImages.map(i => ({ marketingImageCodes: i.imageCodes }));
184
195
 
185
196
  const imageWithUrls: Partial<ImboImageResponseInterface & { url: string }>[] = [];
@@ -189,9 +200,15 @@ export class ImagesService {
189
200
  const imageCode = marketingImage.marketingImageCodes[i];
190
201
  const imageId = transformedImages[imageCode];
191
202
  if (imageId) {
203
+ const url = this.getImagePathWithExtension(imageUrl, carId, imageId, config);
204
+ // meta_aia cards are composited async and may not exist yet for a given car —
205
+ // verify the file is real before publishing the link, not just the placeholder.
206
+ if (config.channelCode === 'meta_aia' && !(await this.imageExists(url))) {
207
+ break;
208
+ }
192
209
  imageWithUrls.push({
193
210
  imageCodePredicted: imageCode,
194
- url: this.getImagePathWithExtension(imageUrl, carId, imageId, config)
211
+ url,
195
212
  });
196
213
  break;
197
214
  }
@@ -208,6 +225,22 @@ export class ImagesService {
208
225
  return imageWithUrls.map(i => i.url);
209
226
  }
210
227
 
228
+ // Imbo serves a 400x300 JPEG placeholder (measured 7996 bytes on both auto.de and
229
+ // autode-dev.de) with a 404 status when a transformation isn't ready yet. nginx in front of
230
+ // Imbo has been seen caching that placeholder body regardless of status code (see
231
+ // nginx-image-cache-negative-caching), so check Content-Length rather than trusting the
232
+ // HTTP status alone.
233
+ private readonly PLACEHOLDER_MAX_BYTES = 8000;
234
+
235
+ private async imageExists(url: string): Promise<boolean> {
236
+ try {
237
+ const { status, headers } = await axios.head(url, { timeout: 5000 });
238
+ return status === 200 && Number(headers['content-length'] ?? 0) > this.PLACEHOLDER_MAX_BYTES;
239
+ } catch {
240
+ return false;
241
+ }
242
+ }
243
+
211
244
  private transformImagesArrayIntoObject(images: ImboImageResponseInterface[]): { [imageCode: string]: string } {
212
245
  return images.reduce((result, image) => {
213
246
  const imageCode = image?.imageCodePredicted?.split('-')[0];
@@ -224,7 +257,11 @@ export class ImagesService {
224
257
 
225
258
  protected getImagePathWithExtension(imageUrl: string, carId: string, imageId: string, config: MarketingImageConfigsInterface): string {
226
259
  const { channelCode, imageExtension } = config;
227
- return `${this.getChannelPath(imageUrl, carId)}/${channelCode.replace(/ /g, '-')}-${imageId}.${imageExtension}`;
260
+ // image-generator's MetaAiaImagesService writes files as `meta-aia-financing-{imageId}` /
261
+ // `meta-aia-leasing-{imageId}` (hyphenated, mode baked into the name) — not derived from
262
+ // channelCode the way other channels are. FB only wants the financing variant.
263
+ const filenamePrefix = channelCode === 'meta_aia' ? 'meta-aia-financing' : channelCode.replace(/ /g, '-');
264
+ return `${this.getChannelPath(imageUrl, carId)}/${filenamePrefix}-${imageId}.${imageExtension}`;
228
265
  }
229
266
 
230
267
  sortImages(images: ImboImageResponseInterface[]): ImboImageResponseInterface[] {
@@ -67,4 +67,17 @@ export class UserService {
67
67
 
68
68
  return result;
69
69
  }
70
+
71
+ // The per-environment fbMarketingImageId config.ts hardcodes doesn't reliably point at the
72
+ // meta_aia channel (dev's id 8 turned out to be a stale, never-populated "fb" channel) —
73
+ // resolve by channelCode instead of trusting a fixed id.
74
+ async getMarketingImageConfigByChannelCode(channelCode: string): Promise<MarketingImageConfigInterface | null> {
75
+ const configs = await this.getMarketingImageConfigs();
76
+ const match = configs.find(config => config.channelCode === channelCode);
77
+ if (!match) {
78
+ return null;
79
+ }
80
+
81
+ return this.getMarketingImageConfig(match.id);
82
+ }
70
83
  }