@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.
Files changed (112) hide show
  1. package/README.md +128 -0
  2. package/dist/configs/config.d.ts +2 -1
  3. package/dist/configs/config.js +1 -0
  4. package/dist/configs/config.js.map +1 -1
  5. package/dist/configs/google.config.js +16 -0
  6. package/dist/configs/google.config.js.map +1 -1
  7. package/dist/configs/index.d.ts +1 -0
  8. package/dist/configs/index.js +1 -0
  9. package/dist/configs/index.js.map +1 -1
  10. package/dist/configs/openai-ads.config.d.ts +22 -0
  11. package/dist/configs/openai-ads.config.js +56 -0
  12. package/dist/configs/openai-ads.config.js.map +1 -0
  13. package/dist/enums/export-type.enum.d.ts +2 -1
  14. package/dist/enums/export-type.enum.js +1 -0
  15. package/dist/enums/export-type.enum.js.map +1 -1
  16. package/dist/enums/image-type.enum.d.ts +2 -1
  17. package/dist/enums/image-type.enum.js +1 -0
  18. package/dist/enums/image-type.enum.js.map +1 -1
  19. package/dist/export.schema.d.ts +6 -2
  20. package/dist/export.schema.js +4 -0
  21. package/dist/export.schema.js.map +1 -1
  22. package/dist/exporters/google.exporter.js +12 -2
  23. package/dist/exporters/google.exporter.js.map +1 -1
  24. package/dist/exporters/index.d.ts +1 -0
  25. package/dist/exporters/index.js +1 -0
  26. package/dist/exporters/index.js.map +1 -1
  27. package/dist/exporters/openai-ads.exporter.d.ts +13 -0
  28. package/dist/exporters/openai-ads.exporter.js +91 -0
  29. package/dist/exporters/openai-ads.exporter.js.map +1 -0
  30. package/dist/formats/openai-ads-csv.builder.d.ts +15 -0
  31. package/dist/formats/openai-ads-csv.builder.js +48 -0
  32. package/dist/formats/openai-ads-csv.builder.js.map +1 -0
  33. package/dist/interfaces/openai-ads.interface.d.ts +35 -0
  34. package/dist/interfaces/openai-ads.interface.js +37 -0
  35. package/dist/interfaces/openai-ads.interface.js.map +1 -0
  36. package/dist/interfaces/params.interface.d.ts +20 -0
  37. package/dist/services/feed-guard.d.ts +44 -0
  38. package/dist/services/feed-guard.js +156 -0
  39. package/dist/services/feed-guard.js.map +1 -0
  40. package/dist/services/image-url-validator.service.d.ts +45 -0
  41. package/dist/services/image-url-validator.service.js +141 -0
  42. package/dist/services/image-url-validator.service.js.map +1 -0
  43. package/dist/services/images.service.d.ts +12 -0
  44. package/dist/services/images.service.js +46 -1
  45. package/dist/services/images.service.js.map +1 -1
  46. package/dist/services/index.d.ts +1 -0
  47. package/dist/services/index.js +1 -0
  48. package/dist/services/index.js.map +1 -1
  49. package/dist/services/openai-ads-feed-run.d.ts +74 -0
  50. package/dist/services/openai-ads-feed-run.js +151 -0
  51. package/dist/services/openai-ads-feed-run.js.map +1 -0
  52. package/dist/services/openai-ads-ledger.d.ts +19 -0
  53. package/dist/services/openai-ads-ledger.js +72 -0
  54. package/dist/services/openai-ads-ledger.js.map +1 -0
  55. package/dist/services/publication-gate.d.ts +15 -0
  56. package/dist/services/publication-gate.js +107 -0
  57. package/dist/services/publication-gate.js.map +1 -0
  58. package/dist/transformers/google-feed-envkv.d.ts +2 -0
  59. package/dist/transformers/google-feed-envkv.js +62 -31
  60. package/dist/transformers/google-feed-envkv.js.map +1 -1
  61. package/dist/transformers/google-feed-item-row.d.ts +6 -1
  62. package/dist/transformers/google-feed-item-row.js +3 -1
  63. package/dist/transformers/google-feed-item-row.js.map +1 -1
  64. package/dist/transformers/openai-ads.transformer.d.ts +28 -0
  65. package/dist/transformers/openai-ads.transformer.js +144 -0
  66. package/dist/transformers/openai-ads.transformer.js.map +1 -0
  67. package/dist/transformers/openai-image-fields.d.ts +16 -0
  68. package/dist/transformers/openai-image-fields.js +34 -0
  69. package/dist/transformers/openai-image-fields.js.map +1 -0
  70. package/dist/tsconfig.build.tsbuildinfo +1 -1
  71. package/docs/openai-ads/AUDIT.md +235 -0
  72. package/docs/openai-ads/RUNBOOK.md +94 -0
  73. package/package.json +64 -64
  74. package/schema/feed-items.json +134 -122
  75. package/src/configs/config.ts +99 -97
  76. package/src/configs/google.config.ts +41 -26
  77. package/src/configs/index.ts +1 -0
  78. package/src/configs/openai-ads.config.ts +70 -0
  79. package/src/enums/export-type.enum.ts +2 -0
  80. package/src/enums/image-type.enum.ts +5 -0
  81. package/src/export.command.ts +38 -38
  82. package/src/export.schema.ts +6 -0
  83. package/src/exporters/google.exporter.ts +67 -56
  84. package/src/exporters/index.ts +1 -0
  85. package/src/exporters/openai-ads.exporter.ts +93 -0
  86. package/src/formats/openai-ads-csv.builder.ts +55 -0
  87. package/src/interfaces/openai-ads.interface.ts +64 -0
  88. package/src/interfaces/params.interface.ts +56 -29
  89. package/src/services/feed-guard.ts +219 -0
  90. package/src/services/feed-items-bigquery.writer.ts +101 -101
  91. package/src/services/image-url-validator.service.ts +181 -0
  92. package/src/services/images.service.ts +72 -0
  93. package/src/services/index.ts +1 -0
  94. package/src/services/openai-ads-feed-run.ts +226 -0
  95. package/src/services/openai-ads-ledger.ts +116 -0
  96. package/src/services/publication-gate.ts +119 -0
  97. package/src/transformers/google-feed-envkv.ts +81 -33
  98. package/src/transformers/google-feed-item-row.ts +78 -71
  99. package/src/transformers/openai-ads.transformer.ts +211 -0
  100. package/src/transformers/openai-image-fields.ts +60 -0
  101. package/test/clean-images.test.ts +86 -0
  102. package/test/fixtures/images-service.harness.ts +32 -0
  103. package/test/fixtures/images.golden.json +218 -0
  104. package/test/fixtures/imbo-images.fixture.ts +77 -0
  105. package/test/fixtures/openai-ads.fixture.ts +89 -0
  106. package/test/google-feed-item-row.test.ts +135 -121
  107. package/test/image-url-validator.test.ts +110 -0
  108. package/test/images-regression.test.ts +23 -0
  109. package/test/openai-ads-feed-run.test.ts +278 -0
  110. package/test/openai-ads-mapping.test.ts +136 -0
  111. package/test/openai-image-fields.test.ts +99 -0
  112. package/test/publication-gate.test.ts +86 -0
