@bytebrand/export-cars 1.26.47 → 1.26.48
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/ooyyo.config.d.ts +14 -0
- package/dist/configs/ooyyo.config.js +40 -6
- package/dist/configs/ooyyo.config.js.map +1 -1
- package/dist/exporters/ooyyo.exporter.d.ts +5 -4
- package/dist/exporters/ooyyo.exporter.js +45 -25
- package/dist/exporters/ooyyo.exporter.js.map +1 -1
- package/dist/formats/ooyyo-xml.builder.d.ts +7 -2
- package/dist/formats/ooyyo-xml.builder.js +59 -36
- package/dist/formats/ooyyo-xml.builder.js.map +1 -1
- package/dist/interfaces/export-item.interface.d.ts +30 -2
- package/dist/interfaces/params.interface.d.ts +3 -0
- package/dist/services/ooyyo-feed-run.d.ts +81 -0
- package/dist/services/ooyyo-feed-run.js +183 -0
- package/dist/services/ooyyo-feed-run.js.map +1 -0
- package/dist/services/ooyyo-feed-verify.d.ts +13 -0
- package/dist/services/ooyyo-feed-verify.js +104 -0
- package/dist/services/ooyyo-feed-verify.js.map +1 -0
- package/dist/transformers/ooyyo.transformer.d.ts +51 -11
- package/dist/transformers/ooyyo.transformer.js +268 -95
- package/dist/transformers/ooyyo.transformer.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 +75 -69
- package/docs/meinestadt/PARTNER-QUESTIONS.md +17 -0
- package/docs/meinestadt/TRACKING.md +107 -0
- package/docs/ooyyo/AUDIT.md +81 -0
- package/docs/ooyyo/PARTNER-QUESTIONS.md +32 -0
- package/docs/ooyyo/RUNBOOK.md +67 -0
- package/docs/ooyyo/TRACKING.md +54 -0
- package/package.json +66 -65
- package/src/configs/marktde.config.ts +70 -70
- package/src/configs/ooyyo.config.ts +65 -15
- package/src/exporters/marktde.exporter.ts +78 -78
- package/src/exporters/ooyyo.exporter.ts +75 -48
- package/src/formats/ooyyo-xml.builder.ts +87 -48
- package/src/interfaces/export-item.interface.ts +43 -2
- package/src/interfaces/params.interface.ts +5 -0
- package/src/services/marktde-feed-run.ts +233 -233
- package/src/services/ooyyo-feed-run.ts +265 -0
- package/src/services/ooyyo-feed-verify.ts +131 -0
- package/src/transformers/marktde.transformer.ts +255 -255
- package/src/transformers/ooyyo.transformer.ts +378 -109
- package/test/fixtures/ooyyo/OoyyoFormat.xml +125 -0
- package/test/fixtures/ooyyo/OoyyoFormat.xsd +116 -0
- package/test/fixtures/ooyyo.fixture.ts +53 -0
- package/test/mapping-csv.test.ts +65 -65
- package/test/marktde-feed-run.test.ts +263 -263
- package/test/marktde-mapping.test.ts +280 -280
- package/test/ooyyo-feed-run.test.ts +293 -0
- package/test/ooyyo-mapping.test.ts +276 -0
|
@@ -1,233 +1,233 @@
|
|
|
1
|
-
import { promises as fs } from 'fs';
|
|
2
|
-
import { dirname, join } from 'path';
|
|
3
|
-
import { ImboImageResponseInterface } from '@bytebrand/chunkable';
|
|
4
|
-
import { DataSchemes } from '@bytebrand/car-schema';
|
|
5
|
-
|
|
6
|
-
import { convertToCSV } from '../utils/converter.utils';
|
|
7
|
-
import { MarktdeExclusion, applyMarktdeColumns, getMarktdeExclusion } from '../transformers/marktde.transformer';
|
|
8
|
-
import { FeedGuard, FeedGuardError, LastGood, inspectSourceSnapshot } from './feed-guard';
|
|
9
|
-
|
|
10
|
-
export interface MarktdeSourceCar {
|
|
11
|
-
car: DataSchemes.Car;
|
|
12
|
-
images: ImboImageResponseInterface[];
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
// Everything the run needs from the outside world — the Nest exporter wires the real services,
|
|
16
|
-
// tests pass fakes.
|
|
17
|
-
export interface MarktdeRunDeps {
|
|
18
|
-
// iterates the base export snapshot chunk by chunk (LoaderService.process)
|
|
19
|
-
forEachChunk: (fn: (cars: MarktdeSourceCar[]) => Promise<void>) => Promise<unknown>;
|
|
20
|
-
// existing shared partner filter (images count, Cronicle rules incl. the dealer filter)
|
|
21
|
-
filterCar: (source: MarktdeSourceCar) => boolean;
|
|
22
|
-
// final validated image URLs for the car, hero first; [] = no usable image
|
|
23
|
-
resolveImages: (source: MarktdeSourceCar) => Promise<string[]>;
|
|
24
|
-
// the legacy mobile.de-style row (MobileTransformer + mapping sheet), keyed by column index
|
|
25
|
-
legacyRow: (source: MarktdeSourceCar) => Promise<Record<string, unknown>>;
|
|
26
|
-
imageStats: () => Record<string, number>;
|
|
27
|
-
// packs csvFile into zipFile (entry name = basename of csvFile)
|
|
28
|
-
zip: (csvFile: string, zipFile: string) => Promise<void>;
|
|
29
|
-
guard: FeedGuard;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface MarktdeRunOptions {
|
|
33
|
-
runId?: string;
|
|
34
|
-
dryRun: boolean;
|
|
35
|
-
domain: string;
|
|
36
|
-
sourceDirectory: string;
|
|
37
|
-
sourceBaseFileName: string;
|
|
38
|
-
csvFile: string;
|
|
39
|
-
zipFile: string;
|
|
40
|
-
stateDir: string;
|
|
41
|
-
now?: () => Date;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface MarktdeRunReport {
|
|
45
|
-
runId?: string;
|
|
46
|
-
mode: 'dry-run' | 'live';
|
|
47
|
-
startedAt: string;
|
|
48
|
-
finishedAt?: string;
|
|
49
|
-
status: 'published' | 'blocked' | 'failed';
|
|
50
|
-
guard?: { code: string; message: string };
|
|
51
|
-
error?: string;
|
|
52
|
-
csvFile: string;
|
|
53
|
-
zipFile: string;
|
|
54
|
-
source: { files: number; newestChunkAt: string | null; cars: number };
|
|
55
|
-
items: number;
|
|
56
|
-
dealers: number;
|
|
57
|
-
excluded: Record<string, number>;
|
|
58
|
-
vin: Record<string, number>;
|
|
59
|
-
vat: { deductible: number; notDeductible: number; rateDefaulted: number };
|
|
60
|
-
envkv: { fuelEmpty: number; co2Empty: number };
|
|
61
|
-
description: { items: number; sanitized: number; removed: { url: number; email: number; phone: number; address: number } };
|
|
62
|
-
imagesPerItem: { min: number | null; max: number | null; total: number };
|
|
63
|
-
images: Record<string, number>;
|
|
64
|
-
lastGood: LastGood | null;
|
|
65
|
-
lastGoodAgeHours: number | null;
|
|
66
|
-
stale: boolean;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const increment = (counter: Record<string, number>, key: string) => {
|
|
70
|
-
counter[key] = (counter[key] ?? 0) + 1;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* One markt.de feed run (ADV-328): source check -> legacy row + markt.de columns + validated images ->
|
|
75
|
-
* temp CSV -> count check -> atomic publish of the CSV -> ZIP of the published CSV -> report.
|
|
76
|
-
* Replaces the old MobileExporter flow for markt.de, which emptied the output directory first (an
|
|
77
|
-
* outage published an empty or partial file) and needed the `zip` binary the DEV host lacks.
|
|
78
|
-
* Nothing outside the output directory and `stateDir` is written; a blocked run leaves the last good
|
|
79
|
-
* CSV/ZIP in place.
|
|
80
|
-
*/
|
|
81
|
-
export class MarktdeFeedRun {
|
|
82
|
-
constructor(private readonly deps: MarktdeRunDeps, private readonly options: MarktdeRunOptions) { }
|
|
83
|
-
|
|
84
|
-
private now(): Date {
|
|
85
|
-
return this.options.now ? this.options.now() : new Date();
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async run(): Promise<MarktdeRunReport> {
|
|
89
|
-
const { guard } = this.deps;
|
|
90
|
-
const { csvFile, zipFile, runId, dryRun } = this.options;
|
|
91
|
-
const startedAt = this.now();
|
|
92
|
-
const lastGood = await guard.readLastGood();
|
|
93
|
-
const report: MarktdeRunReport = {
|
|
94
|
-
runId,
|
|
95
|
-
mode: dryRun ? 'dry-run' : 'live',
|
|
96
|
-
startedAt: startedAt.toISOString(),
|
|
97
|
-
status: 'failed',
|
|
98
|
-
csvFile,
|
|
99
|
-
zipFile,
|
|
100
|
-
source: { files: 0, newestChunkAt: null, cars: 0 },
|
|
101
|
-
items: 0,
|
|
102
|
-
dealers: 0,
|
|
103
|
-
excluded: {},
|
|
104
|
-
vin: {},
|
|
105
|
-
vat: { deductible: 0, notDeductible: 0, rateDefaulted: 0 },
|
|
106
|
-
envkv: { fuelEmpty: 0, co2Empty: 0 },
|
|
107
|
-
description: { items: 0, sanitized: 0, removed: { url: 0, email: 0, phone: 0, address: 0 } },
|
|
108
|
-
imagesPerItem: { min: null, max: null, total: 0 },
|
|
109
|
-
images: {},
|
|
110
|
-
lastGood,
|
|
111
|
-
lastGoodAgeHours: guard.staleHours(lastGood, startedAt),
|
|
112
|
-
stale: guard.isStale(lastGood, startedAt),
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
await guard.acquireLock(runId ?? 'local', startedAt);
|
|
116
|
-
const tmpFile = `${csvFile}.tmp`;
|
|
117
|
-
try {
|
|
118
|
-
const snapshot = await inspectSourceSnapshot(this.options.sourceDirectory, this.options.sourceBaseFileName);
|
|
119
|
-
report.source.files = snapshot.files;
|
|
120
|
-
report.source.newestChunkAt = snapshot.newestMtime?.toISOString() ?? null;
|
|
121
|
-
guard.checkSource(snapshot, startedAt);
|
|
122
|
-
|
|
123
|
-
await fs.mkdir(dirname(csvFile), { recursive: true });
|
|
124
|
-
await fs.rm(tmpFile, { force: true });
|
|
125
|
-
const out = await fs.open(tmpFile, 'w');
|
|
126
|
-
const seen = new Set<string>();
|
|
127
|
-
const dealers = new Set<string>();
|
|
128
|
-
try {
|
|
129
|
-
await this.deps.forEachChunk(async (cars) => {
|
|
130
|
-
for (const source of cars) {
|
|
131
|
-
report.source.cars += 1;
|
|
132
|
-
const id = String(source?.car?._id ?? '');
|
|
133
|
-
if (id && seen.has(id)) {
|
|
134
|
-
increment(report.excluded, MarktdeExclusion.duplicateId);
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
seen.add(id);
|
|
138
|
-
if (!this.deps.filterCar(source)) {
|
|
139
|
-
increment(report.excluded, MarktdeExclusion.filtered);
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
// cheap checks first: no image probe for a car that is excluded anyway
|
|
143
|
-
const excluded = getMarktdeExclusion(source.car);
|
|
144
|
-
if (excluded) {
|
|
145
|
-
increment(report.excluded, excluded);
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
const images = await this.deps.resolveImages(source);
|
|
149
|
-
if (!images.length) {
|
|
150
|
-
increment(report.excluded, MarktdeExclusion.noImage);
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
const row = await this.deps.legacyRow(source);
|
|
154
|
-
const facts = applyMarktdeColumns(row, source.car, { domain: this.options.domain, images });
|
|
155
|
-
await out.write(`${convertToCSV(row)}\n`, null, 'utf8');
|
|
156
|
-
dealers.add(String(source.car?.metaData?.ownerId ?? ''));
|
|
157
|
-
this.count(report, images.length, facts);
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
|
-
} finally {
|
|
161
|
-
await out.close();
|
|
162
|
-
}
|
|
163
|
-
report.dealers = dealers.size;
|
|
164
|
-
report.images = this.deps.imageStats();
|
|
165
|
-
|
|
166
|
-
const counts = { sourceItems: report.source.cars, publishedItems: report.items };
|
|
167
|
-
guard.checkCounts(counts, lastGood);
|
|
168
|
-
|
|
169
|
-
report.lastGood = await guard.publish(tmpFile, csvFile, counts, this.now(), runId);
|
|
170
|
-
const zipTmp = zipFile.replace(/\.zip$/, '.tmp.zip');
|
|
171
|
-
await fs.rm(zipTmp, { force: true });
|
|
172
|
-
await this.deps.zip(csvFile, zipTmp);
|
|
173
|
-
await fs.rename(zipTmp, zipFile);
|
|
174
|
-
report.status = 'published';
|
|
175
|
-
report.lastGoodAgeHours = 0;
|
|
176
|
-
report.stale = false;
|
|
177
|
-
return report;
|
|
178
|
-
} catch (error) {
|
|
179
|
-
if (error instanceof FeedGuardError) {
|
|
180
|
-
report.status = 'blocked';
|
|
181
|
-
report.guard = { code: error.code, message: error.message };
|
|
182
|
-
} else {
|
|
183
|
-
report.error = error?.message ?? String(error);
|
|
184
|
-
}
|
|
185
|
-
// keep the rejected file for inspection; the published files stay untouched
|
|
186
|
-
await fs.rename(tmpFile, `${csvFile}.rejected`).catch(() => undefined);
|
|
187
|
-
throw error;
|
|
188
|
-
} finally {
|
|
189
|
-
await this.writeReport(report);
|
|
190
|
-
await guard.releaseLock();
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
private count(report: MarktdeRunReport, images: number, facts: ReturnType<typeof applyMarktdeColumns>): void {
|
|
195
|
-
report.items += 1;
|
|
196
|
-
increment(report.vin, facts.vin);
|
|
197
|
-
if (facts.price.deductible) {
|
|
198
|
-
report.vat.deductible += 1;
|
|
199
|
-
} else {
|
|
200
|
-
report.vat.notDeductible += 1;
|
|
201
|
-
}
|
|
202
|
-
if (facts.price.rateDefaulted) {
|
|
203
|
-
report.vat.rateDefaulted += 1;
|
|
204
|
-
}
|
|
205
|
-
if (facts.fuelEmpty) {
|
|
206
|
-
report.envkv.fuelEmpty += 1;
|
|
207
|
-
}
|
|
208
|
-
if (facts.co2Empty) {
|
|
209
|
-
report.envkv.co2Empty += 1;
|
|
210
|
-
}
|
|
211
|
-
if (facts.descriptionRemovals) {
|
|
212
|
-
report.description.items += 1;
|
|
213
|
-
const r = facts.descriptionRemovals;
|
|
214
|
-
if (r.url + r.email + r.phone + r.address > 0) {
|
|
215
|
-
report.description.sanitized += 1;
|
|
216
|
-
}
|
|
217
|
-
for (const key of Object.keys(r) as Array<keyof typeof r>) {
|
|
218
|
-
report.description.removed[key] += r[key];
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
const stats = report.imagesPerItem;
|
|
222
|
-
stats.min = stats.min === null ? images : Math.min(stats.min, images);
|
|
223
|
-
stats.max = stats.max === null ? images : Math.max(stats.max, images);
|
|
224
|
-
stats.total += images;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
private async writeReport(report: MarktdeRunReport): Promise<void> {
|
|
228
|
-
report.finishedAt = this.now().toISOString();
|
|
229
|
-
await fs.mkdir(this.options.stateDir, { recursive: true });
|
|
230
|
-
await fs.writeFile(join(this.options.stateDir, 'last-run-report.json'), JSON.stringify(report, null, 2), 'utf8');
|
|
231
|
-
console.log(`marktde feed report: ${JSON.stringify(report)}`);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { ImboImageResponseInterface } from '@bytebrand/chunkable';
|
|
4
|
+
import { DataSchemes } from '@bytebrand/car-schema';
|
|
5
|
+
|
|
6
|
+
import { convertToCSV } from '../utils/converter.utils';
|
|
7
|
+
import { MarktdeExclusion, applyMarktdeColumns, getMarktdeExclusion } from '../transformers/marktde.transformer';
|
|
8
|
+
import { FeedGuard, FeedGuardError, LastGood, inspectSourceSnapshot } from './feed-guard';
|
|
9
|
+
|
|
10
|
+
export interface MarktdeSourceCar {
|
|
11
|
+
car: DataSchemes.Car;
|
|
12
|
+
images: ImboImageResponseInterface[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Everything the run needs from the outside world — the Nest exporter wires the real services,
|
|
16
|
+
// tests pass fakes.
|
|
17
|
+
export interface MarktdeRunDeps {
|
|
18
|
+
// iterates the base export snapshot chunk by chunk (LoaderService.process)
|
|
19
|
+
forEachChunk: (fn: (cars: MarktdeSourceCar[]) => Promise<void>) => Promise<unknown>;
|
|
20
|
+
// existing shared partner filter (images count, Cronicle rules incl. the dealer filter)
|
|
21
|
+
filterCar: (source: MarktdeSourceCar) => boolean;
|
|
22
|
+
// final validated image URLs for the car, hero first; [] = no usable image
|
|
23
|
+
resolveImages: (source: MarktdeSourceCar) => Promise<string[]>;
|
|
24
|
+
// the legacy mobile.de-style row (MobileTransformer + mapping sheet), keyed by column index
|
|
25
|
+
legacyRow: (source: MarktdeSourceCar) => Promise<Record<string, unknown>>;
|
|
26
|
+
imageStats: () => Record<string, number>;
|
|
27
|
+
// packs csvFile into zipFile (entry name = basename of csvFile)
|
|
28
|
+
zip: (csvFile: string, zipFile: string) => Promise<void>;
|
|
29
|
+
guard: FeedGuard;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MarktdeRunOptions {
|
|
33
|
+
runId?: string;
|
|
34
|
+
dryRun: boolean;
|
|
35
|
+
domain: string;
|
|
36
|
+
sourceDirectory: string;
|
|
37
|
+
sourceBaseFileName: string;
|
|
38
|
+
csvFile: string;
|
|
39
|
+
zipFile: string;
|
|
40
|
+
stateDir: string;
|
|
41
|
+
now?: () => Date;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MarktdeRunReport {
|
|
45
|
+
runId?: string;
|
|
46
|
+
mode: 'dry-run' | 'live';
|
|
47
|
+
startedAt: string;
|
|
48
|
+
finishedAt?: string;
|
|
49
|
+
status: 'published' | 'blocked' | 'failed';
|
|
50
|
+
guard?: { code: string; message: string };
|
|
51
|
+
error?: string;
|
|
52
|
+
csvFile: string;
|
|
53
|
+
zipFile: string;
|
|
54
|
+
source: { files: number; newestChunkAt: string | null; cars: number };
|
|
55
|
+
items: number;
|
|
56
|
+
dealers: number;
|
|
57
|
+
excluded: Record<string, number>;
|
|
58
|
+
vin: Record<string, number>;
|
|
59
|
+
vat: { deductible: number; notDeductible: number; rateDefaulted: number };
|
|
60
|
+
envkv: { fuelEmpty: number; co2Empty: number };
|
|
61
|
+
description: { items: number; sanitized: number; removed: { url: number; email: number; phone: number; address: number } };
|
|
62
|
+
imagesPerItem: { min: number | null; max: number | null; total: number };
|
|
63
|
+
images: Record<string, number>;
|
|
64
|
+
lastGood: LastGood | null;
|
|
65
|
+
lastGoodAgeHours: number | null;
|
|
66
|
+
stale: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const increment = (counter: Record<string, number>, key: string) => {
|
|
70
|
+
counter[key] = (counter[key] ?? 0) + 1;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One markt.de feed run (ADV-328): source check -> legacy row + markt.de columns + validated images ->
|
|
75
|
+
* temp CSV -> count check -> atomic publish of the CSV -> ZIP of the published CSV -> report.
|
|
76
|
+
* Replaces the old MobileExporter flow for markt.de, which emptied the output directory first (an
|
|
77
|
+
* outage published an empty or partial file) and needed the `zip` binary the DEV host lacks.
|
|
78
|
+
* Nothing outside the output directory and `stateDir` is written; a blocked run leaves the last good
|
|
79
|
+
* CSV/ZIP in place.
|
|
80
|
+
*/
|
|
81
|
+
export class MarktdeFeedRun {
|
|
82
|
+
constructor(private readonly deps: MarktdeRunDeps, private readonly options: MarktdeRunOptions) { }
|
|
83
|
+
|
|
84
|
+
private now(): Date {
|
|
85
|
+
return this.options.now ? this.options.now() : new Date();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async run(): Promise<MarktdeRunReport> {
|
|
89
|
+
const { guard } = this.deps;
|
|
90
|
+
const { csvFile, zipFile, runId, dryRun } = this.options;
|
|
91
|
+
const startedAt = this.now();
|
|
92
|
+
const lastGood = await guard.readLastGood();
|
|
93
|
+
const report: MarktdeRunReport = {
|
|
94
|
+
runId,
|
|
95
|
+
mode: dryRun ? 'dry-run' : 'live',
|
|
96
|
+
startedAt: startedAt.toISOString(),
|
|
97
|
+
status: 'failed',
|
|
98
|
+
csvFile,
|
|
99
|
+
zipFile,
|
|
100
|
+
source: { files: 0, newestChunkAt: null, cars: 0 },
|
|
101
|
+
items: 0,
|
|
102
|
+
dealers: 0,
|
|
103
|
+
excluded: {},
|
|
104
|
+
vin: {},
|
|
105
|
+
vat: { deductible: 0, notDeductible: 0, rateDefaulted: 0 },
|
|
106
|
+
envkv: { fuelEmpty: 0, co2Empty: 0 },
|
|
107
|
+
description: { items: 0, sanitized: 0, removed: { url: 0, email: 0, phone: 0, address: 0 } },
|
|
108
|
+
imagesPerItem: { min: null, max: null, total: 0 },
|
|
109
|
+
images: {},
|
|
110
|
+
lastGood,
|
|
111
|
+
lastGoodAgeHours: guard.staleHours(lastGood, startedAt),
|
|
112
|
+
stale: guard.isStale(lastGood, startedAt),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
await guard.acquireLock(runId ?? 'local', startedAt);
|
|
116
|
+
const tmpFile = `${csvFile}.tmp`;
|
|
117
|
+
try {
|
|
118
|
+
const snapshot = await inspectSourceSnapshot(this.options.sourceDirectory, this.options.sourceBaseFileName);
|
|
119
|
+
report.source.files = snapshot.files;
|
|
120
|
+
report.source.newestChunkAt = snapshot.newestMtime?.toISOString() ?? null;
|
|
121
|
+
guard.checkSource(snapshot, startedAt);
|
|
122
|
+
|
|
123
|
+
await fs.mkdir(dirname(csvFile), { recursive: true });
|
|
124
|
+
await fs.rm(tmpFile, { force: true });
|
|
125
|
+
const out = await fs.open(tmpFile, 'w');
|
|
126
|
+
const seen = new Set<string>();
|
|
127
|
+
const dealers = new Set<string>();
|
|
128
|
+
try {
|
|
129
|
+
await this.deps.forEachChunk(async (cars) => {
|
|
130
|
+
for (const source of cars) {
|
|
131
|
+
report.source.cars += 1;
|
|
132
|
+
const id = String(source?.car?._id ?? '');
|
|
133
|
+
if (id && seen.has(id)) {
|
|
134
|
+
increment(report.excluded, MarktdeExclusion.duplicateId);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
seen.add(id);
|
|
138
|
+
if (!this.deps.filterCar(source)) {
|
|
139
|
+
increment(report.excluded, MarktdeExclusion.filtered);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// cheap checks first: no image probe for a car that is excluded anyway
|
|
143
|
+
const excluded = getMarktdeExclusion(source.car);
|
|
144
|
+
if (excluded) {
|
|
145
|
+
increment(report.excluded, excluded);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const images = await this.deps.resolveImages(source);
|
|
149
|
+
if (!images.length) {
|
|
150
|
+
increment(report.excluded, MarktdeExclusion.noImage);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const row = await this.deps.legacyRow(source);
|
|
154
|
+
const facts = applyMarktdeColumns(row, source.car, { domain: this.options.domain, images });
|
|
155
|
+
await out.write(`${convertToCSV(row)}\n`, null, 'utf8');
|
|
156
|
+
dealers.add(String(source.car?.metaData?.ownerId ?? ''));
|
|
157
|
+
this.count(report, images.length, facts);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
} finally {
|
|
161
|
+
await out.close();
|
|
162
|
+
}
|
|
163
|
+
report.dealers = dealers.size;
|
|
164
|
+
report.images = this.deps.imageStats();
|
|
165
|
+
|
|
166
|
+
const counts = { sourceItems: report.source.cars, publishedItems: report.items };
|
|
167
|
+
guard.checkCounts(counts, lastGood);
|
|
168
|
+
|
|
169
|
+
report.lastGood = await guard.publish(tmpFile, csvFile, counts, this.now(), runId);
|
|
170
|
+
const zipTmp = zipFile.replace(/\.zip$/, '.tmp.zip');
|
|
171
|
+
await fs.rm(zipTmp, { force: true });
|
|
172
|
+
await this.deps.zip(csvFile, zipTmp);
|
|
173
|
+
await fs.rename(zipTmp, zipFile);
|
|
174
|
+
report.status = 'published';
|
|
175
|
+
report.lastGoodAgeHours = 0;
|
|
176
|
+
report.stale = false;
|
|
177
|
+
return report;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error instanceof FeedGuardError) {
|
|
180
|
+
report.status = 'blocked';
|
|
181
|
+
report.guard = { code: error.code, message: error.message };
|
|
182
|
+
} else {
|
|
183
|
+
report.error = error?.message ?? String(error);
|
|
184
|
+
}
|
|
185
|
+
// keep the rejected file for inspection; the published files stay untouched
|
|
186
|
+
await fs.rename(tmpFile, `${csvFile}.rejected`).catch(() => undefined);
|
|
187
|
+
throw error;
|
|
188
|
+
} finally {
|
|
189
|
+
await this.writeReport(report);
|
|
190
|
+
await guard.releaseLock();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private count(report: MarktdeRunReport, images: number, facts: ReturnType<typeof applyMarktdeColumns>): void {
|
|
195
|
+
report.items += 1;
|
|
196
|
+
increment(report.vin, facts.vin);
|
|
197
|
+
if (facts.price.deductible) {
|
|
198
|
+
report.vat.deductible += 1;
|
|
199
|
+
} else {
|
|
200
|
+
report.vat.notDeductible += 1;
|
|
201
|
+
}
|
|
202
|
+
if (facts.price.rateDefaulted) {
|
|
203
|
+
report.vat.rateDefaulted += 1;
|
|
204
|
+
}
|
|
205
|
+
if (facts.fuelEmpty) {
|
|
206
|
+
report.envkv.fuelEmpty += 1;
|
|
207
|
+
}
|
|
208
|
+
if (facts.co2Empty) {
|
|
209
|
+
report.envkv.co2Empty += 1;
|
|
210
|
+
}
|
|
211
|
+
if (facts.descriptionRemovals) {
|
|
212
|
+
report.description.items += 1;
|
|
213
|
+
const r = facts.descriptionRemovals;
|
|
214
|
+
if (r.url + r.email + r.phone + r.address > 0) {
|
|
215
|
+
report.description.sanitized += 1;
|
|
216
|
+
}
|
|
217
|
+
for (const key of Object.keys(r) as Array<keyof typeof r>) {
|
|
218
|
+
report.description.removed[key] += r[key];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const stats = report.imagesPerItem;
|
|
222
|
+
stats.min = stats.min === null ? images : Math.min(stats.min, images);
|
|
223
|
+
stats.max = stats.max === null ? images : Math.max(stats.max, images);
|
|
224
|
+
stats.total += images;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async writeReport(report: MarktdeRunReport): Promise<void> {
|
|
228
|
+
report.finishedAt = this.now().toISOString();
|
|
229
|
+
await fs.mkdir(this.options.stateDir, { recursive: true });
|
|
230
|
+
await fs.writeFile(join(this.options.stateDir, 'last-run-report.json'), JSON.stringify(report, null, 2), 'utf8');
|
|
231
|
+
console.log(`marktde feed report: ${JSON.stringify(report)}`);
|
|
232
|
+
}
|
|
233
|
+
}
|