@bytebrand/export-cars 1.26.12 → 1.26.14

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.
@@ -1,3 +1,4 @@
1
1
  export declare function convertToCSV(data: unknown): string;
2
2
  export declare const xmlStringReplacer: (str: string) => string;
3
+ export declare function normalizeFeedText(text: string): string;
3
4
  export declare function parseCsvToObjects<T>(csv: string): T[];
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.parseCsvToObjects = exports.xmlStringReplacer = exports.convertToCSV = void 0;
3
+ exports.parseCsvToObjects = exports.normalizeFeedText = exports.xmlStringReplacer = exports.convertToCSV = void 0;
4
4
  const lodash_1 = require("lodash");
5
5
  function convertToCSV(data) {
6
6
  const converted = [];
@@ -16,6 +16,16 @@ const xmlStringReplacer = (str) => {
16
16
  return typeof str === 'string' ? str.replace(/&/g, '&amp;').replace(/[<>]/g, '') : '';
17
17
  };
18
18
  exports.xmlStringReplacer = xmlStringReplacer;
19
+ const DISALLOWED_TEXT_CHARS = /[^\p{L}\p{N} .,\-/()%"°]/gu;
20
+ const EDGE_SEPARATORS = /^[\s.,\-/]+|[\s.,\-/]+$/g;
21
+ function normalizeFeedText(text) {
22
+ return (text || '')
23
+ .replace(DISALLOWED_TEXT_CHARS, ' ')
24
+ .replace(/([.,\-/])\1+/g, '$1')
25
+ .replace(/\s+/g, ' ')
26
+ .replace(EDGE_SEPARATORS, '');
27
+ }
28
+ exports.normalizeFeedText = normalizeFeedText;
19
29
  function parseCsvToObjects(csv) {
20
30
  const rows = [];
21
31
  let row = [];
@@ -1 +1 @@
1
- {"version":3,"file":"converter.utils.js","sourceRoot":"","sources":["../../src/utils/converter.utils.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"converter.utils.js","sourceRoot":"","sources":["../../src/utils/converter.utils.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/export-cars",
3
- "version": "1.26.12",
3
+ "version": "1.26.14",
4
4
  "description": "",
5
5
  "author": "bytebrand",
6
6
  "license": "UNLICENSED",
@@ -8,6 +8,11 @@ import { FbXmlBuilder } from '../formats/fb-xml.builder';
8
8
  import { ModuleRef } from '@nestjs/core';
9
9
  import { FbTransformer } from '../transformers/fb.transformer';
10
10
  import { FbXmlItem } from '../interfaces';
11
+ import { META_PUSH_PRIORITY_ALLOWED_VALUES, META_PUSH_PRIORITY_STANDARD } from '../constants/fb.constants';
12
+
13
+ // dealer_priority_1 first, then dealer_priority_2, then everyone else (standard/unlisted) —
14
+ // mirrors the CSV's own priority tiers (see MappingService.getMetaPushPriorityMapping).
15
+ const PRIORITY_ORDER = [...META_PUSH_PRIORITY_ALLOWED_VALUES, META_PUSH_PRIORITY_STANDARD];
11
16
 
12
17
  @Injectable()
13
18
  export class FbExporter implements Exporter {
@@ -29,6 +34,12 @@ export class FbExporter implements Exporter {
29
34
 
30
35
  const fbTransformer = await this.moduleRef.create(FbTransformer);
31
36
 
37
+ // Buffered (not streamed straight to XML) so the whole feed can be sorted by dealer
38
+ // priority before writing — see the sort below. FbXmlItem doesn't declare custom_label_4
39
+ // (that interface predates ADV-159/161); the transformer always sets it, so extend locally
40
+ // rather than touching the shared interface other export types also cast through.
41
+ const cars: (FbXmlItem & { custom_label_4: string })[] = [];
42
+
32
43
  await this.loaderService.process(async (chunk: FsChunkInterface) => {
33
44
  const chunkData = chunk.data.filter(car => this.carService.filterCar(car, this.rulesService.rules));
34
45
  chunk.data = null;
@@ -42,15 +53,24 @@ export class FbExporter implements Exporter {
42
53
  console.timeEnd(`Transform ${data.car._id}`);
43
54
 
44
55
  if (!car) {
45
- console.log(`Skip car ${data.car._id} because it has no real images`);
56
+ console.log(`Skip car ${data.car._id} — no real images`);
46
57
  continue;
47
58
  }
48
59
 
49
- await fbXmlBuilder.addItem(car as FbXmlItem);
60
+ cars.push(car as FbXmlItem & { custom_label_4: string });
50
61
  this.carService.counter.countCar(data.images);
51
62
  }
52
63
  });
53
64
 
65
+ // ADV-161-follow-up: order by the meta push priority sheet (metaPushPriorityCsvUrl)
66
+ // instead of filtering non-listed dealers out — dealer_priority_1 first, then
67
+ // dealer_priority_2, then everyone else, in whatever order they were transformed.
68
+ cars.sort((a, b) => PRIORITY_ORDER.indexOf(a.custom_label_4) - PRIORITY_ORDER.indexOf(b.custom_label_4));
69
+
70
+ for (const car of cars) {
71
+ await fbXmlBuilder.addItem(car);
72
+ }
73
+
54
74
  await fbXmlBuilder.buildEnd();
55
75
  console.log(this.carService.counter.getCountedCars());
56
76
  }
@@ -46,13 +46,8 @@ export class FbDescription {
46
46
  key: get(this.car, 'engineData.cylinders', '') as string,
47
47
  },
48
48
  },
49
- {
50
- title: 'accordion.captionCapacity',
51
- value: {
52
- key: 'title.cubicCapacity',
53
- data: { cubicCapacity: get(this.car, 'engineData.cubicCapacity') },
54
- },
55
- },
49
+ // "Hubraum" is not exported: the value key resolved to the label itself
50
+ // (title.cubicCapacity === "Hubraum"), so every listing read "Hubraum: Hubraum".
56
51
  {
57
52
  title: 'accordion.captionPower',
58
53
  value: {
@@ -166,12 +161,8 @@ export class FbDescription {
166
161
  },
167
162
  },
168
163
  },
169
- {
170
- title: 'title.base',
171
- value: {
172
- key: get(this.car, 'driveSuspension.wheelBase', '') as string,
173
- },
174
- },
164
+ // "Radstand" is not exported: driveSuspension.wheelBase is stored as null across the
165
+ // catalogue, so every listing read "Radstand: ;".
175
166
  {
176
167
  title: 'title.loadingVolume',
177
168
  value: {
@@ -1,7 +1,6 @@
1
1
  import { Cache } from 'cache-manager';
2
2
  import * as moment from 'moment';
3
3
  import {
4
- escape,
5
4
  findIndex,
6
5
  get,
7
6
  includes,
@@ -12,7 +11,6 @@ import {
12
11
  join,
13
12
  toSafeInteger,
14
13
  toString,
15
- trim,
16
14
  truncate,
17
15
  } from 'lodash';
18
16
 
@@ -30,15 +28,15 @@ import { Description } from '../interfaces/description.interface';
30
28
  import { FbDescription } from './fb-description.transformer';
31
29
  import { ConfigService, ImagesService, MappingService } from '../services';
32
30
  import { FbEquipment } from './fb-equipment.transformer';
33
- import { isValidVin } from '../utils';
31
+ import { isValidVin, normalizeFeedText } from '../utils';
34
32
  import { UserService } from 'src/services';
35
- import { ImageType } from '../enums';
36
33
  import {
37
34
  DEFAULT_ADDR1,
38
35
  DEFAULT_CITY,
39
36
  DEFAULT_ZIP,
40
37
  FB_MAX_TRIM_LENGTH,
41
38
  FB_MIN_MILEAGES,
39
+ META_AIA_MODE_FINANCING,
42
40
  META_PUSH_PRIORITY_ALLOWED_VALUES,
43
41
  META_PUSH_PRIORITY_STANDARD,
44
42
  TIMESTAMP_FIRST_YEAR,
@@ -74,10 +72,12 @@ export class FbTransformer {
74
72
  images: ImboImageResponseInterface[];
75
73
  owner: unknown;
76
74
  }): Promise<unknown> {
77
- // ignoreDefImages=true: cars with no real photos must be excluded, not padded with
78
- // placeholders (matches facebook-shop.transformer.ts's AMA feed policy).
79
- const image_links = await this.imagesService.getImages(car, images, ImageType.marketing, true);
80
- if (!image_links.length) {
75
+ // Meta AIA financing overlay is the ONLY image this feed ships — same HEAD-checked path as
76
+ // the AMA facebook-shop feed. getMetaAiaImage returns null when the car has no eligible photo
77
+ // OR the composite hasn't actually been rendered/landed on the CDN yet, so every exported item
78
+ // is guaranteed a real, existing image (no placeholders, no 404s, no zero-image cars).
79
+ const metaAiaImage = await this.imagesService.getMetaAiaImage(car._id, images, META_AIA_MODE_FINANCING);
80
+ if (!metaAiaImage) {
81
81
  return null;
82
82
  }
83
83
 
@@ -107,7 +107,7 @@ export class FbTransformer {
107
107
  make: toString(get(car, 'mainData.make', '')),
108
108
  model: toString(get(car, 'mainData.model', '')),
109
109
  trim: this.getTrim(car),
110
- image: image_links.slice(0, 20), // Meta caps the whole image array at 20 items
110
+ image: [metaAiaImage], // single Meta AIA financing overlay, per feed spec
111
111
  year: toString(year),
112
112
  mileage: {
113
113
  value: this.getMileage(car),
@@ -188,18 +188,23 @@ export class FbTransformer {
188
188
  }
189
189
 
190
190
  private getTitle(car: DataSchemes.Car): string {
191
- let year = toString(get(car, 'mainData.constructionYear', ''));
192
- if (year === '0') {
193
- year = '';
194
- }
191
+ // mainData.constructionYear holds a raw epoch for part of the catalogue — the same
192
+ // corruption getFirstValidYear() already guards <year> against. Without the guard titles
193
+ // ship as "1609459200 Ford Galaxy …"; an invalid year is dropped instead.
194
+ const year = toString(getFirstValidYear(toString(get(car, 'mainData.constructionYear', ''))));
195
195
  const make = toString(get(car, 'mainData.make', ''));
196
196
  const model = toString(get(car, 'mainData.model', ''));
197
197
  const subModel = toString(get(car, 'mainData.subModel', ''));
198
- return trim(escape(join([year, make, model, subModel], ' ')));
198
+ // No escape() here: FbXmlBuilder writes <title> inside CDATA, so HTML entities are not
199
+ // decoded by the consumer and leak literally ("Monte Carlo &amp; KESSY").
200
+ return normalizeFeedText(join([year, make, model, subModel], ' '));
199
201
  }
200
202
 
201
203
  private getTrim(car: DataSchemes.Car): string {
202
- const trim = toString(get(car, 'mainData.subModel', ''));
204
+ // Same subModel source as the title, so it needs the same cleanup. Normalise before
205
+ // truncating: on the raw string the cut could land mid-junk, and lodash's "..." omission
206
+ // marker would then be collapsed by the repeated-separator rule.
207
+ const trim = normalizeFeedText(toString(get(car, 'mainData.subModel', '')));
203
208
  return truncate(trim, { length: FB_MAX_TRIM_LENGTH, separator: ' ' });
204
209
  }
205
210
 
@@ -14,6 +14,25 @@ export const xmlStringReplacer = (str: string): string => {
14
14
  return typeof str === 'string' ? str.replace(/&/g, '&amp;').replace(/[<>]/g, '') : '';
15
15
  };
16
16
 
17
+ // Dealer-supplied subModel strings are full of decorative punctuation ("*AHK*Leder*Navi*",
18
+ // "|ACC|PANO/AHK", "#PANO #NAVI #SONY #", "Head-Up+B&O+ACC") which must not reach Meta. Keep
19
+ // letters, digits and the punctuation that carries meaning in German trim names — "." "," "-"
20
+ // "/" "(" ")" "%", "°" (360° Kamera) and '"' (Zoll). Everything else, "&" included, becomes a
21
+ // space rather than being deleted, so neighbouring words never fuse ("B&O" -> "B O").
22
+ const DISALLOWED_TEXT_CHARS = /[^\p{L}\p{N} .,\-/()%"°]/gu;
23
+ // Leading/trailing separators only; '"' and "%" are legitimate endings (`LM18"`, `-35%`).
24
+ const EDGE_SEPARATORS = /^[\s.,\-/]+|[\s.,\-/]+$/g;
25
+
26
+ // Applies to the free-text feed fields only (title, trim). URLs, dealer_email and dealer_phone
27
+ // carry "/" ":" "@" "+" as load-bearing characters and must never be run through this.
28
+ export function normalizeFeedText(text: string): string {
29
+ return (text || '')
30
+ .replace(DISALLOWED_TEXT_CHARS, ' ')
31
+ .replace(/([.,\-/])\1+/g, '$1') // "L4H2 //Klimaanlage" -> "L4H2 /Klimaanlage"
32
+ .replace(/\s+/g, ' ')
33
+ .replace(EDGE_SEPARATORS, '');
34
+ }
35
+
17
36
  // Parses a "Publish to web" Google Sheets CSV export (RFC4180-ish, quoted fields may
18
37
  // contain commas/newlines) into row arrays, then maps rows to objects keyed by header.
19
38
  export function parseCsvToObjects<T>(csv: string): T[] {