@bytebrand/export-cars 1.26.31 → 1.26.33
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/dist/configs/autouncle.config.d.ts +14 -0
- package/dist/configs/autouncle.config.js +36 -3
- package/dist/configs/autouncle.config.js.map +1 -1
- package/dist/configs/meinstadt.config.d.ts +15 -0
- package/dist/configs/meinstadt.config.js +42 -3
- package/dist/configs/meinstadt.config.js.map +1 -1
- package/dist/constants/autouncle.contants.d.ts +1 -3
- package/dist/constants/autouncle.contants.js +2 -4
- package/dist/constants/autouncle.contants.js.map +1 -1
- package/dist/exporters/autouncle.exporter.d.ts +3 -2
- package/dist/exporters/autouncle.exporter.js +45 -27
- package/dist/exporters/autouncle.exporter.js.map +1 -1
- package/dist/exporters/meinstadt.exporter.d.ts +3 -2
- package/dist/exporters/meinstadt.exporter.js +40 -23
- package/dist/exporters/meinstadt.exporter.js.map +1 -1
- package/dist/formats/autouncle-json.builder.js +1 -4
- package/dist/formats/autouncle-json.builder.js.map +1 -1
- package/dist/interfaces/export-item.interface.d.ts +8 -12
- package/dist/interfaces/openai-ads.interface.d.ts +2 -1
- package/dist/interfaces/openai-ads.interface.js +1 -0
- package/dist/interfaces/openai-ads.interface.js.map +1 -1
- package/dist/interfaces/params.interface.d.ts +6 -0
- package/dist/services/autouncle-feed-run.d.ts +66 -0
- package/dist/services/autouncle-feed-run.js +135 -0
- package/dist/services/autouncle-feed-run.js.map +1 -0
- package/dist/services/meinstadt-feed-run.d.ts +81 -0
- package/dist/services/meinstadt-feed-run.js +166 -0
- package/dist/services/meinstadt-feed-run.js.map +1 -0
- package/dist/services/openai-ads-feed-run.d.ts +1 -0
- package/dist/services/openai-ads-feed-run.js +4 -0
- package/dist/services/openai-ads-feed-run.js.map +1 -1
- package/dist/transformers/autouncle.transformer.d.ts +38 -22
- package/dist/transformers/autouncle.transformer.js +156 -144
- package/dist/transformers/autouncle.transformer.js.map +1 -1
- package/dist/transformers/meinstadt.transformer.d.ts +17 -13
- package/dist/transformers/meinstadt.transformer.js +88 -61
- package/dist/transformers/meinstadt.transformer.js.map +1 -1
- package/dist/transformers/openai-ads-finance.d.ts +2 -0
- package/dist/transformers/openai-ads-finance.js +39 -0
- package/dist/transformers/openai-ads-finance.js.map +1 -0
- package/dist/transformers/openai-ads.transformer.d.ts +9 -1
- package/dist/transformers/openai-ads.transformer.js +25 -5
- package/dist/transformers/openai-ads.transformer.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/autouncle/AUDIT.md +97 -0
- package/docs/autouncle/PARTNER-QUESTIONS.md +53 -0
- package/docs/autouncle/RUNBOOK.md +64 -0
- package/docs/meinestadt/AUDIT.md +146 -0
- package/docs/meinestadt/PARTNER-QUESTIONS.md +54 -0
- package/docs/meinestadt/RUNBOOK.md +64 -0
- package/docs/openai-ads/AUDIT.md +2 -1
- package/docs/openai-ads/RUNBOOK.md +26 -2
- package/package.json +3 -2
- package/src/configs/autouncle.config.ts +51 -3
- package/src/configs/meinstadt.config.ts +57 -2
- package/src/constants/autouncle.contants.ts +2 -3
- package/src/exporters/autouncle.exporter.ts +59 -37
- package/src/exporters/meinstadt.exporter.ts +53 -32
- package/src/formats/autouncle-json.builder.ts +2 -4
- package/src/interfaces/export-item.interface.ts +15 -12
- package/src/interfaces/openai-ads.interface.ts +2 -0
- package/src/interfaces/params.interface.ts +10 -0
- package/src/services/autouncle-feed-run.ts +197 -0
- package/src/services/meinstadt-feed-run.ts +240 -0
- package/src/services/openai-ads-feed-run.ts +6 -0
- package/src/transformers/autouncle.transformer.ts +217 -175
- package/src/transformers/meinstadt.transformer.ts +114 -68
- package/src/transformers/openai-ads-finance.ts +60 -0
- package/src/transformers/openai-ads.transformer.ts +32 -6
- package/test/autouncle-feed-run.test.ts +256 -0
- package/test/autouncle-mapping.test.ts +225 -0
- package/test/fixtures/meinstadt.fixture.ts +87 -0
- package/test/fixtures/openai-ads-finance-titles.json +325 -0
- package/test/meinstadt-feed-run.test.ts +288 -0
- package/test/meinstadt-mapping.test.ts +215 -0
- package/test/openai-ads-finance.test.ts +109 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* No finance claims in the OpenAI Ads feed (ADV-335/336 launch gate, 13.10). OpenAI's ads policy
|
|
3
|
+
* lists "Auto loans and leasing" outside the US as "Restricted to Select Approved Advertisers", so
|
|
4
|
+
* the feed must not carry rates, interest, leasing or monthly-payment wording at all.
|
|
5
|
+
*
|
|
6
|
+
* The only source today is the dealer's variant text (mainData.subModel), e.g. "GLB 200
|
|
7
|
+
* 4,49%Finz./Progressive" or "Tourneo Custom L1 0% Zins" — the description is generated from
|
|
8
|
+
* structured fields and repeats the title. Two layers:
|
|
9
|
+
* - stripFinanceClaims: removes finance tokens (word-bounded) before the title is normalised;
|
|
10
|
+
* - hasFinanceClaim: a broader substring check on the final title/description; a match keeps the
|
|
11
|
+
* car out of the feed (exclusion `finance_claim`), so anything the stripping misses fails closed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// A percentage next to these words is a battery/damage figure, not an interest rate:
|
|
15
|
+
// "99% SoH", "Akku 95%", "100% elektrisch", "0% Schaden".
|
|
16
|
+
const NON_FINANCE_PERCENT_CONTEXT = /soh|akku|batter|kapazit|elektr|schaden|unfall|reichweite|ladung/i;
|
|
17
|
+
const PERCENT = /\d{1,3}(?:[.,]\d{1,2})?\s*%-?/g;
|
|
18
|
+
|
|
19
|
+
const FINANCE_TOKENS: RegExp[] = [
|
|
20
|
+
// monthly amounts: "ab 199 € mtl.", "199€/Monat", "mtl. 199 €", "pro Monat"
|
|
21
|
+
/(?:\bab\s+)?\d[\d.,]*\s*(?:€|\beur\b|\beuro\b)?\s*(?:\/\s*|pro\s+|im\s+)?(?:\bmonat\b|\bmtl\b\.?|\bmonatl\b\.?|\bmonatlich\b)/gi,
|
|
22
|
+
/(?:\bmtl\b\.?|\bmonatl\b\.?|\bmonatlich\w*)\s*(?:ab\s+)?(?:\d[\d.,]*\s*(?:€|\beur\b|\beuro\b)?)?/gi,
|
|
23
|
+
/(?:€|\beur\b|\beuro\b)?\s*(?:\/\s*|\bpro\s+|\bim\s+)monat\b/gi,
|
|
24
|
+
// finance words
|
|
25
|
+
/\beffektiv(?:er|en)?\s+jahreszins\w*/gi,
|
|
26
|
+
/\b(?:finz\b\.?|finanz\w*|ballonfinanz\w*|zins\w*|sollzins\w*|jahreszins\w*|schlussrate\w*|monatsrate\w*|raten?|leasing\w*|leasen|anzahlung\w*|kredit\w*|darlehen\w*|santander)\b\.?/gi,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// Broader than the stripping patterns (substrings, glued words like "AMGFinanzierung").
|
|
30
|
+
const FINANCE_CLAIM = /finanz|\bfinz\b|zins|leasing|leasen|santander|darlehen|kredit|anzahlung|schlussrate|monatsrate|\bmtl\b|monatl|\braten?\b|\/\s*monat|pro\s+monat|im\s+monat/i;
|
|
31
|
+
|
|
32
|
+
const CUT = '\u0000';
|
|
33
|
+
const SEPARATORS = '[\\s/,;|+&\\-–]*';
|
|
34
|
+
const CUT_WITH_SEPARATORS = new RegExp(`${SEPARATORS}${CUT}(?:${SEPARATORS}${CUT})*${SEPARATORS}`, 'g');
|
|
35
|
+
|
|
36
|
+
// Only the word right before the number or right after the "%" counts ("Akku 95%", "99% SoH"), so
|
|
37
|
+
// "Elektromotor V 0,0%" is still a rate.
|
|
38
|
+
const isFinancePercent = (match: string, offset: number, text: string): boolean => {
|
|
39
|
+
const before = /([^\s/,;|]*)[\s/,;|]*$/.exec(text.slice(0, offset))[1];
|
|
40
|
+
const after = /^[\s/,;|]*([^\s/,;|]*)/.exec(text.slice(offset + match.length))[1];
|
|
41
|
+
return !NON_FINANCE_PERCENT_CONTEXT.test(before) && !NON_FINANCE_PERCENT_CONTEXT.test(after);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Finance tokens out, together with the separators around them ("SB 4,49%/AMG" -> "SB AMG").
|
|
45
|
+
export const stripFinanceClaims = (text: string): string => {
|
|
46
|
+
let out = text || '';
|
|
47
|
+
for (const pattern of FINANCE_TOKENS) {
|
|
48
|
+
out = out.replace(pattern, CUT);
|
|
49
|
+
}
|
|
50
|
+
out = out.replace(PERCENT, (match: string, offset: number, whole: string) => (isFinancePercent(match, offset, whole) ? CUT : match));
|
|
51
|
+
return out.includes(CUT) ? out.replace(CUT_WITH_SEPARATORS, ' ').replace(/\s+/g, ' ').trim() : text || '';
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const hasFinanceClaim = (text: string): boolean => {
|
|
55
|
+
const value = text || '';
|
|
56
|
+
if (FINANCE_CLAIM.test(value)) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return [...value.matchAll(PERCENT)].some(match => isFinancePercent(match[0], match.index, value));
|
|
60
|
+
};
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
OpenAiAdsRow,
|
|
10
10
|
} from '../interfaces/openai-ads.interface';
|
|
11
11
|
import { OpenAiImageFields, encodeCommasInUrl } from './openai-image-fields';
|
|
12
|
+
import { hasFinanceClaim, stripFinanceClaims } from './openai-ads-finance';
|
|
12
13
|
|
|
13
14
|
// OpenAI product file limits (guidance): title ≤ 150, description ≤ 5,000 characters.
|
|
14
15
|
export const OPENAI_TITLE_MAX_LENGTH = 150;
|
|
@@ -51,7 +52,8 @@ export interface OpenAiAdsMappingContext {
|
|
|
51
52
|
export type OpenAiAdsBaseRow = Omit<OpenAiAdsRow, 'image_url' | 'additional_image_urls'>;
|
|
52
53
|
|
|
53
54
|
export type OpenAiAdsMapResult =
|
|
54
|
-
|
|
55
|
+
// financeTermsRemoved: the dealer variant text carried finance wording that was cut from the title
|
|
56
|
+
| { ok: true; row: OpenAiAdsBaseRow; outOfStockReason?: OpenAiAdsOutOfStockReason; financeTermsRemoved?: boolean }
|
|
55
57
|
| { ok: false; reason: OpenAiAdsExclusion };
|
|
56
58
|
|
|
57
59
|
/**
|
|
@@ -98,8 +100,19 @@ export const mapOpenAiAdsVehicle = (car: DataSchemes.Car, ctx: OpenAiAdsMappingC
|
|
|
98
100
|
is_ads_eligible: true,
|
|
99
101
|
};
|
|
100
102
|
|
|
103
|
+
// fail-closed: whatever finance wording survived the stripping keeps the car out of the feed
|
|
104
|
+
if (hasFinanceClaim(row.title) || hasFinanceClaim(row.description)) {
|
|
105
|
+
return { ok: false, reason: OpenAiAdsExclusion.financeClaim };
|
|
106
|
+
}
|
|
107
|
+
|
|
101
108
|
const outOfStockReason = OFFER_AVAILABILITY_OUT_OF_STOCK[toString(get(car, 'offer.offerAvailability', ''))];
|
|
102
|
-
|
|
109
|
+
const financeTermsRemoved = title !== buildOpenAiTitle(car, { keepFinanceTerms: true });
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
row,
|
|
113
|
+
...(outOfStockReason ? { outOfStockReason } : {}),
|
|
114
|
+
...(financeTermsRemoved ? { financeTermsRemoved } : {}),
|
|
115
|
+
};
|
|
103
116
|
};
|
|
104
117
|
|
|
105
118
|
export const withOpenAiImages = (row: OpenAiAdsBaseRow, images: OpenAiImageFields): OpenAiAdsRow => ({
|
|
@@ -112,11 +125,22 @@ export const withOpenAiImages = (row: OpenAiAdsBaseRow, images: OpenAiImageField
|
|
|
112
125
|
// The same vehicle, switched off: kept in the file so OpenAI stops delivering it instead of
|
|
113
126
|
// relying on the row disappearing (not documented as a removal).
|
|
114
127
|
export const asOutOfStock = (row: OpenAiAdsRow): OpenAiAdsRow => ({
|
|
115
|
-
...row,
|
|
128
|
+
...withoutFinanceClaims(row),
|
|
116
129
|
availability: 'out_of_stock',
|
|
117
130
|
is_ads_eligible: false,
|
|
118
131
|
});
|
|
119
132
|
|
|
133
|
+
// Rows re-sent from the ledger may predate the finance rule: strip again, and if wording is still
|
|
134
|
+
// left, fall back to the brand alone (the row only switches the item off).
|
|
135
|
+
export const withoutFinanceClaims = <T extends { title: string; description: string; brand: string }>(row: T): T => {
|
|
136
|
+
const title = stripFinanceClaims(row.title);
|
|
137
|
+
const description = stripFinanceClaims(row.description);
|
|
138
|
+
if (!hasFinanceClaim(title) && !hasFinanceClaim(description)) {
|
|
139
|
+
return title === row.title && description === row.description ? row : { ...row, title, description };
|
|
140
|
+
}
|
|
141
|
+
return { ...row, title: row.brand, description: row.brand };
|
|
142
|
+
};
|
|
143
|
+
|
|
120
144
|
// Purchase price (price.currentSalesPrice, EUR gross) in major units with exactly two decimals and
|
|
121
145
|
// no thousands separator: 17990 -> "17990.00 EUR". Never a monthly financing/leasing rate.
|
|
122
146
|
// null = not a valid positive price; the car is not exported.
|
|
@@ -140,10 +164,12 @@ export const toOpenAiMinorUnits = (value: unknown): number | null => {
|
|
|
140
164
|
// "<make> <model> <variant>" as delivered (German market names), dealer junk punctuation removed
|
|
141
165
|
// with the same normalizer as the Meta feed, cut at a word boundary. No HTML escaping: CSV quoting
|
|
142
166
|
// handles every character. A placeholder model (SONSTIGE) is left out ("MG 1.5 Hybrid Luxury");
|
|
143
|
-
// with neither a real model nor a variant there is no title and the car is not sent.
|
|
144
|
-
|
|
167
|
+
// with neither a real model nor a variant there is no title and the car is not sent. Finance wording
|
|
168
|
+
// in the dealer text ("4,49%Finz.", "0% Zins") is cut first (openai-ads-finance.ts).
|
|
169
|
+
export const buildOpenAiTitle = (car: DataSchemes.Car, options: { keepFinanceTerms?: boolean } = {}): string => {
|
|
145
170
|
const [make, model, subModel] = ['mainData.make', 'mainData.model', 'mainData.subModel']
|
|
146
|
-
.map(path => repairMojibake(trim(toString(get(car, path, '')))))
|
|
171
|
+
.map(path => repairMojibake(trim(toString(get(car, path, '')))))
|
|
172
|
+
.map(part => (options.keepFinanceTerms ? part : stripFinanceClaims(part)));
|
|
147
173
|
const realModel = PLACEHOLDER_NAMES.test(model) ? '' : model;
|
|
148
174
|
if (!normalizeFeedText(`${realModel} ${subModel}`)) {
|
|
149
175
|
return '';
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
+
// Integration: real files in a temp dir (base export snapshot, output, state), real JSON writer,
|
|
3
|
+
// guard and ADV-336 clean-image selection; only HTTP (image check) and UPA (dealer) are faked.
|
|
4
|
+
import * as assert from 'node:assert/strict';
|
|
5
|
+
import { promises as fs } from 'fs';
|
|
6
|
+
import { tmpdir } from 'os';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
|
|
9
|
+
import { FeedGuard, FeedGuardError, FeedGuardOptions } from '../src/services/feed-guard';
|
|
10
|
+
import { AutouncleFeedRun, AutouncleSourceCar } from '../src/services/autouncle-feed-run';
|
|
11
|
+
import { toAutouncleImages } from '../src/transformers/autouncle.transformer';
|
|
12
|
+
import { selectOpenAiImageFields } from '../src/transformers/openai-image-fields';
|
|
13
|
+
import { buildImagesService } from './fixtures/images-service.harness';
|
|
14
|
+
import { NOW, car, images } from './fixtures/meinstadt.fixture';
|
|
15
|
+
|
|
16
|
+
const HOUR = 60 * 60 * 1000;
|
|
17
|
+
const imagesService = buildImagesService();
|
|
18
|
+
const ADDRESS = { city: 'Sandersdorf-Brehna', addr1: 'Max-Planck-Str. 19', region: 'Brehna', postal_code: '06796' };
|
|
19
|
+
|
|
20
|
+
interface World {
|
|
21
|
+
root: string;
|
|
22
|
+
source: string;
|
|
23
|
+
json: string;
|
|
24
|
+
state: string;
|
|
25
|
+
brokenImages: Set<string>;
|
|
26
|
+
unknownDealers: Set<string>;
|
|
27
|
+
dealerLookups: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const newWorld = async (): Promise<World> => {
|
|
31
|
+
const root = await fs.mkdtemp(join(tmpdir(), 'autouncle-'));
|
|
32
|
+
return {
|
|
33
|
+
root,
|
|
34
|
+
source: join(root, 'cars-export'),
|
|
35
|
+
json: join(root, 'autouncle-dryrun', 'api_export_cars_autouncle.json'),
|
|
36
|
+
state: join(root, 'autouncle-state', 'dryrun'),
|
|
37
|
+
brokenImages: new Set(),
|
|
38
|
+
unknownDealers: new Set(),
|
|
39
|
+
dealerLookups: 0,
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const source = (id: string, overrides: Record<string, any> = {}): AutouncleSourceCar => ({ car: car(id, overrides), images: images(id) });
|
|
44
|
+
|
|
45
|
+
const writeSource = async (world: World, cars: AutouncleSourceCar[], chunkSize = 2, mtime = NOW) => {
|
|
46
|
+
await fs.rm(world.source, { recursive: true, force: true });
|
|
47
|
+
await fs.mkdir(world.source, { recursive: true });
|
|
48
|
+
for (let i = 0; i * chunkSize < cars.length; i++) {
|
|
49
|
+
const file = join(world.source, `cars.${i}.json`);
|
|
50
|
+
await fs.writeFile(file, JSON.stringify(cars.slice(i * chunkSize, (i + 1) * chunkSize)));
|
|
51
|
+
await fs.utimes(file, mtime, mtime);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const guardOptions = (world: World, extra: Partial<FeedGuardOptions> = {}): FeedGuardOptions => ({
|
|
56
|
+
stateDir: world.state,
|
|
57
|
+
minSourceItems: 1,
|
|
58
|
+
minPublishedItems: 1,
|
|
59
|
+
maxDropPercent: 20,
|
|
60
|
+
maxSourceAgeHours: 26,
|
|
61
|
+
maxStaleHours: 48,
|
|
62
|
+
acceptDrop: false,
|
|
63
|
+
...extra,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const run = async (world: World, { at = NOW, guard = {} }: { at?: Date; guard?: Partial<FeedGuardOptions> } = {}) => {
|
|
67
|
+
const forEachChunk = async (fn: (cars: AutouncleSourceCar[]) => Promise<void>) => {
|
|
68
|
+
const names = (await fs.readdir(world.source)).sort((a, b) => Number(a.split('.')[1]) - Number(b.split('.')[1]));
|
|
69
|
+
for (const name of names) {
|
|
70
|
+
await fn(JSON.parse(await fs.readFile(join(world.source, name), 'utf8')));
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const feed = new AutouncleFeedRun(
|
|
74
|
+
{
|
|
75
|
+
forEachChunk,
|
|
76
|
+
filterCar: ({ car: c }) => (c?.metaData?.imagesCount ?? 0) > 0,
|
|
77
|
+
dealerAddress: async (ownerId) => {
|
|
78
|
+
world.dealerLookups += 1;
|
|
79
|
+
return world.unknownDealers.has(ownerId) ? null : ADDRESS;
|
|
80
|
+
},
|
|
81
|
+
resolveImages: async ({ car: c, images: list }) => {
|
|
82
|
+
const clean = imagesService.getCleanImages(c._id, list);
|
|
83
|
+
return toAutouncleImages(selectOpenAiImageFields(clean, clean.map(i => ({ url: i.url, ok: !world.brokenImages.has(i.url) }))));
|
|
84
|
+
},
|
|
85
|
+
imageStats: () => ({ ok: 1 }),
|
|
86
|
+
translate: key => key,
|
|
87
|
+
guard: new FeedGuard(guardOptions(world, guard)),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
runId: 'test',
|
|
91
|
+
dryRun: true,
|
|
92
|
+
domain: 'auto.de',
|
|
93
|
+
sourceDirectory: world.source,
|
|
94
|
+
sourceBaseFileName: 'cars',
|
|
95
|
+
jsonFile: world.json,
|
|
96
|
+
stateDir: world.state,
|
|
97
|
+
now: () => at,
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
return feed.run();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const readAds = async (world: World) => JSON.parse(await fs.readFile(world.json, 'utf8')).ads;
|
|
104
|
+
const ids = (ads: any[]) => ads.map((a: any) => a.back_link.split('/vehicle/')[1].split('?')[0]);
|
|
105
|
+
const exists = (file: string) => fs.stat(file).then(() => true, () => false);
|
|
106
|
+
|
|
107
|
+
const tests: Array<[string, () => Promise<void>]> = [
|
|
108
|
+
['first run: {"ads":[...]} without the duplicated first car, exclusions counted, report written', async () => {
|
|
109
|
+
const world = await newWorld();
|
|
110
|
+
const noImages = source('nopic');
|
|
111
|
+
noImages.car.metaData.imagesCount = 0;
|
|
112
|
+
const noHero = source('nohero');
|
|
113
|
+
noHero.images = noHero.images.filter(i => i.imageCodePredicted !== 'ext_f_right');
|
|
114
|
+
world.unknownDealers.add('dealer-x');
|
|
115
|
+
await writeSource(world, [
|
|
116
|
+
source('aaa1', { 'metaData.ownerId': 'dealer-1' }),
|
|
117
|
+
source('aaa2', { 'metaData.ownerId': 'dealer-1' }),
|
|
118
|
+
source('aaa3', { 'metaData.ownerId': 'dealer-2', 'mainData.firstRegistration': null }),
|
|
119
|
+
source('aaa1'),
|
|
120
|
+
noImages,
|
|
121
|
+
noHero,
|
|
122
|
+
source('sold', { 'offer.offerAvailability': 'selector_offerAvailability_sold' }),
|
|
123
|
+
source('resv', { 'offer.offerAvailability': 'selector_offerAvailability_reserved' }),
|
|
124
|
+
source('noprice', { 'price.currentSalesPrice': null }),
|
|
125
|
+
source('nodealer', { 'metaData.ownerId': 'dealer-x' }),
|
|
126
|
+
]);
|
|
127
|
+
const report = await run(world);
|
|
128
|
+
const ads = await readAds(world);
|
|
129
|
+
assert.deepEqual(ids(ads), ['aaa1', 'aaa2', 'aaa3']);
|
|
130
|
+
// two cars of one dealer share seller_id
|
|
131
|
+
assert.deepEqual(ads.map((a: any) => a.seller_id), ['dealer-1', 'dealer-1', 'dealer-2']);
|
|
132
|
+
assert.deepEqual(ads.map((a: any) => a.year), ['2025', '2025', '']);
|
|
133
|
+
for (const ad of ads) {
|
|
134
|
+
assert.equal(ad.images.length, 4);
|
|
135
|
+
assert.match(ad.images[0].url, /hero1\/large-cached\.jpeg$/);
|
|
136
|
+
assert.ok(ad.images.every(({ url }: { url: string }) => !url.includes('/noimage/')));
|
|
137
|
+
assert.equal(ad.price, 'EUR 15148');
|
|
138
|
+
}
|
|
139
|
+
assert.equal(report.status, 'published');
|
|
140
|
+
assert.equal(report.items, 3);
|
|
141
|
+
assert.equal(report.dealers, 2);
|
|
142
|
+
assert.deepEqual(report.excluded, {
|
|
143
|
+
duplicate_id: 1,
|
|
144
|
+
filtered: 1,
|
|
145
|
+
no_valid_image: 1,
|
|
146
|
+
reserved_or_sold: 2,
|
|
147
|
+
no_valid_price: 1,
|
|
148
|
+
no_dealer: 1,
|
|
149
|
+
});
|
|
150
|
+
assert.deepEqual(report.imagesPerItem, { min: 4, max: 4, total: 12 });
|
|
151
|
+
const saved = JSON.parse(await fs.readFile(join(world.state, 'last-run-report.json'), 'utf8'));
|
|
152
|
+
assert.equal(saved.status, 'published');
|
|
153
|
+
assert.equal(await exists(`${world.json}.tmp`), false);
|
|
154
|
+
assert.equal(await exists(join(world.state, 'run.lock')), false);
|
|
155
|
+
}],
|
|
156
|
+
|
|
157
|
+
['JSON file: UTF-8 umlauts, no BOM, single object with ads array', async () => {
|
|
158
|
+
const world = await newWorld();
|
|
159
|
+
await writeSource(world, [source('u1', { 'mainData.subModel': 'Größe Ü' }), source('u2')]);
|
|
160
|
+
await run(world);
|
|
161
|
+
const bytes = await fs.readFile(world.json);
|
|
162
|
+
assert.notEqual(bytes[0], 0xef);
|
|
163
|
+
assert.ok(bytes.includes(Buffer.from('Größe Ü', 'utf8')));
|
|
164
|
+
assert.ok(bytes.toString('utf8').startsWith('{"ads":[{'));
|
|
165
|
+
assert.equal((await readAds(world)).length, 2);
|
|
166
|
+
}],
|
|
167
|
+
|
|
168
|
+
['broken image URLs are left out; broken hero -> car not sent', async () => {
|
|
169
|
+
const world = await newWorld();
|
|
170
|
+
world.brokenImages.add('https://images.auto.de/carimage/b1/b1back1/large-cached.jpeg');
|
|
171
|
+
world.brokenImages.add('https://images.auto.de/carimage/b2/b2hero1/large-cached.jpeg');
|
|
172
|
+
await writeSource(world, [source('b1'), source('b2')]);
|
|
173
|
+
const report = await run(world);
|
|
174
|
+
const ads = await readAds(world);
|
|
175
|
+
assert.deepEqual(ids(ads), ['b1']);
|
|
176
|
+
assert.deepEqual(ads[0].images.map((i: any) => i.url), [
|
|
177
|
+
'https://images.auto.de/carimage/b1/b1hero1/large-cached.jpeg',
|
|
178
|
+
'https://images.auto.de/carimage/b1/b1side1/large-cached.jpeg',
|
|
179
|
+
'https://images.auto.de/carimage/b1/b1int01/large-cached.jpeg',
|
|
180
|
+
]);
|
|
181
|
+
assert.equal(report.excluded.no_valid_image, 1);
|
|
182
|
+
}],
|
|
183
|
+
|
|
184
|
+
['source outage: empty snapshot is blocked, last good JSON untouched', async () => {
|
|
185
|
+
const world = await newWorld();
|
|
186
|
+
await writeSource(world, [source('g1'), source('g2')]);
|
|
187
|
+
await run(world);
|
|
188
|
+
const good = await fs.readFile(world.json, 'utf8');
|
|
189
|
+
await writeSource(world, []);
|
|
190
|
+
await assert.rejects(run(world, { at: new Date(NOW.getTime() + HOUR) }), (e: any) => e instanceof FeedGuardError && e.code === 'source_empty');
|
|
191
|
+
assert.equal(await fs.readFile(world.json, 'utf8'), good);
|
|
192
|
+
const saved = JSON.parse(await fs.readFile(join(world.state, 'last-run-report.json'), 'utf8'));
|
|
193
|
+
assert.equal(saved.status, 'blocked');
|
|
194
|
+
assert.equal(saved.guard.code, 'source_empty');
|
|
195
|
+
assert.equal(await exists(join(world.state, 'run.lock')), false);
|
|
196
|
+
}],
|
|
197
|
+
|
|
198
|
+
['partial snapshot (missing chunk) and stale snapshot are blocked', async () => {
|
|
199
|
+
const world = await newWorld();
|
|
200
|
+
await writeSource(world, [source('p1'), source('p2'), source('p3'), source('p4'), source('p5')]);
|
|
201
|
+
await fs.rm(join(world.source, 'cars.1.json'));
|
|
202
|
+
await assert.rejects(run(world), (e: any) => e instanceof FeedGuardError && e.code === 'source_partial');
|
|
203
|
+
assert.equal(await exists(world.json), false);
|
|
204
|
+
|
|
205
|
+
await writeSource(world, [source('s1')], 2, new Date(NOW.getTime() - 30 * HOUR));
|
|
206
|
+
await assert.rejects(run(world), (e: any) => e instanceof FeedGuardError && e.code === 'source_stale');
|
|
207
|
+
assert.equal(await exists(world.json), false);
|
|
208
|
+
}],
|
|
209
|
+
|
|
210
|
+
['count drop > 20% is blocked (rejected file kept), acceptDrop publishes once', async () => {
|
|
211
|
+
const world = await newWorld();
|
|
212
|
+
const ten = Array.from({ length: 10 }, (_, i) => source(`c${i}`));
|
|
213
|
+
await writeSource(world, ten);
|
|
214
|
+
await run(world);
|
|
215
|
+
const good = await fs.readFile(world.json, 'utf8');
|
|
216
|
+
await writeSource(world, ten.slice(0, 4));
|
|
217
|
+
const later = new Date(NOW.getTime() + HOUR);
|
|
218
|
+
await assert.rejects(run(world, { at: later }), (e: any) => e instanceof FeedGuardError && e.code === 'count_drop');
|
|
219
|
+
assert.equal(await fs.readFile(world.json, 'utf8'), good);
|
|
220
|
+
assert.equal(await exists(`${world.json}.rejected`), true);
|
|
221
|
+
const accepted = await run(world, { at: later, guard: { acceptDrop: true } });
|
|
222
|
+
assert.equal(accepted.status, 'published');
|
|
223
|
+
assert.equal((await readAds(world)).length, 4);
|
|
224
|
+
assert.equal(await fs.readFile(`${world.json}.prev`, 'utf8'), good);
|
|
225
|
+
}],
|
|
226
|
+
|
|
227
|
+
['nothing publishable -> blocked, never an empty {"ads":[]} file', async () => {
|
|
228
|
+
const world = await newWorld();
|
|
229
|
+
await writeSource(world, [source('n1', { 'price.currentSalesPrice': 0 }), source('n2', { 'offer.offerAvailability': 'selector_offerAvailability_sold' })]);
|
|
230
|
+
await assert.rejects(run(world), (e: any) => e instanceof FeedGuardError && e.code === 'published_too_small');
|
|
231
|
+
assert.equal(await exists(world.json), false);
|
|
232
|
+
}],
|
|
233
|
+
|
|
234
|
+
['second run with the same snapshot: byte-identical file (stable ids, order, links)', async () => {
|
|
235
|
+
const world = await newWorld();
|
|
236
|
+
await writeSource(world, [source('i1'), source('i2'), source('i3')]);
|
|
237
|
+
await run(world);
|
|
238
|
+
const first = await fs.readFile(world.json, 'utf8');
|
|
239
|
+
await run(world, { at: new Date(NOW.getTime() + HOUR) });
|
|
240
|
+
assert.equal(await fs.readFile(world.json, 'utf8'), first);
|
|
241
|
+
}],
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
(async () => {
|
|
245
|
+
let failed = 0;
|
|
246
|
+
for (const [name, fn] of tests) {
|
|
247
|
+
try {
|
|
248
|
+
await fn();
|
|
249
|
+
console.log(`ok ${name}`);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
failed += 1;
|
|
252
|
+
console.error(`FAIL ${name}\n ${e.message}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (failed) process.exit(1);
|
|
256
|
+
})();
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
+
// AutoUncle `ads[]` mapping (ADV-322) and image list (ADV-323) — pure functions, no network.
|
|
3
|
+
import * as assert from 'node:assert/strict';
|
|
4
|
+
|
|
5
|
+
import { AutouncleConfig } from '../src/configs/autouncle.config';
|
|
6
|
+
import { AutouncleJsonBuilder } from '../src/formats/autouncle-json.builder';
|
|
7
|
+
import { Environment, ExportType, ImageType } from '../src/enums';
|
|
8
|
+
import {
|
|
9
|
+
AutouncleExclusion,
|
|
10
|
+
AutouncleTransformer,
|
|
11
|
+
getAutounclePrice,
|
|
12
|
+
getAutouncleStatus,
|
|
13
|
+
getAutouncleYear,
|
|
14
|
+
mapAutouncleItem,
|
|
15
|
+
toAutouncleImages,
|
|
16
|
+
} from '../src/transformers/autouncle.transformer';
|
|
17
|
+
import { selectOpenAiImageFields } from '../src/transformers/openai-image-fields';
|
|
18
|
+
import { buildImagesService } from './fixtures/images-service.harness';
|
|
19
|
+
import { car, eightImages, images } from './fixtures/meinstadt.fixture';
|
|
20
|
+
import { promises as fs } from 'fs';
|
|
21
|
+
import { tmpdir } from 'os';
|
|
22
|
+
import { join } from 'path';
|
|
23
|
+
|
|
24
|
+
const imagesService = buildImagesService();
|
|
25
|
+
const ADDRESS = { city: 'Sandersdorf-Brehna', addr1: 'Max-Planck-Str. 19', region: 'Sachsen-Anhalt', postal_code: '06796' };
|
|
26
|
+
const ctx = (extra: Record<string, any> = {}) => ({
|
|
27
|
+
domain: 'auto.de',
|
|
28
|
+
address: ADDRESS,
|
|
29
|
+
images: ['https://images.auto.de/carimage/x/hero/large-cached.jpeg'],
|
|
30
|
+
translate: (key: string) => ({ selector_exteriorColor_red: 'Rot', other: 'Andere' }[key] ?? key),
|
|
31
|
+
...extra,
|
|
32
|
+
});
|
|
33
|
+
const item = (c: any, extra: Record<string, any> = {}) => {
|
|
34
|
+
const result = mapAutouncleItem(c, ctx(extra));
|
|
35
|
+
assert.equal(result.ok, true, `expected ok, got ${JSON.stringify(result)}`);
|
|
36
|
+
return (result as any).item;
|
|
37
|
+
};
|
|
38
|
+
const reason = (c: any, extra: Record<string, any> = {}) => {
|
|
39
|
+
const result = mapAutouncleItem(c, ctx(extra));
|
|
40
|
+
assert.equal(result.ok, false);
|
|
41
|
+
return (result as any).reason;
|
|
42
|
+
};
|
|
43
|
+
const NOW = new Date('2026-10-13T08:00:00Z');
|
|
44
|
+
|
|
45
|
+
// Historic key set and order (Confluence "2.7.2 Export Plugins" + the exporter since init commit),
|
|
46
|
+
// misspellings included, until AutoUncle confirms a current schema.
|
|
47
|
+
const HISTORIC_KEYS = [
|
|
48
|
+
'back_link', 'make', 'model_name', 'model_variant', 'fuel', 'body', 'doors', 'price', 'year', 'status',
|
|
49
|
+
'mileage', 'leasing', 'color', 'engine_size', 'power', 'gear_type', 'has_4wd', 'seller_type', 'seller_region',
|
|
50
|
+
'seller_city', 'seller_street_address', 'seller_postcode', 'seller_name', 'seller_id', 'description', 'images',
|
|
51
|
+
'fuel_economy', 'co2_emission', 'co2_effiency_class', 'euro_emission_class', 'enviromental_sticker',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const tests: Array<[string, () => void | Promise<void>]> = [
|
|
55
|
+
['keys: historic set, order and spellings unchanged', () => {
|
|
56
|
+
assert.deepEqual(Object.keys(item(car('k1'))), HISTORIC_KEYS);
|
|
57
|
+
}],
|
|
58
|
+
|
|
59
|
+
['identity: seller_id = dealer (ownerId) for every car of one dealer; back_link = stable car _id', () => {
|
|
60
|
+
const a = item(car('car-a', { 'metaData.ownerId': 'dealer-7' }));
|
|
61
|
+
const b = item(car('car-b', { 'metaData.ownerId': 'dealer-7' }));
|
|
62
|
+
assert.equal(a.seller_id, 'dealer-7');
|
|
63
|
+
assert.equal(b.seller_id, 'dealer-7');
|
|
64
|
+
assert.notEqual(a.back_link, b.back_link);
|
|
65
|
+
assert.equal(a.back_link, 'https://www.auto.de/search/vehicle/car-a?source=classifieds&medium=cpc&campaign=autouncle');
|
|
66
|
+
// same car again -> byte-identical back_link (no relisting under a new URL)
|
|
67
|
+
assert.equal(item(car('car-a', { 'metaData.ownerId': 'dealer-7' })).back_link, a.back_link);
|
|
68
|
+
// seller_id is never a vehicle id
|
|
69
|
+
assert.notEqual(a.seller_id, car('car-a').metaData.vehicleId);
|
|
70
|
+
}],
|
|
71
|
+
|
|
72
|
+
['year: null / 0 / missing / garbage -> "" (never 1970); status follows the same rule', () => {
|
|
73
|
+
for (const value of [null, 0, undefined, '', 'abc', -5]) {
|
|
74
|
+
assert.equal(getAutouncleYear(value, NOW), '', `year for ${JSON.stringify(value)}`);
|
|
75
|
+
assert.equal(getAutouncleStatus(value), 'Neuwagen');
|
|
76
|
+
}
|
|
77
|
+
const noField = car('y0');
|
|
78
|
+
delete noField.mainData.firstRegistration;
|
|
79
|
+
assert.equal(item(noField).year, '');
|
|
80
|
+
const nullYear = item(car('y1', { 'mainData.firstRegistration': null }));
|
|
81
|
+
assert.equal(nullYear.year, '');
|
|
82
|
+
assert.notEqual(nullYear.year, '1970');
|
|
83
|
+
assert.equal(nullYear.status, 'Neuwagen');
|
|
84
|
+
// 2025-06-25 in Unix seconds
|
|
85
|
+
const used = item(car('y2', { 'mainData.firstRegistration': 1750809600 }));
|
|
86
|
+
assert.equal(used.year, '2025');
|
|
87
|
+
assert.equal(used.status, 'Gebrauchtwagen');
|
|
88
|
+
// milliseconds by mistake -> year 57453 -> unknown, not a fantasy year
|
|
89
|
+
assert.equal(getAutouncleYear(1750809600000, NOW), '');
|
|
90
|
+
}],
|
|
91
|
+
|
|
92
|
+
['price: EUR purchase price (currentSalesPrice), never the rate or the dealer cost; invalid -> car not sent', () => {
|
|
93
|
+
const c = car('p1', { 'price.currentSalesPrice': 15148, 'price.purchasePrice': 11000, 'rate.monthlyRate': 199 });
|
|
94
|
+
assert.equal(item(c).price, 'EUR 15148');
|
|
95
|
+
// a price change shows up 1:1
|
|
96
|
+
assert.equal(item(car('p1', { 'price.currentSalesPrice': 14990 })).price, 'EUR 14990');
|
|
97
|
+
for (const value of [null, undefined, '', 0, -1, 'abc', true]) {
|
|
98
|
+
assert.equal(getAutounclePrice(value), null, `price ${JSON.stringify(value)}`);
|
|
99
|
+
assert.equal(reason(car('p2', { 'price.currentSalesPrice': value })), AutouncleExclusion.noPrice);
|
|
100
|
+
}
|
|
101
|
+
}],
|
|
102
|
+
|
|
103
|
+
['availability: reserved / sold cars are not sendable stock', () => {
|
|
104
|
+
assert.equal(reason(car('r1', { 'offer.offerAvailability': 'selector_offerAvailability_reserved' })), AutouncleExclusion.notAvailable);
|
|
105
|
+
assert.equal(reason(car('r2', { 'offer.offerAvailability': 'selector_offerAvailability_sold' })), AutouncleExclusion.notAvailable);
|
|
106
|
+
assert.equal(mapAutouncleItem(car('r3', { 'offer.offerAvailability': 'selector_offerAvailability_available' }), ctx()).ok, true);
|
|
107
|
+
}],
|
|
108
|
+
|
|
109
|
+
['no id / no dealer -> not sent', () => {
|
|
110
|
+
assert.equal(reason(car('', {})), AutouncleExclusion.noId);
|
|
111
|
+
assert.equal(reason(car('d1', { 'metaData.ownerId': '' })), AutouncleExclusion.noDealer);
|
|
112
|
+
}],
|
|
113
|
+
|
|
114
|
+
['selectors: unknown / null category, fuel, gearbox -> Other/other, the key is never dropped', () => {
|
|
115
|
+
const odd = item(car('o1', { 'mainData.category': 'selector_category_pickup', 'consumption.fuel': null, 'driveSuspension.gearbox': undefined }));
|
|
116
|
+
assert.equal(odd.body, 'Other');
|
|
117
|
+
assert.equal(odd.fuel, 'Other');
|
|
118
|
+
assert.equal(odd.gear_type, 'other');
|
|
119
|
+
assert.deepEqual(Object.keys(JSON.parse(JSON.stringify(odd))), HISTORIC_KEYS);
|
|
120
|
+
const known = item(car('o2', { 'mainData.category': 'selector_category_suv', 'consumption.fuel': 'selector_fuel_hybridElectricalPetrol', 'driveSuspension.gearbox': 'selector_gearbox_semiAutomatic' }));
|
|
121
|
+
assert.deepEqual([known.body, known.fuel, known.gear_type], ['Suv', 'Hybrid', 'automatic']);
|
|
122
|
+
}],
|
|
123
|
+
|
|
124
|
+
['units: no "ccm" without a value; km and kW as before', () => {
|
|
125
|
+
assert.equal(item(car('u1')).engine_size, '');
|
|
126
|
+
assert.equal(item(car('u2', { 'engineData.cubicCapacity': 1497 })).engine_size, '1497ccm');
|
|
127
|
+
assert.equal(item(car('u3')).mileage, '13000km');
|
|
128
|
+
assert.equal(item(car('u4', { 'mainData.mileage': 0 })).mileage, '1km');
|
|
129
|
+
assert.equal(item(car('u5')).power, '85');
|
|
130
|
+
}],
|
|
131
|
+
|
|
132
|
+
['seller address: the dealer\'s own address, region from the postal code table', () => {
|
|
133
|
+
const a = item(car('s1'));
|
|
134
|
+
assert.equal(a.seller_city, 'Sandersdorf-Brehna');
|
|
135
|
+
assert.equal(a.seller_postcode, '06796');
|
|
136
|
+
assert.equal(a.seller_street_address, 'Max-Planck-Str. 19');
|
|
137
|
+
assert.equal(a.seller_region, 'Sachsen-Anhalt');
|
|
138
|
+
assert.equal(a.seller_name, 'auto.de');
|
|
139
|
+
assert.equal(a.seller_type, 'Dealer');
|
|
140
|
+
}],
|
|
141
|
+
|
|
142
|
+
['getAddress: place unknown to the postal code table keeps street/city/zip (were blanked), region Deutschland', async () => {
|
|
143
|
+
const transformer = new AutouncleTransformer();
|
|
144
|
+
const unknown = await transformer.getAddress({ mainAddress: { city: 'Nirgendwo', zip: '00000', street: 'Teststr. 1', country: { name: 'Deutschland' } } });
|
|
145
|
+
assert.deepEqual(unknown, { city: 'Nirgendwo', addr1: 'Teststr. 1', region: 'Deutschland', postal_code: '00000' });
|
|
146
|
+
const known = await transformer.getAddress({ businessAddress: { city: 'Sandersdorf-Brehna', zip: '06796', street: 'Max-Planck-Str. 19', country: { name: 'Deutschland' } } });
|
|
147
|
+
assert.equal(known.city, 'Sandersdorf-Brehna');
|
|
148
|
+
assert.equal(known.postal_code, '06796');
|
|
149
|
+
assert.ok(known.region && known.region !== 'Deutschland', `region from table, got ${known.region}`);
|
|
150
|
+
assert.deepEqual(await transformer.getAddress(null), { city: '', addr1: '', region: 'Deutschland', postal_code: '' });
|
|
151
|
+
}],
|
|
152
|
+
|
|
153
|
+
['images: [{url}] objects, clean profile order, explicit JPEG, never a placeholder', () => {
|
|
154
|
+
const clean = imagesService.getCleanImages('car-i', images('i'));
|
|
155
|
+
const urls = toAutouncleImages(selectOpenAiImageFields(clean, clean.map(i => ({ url: i.url, ok: true }))));
|
|
156
|
+
const out = item(car('car-i'), { images: urls }).images;
|
|
157
|
+
assert.deepEqual(out, [
|
|
158
|
+
{ url: 'https://images.auto.de/carimage/car-i/ihero1/large-cached.jpeg' },
|
|
159
|
+
{ url: 'https://images.auto.de/carimage/car-i/iside1/large-cached.jpeg' },
|
|
160
|
+
{ url: 'https://images.auto.de/carimage/car-i/iback1/large-cached.jpeg' },
|
|
161
|
+
{ url: 'https://images.auto.de/carimage/car-i/iint01/large-cached.jpeg' },
|
|
162
|
+
]);
|
|
163
|
+
assert.ok(out.every(({ url }: { url: string }) => !url.includes('/noimage/')));
|
|
164
|
+
}],
|
|
165
|
+
|
|
166
|
+
['images: no valid 3/4 hero -> [] -> car not sent; duplicates once; maxImages cuts only the list', () => {
|
|
167
|
+
const clean = imagesService.getCleanImages('car-h', images('h'));
|
|
168
|
+
const heroBroken = selectOpenAiImageFields(clean, clean.map(i => ({ url: i.url, ok: !i.heroCandidate })));
|
|
169
|
+
assert.deepEqual(toAutouncleImages(heroBroken), []);
|
|
170
|
+
assert.equal(reason(car('car-h'), { images: [] }), AutouncleExclusion.noImage);
|
|
171
|
+
// no hero shot at all
|
|
172
|
+
assert.deepEqual(imagesService.getCleanImages('car-n', images('n').filter(i => i.imageCodePredicted !== 'ext_f_right')), []);
|
|
173
|
+
// the same Imbo image twice -> once
|
|
174
|
+
const dup = [...eightImages('d'), ...eightImages('d')];
|
|
175
|
+
const dupClean = imagesService.getCleanImages('car-d', dup);
|
|
176
|
+
assert.equal(dupClean.length, 8);
|
|
177
|
+
const allOk = selectOpenAiImageFields(dupClean, dupClean.map(i => ({ url: i.url, ok: true })));
|
|
178
|
+
assert.equal(toAutouncleImages(allOk).length, 8);
|
|
179
|
+
assert.equal(toAutouncleImages(allOk, 3).length, 3);
|
|
180
|
+
assert.equal(allOk.fields.additional_image_urls.length, 7);
|
|
181
|
+
}],
|
|
182
|
+
|
|
183
|
+
['builder: first item written once, {"ads":[...]} parses', async () => {
|
|
184
|
+
const file = join(await fs.mkdtemp(join(tmpdir(), 'autouncle-b-')), 'out.json');
|
|
185
|
+
const builder = new AutouncleJsonBuilder(file);
|
|
186
|
+
await builder.buildStart();
|
|
187
|
+
await builder.addItem(item(car('b1')), true);
|
|
188
|
+
await builder.addItem(item(car('b2')), false);
|
|
189
|
+
await builder.buildEnd();
|
|
190
|
+
const parsed = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
191
|
+
assert.deepEqual(parsed.ads.map((a: any) => a.back_link.split('/vehicle/')[1].split('?')[0]), ['b1', 'b2']);
|
|
192
|
+
}],
|
|
193
|
+
|
|
194
|
+
['config: dry-run by default (path no Cronicle step reads), live only with autouncleDryRun=false; clean images', () => {
|
|
195
|
+
const base = { environment: Environment.DEVELOP, exportType: ExportType.AUTOUNCLE };
|
|
196
|
+
const dry = new AutouncleConfig({ ...base } as any);
|
|
197
|
+
assert.equal(dry.dryRun, true);
|
|
198
|
+
assert.equal(dry.outputFiles[0], 'resources/autouncle-dryrun/api_export_cars_autouncle.json');
|
|
199
|
+
assert.equal(dry.stateDir, 'resources/autouncle-state/dryrun');
|
|
200
|
+
assert.equal(dry.imageType, ImageType.clean);
|
|
201
|
+
assert.equal(dry.maxImages, 0);
|
|
202
|
+
assert.equal(dry.feedGuard.minPublishedItems, 1);
|
|
203
|
+
assert.equal(new AutouncleConfig({ ...base, autouncleDryRun: 'no' } as any).dryRun, true);
|
|
204
|
+
const live = new AutouncleConfig({ ...base, autouncleDryRun: 'false', autouncleMaxImages: '12', feedGuardMinPublishedItems: '50' } as any);
|
|
205
|
+
assert.equal(live.dryRun, false);
|
|
206
|
+
assert.equal(live.outputFiles[0], 'resources/autouncle-export/api_export_cars_autouncle.json');
|
|
207
|
+
assert.equal(live.stateDir, 'resources/autouncle-state/export');
|
|
208
|
+
assert.equal(live.maxImages, 12);
|
|
209
|
+
assert.equal(live.feedGuard.minPublishedItems, 50);
|
|
210
|
+
}],
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
(async () => {
|
|
214
|
+
let failed = 0;
|
|
215
|
+
for (const [name, fn] of tests) {
|
|
216
|
+
try {
|
|
217
|
+
await fn();
|
|
218
|
+
console.log(`ok ${name}`);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
failed += 1;
|
|
221
|
+
console.error(`FAIL ${name}\n ${e.message}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (failed) process.exit(1);
|
|
225
|
+
})();
|