@@ -1,38 +1,38 @@
1
- import { createInterface } from 'readline';
2
- import { Command, CommandRunner } from 'nest-commander';
3
- import { ConfigService } from './services';
4
- import { Input } from './interfaces/input.interface';
5
- import { exportSchema } from './export.schema';
6
- import { ModuleRef } from '@nestjs/core';
7
- import { Exporter } from './interfaces/exporter.interface';
8
- import { LoggerService } from '@bytebrand/cronicle-helper';
9
-
10
- @Command({
11
- name: 'export',
12
- options: { isDefault: true },
13
- })
14
- export class ExportRunner extends CommandRunner {
15
- constructor(
16
- private configService: ConfigService,
17
- private moduleRef: ModuleRef,
18
- ) {
19
- super();
20
- }
21
-
22
- async run(): Promise<void> {
23
- const rl = createInterface({ input: process.stdin });
24
- await new Promise<void>((resolve, reject) => {
25
- rl.on('line', async (data: string) => {
26
- const input = JSON.parse(data) as Input;
27
- LoggerService.log(`Running job: ${input.id} - ${JSON.stringify(input)} \n`);
28
-
29
- this.configService.config = new exportSchema[input.params.exportType].config(input.params);
30
- this.configService.config.runId = input.id;
31
- const exporter = await this.moduleRef.create<Exporter>(exportSchema[input.params.exportType].exporter);
32
- await exporter.export().catch(reject);
33
-
34
- resolve(rl.close());
35
- });
36
- });
37
- }
38
- }
1
+ import { createInterface } from 'readline';
2
+ import { Command, CommandRunner } from 'nest-commander';
3
+ import { ConfigService } from './services';
4
+ import { Input } from './interfaces/input.interface';
5
+ import { exportSchema } from './export.schema';
6
+ import { ModuleRef } from '@nestjs/core';
7
+ import { Exporter } from './interfaces/exporter.interface';
8
+ import { LoggerService } from '@bytebrand/cronicle-helper';
9
+
10
+ @Command({
11
+ name: 'export',
12
+ options: { isDefault: true },
13
+ })
14
+ export class ExportRunner extends CommandRunner {
15
+ constructor(
16
+ private configService: ConfigService,
17
+ private moduleRef: ModuleRef,
18
+ ) {
19
+ super();
20
+ }
21
+
22
+ async run(): Promise<void> {
23
+ const rl = createInterface({ input: process.stdin });
24
+ await new Promise<void>((resolve, reject) => {
25
+ rl.on('line', async (data: string) => {
26
+ const input = JSON.parse(data) as Input;
27
+ LoggerService.log(`Running job: ${input.id} - ${JSON.stringify(input)} \n`);
28
+
29
+ this.configService.config = new exportSchema[input.params.exportType].config(input.params);
30
+ this.configService.config.runId = input.id;
31
+ const exporter = await this.moduleRef.create<Exporter>(exportSchema[input.params.exportType].exporter);
32
+ await exporter.export().catch(reject);
33
+
34
+ resolve(rl.close());
35
+ });
36
+ });
37
+ }
38
+ }
@@ -23,6 +23,7 @@ import {
23
23
  AwinConfig,
24
24
  EviumConfig,
25
25
  SantanderConfig,
26
+ OpenAiAdsConfig,
26
27
  } from './configs';
