@bytebrand/export-cars 1.26.27 → 1.26.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -0
- package/dist/configs/config.d.ts +2 -1
- package/dist/configs/config.js +1 -0
- package/dist/configs/config.js.map +1 -1
- package/dist/configs/google.config.js +16 -0
- package/dist/configs/google.config.js.map +1 -1
- package/dist/configs/index.d.ts +1 -0
- package/dist/configs/index.js +1 -0
- package/dist/configs/index.js.map +1 -1
- package/dist/configs/openai-ads.config.d.ts +22 -0
- package/dist/configs/openai-ads.config.js +56 -0
- package/dist/configs/openai-ads.config.js.map +1 -0
- package/dist/enums/export-type.enum.d.ts +2 -1
- package/dist/enums/export-type.enum.js +1 -0
- package/dist/enums/export-type.enum.js.map +1 -1
- package/dist/enums/image-type.enum.d.ts +2 -1
- package/dist/enums/image-type.enum.js +1 -0
- package/dist/enums/image-type.enum.js.map +1 -1
- package/dist/export.schema.d.ts +6 -2
- package/dist/export.schema.js +4 -0
- package/dist/export.schema.js.map +1 -1
- package/dist/exporters/google.exporter.js +12 -2
- package/dist/exporters/google.exporter.js.map +1 -1
- package/dist/exporters/index.d.ts +1 -0
- package/dist/exporters/index.js +1 -0
- package/dist/exporters/index.js.map +1 -1
- package/dist/exporters/openai-ads.exporter.d.ts +13 -0
- package/dist/exporters/openai-ads.exporter.js +91 -0
- package/dist/exporters/openai-ads.exporter.js.map +1 -0
- package/dist/formats/openai-ads-csv.builder.d.ts +15 -0
- package/dist/formats/openai-ads-csv.builder.js +48 -0
- package/dist/formats/openai-ads-csv.builder.js.map +1 -0
- package/dist/interfaces/openai-ads.interface.d.ts +35 -0
- package/dist/interfaces/openai-ads.interface.js +37 -0
- package/dist/interfaces/openai-ads.interface.js.map +1 -0
- package/dist/interfaces/params.interface.d.ts +20 -0
- package/dist/services/feed-guard.d.ts +44 -0
- package/dist/services/feed-guard.js +156 -0
- package/dist/services/feed-guard.js.map +1 -0
- package/dist/services/image-url-validator.service.d.ts +45 -0
- package/dist/services/image-url-validator.service.js +141 -0
- package/dist/services/image-url-validator.service.js.map +1 -0
- package/dist/services/images.service.d.ts +12 -0
- package/dist/services/images.service.js +46 -1
- package/dist/services/images.service.js.map +1 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/openai-ads-feed-run.d.ts +74 -0
- package/dist/services/openai-ads-feed-run.js +151 -0
- package/dist/services/openai-ads-feed-run.js.map +1 -0
- package/dist/services/openai-ads-ledger.d.ts +19 -0
- package/dist/services/openai-ads-ledger.js +72 -0
- package/dist/services/openai-ads-ledger.js.map +1 -0
- package/dist/services/publication-gate.d.ts +15 -0
- package/dist/services/publication-gate.js +107 -0
- package/dist/services/publication-gate.js.map +1 -0
- package/dist/transformers/google-feed-envkv.d.ts +2 -0
- package/dist/transformers/google-feed-envkv.js +62 -31
- package/dist/transformers/google-feed-envkv.js.map +1 -1
- package/dist/transformers/google-feed-item-row.d.ts +6 -1
- package/dist/transformers/google-feed-item-row.js +3 -1
- package/dist/transformers/google-feed-item-row.js.map +1 -1
- package/dist/transformers/openai-ads.transformer.d.ts +28 -0
- package/dist/transformers/openai-ads.transformer.js +144 -0
- package/dist/transformers/openai-ads.transformer.js.map +1 -0
- package/dist/transformers/openai-image-fields.d.ts +16 -0
- package/dist/transformers/openai-image-fields.js +34 -0
- package/dist/transformers/openai-image-fields.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/openai-ads/AUDIT.md +235 -0
- package/docs/openai-ads/RUNBOOK.md +94 -0
- package/package.json +64 -64
- package/schema/feed-items.json +134 -122
- package/src/configs/config.ts +99 -97
- package/src/configs/google.config.ts +41 -26
- package/src/configs/index.ts +1 -0
- package/src/configs/openai-ads.config.ts +70 -0
- package/src/enums/export-type.enum.ts +2 -0
- package/src/enums/image-type.enum.ts +5 -0
- package/src/export.command.ts +38 -38
- package/src/export.schema.ts +6 -0
- package/src/exporters/google.exporter.ts +67 -56
- package/src/exporters/index.ts +1 -0
- package/src/exporters/openai-ads.exporter.ts +93 -0
- package/src/formats/openai-ads-csv.builder.ts +55 -0
- package/src/interfaces/openai-ads.interface.ts +64 -0
- package/src/interfaces/params.interface.ts +56 -29
- package/src/services/feed-guard.ts +219 -0
- package/src/services/feed-items-bigquery.writer.ts +101 -101
- package/src/services/image-url-validator.service.ts +181 -0
- package/src/services/images.service.ts +72 -0
- package/src/services/index.ts +1 -0
- package/src/services/openai-ads-feed-run.ts +226 -0
- package/src/services/openai-ads-ledger.ts +116 -0
- package/src/services/publication-gate.ts +119 -0
- package/src/transformers/google-feed-envkv.ts +81 -33
- package/src/transformers/google-feed-item-row.ts +78 -71
- package/src/transformers/openai-ads.transformer.ts +211 -0
- package/src/transformers/openai-image-fields.ts +60 -0
- package/test/clean-images.test.ts +86 -0
- package/test/fixtures/images-service.harness.ts +32 -0
- package/test/fixtures/images.golden.json +218 -0
- package/test/fixtures/imbo-images.fixture.ts +77 -0
- package/test/fixtures/openai-ads.fixture.ts +89 -0
- package/test/google-feed-item-row.test.ts +135 -121
- package/test/image-url-validator.test.ts +110 -0
- package/test/images-regression.test.ts +23 -0
- package/test/openai-ads-feed-run.test.ts +278 -0
- package/test/openai-ads-mapping.test.ts +136 -0
- package/test/openai-image-fields.test.ts +99 -0
- package/test/publication-gate.test.ts +86 -0
|
@@ -15,6 +15,20 @@ import { isNotEmptyArray } from '../utils';
|
|
|
15
15
|
import { Config } from '../configs/config';
|
|
16
16
|
import { ImageSize, ImageType } from '../enums';
|
|
17
17
|
|
|
18
|
+
export interface CleanImage {
|
|
19
|
+
url: string;
|
|
20
|
+
imageIdentifier: string;
|
|
21
|
+
imageCode: string;
|
|
22
|
+
// 3/4 exterior shot that may serve as the main image (image_url)
|
|
23
|
+
heroCandidate: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Hero candidates in priority order: the 3/4 front views (ext_f_right first, matching IMAGES_SORT_ORDER).
|
|
27
|
+
export const CLEAN_HERO_IMAGE_CODES = ['ext_f_right', 'ext_f_left'];
|
|
28
|
+
// Explicit JPEG variant — the extension-less `large` URL negotiates WebP from the Accept header
|
|
29
|
+
// (no Vary), so a crawler could get a different format than the one we validated.
|
|
30
|
+
export const CLEAN_IMAGE_VARIANT = 'large-cached.jpeg';
|
|
31
|
+
|
|
18
32
|
@Injectable()
|
|
19
33
|
export class ImagesService {
|
|
20
34
|
private readonly IMAGES_SORT_ORDER: { [imageCode: string]: number } = {
|
|
@@ -90,6 +104,9 @@ export class ImagesService {
|
|
|
90
104
|
async getImages(car: DataSchemes.Car, images: ImboImageResponseInterface[], imageType: ImageType, ignoreDefImages: boolean): Promise<Array<string>> {
|
|
91
105
|
if (imageType === ImageType.default) {
|
|
92
106
|
return this.getDefaultImages(car._id, images, ignoreDefImages);
|
|
107
|
+
} else if (imageType === ImageType.clean) {
|
|
108
|
+
// never padded with placeholders, whatever ignoreDefImages says
|
|
109
|
+
return this.getCleanImageUrls(car._id, images);
|
|
93
110
|
} else {
|
|
94
111
|
return await this.getMarketingImages(car._id, images, ignoreDefImages);
|
|
95
112
|
}
|
|
@@ -175,6 +192,61 @@ export class ImagesService {
|
|
|
175
192
|
});
|
|
176
193
|
}
|
|
177
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Clean channel profile (ADV-336): the car's own photos only, as explicit JPEG URLs, in a
|
|
197
|
+
* deterministic order — 3/4 exterior hero first, then the remaining exterior views, then the
|
|
198
|
+
* interior views (IMAGES_SORT_ORDER). Feature close-ups (`_feat_`), `conext_*` and codes the
|
|
199
|
+
* sort order doesn't know (simple_*, promo, ...) are dropped; so are repeated imageIdentifiers.
|
|
200
|
+
* Returns [] when the car has no 3/4 exterior shot — callers skip the car, never substitute a
|
|
201
|
+
* placeholder. Does not validate the URLs (see ImageUrlValidator) and never mutates `images`.
|
|
202
|
+
*/
|
|
203
|
+
getCleanImages(carId: string, images: ImboImageResponseInterface[]): CleanImage[] {
|
|
204
|
+
if (!isNotEmptyArray(images)) {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const seen = new Set<string>();
|
|
209
|
+
const eligible = images.filter((image) => {
|
|
210
|
+
const imageCode = this.getImageCode(image);
|
|
211
|
+
if (!image?.imageIdentifier || !imageCode || seen.has(image.imageIdentifier) || !this.isCleanImageCode(imageCode)) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
seen.add(image.imageIdentifier);
|
|
215
|
+
return true;
|
|
216
|
+
});
|
|
217
|
+
const sorted = this.sortImages(eligible);
|
|
218
|
+
|
|
219
|
+
const heroIndex = CLEAN_HERO_IMAGE_CODES
|
|
220
|
+
.map(code => sorted.findIndex(image => this.getImageCode(image) === code))
|
|
221
|
+
.find(index => index >= 0);
|
|
222
|
+
if (heroIndex === undefined) {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const ordered = [sorted[heroIndex], ...sorted.filter((_, index) => index !== heroIndex)];
|
|
227
|
+
return ordered.map((image) => {
|
|
228
|
+
const imageCode = this.getImageCode(image);
|
|
229
|
+
return {
|
|
230
|
+
url: `${this.configService.config.baseImageUrl}/carimage/${carId}/${image.imageIdentifier}/${CLEAN_IMAGE_VARIANT}`,
|
|
231
|
+
imageIdentifier: image.imageIdentifier,
|
|
232
|
+
imageCode,
|
|
233
|
+
heroCandidate: CLEAN_HERO_IMAGE_CODES.includes(imageCode),
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
getCleanImageUrls(carId: string, images: ImboImageResponseInterface[]): string[] {
|
|
239
|
+
return this.getCleanImages(carId, images).map(image => image.url);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private isCleanImageCode(imageCode: string): boolean {
|
|
243
|
+
return /^(ext|int)_/.test(imageCode) && !imageCode.includes('_feat_') && imageCode in this.IMAGES_SORT_ORDER;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private getImageCode(image: ImboImageResponseInterface): string | undefined {
|
|
247
|
+
return image?.imageCodePredicted?.split('-')[0];
|
|
248
|
+
}
|
|
249
|
+
|
|
178
250
|
async getMarketingImages(carId: string, images: ImboImageResponseInterface[], ignoreDefImages: boolean): Promise<string[]> {
|
|
179
251
|
const imageUrl = this.configService.config.marketingImageUrl;
|
|
180
252
|
|
package/src/services/index.ts
CHANGED
|
@@ -0,0 +1,226 @@
|
|
|
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 { OpenAiAdsCsvBuilder } from '../formats/openai-ads-csv.builder';
|
|
7
|
+
import {
|
|
8
|
+
OpenAiAdsExclusion,
|
|
9
|
+
OpenAiAdsOutOfStockReason,
|
|
10
|
+
OpenAiAdsRow,
|
|
11
|
+
} from '../interfaces/openai-ads.interface';
|
|
12
|
+
import {
|
|
13
|
+
OpenAiAdsMappingContext,
|
|
14
|
+
mapOpenAiAdsVehicle,
|
|
15
|
+
withOpenAiImages,
|
|
16
|
+
} from '../transformers/openai-ads.transformer';
|
|
17
|
+
import { OpenAiImageSelection } from '../transformers/openai-image-fields';
|
|
18
|
+
import { FeedGuard, FeedGuardError, LastGood, inspectSourceSnapshot } from './feed-guard';
|
|
19
|
+
import { OpenAiAdsLedger } from './openai-ads-ledger';
|
|
20
|
+
|
|
21
|
+
export interface SourceCar {
|
|
22
|
+
car: DataSchemes.Car;
|
|
23
|
+
images: ImboImageResponseInterface[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type DealerStatus = 'active' | 'inactive' | 'unknown';
|
|
27
|
+
|
|
28
|
+
// Everything the run needs from the outside world — the Nest exporter wires the real services,
|
|
29
|
+
// tests pass fakes.
|
|
30
|
+
export interface OpenAiAdsRunDeps {
|
|
31
|
+
// iterates the base export snapshot chunk by chunk (LoaderService.process)
|
|
32
|
+
forEachChunk: (fn: (cars: SourceCar[]) => Promise<void>) => Promise<unknown>;
|
|
33
|
+
// existing shared partner filter (images count, optional price cap, Cronicle rules)
|
|
34
|
+
filterCar: (source: SourceCar) => boolean;
|
|
35
|
+
dealerStatus: (ownerId: string) => Promise<DealerStatus>;
|
|
36
|
+
// ADV-336 clean images, validated
|
|
37
|
+
resolveImages: (source: SourceCar) => Promise<OpenAiImageSelection>;
|
|
38
|
+
imageStats: () => Record<string, number>;
|
|
39
|
+
guard: FeedGuard;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface OpenAiAdsRunOptions {
|
|
43
|
+
runId?: string;
|
|
44
|
+
dryRun: boolean;
|
|
45
|
+
sourceDirectory: string;
|
|
46
|
+
sourceBaseFileName: string;
|
|
47
|
+
outputFile: string;
|
|
48
|
+
stateDir: string;
|
|
49
|
+
retentionDays: number;
|
|
50
|
+
mapping: Omit<OpenAiAdsMappingContext, 'now'>;
|
|
51
|
+
now?: () => Date;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface OpenAiAdsRunReport {
|
|
55
|
+
runId?: string;
|
|
56
|
+
mode: 'dry-run' | 'live';
|
|
57
|
+
startedAt: string;
|
|
58
|
+
finishedAt?: string;
|
|
59
|
+
status: 'published' | 'blocked' | 'failed';
|
|
60
|
+
guard?: { code: string; message: string };
|
|
61
|
+
outputFile: string;
|
|
62
|
+
source: { files: number; newestChunkAt: string | null; cars: number };
|
|
63
|
+
rows: { inStock: number; outOfStock: number; total: number };
|
|
64
|
+
outOfStock: Record<string, number>;
|
|
65
|
+
excluded: Record<string, number>;
|
|
66
|
+
reservedNeverSent: number;
|
|
67
|
+
ledger: { before: number; after: number; expired: number };
|
|
68
|
+
price: { min: number | null; max: number | null };
|
|
69
|
+
images: Record<string, number>;
|
|
70
|
+
lastGood: LastGood | null;
|
|
71
|
+
lastGoodAgeHours: number | null;
|
|
72
|
+
stale: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const increment = (counter: Record<string, number>, key: string) => {
|
|
76
|
+
counter[key] = (counter[key] ?? 0) + 1;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One OpenAI Ads feed run (ADV-335): source check -> map + validate -> temp CSV -> count check ->
|
|
81
|
+
* atomic publish -> ledger + report. Nothing outside `outputFile`'s directory and `stateDir` is
|
|
82
|
+
* written; transport (hosted URL / SFTP) is a separate step that only ever reads the published file.
|
|
83
|
+
*/
|
|
84
|
+
export class OpenAiAdsFeedRun {
|
|
85
|
+
constructor(private readonly deps: OpenAiAdsRunDeps, private readonly options: OpenAiAdsRunOptions) { }
|
|
86
|
+
|
|
87
|
+
private now(): Date {
|
|
88
|
+
return this.options.now ? this.options.now() : new Date();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async run(): Promise<OpenAiAdsRunReport> {
|
|
92
|
+
const { guard } = this.deps;
|
|
93
|
+
const { outputFile, stateDir, runId } = this.options;
|
|
94
|
+
const startedAt = this.now();
|
|
95
|
+
const lastGood = await guard.readLastGood();
|
|
96
|
+
const report: OpenAiAdsRunReport = {
|
|
97
|
+
runId,
|
|
98
|
+
mode: this.options.dryRun ? 'dry-run' : 'live',
|
|
99
|
+
startedAt: startedAt.toISOString(),
|
|
100
|
+
status: 'failed',
|
|
101
|
+
outputFile,
|
|
102
|
+
source: { files: 0, newestChunkAt: null, cars: 0 },
|
|
103
|
+
rows: { inStock: 0, outOfStock: 0, total: 0 },
|
|
104
|
+
outOfStock: {},
|
|
105
|
+
excluded: {},
|
|
106
|
+
reservedNeverSent: 0,
|
|
107
|
+
ledger: { before: 0, after: 0, expired: 0 },
|
|
108
|
+
price: { min: null, max: null },
|
|
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 = `${outputFile}.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
|
+
const ledgerFile = join(stateDir, 'ledger.json');
|
|
124
|
+
const ledger = await OpenAiAdsLedger.load(ledgerFile);
|
|
125
|
+
report.ledger.before = ledger.size;
|
|
126
|
+
|
|
127
|
+
await fs.mkdir(dirname(outputFile), { recursive: true });
|
|
128
|
+
const builder = new OpenAiAdsCsvBuilder(tmpFile);
|
|
129
|
+
await builder.buildStart();
|
|
130
|
+
|
|
131
|
+
const seen = new Set<string>();
|
|
132
|
+
const inStockIds = new Set<string>();
|
|
133
|
+
const freshOutOfStock = new Map<string, OpenAiAdsRow>();
|
|
134
|
+
const freshReasons = new Map<string, OpenAiAdsOutOfStockReason>();
|
|
135
|
+
const mapping: OpenAiAdsMappingContext = { ...this.options.mapping, now: startedAt };
|
|
136
|
+
|
|
137
|
+
await this.deps.forEachChunk(async (cars) => {
|
|
138
|
+
for (const source of cars) {
|
|
139
|
+
report.source.cars += 1;
|
|
140
|
+
const id = String(source?.car?._id ?? '');
|
|
141
|
+
if (id && seen.has(id)) {
|
|
142
|
+
increment(report.excluded, OpenAiAdsExclusion.duplicateId);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
seen.add(id);
|
|
146
|
+
if (!this.deps.filterCar(source)) {
|
|
147
|
+
increment(report.excluded, OpenAiAdsExclusion.filtered);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const mapped = mapOpenAiAdsVehicle(source.car, mapping);
|
|
151
|
+
if (mapped.ok === false) {
|
|
152
|
+
increment(report.excluded, mapped.reason);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// only cars that would be advertised are worth a dealer/image check
|
|
156
|
+
if (mapped.outOfStockReason && !ledger.has(mapped.row.item_id)) {
|
|
157
|
+
report.reservedNeverSent += 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const dealer = await this.deps.dealerStatus(String(source.car?.metaData?.ownerId ?? ''));
|
|
161
|
+
if (dealer !== 'active' && !mapped.outOfStockReason) {
|
|
162
|
+
increment(report.excluded, dealer === 'inactive' ? OpenAiAdsExclusion.dealerInactive : OpenAiAdsExclusion.dealerUnknown);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const images = await this.deps.resolveImages(source);
|
|
166
|
+
if (!images.fields) {
|
|
167
|
+
if (!mapped.outOfStockReason) {
|
|
168
|
+
increment(report.excluded, OpenAiAdsExclusion.noImage);
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const row = withOpenAiImages(mapped.row, images.fields);
|
|
173
|
+
if (mapped.outOfStockReason) {
|
|
174
|
+
freshOutOfStock.set(row.item_id, row);
|
|
175
|
+
freshReasons.set(row.item_id, mapped.outOfStockReason);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
await builder.addItem(row);
|
|
179
|
+
inStockIds.add(row.item_id);
|
|
180
|
+
ledger.markInStock(row, startedAt);
|
|
181
|
+
report.rows.inStock += 1;
|
|
182
|
+
const amount = Number(row.price.split(' ')[0]);
|
|
183
|
+
report.price.min = report.price.min === null ? amount : Math.min(report.price.min, amount);
|
|
184
|
+
report.price.max = report.price.max === null ? amount : Math.max(report.price.max, amount);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const { rows: outOfStockRows, expired } = ledger.collectOutOfStock(
|
|
189
|
+
inStockIds, freshOutOfStock, startedAt, this.options.retentionDays,
|
|
190
|
+
);
|
|
191
|
+
for (const { row, fresh } of outOfStockRows) {
|
|
192
|
+
await builder.addItem(row);
|
|
193
|
+
increment(report.outOfStock, fresh ? freshReasons.get(row.item_id) : OpenAiAdsOutOfStockReason.notInSnapshot);
|
|
194
|
+
}
|
|
195
|
+
report.rows.outOfStock = outOfStockRows.length;
|
|
196
|
+
report.rows.total = await builder.buildEnd();
|
|
197
|
+
report.ledger.expired = expired;
|
|
198
|
+
report.images = this.deps.imageStats();
|
|
199
|
+
|
|
200
|
+
const counts = { sourceItems: report.source.cars, publishedItems: report.rows.inStock };
|
|
201
|
+
guard.checkCounts(counts, lastGood);
|
|
202
|
+
|
|
203
|
+
report.lastGood = await guard.publish(tmpFile, outputFile, counts, this.now(), runId);
|
|
204
|
+
await ledger.save(ledgerFile, this.now());
|
|
205
|
+
report.ledger.after = ledger.size;
|
|
206
|
+
report.status = 'published';
|
|
207
|
+
report.lastGoodAgeHours = 0;
|
|
208
|
+
report.stale = false;
|
|
209
|
+
return report;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (error instanceof FeedGuardError) {
|
|
212
|
+
report.status = 'blocked';
|
|
213
|
+
report.guard = { code: error.code, message: error.message };
|
|
214
|
+
}
|
|
215
|
+
// keep the rejected file for inspection; the published file stays untouched
|
|
216
|
+
await fs.rename(tmpFile, `${outputFile}.rejected`).catch(() => undefined);
|
|
217
|
+
throw error;
|
|
218
|
+
} finally {
|
|
219
|
+
report.finishedAt = this.now().toISOString();
|
|
220
|
+
await fs.mkdir(stateDir, { recursive: true });
|
|
221
|
+
await fs.writeFile(join(stateDir, 'last-run-report.json'), JSON.stringify(report, null, 2), 'utf8');
|
|
222
|
+
console.log(`OpenAI ads feed report: ${JSON.stringify(report)}`);
|
|
223
|
+
await guard.releaseLock();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
|
|
4
|
+
import { OpenAiAdsRow } from '../interfaces/openai-ads.interface';
|
|
5
|
+
import { asOutOfStock } from '../transformers/openai-ads.transformer';
|
|
6
|
+
|
|
7
|
+
const LEDGER_VERSION = 1;
|
|
8
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
interface LedgerEntry {
|
|
11
|
+
// last run in which the item was sent in_stock (ISO)
|
|
12
|
+
lastInStockAt: string;
|
|
13
|
+
// the row as last sent in_stock, without additional_image_urls (kept small)
|
|
14
|
+
row: OpenAiAdsRow;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface LedgerFile {
|
|
18
|
+
version: number;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
items: Record<string, LedgerEntry>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface OutOfStockRow {
|
|
24
|
+
row: OpenAiAdsRow;
|
|
25
|
+
// true when the row data comes from this run's snapshot (car still online but reserved/sold)
|
|
26
|
+
fresh: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Items already sent to OpenAI as in_stock (ADV-335). The base export only contains online cars,
|
|
31
|
+
* so a sold, offline or deleted car simply disappears from the source — but a missing row is not a
|
|
32
|
+
* documented removal. Every item that was sent in_stock and is not in_stock in the current run is
|
|
33
|
+
* therefore sent again as out_of_stock (same item_id) until `retentionDays` after it was last in
|
|
34
|
+
* stock; OpenAI expires items that are not refreshed for two weeks, so the default retention
|
|
35
|
+
* (15 days) covers that window. A car that comes back is sent in_stock again with the same id.
|
|
36
|
+
*
|
|
37
|
+
* The ledger is changed in memory during a run and written only by the exporter after the file
|
|
38
|
+
* was published, so a blocked run never "forgets" items.
|
|
39
|
+
*/
|
|
40
|
+
export class OpenAiAdsLedger {
|
|
41
|
+
private constructor(private readonly items: Record<string, LedgerEntry>) { }
|
|
42
|
+
|
|
43
|
+
static empty(): OpenAiAdsLedger {
|
|
44
|
+
return new OpenAiAdsLedger({});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static async load(file: string): Promise<OpenAiAdsLedger> {
|
|
48
|
+
let raw: string;
|
|
49
|
+
try {
|
|
50
|
+
raw = await fs.readFile(file, 'utf8');
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error?.code === 'ENOENT') {
|
|
53
|
+
return OpenAiAdsLedger.empty();
|
|
54
|
+
}
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
// A corrupt ledger must stop the run: starting empty would silently drop every out_of_stock row.
|
|
58
|
+
const parsed = JSON.parse(raw) as LedgerFile;
|
|
59
|
+
if (parsed?.version !== LEDGER_VERSION || typeof parsed.items !== 'object') {
|
|
60
|
+
throw new Error(`OpenAI ads ledger ${file} has an unknown format (version ${parsed?.version})`);
|
|
61
|
+
}
|
|
62
|
+
return new OpenAiAdsLedger(parsed.items);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get size(): number {
|
|
66
|
+
return Object.keys(this.items).length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
has(itemId: string): boolean {
|
|
70
|
+
return itemId in this.items;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
markInStock(row: OpenAiAdsRow, now: Date): void {
|
|
74
|
+
this.items[row.item_id] = {
|
|
75
|
+
lastInStockAt: now.toISOString(),
|
|
76
|
+
row: { ...row, additional_image_urls: '' },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* out_of_stock rows for every ledger item that is not in `inStockIds`. `freshRows` holds this
|
|
82
|
+
* run's data for cars that are online but reserved/sold; other items reuse the last sent row.
|
|
83
|
+
* Items past the retention window are dropped from the ledger and not sent any more.
|
|
84
|
+
*/
|
|
85
|
+
collectOutOfStock(
|
|
86
|
+
inStockIds: Set<string>,
|
|
87
|
+
freshRows: Map<string, OpenAiAdsRow>,
|
|
88
|
+
now: Date,
|
|
89
|
+
retentionDays: number,
|
|
90
|
+
): { rows: OutOfStockRow[]; expired: number } {
|
|
91
|
+
const rows: OutOfStockRow[] = [];
|
|
92
|
+
let expired = 0;
|
|
93
|
+
for (const [itemId, entry] of Object.entries(this.items)) {
|
|
94
|
+
if (inStockIds.has(itemId)) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (now.getTime() - Date.parse(entry.lastInStockAt) > retentionDays * DAY_MS) {
|
|
98
|
+
delete this.items[itemId];
|
|
99
|
+
expired += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const fresh = freshRows.get(itemId);
|
|
103
|
+
rows.push({ row: asOutOfStock(fresh ?? entry.row), fresh: !!fresh });
|
|
104
|
+
}
|
|
105
|
+
return { rows, expired };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Write-then-rename, so a crash never leaves a half-written ledger behind.
|
|
109
|
+
async save(file: string, now: Date): Promise<void> {
|
|
110
|
+
const content: LedgerFile = { version: LEDGER_VERSION, updatedAt: now.toISOString(), items: this.items };
|
|
111
|
+
await fs.mkdir(dirname(file), { recursive: true });
|
|
112
|
+
const tmp = `${file}.tmp`;
|
|
113
|
+
await fs.writeFile(tmp, JSON.stringify(content), 'utf8');
|
|
114
|
+
await fs.rename(tmp, file);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { get } from 'lodash';
|
|
3
|
+
import { BigQueryLogger } from '@bytebrand/bigquery-logger';
|
|
4
|
+
import { DataSchemes } from '@bytebrand/car-schema';
|
|
5
|
+
import { PublicationGateConfig } from '../interfaces/params.interface';
|
|
6
|
+
import { GoogleEnvkvAttribute, googleEnvkvSourceFields } from '../transformers/google-feed-envkv';
|
|
7
|
+
|
|
8
|
+
// EnVKV stage C8 — publication gate for estimated EnVKV values in the Google feed.
|
|
9
|
+
//
|
|
10
|
+
// No car field says whether a value was delivered or estimated; car-basic-data writes every change it
|
|
11
|
+
// makes to the enrichment audit log (BigQuery). At the start of the export the gate reads the latest
|
|
12
|
+
// log row per car and field; a field counts as estimated when that row came from an estimate source
|
|
13
|
+
// and its final value is still the value stored on the car (a later import with a delivered value
|
|
14
|
+
// replaces it without a log row, so the stored value no longer matches).
|
|
15
|
+
//
|
|
16
|
+
// off no BigQuery call, the feed as before (default)
|
|
17
|
+
// audit the feed as before, the estimated attributes are recorded in the feed items table
|
|
18
|
+
// enforce estimated attributes are left out of the item (and recorded)
|
|
19
|
+
|
|
20
|
+
// finalValueSource values of CBD's enrichment log that are estimates: the CO2 estimator (EEA register /
|
|
21
|
+
// model), the make-model template sheet and the global defaults.
|
|
22
|
+
export const ESTIMATE_SOURCES = ['co2-estimate', 'mmm', 'globalDefault'];
|
|
23
|
+
|
|
24
|
+
// Every car field a Google EnVKV attribute can be taken from (google-feed-envkv picks).
|
|
25
|
+
export const GATED_FIELDS = [
|
|
26
|
+
'consumption.wltpWeightedCombined',
|
|
27
|
+
'consumption.wltpCombined',
|
|
28
|
+
'consumption.consumptionCombined',
|
|
29
|
+
'consumption.wltpWeightedPowerCombined',
|
|
30
|
+
'consumption.wltpPowerCombined',
|
|
31
|
+
'consumption.consumptionPowerCombined',
|
|
32
|
+
'environmentEmissions.wltpCo2',
|
|
33
|
+
'environmentEmissions.co2',
|
|
34
|
+
'battery.wltpTotalRange',
|
|
35
|
+
'battery.wltpRange',
|
|
36
|
+
'battery.batteryRangeElectric',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// '7.10' and 7.1 are the same value; non-numeric values compare as strings.
|
|
40
|
+
const sameValue = (logged: string, stored: unknown): boolean => {
|
|
41
|
+
if (stored === undefined || stored === null) return false;
|
|
42
|
+
const a = Number(logged);
|
|
43
|
+
const b = Number(stored);
|
|
44
|
+
if (Number.isFinite(a) && Number.isFinite(b)) return Math.abs(a - b) < 1e-9;
|
|
45
|
+
return logged === String(stored);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export class PublicationGate {
|
|
49
|
+
// estimates: `${carId}|${field}` -> final value of the latest estimate row
|
|
50
|
+
constructor(readonly mode: 'off' | 'audit' | 'enforce', private estimates: Map<string, string>) {}
|
|
51
|
+
|
|
52
|
+
static off(): PublicationGate {
|
|
53
|
+
return new PublicationGate('off', new Map());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get size(): number {
|
|
57
|
+
return this.estimates.size;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The attributes of this car whose value comes from an estimated field, in a fixed order.
|
|
61
|
+
estimatedAttributes(car: DataSchemes.Car): GoogleEnvkvAttribute[] {
|
|
62
|
+
if (this.mode === 'off') return [];
|
|
63
|
+
const carId = String(get(car, '_id', ''));
|
|
64
|
+
const attributes: GoogleEnvkvAttribute[] = [];
|
|
65
|
+
for (const [attribute, field] of Object.entries(googleEnvkvSourceFields(car))) {
|
|
66
|
+
const logged = this.estimates.get(`${carId}|${field}`);
|
|
67
|
+
if (logged !== undefined && sameValue(logged, get(car, field))) attributes.push(attribute as GoogleEnvkvAttribute);
|
|
68
|
+
}
|
|
69
|
+
return attributes;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// enforce: drops the estimated attributes from the item. Returns the item for chaining.
|
|
73
|
+
apply<T extends Partial<Record<GoogleEnvkvAttribute, string>>>(item: T, attributes: GoogleEnvkvAttribute[]): T {
|
|
74
|
+
if (this.mode !== 'enforce') return item;
|
|
75
|
+
for (const attribute of attributes) delete item[attribute];
|
|
76
|
+
return item;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class EnrichmentLogReader extends BigQueryLogger {
|
|
81
|
+
async latestEstimates(table: string): Promise<Map<string, string>> {
|
|
82
|
+
const query = `
|
|
83
|
+
SELECT carId, fieldName, finalValue
|
|
84
|
+
FROM (
|
|
85
|
+
SELECT carId, fieldName, finalValue, finalValueSource,
|
|
86
|
+
ROW_NUMBER() OVER (PARTITION BY carId, fieldName ORDER BY timestamp DESC) AS rn
|
|
87
|
+
FROM \`${table}\`
|
|
88
|
+
WHERE fieldName IN UNNEST(@fields)
|
|
89
|
+
)
|
|
90
|
+
WHERE rn = 1 AND finalValueSource IN UNNEST(@sources) AND finalValue IS NOT NULL`;
|
|
91
|
+
const [rows] = await this.client.query({ query, params: { fields: GATED_FIELDS, sources: ESTIMATE_SOURCES } });
|
|
92
|
+
const estimates = new Map<string, string>();
|
|
93
|
+
for (const row of rows) estimates.set(`${row.carId}|${row.fieldName}`, String(row.finalValue));
|
|
94
|
+
return estimates;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Reads the estimates once per run. audit: a failure only switches the gate off. enforce: a failure
|
|
99
|
+
// fails the export, so the last published feed stays in place instead of a feed with unchecked estimates.
|
|
100
|
+
export async function loadPublicationGate(config: PublicationGateConfig | undefined): Promise<PublicationGate> {
|
|
101
|
+
if (!config || config.mode === 'off') return PublicationGate.off();
|
|
102
|
+
const table = `${config.project}.${config.dataset}.${config.table}`;
|
|
103
|
+
try {
|
|
104
|
+
const credentials = require(join(process.cwd(), 'credentials/google-cloud-project-id.json'));
|
|
105
|
+
const reader = new EnrichmentLogReader(credentials, {
|
|
106
|
+
BigQueryProject: config.project,
|
|
107
|
+
BigQueryDataset: config.dataset,
|
|
108
|
+
BigQueryLogTable: config.table,
|
|
109
|
+
});
|
|
110
|
+
const estimates = await reader.latestEstimates(table);
|
|
111
|
+
console.log(`Publication gate ${config.mode}: ${estimates.size} estimated car fields from ${table}`);
|
|
112
|
+
return new PublicationGate(config.mode, estimates);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
const message = String(e?.message || e).split('\n')[0];
|
|
115
|
+
if (config.mode === 'enforce') throw new Error(`Publication gate: cannot read ${table}: ${message}`);
|
|
116
|
+
console.error(`Publication gate disabled for this run: cannot read ${table}: ${message}`);
|
|
117
|
+
return PublicationGate.off();
|
|
118
|
+
}
|
|
119
|
+
}
|