@bytebrand/export-cars 1.26.29 → 1.26.31

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.
@@ -128,7 +128,7 @@ Null policy "EXCLUDE" = row not written + counted in the run report under the re
128
128
  | OpenAI field | Req. | Source path | Transform / format | Null / invalid policy | Test case |
129
129
  |---|---|---|---|---|---|
130
130
  | `item_id` | R | `car._id` | string as-is (UUID) | missing → EXCLUDE `no_id`; duplicate in run → keep first, count `duplicate_id` | dup id; id equals GA4 `item_id` |
131
- | `title` | R | `mainData.make`, `mainData.model`, `mainData.subModel` | `normalizeFeedText(make model subModel)`, German as delivered, ≤150 at word boundary, no HTML escape | make or model empty → EXCLUDE `no_title` | umlauts, `*AHK*Leder*`, `&`, 200-char subModel |
131
+ | `title` | R | `mainData.make`, `mainData.model`, `mainData.subModel` | `normalizeFeedText(make model subModel)`, German as delivered, ≤150 at word boundary, no HTML escape; placeholder model `SONSTIGE` left out; UTF-8-read-as-Latin-1/cp1252 (`ü`) repaired (1.26.30) | neither a real model nor a variant → EXCLUDE `no_title` | umlauts, `*AHK*Leder*`, `&`, 200-char subModel |
132
132
  | `description` | R | structured fields only — NOT the dealer summary templates of the Google feed (free text, finance claims unverifiable, Q6) | `Gebrauchtwagen: <title>. Erstzulassung: 03/2021 · Kilometerstand: 45.000 km · Kraftstoff: Benzin · Getriebe: Manuell · Leistung: 110 kW (150 PS) · Karosserie · Außenfarbe`, ≤5000, real mileage (no 901/1001 floors) | facts without a German label are left out | sparse car, no `selector_*` leaks |
133
133
  | `url` | R | `car._id` | `https://www.{domain}/search/vehicle/{_id}` (same as Google/fb; no UTM — tracking via ADV-337) | always set | url host per env |
