@bytebrand/export-cars 1.26.18 → 1.26.19-hotfix.2

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 (85) hide show
  1. package/README.md +58 -0
  2. package/dist/configs/config.d.ts +4 -1
  3. package/dist/configs/config.js +3 -0
  4. package/dist/configs/config.js.map +1 -1
  5. package/dist/configs/facebook-shop.config.js +1 -1
  6. package/dist/configs/facebook-shop.config.js.map +1 -1
  7. package/dist/configs/fb.config.js +1 -1
  8. package/dist/configs/fb.config.js.map +1 -1
  9. package/dist/configs/google.config.js +26 -1
  10. package/dist/configs/google.config.js.map +1 -1
  11. package/dist/constants/feed-price.constants.d.ts +1 -0
  12. package/dist/constants/feed-price.constants.js +4 -1
  13. package/dist/constants/feed-price.constants.js.map +1 -1
  14. package/dist/export.command.js +1 -0
  15. package/dist/export.command.js.map +1 -1
  16. package/dist/exporters/facebook-shop.exporter.js +1 -3
  17. package/dist/exporters/facebook-shop.exporter.js.map +1 -1
  18. package/dist/exporters/google.exporter.js +20 -2
  19. package/dist/exporters/google.exporter.js.map +1 -1
  20. package/dist/formats/facebook-shop.builder.d.ts +1 -1
  21. package/dist/formats/facebook-shop.builder.js +6 -9
  22. package/dist/formats/facebook-shop.builder.js.map +1 -1
  23. package/dist/interfaces/export-item.interface.d.ts +2 -2
  24. package/dist/interfaces/params.interface.d.ts +19 -0
  25. package/dist/services/feed-items-bigquery.writer.d.ts +17 -0
  26. package/dist/services/feed-items-bigquery.writer.js +96 -0
  27. package/dist/services/feed-items-bigquery.writer.js.map +1 -0
  28. package/dist/services/publication-gate.d.ts +15 -0
  29. package/dist/services/publication-gate.js +107 -0
  30. package/dist/services/publication-gate.js.map +1 -0
  31. package/dist/transformers/auto24.transfomer.js +1 -1
  32. package/dist/transformers/auto24.transfomer.js.map +1 -1
  33. package/dist/transformers/autouncle.transformer.js +1 -1
  34. package/dist/transformers/autouncle.transformer.js.map +1 -1
  35. package/dist/transformers/awin.transformer.js +1 -1
  36. package/dist/transformers/awin.transformer.js.map +1 -1
  37. package/dist/transformers/criteo.transformer.js +1 -1
  38. package/dist/transformers/criteo.transformer.js.map +1 -1
  39. package/dist/transformers/fb.transformer.js +2 -2
  40. package/dist/transformers/fb.transformer.js.map +1 -1
  41. package/dist/transformers/google-feed-envkv.d.ts +10 -0
  42. package/dist/transformers/google-feed-envkv.js +126 -0
  43. package/dist/transformers/google-feed-envkv.js.map +1 -0
  44. package/dist/transformers/google-feed-item-row.d.ts +30 -0
  45. package/dist/transformers/google-feed-item-row.js +39 -0
  46. package/dist/transformers/google-feed-item-row.js.map +1 -0
  47. package/dist/transformers/google.transformer.d.ts +0 -4
  48. package/dist/transformers/google.transformer.js +10 -48
  49. package/dist/transformers/google.transformer.js.map +1 -1
  50. package/dist/transformers/mobile.transformer.js +3 -2
  51. package/dist/transformers/mobile.transformer.js.map +1 -1
  52. package/dist/transformers/rubrikk.transformer.js +1 -1
  53. package/dist/transformers/rubrikk.transformer.js.map +1 -1
  54. package/dist/transformers/santander.transformer.js +3 -2
  55. package/dist/transformers/santander.transformer.js.map +1 -1
  56. package/dist/tsconfig.build.tsbuildinfo +1 -1
  57. package/package.json +4 -2
  58. package/schema/feed-items.json +134 -0
  59. package/src/configs/config.ts +7 -1
  60. package/src/configs/facebook-shop.config.ts +2 -2
  61. package/src/configs/fb.config.ts +2 -2
  62. package/src/configs/google.config.ts +28 -3
  63. package/src/constants/feed-price.constants.ts +7 -0
  64. package/src/export.command.ts +1 -0
  65. package/src/exporters/facebook-shop.exporter.ts +1 -3
  66. package/src/exporters/google.exporter.ts +23 -2
  67. package/src/formats/facebook-shop.builder.ts +8 -9
  68. package/src/interfaces/export-item.interface.ts +2 -2
  69. package/src/interfaces/params.interface.ts +29 -0
  70. package/src/services/feed-items-bigquery.writer.ts +101 -0
  71. package/src/services/publication-gate.ts +120 -0
  72. package/src/transformers/auto24.transfomer.ts +2 -1
  73. package/src/transformers/autouncle.transformer.ts +2 -1
  74. package/src/transformers/awin.transformer.ts +2 -1
  75. package/src/transformers/criteo.transformer.ts +2 -1
  76. package/src/transformers/fb.transformer.ts +5 -2
  77. package/src/transformers/google-feed-envkv.ts +168 -0
  78. package/src/transformers/google-feed-item-row.ts +78 -0
  79. package/src/transformers/google.transformer.ts +24 -50
  80. package/src/transformers/mobile.transformer.ts +6 -2
  81. package/src/transformers/rubrikk.transformer.ts +2 -1
  82. package/src/transformers/santander.transformer.ts +6 -2
  83. package/test/google-feed-envkv.test.ts +132 -0
  84. package/test/google-feed-item-row.test.ts +135 -0
  85. package/test/publication-gate.test.ts +86 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/export-cars",
