@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.
- package/README.md +12 -4
- package/dist/services/images.service.d.ts +4 -0
- package/dist/services/images.service.js +9 -3
- package/dist/services/images.service.js.map +1 -1
- 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 +2 -1
- package/docs/openai-ads/RUNBOOK.md +19 -0
- package/package.json +1 -1
- package/src/services/images.service.ts +15 -4
- package/src/transformers/openai-ads.transformer.ts +40 -8
- package/test/clean-images.test.ts +26 -0
- 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 |
|
|
@@ -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
|
@@ -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_`),
|
|
199
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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".
|
|
@@ -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');
|