@bytebrand/export-cars 1.26.45 → 1.26.47

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.
@@ -13,7 +13,7 @@ letters are code evidence, not a contract.
13
13
  | Row format | `MobileTransformer` + Google sheet `147qz9sT…` tab `mobile_yescar` (165 rules, ACF-4370 reused the YesCar / mobile.de CSV). `;` separated, every cell quoted, no header row, UTF-8, 517–528 columns per row |
14
14
  | Files | `api_export_cars_marktde.csv` + `.zip` (was `resources/marktde-export/`) |
15
15
  | DEV Cronicle | `9c.0 Export markt.de - part 1` `ekpy4pqg21u` (rule `metaData.ownerId:xo1ag9JooQebXhnfSclw3NhZyG63`) → `9c.1 … part 2` `ekyvib2rm06` (plain `ftp -pinv 185.15.211.90`, uploads `develop_marktde.zip/.csv`, credentials in the script) → `9d.0 facebook shop`. Triggered by the chain after `9b.1 Export Focus`. No run in the job history |
16
- | DEV runtime defects found | (a) the Google Sheets API key in `9c.0` is **expired** — the mapping sheet cannot load, every row-building run fails; (b) the filtered dealer has 0 cars in the DEV snapshot; (c) the host has no `zip` binary — the legacy run wrote the CSV, failed the ZIP and still reported `code 0`; (d) `9c.1` uploads names the exporter never writes; (e) 185.15.211.90 is the legacy Jelastic FTP (port 21 closed, see ADV-319 4.1) |
16
+ | DEV runtime defects found | (a) the Google Sheets API key in `9c.0` is **expired** — the mapping sheet could not load (fixed in 1.26.47: public CSV, no key); (b) the filtered dealer has 0 cars in the DEV snapshot; (c) the host has no `zip` binary — the legacy run wrote the CSV, failed the ZIP and still reported `code 0`; (d) `9c.1` uploads names the exporter never writes; (e) 185.15.211.90 is the legacy Jelastic FTP (port 21 closed, see ADV-319 4.1) |
17
17
  | PROD | not checked (DEV-only task). Old contract / account belong to the former auto.de Media GmbH — not transferable (Daniel, 16.10) |
18
18
 
19
19
  ## 2. Source → target matrix (columns the feed relies on)
@@ -4,7 +4,7 @@
4
4
 
5
5
  | Param | Default | Meaning |
6
6
  |---|---|---|