3
- "version": "1.26.18",
3
+ "version": "1.26.19-hotfix.2",
4
4
  "description": "",
5
5
  "author": "bytebrand",
6
6
  "license": "UNLICENSED",
@@ -10,10 +10,12 @@
10
10
  "prepublishOnly": "npm run build",
11
11
  "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
12
12
  "lint": "eslint -c .eslintrc.js --ext .ts src/",
13
- "start:dev": "nest start --watch"
13
+ "start:dev": "nest start --watch",
14
+ "test": "ts-node --transpile-only test/google-feed-envkv.test.ts && ts-node --transpile-only test/google-feed-item-row.test.ts && ts-node --transpile-only test/publication-gate.test.ts"
14
15
  },
15
16
  "dependencies": {
16
17
  "@bytebrand/autode-request": "^1.19.2",
18
+ "@bytebrand/bigquery-logger": "^2.1.2",
17
19
  "@bytebrand/car-basic-data-scope": "^9.11.24",
18
20
  "@bytebrand/car-description": "^1.17.20",
19
21
  "@bytebrand/car-schema": "^3.14.15",
@@ -0,0 +1,134 @@
1
+ [
2
+ {
3
+ "name": "partitionDay",
4
+ "type": "DATE",
5
+ "mode": "NULLABLE",
6
+ "description": "UTC day of exportedAt; the table is partitioned by it"
7
+ },
8
+ {
9
+ "name": "exportedAt",
10
+ "type": "TIMESTAMP",
11
+ "mode": "NULLABLE",
12
+ "description": "Start of the export run (same for every row of a run)"
13
+ },
14
+ {
15
+ "name": "feed",
16
+ "type": "STRING",
17
+ "mode": "NULLABLE",
18
+ "description": "Feed the item was written to ('google')"
19
+ },
20
+ {
21
+ "name": "runId",
22
+ "type": "STRING",
23
+ "mode": "NULLABLE",
24
+ "description": "Cronicle job id of the run"
25
+ },
26
+ {
27
+ "name": "itemId",
28
+ "type": "STRING",
29
+ "mode": "NULLABLE",
30
+ "description": "<g:id> = CBD _id"
31
+ },
32
+ {
33
+ "name": "vehicleId",
34
+ "type": "STRING",
35
+ "mode": "NULLABLE",
36
+ "description": "metaData.vehicleId"
37
+ },
38
+ {
39
+ "name": "make",
40
+ "type": "STRING",
41
+ "mode": "NULLABLE",
42
+ "description": "<g:brand> as sent"
43
+ },
44
+ {
45
+ "name": "model",
46
+ "type": "STRING",
47
+ "mode": "NULLABLE",
48
+ "description": "<g:model> as sent"
49
+ },
50
+ {
51
+ "name": "condition",
52
+ "type": "STRING",
53
+ "mode": "NULLABLE",
54
+ "description": "<g:condition> as sent"
55
+ },
56
+ {
57
+ "name": "fuel",
58
+ "type": "STRING",
59
+ "mode": "NULLABLE",
60
+ "description": "consumption.fuel selector"
61
+ },
62
+ {
63
+ "name": "hybridPlugin",
64
+ "type": "BOOLEAN",
65
+ "mode": "NULLABLE",
66
+ "description": "engineData.hybridPlugin, null when unset"
67
+ },
68
+ {
69
+ "name": "engine",
70
+ "type": "STRING",
71
+ "mode": "NULLABLE",
72
+ "description": "<g:engine> as sent"
73
+ },
74
+ {
75
+ "name": "electricRange",
76
+ "type": "STRING",
77
+ "mode": "NULLABLE",
78
+ "description": "<g:electric_range> as sent, null when omitted"
79
+ },
80
+ {
81
+ "name": "fuelConsumption",
82
+ "type": "STRING",
83
+ "mode": "NULLABLE",
84
+ "description": "<g:fuel_consumption> as sent, null when omitted"
85
+ },
86
+ {
87
+ "name": "fuelConsumptionDischarged",
88
+ "type": "STRING",
89
+ "mode": "NULLABLE",
90
+ "description": "<g:fuel_consumption_discharged_battery> as sent, null when omitted"
91
+ },
92
+ {
93
+ "name": "energyConsumption",
94
+ "type": "STRING",
95
+ "mode": "NULLABLE",
96
+ "description": "<g:energy_consumption> as sent, null when omitted"
97
+ },
98
+ {
99
+ "name": "co2Emissions",
100
+ "type": "STRING",
101
+ "mode": "NULLABLE",
102
+ "description": "<g:co2_emissions> as sent, null when omitted"
103
+ },
104
+ {
105
+ "name": "rangeSourceField",
106
+ "type": "STRING",
107
+ "mode": "NULLABLE",
108
+ "description": "Car field electric_range came from: battery.wltpRange / battery.wltpTotalRange / battery.batteryRangeElectric"
109
+ },
110
+ {
111
+ "name": "price",
112
+ "type": "FLOAT",
113
+ "mode": "NULLABLE",
114
+ "description": "<g:price> amount as sent (EUR)"
115
+ },
116
+ {
117
+ "name": "availability",
118
+ "type": "STRING",
119
+ "mode": "NULLABLE",
120
+ "description": "<g:availability> as sent"
121
+ },
122
+ {
123
+ "name": "publicationGate",
124
+ "type": "STRING",
125
+ "mode": "NULLABLE",
126
+ "description": "EnVKV C8 gate mode of the run (audit / enforce), null when off"
127
+ },
128
+ {
129
+ "name": "estimatedAttributes",
130
+ "type": "STRING",
131
+ "mode": "NULLABLE",
132
+ "description": "Comma-separated EnVKV attributes whose value CBD estimated; left out of the item in enforce mode"
133
+ }
134
+ ]
@@ -1,5 +1,5 @@
1
1
  import { Environment, ExportType, ImageType } from '../enums';
2
- import { Params } from '../interfaces/params.interface';
2
+ import { FeedItemsBigQueryTarget, Params, PublicationGateConfig } from '../interfaces/params.interface';
3
3
 
4
4
  export class Config {
5
5
  apikey: string;
@@ -39,6 +39,12 @@ export class Config {
39
39
  dealersSpreadsheetId: string;
40
40
  dealersSpreadsheetTitle: string;
41
41
  metaPushPriorityCsvUrl: string;
42
+ // Cronicle job id of the run (set by ExportRunner), stored with the feed item rows.
43
+ runId?: string;
44
+ // Set only when the job configures the feed items BigQuery table; unset = no BigQuery calls.
45
+ feedItemsBigQuery?: FeedItemsBigQueryTarget;
46
+ // EnVKV C8 publication gate of the Google feed; unset = off.
47
+ publicationGate?: PublicationGateConfig;
42
48
 
43
49
  protected environmentConfig = {
44
50
  [Environment.DEVELOP]: {
@@ -1,13 +1,13 @@
1
1
  import { Params } from '../interfaces/params.interface';
2
2
  import { Config } from './config';
3
3
  import { ExportType, FileType } from '../enums';
4
- import { MAX_FEED_SALES_PRICE_EUR } from '../constants/feed-price.constants';
4
+ import { resolveMaxFeedSalesPrice } from '../constants/feed-price.constants';
5
5
 
6
6
  export class FacebookShopConfig extends Config {
7
7
  constructor(params: Params) {
8
8
  super(params);
9
9
  this.pathesConfig.localConvertedDirectory = `resources/${ExportType.FACEBOOK_SHOP}-export`;
10
- this.maxCurrentSalesPrice = MAX_FEED_SALES_PRICE_EUR;
10
+ this.maxCurrentSalesPrice = resolveMaxFeedSalesPrice(params.maxCurrentSalesPrice);
11
11
  this.imageUrl = this.environmentConfig[params.environment].baseImageUrl;
12
12
  this.outputFiles = [
13
13
  `${this.pathesConfig.localConvertedDirectory}/api_export_cars_${ExportType.FACEBOOK_SHOP}.${FileType.XML}`,
@@ -1,5 +1,5 @@
1
1
  import { ExportType, FileType, ImageType } from 'src/enums';
2
- import { MAX_FEED_SALES_PRICE_EUR } from '../constants/feed-price.constants';
2
+ import { resolveMaxFeedSalesPrice } from '../constants/feed-price.constants';
3
3
  import { Params } from '../interfaces/params.interface';
4
4
  import { Config } from './config';
5
5
 
@@ -7,7 +7,7 @@ export class FbConfig extends Config {
7
7
  constructor(params: Params) {
8
8
  super(params);
9
9
  this.pathesConfig.localConvertedDirectory = `resources/${ExportType.FB}-export`;
10
- this.maxCurrentSalesPrice = MAX_FEED_SALES_PRICE_EUR;
10
+ this.maxCurrentSalesPrice = resolveMaxFeedSalesPrice(params.maxCurrentSalesPrice);
11
11
  this.imageUrl = this.environmentConfig[params.environment].imageUrl;
12
12
  this.imageType = ImageType.marketing;
13
13
  this.marketingImageConfigId = this.environmentConfig[params.environment].fbMarketingImageId;
@@ -1,14 +1,39 @@
1
1
  import { ExportType, FileType, ImageType } from 'src/enums';
2
- import { MAX_FEED_SALES_PRICE_EUR } from '../constants/feed-price.constants';
3
- import { Params } from '../interfaces/params.interface';
2
+ import { resolveMaxFeedSalesPrice } from '../constants/feed-price.constants';
3
+ import { Params, PublicationGateConfig } from '../interfaces/params.interface';
4
4
  import { Config } from './config';
5
5
 
6
+ const trimmed = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
7
+
6
8
  export class GoogleConfig extends Config {
7
9
  constructor(params: Params) {
8
10
  super(params);
9
11
  this.pathesConfig.localConvertedDirectory = `resources/${ExportType.GOOGLE}-export`;
10
12
  this.imageType = ImageType.default;
11
- this.maxCurrentSalesPrice = MAX_FEED_SALES_PRICE_EUR;
13
+ this.maxCurrentSalesPrice = resolveMaxFeedSalesPrice(params.maxCurrentSalesPrice);
14
+ const feedItemsBigQuery = {
15
+ project: trimmed(params.feedItemsBqProject),
16
+ dataset: trimmed(params.feedItemsBqDataset),
17
+ table: trimmed(params.feedItemsBqTable),
18
+ };
19
+ if (feedItemsBigQuery.project && feedItemsBigQuery.dataset && feedItemsBigQuery.table) {
20
+ this.feedItemsBigQuery = feedItemsBigQuery;
21
+ }
22
+ const gateMode = trimmed(params.publicationGate).toLowerCase();
23
+ if (gateMode === 'audit' || gateMode === 'enforce') {
24
+ const gate = {
25
+ mode: gateMode as PublicationGateConfig['mode'],
26
+ project: trimmed(params.publicationGateBqProject),
27
+ dataset: trimmed(params.publicationGateBqDataset),
28
+ table: trimmed(params.publicationGateBqTable),
29
+ };
30
+ if (!gate.project || !gate.dataset || !gate.table) {
31
+ throw new Error(`publicationGate=${gateMode} needs publicationGateBqProject, publicationGateBqDataset and publicationGateBqTable`);
32
+ }
33
+ this.publicationGate = gate;
34
+ } else if (gateMode && gateMode !== 'off') {
35
+ throw new Error(`publicationGate must be off, audit or enforce, got "${gateMode}"`);
36
+ }
12
37
  this.outputFiles = [
13
38
  `${this.pathesConfig.localConvertedDirectory}/api_export_cars_${ExportType.GOOGLE}.${FileType.XML}`,
14
39
  ];
@@ -1,4 +1,11 @@
1
+ import { toNumber } from 'lodash';
2
+
1
3
  // Shared eligibility cap for the Google Vehicle Ads and Meta AIA/AMA feeds:
2
4
  // only cars whose end-customer selling price (price.currentSalesPrice) is
3
5
  // ≤ €80,000 may be exported. €80,000.00 itself is still included.
4
6
  export const MAX_FEED_SALES_PRICE_EUR = 80_000;
7
+
8
+ // The Cronicle "Max currentSalesPrice" plugin param (a text field) can override
9
+ // the default cap per event; empty/invalid/non-positive values fall back to it.
10
+ export const resolveMaxFeedSalesPrice = (raw?: string): number =>
11
+ toNumber(raw) > 0 ? toNumber(raw) : MAX_FEED_SALES_PRICE_EUR;
@@ -27,6 +27,7 @@ export class ExportRunner extends CommandRunner {
27
27
  LoggerService.log(`Running job: ${input.id} - ${JSON.stringify(input)} \n`);
28
28
 
29
29
  this.configService.config = new exportSchema[input.params.exportType].config(input.params);
30
+ this.configService.config.runId = input.id;
30
31
  const exporter = await this.moduleRef.create<Exporter>(exportSchema[input.params.exportType].exporter);
31
32
  await exporter.export().catch(reject);
32
33
 
@@ -1,6 +1,5 @@
1
1
  import { Injectable } from '@nestjs/common';
2
2
  import { ModuleRef } from '@nestjs/core';
3
- import { basename } from 'path';
4
3
 
5
4
  import { CronicleUtils } from '@bytebrand/cronicle-helper';
6
5
  import { FsChunkInterface } from '@bytebrand/chunkable';
@@ -25,9 +24,8 @@ export class FacebookShopExporter implements Exporter {
25
24
  await CronicleUtils.ensureDir(this.configService.config.pathesConfig.localConvertedDirectory);
26
25
  await CronicleUtils.emptyDir(this.configService.config.pathesConfig.localConvertedDirectory);
27
26
 
28
- const selfUrl = `https://ftp.${this.configService.config.domain}/feeds/${basename(fileXml)}`;
29
27
  const facebookShopXmlBuilder = new FacebookShopXmlBuilder(fileXml);
30
- await facebookShopXmlBuilder.buildStart(selfUrl);
28
+ await facebookShopXmlBuilder.buildStart();
31
29
 
32
30
  const facebookShopTransformer = await this.moduleRef.create(FacebookShopTransformer);
33
31
 
@@ -6,6 +6,9 @@ import { FsChunkInterface } from '@bytebrand/chunkable';
6
6
 
7
7
  import { CarService, ConfigService, LoaderService, RulesService } from '../services';
8
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';
9
12
  import { GoogleXmlBuilder } from '../formats/google-xml.builder';
10
13
  import { Exporter } from '../interfaces/exporter.interface';
11
14
 
@@ -20,6 +23,9 @@ export class GoogleExporter implements Exporter {
20
23
  ) { }
21
24
 
22
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);
23
29
  const [fileName] = this.configService.config.outputFiles;
24
30
  await CronicleUtils.ensureDir(this.configService.config.pathesConfig.localConvertedDirectory);
25
31
  await CronicleUtils.emptyDir(this.configService.config.pathesConfig.localConvertedDirectory);
@@ -29,18 +35,33 @@ export class GoogleExporter implements Exporter {
29
35
 
30
36
  const googleTransformer = await this.moduleRef.create(GoogleTransformer);
31
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
+
32
44
  await this.loaderService.process(async (chunk: FsChunkInterface) => {
33
45
  chunk.data = chunk.data.filter(car => this.carService.filterCar(car, this.rulesService.rules));
34
46
 
35
47
  for (const data of chunk.data) {
36
48
  const car = await googleTransformer.transform(data);
37
- if (car)
38
- await googleXmlBuilder.addItem(car);
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
+ }
39
56
  this.carService.counter.countCar(data.images);
40
57
  }
41
58
  });
42
59
 
43
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();
44
65
  console.log(this.carService.counter.getCountedCars());
45
66
  }
46
67
  }
@@ -8,18 +8,17 @@ export class FacebookShopXmlBuilder extends XMLBuilderBase {
8
8
  // No xmlns:g / g: prefixes here — that namespace belongs to the old Google Merchant /
9
9
  // Facebook Shop product-catalog schema. Meta's Automotive Model Ads Vehicle Offers Feed
10
10
  // uses plain, undecorated tag names (see facebook-shop.builder field reference above).
11
- async buildStart(selfUrl?: string): Promise<void> {
11
+ // Envelope is <listings>/<listing>, not RSS <rss><channel>/<item>: Meta Commerce Manager
12
+ // rejects the RSS form for a Vehicle Offers catalog as an invalid file format (ADV-369).
13
+ async buildStart(): Promise<void> {
12
14
  await this.write(
13
- '<?xml version="1.0" encoding="utf-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">' +
14
- '\n<channel>\n<title>auto.de Vehicle Offers Feed</title>' +
15
- '\n<description>Automotive Model Ads Vehicle Offers Feed für auto.de</description>' +
16
- '\n<link>https://www.auto.de/</link>' +
17
- `\n<atom:link href="${escape(selfUrl)}" rel="self" type="application/rss+xml" />\n`,
15
+ '<?xml version="1.0" encoding="utf-8"?>\n<listings>' +
16
+ '\n<title>auto.de Vehicle Offers Feed</title>\n',
18
17
  );
19
18
  }
20
19
 
21
20
  async addItem(car: FacebookShopItem): Promise<void> {
22
- let item = '\n<item>\n';
21
+ let item = '\n<listing>\n';
23
22
 
24
23
  const { image, ...tags } = car;
25
24
 
@@ -32,7 +31,7 @@ export class FacebookShopXmlBuilder extends XMLBuilderBase {
32
31
  .map((url: string) => `<image><url>${escape(url)}</url></image>\n`)
33
32
  .join('');
34
33
 
35
- item += '</item>';
34
+ item += '</listing>';
36
35
 
37
36
  await this.write(item);
38
37
  }
@@ -53,7 +52,7 @@ export class FacebookShopXmlBuilder extends XMLBuilderBase {
53
52
  }
54
53
 
55
54
  async buildEnd(): Promise<void> {
56
- await this.write('\n</channel>\n</rss>\n');
55
+ await this.write('\n</listings>\n');
57
56
  await this.end();
58
57
  }
59
58
  }
@@ -199,11 +199,11 @@ export interface GoogleXmlItem {
199
199
  doors?: number;
200
200
  capacity?: number;
201
201
  date_first_registered: string,
202
- fuel_consumption: string,
202
+ fuel_consumption?: string,
203
203
  energy_consumption?: string,
204
204
  electric_range?: string,
205
205
  fuel_consumption_discharged_battery?: string,
206
- co2_emissions: string,
206
+ co2_emissions?: string,
207
207
  identifier_exists: string,
208
208
  ads_redirect?: string;
209
209
  "certified_pre-owned"?: boolean;
@@ -11,4 +11,33 @@ export interface Params {
11
11
  cpc?: string;
12
12
  ownerId?: string;
13
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
+ }
29
+
30
+ // Parsed from feedItemsBq* by GoogleConfig.
31
+ export interface FeedItemsBigQueryTarget {
32
+ project: string;
33
+ dataset: string;
34
+ table: string;
35
+ }
36
+
37
+ // Parsed from publicationGate* by GoogleConfig.
38
+ export interface PublicationGateConfig {
39
+ mode: 'off' | 'audit' | 'enforce';
40
+ project: string;
41
+ dataset: string;
42
+ table: string;
14
43
  }
@@ -0,0 +1,101 @@
1
+ import { join } from 'path';
2
+ import { BigQueryLogger } from '@bytebrand/bigquery-logger';
3
+ import { FeedItemsBigQueryTarget } from '../interfaces/params.interface';
4
+ import { FeedItemRow } from '../transformers/google-feed-item-row';
5
+
6
+ const BATCH_SIZE = 500;
7
+ // After this many failed batches the writer gives up for the run (missing table, bad credentials).
8
+ const MAX_FAILED_BATCHES = 3;
9
+ const CLOSE_TIMEOUT_MS = 60000;
10
+
11
+ class FeedItemsBigQueryLogger extends BigQueryLogger {
12
+ async insertRows(rows: FeedItemRow[]): Promise<void> {
13
+ await this.client
14
+ .dataset(this.options.BigQueryDataset)
15
+ .table(this.options.BigQueryLogTable)
16
+ .insert(rows, { skipInvalidRows: true, ignoreUnknownValues: true });
17
+ }
18
+ }
19
+
20
+ // Audit trail of the feed items: rows are buffered and inserted in batches of 500 in the background,
21
+ // one batch at a time. Errors are logged and swallowed — the feed export must never fail because of
22
+ // BigQuery.
23
+ export class FeedItemsBigQueryWriter {
24
+ private logger: FeedItemsBigQueryLogger;
25
+ private buffer: FeedItemRow[] = [];
26
+ private queue: Promise<void> = Promise.resolve();
27
+ private failedBatches = 0;
28
+ private insertedRows = 0;
29
+
30
+ constructor(private target: FeedItemsBigQueryTarget) {
31
+ try {
32
+ const credentials = require(join(process.cwd(), 'credentials/google-cloud-project-id.json'));
33
+ this.logger = new FeedItemsBigQueryLogger(credentials, {
34
+ BigQueryProject: target.project,
35
+ BigQueryDataset: target.dataset,
36
+ BigQueryLogTable: target.table,
37
+ });
38
+ } catch (e) {
39
+ // First line only: a missing credentials file comes with the whole require stack.
40
+ console.log(`Feed items BigQuery disabled: ${String(e?.message || e).split('\n')[0]}`);
41
+ }
42
+ }
43
+
44
+ add(row: FeedItemRow): void {
45
+ if (!this.enabled) return;
46
+ this.buffer.push(row);
47
+ if (this.buffer.length >= BATCH_SIZE) this.flush();
48
+ }
49
+
50
+ // Waits for the pending batches, but never longer than CLOSE_TIMEOUT_MS: a hanging BigQuery
51
+ // must not hold up the feed export (the XML is already written at this point).
52
+ async close(): Promise<void> {
53
+ this.flush();
54
+ let timer: NodeJS.Timeout | undefined;
55
+ const timeout = new Promise<'timeout'>((resolve) => {
56
+ timer = setTimeout(() => resolve('timeout'), CLOSE_TIMEOUT_MS);
57
+ });
58
+ const outcome = await Promise.race([this.queue.then(() => 'done' as const), timeout]);
59
+ if (timer) clearTimeout(timer);
60
+ if (outcome === 'timeout') console.error(`Feed items BigQuery: gave up waiting after ${CLOSE_TIMEOUT_MS} ms`);
61
+ if (this.logger) {
62
+ console.log(`Feed items BigQuery: ${this.insertedRows} rows to ${this.tableName}, ${this.failedBatches} failed batches`);
63
+ }
64
+ }
65
+
66
+ private get enabled(): boolean {
67
+ return !!this.logger && this.failedBatches < MAX_FAILED_BATCHES;
68
+ }
69
+
70
+ private get tableName(): string {
71
+ return `${this.target.project}.${this.target.dataset}.${this.target.table}`;
72
+ }
73
+
74
+ private flush(): void {
75
+ if (!this.buffer.length) return;
76
+ const rows = this.buffer;
77
+ this.buffer = [];
78
+ this.queue = this.queue.then(() => this.insert(rows));
79
+ }
80
+
81
+ private async insert(rows: FeedItemRow[]): Promise<void> {
82
+ if (!this.enabled) return;
83
+ try {
84
+ await this.logger.insertRows(rows);
85
+ this.insertedRows += rows.length;
86
+ } catch (e) {
87
+ // PartialFailureError: the valid rows went in (skipInvalidRows), `errors` lists the rejected
88
+ // ones; the first is enough to see the cause.
89
+ const rejected: any[] = e?.name === 'PartialFailureError' && Array.isArray(e.errors) ? e.errors : null;
90
+ const firstRowError = rejected?.[0]?.errors?.[0];
91
+ const detail = firstRowError ? ` (${firstRowError.reason}: ${firstRowError.message})` : '';
92
+ if (rejected) {
93
+ this.insertedRows += rows.length - rejected.length;
94
+ console.log(`Feed items BigQuery rejected ${rejected.length} of ${rows.length} rows${detail}`);
95
+ } else {
96
+ this.failedBatches++;
97
+ console.log(`Feed items BigQuery insert of ${rows.length} rows failed: ${e?.message || e}`);
98
+ }
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,120 @@
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, the global defaults, and a weighted fuel / CO2 derived from
22
+ // its pair value.
23
+ export const ESTIMATE_SOURCES = ['co2-estimate', 'mmm', 'globalDefault', 'derived'];
24
+
25
+ // Every car field a Google EnVKV attribute can be taken from (google-feed-envkv picks).
26
+ export const GATED_FIELDS = [
27
+ 'consumption.wltpWeightedCombined',
28
+ 'consumption.wltpCombined',
29
+ 'consumption.consumptionCombined',
30
+ 'consumption.wltpWeightedPowerCombined',
31
+ 'consumption.wltpPowerCombined',
32
+ 'consumption.consumptionPowerCombined',
33
+ 'environmentEmissions.wltpCo2',
34
+ 'environmentEmissions.co2',
35
+ 'battery.wltpTotalRange',
36
+ 'battery.wltpRange',
37
+ 'battery.batteryRangeElectric',
38
+ ];
39
+
40
+ // '7.10' and 7.1 are the same value; non-numeric values compare as strings.
41
+ const sameValue = (logged: string, stored: unknown): boolean => {
42
+ if (stored === undefined || stored === null) return false;
43
+ const a = Number(logged);
44
+ const b = Number(stored);
45
+ if (Number.isFinite(a) && Number.isFinite(b)) return Math.abs(a - b) < 1e-9;
46
+ return logged === String(stored);
47
+ };
48
+
49
+ export class PublicationGate {
50
+ // estimates: `${carId}|${field}` -> final value of the latest estimate row
51
+ constructor(readonly mode: 'off' | 'audit' | 'enforce', private estimates: Map<string, string>) {}
52
+
53
+ static off(): PublicationGate {
54
+ return new PublicationGate('off', new Map());
55
+ }
56
+
57
+ get size(): number {
58
+ return this.estimates.size;
59
+ }
60
+
61
+ // The attributes of this car whose value comes from an estimated field, in a fixed order.
62
+ estimatedAttributes(car: DataSchemes.Car): GoogleEnvkvAttribute[] {
63
+ if (this.mode === 'off') return [];
64
+ const carId = String(get(car, '_id', ''));
65
+ const attributes: GoogleEnvkvAttribute[] = [];
66
+ for (const [attribute, field] of Object.entries(googleEnvkvSourceFields(car))) {
67
+ const logged = this.estimates.get(`${carId}|${field}`);
68
+ if (logged !== undefined && sameValue(logged, get(car, field))) attributes.push(attribute as GoogleEnvkvAttribute);
69
+ }
70
+ return attributes;
71
+ }
72
+
73
+ // enforce: drops the estimated attributes from the item. Returns the item for chaining.
74
+ apply<T extends Partial<Record<GoogleEnvkvAttribute, string>>>(item: T, attributes: GoogleEnvkvAttribute[]): T {
75
+ if (this.mode !== 'enforce') return item;
76
+ for (const attribute of attributes) delete item[attribute];
77
+ return item;
78
+ }
79
+ }
80
+
81
+ class EnrichmentLogReader extends BigQueryLogger {
82
+ async latestEstimates(table: string): Promise<Map<string, string>> {
83
+ const query = `
84
+ SELECT carId, fieldName, finalValue
85
+ FROM (
86
+ SELECT carId, fieldName, finalValue, finalValueSource,
87
+ ROW_NUMBER() OVER (PARTITION BY carId, fieldName ORDER BY timestamp DESC) AS rn
88
+ FROM \`${table}\`
89
+ WHERE fieldName IN UNNEST(@fields)
90
+ )
91
+ WHERE rn = 1 AND finalValueSource IN UNNEST(@sources) AND finalValue IS NOT NULL`;
92
+ const [rows] = await this.client.query({ query, params: { fields: GATED_FIELDS, sources: ESTIMATE_SOURCES } });
93
+ const estimates = new Map<string, string>();
94
+ for (const row of rows) estimates.set(`${row.carId}|${row.fieldName}`, String(row.finalValue));
95
+ return estimates;
96
+ }
97
+ }
98
+
99
+ // Reads the estimates once per run. audit: a failure only switches the gate off. enforce: a failure
100
+ // fails the export, so the last published feed stays in place instead of a feed with unchecked estimates.
101
+ export async function loadPublicationGate(config: PublicationGateConfig | undefined): Promise<PublicationGate> {
102
+ if (!config || config.mode === 'off') return PublicationGate.off();
103
+ const table = `${config.project}.${config.dataset}.${config.table}`;
104
+ try {
105
+ const credentials = require(join(process.cwd(), 'credentials/google-cloud-project-id.json'));
106
+ const reader = new EnrichmentLogReader(credentials, {
107
+ BigQueryProject: config.project,
108
+ BigQueryDataset: config.dataset,
109
+ BigQueryLogTable: config.table,
110
+ });
111
+ const estimates = await reader.latestEstimates(table);
112
+ console.log(`Publication gate ${config.mode}: ${estimates.size} estimated car fields from ${table}`);
113
+ return new PublicationGate(config.mode, estimates);
114
+ } catch (e) {
115
+ const message = String(e?.message || e).split('\n')[0];
116
+ if (config.mode === 'enforce') throw new Error(`Publication gate: cannot read ${table}: ${message}`);
117
+ console.error(`Publication gate disabled for this run: cannot read ${table}: ${message}`);
118
+ return PublicationGate.off();
119
+ }
120
+ }