27
28
  import {
28
29
  As24Exporter,
@@ -41,6 +42,7 @@ import {
41
42
  AwinExporter,
42
43
  SantanderExporter,
43
44
  CriteoExporter,
45
+ OpenAiAdsExporter,
44
46
  } from './exporters';
45
47
 
46
48
  export const exportSchema = {
@@ -132,4 +134,8 @@ export const exportSchema = {
132
134
  config: SantanderConfig,
133
135
  exporter: SantanderExporter,
134
136
  },
137
+ [ExportType.OPENAI_ADS]: {
138
+ config: OpenAiAdsConfig,
139
+ exporter: OpenAiAdsExporter,
140
+ },
135
141
  };
@@ -1,56 +1,67 @@
1
- import { Injectable } from '@nestjs/common';
2
- import { ModuleRef } from '@nestjs/core';
3
-
4
- import { CronicleUtils } from '@bytebrand/cronicle-helper';
5
- import { FsChunkInterface } from '@bytebrand/chunkable';
6
-
7
- import { CarService, ConfigService, LoaderService, RulesService } from '../services';
8
- import { GoogleTransformer } from '../transformers/google.transformer';
9
- import { buildGoogleFeedItemRow } from '../transformers/google-feed-item-row';
10
- import { FeedItemsBigQueryWriter } from '../services/feed-items-bigquery.writer';
11
- import { GoogleXmlBuilder } from '../formats/google-xml.builder';
12
- import { Exporter } from '../interfaces/exporter.interface';
13
-
14
- @Injectable()
15
- export class GoogleExporter implements Exporter {
16
- constructor(
17
- private carService: CarService,
18
- private configService: ConfigService,
19
- private rulesService: RulesService,
20
- private loaderService: LoaderService,
21
- private moduleRef: ModuleRef,
22
- ) { }
23
-
24
- async export(): Promise<void> {
25
- const [fileName] = this.configService.config.outputFiles;
26
- await CronicleUtils.ensureDir(this.configService.config.pathesConfig.localConvertedDirectory);
27
- await CronicleUtils.emptyDir(this.configService.config.pathesConfig.localConvertedDirectory);
28
-
29
- const googleXmlBuilder = new GoogleXmlBuilder(fileName);
30
- await googleXmlBuilder.buildStart(this.configService.config.domain);
31
-
32
- const googleTransformer = await this.moduleRef.create(GoogleTransformer);
33
-
34
- // Opt-in audit of the sent items (feedItemsBq* job params); null = no BigQuery calls at all.
35
- const { feedItemsBigQuery, runId } = this.configService.config;
36
- const feedItemsWriter = feedItemsBigQuery ? new FeedItemsBigQueryWriter(feedItemsBigQuery) : null;
37
- const exportedAt = new Date();
38
-
39
- await this.loaderService.process(async (chunk: FsChunkInterface) => {
40
- chunk.data = chunk.data.filter(car => this.carService.filterCar(car, this.rulesService.rules));
41
-
42
- for (const data of chunk.data) {
43
- const car = await googleTransformer.transform(data);
44
- if (car) {
45
- await googleXmlBuilder.addItem(car);
46
- feedItemsWriter?.add(buildGoogleFeedItemRow(data.car, car, runId, exportedAt));
47
- }
48
- this.carService.counter.countCar(data.images);
49
- }
50
- });
51
-
52
- await googleXmlBuilder.buildEnd();
53
- await feedItemsWriter?.close();
54
- console.log(this.carService.counter.getCountedCars());
55
- }
56
- }
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ModuleRef } from '@nestjs/core';
3
+
4
+ import { CronicleUtils } from '@bytebrand/cronicle-helper';
5
+ import { FsChunkInterface } from '@bytebrand/chunkable';
6
+
7
+ import { CarService, ConfigService, LoaderService, RulesService } from '../services';
8
+ import { GoogleTransformer } from '../transformers/google.transformer';
9
+ import { buildGoogleFeedItemRow } from '../transformers/google-feed-item-row';
10
+ import { FeedItemsBigQueryWriter } from '../services/feed-items-bigquery.writer';
11
+ import { loadPublicationGate } from '../services/publication-gate';
12
+ import { GoogleXmlBuilder } from '../formats/google-xml.builder';
13
+ import { Exporter } from '../interfaces/exporter.interface';
14
+
15
+ @Injectable()
16
+ export class GoogleExporter implements Exporter {
17
+ constructor(
18
+ private carService: CarService,
19
+ private configService: ConfigService,
20
+ private rulesService: RulesService,
21
+ private loaderService: LoaderService,
22
+ private moduleRef: ModuleRef,
23
+ ) { }
24
+
25
+ async export(): Promise<void> {
26
+ // EnVKV C8: estimated EnVKV values (CBD enrichment log); off unless the job sets publicationGate.
27
+ // Read first: in enforce mode a failure stops the run before the output directory is emptied.
28
+ const gate = await loadPublicationGate(this.configService.config.publicationGate);
29
+ const [fileName] = this.configService.config.outputFiles;
30
+ await CronicleUtils.ensureDir(this.configService.config.pathesConfig.localConvertedDirectory);
31
+ await CronicleUtils.emptyDir(this.configService.config.pathesConfig.localConvertedDirectory);
32
+
33
+ const googleXmlBuilder = new GoogleXmlBuilder(fileName);
34
+ await googleXmlBuilder.buildStart(this.configService.config.domain);
35
+
36
+ const googleTransformer = await this.moduleRef.create(GoogleTransformer);
37
+
38
+ // Opt-in audit of the sent items (feedItemsBq* job params); null = no BigQuery calls at all.
39
+ const { feedItemsBigQuery, runId } = this.configService.config;
40
+ const feedItemsWriter = feedItemsBigQuery ? new FeedItemsBigQueryWriter(feedItemsBigQuery) : null;
41
+ const exportedAt = new Date();
42
+ let gatedItems = 0;
43
+
44
+ await this.loaderService.process(async (chunk: FsChunkInterface) => {
45
+ chunk.data = chunk.data.filter(car => this.carService.filterCar(car, this.rulesService.rules));
46
+
47
+ for (const data of chunk.data) {
48
+ const car = await googleTransformer.transform(data);
49
+ if (car) {
50
+ const estimatedAttributes = gate.estimatedAttributes(data.car);
51
+ if (estimatedAttributes.length) gatedItems += 1;
52
+ await googleXmlBuilder.addItem(gate.apply(car, estimatedAttributes));
53
+ const gateInfo = gate.mode === 'off' ? null : { mode: gate.mode, estimatedAttributes };
54
+ feedItemsWriter?.add(buildGoogleFeedItemRow(data.car, car, runId, exportedAt, gateInfo));
55
+ }
56
+ this.carService.counter.countCar(data.images);
57
+ }
58
+ });
59
+
60
+ await googleXmlBuilder.buildEnd();
61
+ if (gate.mode !== 'off') {
62
+ console.log(`Publication gate ${gate.mode}: ${gatedItems} items with estimated EnVKV values${gate.mode === 'enforce' ? ' (attributes left out)' : ''}`);
63
+ }
64
+ await feedItemsWriter?.close();
65
+ console.log(this.carService.counter.getCountedCars());
66
+ }
67
+ }
@@ -14,3 +14,4 @@ export * from './facebook-shop.exporter';
14
14
  export * from './trovit.exporter';
