@bytebrand/export-cars 1.26.31 → 1.26.32
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/autouncle.config.d.ts +14 -0
- package/dist/configs/autouncle.config.js +36 -3
- package/dist/configs/autouncle.config.js.map +1 -1
- package/dist/configs/meinstadt.config.d.ts +15 -0
- package/dist/configs/meinstadt.config.js +42 -3
- package/dist/configs/meinstadt.config.js.map +1 -1
- package/dist/constants/autouncle.contants.d.ts +1 -3
- package/dist/constants/autouncle.contants.js +2 -4
- package/dist/constants/autouncle.contants.js.map +1 -1
- package/dist/exporters/autouncle.exporter.d.ts +3 -2
- package/dist/exporters/autouncle.exporter.js +45 -27
- package/dist/exporters/autouncle.exporter.js.map +1 -1
- package/dist/exporters/meinstadt.exporter.d.ts +3 -2
- package/dist/exporters/meinstadt.exporter.js +40 -23
- package/dist/exporters/meinstadt.exporter.js.map +1 -1
- package/dist/formats/autouncle-json.builder.js +1 -4
- package/dist/formats/autouncle-json.builder.js.map +1 -1
- package/dist/interfaces/export-item.interface.d.ts +8 -12
- package/dist/interfaces/params.interface.d.ts +6 -0
- package/dist/services/autouncle-feed-run.d.ts +66 -0
- package/dist/services/autouncle-feed-run.js +135 -0
- package/dist/services/autouncle-feed-run.js.map +1 -0
- package/dist/services/meinstadt-feed-run.d.ts +81 -0
- package/dist/services/meinstadt-feed-run.js +166 -0
- package/dist/services/meinstadt-feed-run.js.map +1 -0
- package/dist/transformers/autouncle.transformer.d.ts +38 -22
- package/dist/transformers/autouncle.transformer.js +156 -144
- package/dist/transformers/autouncle.transformer.js.map +1 -1
- package/dist/transformers/meinstadt.transformer.d.ts +17 -13
- package/dist/transformers/meinstadt.transformer.js +88 -61
- package/dist/transformers/meinstadt.transformer.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/autouncle/AUDIT.md +97 -0
- package/docs/autouncle/PARTNER-QUESTIONS.md +53 -0
- package/docs/autouncle/RUNBOOK.md +64 -0
- package/docs/meinestadt/AUDIT.md +146 -0
- package/docs/meinestadt/PARTNER-QUESTIONS.md +54 -0
- package/docs/meinestadt/RUNBOOK.md +64 -0
- package/package.json +3 -2
- package/src/configs/autouncle.config.ts +51 -3
- package/src/configs/meinstadt.config.ts +57 -2
- package/src/constants/autouncle.contants.ts +2 -3
- package/src/exporters/autouncle.exporter.ts +59 -37
- package/src/exporters/meinstadt.exporter.ts +53 -32
- package/src/formats/autouncle-json.builder.ts +2 -4
- package/src/interfaces/export-item.interface.ts +15 -12
- package/src/interfaces/params.interface.ts +10 -0
- package/src/services/autouncle-feed-run.ts +197 -0
- package/src/services/meinstadt-feed-run.ts +240 -0
- package/src/transformers/autouncle.transformer.ts +217 -175
- package/src/transformers/meinstadt.transformer.ts +114 -68
- package/test/autouncle-feed-run.test.ts +256 -0
- package/test/autouncle-mapping.test.ts +225 -0
- package/test/fixtures/meinstadt.fixture.ts +87 -0
- package/test/meinstadt-feed-run.test.ts +288 -0
- package/test/meinstadt-mapping.test.ts +215 -0
|
@@ -0,0 +1,240 @@
|
|
|
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 { MeinstadtJsonBuilder } from '../formats/meinstadt-json.builder';
|
|
7
|
+
import { MeinstadtJsonItem } from '../interfaces';
|
|
8
|
+
import { mapMeinstadtItem } from '../transformers/meinstadt.transformer';
|
|
9
|
+
import { FeedGuard, FeedGuardError, LastGood, inspectSourceSnapshot } from './feed-guard';
|
|
10
|
+
|
|
11
|
+
export interface MeinstadtSourceCar {
|
|
12
|
+
car: DataSchemes.Car;
|
|
13
|
+
images: ImboImageResponseInterface[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export enum MeinstadtExclusion {
|
|
17
|
+
duplicateId = 'duplicate_id',
|
|
18
|
+
filtered = 'filtered',
|
|
19
|
+
noVehicleId = 'no_vehicle_id',
|
|
20
|
+
noImage = 'no_valid_image',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class MeinstadtConfigError extends Error {
|
|
24
|
+
constructor(readonly code: string, message: string) {
|
|
25
|
+
super(`[meinstadt:${code}] ${message}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Everything the run needs from the outside world — the Nest exporter wires the real services,
|
|
30
|
+
// tests pass fakes.
|
|
31
|
+
export interface MeinstadtRunDeps {
|
|
32
|
+
// iterates the base export snapshot chunk by chunk (LoaderService.process)
|
|
33
|
+
forEachChunk: (fn: (cars: MeinstadtSourceCar[]) => Promise<void>) => Promise<unknown>;
|
|
34
|
+
// existing shared partner filter (images count, Cronicle rules)
|
|
35
|
+
filterCar: (source: MeinstadtSourceCar) => boolean;
|
|
36
|
+
// final validated image URLs for the car, hero first; [] = no usable image
|
|
37
|
+
resolveImages: (source: MeinstadtSourceCar) => Promise<string[]>;
|
|
38
|
+
imageStats: () => Record<string, number>;
|
|
39
|
+
equipment: (car: DataSchemes.Car) => Promise<string>;
|
|
40
|
+
// packs jsonFile into zipFile (entry name = basename of jsonFile)
|
|
41
|
+
zip: (jsonFile: string, zipFile: string) => Promise<void>;
|
|
42
|
+
guard: FeedGuard;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MeinstadtRunOptions {
|
|
46
|
+
runId?: string;
|
|
47
|
+
dryRun: boolean;
|
|
48
|
+
cpc: string | null;
|
|
49
|
+
domain: string;
|
|
50
|
+
sourceDirectory: string;
|
|
51
|
+
sourceBaseFileName: string;
|
|
52
|
+
jsonFile: string;
|
|
53
|
+
zipFile: string;
|
|
54
|
+
stateDir: string;
|
|
55
|
+
translate?: (key: string) => string | undefined;
|
|
56
|
+
now?: () => Date;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface MeinstadtRunReport {
|
|
60
|
+
runId?: string;
|
|
61
|
+
mode: 'dry-run' | 'live';
|
|
62
|
+
startedAt: string;
|
|
63
|
+
finishedAt?: string;
|
|
64
|
+
status: 'published' | 'blocked' | 'failed';
|
|
65
|
+
guard?: { code: string; message: string };
|
|
66
|
+
error?: string;
|
|
67
|
+
cpc: string | null;
|
|
68
|
+
jsonFile: string;
|
|
69
|
+
zipFile: string;
|
|
70
|
+
source: { files: number; newestChunkAt: string | null; cars: number };
|
|
71
|
+
items: number;
|
|
72
|
+
excluded: Record<string, number>;
|
|
73
|
+
accidentFree: Record<string, number>;
|
|
74
|
+
imagesPerItem: { min: number | null; max: number | null; total: number };
|
|
75
|
+
images: Record<string, number>;
|
|
76
|
+
lastGood: LastGood | null;
|
|
77
|
+
lastGoodAgeHours: number | null;
|
|
78
|
+
stale: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
82
|
+
const AdmZip = require('adm-zip');
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Packs one file into a new ZIP, entry name = the file's basename. In-process (adm-zip, the same
|
|
86
|
+
* library @bytebrand/zipper uses for unzipping): Zipper.zip shells out to the `zip` binary, which
|
|
87
|
+
* the DEV Cronicle host does not have (13.10.2026: only unzip).
|
|
88
|
+
*/
|
|
89
|
+
export const zipSingleFile = async (file: string, zipFile: string): Promise<void> => {
|
|
90
|
+
const archive = new AdmZip();
|
|
91
|
+
archive.addLocalFile(file);
|
|
92
|
+
archive.writeZip(zipFile);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const increment = (counter: Record<string, number>, key: string) => {
|
|
96
|
+
counter[key] = (counter[key] ?? 0) + 1;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One meinestadt feed run (ADV-325/326): config check -> source check -> map + validated images ->
|
|
101
|
+
* temp JSON -> count check -> atomic publish of the JSON -> ZIP of the published JSON -> report.
|
|
102
|
+
* Nothing outside the output directory and `stateDir` is written; a blocked run leaves the last
|
|
103
|
+
* good JSON/ZIP in place. The Cronicle upload step only ever reads the published files.
|
|
104
|
+
*/
|
|
105
|
+
export class MeinstadtFeedRun {
|
|
106
|
+
constructor(private readonly deps: MeinstadtRunDeps, private readonly options: MeinstadtRunOptions) { }
|
|
107
|
+
|
|
108
|
+
private now(): Date {
|
|
109
|
+
return this.options.now ? this.options.now() : new Date();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async run(): Promise<MeinstadtRunReport> {
|
|
113
|
+
const { guard } = this.deps;
|
|
114
|
+
const { jsonFile, zipFile, stateDir, runId, dryRun, cpc } = this.options;
|
|
115
|
+
const startedAt = this.now();
|
|
116
|
+
const lastGood = await guard.readLastGood();
|
|
117
|
+
const report: MeinstadtRunReport = {
|
|
118
|
+
runId,
|
|
119
|
+
mode: dryRun ? 'dry-run' : 'live',
|
|
120
|
+
startedAt: startedAt.toISOString(),
|
|
121
|
+
status: 'failed',
|
|
122
|
+
cpc,
|
|
123
|
+
jsonFile,
|
|
124
|
+
zipFile,
|
|
125
|
+
source: { files: 0, newestChunkAt: null, cars: 0 },
|
|
126
|
+
items: 0,
|
|
127
|
+
excluded: {},
|
|
128
|
+
accidentFree: {},
|
|
129
|
+
imagesPerItem: { min: null, max: null, total: 0 },
|
|
130
|
+
images: {},
|
|
131
|
+
lastGood,
|
|
132
|
+
lastGoodAgeHours: guard.staleHours(lastGood, startedAt),
|
|
133
|
+
stale: guard.isStale(lastGood, startedAt),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// A live feed without a tariff is a configuration error, not a 0 / historical 0.08 bid.
|
|
137
|
+
if (!dryRun && !cpc) {
|
|
138
|
+
report.error = 'cpc_missing';
|
|
139
|
+
await this.writeReport(report);
|
|
140
|
+
throw new MeinstadtConfigError('cpc_missing', 'live meinestadt feed needs a configured cpc (positive decimal, e.g. "0.99")');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await guard.acquireLock(runId ?? 'local', startedAt);
|
|
144
|
+
const tmpFile = `${jsonFile}.tmp`;
|
|
145
|
+
try {
|
|
146
|
+
const snapshot = await inspectSourceSnapshot(this.options.sourceDirectory, this.options.sourceBaseFileName);
|
|
147
|
+
report.source.files = snapshot.files;
|
|
148
|
+
report.source.newestChunkAt = snapshot.newestMtime?.toISOString() ?? null;
|
|
149
|
+
guard.checkSource(snapshot, startedAt);
|
|
150
|
+
|
|
151
|
+
await fs.mkdir(dirname(jsonFile), { recursive: true });
|
|
152
|
+
// the builder appends — never onto a leftover temp file
|
|
153
|
+
await fs.rm(tmpFile, { force: true });
|
|
154
|
+
const builder = new MeinstadtJsonBuilder(tmpFile);
|
|
155
|
+
await builder.buildStart();
|
|
156
|
+
|
|
157
|
+
const seen = new Set<string>();
|
|
158
|
+
let first = true;
|
|
159
|
+
await this.deps.forEachChunk(async (cars) => {
|
|
160
|
+
for (const source of cars) {
|
|
161
|
+
report.source.cars += 1;
|
|
162
|
+
const id = String(source?.car?._id ?? '');
|
|
163
|
+
if (id && seen.has(id)) {
|
|
164
|
+
increment(report.excluded, MeinstadtExclusion.duplicateId);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
seen.add(id);
|
|
168
|
+
if (!this.deps.filterCar(source)) {
|
|
169
|
+
increment(report.excluded, MeinstadtExclusion.filtered);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!source.car?.metaData?.vehicleId) {
|
|
173
|
+
increment(report.excluded, MeinstadtExclusion.noVehicleId);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const images = await this.deps.resolveImages(source);
|
|
177
|
+
if (!images.length) {
|
|
178
|
+
increment(report.excluded, MeinstadtExclusion.noImage);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const item = mapMeinstadtItem(source.car, {
|
|
182
|
+
domain: this.options.domain,
|
|
183
|
+
cpc,
|
|
184
|
+
equipment: await this.deps.equipment(source.car),
|
|
185
|
+
images,
|
|
186
|
+
translate: this.options.translate,
|
|
187
|
+
});
|
|
188
|
+
await builder.addItem(item, first);
|
|
189
|
+
first = false;
|
|
190
|
+
this.count(report, item);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
await builder.buildEnd();
|
|
194
|
+
report.images = this.deps.imageStats();
|
|
195
|
+
|
|
196
|
+
const counts = { sourceItems: report.source.cars, publishedItems: report.items };
|
|
197
|
+
guard.checkCounts(counts, lastGood);
|
|
198
|
+
|
|
199
|
+
report.lastGood = await guard.publish(tmpFile, jsonFile, counts, this.now(), runId);
|
|
200
|
+
const zipTmp = zipFile.replace(/\.zip$/, '.tmp.zip');
|
|
201
|
+
await fs.rm(zipTmp, { force: true });
|
|
202
|
+
await this.deps.zip(jsonFile, zipTmp);
|
|
203
|
+
await fs.rename(zipTmp, zipFile);
|
|
204
|
+
report.status = 'published';
|
|
205
|
+
report.lastGoodAgeHours = 0;
|
|
206
|
+
report.stale = false;
|
|
207
|
+
return report;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (error instanceof FeedGuardError) {
|
|
210
|
+
report.status = 'blocked';
|
|
211
|
+
report.guard = { code: error.code, message: error.message };
|
|
212
|
+
} else {
|
|
213
|
+
report.error = error?.message ?? String(error);
|
|
214
|
+
}
|
|
215
|
+
// keep the rejected file for inspection; the published files stay untouched
|
|
216
|
+
await fs.rename(tmpFile, `${jsonFile}.rejected`).catch(() => undefined);
|
|
217
|
+
throw error;
|
|
218
|
+
} finally {
|
|
219
|
+
await this.writeReport(report);
|
|
220
|
+
await guard.releaseLock();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private count(report: MeinstadtRunReport, item: MeinstadtJsonItem): void {
|
|
225
|
+
report.items += 1;
|
|
226
|
+
increment(report.accidentFree, String(item.accidentFree));
|
|
227
|
+
const n = item.images.length;
|
|
228
|
+
const stats = report.imagesPerItem;
|
|
229
|
+
stats.min = stats.min === null ? n : Math.min(stats.min, n);
|
|
230
|
+
stats.max = stats.max === null ? n : Math.max(stats.max, n);
|
|
231
|
+
stats.total += n;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async writeReport(report: MeinstadtRunReport): Promise<void> {
|
|
235
|
+
report.finishedAt = this.now().toISOString();
|
|
236
|
+
await fs.mkdir(this.options.stateDir, { recursive: true });
|
|
237
|
+
await fs.writeFile(join(this.options.stateDir, 'last-run-report.json'), JSON.stringify(report, null, 2), 'utf8');
|
|
238
|
+
console.log(`meinestadt feed report: ${JSON.stringify(report)}`);
|
|
239
|
+
}
|
|
240
|
+
}
|