134
134
  | `brand` | R | `mainData.make` | raw manufacturer name (NOT Google's "Mercedes"/"VW" rewrite) | empty → EXCLUDE | Mercedes-Benz stays Mercedes-Benz |
@@ -182,6 +182,7 @@ Filters before mapping (all reasons counted in the run report):
182
182
  | Q11 | OAI-AdsBot / OAI-SearchBot reachability of `www.auto.de` VDP and `images.auto.de` behind Cloudflare (js_challenge rules from ADV-193). Test only, no CF change without approval. | Eng + approval | go-live |
183
183
  | Q12 | Image: ADV-336 clean profile is used (no overlay). Approval of plain photos + `ext_lr_main` question open in ADV-336 | ADV-336 | image_url |
184
184
  | Q13 | Is a "Stable" vs "Draft" schema selector visible in your docs/account? The public page shows none on 09.10. | Marketing | — |
185
+ | Q14 | Make mapping at import (out of feed scope): DEV car titled "Jeep SWM G01a" — model SONSTIGE, variant names SWM (a different manufacturer), so the feed sends `brand=Jeep` for a non-Jeep car. Fix belongs to the import make/model mapping (sheet / CBD), not to the exporter. | Import owner | brand correctness |
185
186
 
186
187
  ---
187
188
 
@@ -77,6 +77,25 @@ up for the **live** output only (separate, approved step).
77
77
  | `count_drop` | > N % fewer cars than the last good run | confirm the drop is real (CBD online count, dealer offboarding), then ONE run with `feedGuardAcceptDrop=true` |
78
78
  | `locked` | another run holds `run.lock` (< 6 h) | wait; a lock older than 6 h is taken over automatically |
79
79
 
80
+ ## Known risk: other export-cars jobs report failures as success (accepted, 12.10)
81
+
82
+ Every export-cars run except `openai_ads` ends with `{"complete":1,"code":0}`, even when the exporter threw:
83
+ nest-commander's default `serviceErrorHandler` only prints the error and `main.ts` then reports success. Cronicle
84
+ therefore treats a failed part 1 as successful and starts the chained part 2, which can upload an empty or
85
+ partial file (Google feed incident 24.09: a 169-byte feed was uploaded and fetched by Google). Decision
86
+ (user, 12.10): keep it — no global fix, no `chain_error` handlers, no Cronicle config change; the chains keep
87
+ running after a failure as before.
88
+
89
+ Only `openai_ads` is protected: the feed guard keeps the last good file, and the job exits with code 1
90
+ (`exitError` + exit 1 in `openai-ads.exporter.ts`). It has no chain, so this stops nothing else.
91
+
92
+ ## Known source-data issues (not fixed by the feed)
93
+
94
+ - Wrong make from the import mapping: e.g. DEV "Jeep SWM G01a" (model SONSTIGE, the variant names another
95
+ manufacturer). The feed sends the stored make as `brand`; correct it in the import make/model mapping (AUDIT Q14).
96
+ - Handled in the feed since 1.26.30: placeholder model `SONSTIGE` is left out of title/description; UTF-8 read as
97
+ Latin-1/cp1252 upstream (`ü` → `ü`) is repaired.
98
+
80
99
  ## Rollback
81
100
 
82
101
  - Bad content published: `cp <output>.prev <output>` (the previous good file), then disable the Cronicle event.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/export-cars",
3
- "version": "1.26.29",
3
+ "version": "1.26.31",
4
4
  "description": "",
5
5
  "author": "bytebrand",
6
6
  "license": "UNLICENSED",
@@ -28,6 +28,11 @@ export const CLEAN_HERO_IMAGE_CODES = ['ext_f_right', 'ext_f_left'];
28
28
  // Explicit JPEG variant — the extension-less `large` URL negotiates WebP from the Accept header
29
29
  // (no Vary), so a crawler could get a different format than the one we validated.
30
30
  export const CLEAN_IMAGE_VARIANT = 'large-cached.jpeg';
31
+ // Real exterior views the shared IMAGES_SORT_ORDER doesn't know, accepted by the clean profile only
32
+ // (other feeds stay unchanged). Ranks sit between the last exterior view (18) and conext_* (19).
33
+ export const CLEAN_EXTRA_IMAGE_SORT_ORDER: { [imageCode: string]: number } = {
34
+ ext_lr_main: 18.5,
35
+ };
31
36
 
32
37
  @Injectable()
33
38
  export class ImagesService {
@@ -195,8 +200,9 @@ export class ImagesService {
195
200
  /**
196
201
  * Clean channel profile (ADV-336): the car's own photos only, as explicit JPEG URLs, in a
197
202
  * deterministic order — 3/4 exterior hero first, then the remaining exterior views, then the
198
- * interior views (IMAGES_SORT_ORDER). Feature close-ups (`_feat_`), `conext_*` and codes the
199
- * sort order doesn't know (simple_*, promo, ...) are dropped; so are repeated imageIdentifiers.
203
+ * interior views (IMAGES_SORT_ORDER + CLEAN_EXTRA_IMAGE_SORT_ORDER). Feature close-ups (`_feat_`),
204
+ * `conext_*` and codes neither order knows (simple_*, promo, ...) are dropped; so are repeated
205
+ * imageIdentifiers.
200
206
  * Returns [] when the car has no 3/4 exterior shot — callers skip the car, never substitute a
201
207
  * placeholder. Does not validate the URLs (see ImageUrlValidator) and never mutates `images`.
202
208
  */
@@ -214,7 +220,8 @@ export class ImagesService {
214
220
  seen.add(image.imageIdentifier);
215
221
  return true;
216
222
  });
217
- const sorted = this.sortImages(eligible);
223
+ // stable sort, ties keep Imbo order
224
+ const sorted = eligible.sort((a, b) => this.getCleanSortOrder(this.getImageCode(a)) - this.getCleanSortOrder(this.getImageCode(b)));
218
225
 
219
226
  const heroIndex = CLEAN_HERO_IMAGE_CODES
220
227
  .map(code => sorted.findIndex(image => this.getImageCode(image) === code))
@@ -240,7 +247,11 @@ export class ImagesService {
240
247
  }
241
248
 
242
249
  private isCleanImageCode(imageCode: string): boolean {
243
- return /^(ext|int)_/.test(imageCode) && !imageCode.includes('_feat_') && imageCode in this.IMAGES_SORT_ORDER;
250
+ return /^(ext|int)_/.test(imageCode) && !imageCode.includes('_feat_') && this.getCleanSortOrder(imageCode) !== undefined;
251
+ }
252
+
253
+ private getCleanSortOrder(imageCode: string): number | undefined {
254
+ return this.IMAGES_SORT_ORDER[imageCode] ?? CLEAN_EXTRA_IMAGE_SORT_ORDER[imageCode];
244
255
  }
245
256
 
246
257
  private getImageCode(image: ImboImageResponseInterface): string | undefined {
@@ -14,9 +14,24 @@ import { OpenAiImageFields, encodeCommasInUrl } from './openai-image-fields';
14
14
  export const OPENAI_TITLE_MAX_LENGTH = 150;
15
15
  export const OPENAI_DESCRIPTION_MAX_LENGTH = 5000;
16
16
 
17
- // Placeholder makes from import mapping (SONSTIGE = "other") — not a real manufacturer, so the
18
- // required `brand` would be invented.
19
- const PLACEHOLDER_MAKES = /^(sonstige|sonstiges|andere|other|unknown|unbekannt)$/i;
17
+ // Placeholder make/model values from import mapping (SONSTIGE = "other"). As `brand` the value would
18
+ // be invented (car excluded); as model it is left out of title and description.
19
+ const PLACEHOLDER_NAMES = /^(sonstige|sonstiges|andere|other|unknown|unbekannt)$/i;
20
+
21
+ // UTF-8 text that was decoded as Latin-1 / Windows-1252 somewhere upstream ("Navi über" for
22
+ // "Navi über", "ß" for "ß", "„" for "„"). Continuation bytes 0x80–0xBF show up as the Latin-1
23
+ // character of the same code, or — for 0x80–0x9F — as the Windows-1252 character below.
24
+ const CP1252_HIGH: Record<string, number> = {
25
+ '€': 0x80, '‚': 0x82, 'ƒ': 0x83, '„': 0x84, '…': 0x85, '†': 0x86,
26
+ '‡': 0x87, 'ˆ': 0x88, '‰': 0x89, 'Š': 0x8a, '‹': 0x8b, 'Œ': 0x8c,
27
+ 'Ž': 0x8e, '‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95,
28
+ '–': 0x96, '—': 0x97, '˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b,
29
+ 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f,
30
+ };
31
+ const CONTINUATION = `[\\u0080-\\u00BF${Object.keys(CP1252_HIGH).join('')}]`;
32
+ // a C2–C5 lead + one continuation (Latin-1 Supplement / Latin Extended-A), or E2 + two
33
+ // (general punctuation, €)
34
+ const MOJIBAKE_SEQUENCE = new RegExp(`[\\u00C2-\\u00C5]${CONTINUATION}|\\u00E2${CONTINUATION}{2}`, 'g');
20
35
 
21
36
  const OFFER_AVAILABILITY_OUT_OF_STOCK: Record<string, OpenAiAdsOutOfStockReason> = {
22
37
  selector_offerAvailability_reserved: OpenAiAdsOutOfStockReason.reserved,
@@ -55,8 +70,8 @@ export const mapOpenAiAdsVehicle = (car: DataSchemes.Car, ctx: OpenAiAdsMappingC
55
70
  return { ok: false, reason: OpenAiAdsExclusion.invalidPrice };
56
71
  }
57
72
 
58
- const brand = trim(toString(get(car, 'mainData.make', '')));
59
- if (!brand || PLACEHOLDER_MAKES.test(brand)) {
73
+ const brand = repairMojibake(trim(toString(get(car, 'mainData.make', ''))));
74
+ if (!brand || PLACEHOLDER_NAMES.test(brand)) {
60
75
  return { ok: false, reason: OpenAiAdsExclusion.noBrand };
61
76
  }
62
77
 
@@ -124,13 +139,30 @@ export const toOpenAiMinorUnits = (value: unknown): number | null => {
124
139
 
125
140
  // "<make> <model> <variant>" as delivered (German market names), dealer junk punctuation removed
126
141
  // with the same normalizer as the Meta feed, cut at a word boundary. No HTML escaping: CSV quoting
127
- // handles every character.
142
+ // handles every character. A placeholder model (SONSTIGE) is left out ("MG 1.5 Hybrid Luxury");
143
+ // with neither a real model nor a variant there is no title and the car is not sent.
128
144
  export const buildOpenAiTitle = (car: DataSchemes.Car): string => {
129
- const parts = ['mainData.make', 'mainData.model', 'mainData.subModel'].map(path => toString(get(car, path, '')));
130
- const title = normalizeFeedText(parts.join(' '));
145
+ const [make, model, subModel] = ['mainData.make', 'mainData.model', 'mainData.subModel']
146
+ .map(path => repairMojibake(trim(toString(get(car, path, '')))));
147
+ const realModel = PLACEHOLDER_NAMES.test(model) ? '' : model;
148
+ if (!normalizeFeedText(`${realModel} ${subModel}`)) {
149
+ return '';
150
+ }
151
+ const title = normalizeFeedText([make, realModel, subModel].join(' '));
131
152
  return truncate(title, { length: OPENAI_TITLE_MAX_LENGTH, separator: ' ', omission: '' }).trim();
132
153
  };
133
154
 
155
+ // Repairs only sequences that decode to one character of the expected ranges (Latin-1 Supplement,
156
+ // Latin Extended-A, general punctuation, €), so regular German text ("ä°", "ß") is never touched.
157
+ export const repairMojibake = (text: string): string =>
158
+ (text || '').replace(MOJIBAKE_SEQUENCE, (sequence) => {
159
+ const bytes = [...sequence].map(char => CP1252_HIGH[char] ?? char.charCodeAt(0));
160
+ const decoded = Buffer.from(bytes).toString('utf8');
161
+ const code = decoded.length === 1 ? decoded.charCodeAt(0) : -1;
162
+ const expected = (code >= 0x00a0 && code <= 0x017f) || (code >= 0x2010 && code <= 0x20ac);
163
+ return expected ? decoded : sequence;
164
+ });
165
+
134
166
  // new/used from mainData.condition. selector_unknown is only "used" when the car has a first
135
167
  // registration in the past (a registered car is used for advertising purposes); otherwise
136
168
  // the car is excluded rather than defaulted — OpenAI treats an omitted condition as "new".
@@ -38,6 +38,32 @@ const cases: Array<[string, () => void | Promise<void>]> = [
38
38
  ];
39
39
  assert.deepEqual(service.getCleanImages('car', images).map(i => i.imageIdentifier), ['left', 'cowl']);
40
40
  }],
41
+ ['ext_lr_main (clean-only allowlist) comes after the other exterior views, before interior', () => {
42
+ const images = [
43
+ { imageIdentifier: 'dash', imageCodePredicted: 'int_f_ipanel1' },
44
+ { imageIdentifier: 'lr', imageCodePredicted: 'ext_lr_main-normalcar' },
45
+ { imageIdentifier: 'opened', imageCodePredicted: 'ext_f_right_opened' },
46
+ { imageIdentifier: 'back', imageCodePredicted: 'ext_b_main' },
47
+ { imageIdentifier: 'right', imageCodePredicted: 'ext_f_right-normalcar' },
48
+ ];
49
+ const result = service.getCleanImages('car', images);
50
+ assert.deepEqual(result.map(i => i.imageIdentifier), ['right', 'back', 'opened', 'lr', 'dash']);
51
+ assert.equal(result.find(i => i.imageIdentifier === 'lr').heroCandidate, false);
52
+ }],
53
+ ['ext_lr_main is never the hero', () => {
54
+ assert.deepEqual(service.getCleanImages('car', [{ imageIdentifier: 'lr', imageCodePredicted: 'ext_lr_main' }]), []);
55
+ }],
56
+ ['the clean-only allowlist does not touch the other profiles (ext_lr_main still ranks 100 there)', async () => {
57
+ const images = () => [
58
+ { imageIdentifier: 'lr', imageCodePredicted: 'ext_lr_main' },
59
+ { imageIdentifier: 'dash', imageCodePredicted: 'int_f_ipanel1' },
60
+ { imageIdentifier: 'right', imageCodePredicted: 'ext_f_right' },
61
+ ];
62
+ const urls = await service.getImages({ _id: 'car' } as any, images(), ImageType.default, true);
63
+ assert.deepEqual(urls.map(u => u.split('/')[5]), ['right', 'dash', 'lr']);
64
+ const google = await service.getExtIntDefaultImages({ _id: 'car' } as any, images(), ImageType.default, false);
65
+ assert.deepEqual(google.ext.map(u => u.split('/')[5]), ['lr']);
66
+ }],
41
67
  ['no 3/4 exterior shot: no images at all (car is skipped, no fallback hero)', () => {
42
68
  assert.deepEqual(clean('noHero'), []);
43
69
  assert.deepEqual(clean('onlyUnknown'), []);
@@ -6,6 +6,7 @@ import {
6
6
  asOutOfStock,
7
7
  formatOpenAiPrice,
8
8
  mapOpenAiAdsVehicle,
9
+ repairMojibake,
9
10
  toOpenAiMinorUnits,
10
11
  withOpenAiImages,
11
12
  } from '../src/transformers/openai-ads.transformer';
@@ -69,6 +70,26 @@ const cases: Array<[string, () => void]> = [
69
70
  const long = okRow(car('a', { 'mainData.subModel': 'Ausstattung '.repeat(30) })).title;
70
71
  assert.ok(long.length <= 150 && !long.endsWith(' '), long);
71
72
  }],
73
+ ['title: placeholder model SONSTIGE is left out of title and description; nothing left -> excluded', () => {
74
+ const row = okRow(car('a', { 'mainData.make': 'MG', 'mainData.model': 'SONSTIGE', 'mainData.subModel': '1.5 Hybrid Luxury' }));
75
+ assert.equal(row.title, 'MG 1.5 Hybrid Luxury');
76
+ assert.ok(!/sonstige/i.test(row.description), row.description);
77
+ assert.equal(okRow(car('a', { 'mainData.model': 'Sonstige', 'mainData.subModel': 'Golf' })).title, 'Volkswagen Golf');
78
+ assert.equal(reason(car('a', { 'mainData.model': 'SONSTIGE', 'mainData.subModel': '' })), X.noTitle);
79
+ assert.equal(reason(car('a', { 'mainData.model': '', 'mainData.subModel': '***' })), X.noTitle);
80
+ // a real model that merely contains the word stays as it is
81
+ assert.equal(okRow(car('a', { 'mainData.model': 'Sonstige Modelle 2', 'mainData.subModel': '' })).title, 'Volkswagen Sonstige Modelle 2');
82
+ }],
83
+ ['title: UTF-8 read as Latin-1 upstream is repaired, regular umlauts untouched', () => {
84
+ assert.equal(repairMojibake('AHK/Navi über SmartLink'), 'AHK/Navi über SmartLink');
85
+ assert.equal(repairMojibake('Größe Äußeres'), 'Größe Äußeres');
86
+ assert.equal(repairMojibake('Sitzheizung – Paket „1“'), 'Sitzheizung – Paket „1“');
87
+ assert.equal(repairMojibake('Größe Äußeres 360° Škoda Č'), 'Größe Äußeres 360° Škoda Č');
88
+ assert.equal(repairMojibake(''), '');
89
+ const row = okRow(car('a', { 'mainData.make': 'Skoda', 'mainData.model': 'Octavia', 'mainData.subModel': 'Navi über SmartLink' }));
90
+ assert.equal(row.title, 'Skoda Octavia Navi über SmartLink');
91
+ assert.ok(row.description.includes('Navi über SmartLink'));
92
+ }],
72
93
  ['condition: new/used; unknown only used with a past first registration', () => {
73
94
  assert.equal(okRow(car('a', { 'mainData.condition': 'selector_condition_new' })).condition, 'new');
74
95
  assert.equal(okRow(car('a')).condition, 'used');