@bytebrand/export-cars 1.26.46 → 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.
package/package.json CHANGED
@@ -1,65 +1,65 @@
1
- {
2
- "name": "@bytebrand/export-cars",
3
- "version": "1.26.46",
4
- "description": "",
5
- "author": "bytebrand",
6
- "license": "UNLICENSED",
7
- "scripts": {
8
- "prebuild": "rimraf dist",
9
- "build": "nest build",
10
- "prepublishOnly": "npm run build",
11
- "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
12
- "lint": "eslint -c .eslintrc.js --ext .ts src/",
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"
15
- },
16
- "dependencies": {
17
- "@bytebrand/autode-request": "^1.19.2",
18
- "@bytebrand/bigquery-logger": "^2.1.2",
19
- "@bytebrand/car-basic-data-scope": "^9.11.24",
20
- "@bytebrand/car-description": "^1.17.20",
21
- "@bytebrand/car-schema": "^3.14.15",
22
- "@bytebrand/chunkable": "^5.19.8",
23
- "@bytebrand/cronicle-helper": "^1.21.8",
24
- "@bytebrand/fe-financing": "^5.14.19",
25
- "@bytebrand/i18n-dictionaries": "^1.5.0",
26
- "@bytebrand/import-helper": "^0.45.0",
27
- "@bytebrand/schema-tools": "^3.18.8",
28
- "@bytebrand/zipper": "^2.2.0",
29
- "@nestjs/cache-manager": "^2.1.0",
30
- "@nestjs/common": "^10.2.7",
31
- "@nestjs/core": "^10.2.7",
32
- "adm-zip": "0.5.5",
33
- "axios": "^1.5.1",
34
- "bottleneck": "^2.19.5",
35
- "cache-manager": "^5.2.4",
36
- "csv-write-stream": "^2.0.0",
37
- "google-spreadsheet": "^3.3.0",
38
- "lodash": "^4.17.21",
39
- "moment": "^2.29.4",
40
- "nest-commander": "~3.15.0",
41
- "reflect-metadata": "^0.1.13",
42
- "vin-generator": "^1.0.1"
43
- },
44
- "devDependencies": {
45
- "@nestjs/cli": "^10.1.18",
46
- "@types/google-spreadsheet": "^3.3.0",
47
- "@types/inquirer": "^8.2.4",
48
- "@types/lodash": "^4.14.186",
49
- "@types/node": "^16.18.58",
50
- "@typescript-eslint/eslint-plugin": "^6.8.0",
51
- "@typescript-eslint/parser": "^6.8.0",
52
- "eslint": "^8.0.1",
53
- "eslint-config-prettier": "^8.3.0",
54
- "eslint-plugin-prettier": "^4.0.0",
55
- "gts": "^3.1.1",
56
- "prettier": "^2.3.2",
57
- "ts-node": "^10.0.0",
58
- "tsconfig-paths": "4.1.0",
59
- "typescript": "^5.2.2"
60
- },
61
- "engines": {
62
- "node": ">=14.0.0"
63
- },
64
- "gitHead": "e827f35865032821bf3a081f03a026d3d3f8a540"
65
- }
1
+ {
2
+ "name": "@bytebrand/export-cars",
3
+ "version": "1.26.47",
4
+ "description": "",
5
+ "author": "bytebrand",
6
+ "license": "UNLICENSED",
7
+ "scripts": {
8
+ "prebuild": "rimraf dist",
9
+ "build": "nest build",
10
+ "prepublishOnly": "npm run build",
11
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
12
+ "lint": "eslint -c .eslintrc.js --ext .ts src/",
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/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
+ },
16
+ "dependencies": {
17
+ "@bytebrand/autode-request": "^1.19.2",
18
+ "@bytebrand/bigquery-logger": "^2.1.2",
19
+ "@bytebrand/car-basic-data-scope": "^9.11.24",
20
+ "@bytebrand/car-description": "^1.17.20",
21
+ "@bytebrand/car-schema": "^3.14.15",
22
+ "@bytebrand/chunkable": "^5.19.8",
23
+ "@bytebrand/cronicle-helper": "^1.21.8",
24
+ "@bytebrand/fe-financing": "^5.14.19",
25
+ "@bytebrand/i18n-dictionaries": "^1.5.0",
26
+ "@bytebrand/import-helper": "^0.45.0",
27
+ "@bytebrand/schema-tools": "^3.18.8",
28
+ "@bytebrand/zipper": "^2.2.0",
29
+ "@nestjs/cache-manager": "^2.1.0",
30
+ "@nestjs/common": "^10.2.7",
31
+ "@nestjs/core": "^10.2.7",
32
+ "adm-zip": "0.5.5",
33
+ "axios": "^1.5.1",
34
+ "bottleneck": "^2.19.5",
35
+ "cache-manager": "^5.2.4",
36
+ "csv-write-stream": "^2.0.0",
37
+ "google-spreadsheet": "^3.3.0",
38
+ "lodash": "^4.17.21",
39
+ "moment": "^2.29.4",
40
+ "nest-commander": "~3.15.0",
41
+ "reflect-metadata": "^0.1.13",
42
+ "vin-generator": "^1.0.1"
43
+ },
44
+ "devDependencies": {
45
+ "@nestjs/cli": "^10.1.18",
46
+ "@types/google-spreadsheet": "^3.3.0",
47
+ "@types/inquirer": "^8.2.4",
48
+ "@types/lodash": "^4.14.186",
49
+ "@types/node": "^16.18.58",
50
+ "@typescript-eslint/eslint-plugin": "^6.8.0",
51
+ "@typescript-eslint/parser": "^6.8.0",
52
+ "eslint": "^8.0.1",
53
+ "eslint-config-prettier": "^8.3.0",
54
+ "eslint-plugin-prettier": "^4.0.0",
55
+ "gts": "^3.1.1",
56
+ "prettier": "^2.3.2",
57
+ "ts-node": "^10.0.0",
58
+ "tsconfig-paths": "4.1.0",
59
+ "typescript": "^5.2.2"
60
+ },
61
+ "engines": {
62
+ "node": ">=14.0.0"
63
+ },
64
+ "gitHead": "e827f35865032821bf3a081f03a026d3d3f8a540"
65
+ }
@@ -1,70 +1,70 @@
1
- import { toNumber } from 'lodash';
2
-
3
- import { ExportType, FileType, ImageType } from '../enums';
4
- import { Params } from '../interfaces/params.interface';
5
- import { FeedGuardOptions } from '../services/feed-guard';
6
- import { Config } from './config';
7
-
8
- const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
9
-
10
- const positiveNumber = (value: unknown, fallback: number): number => {
11
- const parsed = toNumber(text(value));
12
- return text(value) && Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
13
- };
14
-
15
- // Guard defaults (same as AutoUncle). The DEV job exports one dealer via the Cronicle rules, so the
16
- // published count is far below the base snapshot: minPublishedItems only forbids an empty file.
17
- export const MARKTDE_DEFAULTS = {
18
- imageConcurrency: 16,
19
- minSourceItems: 1000,
20
- minPublishedItems: 1,
21
- maxDropPercent: 20,
22
- maxSourceAgeHours: 26,
23
- maxStaleHours: 48,
24
- };
25
-
26
- /**
27
- * markt.de vehicle feed (ADV-328): the legacy mobile.de-style CSV (mapping sheet `mobile_yescar`,
28
- * ACF-4370) plus a ZIP of it, with clean ADV-336 images. There is no current markt.de spec yet, so
29
- * the format (columns, file names, no header row, `;` separator, UTF-8) stays as it was.
30
- * Dry-run is the default: the files go to resources/marktde-dryrun, which no Cronicle step reads.
31
- * Only marktdeDryRun=false writes resources/marktde-export (the directory `9c.1 … part 2` uploads
32
- * from). Each mode has its own guard state.
33
- */
34
- export class MarktdeConfig extends Config {
35
- dryRun: boolean;
36
- imageConcurrency: number;
37
- // 0 = no limit (no partner limit known)
38
- maxImages: number;
39
- stateDir: string;
40
- feedGuard: FeedGuardOptions;
41
-
42
- constructor(params: Params) {
43
- super(params);
44
- this.campaign = ExportType.MARKTDE;
45
- this.dryRun = text(params.marktdeDryRun).toLowerCase() !== 'false';
46
- const mode = this.dryRun ? 'dryrun' : 'export';
47
- this.pathesConfig.localConvertedDirectory = `resources/${ExportType.MARKTDE}-${mode}`;
48
- this.stateDir = `resources/${ExportType.MARKTDE}-state/${mode}`;
49
- this.imageUrl = `https://images.${this.domain}/carimage`;
50
- this.mappingSpreadsheetTitle = 'mobile_yescar';
51
- // MobileTransformer still builds its legacy `image` column; marktde.transformer replaces it with
52
- // the validated clean list.
53
- this.imageType = ImageType.default;
54
- this.imageConcurrency = positiveNumber(params.marktdeImageConcurrency, MARKTDE_DEFAULTS.imageConcurrency);
55
- this.maxImages = Math.floor(positiveNumber(params.marktdeMaxImages, 0));
56
- this.feedGuard = {
57
- stateDir: this.stateDir,
58
- minSourceItems: positiveNumber(params.feedGuardMinSourceItems, MARKTDE_DEFAULTS.minSourceItems),
59
- minPublishedItems: positiveNumber(params.feedGuardMinPublishedItems, MARKTDE_DEFAULTS.minPublishedItems),
60
- maxDropPercent: positiveNumber(params.feedGuardMaxDropPercent, MARKTDE_DEFAULTS.maxDropPercent),
61
- maxSourceAgeHours: positiveNumber(params.feedGuardMaxSourceAgeHours, MARKTDE_DEFAULTS.maxSourceAgeHours),
62
- maxStaleHours: positiveNumber(params.feedGuardMaxStaleHours, MARKTDE_DEFAULTS.maxStaleHours),
63
- acceptDrop: text(params.feedGuardAcceptDrop).toLowerCase() === 'true',
64
- };
65
- this.outputFiles = [
66
- `${this.pathesConfig.localConvertedDirectory}/api_export_cars_${ExportType.MARKTDE}.${FileType.CSV}`,
67
- `${this.pathesConfig.localConvertedDirectory}/api_export_cars_${ExportType.MARKTDE}.${FileType.ZIP}`,
68
- ];
69
- }
70
- }
1
+ import { toNumber } from 'lodash';
2
+
3
+ import { ExportType, FileType, ImageType } from '../enums';
4
+ import { Params } from '../interfaces/params.interface';
5
+ import { FeedGuardOptions } from '../services/feed-guard';
6
+ import { Config } from './config';
7
+
8
+ const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
9
+
10
+ const positiveNumber = (value: unknown, fallback: number): number => {
11
+ const parsed = toNumber(text(value));
12
+ return text(value) && Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
13
+ };
14
+
15
+ // Guard defaults (same as AutoUncle). The DEV job exports one dealer via the Cronicle rules, so the
16
+ // published count is far below the base snapshot: minPublishedItems only forbids an empty file.
17
+ export const MARKTDE_DEFAULTS = {
18
+ imageConcurrency: 16,
19
+ minSourceItems: 1000,
20
+ minPublishedItems: 1,
21
+ maxDropPercent: 20,
22
+ maxSourceAgeHours: 26,
23
+ maxStaleHours: 48,
24
+ };
25
+
26
+ /**
27
+ * markt.de vehicle feed (ADV-328): the legacy mobile.de-style CSV (mapping sheet `mobile_yescar`,
28
+ * ACF-4370) plus a ZIP of it, with clean ADV-336 images. There is no current markt.de spec yet, so
29
+ * the format (columns, file names, no header row, `;` separator, UTF-8) stays as it was.
30
+ * Dry-run is the default: the files go to resources/marktde-dryrun, which no Cronicle step reads.
31
+ * Only marktdeDryRun=false writes resources/marktde-export (the directory `9c.1 … part 2` uploads
32
+ * from). Each mode has its own guard state.
33
+ */
34
+ export class MarktdeConfig extends Config {
35
+ dryRun: boolean;
36
+ imageConcurrency: number;
37
+ // 0 = no limit (no partner limit known)
38
+ maxImages: number;
39
+ stateDir: string;
40
+ feedGuard: FeedGuardOptions;
41
+
42
+ constructor(params: Params) {
43
+ super(params);
44
+ this.campaign = ExportType.MARKTDE;
45
+ this.dryRun = text(params.marktdeDryRun).toLowerCase() !== 'false';
46
+ const mode = this.dryRun ? 'dryrun' : 'export';
47
+ this.pathesConfig.localConvertedDirectory = `resources/${ExportType.MARKTDE}-${mode}`;
48
+ this.stateDir = `resources/${ExportType.MARKTDE}-state/${mode}`;
49
+ this.imageUrl = `https://images.${this.domain}/carimage`;
50
+ this.mappingSpreadsheetTitle = 'mobile_yescar';
51
+ // MobileTransformer still builds its legacy `image` column; marktde.transformer replaces it with
52
+ // the validated clean list.
53
+ this.imageType = ImageType.default;
54
+ this.imageConcurrency = positiveNumber(params.marktdeImageConcurrency, MARKTDE_DEFAULTS.imageConcurrency);
55
+ this.maxImages = Math.floor(positiveNumber(params.marktdeMaxImages, 0));
56
+ this.feedGuard = {
57
+ stateDir: this.stateDir,
58
+ minSourceItems: positiveNumber(params.feedGuardMinSourceItems, MARKTDE_DEFAULTS.minSourceItems),
59
+ minPublishedItems: positiveNumber(params.feedGuardMinPublishedItems, MARKTDE_DEFAULTS.minPublishedItems),
60
+ maxDropPercent: positiveNumber(params.feedGuardMaxDropPercent, MARKTDE_DEFAULTS.maxDropPercent),
61
+ maxSourceAgeHours: positiveNumber(params.feedGuardMaxSourceAgeHours, MARKTDE_DEFAULTS.maxSourceAgeHours),
62
+ maxStaleHours: positiveNumber(params.feedGuardMaxStaleHours, MARKTDE_DEFAULTS.maxStaleHours),
63
+ acceptDrop: text(params.feedGuardAcceptDrop).toLowerCase() === 'true',
64
+ };
65
+ this.outputFiles = [
66
+ `${this.pathesConfig.localConvertedDirectory}/api_export_cars_${ExportType.MARKTDE}.${FileType.CSV}`,
67
+ `${this.pathesConfig.localConvertedDirectory}/api_export_cars_${ExportType.MARKTDE}.${FileType.ZIP}`,
68
+ ];
69
+ }
70
+ }
@@ -1,34 +1,34 @@
1
- export const FB_MAX_TRIM_LENGTH = 50;
2
- export const FB_MAX_IMAGE_COUNT_FOR_DEAFULT = 47;
3
- export const FB_MIN_MILEAGES = 900;
4
- export const DEFAULT_ZIP = '06796';
5
- export const DEFAULT_CITY = 'Sandersdorf-Brehna';
6
- export const DEFAULT_ADDR1 = 'Max-Planck-Str. 19';
7
- export const TIMESTAMP_FIRST_YEAR = 1970;
8
- export const META_PUSH_PRIORITY_STANDARD = 'standard';
9
- export const META_PUSH_PRIORITY_ALLOWED_VALUES = ['dealer_priority_1', 'dealer_priority_2'];
10
-
11
- // MarketingImageExportConfig channel code for the Meta AIA overlay images (image-generator's
12
- // MetaAiaImagesService.CHANNEL_CODE) — distinct from this feed's now-disabled legacy "fb" config.
13
- export const META_AIA_CHANNEL_CODE = 'meta_aia';
14
- // Only the financing variant exists so far; leasing/other modes will be added once image-generator
15
- // renders them too (see MetaAiaImagesService's MetaAiaMode).
16
- export const META_AIA_MODE_FINANCING = 'financing';
17
-
18
- // Values must match Meta's Automotive Model Ads offer_type enum exactly (lease/finance/cash) —
19
- // the old 'financing'/'leasing'/'buy' values are not recognized by the AMA Vehicle Offers Feed.
20
- export enum OfferType {
21
- financing = 'finance',
22
- leasing = 'lease',
23
- buy = 'cash',
24
- }
25
-
26
- export const AMOUNT_QUALIFIER_APR = 'APR';
27
- export const TERM_QUALIFIER_MONTHS = 'months';
28
-
29
- export const OFFER_DISCLAIMER = 'Für Ihre Finanzierungswünsche stellen wir eine Bonitätsanfrage. Bonität ' +
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 Openbank Deutschland AG, ' +
32
- 'Santander-Platz 1, 41061 Mönchengladbach wird vermittelt durch die auto.de GmbH, Max-Planck-Str. 19, 06796 ' +
33
- 'Sandersdorf-Brehna, die als ungebundener Vermittler nicht beratend tätig ist. Irrtümer vorbehalten. Preise ' +
34
- 'ggf. inkl. MwSt.';
1
+ export const FB_MAX_TRIM_LENGTH = 50;
2
+ export const FB_MAX_IMAGE_COUNT_FOR_DEAFULT = 47;
3
+ export const FB_MIN_MILEAGES = 900;
4
+ export const DEFAULT_ZIP = '06796';
5
+ export const DEFAULT_CITY = 'Sandersdorf-Brehna';
6
+ export const DEFAULT_ADDR1 = 'Max-Planck-Str. 19';
7
+ export const TIMESTAMP_FIRST_YEAR = 1970;
8
+ export const META_PUSH_PRIORITY_STANDARD = 'standard';
9
+ export const META_PUSH_PRIORITY_ALLOWED_VALUES = ['dealer_priority_1', 'dealer_priority_2'];
10
+
11
+ // MarketingImageExportConfig channel code for the Meta AIA overlay images (image-generator's
12
+ // MetaAiaImagesService.CHANNEL_CODE) — distinct from this feed's now-disabled legacy "fb" config.
13
+ export const META_AIA_CHANNEL_CODE = 'meta_aia';
14
+ // Only the financing variant exists so far; leasing/other modes will be added once image-generator
15
+ // renders them too (see MetaAiaImagesService's MetaAiaMode).
16
+ export const META_AIA_MODE_FINANCING = 'financing';
17
+
18
+ // Values must match Meta's Automotive Model Ads offer_type enum exactly (lease/finance/cash) —
19
+ // the old 'financing'/'leasing'/'buy' values are not recognized by the AMA Vehicle Offers Feed.
20
+ export enum OfferType {
21
+ financing = 'finance',
22
+ leasing = 'lease',
23
+ buy = 'cash',
24
+ }
25
+
26
+ export const AMOUNT_QUALIFIER_APR = 'APR';
27
+ export const TERM_QUALIFIER_MONTHS = 'months';
28
+
29
+ export const OFFER_DISCLAIMER = 'Für Ihre Finanzierungswünsche stellen wir eine Bonitätsanfrage. Bonität ' +
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 Openbank Deutschland AG, ' +
32
+ 'Santander-Platz 1, 41061 Mönchengladbach wird vermittelt durch die auto.de GmbH, Max-Planck-Str. 19, 06796 ' +
33
+ 'Sandersdorf-Brehna, die als ungebundener Vermittler nicht beratend tätig ist. Irrtümer vorbehalten. Preise ' +
34
+ 'ggf. inkl. MwSt.';
@@ -1,78 +1,78 @@
1
- import { Injectable } from '@nestjs/common';
2
- import { ModuleRef } from '@nestjs/core';
3
-
4
- import { FsChunkInterface } from '@bytebrand/chunkable';
5
- import { LoggerService } from '@bytebrand/cronicle-helper';
6
-
7
- import { CarService, RulesService, LoaderService, ConfigService, ImagesService } from '../services';
8
- import { ImageUrlValidator } from '../services/image-url-validator.service';
9
- import { FeedGuard } from '../services/feed-guard';
10
- import { MarktdeFeedRun, MarktdeSourceCar } from '../services/marktde-feed-run';
11
- import { zipSingleFile } from '../services/meinstadt-feed-run';
12
- import { MobileTransformer } from '../transformers/mobile.transformer';
13
- import { toMarktdeImages } from '../transformers/marktde.transformer';
14
- import { resolveOpenAiImageFields } from '../transformers/openai-image-fields';
15
- import { MarktdeConfig } from '../configs/marktde.config';
16
- import { Exporter } from '../interfaces/exporter.interface';
17
-
18
- /**
19
- * ExportType.MARKTDE (ADV-328). Was the shared MobileExporter; now wires the Cronicle/Nest services
20
- * into MarktdeFeedRun, which holds the logic. The row itself still comes from MobileTransformer (same
21
- * mapping sheet, same columns); only the markt.de columns are replaced. EAUTO / YESCAR / EVIUM keep
22
- * the MobileExporter unchanged.
23
- */
24
- @Injectable()
25
- export class MarktdeExporter implements Exporter {
26
- constructor(
27
- private carService: CarService,
28
- private configService: ConfigService,
29
- private rulesService: RulesService,
30
- private loaderService: LoaderService,
31
- private imagesService: ImagesService,
32
- private moduleRef: ModuleRef,
33
- ) {}
34
-
35
- async export(): Promise<void> {
36
- const config = this.configService.config as MarktdeConfig;
37
- const [csvFile, zipFile] = config.outputFiles;
38
- const validator = new ImageUrlValidator({ concurrency: config.imageConcurrency });
39
- const transformer = await this.moduleRef.create(MobileTransformer);
40
-
41
- const run = new MarktdeFeedRun(
42
- {
43
- forEachChunk: fn => this.loaderService.process((chunk: FsChunkInterface) => fn(chunk.data as MarktdeSourceCar[])),
44
- filterCar: source => this.carService.filterCar(source, this.rulesService.rules),
45
- resolveImages: async ({ car, images }) => toMarktdeImages(
46
- await resolveOpenAiImageFields(this.imagesService.getCleanImages(car._id, images), validator),
47
- config.maxImages,
48
- ),
49
- legacyRow: async ({ car, images }) => (await transformer.transform(car, images)) as Record<string, unknown>,
50
- imageStats: () => validator.stats,
51
- zip: zipSingleFile,
52
- guard: new FeedGuard(config.feedGuard),
53
- },
54
- {
55
- runId: config.runId,
56
- dryRun: config.dryRun,
57
- domain: config.domain,
58
- sourceDirectory: config.pathesConfig.localDirectory,
59
- sourceBaseFileName: config.pathesConfig.baseFileName,
60
- csvFile,
61
- zipFile,
62
- stateDir: config.stateDir,
63
- },
64
- );
65
-
66
- try {
67
- const report = await run.run();
68
- if (report.stale) {
69
- console.log(`marktde feed STALE: last good file ${report.lastGoodAgeHours?.toFixed(1)} h old`);
70
- }
71
- } catch (error) {
72
- // main.ts would report a rejected run as {"complete":1,"code":0}; fail the job so the chained
73
- // upload step does not run (report + lock already handled by the run).
74
- LoggerService.exitError(`marktde feed not published: ${error?.message ?? error}`);
75
- process.exit(1);
76
- }
77
- }
78
- }
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ModuleRef } from '@nestjs/core';
3
+
4
+ import { FsChunkInterface } from '@bytebrand/chunkable';
5
+ import { LoggerService } from '@bytebrand/cronicle-helper';
6
+
7
+ import { CarService, RulesService, LoaderService, ConfigService, ImagesService } from '../services';
8
+ import { ImageUrlValidator } from '../services/image-url-validator.service';
9
+ import { FeedGuard } from '../services/feed-guard';
10
+ import { MarktdeFeedRun, MarktdeSourceCar } from '../services/marktde-feed-run';
11
+ import { zipSingleFile } from '../services/meinstadt-feed-run';
12
+ import { MobileTransformer } from '../transformers/mobile.transformer';
13
+ import { toMarktdeImages } from '../transformers/marktde.transformer';
14
+ import { resolveOpenAiImageFields } from '../transformers/openai-image-fields';
15
+ import { MarktdeConfig } from '../configs/marktde.config';
16
+ import { Exporter } from '../interfaces/exporter.interface';
17
+
18
+ /**
19
+ * ExportType.MARKTDE (ADV-328). Was the shared MobileExporter; now wires the Cronicle/Nest services
20
+ * into MarktdeFeedRun, which holds the logic. The row itself still comes from MobileTransformer (same
21
+ * mapping sheet, same columns); only the markt.de columns are replaced. EAUTO / YESCAR / EVIUM keep
22
+ * the MobileExporter unchanged.
23
+ */
24
+ @Injectable()
25
+ export class MarktdeExporter implements Exporter {
26
+ constructor(
27
+ private carService: CarService,
28
+ private configService: ConfigService,
29
+ private rulesService: RulesService,
30
+ private loaderService: LoaderService,
31
+ private imagesService: ImagesService,
32
+ private moduleRef: ModuleRef,
33
+ ) {}
34
+
35
+ async export(): Promise<void> {
36
+ const config = this.configService.config as MarktdeConfig;
37
+ const [csvFile, zipFile] = config.outputFiles;
38
+ const validator = new ImageUrlValidator({ concurrency: config.imageConcurrency });
39
+ const transformer = await this.moduleRef.create(MobileTransformer);
40
+
41
+ const run = new MarktdeFeedRun(
42
+ {
43
+ forEachChunk: fn => this.loaderService.process((chunk: FsChunkInterface) => fn(chunk.data as MarktdeSourceCar[])),
44
+ filterCar: source => this.carService.filterCar(source, this.rulesService.rules),
45
+ resolveImages: async ({ car, images }) => toMarktdeImages(
46
+ await resolveOpenAiImageFields(this.imagesService.getCleanImages(car._id, images), validator),
47
+ config.maxImages,
48
+ ),
49
+ legacyRow: async ({ car, images }) => (await transformer.transform(car, images)) as Record<string, unknown>,
50
+ imageStats: () => validator.stats,
51
+ zip: zipSingleFile,
52
+ guard: new FeedGuard(config.feedGuard),
53
+ },
54
+ {
55
+ runId: config.runId,
56
+ dryRun: config.dryRun,
57
+ domain: config.domain,
58
+ sourceDirectory: config.pathesConfig.localDirectory,
59
+ sourceBaseFileName: config.pathesConfig.baseFileName,
60
+ csvFile,
61
+ zipFile,
62
+ stateDir: config.stateDir,
63
+ },
64
+ );
65
+
66
+ try {
67
+ const report = await run.run();
68
+ if (report.stale) {
69
+ console.log(`marktde feed STALE: last good file ${report.lastGoodAgeHours?.toFixed(1)} h old`);
70
+ }
71
+ } catch (error) {
72
+ // main.ts would report a rejected run as {"complete":1,"code":0}; fail the job so the chained
73
+ // upload step does not run (report + lock already handled by the run).
74
+ LoggerService.exitError(`marktde feed not published: ${error?.message ?? error}`);
75
+ process.exit(1);
76
+ }
77
+ }
78
+ }
@@ -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[]> {