@bytebrand/export-cars 1.26.44 → 1.26.45
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/configs/marktde.config.d.ts +14 -0
- package/dist/configs/marktde.config.js +35 -2
- package/dist/configs/marktde.config.js.map +1 -1
- package/dist/export.schema.d.ts +2 -2
- package/dist/export.schema.js +1 -1
- 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/marktde.exporter.d.ts +13 -0
- package/dist/exporters/marktde.exporter.js +84 -0
- package/dist/exporters/marktde.exporter.js.map +1 -0
- package/dist/interfaces/params.interface.d.ts +3 -0
- package/dist/services/marktde-feed-run.d.ts +87 -0
- package/dist/services/marktde-feed-run.js +166 -0
- package/dist/services/marktde-feed-run.js.map +1 -0
- package/dist/transformers/marktde.transformer.d.ts +58 -0
- package/dist/transformers/marktde.transformer.js +158 -0
- package/dist/transformers/marktde.transformer.js.map +1 -0
- package/dist/transformers/mobile.transformer.js +1 -10
- package/dist/transformers/mobile.transformer.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/marktde/AUDIT.md +84 -0
- package/docs/marktde/PARTNER-QUESTIONS.md +49 -0
- package/docs/marktde/RUNBOOK.md +58 -0
- package/docs/marktde/TRACKING.md +69 -0
- package/package.json +65 -65
- package/src/configs/marktde.config.ts +70 -18
- package/src/export.schema.ts +2 -1
- package/src/exporters/index.ts +1 -0
- package/src/exporters/marktde.exporter.ts +78 -0
- package/src/interfaces/params.interface.ts +5 -0
- package/src/services/marktde-feed-run.ts +233 -0
- package/src/transformers/marktde.transformer.ts +255 -0
- package/src/transformers/mobile.transformer.ts +4 -13
- package/test/marktde-feed-run.test.ts +263 -0
- package/test/marktde-mapping.test.ts +280 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
+
// Integration: real files in a temp dir (base export snapshot, output, state), real CSV writer, real ZIP,
|
|
3
|
+
// guard and ADV-336 clean-image selection; only HTTP (image check) and the mapping sheet row are faked.
|
|
4
|
+
import * as assert from 'node:assert/strict';
|
|
5
|
+
import { promises as fs } from 'fs';
|
|
6
|
+
import { tmpdir } from 'os';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
|
|
9
|
+
import { FeedGuard, FeedGuardError, FeedGuardOptions } from '../src/services/feed-guard';
|
|
10
|
+
import { MarktdeFeedRun, MarktdeSourceCar } from '../src/services/marktde-feed-run';
|
|
11
|
+
import { zipSingleFile } from '../src/services/meinstadt-feed-run';
|
|
12
|
+
import { toMarktdeImages } from '../src/transformers/marktde.transformer';
|
|
13
|
+
import { selectOpenAiImageFields } from '../src/transformers/openai-image-fields';
|
|
14
|
+
import { buildImagesService } from './fixtures/images-service.harness';
|
|
15
|
+
import { NOW, car, images } from './fixtures/meinstadt.fixture';
|
|
16
|
+
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
18
|
+
const AdmZip = require('adm-zip');
|
|
19
|
+
|
|
20
|
+
const HOUR = 60 * 60 * 1000;
|
|
21
|
+
const imagesService = buildImagesService();
|
|
22
|
+
|
|
23
|
+
interface World {
|
|
24
|
+
root: string;
|
|
25
|
+
source: string;
|
|
26
|
+
csv: string;
|
|
27
|
+
zip: string;
|
|
28
|
+
state: string;
|
|
29
|
+
brokenImages: Set<string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const newWorld = async (): Promise<World> => {
|
|
33
|
+
const root = await fs.mkdtemp(join(tmpdir(), 'marktde-'));
|
|
34
|
+
return {
|
|
35
|
+
root,
|
|
36
|
+
source: join(root, 'cars-export'),
|
|
37
|
+
csv: join(root, 'marktde-dryrun', 'api_export_cars_marktde.csv'),
|
|
38
|
+
zip: join(root, 'marktde-dryrun', 'api_export_cars_marktde.zip'),
|
|
39
|
+
state: join(root, 'marktde-state', 'dryrun'),
|
|
40
|
+
brokenImages: new Set(),
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const source = (id: string, overrides: Record<string, any> = {}): MarktdeSourceCar => ({ car: car(id, overrides), images: images(id) });
|
|
45
|
+
|
|
46
|
+
const writeSource = async (world: World, cars: MarktdeSourceCar[], chunkSize = 2, mtime = NOW) => {
|
|
47
|
+
await fs.rm(world.source, { recursive: true, force: true });
|
|
48
|
+
await fs.mkdir(world.source, { recursive: true });
|
|
49
|
+
for (let i = 0; i * chunkSize < cars.length; i++) {
|
|
50
|
+
const file = join(world.source, `cars.${i}.json`);
|
|
51
|
+
await fs.writeFile(file, JSON.stringify(cars.slice(i * chunkSize, (i + 1) * chunkSize)));
|
|
52
|
+
await fs.utimes(file, mtime, mtime);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const guardOptions = (world: World, extra: Partial<FeedGuardOptions> = {}): FeedGuardOptions => ({
|
|
57
|
+
stateDir: world.state,
|
|
58
|
+
minSourceItems: 1,
|
|
59
|
+
minPublishedItems: 1,
|
|
60
|
+
maxDropPercent: 20,
|
|
61
|
+
maxSourceAgeHours: 26,
|
|
62
|
+
maxStaleHours: 48,
|
|
63
|
+
acceptDrop: false,
|
|
64
|
+
...extra,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// What MobileTransformer would return from the mapping sheet (the columns the run does not replace).
|
|
68
|
+
const legacyRow = async ({ car: c }: MarktdeSourceCar): Promise<Record<string, unknown>> => ({
|
|
69
|
+
'0': '04109003',
|
|
70
|
+
'1': c.metaData.vehicleId,
|
|
71
|
+
'3': c.mainData.make,
|
|
72
|
+
'4': c.mainData.model,
|
|
73
|
+
'14': 'GENERATEDFAKEVIN0',
|
|
74
|
+
'25': 'Autohaus Test\\\\Tel. 0341 12345678\\\\**Ausstattung**\\\\* ABS\\\\',
|
|
75
|
+
'26': 'https://images.auto.de/noimage/large.jpg',
|
|
76
|
+
'99': 'undefined',
|
|
77
|
+
'402': `https://www.auto.de${c._id}?source=classifieds&medium=listing&campaign=marktde`,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const run = async (world: World, { at = NOW, guard = {} }: { at?: Date; guard?: Partial<FeedGuardOptions> } = {}) => {
|
|
81
|
+
const forEachChunk = async (fn: (cars: MarktdeSourceCar[]) => Promise<void>) => {
|
|
82
|
+
const names = (await fs.readdir(world.source)).sort((a, b) => Number(a.split('.')[1]) - Number(b.split('.')[1]));
|
|
83
|
+
for (const name of names) {
|
|
84
|
+
await fn(JSON.parse(await fs.readFile(join(world.source, name), 'utf8')));
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const feed = new MarktdeFeedRun(
|
|
88
|
+
{
|
|
89
|
+
forEachChunk,
|
|
90
|
+
filterCar: ({ car: c }) => (c?.metaData?.imagesCount ?? 0) > 0,
|
|
91
|
+
resolveImages: async ({ car: c, images: list }) => {
|
|
92
|
+
const clean = imagesService.getCleanImages(c._id, list);
|
|
93
|
+
return toMarktdeImages(selectOpenAiImageFields(clean, clean.map(i => ({ url: i.url, ok: !world.brokenImages.has(i.url) }))));
|
|
94
|
+
},
|
|
95
|
+
legacyRow,
|
|
96
|
+
imageStats: () => ({ ok: 1 }),
|
|
97
|
+
zip: zipSingleFile,
|
|
98
|
+
guard: new FeedGuard(guardOptions(world, guard)),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
runId: 'test',
|
|
102
|
+
dryRun: true,
|
|
103
|
+
domain: 'auto.de',
|
|
104
|
+
sourceDirectory: world.source,
|
|
105
|
+
sourceBaseFileName: 'cars',
|
|
106
|
+
csvFile: world.csv,
|
|
107
|
+
zipFile: world.zip,
|
|
108
|
+
stateDir: world.state,
|
|
109
|
+
now: () => at,
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
return feed.run();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const cellsOf = (line: string): string[] => line.match(/"(?:[^"]|"")*"|(?<=^|;)(?=;|$)/g);
|
|
116
|
+
const readRows = async (world: World) => (await fs.readFile(world.csv, 'utf8')).split('\n').filter(Boolean).map(cellsOf);
|
|
117
|
+
const linkIds = (rows: string[][]) => rows.map(r => r[402].split('/vehicle/')[1].split('?')[0]);
|
|
118
|
+
const exists = (file: string) => fs.stat(file).then(() => true, () => false);
|
|
119
|
+
|
|
120
|
+
const tests: Array<[string, () => Promise<void>]> = [
|
|
121
|
+
['first run: one row per sellable car, exclusions counted, markt.de columns replaced, ZIP holds the CSV', async () => {
|
|
122
|
+
const world = await newWorld();
|
|
123
|
+
const noImages = source('nopic');
|
|
124
|
+
noImages.car.metaData.imagesCount = 0;
|
|
125
|
+
const noHero = source('nohero');
|
|
126
|
+
noHero.images = noHero.images.filter(i => i.imageCodePredicted !== 'ext_f_right');
|
|
127
|
+
await writeSource(world, [
|
|
128
|
+
source('aaa1'),
|
|
129
|
+
source('aaa2', { 'price.vatDeductible': true, 'price.vatRate': 19, 'price.currentSalesPrice': 23800 }),
|
|
130
|
+
source('aaa3', { 'metaData.vin': 'WVWZZZ1KZAW000001', 'metaData.ownerId': 'dealer-2' }),
|
|
131
|
+
source('aaa1'),
|
|
132
|
+
noImages,
|
|
133
|
+
noHero,
|
|
134
|
+
source('sold', { 'offer.offerAvailability': 'selector_offerAvailability_sold' }),
|
|
135
|
+
source('resv', { 'offer.offerAvailability': 'selector_offerAvailability_reserved' }),
|
|
136
|
+
source('noprice', { 'price.currentSalesPrice': null }),
|
|
137
|
+
source('noveh', { 'metaData.vehicleId': '' }),
|
|
138
|
+
]);
|
|
139
|
+
const report = await run(world);
|
|
140
|
+
const rows = await readRows(world);
|
|
141
|
+
assert.deepEqual(linkIds(rows), ['aaa1', 'aaa2', 'aaa3']);
|
|
142
|
+
for (const r of rows) {
|
|
143
|
+
assert.equal(r.length, 403);
|
|
144
|
+
assert.equal(r[0], '"04109003"', 'untouched legacy column');
|
|
145
|
+
assert.match(r[26], /^"https:\/\/images\.auto\.de\/carimage\/[^"]+hero1\/large-cached\.jpeg;/);
|
|
146
|
+
assert.ok(!r[26].includes('noimage'));
|
|
147
|
+
assert.ok(!r[25].includes('0341'), 'phone removed from description');
|
|
148
|
+
assert.ok(r[25].includes('**Ausstattung**'));
|
|
149
|
+
assert.ok(r[402].includes('?source=marktde&medium=listing&campaign=listing&utm_source=marktde'));
|
|
150
|
+
assert.ok(!r.join(';').includes('undefined'));
|
|
151
|
+
}
|
|
152
|
+
assert.deepEqual(rows.map(r => r[14]), ['""', '""', '"WVWZZZ1KZAW000001"'], 'no fake VIN');
|
|
153
|
+
assert.deepEqual(rows.map(r => [r[10], r[11], r[29]]), [
|
|
154
|
+
['"15148"', '"1"', '"0"'],
|
|
155
|
+
['"20000.00"', '"0"', '"19"'],
|
|
156
|
+
['"15148"', '"1"', '"0"'],
|
|
157
|
+
]);
|
|
158
|
+
assert.equal(report.status, 'published');
|
|
159
|
+
assert.equal(report.items, 3);
|
|
160
|
+
assert.equal(report.dealers, 2);
|
|
161
|
+
assert.deepEqual(report.excluded, { duplicate_id: 1, filtered: 1, no_valid_image: 1, reserved_or_sold: 2, no_valid_price: 1, no_vehicle_id: 1 });
|
|
162
|
+
assert.deepEqual(report.vin, { empty: 2, sent: 1 });
|
|
163
|
+
assert.deepEqual(report.vat, { deductible: 1, notDeductible: 2, rateDefaulted: 0 });
|
|
164
|
+
assert.equal(report.description.sanitized, 3);
|
|
165
|
+
assert.equal(report.description.removed.phone, 3);
|
|
166
|
+
const zip = new AdmZip(world.zip);
|
|
167
|
+
assert.deepEqual(zip.getEntries().map((e: any) => e.entryName), ['api_export_cars_marktde.csv']);
|
|
168
|
+
assert.equal(zip.readAsText('api_export_cars_marktde.csv'), await fs.readFile(world.csv, 'utf8'));
|
|
169
|
+
const saved = JSON.parse(await fs.readFile(join(world.state, 'last-run-report.json'), 'utf8'));
|
|
170
|
+
assert.equal(saved.items, 3);
|
|
171
|
+
}],
|
|
172
|
+
|
|
173
|
+
['second run with the same snapshot: byte-identical CSV (stable ids, order, links)', async () => {
|
|
174
|
+
const world = await newWorld();
|
|
175
|
+
await writeSource(world, [source('i1'), source('i2'), source('i3')]);
|
|
176
|
+
await run(world);
|
|
177
|
+
const first = await fs.readFile(world.csv, 'utf8');
|
|
178
|
+
await run(world, { at: new Date(NOW.getTime() + HOUR) });
|
|
179
|
+
assert.equal(await fs.readFile(world.csv, 'utf8'), first);
|
|
180
|
+
}],
|
|
181
|
+
|
|
182
|
+
['partial snapshot (missing chunk) -> blocked, last good CSV/ZIP untouched', async () => {
|
|
183
|
+
const world = await newWorld();
|
|
184
|
+
await writeSource(world, [source('p1'), source('p2'), source('p3'), source('p4')]);
|
|
185
|
+
await run(world);
|
|
186
|
+
const good = await fs.readFile(world.csv, 'utf8');
|
|
187
|
+
const goodZip = await fs.readFile(world.zip);
|
|
188
|
+
await fs.rm(join(world.source, 'cars.0.json'));
|
|
189
|
+
await assert.rejects(run(world, { at: new Date(NOW.getTime() + HOUR) }), (e: any) => e instanceof FeedGuardError && e.code === 'source_partial');
|
|
190
|
+
assert.equal(await fs.readFile(world.csv, 'utf8'), good);
|
|
191
|
+
assert.deepEqual(await fs.readFile(world.zip), goodZip);
|
|
192
|
+
const report = JSON.parse(await fs.readFile(join(world.state, 'last-run-report.json'), 'utf8'));
|
|
193
|
+
assert.equal(report.status, 'blocked');
|
|
194
|
+
}],
|
|
195
|
+
|
|
196
|
+
['stale snapshot -> blocked before any output is written', async () => {
|
|
197
|
+
const world = await newWorld();
|
|
198
|
+
await writeSource(world, [source('s1')], 2, new Date(NOW.getTime() - 30 * HOUR));
|
|
199
|
+
await assert.rejects(run(world), (e: any) => e.code === 'source_stale');
|
|
200
|
+
assert.equal(await exists(world.csv), false);
|
|
201
|
+
assert.equal(await exists(world.zip), false);
|
|
202
|
+
}],
|
|
203
|
+
|
|
204
|
+
['stock drop > 20 % -> blocked (rejected file kept); acceptDrop publishes it', async () => {
|
|
205
|
+
const world = await newWorld();
|
|
206
|
+
await writeSource(world, Array.from({ length: 10 }, (_, i) => source(`d${i}`)));
|
|
207
|
+
await run(world);
|
|
208
|
+
// half of the stock sold
|
|
209
|
+
await writeSource(world, Array.from({ length: 10 }, (_, i) => source(`d${i}`, { 'offer.offerAvailability': i < 5 ? 'selector_offerAvailability_sold' : undefined })));
|
|
210
|
+
await assert.rejects(run(world, { at: new Date(NOW.getTime() + HOUR) }), (e: any) => e.code === 'count_drop');
|
|
211
|
+
assert.equal((await readRows(world)).length, 10);
|
|
212
|
+
assert.equal(await exists(`${world.csv}.rejected`), true);
|
|
213
|
+
await run(world, { at: new Date(NOW.getTime() + 2 * HOUR), guard: { acceptDrop: true } });
|
|
214
|
+
assert.equal((await readRows(world)).length, 5);
|
|
215
|
+
assert.equal(await exists(`${world.csv}.prev`), true);
|
|
216
|
+
}],
|
|
217
|
+
|
|
218
|
+
['nothing sellable -> published_too_small, never an empty file', async () => {
|
|
219
|
+
const world = await newWorld();
|
|
220
|
+
const all = [source('n1'), source('n2')];
|
|
221
|
+
for (const s of all) {
|
|
222
|
+
for (const i of imagesService.getCleanImages(s.car._id, s.images)) world.brokenImages.add(i.url);
|
|
223
|
+
}
|
|
224
|
+
await writeSource(world, all);
|
|
225
|
+
await assert.rejects(run(world), (e: any) => e.code === 'published_too_small');
|
|
226
|
+
assert.equal(await exists(world.csv), false);
|
|
227
|
+
}],
|
|
228
|
+
|
|
229
|
+
['old vs new id: a car re-created under a new _id gets a new link but keeps its vehicle id column', async () => {
|
|
230
|
+
const world = await newWorld();
|
|
231
|
+
await writeSource(world, [source('old1', { 'metaData.vehicleId': 'V-STABLE' })]);
|
|
232
|
+
await run(world);
|
|
233
|
+
const [before] = await readRows(world);
|
|
234
|
+
await writeSource(world, [source('new1', { 'metaData.vehicleId': 'V-STABLE' })]);
|
|
235
|
+
await run(world, { at: new Date(NOW.getTime() + HOUR) });
|
|
236
|
+
const [after] = await readRows(world);
|
|
237
|
+
assert.equal(before[1], '"V-STABLE"');
|
|
238
|
+
assert.equal(after[1], '"V-STABLE"');
|
|
239
|
+
assert.notEqual(before[402], after[402]);
|
|
240
|
+
}],
|
|
241
|
+
|
|
242
|
+
['a second run while one holds the lock is refused', async () => {
|
|
243
|
+
const world = await newWorld();
|
|
244
|
+
await writeSource(world, [source('l1')]);
|
|
245
|
+
await fs.mkdir(world.state, { recursive: true });
|
|
246
|
+
await fs.writeFile(join(world.state, 'run.lock'), '{}');
|
|
247
|
+
await assert.rejects(run(world), (e: any) => e.code === 'locked');
|
|
248
|
+
}],
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
(async () => {
|
|
252
|
+
let failed = 0;
|
|
253
|
+
for (const [name, fn] of tests) {
|
|
254
|
+
try {
|
|
255
|
+
await fn();
|
|
256
|
+
console.log(`ok ${name}`);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
failed += 1;
|
|
259
|
+
console.error(`FAIL ${name}\n ${e.message}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (failed) process.exit(1);
|
|
263
|
+
})();
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
+
// markt.de columns (ADV-328) on top of the legacy mobile.de-style row — pure functions, no I/O.
|
|
3
|
+
import * as assert from 'node:assert/strict';
|
|
4
|
+
|
|
5
|
+
import { MarktdeConfig } from '../src/configs/marktde.config';
|
|
6
|
+
import { Environment, ExportType, ImageType } from '../src/enums';
|
|
7
|
+
import {
|
|
8
|
+
MARKTDE_COLUMNS,
|
|
9
|
+
MARKTDE_LINK_PARAMS,
|
|
10
|
+
MarktdeExclusion,
|
|
11
|
+
applyMarktdeColumns,
|
|
12
|
+
getMarktdeExclusion,
|
|
13
|
+
getMarktdeListingUrl,
|
|
14
|
+
getMarktdePriceColumns,
|
|
15
|
+
getMarktdeVin,
|
|
16
|
+
sanitizeMarktdeDescription,
|
|
17
|
+
toMarktdeImages,
|
|
18
|
+
} from '../src/transformers/marktde.transformer';
|
|
19
|
+
import { selectOpenAiImageFields } from '../src/transformers/openai-image-fields';
|
|
20
|
+
import { convertToCSV } from '../src/utils/converter.utils';
|
|
21
|
+
import { buildImagesService } from './fixtures/images-service.harness';
|
|
22
|
+
import { car, eightImages, images } from './fixtures/meinstadt.fixture';
|
|
23
|
+
|
|
24
|
+
const imagesService = buildImagesService();
|
|
25
|
+
const C = MARKTDE_COLUMNS;
|
|
26
|
+
const ctx = { domain: 'auto.de', images: ['https://images.auto.de/carimage/x/hero1/large-cached.jpeg', 'https://images.auto.de/carimage/x/side1/large-cached.jpeg'] };
|
|
27
|
+
|
|
28
|
+
// The row MobileTransformer builds from the mapping sheet (only the columns the test looks at).
|
|
29
|
+
const legacyRow = (c: any): Record<string, unknown> => ({
|
|
30
|
+
'0': '04109003',
|
|
31
|
+
'1': c.metaData.vehicleId,
|
|
32
|
+
'3': c.mainData.make,
|
|
33
|
+
'4': `${c.mainData.model} ${c.mainData.subModel}`,
|
|
34
|
+
'10': 'legacy-price',
|
|
35
|
+
'11': 'legacy-flag',
|
|
36
|
+
'14': 'legacy-vin',
|
|
37
|
+
'25': c.offer?.offerDescription ? 'Fahrzeugbeschreibung\\\\**Ausstattung**\\\\* ABS\\\\' : undefined,
|
|
38
|
+
'26': 'legacy-images',
|
|
39
|
+
'29': 'legacy-rate',
|
|
40
|
+
'98': '6,1',
|
|
41
|
+
'99': 'undefined',
|
|
42
|
+
'402': `https://www.auto.de${c._id}?source=classifieds&medium=listing&campaign=marktde`,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const tests: Array<[string, () => void | Promise<void>]> = [
|
|
46
|
+
['column letters: B=1, K=10, L=11, O=14, Z=25, AA=26, AD=29, OM=402 (historic ACF-4370 mapping)', () => {
|
|
47
|
+
const letters = (index: number): string => {
|
|
48
|
+
let n = index + 1;
|
|
49
|
+
let out = '';
|
|
50
|
+
while (n > 0) {
|
|
51
|
+
const r = (n - 1) % 26;
|
|
52
|
+
out = String.fromCharCode(65 + r) + out;
|
|
53
|
+
n = Math.floor((n - 1) / 26);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
};
|
|
57
|
+
assert.deepEqual(
|
|
58
|
+
Object.fromEntries(Object.entries(C).map(([k, v]) => [k, letters(Number(v))])),
|
|
59
|
+
{ vehicleId: 'B', price: 'K', vatFlag: 'L', vin: 'O', description: 'Z', images: 'AA', vatRate: 'AD', fuelCombined: 'CU', co2: 'CV', listingUrl: 'OM' },
|
|
60
|
+
);
|
|
61
|
+
}],
|
|
62
|
+
|
|
63
|
+
['link: auto.de vehicle page with source=marktde + identical utm_* (no classifieds, no paid medium)', () => {
|
|
64
|
+
assert.equal(
|
|
65
|
+
getMarktdeListingUrl('auto.de', 'abc123'),
|
|
66
|
+
'https://www.auto.de/search/vehicle/abc123?source=marktde&medium=listing&campaign=listing&utm_source=marktde&utm_medium=listing&utm_campaign=listing',
|
|
67
|
+
);
|
|
68
|
+
assert.ok(!MARKTDE_LINK_PARAMS.includes('classifieds'));
|
|
69
|
+
assert.ok(!/cpc|paid/.test(MARKTDE_LINK_PARAMS));
|
|
70
|
+
}],
|
|
71
|
+
|
|
72
|
+
['VIN: own valid VIN (upper-cased) or empty — never a generated / marketing fake VIN', () => {
|
|
73
|
+
assert.equal(getMarktdeVin(car('a', { 'metaData.vin': 'wvwzzz1kzaw000001' })), 'WVWZZZ1KZAW000001');
|
|
74
|
+
assert.equal(getMarktdeVin(car('a', { 'metaData.vin': '' })), '');
|
|
75
|
+
assert.equal(getMarktdeVin(car('a', { 'marketing.fakeVIN': 'WVWZZZ1KZAW999999' })), '');
|
|
76
|
+
assert.equal(getMarktdeVin(car('a', { 'metaData.vin': 'WVWZZZ1KZAW00000' })), '', '16 chars');
|
|
77
|
+
assert.equal(getMarktdeVin(car('a', { 'metaData.vin': 'WVWZZZ1KZAWO00001' })), '', 'letter O is not allowed');
|
|
78
|
+
assert.equal(getMarktdeVin(car('a', { 'metaData.vin': 'unbekannt' })), '');
|
|
79
|
+
}],
|
|
80
|
+
|
|
81
|
+
['price: VAT-deductible -> net K, L=0, AD=rate; gross is restored exactly as K*(1+AD)', () => {
|
|
82
|
+
const p = getMarktdePriceColumns(car('a', { 'price.currentSalesPrice': 23800, 'price.vatDeductible': true, 'price.vatRate': 19 }));
|
|
83
|
+
assert.deepEqual(p, { price: '20000.00', vatFlag: '0', vatRate: '19', deductible: true, rateDefaulted: false });
|
|
84
|
+
assert.equal(Math.round(Number(p.price) * 1.19), 23800);
|
|
85
|
+
}],
|
|
86
|
+
|
|
87
|
+
['price: not deductible (margin scheme / exempt) -> gross K as stored, L=1, AD=0 — never +19 % on a gross price', () => {
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
getMarktdePriceColumns(car('a', { 'price.currentSalesPrice': 15148, 'price.vatDeductible': false })),
|
|
90
|
+
{ price: '15148', vatFlag: '1', vatRate: '0', deductible: false, rateDefaulted: false },
|
|
91
|
+
);
|
|
92
|
+
assert.equal(getMarktdePriceColumns(car('a', { 'price.currentSalesPrice': 15148 })).vatFlag, '1', 'unknown = not deductible');
|
|
93
|
+
}],
|
|
94
|
+
|
|
95
|
+
['price: deductible with null / 0 vatRate -> 19 % for BOTH K and AD (legacy kept K gross but AD=19)', () => {
|
|
96
|
+
for (const rate of [null, 0, undefined]) {
|
|
97
|
+
const p = getMarktdePriceColumns(car('a', { 'price.currentSalesPrice': 11900, 'price.vatDeductible': true, 'price.vatRate': rate }));
|
|
98
|
+
assert.deepEqual(p, { price: '10000.00', vatFlag: '0', vatRate: '19', deductible: true, rateDefaulted: true });
|
|
99
|
+
}
|
|
100
|
+
}],
|
|
101
|
+
|
|
102
|
+
['price: missing / 0 / negative / text -> null (car is excluded)', () => {
|
|
103
|
+
for (const value of [null, undefined, '', 0, -5, 'abc', true]) {
|
|
104
|
+
assert.equal(getMarktdePriceColumns(car('a', { 'price.currentSalesPrice': value })), null, String(value));
|
|
105
|
+
}
|
|
106
|
+
}],
|
|
107
|
+
|
|
108
|
+
['exclusions: sold / reserved / no price / no vehicle id / no _id', () => {
|
|
109
|
+
assert.equal(getMarktdeExclusion(car('a')), null);
|
|
110
|
+
assert.equal(getMarktdeExclusion(car('a', { 'offer.offerAvailability': 'selector_offerAvailability_sold' })), MarktdeExclusion.notAvailable);
|
|
111
|
+
assert.equal(getMarktdeExclusion(car('a', { 'offer.offerAvailability': 'selector_offerAvailability_reserved' })), MarktdeExclusion.notAvailable);
|
|
112
|
+
assert.equal(getMarktdeExclusion(car('a', { 'offer.offerAvailability': 'selector_offerAvailability_available' })), null);
|
|
113
|
+
assert.equal(getMarktdeExclusion(car('a', { 'price.currentSalesPrice': null })), MarktdeExclusion.noPrice);
|
|
114
|
+
assert.equal(getMarktdeExclusion(car('a', { 'metaData.vehicleId': '' })), MarktdeExclusion.noVehicleId);
|
|
115
|
+
assert.equal(getMarktdeExclusion(car('', {})), MarktdeExclusion.noId);
|
|
116
|
+
}],
|
|
117
|
+
|
|
118
|
+
['images: validated clean list, hero first, no placeholders; no valid hero -> []', () => {
|
|
119
|
+
const clean = imagesService.getCleanImages('car1', eightImages('car1'));
|
|
120
|
+
const all = toMarktdeImages(selectOpenAiImageFields(clean, clean.map(i => ({ url: i.url, ok: true }))));
|
|
121
|
+
assert.equal(all.length, 8);
|
|
122
|
+
assert.match(all[0], /hero1\/large-cached\.jpeg$/);
|
|
123
|
+
assert.ok(all.every(u => !u.includes('noimage')));
|
|
124
|
+
assert.equal(toMarktdeImages(selectOpenAiImageFields(clean, clean.map(i => ({ url: i.url, ok: true }))), 3).length, 3);
|
|
125
|
+
const brokenHeroes = clean.map(i => ({ url: i.url, ok: !i.heroCandidate }));
|
|
126
|
+
assert.deepEqual(toMarktdeImages(selectOpenAiImageFields(clean, brokenHeroes)), []);
|
|
127
|
+
assert.deepEqual(toMarktdeImages(selectOpenAiImageFields(imagesService.getCleanImages('c', []), [])), []);
|
|
128
|
+
}],
|
|
129
|
+
|
|
130
|
+
['description: URLs, e-mails, phone numbers, street / postcode lines removed; vehicle facts and markup stay', () => {
|
|
131
|
+
const text = 'Autohaus Muster GmbH\\\\Tel.: 0341 / 123 456 78\\\\Mobil: +49 (0) 171 2345678\\\\E-Mail: info@autohaus-muster.de\\\\'
|
|
132
|
+
+ 'Besuchen Sie uns auf www.autohaus-muster.de oder https://autohaus-muster.de/angebote\\\\Max-Planck-Straße 19, 06796 Sandersdorf-Brehna\\\\'
|
|
133
|
+
+ '**Ausstattung**\\\\* ABS\\\\HU: 08/2027\\\\Erstzulassung: 01.08.2021\\\\Kilometerstand: 125000 Km\\\\Preis: 15990 Euro\\\\Hubraum: 1598 ccm\\\\';
|
|
134
|
+
const { text: out, removed } = sanitizeMarktdeDescription(text);
|
|
135
|
+
assert.deepEqual(removed, { url: 2, email: 1, phone: 2, address: 2 });
|
|
136
|
+
for (const forbidden of ['0341', '171', '@', 'www.', 'http', 'autohaus-muster.de', 'Planck', '06796', 'Tel.:', 'E-Mail:']) {
|
|
137
|
+
assert.ok(!out.includes(forbidden), `${forbidden} left in: ${out}`);
|
|
138
|
+
}
|
|
139
|
+
for (const kept of ['Autohaus Muster GmbH', '**Ausstattung**', '* ABS', 'HU: 08/2027', 'Erstzulassung: 01.08.2021', '125000 Km', '15990 Euro', '1598 ccm']) {
|
|
140
|
+
assert.ok(out.includes(kept), `${kept} missing in: ${out}`);
|
|
141
|
+
}
|
|
142
|
+
}],
|
|
143
|
+
|
|
144
|
+
['description: the seller name auto.de stays (brand + auto.de GmbH), links to it and contact lines go; markup untouched', () => {
|
|
145
|
+
const text = ' **auto.de | Einfach - Sicher - Online - Kaufen**\\\\\\\\ ----\\\\so funktioniert auto.de\\\\'
|
|
146
|
+
+ 'Sollten Sie ein günstigeres Angebot finden als auf auto.de, dann passen wir es an.\\\\Auto.de legt höchsten Wert auf Zufriedenheit.\\\\'
|
|
147
|
+
+ 'auto.de-ID: 96BAE0\\\\Tel.: 0800 0000 466\\\\WhatsApp: 0151 225 94 090\\\\SMS: 0151 225 94 090\\\\E-Mail: auto@auto.de\\\\ ---- **Basic data**\\\\'
|
|
148
|
+
+ 'Mehr unter https://www.auto.de/angebote und www.auto.de\\\\';
|
|
149
|
+
const { text: out, removed } = sanitizeMarktdeDescription(text);
|
|
150
|
+
assert.deepEqual(removed, { url: 2, email: 1, phone: 3, address: 0 });
|
|
151
|
+
assert.equal(
|
|
152
|
+
out,
|
|
153
|
+
' **auto.de | Einfach - Sicher - Online - Kaufen**\\\\\\\\ ----\\\\so funktioniert auto.de\\\\'
|
|
154
|
+
+ 'Sollten Sie ein günstigeres Angebot finden als auf auto.de, dann passen wir es an.\\\\Auto.de legt höchsten Wert auf Zufriedenheit.\\\\'
|
|
155
|
+
+ 'auto.de-ID: 96BAE0\\\\\\\\\\\\\\\\\\\\ ---- **Basic data**\\\\'
|
|
156
|
+
+ 'Mehr unter und\\\\',
|
|
157
|
+
);
|
|
158
|
+
}],
|
|
159
|
+
|
|
160
|
+
['description: other bare domains go ("autohaus-muster.de"), words with dots stay ("z.B.", "ca.", "1.6 TDI")', () => {
|
|
161
|
+
const { text: out, removed } = sanitizeMarktdeDescription('Alle Fahrzeuge auf autohaus-muster.de ansehen, z.B. 1.6 TDI ca. 5 l\\\\');
|
|
162
|
+
assert.equal(removed.url, 1);
|
|
163
|
+
assert.equal(out, 'Alle Fahrzeuge auf ansehen, z.B. 1.6 TDI ca. 5 l\\\\');
|
|
164
|
+
// DEV 12.11: a model name looked like a street ("…ring" + number)
|
|
165
|
+
const model = 'Untermodell: M3 Competition xDrive Touring 3.0 8-Gang St...\\\\Hubraum: 2993\\\\';
|
|
166
|
+
assert.deepEqual(sanitizeMarktdeDescription(model), { text: model, removed: { url: 0, email: 0, phone: 0, address: 0 } });
|
|
167
|
+
}],
|
|
168
|
+
|
|
169
|
+
['description: text without contact data is returned unchanged (byte-identical)', () => {
|
|
170
|
+
const text = '**Ausstattung**\\\\* ABS\\\\* Klimaanlage\\\\Leistung: 85 kW (116 PS)\\\\Verbrauch: 6,1 l/100km\\\\';
|
|
171
|
+
const result = sanitizeMarktdeDescription(text);
|
|
172
|
+
assert.equal(result.text, text);
|
|
173
|
+
assert.deepEqual(result.removed, { url: 0, email: 0, phone: 0, address: 0 });
|
|
174
|
+
assert.deepEqual(sanitizeMarktdeDescription('').removed, { url: 0, email: 0, phone: 0, address: 0 });
|
|
175
|
+
}],
|
|
176
|
+
|
|
177
|
+
['applyMarktdeColumns: only the markt.de columns change, every other column stays', () => {
|
|
178
|
+
const c = car('car1', { 'metaData.vin': 'WVWZZZ1KZAW000001', 'price.vatDeductible': true, 'price.vatRate': 19, 'price.currentSalesPrice': 23800, 'offer.offerDescription': 'x' });
|
|
179
|
+
const row = legacyRow(c);
|
|
180
|
+
const before = { ...row };
|
|
181
|
+
const facts = applyMarktdeColumns(row, c, ctx);
|
|
182
|
+
assert.equal(row[C.price], '20000.00');
|
|
183
|
+
assert.equal(row[C.vatFlag], '0');
|
|
184
|
+
assert.equal(row[C.vatRate], '19');
|
|
185
|
+
assert.equal(row[C.vin], 'WVWZZZ1KZAW000001');
|
|
186
|
+
assert.equal(row[C.images], ctx.images.join(';'));
|
|
187
|
+
assert.equal(row[C.listingUrl], getMarktdeListingUrl('auto.de', 'car1'));
|
|
188
|
+
assert.equal(row[C.co2], '137');
|
|
189
|
+
assert.equal(row[C.fuelCombined], '6,1');
|
|
190
|
+
assert.equal(row[C.description], before[C.description], 'description without contact data unchanged');
|
|
191
|
+
for (const key of ['0', '1', '3', '4']) {
|
|
192
|
+
assert.equal(row[key], before[key], `column ${key}`);
|
|
193
|
+
}
|
|
194
|
+
assert.equal(facts.vin, 'sent');
|
|
195
|
+
assert.equal(facts.descriptionRemovals.url, 0);
|
|
196
|
+
}],
|
|
197
|
+
|
|
198
|
+
['applyMarktdeColumns: BEV -> fuel empty, CO2 0; nothing usable -> empty (never "undefined")', () => {
|
|
199
|
+
const bev = car('bev1', { 'consumption.fuel': 'selector_fuel_electrical', 'environmentEmissions.co2': null, 'consumption.consumptionCombined': 16.5 });
|
|
200
|
+
const row = legacyRow(bev);
|
|
201
|
+
applyMarktdeColumns(row, bev, ctx);
|
|
202
|
+
assert.equal(row[C.fuelCombined], '');
|
|
203
|
+
assert.equal(row[C.co2], '0');
|
|
204
|
+
const none = car('none1', { 'environmentEmissions.co2': null, 'consumption.consumptionCombined': null });
|
|
205
|
+
const row2 = legacyRow(none);
|
|
206
|
+
const facts = applyMarktdeColumns(row2, none, ctx);
|
|
207
|
+
assert.equal(row2[C.co2], '');
|
|
208
|
+
assert.equal(row2[C.fuelCombined], '');
|
|
209
|
+
assert.ok(facts.co2Empty && facts.fuelEmpty);
|
|
210
|
+
assert.ok(!convertToCSV(row2).includes('undefined'));
|
|
211
|
+
}],
|
|
212
|
+
|
|
213
|
+
['applyMarktdeColumns: invalid stored VIN reported as invalid_dropped, missing as empty', () => {
|
|
214
|
+
const bad = car('v1', { 'metaData.vin': 'XYZ' });
|
|
215
|
+
assert.equal(applyMarktdeColumns(legacyRow(bad), bad, ctx).vin, 'invalid_dropped');
|
|
216
|
+
const missing = car('v2', {});
|
|
217
|
+
assert.equal(applyMarktdeColumns(legacyRow(missing), missing, ctx).vin, 'empty');
|
|
218
|
+
}],
|
|
219
|
+
|
|
220
|
+
['20 fixtures: one CSV line each, 403 columns, B = vehicle id, OM = link with the car id, UTF-8 German text', () => {
|
|
221
|
+
const variants: Array<Record<string, any>> = [
|
|
222
|
+
{}, { 'price.vatDeductible': true, 'price.vatRate': 19 }, { 'price.vatDeductible': true, 'price.vatRate': null },
|
|
223
|
+
{ 'consumption.fuel': 'selector_fuel_electrical' }, { 'metaData.vin': 'WVWZZZ1KZAW000001' }, { 'metaData.vin': 'bad' },
|
|
224
|
+
{ 'mainData.subModel': null }, { 'mainData.make': 'Škoda', 'mainData.model': 'Octavia Combi' }, { 'mainData.firstRegistration': null },
|
|
225
|
+
{ 'environmentEmissions.co2': null }, { 'consumption.consumptionCombined': null }, { 'offer.offerDescription': 'x' },
|
|
226
|
+
{ 'price.currentSalesPrice': 79999 }, { 'price.currentSalesPrice': 1 }, { 'mainData.mileage': 0 },
|
|
227
|
+
{ 'mainData.make': 'Mercedes-Benz', 'mainData.model': 'C 200 "Avantgarde"' }, { 'metaData.ownerId': 'dealer-2' },
|
|
228
|
+
{ 'price.vatDeductible': true, 'price.vatRate': 7 }, { 'mainData.accidentDamaged': true }, { 'consumption.fuel': 'selector_fuel_diesel' },
|
|
229
|
+
];
|
|
230
|
+
assert.equal(variants.length, 20);
|
|
231
|
+
variants.forEach((overrides, i) => {
|
|
232
|
+
const id = `fx${String(i).padStart(2, '0')}abcdef`;
|
|
233
|
+
const c = car(id, overrides);
|
|
234
|
+
const row = legacyRow(c);
|
|
235
|
+
applyMarktdeColumns(row, c, { domain: 'auto.de', images: imagesService.getCleanImages(id, images(id)).map(x => x.url) });
|
|
236
|
+
const line = convertToCSV(row);
|
|
237
|
+
assert.ok(!line.includes('\n'), 'one line per car');
|
|
238
|
+
// quote-aware split: the image cell carries `;` between URLs inside its quotes
|
|
239
|
+
const cells = line.match(/"(?:[^"]|"")*"|(?<=^|;)(?=;|$)/g);
|
|
240
|
+
assert.equal(cells.length, 403, `fixture ${i}: column count`);
|
|
241
|
+
assert.equal(cells[1], `"${c.metaData.vehicleId}"`);
|
|
242
|
+
assert.ok(cells[26].includes('hero1') && cells[26].includes(';'));
|
|
243
|
+
assert.ok(cells[402].includes(`/search/vehicle/${id}?source=marktde`));
|
|
244
|
+
assert.ok(!line.includes('undefined'), `fixture ${i}`);
|
|
245
|
+
});
|
|
246
|
+
}],
|
|
247
|
+
|
|
248
|
+
['config: dry-run by default (marktde-dryrun), live only with marktdeDryRun=false; legacy sheet + file names', () => {
|
|
249
|
+
const base = { environment: Environment.PRODUCTION, exportType: ExportType.MARKTDE, apikey: 'k' } as any;
|
|
250
|
+
const dry = new MarktdeConfig(base);
|
|
251
|
+
assert.equal(dry.dryRun, true);
|
|
252
|
+
assert.deepEqual(dry.outputFiles, ['resources/marktde-dryrun/api_export_cars_marktde.csv', 'resources/marktde-dryrun/api_export_cars_marktde.zip']);
|
|
253
|
+
assert.equal(dry.stateDir, 'resources/marktde-state/dryrun');
|
|
254
|
+
assert.equal(dry.mappingSpreadsheetTitle, 'mobile_yescar');
|
|
255
|
+
assert.equal(dry.campaign, 'marktde');
|
|
256
|
+
assert.equal(dry.imageType, ImageType.default);
|
|
257
|
+
assert.equal(dry.domain, 'auto.de');
|
|
258
|
+
assert.equal(new MarktdeConfig({ ...base, marktdeDryRun: 'true' }).dryRun, true);
|
|
259
|
+
assert.equal(new MarktdeConfig({ ...base, marktdeDryRun: 'nope' }).dryRun, true);
|
|
260
|
+
const live = new MarktdeConfig({ ...base, marktdeDryRun: 'false' });
|
|
261
|
+
assert.equal(live.dryRun, false);
|
|
262
|
+
assert.equal(live.outputFiles[0], 'resources/marktde-export/api_export_cars_marktde.csv');
|
|
263
|
+
assert.equal(live.feedGuard.minPublishedItems, 1);
|
|
264
|
+
assert.equal(new MarktdeConfig({ ...base, feedGuardMinPublishedItems: '50' }).feedGuard.minPublishedItems, 50);
|
|
265
|
+
}],
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
(async () => {
|
|
269
|
+
let failed = 0;
|
|
270
|
+
for (const [name, fn] of tests) {
|
|
271
|
+
try {
|
|
272
|
+
await fn();
|
|
273
|
+
console.log(`ok ${name}`);
|
|
274
|
+
} catch (e) {
|
|
275
|
+
failed += 1;
|
|
276
|
+
console.error(`FAIL ${name}\n ${e.message}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (failed) process.exit(1);
|
|
280
|
+
})();
|