@bytebrand/export-cars 1.26.45 → 1.26.46
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/constants/fb.constants.js +1 -1
- package/dist/constants/fb.constants.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/marktde/AUDIT.md +84 -84
- package/docs/marktde/PARTNER-QUESTIONS.md +49 -49
- package/docs/marktde/RUNBOOK.md +58 -58
- package/docs/marktde/TRACKING.md +69 -69
- package/package.json +65 -65
- package/src/configs/marktde.config.ts +70 -70
- package/src/constants/fb.constants.ts +34 -34
- package/src/exporters/marktde.exporter.ts +78 -78
- package/src/services/marktde-feed-run.ts +233 -233
- package/src/transformers/marktde.transformer.ts +255 -255
- package/test/marktde-feed-run.test.ts +263 -263
- package/test/marktde-mapping.test.ts +280 -280
|
@@ -1,263 +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
|
-
})();
|
|
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
|
+
})();
|