@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.
- package/dist/transformers/openai-ads.transformer.d.ts +1 -0
- package/dist/transformers/openai-ads.transformer.js +28 -6
- package/dist/transformers/openai-ads.transformer.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/openai-ads/AUDIT.md +1 -1
- package/package.json +1 -1
- package/src/transformers/openai-ads.transformer.ts +40 -8
- package/test/openai-ads-mapping.test.ts +21 -0
package/docs/openai-ads/AUDIT.md
CHANGED
|
@@ -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 |
|
|
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
|
@@ -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
|
|
18
|
-
//
|
|
19
|
-
const
|
|
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 ||
|
|
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
|
|
130
|
-
|
|
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');
|