@bytebrand/export-cars 1.26.28 → 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/README.md +100 -0
- package/dist/configs/index.d.ts +1 -0
- package/dist/configs/index.js +1 -0
- package/dist/configs/index.js.map +1 -1
- package/dist/configs/openai-ads.config.d.ts +22 -0
- package/dist/configs/openai-ads.config.js +56 -0
- package/dist/configs/openai-ads.config.js.map +1 -0
- package/dist/enums/export-type.enum.d.ts +2 -1
- package/dist/enums/export-type.enum.js +1 -0
- package/dist/enums/export-type.enum.js.map +1 -1
- package/dist/enums/image-type.enum.d.ts +2 -1
- package/dist/enums/image-type.enum.js +1 -0
- package/dist/enums/image-type.enum.js.map +1 -1
- package/dist/export.schema.d.ts +6 -2
- package/dist/export.schema.js +4 -0
- package/dist/export.schema.js.map +1 -1
- package/dist/exporters/index.d.ts +1 -0
- package/dist/exporters/index.js +1 -0
- package/dist/exporters/index.js.map +1 -1
- package/dist/exporters/openai-ads.exporter.d.ts +13 -0
- package/dist/exporters/openai-ads.exporter.js +91 -0
- package/dist/exporters/openai-ads.exporter.js.map +1 -0
- package/dist/formats/openai-ads-csv.builder.d.ts +15 -0
- package/dist/formats/openai-ads-csv.builder.js +48 -0
- package/dist/formats/openai-ads-csv.builder.js.map +1 -0
- package/dist/interfaces/openai-ads.interface.d.ts +35 -0
- package/dist/interfaces/openai-ads.interface.js +37 -0
- package/dist/interfaces/openai-ads.interface.js.map +1 -0
- package/dist/interfaces/params.interface.d.ts +10 -0
- package/dist/services/feed-guard.d.ts +44 -0
- package/dist/services/feed-guard.js +156 -0
- package/dist/services/feed-guard.js.map +1 -0
- package/dist/services/image-url-validator.service.d.ts +45 -0
- package/dist/services/image-url-validator.service.js +141 -0
- package/dist/services/image-url-validator.service.js.map +1 -0
- package/dist/services/images.service.d.ts +12 -0
- package/dist/services/images.service.js +46 -1
- package/dist/services/images.service.js.map +1 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/openai-ads-feed-run.d.ts +74 -0
- package/dist/services/openai-ads-feed-run.js +151 -0
- package/dist/services/openai-ads-feed-run.js.map +1 -0
- package/dist/services/openai-ads-ledger.d.ts +19 -0
- package/dist/services/openai-ads-ledger.js +72 -0
- package/dist/services/openai-ads-ledger.js.map +1 -0
- package/dist/services/publication-gate.js +8 -8
- package/dist/transformers/openai-ads.transformer.d.ts +29 -0
- package/dist/transformers/openai-ads.transformer.js +166 -0
- package/dist/transformers/openai-ads.transformer.js.map +1 -0
- package/dist/transformers/openai-image-fields.d.ts +16 -0
- package/dist/transformers/openai-image-fields.js +34 -0
- package/dist/transformers/openai-image-fields.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/openai-ads/AUDIT.md +235 -0
- package/docs/openai-ads/RUNBOOK.md +94 -0
- package/package.json +64 -64
- package/schema/feed-items.json +134 -134
- package/src/configs/config.ts +99 -99
- package/src/configs/index.ts +1 -0
- package/src/configs/openai-ads.config.ts +70 -0
- package/src/enums/export-type.enum.ts +2 -0
- package/src/enums/image-type.enum.ts +5 -0
- package/src/export.schema.ts +6 -0
- package/src/exporters/index.ts +1 -0
- package/src/exporters/openai-ads.exporter.ts +93 -0
- package/src/formats/openai-ads-csv.builder.ts +55 -0
- package/src/interfaces/openai-ads.interface.ts +64 -0
- package/src/interfaces/params.interface.ts +13 -0
- package/src/services/feed-guard.ts +219 -0
- package/src/services/image-url-validator.service.ts +181 -0
- package/src/services/images.service.ts +72 -0
- package/src/services/index.ts +1 -0
- package/src/services/openai-ads-feed-run.ts +226 -0
- package/src/services/openai-ads-ledger.ts +116 -0
- package/src/services/publication-gate.ts +119 -119
- package/src/transformers/openai-ads.transformer.ts +243 -0
- package/src/transformers/openai-image-fields.ts +60 -0
- package/test/clean-images.test.ts +86 -0
- package/test/fixtures/images-service.harness.ts +32 -0
- package/test/fixtures/images.golden.json +218 -0
- package/test/fixtures/imbo-images.fixture.ts +77 -0
- package/test/fixtures/openai-ads.fixture.ts +89 -0
- package/test/image-url-validator.test.ts +110 -0
- package/test/images-regression.test.ts +23 -0
- package/test/openai-ads-feed-run.test.ts +278 -0
- package/test/openai-ads-mapping.test.ts +157 -0
- package/test/openai-image-fields.test.ts +99 -0
- package/test/publication-gate.test.ts +86 -86
|
@@ -1,119 +1,119 @@
|
|
|
1
|
-
import { join } from 'path';
|
|
2
|
-
import { get } from 'lodash';
|
|
3
|
-
import { BigQueryLogger } from '@bytebrand/bigquery-logger';
|
|
4
|
-
import { DataSchemes } from '@bytebrand/car-schema';
|
|
5
|
-
import { PublicationGateConfig } from '../interfaces/params.interface';
|
|
6
|
-
import { GoogleEnvkvAttribute, googleEnvkvSourceFields } from '../transformers/google-feed-envkv';
|
|
7
|
-
|
|
8
|
-
// EnVKV stage C8 — publication gate for estimated EnVKV values in the Google feed.
|
|
9
|
-
//
|
|
10
|
-
// No car field says whether a value was delivered or estimated; car-basic-data writes every change it
|
|
11
|
-
// makes to the enrichment audit log (BigQuery). At the start of the export the gate reads the latest
|
|
12
|
-
// log row per car and field; a field counts as estimated when that row came from an estimate source
|
|
13
|
-
// and its final value is still the value stored on the car (a later import with a delivered value
|
|
14
|
-
// replaces it without a log row, so the stored value no longer matches).
|
|
15
|
-
//
|
|
16
|
-
// off no BigQuery call, the feed as before (default)
|
|
17
|
-
// audit the feed as before, the estimated attributes are recorded in the feed items table
|
|
18
|
-
// enforce estimated attributes are left out of the item (and recorded)
|
|
19
|
-
|
|
20
|
-
// finalValueSource values of CBD's enrichment log that are estimates: the CO2 estimator (EEA register /
|
|
21
|
-
// model), the make-model template sheet and the global defaults.
|
|
22
|
-
export const ESTIMATE_SOURCES = ['co2-estimate', 'mmm', 'globalDefault'];
|
|
23
|
-
|
|
24
|
-
// Every car field a Google EnVKV attribute can be taken from (google-feed-envkv picks).
|
|
25
|
-
export const GATED_FIELDS = [
|
|
26
|
-
'consumption.wltpWeightedCombined',
|
|
27
|
-
'consumption.wltpCombined',
|
|
28
|
-
'consumption.consumptionCombined',
|
|
29
|
-
'consumption.wltpWeightedPowerCombined',
|
|
30
|
-
'consumption.wltpPowerCombined',
|
|
31
|
-
'consumption.consumptionPowerCombined',
|
|
32
|
-
'environmentEmissions.wltpCo2',
|
|
33
|
-
'environmentEmissions.co2',
|
|
34
|
-
'battery.wltpTotalRange',
|
|
35
|
-
'battery.wltpRange',
|
|
36
|
-
'battery.batteryRangeElectric',
|
|
37
|
-
];
|
|
38
|
-
|
|
39
|
-
// '7.10' and 7.1 are the same value; non-numeric values compare as strings.
|
|
40
|
-
const sameValue = (logged: string, stored: unknown): boolean => {
|
|
41
|
-
if (stored === undefined || stored === null) return false;
|
|
42
|
-
const a = Number(logged);
|
|
43
|
-
const b = Number(stored);
|
|
44
|
-
if (Number.isFinite(a) && Number.isFinite(b)) return Math.abs(a - b) < 1e-9;
|
|
45
|
-
return logged === String(stored);
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
export class PublicationGate {
|
|
49
|
-
// estimates: `${carId}|${field}` -> final value of the latest estimate row
|
|
50
|
-
constructor(readonly mode: 'off' | 'audit' | 'enforce', private estimates: Map<string, string>) {}
|
|
51
|
-
|
|
52
|
-
static off(): PublicationGate {
|
|
53
|
-
return new PublicationGate('off', new Map());
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
get size(): number {
|
|
57
|
-
return this.estimates.size;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// The attributes of this car whose value comes from an estimated field, in a fixed order.
|
|
61
|
-
estimatedAttributes(car: DataSchemes.Car): GoogleEnvkvAttribute[] {
|
|
62
|
-
if (this.mode === 'off') return [];
|
|
63
|
-
const carId = String(get(car, '_id', ''));
|
|
64
|
-
const attributes: GoogleEnvkvAttribute[] = [];
|
|
65
|
-
for (const [attribute, field] of Object.entries(googleEnvkvSourceFields(car))) {
|
|
66
|
-
const logged = this.estimates.get(`${carId}|${field}`);
|
|
67
|
-
if (logged !== undefined && sameValue(logged, get(car, field))) attributes.push(attribute as GoogleEnvkvAttribute);
|
|
68
|
-
}
|
|
69
|
-
return attributes;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// enforce: drops the estimated attributes from the item. Returns the item for chaining.
|
|
73
|
-
apply<T extends Partial<Record<GoogleEnvkvAttribute, string>>>(item: T, attributes: GoogleEnvkvAttribute[]): T {
|
|
74
|
-
if (this.mode !== 'enforce') return item;
|
|
75
|
-
for (const attribute of attributes) delete item[attribute];
|
|
76
|
-
return item;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
class EnrichmentLogReader extends BigQueryLogger {
|
|
81
|
-
async latestEstimates(table: string): Promise<Map<string, string>> {
|
|
82
|
-
const query = `
|
|
83
|
-
SELECT carId, fieldName, finalValue
|
|
84
|
-
FROM (
|
|
85
|
-
SELECT carId, fieldName, finalValue, finalValueSource,
|
|
86
|
-
ROW_NUMBER() OVER (PARTITION BY carId, fieldName ORDER BY timestamp DESC) AS rn
|
|
87
|
-
FROM \`${table}\`
|
|
88
|
-
WHERE fieldName IN UNNEST(@fields)
|
|
89
|
-
)
|
|
90
|
-
WHERE rn = 1 AND finalValueSource IN UNNEST(@sources) AND finalValue IS NOT NULL`;
|
|
91
|
-
const [rows] = await this.client.query({ query, params: { fields: GATED_FIELDS, sources: ESTIMATE_SOURCES } });
|
|
92
|
-
const estimates = new Map<string, string>();
|
|
93
|
-
for (const row of rows) estimates.set(`${row.carId}|${row.fieldName}`, String(row.finalValue));
|
|
94
|
-
return estimates;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Reads the estimates once per run. audit: a failure only switches the gate off. enforce: a failure
|
|
99
|
-
// fails the export, so the last published feed stays in place instead of a feed with unchecked estimates.
|
|
100
|
-
export async function loadPublicationGate(config: PublicationGateConfig | undefined): Promise<PublicationGate> {
|
|
101
|
-
if (!config || config.mode === 'off') return PublicationGate.off();
|
|
102
|
-
const table = `${config.project}.${config.dataset}.${config.table}`;
|
|
103
|
-
try {
|
|
104
|
-
const credentials = require(join(process.cwd(), 'credentials/google-cloud-project-id.json'));
|
|
105
|
-
const reader = new EnrichmentLogReader(credentials, {
|
|
106
|
-
BigQueryProject: config.project,
|
|
107
|
-
BigQueryDataset: config.dataset,
|
|
108
|
-
BigQueryLogTable: config.table,
|
|
109
|
-
});
|
|
110
|
-
const estimates = await reader.latestEstimates(table);
|
|
111
|
-
console.log(`Publication gate ${config.mode}: ${estimates.size} estimated car fields from ${table}`);
|
|
112
|
-
return new PublicationGate(config.mode, estimates);
|
|
113
|
-
} catch (e) {
|
|
114
|
-
const message = String(e?.message || e).split('\n')[0];
|
|
115
|
-
if (config.mode === 'enforce') throw new Error(`Publication gate: cannot read ${table}: ${message}`);
|
|
116
|
-
console.error(`Publication gate disabled for this run: cannot read ${table}: ${message}`);
|
|
117
|
-
return PublicationGate.off();
|
|
118
|
-
}
|
|
119
|
-
}
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { get } from 'lodash';
|
|
3
|
+
import { BigQueryLogger } from '@bytebrand/bigquery-logger';
|
|
4
|
+
import { DataSchemes } from '@bytebrand/car-schema';
|
|
5
|
+
import { PublicationGateConfig } from '../interfaces/params.interface';
|
|
6
|
+
import { GoogleEnvkvAttribute, googleEnvkvSourceFields } from '../transformers/google-feed-envkv';
|
|
7
|
+
|
|
8
|
+
// EnVKV stage C8 — publication gate for estimated EnVKV values in the Google feed.
|
|
9
|
+
//
|
|
10
|
+
// No car field says whether a value was delivered or estimated; car-basic-data writes every change it
|
|
11
|
+
// makes to the enrichment audit log (BigQuery). At the start of the export the gate reads the latest
|
|
12
|
+
// log row per car and field; a field counts as estimated when that row came from an estimate source
|
|
13
|
+
// and its final value is still the value stored on the car (a later import with a delivered value
|
|
14
|
+
// replaces it without a log row, so the stored value no longer matches).
|
|
15
|
+
//
|
|
16
|
+
// off no BigQuery call, the feed as before (default)
|
|
17
|
+
// audit the feed as before, the estimated attributes are recorded in the feed items table
|
|
18
|
+
// enforce estimated attributes are left out of the item (and recorded)
|
|
19
|
+
|
|
20
|
+
// finalValueSource values of CBD's enrichment log that are estimates: the CO2 estimator (EEA register /
|
|
21
|
+
// model), the make-model template sheet and the global defaults.
|
|
22
|
+
export const ESTIMATE_SOURCES = ['co2-estimate', 'mmm', 'globalDefault'];
|
|
23
|
+
|
|
24
|
+
// Every car field a Google EnVKV attribute can be taken from (google-feed-envkv picks).
|
|
25
|
+
export const GATED_FIELDS = [
|
|
26
|
+
'consumption.wltpWeightedCombined',
|
|
27
|
+
'consumption.wltpCombined',
|
|
28
|
+
'consumption.consumptionCombined',
|
|
29
|
+
'consumption.wltpWeightedPowerCombined',
|
|
30
|
+
'consumption.wltpPowerCombined',
|
|
31
|
+
'consumption.consumptionPowerCombined',
|
|
32
|
+
'environmentEmissions.wltpCo2',
|
|
33
|
+
'environmentEmissions.co2',
|
|
34
|
+
'battery.wltpTotalRange',
|
|
35
|
+
'battery.wltpRange',
|
|
36
|
+
'battery.batteryRangeElectric',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// '7.10' and 7.1 are the same value; non-numeric values compare as strings.
|
|
40
|
+
const sameValue = (logged: string, stored: unknown): boolean => {
|
|
41
|
+
if (stored === undefined || stored === null) return false;
|
|
42
|
+
const a = Number(logged);
|
|
43
|
+
const b = Number(stored);
|
|
44
|
+
if (Number.isFinite(a) && Number.isFinite(b)) return Math.abs(a - b) < 1e-9;
|
|
45
|
+
return logged === String(stored);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export class PublicationGate {
|
|
49
|
+
// estimates: `${carId}|${field}` -> final value of the latest estimate row
|
|
50
|
+
constructor(readonly mode: 'off' | 'audit' | 'enforce', private estimates: Map<string, string>) {}
|
|
51
|
+
|
|
52
|
+
static off(): PublicationGate {
|
|
53
|
+
return new PublicationGate('off', new Map());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get size(): number {
|
|
57
|
+
return this.estimates.size;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The attributes of this car whose value comes from an estimated field, in a fixed order.
|
|
61
|
+
estimatedAttributes(car: DataSchemes.Car): GoogleEnvkvAttribute[] {
|
|
62
|
+
if (this.mode === 'off') return [];
|
|
63
|
+
const carId = String(get(car, '_id', ''));
|
|
64
|
+
const attributes: GoogleEnvkvAttribute[] = [];
|
|
65
|
+
for (const [attribute, field] of Object.entries(googleEnvkvSourceFields(car))) {
|
|
66
|
+
const logged = this.estimates.get(`${carId}|${field}`);
|
|
67
|
+
if (logged !== undefined && sameValue(logged, get(car, field))) attributes.push(attribute as GoogleEnvkvAttribute);
|
|
68
|
+
}
|
|
69
|
+
return attributes;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// enforce: drops the estimated attributes from the item. Returns the item for chaining.
|
|
73
|
+
apply<T extends Partial<Record<GoogleEnvkvAttribute, string>>>(item: T, attributes: GoogleEnvkvAttribute[]): T {
|
|
74
|
+
if (this.mode !== 'enforce') return item;
|
|
75
|
+
for (const attribute of attributes) delete item[attribute];
|
|
76
|
+
return item;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class EnrichmentLogReader extends BigQueryLogger {
|
|
81
|
+
async latestEstimates(table: string): Promise<Map<string, string>> {
|
|
82
|
+
const query = `
|
|
83
|
+
SELECT carId, fieldName, finalValue
|
|
84
|
+
FROM (
|
|
85
|
+
SELECT carId, fieldName, finalValue, finalValueSource,
|
|
86
|
+
ROW_NUMBER() OVER (PARTITION BY carId, fieldName ORDER BY timestamp DESC) AS rn
|
|
87
|
+
FROM \`${table}\`
|
|
88
|
+
WHERE fieldName IN UNNEST(@fields)
|
|
89
|
+
)
|
|
90
|
+
WHERE rn = 1 AND finalValueSource IN UNNEST(@sources) AND finalValue IS NOT NULL`;
|
|
91
|
+
const [rows] = await this.client.query({ query, params: { fields: GATED_FIELDS, sources: ESTIMATE_SOURCES } });
|
|
92
|
+
const estimates = new Map<string, string>();
|
|
93
|
+
for (const row of rows) estimates.set(`${row.carId}|${row.fieldName}`, String(row.finalValue));
|
|
94
|
+
return estimates;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Reads the estimates once per run. audit: a failure only switches the gate off. enforce: a failure
|
|
99
|
+
// fails the export, so the last published feed stays in place instead of a feed with unchecked estimates.
|
|
100
|
+
export async function loadPublicationGate(config: PublicationGateConfig | undefined): Promise<PublicationGate> {
|
|
101
|
+
if (!config || config.mode === 'off') return PublicationGate.off();
|
|
102
|
+
const table = `${config.project}.${config.dataset}.${config.table}`;
|
|
103
|
+
try {
|
|
104
|
+
const credentials = require(join(process.cwd(), 'credentials/google-cloud-project-id.json'));
|
|
105
|
+
const reader = new EnrichmentLogReader(credentials, {
|
|
106
|
+
BigQueryProject: config.project,
|
|
107
|
+
BigQueryDataset: config.dataset,
|
|
108
|
+
BigQueryLogTable: config.table,
|
|
109
|
+
});
|
|
110
|
+
const estimates = await reader.latestEstimates(table);
|
|
111
|
+
console.log(`Publication gate ${config.mode}: ${estimates.size} estimated car fields from ${table}`);
|
|
112
|
+
return new PublicationGate(config.mode, estimates);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
const message = String(e?.message || e).split('\n')[0];
|
|
115
|
+
if (config.mode === 'enforce') throw new Error(`Publication gate: cannot read ${table}: ${message}`);
|
|
116
|
+
console.error(`Publication gate disabled for this run: cannot read ${table}: ${message}`);
|
|
117
|
+
return PublicationGate.off();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { get, toNumber, toString, trim, truncate } from 'lodash';
|
|
2
|
+
import { DataSchemes } from '@bytebrand/car-schema';
|
|
3
|
+
|
|
4
|
+
import { normalizeFeedText } from '../utils';
|
|
5
|
+
import {
|
|
6
|
+
OpenAiAdsCondition,
|
|
7
|
+
OpenAiAdsExclusion,
|
|
8
|
+
OpenAiAdsOutOfStockReason,
|
|
9
|
+
OpenAiAdsRow,
|
|
10
|
+
} from '../interfaces/openai-ads.interface';
|
|
11
|
+
import { OpenAiImageFields, encodeCommasInUrl } from './openai-image-fields';
|
|
12
|
+
|
|
13
|
+
// OpenAI product file limits (guidance): title ≤ 150, description ≤ 5,000 characters.
|
|
14
|
+
export const OPENAI_TITLE_MAX_LENGTH = 150;
|
|
15
|
+
export const OPENAI_DESCRIPTION_MAX_LENGTH = 5000;
|
|
16
|
+
|
|
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');
|
|
35
|
+
|
|
36
|
+
const OFFER_AVAILABILITY_OUT_OF_STOCK: Record<string, OpenAiAdsOutOfStockReason> = {
|
|
37
|
+
selector_offerAvailability_reserved: OpenAiAdsOutOfStockReason.reserved,
|
|
38
|
+
selector_offerAvailability_sold: OpenAiAdsOutOfStockReason.soldFlag,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface OpenAiAdsMappingContext {
|
|
42
|
+
domain: string;
|
|
43
|
+
sellerName: string;
|
|
44
|
+
// German label of a schema selector (TranslatorExtended.translateVal); returns the key itself
|
|
45
|
+
// when it has no label
|
|
46
|
+
translate: (key: string) => string;
|
|
47
|
+
now: Date;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Row without the media columns: mapped first so cheap exclusions happen before any image probe.
|
|
51
|
+
export type OpenAiAdsBaseRow = Omit<OpenAiAdsRow, 'image_url' | 'additional_image_urls'>;
|
|
52
|
+
|
|
53
|
+
export type OpenAiAdsMapResult =
|
|
54
|
+
| { ok: true; row: OpenAiAdsBaseRow; outOfStockReason?: OpenAiAdsOutOfStockReason }
|
|
55
|
+
| { ok: false; reason: OpenAiAdsExclusion };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Maps one CBD car (an online car of the base export snapshot) to the OpenAI columns.
|
|
59
|
+
* The row comes out in_stock; `outOfStockReason` is set when the car is online but flagged
|
|
60
|
+
* reserved/sold — the exporter then sends it as out_of_stock (only if it was advertised before).
|
|
61
|
+
*/
|
|
62
|
+
export const mapOpenAiAdsVehicle = (car: DataSchemes.Car, ctx: OpenAiAdsMappingContext): OpenAiAdsMapResult => {
|
|
63
|
+
const itemId = toString(get(car, '_id', '')).trim();
|
|
64
|
+
if (!itemId) {
|
|
65
|
+
return { ok: false, reason: OpenAiAdsExclusion.noId };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const price = formatOpenAiPrice(get(car, 'price.currentSalesPrice', null));
|
|
69
|
+
if (!price) {
|
|
70
|
+
return { ok: false, reason: OpenAiAdsExclusion.invalidPrice };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const brand = repairMojibake(trim(toString(get(car, 'mainData.make', ''))));
|
|
74
|
+
if (!brand || PLACEHOLDER_NAMES.test(brand)) {
|
|
75
|
+
return { ok: false, reason: OpenAiAdsExclusion.noBrand };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const title = buildOpenAiTitle(car);
|
|
79
|
+
if (!title) {
|
|
80
|
+
return { ok: false, reason: OpenAiAdsExclusion.noTitle };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const condition = resolveOpenAiCondition(car, ctx.now);
|
|
84
|
+
if (!condition) {
|
|
85
|
+
return { ok: false, reason: OpenAiAdsExclusion.conditionUnknown };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const row: OpenAiAdsBaseRow = {
|
|
89
|
+
item_id: itemId,
|
|
90
|
+
title,
|
|
91
|
+
description: buildOpenAiDescription(car, title, condition, ctx),
|
|
92
|
+
url: `https://www.${ctx.domain}/search/vehicle/${itemId}`,
|
|
93
|
+
brand,
|
|
94
|
+
seller_name: ctx.sellerName,
|
|
95
|
+
availability: 'in_stock',
|
|
96
|
+
price,
|
|
97
|
+
condition,
|
|
98
|
+
is_ads_eligible: true,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const outOfStockReason = OFFER_AVAILABILITY_OUT_OF_STOCK[toString(get(car, 'offer.offerAvailability', ''))];
|
|
102
|
+
return outOfStockReason ? { ok: true, row, outOfStockReason } : { ok: true, row };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const withOpenAiImages = (row: OpenAiAdsBaseRow, images: OpenAiImageFields): OpenAiAdsRow => ({
|
|
106
|
+
...row,
|
|
107
|
+
image_url: images.image_url,
|
|
108
|
+
// the CSV writer quotes the cell; commas inside a URL are %2C so the list stays parseable
|
|
109
|
+
additional_image_urls: images.additional_image_urls.map(encodeCommasInUrl).join(','),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// The same vehicle, switched off: kept in the file so OpenAI stops delivering it instead of
|
|
113
|
+
// relying on the row disappearing (not documented as a removal).
|
|
114
|
+
export const asOutOfStock = (row: OpenAiAdsRow): OpenAiAdsRow => ({
|
|
115
|
+
...row,
|
|
116
|
+
availability: 'out_of_stock',
|
|
117
|
+
is_ads_eligible: false,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Purchase price (price.currentSalesPrice, EUR gross) in major units with exactly two decimals and
|
|
121
|
+
// no thousands separator: 17990 -> "17990.00 EUR". Never a monthly financing/leasing rate.
|
|
122
|
+
// null = not a valid positive price; the car is not exported.
|
|
123
|
+
export const formatOpenAiPrice = (value: unknown): string | null => {
|
|
124
|
+
if (value === null || value === undefined || value === '' || typeof value === 'boolean') {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const amount = toNumber(value);
|
|
128
|
+
if (!Number.isFinite(amount) || amount <= 0) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
return `${amount.toFixed(2)} EUR`;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Integer minor units (cents) for the deferred delta API: 17990.5 -> 1799050.
|
|
135
|
+
export const toOpenAiMinorUnits = (value: unknown): number | null => {
|
|
136
|
+
const formatted = formatOpenAiPrice(value);
|
|
137
|
+
return formatted ? Math.round(toNumber(formatted.split(' ')[0]) * 100) : null;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// "<make> <model> <variant>" as delivered (German market names), dealer junk punctuation removed
|
|
141
|
+
// with the same normalizer as the Meta feed, cut at a word boundary. No HTML escaping: CSV quoting
|
|
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.
|
|
144
|
+
export const buildOpenAiTitle = (car: DataSchemes.Car): string => {
|
|
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(' '));
|
|
152
|
+
return truncate(title, { length: OPENAI_TITLE_MAX_LENGTH, separator: ' ', omission: '' }).trim();
|
|
153
|
+
};
|
|
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
|
+
|
|
166
|
+
// new/used from mainData.condition. selector_unknown is only "used" when the car has a first
|
|
167
|
+
// registration in the past (a registered car is used for advertising purposes); otherwise
|
|
168
|
+
// the car is excluded rather than defaulted — OpenAI treats an omitted condition as "new".
|
|
169
|
+
export const resolveOpenAiCondition = (car: DataSchemes.Car, now: Date): OpenAiAdsCondition | null => {
|
|
170
|
+
const condition = toString(get(car, 'mainData.condition', ''));
|
|
171
|
+
if (condition === 'selector_condition_new') {
|
|
172
|
+
return 'new';
|
|
173
|
+
}
|
|
174
|
+
if (condition === 'selector_condition_used') {
|
|
175
|
+
return 'used';
|
|
176
|
+
}
|
|
177
|
+
const firstRegistration = toNumber(get(car, 'mainData.firstRegistration', 0));
|
|
178
|
+
return firstRegistration > 0 && firstRegistration * 1000 <= now.getTime() ? 'used' : null;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Plain German text from structured vehicle data only. Dealer summary templates (Google feed) are
|
|
183
|
+
* not used: their content is free text we cannot check for finance claims. No rates, no prices,
|
|
184
|
+
* no marketing promises.
|
|
185
|
+
* e.g. "Gebrauchtwagen: VW Golf 1.5 TSI Life. Erstzulassung: 03/2021 · Kilometerstand: 45.000 km · …"
|
|
186
|
+
*/
|
|
187
|
+
export const buildOpenAiDescription = (
|
|
188
|
+
car: DataSchemes.Car,
|
|
189
|
+
title: string,
|
|
190
|
+
condition: OpenAiAdsCondition,
|
|
191
|
+
ctx: OpenAiAdsMappingContext,
|
|
192
|
+
): string => {
|
|
193
|
+
const label = (path: string): string | null => {
|
|
194
|
+
const key = toString(get(car, path, ''));
|
|
195
|
+
if (!key || key === 'selector_unknown') {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const value = trim(ctx.translate(key));
|
|
199
|
+
return value && !value.startsWith('selector_') && value !== 'Unbekannt' ? value : null;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const facts: Array<[string, string | null]> = [
|
|
203
|
+
['Erstzulassung', formatFirstRegistration(get(car, 'mainData.firstRegistration'))],
|
|
204
|
+
['Kilometerstand', formatMileage(get(car, 'mainData.mileage'))],
|
|
205
|
+
['Kraftstoff', label('consumption.fuel')],
|
|
206
|
+
['Getriebe', label('driveSuspension.gearbox')],
|
|
207
|
+
['Leistung', formatPower(get(car, 'engineData.powerKW'), get(car, 'engineData.powerPS'))],
|
|
208
|
+
['Karosserie', label('mainData.category')],
|
|
209
|
+
['Außenfarbe', label('exterior.exteriorColor')],
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
const intro = `${condition === 'new' ? 'Neuwagen' : 'Gebrauchtwagen'}: ${title}.`;
|
|
213
|
+
const details = facts.filter(([, value]) => value).map(([name, value]) => `${name}: ${value}`).join(' · ');
|
|
214
|
+
const text = (details ? `${intro} ${details}` : intro).replace(/\s+/g, ' ').trim();
|
|
215
|
+
return truncate(text, { length: OPENAI_DESCRIPTION_MAX_LENGTH, separator: ' ', omission: '' }).trim();
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const DE_NUMBER = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 });
|
|
219
|
+
|
|
220
|
+
const formatFirstRegistration = (value: unknown): string | null => {
|
|
221
|
+
const seconds = toNumber(value);
|
|
222
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
const date = new Date(seconds * 1000);
|
|
226
|
+
return `${String(date.getUTCMonth() + 1).padStart(2, '0')}/${date.getUTCFullYear()}`;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Real odometer value — no 901/1001 km floors as in the Meta/Google feeds.
|
|
230
|
+
const formatMileage = (value: unknown): string | null => {
|
|
231
|
+
const km = toNumber(value);
|
|
232
|
+
return Number.isFinite(km) && km > 0 ? `${DE_NUMBER.format(km)} km` : null;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const formatPower = (kw: unknown, ps: unknown): string | null => {
|
|
236
|
+
const powerKw = toNumber(kw);
|
|
237
|
+
if (!Number.isFinite(powerKw) || powerKw <= 0) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
const powerPs = toNumber(ps);
|
|
241
|
+
const psPart = Number.isFinite(powerPs) && powerPs > 0 ? ` (${DE_NUMBER.format(powerPs)} PS)` : '';
|
|
242
|
+
return `${DE_NUMBER.format(powerKw)} kW${psPart}`;
|
|
243
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { CleanImage } from '../services/images.service';
|
|
2
|
+
import { ImageUrlCheck, ImageUrlValidator } from '../services/image-url-validator.service';
|
|
3
|
+
|
|
4
|
+
// OpenAI product file (Stable schema) media fields. Not the Google-compatible image_link /
|
|
5
|
+
// additional_image_link pair — the two feed formats must not be mixed.
|
|
6
|
+
export interface OpenAiImageFields {
|
|
7
|
+
image_url: string;
|
|
8
|
+
additional_image_urls: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface OpenAiImageSelection {
|
|
12
|
+
fields: OpenAiImageFields | null;
|
|
13
|
+
// every clean image that failed validation, for the run log / reject tracking
|
|
14
|
+
rejected: ImageUrlCheck[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* image_url = first valid 3/4 exterior hero candidate; additional_image_urls = every other valid
|
|
19
|
+
* image in clean-profile order (exterior before interior), without an artificial count limit —
|
|
20
|
+
* OpenAI states none. Invalid optional URLs are dropped. No valid hero => fields null: the car must
|
|
21
|
+
* be skipped and counted, never sent with a placeholder or a non-hero view as its main image.
|
|
22
|
+
*/
|
|
23
|
+
export const selectOpenAiImageFields = (images: CleanImage[], checks: ImageUrlCheck[]): OpenAiImageSelection => {
|
|
24
|
+
const byUrl = new Map(checks.map(check => [check.url, check]));
|
|
25
|
+
const isValid = (image: CleanImage) => byUrl.get(image.url)?.ok === true;
|
|
26
|
+
const rejected = images.map(image => byUrl.get(image.url)).filter(check => check && !check.ok);
|
|
27
|
+
|
|
28
|
+
const hero = images.find(image => image.heroCandidate && isValid(image));
|
|
29
|
+
if (!hero) {
|
|
30
|
+
return { fields: null, rejected };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
fields: {
|
|
35
|
+
image_url: hero.url,
|
|
36
|
+
additional_image_urls: images.filter(image => image !== hero && isValid(image)).map(image => image.url),
|
|
37
|
+
},
|
|
38
|
+
rejected,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const resolveOpenAiImageFields = async (
|
|
43
|
+
images: CleanImage[],
|
|
44
|
+
validator: ImageUrlValidator,
|
|
45
|
+
): Promise<OpenAiImageSelection> => {
|
|
46
|
+
const checks = await validator.validateAll(images.map(image => image.url));
|
|
47
|
+
return selectOpenAiImageFields(images, checks);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Percent-encode commas inside a URL (spec: "Percent-encode commas inside a URL as %2C").
|
|
51
|
+
export const encodeCommasInUrl = (url: string): string => url.replace(/,/g, '%2C');
|
|
52
|
+
|
|
53
|
+
// additional_image_urls as ONE CSV cell: comma-separated (no spaces, no semicolons), commas inside
|
|
54
|
+
// URLs encoded, quoted for the CSV writer. JSONL keeps the plain array instead.
|
|
55
|
+
export const serializeAdditionalImageUrlsForCsv = (urls: string[]): string =>
|
|
56
|
+
quoteCsvCell(urls.map(encodeCommasInUrl).join(','));
|
|
57
|
+
|
|
58
|
+
export const serializeAdditionalImageUrlsForJsonl = (urls: string[]): string[] => urls.map(encodeCommasInUrl);
|
|
59
|
+
|
|
60
|
+
export const quoteCsvCell = (value: string): string => `"${value.replace(/"/g, '""')}"`;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
+
import * as assert from 'node:assert/strict';
|
|
3
|
+
import { ImageType } from '../src/enums';
|
|
4
|
+
import { buildImagesService } from './fixtures/images-service.harness';
|
|
5
|
+
import { IMBO_FIXTURES, cloneImages } from './fixtures/imbo-images.fixture';
|
|
6
|
+
|
|
7
|
+
const service = buildImagesService();
|
|
8
|
+
const clean = (name: string) => {
|
|
9
|
+
const { carId, images } = IMBO_FIXTURES[name];
|
|
10
|
+
return service.getCleanImages(carId, cloneImages(images));
|
|
11
|
+
};
|
|
12
|
+
const ids = (name: string) => clean(name).map(i => i.imageIdentifier);
|
|
13
|
+
|
|
14
|
+
const cases: Array<[string, () => void | Promise<void>]> = [
|
|
15
|
+
['3/4 exterior hero first, then exterior, then interior; feat/conext/unknown dropped', () => {
|
|
16
|
+
assert.deepEqual(ids('full'), [
|
|
17
|
+
'extRight0001', // ext_f_right (hero)
|
|
18
|
+
'extCowl00001', 'extLeft00001', 'extSide00001', 'extBack00001', 'extOpened001',
|
|
19
|
+
'intDash00001', 'intSeats0001', 'intTrunk0001',
|
|
20
|
+
]);
|
|
21
|
+
}],
|
|
22
|
+
['explicit JPEG variant on the images host, never a placeholder', () => {
|
|
23
|
+
const urls = clean('full').map(i => i.url);
|
|
24
|
+
assert.equal(urls[0], 'https://images.auto.de/carimage/2ce1d71f-851f-4123-ae16-187ddd7923c5/extRight0001/large-cached.jpeg');
|
|
25
|
+
assert.ok(urls.every(u => u.endsWith('/large-cached.jpeg')));
|
|
26
|
+
assert.ok(urls.every(u => !u.includes('/noimage/')));
|
|
27
|
+
}],
|
|
28
|
+
['hero candidates are only the 3/4 front views', () => {
|
|
29
|
+
assert.deepEqual(clean('full').filter(i => i.heroCandidate).map(i => i.imageCode), ['ext_f_right', 'ext_f_left']);
|
|
30
|
+
}],
|
|
31
|
+
['no ext_f_right: ext_f_left becomes the hero', () => {
|
|
32
|
+
assert.deepEqual(ids('leftOnly'), ['extLeft00002', 'extBack00002', 'intDash00002']);
|
|
33
|
+
}],
|
|
34
|
+
['ext_f_left is lifted above ext_f_cowl when it is the hero', () => {
|
|
35
|
+
const images = [
|
|
36
|
+
{ imageIdentifier: 'cowl', imageCodePredicted: 'ext_f_cowl' },
|
|
37
|
+
{ imageIdentifier: 'left', imageCodePredicted: 'ext_f_left' },
|
|
38
|
+
];
|
|
39
|
+
assert.deepEqual(service.getCleanImages('car', images).map(i => i.imageIdentifier), ['left', 'cowl']);
|
|
40
|
+
}],
|
|
41
|
+
['no 3/4 exterior shot: no images at all (car is skipped, no fallback hero)', () => {
|
|
42
|
+
assert.deepEqual(clean('noHero'), []);
|
|
43
|
+
assert.deepEqual(clean('onlyUnknown'), []);
|
|
44
|
+
assert.deepEqual(clean('empty'), []);
|
|
45
|
+
assert.deepEqual(service.getCleanImages('car', undefined), []);
|
|
46
|
+
}],
|
|
47
|
+
['repeated imageIdentifier appears once; distinct shots with the same code keep Imbo order', () => {
|
|
48
|
+
assert.deepEqual(ids('duplicates'), ['extRight0004', 'extRight0005', 'intDash00004']);
|
|
49
|
+
}],
|
|
50
|
+
['images without imageCodePredicted or imageIdentifier are ignored', () => {
|
|
51
|
+
const images: any[] = [
|
|
52
|
+
{ imageIdentifier: 'noCode' },
|
|
53
|
+
{ imageCodePredicted: 'ext_f_right' },
|
|
54
|
+
{ imageIdentifier: 'right', imageCodePredicted: 'ext_f_right' },
|
|
55
|
+
];
|
|
56
|
+
assert.deepEqual(service.getCleanImages('car', images).map(i => i.imageIdentifier), ['right']);
|
|
57
|
+
}],
|
|
58
|
+
['deterministic and does not reorder the caller\'s image list', () => {
|
|
59
|
+
const images = cloneImages(IMBO_FIXTURES.full.images);
|
|
60
|
+
const before = images.map(i => i.imageIdentifier);
|
|
61
|
+
const first = service.getCleanImages('car', images);
|
|
62
|
+
assert.deepEqual(images.map(i => i.imageIdentifier), before);
|
|
63
|
+
assert.deepEqual(service.getCleanImages('car', images), first);
|
|
64
|
+
}],
|
|
65
|
+
['ImageType.clean via getImages: same URLs, no placeholders even with ignoreDefImages=false', async () => {
|
|
66
|
+
const { carId, images } = IMBO_FIXTURES.leftOnly;
|
|
67
|
+
const urls = await service.getImages({ _id: carId } as any, cloneImages(images), ImageType.clean, false);
|
|
68
|
+
assert.deepEqual(urls, service.getCleanImageUrls(carId, cloneImages(images)));
|
|
69
|
+
assert.equal(urls.length, 3);
|
|
70
|
+
assert.deepEqual(await service.getImages({ _id: 'x' } as any, [], ImageType.clean, false), []);
|
|
71
|
+
}],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
(async () => {
|
|
75
|
+
let failed = 0;
|
|
76
|
+
for (const [name, fn] of cases) {
|
|
77
|
+
try {
|
|
78
|
+
await fn();
|
|
79
|
+
console.log(`ok ${name}`);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
failed += 1;
|
|
82
|
+
console.error(`FAIL ${name}\n ${e.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (failed) process.exit(1);
|
|
86
|
+
})();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ImagesService } from '../../src/services/images.service';
|
|
2
|
+
import { ImageType } from '../../src/enums';
|
|
3
|
+
import { IMBO_FIXTURES, MARKETING_CONFIG_FIXTURE, cloneImages } from './imbo-images.fixture';
|
|
4
|
+
|
|
5
|
+
export const buildImagesService = (): ImagesService => {
|
|
6
|
+
const configService: any = {
|
|
7
|
+
config: {
|
|
8
|
+
baseImageUrl: 'https://images.auto.de',
|
|
9
|
+
marketingImageUrl: 'https://images.auto.de/generated_images',
|
|
10
|
+
marketingImageConfigId: 3,
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
const userService: any = { getMarketingImageConfig: async () => MARKETING_CONFIG_FIXTURE };
|
|
14
|
+
return new ImagesService(configService, userService);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Every image output the existing partner exporters consume, per fixture car. The regression test
|
|
18
|
+
// compares this against images.golden.json, recorded from the pre-ADV-336 ImagesService.
|
|
19
|
+
export const snapshotExistingImageOutputs = async (): Promise<Record<string, unknown>> => {
|
|
20
|
+
const service = buildImagesService();
|
|
21
|
+
const out: Record<string, unknown> = {};
|
|
22
|
+
for (const [name, { carId, images }] of Object.entries(IMBO_FIXTURES)) {
|
|
23
|
+
const car: any = { _id: carId };
|
|
24
|
+
out[name] = {
|
|
25
|
+
defaultWithPlaceholders: await service.getImages(car, cloneImages(images), ImageType.default, false),
|
|
26
|
+
defaultIgnorePlaceholders: await service.getImages(car, cloneImages(images), ImageType.default, true),
|
|
27
|
+
marketing: images.length ? await service.getImages(car, cloneImages(images), ImageType.marketing, false) : null,
|
|
28
|
+
googleExtInt: await service.getExtIntDefaultImages(car, cloneImages(images), ImageType.default, false),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
};
|