15
15
  export * from './awin.exporter';
16
16
  export * from './santander.exporter';
17
+ export * from './openai-ads.exporter';
@@ -0,0 +1,93 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { FsChunkInterface } from '@bytebrand/chunkable';
3
+ import { LoggerService } from '@bytebrand/cronicle-helper';
4
+ import { TranslatorExtended } from '@bytebrand/schema-tools';
5
+ import { de, deVehicleDetailsPage, deVehicleProps } from '@bytebrand/i18n-dictionaries';
6
+
7
+ import { CarService, ConfigService, ImagesService, LoaderService, RulesService } from '../services';
8
+ import { ImageUrlValidator } from '../services/image-url-validator.service';
9
+ import { FeedGuard } from '../services/feed-guard';
10
+ import { DealerStatus, OpenAiAdsFeedRun, SourceCar } from '../services/openai-ads-feed-run';
11
+ import { resolveOpenAiImageFields } from '../transformers/openai-image-fields';
12
+ import { OpenAiAdsConfig } from '../configs/openai-ads.config';
13
+ import { Exporter } from '../interfaces/exporter.interface';
14
+
15
+ /**
16
+ * ExportType.OPENAI_ADS (ADV-335). Wires the Cronicle/Nest services into OpenAiAdsFeedRun, which
17
+ * holds the logic. Unlike the other exporters it never empties its output directory: the new file
18
+ * replaces the old one only after the fail-closed guard passed.
19
+ */
20
+ @Injectable()
21
+ export class OpenAiAdsExporter implements Exporter {
22
+ constructor(
23
+ private carService: CarService,
24
+ private configService: ConfigService,
25
+ private rulesService: RulesService,
26
+ private loaderService: LoaderService,
27
+ private imagesService: ImagesService,
28
+ ) { }
29
+
30
+ async export(): Promise<void> {
31
+ const config = this.configService.config as OpenAiAdsConfig;
32
+ const translator = new TranslatorExtended({ ...de, ...deVehicleProps, ...deVehicleDetailsPage });
33
+ const validator = new ImageUrlValidator({ concurrency: config.imageConcurrency });
34
+
35
+ const run = new OpenAiAdsFeedRun(
36
+ {
37
+ forEachChunk: fn => this.loaderService.process((chunk: FsChunkInterface) => fn(chunk.data as SourceCar[])),
38
+ filterCar: source => this.carService.filterCar(source, this.rulesService.rules),
39
+ dealerStatus: ownerId => this.dealerStatus(ownerId),
40
+ resolveImages: ({ car, images }) =>
41
+ resolveOpenAiImageFields(this.imagesService.getCleanImages(car._id, images), validator),
42
+ imageStats: () => validator.stats,
43
+ guard: new FeedGuard(config.feedGuard),
44
+ },
45
+ {
46
+ runId: config.runId,
47
+ dryRun: config.dryRun,
48
+ sourceDirectory: config.pathesConfig.localDirectory,
49
+ sourceBaseFileName: config.pathesConfig.baseFileName,
50
+ outputFile: config.outputFiles[0],
51
+ stateDir: config.stateDir,
52
+ retentionDays: config.retentionDays,
53
+ mapping: {
54
+ domain: config.domain,
55
+ sellerName: config.sellerName,
56
+ translate: key => translator.translateVal(key),
57
+ },
58
+ },
59
+ );
60
+
61
+ try {
62
+ const report = await run.run();
63
+ if (report.stale) {
64
+ console.log(`OpenAI ads feed STALE: last good file ${report.lastGoodAgeHours?.toFixed(1)} h old`);
65
+ }
66
+ } catch (error) {
67
+ // nest-commander's default serviceErrorHandler only prints a rejected run, and main.ts then
68
+ // reports {"complete":1,"code":0} — a blocked feed would look green in Cronicle. Fail the job
69
+ // here (report + lock already handled by the run).
70
+ LoggerService.exitError(`OpenAI ads feed not published: ${error?.message ?? error}`);
71
+ process.exit(1);
72
+ }
73
+ }
74
+
75
+ // One UPA lookup per dealer and run. CarService.getOwner retries 4× and does not remember
76
+ // failures, so without this map every car of an unknown dealer would wait for the retries again.
77
+ private readonly dealerStatuses = new Map<string, Promise<DealerStatus>>();
78
+
79
+ // CBD already takes blacklisted dealers' cars offline, but a deactivated dealer's cars stay
80
+ // online — not advertised here. A missing `active` field counts as active (only explicit false).
81
+ private dealerStatus(ownerId: string): Promise<DealerStatus> {
82
+ if (!ownerId) {
83
+ return Promise.resolve('unknown');
84
+ }
85
+ if (!this.dealerStatuses.has(ownerId)) {
86
+ this.dealerStatuses.set(ownerId, this.carService.getOwner(ownerId).then(
87
+ owner => (!owner ? 'unknown' : owner.active === false || owner.blacklist === true ? 'inactive' : 'active'),
88
+ () => 'unknown' as DealerStatus,
89
+ ));
90
+ }
91
+ return this.dealerStatuses.get(ownerId);
92
+ }
93
+ }
@@ -0,0 +1,55 @@
1
+ import { createWriteStream, WriteStream } from 'fs';
2
+ import { once } from 'events';
3
+
4
+ import { OPENAI_ADS_COLUMNS, OpenAiAdsRow } from '../interfaces/openai-ads.interface';
5
+
6
+ // RFC 4180 cell: quoted only when it contains a comma, a quote, CR or LF (the OpenAI product file
7
+ // rule); embedded quotes are doubled. Values are written as they are — callers keep free text on
8
+ // one line, but a stray newline still stays inside its quoted cell instead of breaking the row.
9
+ export const toCsvCell = (value: string | boolean | undefined | null): string => {
10
+ if (value === undefined || value === null) {
11
+ return '';
12
+ }
13
+ const text = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
14
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
15
+ };
16
+
17
+ export const toCsvLine = (cells: Array<string | boolean | undefined | null>): string => `${cells.map(toCsvCell).join(',')}\n`;
18
+
19
+ export const openAiAdsCsvHeader = (): string => toCsvLine([...OPENAI_ADS_COLUMNS]);
20
+
21
+ export const openAiAdsCsvRow = (row: OpenAiAdsRow): string => toCsvLine(OPENAI_ADS_COLUMNS.map(column => row[column]));
22
+
23
+ /**
24
+ * OpenAI product file (OpenAI format, not the Google-compatible profile): UTF-8 without BOM, one
25
+ * header row, one vehicle per row, fixed column order (OPENAI_ADS_COLUMNS). Not built on
26
+ * XMLBuilderBase and not shared with the Awin/Santander writers, which do not escape quotes.
27
+ */
28
+ export class OpenAiAdsCsvBuilder {
29
+ private stream: WriteStream;
30
+ private rows = 0;
31
+
32
+ constructor(private readonly fileName: string) { }
33
+
34
+ async buildStart(): Promise<void> {
35
+ this.stream = createWriteStream(this.fileName, { encoding: 'utf8' });
36
+ await this.write(openAiAdsCsvHeader());
37
+ }
38
+
39
+ async addItem(row: OpenAiAdsRow): Promise<void> {
40
+ await this.write(openAiAdsCsvRow(row));
41
+ this.rows += 1;
42
+ }
43
+
44
+ async buildEnd(): Promise<number> {
45
+ this.stream.end();
46
+ await once(this.stream, 'finish');
47
+ return this.rows;
48
+ }
49
+
50
+ private async write(chunk: string): Promise<void> {
51
+ if (!this.stream.write(chunk)) {
52
+ await once(this.stream, 'drain');
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,64 @@
1
+ // OpenAI product file, OpenAI format (developers.openai.com/commerce/specs/file-upload/products,
2
+ // read 2026-10-09). Only the columns below are sent: no gtin/mpn/VIN (never invented), no
3
+ // target_countries/store_country (market comes from the account setup), no Google-compatible names.
4
+ export const OPENAI_ADS_COLUMNS = [
5
+ 'item_id',
6
+ 'title',
7
+ 'description',
8
+ 'url',
9
+ 'brand',
10
+ 'seller_name',
11
+ 'image_url',
12
+ 'additional_image_urls',
13
+ 'availability',
14
+ 'price',
15
+ 'condition',
16
+ 'is_ads_eligible',
17
+ ] as const;
18
+
19
+ export type OpenAiAdsColumn = typeof OPENAI_ADS_COLUMNS[number];
20
+
21
+ export type OpenAiAdsAvailability = 'in_stock' | 'out_of_stock';
22
+
23
+ export type OpenAiAdsCondition = 'new' | 'used';
24
+
25
+ export interface OpenAiAdsRow {
26
+ // canonical auto.de vehicle id (car._id, the VDP id) — same value as GA4 item_id and the
27
+ // Pixel/CAPI contents[].id of ADV-337
28
+ item_id: string;
29
+ title: string;
30
+ description: string;
31
+ url: string;
32
+ brand: string;
33
+ seller_name: string;
34
+ image_url: string;
35
+ // one CSV cell, comma-separated, commas inside URLs as %2C; '' = none
36
+ additional_image_urls: string;
37
+ availability: OpenAiAdsAvailability;
38
+ // "17990.00 EUR": purchase price in major units — never a monthly rate
39
+ price: string;
40
+ condition: OpenAiAdsCondition;
41
+ is_ads_eligible: boolean;
42
+ }
43
+
44
+ // Why a car of the source snapshot is not in the file as in_stock. Counted in the run report.
45
+ export enum OpenAiAdsExclusion {
46
+ noId = 'no_id',
47
+ duplicateId = 'duplicate_id',
48
+ filtered = 'filtered',
49
+ invalidPrice = 'invalid_price',
50
+ noTitle = 'no_title',
51
+ noBrand = 'no_brand',
52
+ conditionUnknown = 'condition_unknown',
53
+ dealerInactive = 'dealer_inactive',
54
+ dealerUnknown = 'dealer_unknown',
55
+ noImage = 'no_image',
56
+ }
57
+
58
+ // Why an item is sent as out_of_stock.
59
+ export enum OpenAiAdsOutOfStockReason {
60
+ reserved = 'reserved',
61
+ soldFlag = 'sold_flag',
62
+ // sent before, not in_stock in this run (sold/offline/deleted/now excluded)
63
+ notInSnapshot = 'not_in_snapshot',
64
+ }
@@ -1,29 +1,56 @@
1
- import { Environment, ExportType } from '../enums';
2
-
3
- export interface Params {
4
- environment: Environment;
5
- exportType?: ExportType;
6
- apikey?: string;
7
- String?: string;
8
- Number?: string;
9
- Date?: string;
10
- isNewCar?: boolean;
11
- cpc?: string;
12
- ownerId?: string;
13
- metaPushPriorityCsvUrl?: string;
14
- // Cronicle plugin param (text field, so it arrives as a string); overrides the
15
- // default MAX_FEED_SALES_PRICE_EUR cap in the configs that apply one.
16
- maxCurrentSalesPrice?: string;
17
- // Google feed only, opt-in: when all three are set, every exported item is also written to
18
- // `<project>.<dataset>.<table>` in BigQuery (schema/feed-items.json, see README).
19
- feedItemsBqProject?: string;
20
- feedItemsBqDataset?: string;
21
- feedItemsBqTable?: string;
22
- }
23
-
24
- // Parsed from feedItemsBq* by GoogleConfig.
25
- export interface FeedItemsBigQueryTarget {
26
- project: string;
27
- dataset: string;
28
- table: string;
29
- }
1
+ import { Environment, ExportType } from '../enums';
2
+
3
+ export interface Params {
4
+ environment: Environment;
5
+ exportType?: ExportType;
6
+ apikey?: string;
7
+ String?: string;
8
+ Number?: string;
9
+ Date?: string;
10
+ isNewCar?: boolean;
11
+ cpc?: string;
12
+ ownerId?: string;
13
+ metaPushPriorityCsvUrl?: string;
14
+ // Cronicle plugin param (text field, so it arrives as a string); overrides the
15
+ // default MAX_FEED_SALES_PRICE_EUR cap in the configs that apply one.
16
+ maxCurrentSalesPrice?: string;
17
+ // Google feed only, opt-in: when all three are set, every exported item is also written to
18
+ // `<project>.<dataset>.<table>` in BigQuery (schema/feed-items.json, see README).
19
+ feedItemsBqProject?: string;
20
+ feedItemsBqDataset?: string;
21
+ feedItemsBqTable?: string;
22
+ // Google feed only, EnVKV C8: off (default) | audit | enforce, and the car-basic-data enrichment log
23
+ // table the estimates are read from (see services/publication-gate.ts).
24
+ publicationGate?: string;
25
+ publicationGateBqProject?: string;
26
+ publicationGateBqDataset?: string;
27
+ publicationGateBqTable?: string;
28
+ // OpenAI Ads feed only (ADV-335). Cronicle text params, so all arrive as strings; see
29
+ // configs/openai-ads.config.ts for defaults. Live output needs openAiAdsDryRun=false explicitly.
30
+ openAiAdsDryRun?: string;
31
+ openAiAdsSellerName?: string;
32
+ openAiAdsRetentionDays?: string;
33
+ openAiAdsImageConcurrency?: string;
34
+ feedGuardMinSourceItems?: string;
35
+ feedGuardMinPublishedItems?: string;
36
+ feedGuardMaxDropPercent?: string;
37
+ feedGuardMaxSourceAgeHours?: string;
38
+ feedGuardMaxStaleHours?: string;
39
+ // "true" for ONE run after a real stock drop the guard blocked
40
+ feedGuardAcceptDrop?: string;
41
+ }
42
+
43
+ // Parsed from feedItemsBq* by GoogleConfig.
44
+ export interface FeedItemsBigQueryTarget {
45
+ project: string;
46
+ dataset: string;
47
+ table: string;
48
+ }
49
+
50
+ // Parsed from publicationGate* by GoogleConfig.
51
+ export interface PublicationGateConfig {
52
+ mode: 'off' | 'audit' | 'enforce';
53
+ project: string;
54
+ dataset: string;
55
+ table: string;
56
+ }