@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/dist/configs/config.d.ts +2 -1
- package/dist/configs/config.js +2 -0
- package/dist/configs/config.js.map +1 -1
- package/dist/exporters/fb.exporter.js +1 -1
- package/dist/exporters/fb.exporter.js.map +1 -1
- package/dist/services/images.service.d.ts +2 -0
- package/dist/services/images.service.js +24 -3
- package/dist/services/images.service.js.map +1 -1
- package/dist/services/user.service.d.ts +1 -0
- package/dist/services/user.service.js +8 -0
- package/dist/services/user.service.js.map +1 -1
- package/dist/transformers/fb.transformer.js +6 -0
- package/dist/transformers/fb.transformer.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/configs/config.ts +2 -0
- package/src/exporters/fb.exporter.ts +1 -1
- package/src/formats/facebook-shop.builder.ts +59 -59
- package/src/services/images.service.ts +41 -4
- package/src/services/user.service.ts +13 -0
- package/src/transformers/facebook-shop.transformer.ts +259 -259
- package/src/transformers/fb.transformer.ts +12 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytebrand/export-cars",
|
|
3
|
-
"version": "1.26.
|
|
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.
|
|
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",
|
package/src/configs/config.ts
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|