@bytebrand/export-cars 1.26.30 → 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.
@@ -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.30",
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 {
@@ -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'), []);