7
- | `apikey` | — | Google Sheets API key for the mapping sheet `mobile_yescar`. **DEV 9c.0: expired (12.11.2026)** |
7
+ | `apikey` | — | no longer used for the mapping (since 1.26.47 the tab `mobile_yescar` is read from the sheet's public CSV, no key); the sheet must stay readable by everyone with the link. The DEV 9c.0 key is expired (12.11.2026) |
8
8
  | `String` / `Number` / `Date` | — | Cronicle filter rules (DEV 9c.0: one dealer, 0 cars today) |
9
9
  | `marktdeDryRun` | dry-run | only the exact text `false` makes a live run |
10
10
  | `marktdeMaxImages` | no cut | cut the image list (no markt.de limit known) |
@@ -42,7 +42,7 @@ the API with `chain: ""` (see below) or from the UI with the chain removed for t
42
42
  D=/home/deploy/export-cars-<ver>-dryrun/<name> # job.json (chmod 600) + credentials + resources/cars-export symlinks
43
43
  NODE_PATH=<pkg>/dist:/opt/cronicle/node_modules node <pkg>/dist/main.js < job.json > run.log
44
44
  ```
45
- While the event's Sheets key is expired: `node harness.js <pkg> < job.json` — the harness runs the same
45
+ Before 1.26.47 (Sheets API key expired): `node harness.js <pkg> < job.json` — the harness runs the same
46
46
  `main.js` but loads the mapping rules from the public CSV of the same sheet tab (gviz) with the same
47
47
  post-processing as `MappingService`.
48
48
 
@@ -50,7 +50,7 @@ post-processing as `MappingService`.
50
50
 
51
51
  1. Signed contract / account for auto.de GmbH, current markt.de feed spec + test importer
52
52
  (`PARTNER-QUESTIONS.md`); written acceptance of a test import of the 20-row sample.
53
- 2. New Sheets API key in the 9c.0 params (or move the mapping out of Google Sheets).
53
+ 2. Keep the mapping sheet shared with everyone who has the link (1.26.47 reads its public CSV; a private sheet fails the run).
54
54
  3. Dealer scope (the rule in 9c.0) and `feedGuardMinPublishedItems` from a PROD dry-run.
55
55
  4. Replace `9c.1`: transport and file names from the spec, credentials out of the script; never the
56
56
  legacy Jelastic host. Only then `marktdeDryRun=false`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/export-cars",
3
- "version": "1.26.45",
3
+ "version": "1.26.47",
4
4
  "description": "",
5
5
  "author": "bytebrand",
6
6
  "license": "UNLICENSED",
@@ -11,7 +11,7 @@
11
11
  "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
12
12
  "lint": "eslint -c .eslintrc.js --ext .ts src/",
13
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 && ts-node --transpile-only test/images-regression.test.ts && ts-node --transpile-only test/clean-images.test.ts && ts-node --transpile-only test/image-url-validator.test.ts && ts-node --transpile-only test/openai-image-fields.test.ts && ts-node --transpile-only test/openai-ads-mapping.test.ts && ts-node --transpile-only test/openai-ads-feed-run.test.ts && ts-node --transpile-only test/openai-ads-finance.test.ts && ts-node --transpile-only test/openai-ads-price-cap.test.ts && ts-node --transpile-only test/meinstadt-mapping.test.ts && ts-node --transpile-only test/meinstadt-feed-run.test.ts && ts-node --transpile-only test/autouncle-mapping.test.ts && ts-node --transpile-only test/autouncle-feed-run.test.ts && ts-node --transpile-only test/marktde-mapping.test.ts && ts-node --transpile-only test/marktde-feed-run.test.ts && ts-node --transpile-only test/criteo-images.test.ts && ts-node --transpile-only test/criteo-mapping.test.ts && ts-node --transpile-only test/criteo-feed-run.test.ts"
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 && ts-node --transpile-only test/images-regression.test.ts && ts-node --transpile-only test/clean-images.test.ts && ts-node --transpile-only test/image-url-validator.test.ts && ts-node --transpile-only test/openai-image-fields.test.ts && ts-node --transpile-only test/openai-ads-mapping.test.ts && ts-node --transpile-only test/openai-ads-feed-run.test.ts && ts-node --transpile-only test/openai-ads-finance.test.ts && ts-node --transpile-only test/openai-ads-price-cap.test.ts && ts-node --transpile-only test/meinstadt-mapping.test.ts && ts-node --transpile-only test/meinstadt-feed-run.test.ts && ts-node --transpile-only test/autouncle-mapping.test.ts && ts-node --transpile-only test/autouncle-feed-run.test.ts && ts-node --transpile-only test/mapping-csv.test.ts && ts-node --transpile-only test/marktde-mapping.test.ts && ts-node --transpile-only test/marktde-feed-run.test.ts && ts-node --transpile-only test/criteo-images.test.ts && ts-node --transpile-only test/criteo-mapping.test.ts && ts-node --transpile-only test/criteo-feed-run.test.ts"
15
15
  },
16
16
  "dependencies": {
17
17
  "@bytebrand/autode-request": "^1.19.2",
@@ -28,7 +28,7 @@ export const TERM_QUALIFIER_MONTHS = 'months';
28
28
 
29
29
  export const OFFER_DISCLAIMER = 'Für Ihre Finanzierungswünsche stellen wir eine Bonitätsanfrage. Bonität ' +
30
30
  'vorausgesetzt, ist dies ein repräsentatives Berechnungsbeispiel gem. der Angaben, welches 2/3 aller Kunden, ' +
31
- 'im Sinne des § 17a Abs. 4 PAngV, erhalten. Dieses freibleibende Angebot der Santander Consumer Bank AG, ' +
31
+ 'im Sinne des § 17a Abs. 4 PAngV, erhalten. Dieses freibleibende Angebot der Openbank Deutschland AG, ' +
32
32
  'Santander-Platz 1, 41061 Mönchengladbach wird vermittelt durch die auto.de GmbH, Max-Planck-Str. 19, 06796 ' +
33
33
  'Sandersdorf-Brehna, die als ungebundener Vermittler nicht beratend tätig ist. Irrtümer vorbehalten. Preise ' +
34
34
  'ggf. inkl. MwSt.';
@@ -1,11 +1,22 @@
1
1
  import { Injectable } from '@nestjs/common';
2
2
  import axios from 'axios';
3
+ import { CronicleUtils } from '@bytebrand/cronicle-helper';
3
4
  import { MappingRules } from '../interfaces/mapping-rules.interface';
4
5
  import { GoogleSpreadsheet, GoogleSpreadsheetRow } from 'google-spreadsheet';
5
6
  import { ConfigService } from './config.service';
6
7
  import { DealersMapping, MetaPushPriorityMapping } from '../interfaces/dealers-mapping.interface';
7
8
  import { parseCsvToObjects } from '../utils';
8
9
 
10
+ // Public CSV of one tab of a Google sheet, addressed by the tab title — no API key. The sheet must stay
11
+ // readable by everyone with the link. (Checked 13.11.2026: gviz CSV = the raw export of the tabs
12
+ // mobile_yescar and mobile, cell for cell; gviz only adds trailing empty columns.)
13
+ export const mappingSheetCsvUrl = (spreadsheetId: string, title: string): string =>
14
+ `https://docs.google.com/spreadsheets/d/${spreadsheetId}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(title)}`;
15
+
16
+ // Columns every mapping tab read by getRules() has; missing ones mean the response is not the tab
17
+ // (e.g. Google's sign-in page once the sheet is no longer shared).
18
+ const REQUIRED_MAPPING_COLUMNS = ['importField', 'importFieldType', 'autodeField', 'autodeFieldType'];
19
+
9
20
  @Injectable()
10
21
  export class MappingService {
11
22
  constructor(private configService: ConfigService) {}
@@ -13,24 +24,54 @@ export class MappingService {
13
24
  private dealers: any;
14
25
  private metaPushPriorities: MetaPushPriorityMapping[];
15
26
 
27
+ /**
28
+ * Mapping rules of the export type's tab (mappingSpreadsheetTitle) of the mapping sheet. Read from the
29
+ * sheet's public CSV instead of the Google Sheets API: the API needed the event's `apikey`, and an
30
+ * expired key (DEV 9c.0, found 12.11.2026) silently broke every feed on the sheet. Fails the run
31
+ * when the tab cannot be read or yields no rule — never an export without a mapping.
32
+ */
16
33
  async getRules(): Promise<MappingRules[]> {
17
34
  if (!this.rules) {
18
- const spreadsheet = new GoogleSpreadsheet(this.configService.config.mappingSpreadsheetId);
19
- spreadsheet.useApiKey(this.configService.config.apikey);
20
- await spreadsheet.loadInfo();
21
- const rows = await spreadsheet.sheetsByTitle[this.configService.config.mappingSpreadsheetTitle].getRows();
22
- const mappingRules = this.rowsToRules(rows) as MappingRules[];
35
+ const { mappingSpreadsheetId, mappingSpreadsheetTitle } = this.configService.config;
36
+ const url = mappingSheetCsvUrl(mappingSpreadsheetId, mappingSpreadsheetTitle);
37
+ const { data } = await CronicleUtils.retry(
38
+ () => axios.get<string>(url, { responseType: 'text', timeout: 30_000 }),
39
+ 3,
40
+ 5_000,
41
+ true,
42
+ );
43
+ this.rules = this.rulesFromCsv(data, mappingSpreadsheetTitle);
44
+ console.log(`mapping: ${this.rules.length} rules from the public CSV of tab ${mappingSpreadsheetTitle}`);
45
+ }
46
+ return this.rules;
47
+ }
23
48
 
24
- mappingRules.map((rule, _i, selfRules) => {
25
- this.removeEmptyKeys(rule);
26
- if (rule.importFieldType && rule.importFieldType.includes('selector')) {
27
- this.groupSelectors(rule, selfRules);
28
- }
29
- });
49
+ // CSV text of a mapping tab -> rules, with the same post-processing the Sheets API rows had.
50
+ rulesFromCsv(csv: string, title: string): MappingRules[] {
51
+ const rows = parseCsvToObjects<Record<string, string>>(typeof csv === 'string' ? csv : '');
52
+ const columns = rows.length ? Object.keys(rows[0]) : [];
53
+ const missing = REQUIRED_MAPPING_COLUMNS.filter(column => !columns.includes(column));
54
+ if (missing.length) {
55
+ throw new Error(`mapping sheet tab "${title}" not readable as CSV (missing columns ${missing.join(', ')}) — is the sheet still shared with everyone who has the link?`);
56
+ }
57
+ // unnamed columns (notes next to the table) are not part of a rule
58
+ const mappingRules = rows.map((row) => {
59
+ delete row[''];
60
+ return row;
61
+ }) as unknown as MappingRules[];
62
+
63
+ mappingRules.forEach((rule, _i, selfRules) => {
64
+ this.removeEmptyKeys(rule);
65
+ if (rule.importFieldType && rule.importFieldType.includes('selector')) {
66
+ this.groupSelectors(rule, selfRules);
67
+ }
68
+ });
30
69
 
31
- this.rules = this.filterResultedRows(mappingRules);
70
+ const rules = this.filterResultedRows(mappingRules);
71
+ if (!rules.length) {
72
+ throw new Error(`mapping sheet tab "${title}" has no rule`);
32
73
  }
33
- return this.rules;
74
+ return rules;
34
75
  }
35
76
 
36
77
  async getDealersMapping(): Promise<DealersMapping[]> {
@@ -0,0 +1,65 @@
1
+ // Run: npm test (ts-node, no test framework needed)
2
+ // Mapping rules from the public CSV of the mapping sheet tab (no Sheets API key) — pure, no I/O.
3
+ import * as assert from 'node:assert/strict';
4
+
5
+ import { MappingService, mappingSheetCsvUrl } from '../src/services/mapping.service';
6
+
7
+ const service = new MappingService({ config: {} } as any);
8
+
9
+ // Excerpt of the tab mobile_yescar as gviz returns it (13.11.2026): quoted cells, an unnamed column,
10
+ // trailing empty columns, selector rows that group onto the first row of their importField.
11
+ const CSV = [
12
+ '"importField","importFieldType","autodeField","autodeFieldType","importFieldSelector","autodeFieldSelector","","custom","",""',
13
+ '"[\'0\']","string","persistentId","string","","","","yes","",""',
14
+ '"[\'2\']","categoryOrClass_selector","mainData.category","selector","cabrio/roadster","selector_category_convertible","note","","",""',
15
+ '"[\'2\']","categoryOrClass_selector","mainData.category","selector","Kombi, Van","selector_category_combi","","","",""',
16
+ '"[\'3\']","string","mainData.make","string","","","","","",""',
17
+ '"[\'110\']","selector","driveSuspension.gearbox","selector","1","selector_gearbox_manualShift","","","",""',
18
+ '"[\'110\']","selector","driveSuspension.gearbox","selector","3","selector_gearbox_automatic","","","",""',
19
+ '"[\'999\']","string","","string","","","","","",""',
20
+ '',
21
+ ].join('\n');
22
+
23
+ const tests: Array<[string, () => void]> = [
24
+ ['url: gviz CSV of the tab by title, title URL-encoded, no key', () => {
25
+ assert.equal(
26
+ mappingSheetCsvUrl('147qz9sTgs1wi13ua8YKa0IgfxAn1X_qH6LkGnAnzeyY', 'mobile_yescar'),
27
+ 'https://docs.google.com/spreadsheets/d/147qz9sTgs1wi13ua8YKa0IgfxAn1X_qH6LkGnAnzeyY/gviz/tq?tqx=out:csv&sheet=mobile_yescar',
28
+ );
29
+ assert.ok(mappingSheetCsvUrl('x', 'a b&c').endsWith('sheet=a%20b%26c'));
30
+ }],
31
+
32
+ ['rules: same post-processing as the Sheets API rows (empty keys dropped, selectors grouped, rows without autodeField out)', () => {
33
+ const rules = service.rulesFromCsv(CSV, 'mobile_yescar') as any[];
34
+ assert.deepEqual(rules.map(r => r.importField), ["['0']", "['2']", "['3']", "['110']"]);
35
+ assert.deepEqual(rules[0], { importField: "['0']", importFieldType: 'string', autodeField: 'persistentId', autodeFieldType: 'string', custom: 'yes' });
36
+ assert.deepEqual(rules[1].selectors, ['cabrio/roadster:selector_category_convertible', 'Kombi, Van:selector_category_combi']);
37
+ assert.equal(rules[1].importFieldSelector, undefined);
38
+ assert.equal(rules[1][''], undefined, 'unnamed column is not part of the rule');
39
+ assert.deepEqual(rules[3].selectors, ['1:selector_gearbox_manualShift', '3:selector_gearbox_automatic']);
40
+ assert.deepEqual(Object.keys(rules[2]).sort(), ['autodeField', 'autodeFieldType', 'importField', 'importFieldType']);
41
+ }],
42
+
43
+ ['not the tab (sign-in page / other table) -> error, never an empty mapping', () => {
44
+ assert.throws(() => service.rulesFromCsv('<!DOCTYPE html><html><body>Sign in</body></html>', 'mobile_yescar'), /not readable as CSV.*shared/);
45
+ assert.throws(() => service.rulesFromCsv('', 'mobile_yescar'), /not readable as CSV/);
46
+ assert.throws(() => service.rulesFromCsv('"a","b"\n"1","2"\n', 'mobile_yescar'), /missing columns importField/);
47
+ }],
48
+
49
+ ['a tab with the columns but no usable rule -> error', () => {
50
+ const header = '"importField","importFieldType","autodeField","autodeFieldType"';
51
+ assert.throws(() => service.rulesFromCsv(`${header}\n"[\'1\']","string","",""\n`, 'mobile'), /has no rule/);
52
+ }],
53
+ ];
54
+
55
+ let failed = 0;
56
+ for (const [name, fn] of tests) {
57
+ try {
58
+ fn();
59
+ console.log(`ok ${name}`);
60
+ } catch (e) {
61
+ failed += 1;
62
+ console.error(`FAIL ${name}\n ${e.message}`);
63
+ }
64
+ }
65
+ if (failed) process.exit(1);