@bytebrand/export-cars 1.26.29 → 1.26.30

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 |
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.30",
4
4
  "description": "",
5
5
  "author": "bytebrand",
6
6
  "license": "UNLICENSED",
@@ -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".
@@ -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');