Skip to content

Commit 12ee18b

Browse files
committed
Update login automation
1 parent 93ba174 commit 12ee18b

6 files changed

Lines changed: 181 additions & 159 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ jobs:
1414
node-version: 20
1515
cache: npm
1616
- run: npm ci
17+
- run: npx oxlint
1718
- run: npm test

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ Available for both `search raw` and `search build`:
6060
- `-r, --retailer <name>`: Filter by retailer (client-side)
6161
- `-j, --json`: JSON output
6262

63+
If no API key is configured, `search` will automatically run `login` to extract one.
64+
6365
Builder-only:
6466
- `--term <value>`: Add a term (repeatable)
6567
- `--phrase <value>`: Add an exact phrase (repeatable)

src/api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,12 @@ export interface SearchOptions {
4646
limit?: number;
4747
offset?: number;
4848
retailerId?: number;
49+
apiKey?: string;
4950
}
5051

5152
export async function search(options: SearchOptions): Promise<SearchResult> {
5253
const config = await getConfig();
53-
const apiKey = config.apiKey;
54+
const apiKey = options.apiKey ?? config.apiKey;
5455
if (!apiKey) {
5556
throw new Error("No API key configured. Run 'marktguru login' first.");
5657
}

src/auth.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
interface ExtractOptions {
2+
log?: (message: string) => void;
3+
}
4+
5+
const BASE_URL = "https://www.marktguru.at";
6+
const API_BASE = "https://api.marktguru.at/api/v1";
7+
const DEFAULT_ZIP_CODE = "1010";
8+
const MAX_SCRIPTS = 20;
9+
10+
async function maybeGetHeaders(): Promise<Record<string, string>> {
11+
try {
12+
const { HeaderGenerator } = await import("header-generator");
13+
const generator = new HeaderGenerator({
14+
browsers: [{ name: "chrome", minVersion: 110 }],
15+
devices: ["desktop"],
16+
operatingSystems: ["macos"],
17+
});
18+
return generator.getHeaders({ httpVersion: "2" });
19+
} catch {
20+
return {
21+
"user-agent":
22+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
23+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
24+
"accept-language": "en-US,en;q=0.9",
25+
"accept-encoding": "gzip, deflate, br",
26+
};
27+
}
28+
}
29+
30+
async function fetchText(url: string, headers: Record<string, string>): Promise<string> {
31+
const controller = new AbortController();
32+
const timeout = setTimeout(() => controller.abort(), 15000);
33+
try {
34+
const res = await fetch(url, { headers, signal: controller.signal });
35+
if (!res.ok) {
36+
throw new Error(`HTTP ${res.status} for ${url}`);
37+
}
38+
return await res.text();
39+
} finally {
40+
clearTimeout(timeout);
41+
}
42+
}
43+
44+
async function fetchFirstOk(urls: string[], headers: Record<string, string>) {
45+
let lastError: unknown = null;
46+
for (const url of urls) {
47+
try {
48+
const text = await fetchText(url, headers);
49+
return { url, text };
50+
} catch (error) {
51+
lastError = error;
52+
}
53+
}
54+
if (lastError) throw lastError;
55+
throw new Error("No URLs to fetch.");
56+
}
57+
58+
function extractScriptUrls(html: string): string[] {
59+
const urls = new Set<string>();
60+
const regex = /<script[^>]+src=["']([^"']+)["'][^>]*>/gi;
61+
let match: RegExpExecArray | null;
62+
while ((match = regex.exec(html))) {
63+
let src = match[1];
64+
if (src.startsWith("//")) src = `https:${src}`;
65+
if (src.startsWith("/")) src = `${BASE_URL}${src}`;
66+
if (src.startsWith("http")) urls.add(src);
67+
}
68+
return [...urls];
69+
}
70+
71+
function findCandidates(text: string): string[] {
72+
const candidates = new Set<string>();
73+
74+
const headerRegex = /x-apikey\s*['"]?\s*[:=]\s*['"]([^'"]{10,})['"]/gi;
75+
let match: RegExpExecArray | null;
76+
while ((match = headerRegex.exec(text))) {
77+
candidates.add(match[1]);
78+
}
79+
80+
const apiKeyRegex = /apiKey\s*[:=]\s*['"]([^'"]{10,})['"]/gi;
81+
while ((match = apiKeyRegex.exec(text))) {
82+
candidates.add(match[1]);
83+
}
84+
85+
const base64Regex = /[A-Za-z0-9+/]{40,80}={0,2}/g;
86+
while ((match = base64Regex.exec(text))) {
87+
const value = match[0];
88+
if (value.length >= 40 && value.length <= 60 && value.includes("=")) {
89+
candidates.add(value);
90+
}
91+
}
92+
93+
return [...candidates];
94+
}
95+
96+
async function validateKey(apiKey: string): Promise<boolean> {
97+
const url = `${API_BASE}/offers/search?as=web&q=test&limit=1&zipCode=${DEFAULT_ZIP_CODE}`;
98+
const controller = new AbortController();
99+
const timeout = setTimeout(() => controller.abort(), 15000);
100+
try {
101+
const res = await fetch(url, {
102+
headers: {
103+
"x-apikey": apiKey,
104+
"accept": "application/json",
105+
},
106+
signal: controller.signal,
107+
});
108+
return res.ok;
109+
} finally {
110+
clearTimeout(timeout);
111+
}
112+
}
113+
114+
export async function extractApiKey(options: ExtractOptions = {}): Promise<string> {
115+
const log = options.log;
116+
const headers = await maybeGetHeaders();
117+
118+
const entryUrls = [
119+
`${BASE_URL}/`,
120+
`${BASE_URL}/search`,
121+
`${BASE_URL}/search?q=test`,
122+
`${BASE_URL}/suche`,
123+
`${BASE_URL}/suche?q=test`,
124+
];
125+
126+
log?.("→ Fetching entry HTML...");
127+
const { url: entryUrl, text: html } = await fetchFirstOk(entryUrls, headers);
128+
log?.(`✓ Using entry URL: ${entryUrl}`);
129+
130+
const candidates = new Set(findCandidates(html));
131+
132+
const scripts = extractScriptUrls(html).slice(0, MAX_SCRIPTS);
133+
if (scripts.length === 0) {
134+
throw new Error("No scripts found to scan for API keys.");
135+
}
136+
137+
log?.(`→ Scanning ${scripts.length} script(s)...`);
138+
for (const scriptUrl of scripts) {
139+
try {
140+
const text = await fetchText(scriptUrl, headers);
141+
for (const candidate of findCandidates(text)) {
142+
candidates.add(candidate);
143+
}
144+
} catch {
145+
// Ignore script fetch failures
146+
}
147+
}
148+
149+
for (const candidate of candidates) {
150+
if (await validateKey(candidate)) {
151+
return candidate;
152+
}
153+
}
154+
155+
throw new Error("Failed to capture a valid API key. The site may have changed.");
156+
}

src/commands/login.ts

Lines changed: 4 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { saveConfig } from "../config.js";
2+
import { extractApiKey } from "../auth.js";
23

34
interface LoginOptions {
45
json?: boolean;
@@ -10,11 +11,6 @@ interface LoginResult {
1011
error?: string;
1112
}
1213

13-
const BASE_URL = "https://www.marktguru.at";
14-
const API_BASE = "https://api.marktguru.at/api/v1";
15-
const DEFAULT_ZIP_CODE = "1010";
16-
const MAX_SCRIPTS = 20;
17-
1814
function output(result: LoginResult, json: boolean): void {
1915
if (json) {
2016
console.log(JSON.stringify(result));
@@ -26,166 +22,16 @@ function output(result: LoginResult, json: boolean): void {
2622
}
2723
}
2824

29-
async function maybeGetHeaders(): Promise<Record<string, string>> {
30-
try {
31-
const { HeaderGenerator } = await import("header-generator");
32-
const generator = new HeaderGenerator({
33-
browsers: [{ name: "chrome", minVersion: 110 }],
34-
devices: ["desktop"],
35-
operatingSystems: ["macos"],
36-
});
37-
return generator.getHeaders({ httpVersion: "2" });
38-
} catch {
39-
return {
40-
"user-agent":
41-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
42-
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
43-
"accept-language": "en-US,en;q=0.9",
44-
"accept-encoding": "gzip, deflate, br",
45-
};
46-
}
47-
}
48-
49-
async function fetchText(url: string, headers: Record<string, string>): Promise<string> {
50-
const controller = new AbortController();
51-
const timeout = setTimeout(() => controller.abort(), 15000);
52-
try {
53-
const res = await fetch(url, { headers, signal: controller.signal });
54-
if (!res.ok) {
55-
throw new Error(`HTTP ${res.status} for ${url}`);
56-
}
57-
return await res.text();
58-
} finally {
59-
clearTimeout(timeout);
60-
}
61-
}
62-
63-
async function fetchFirstOk(urls: string[], headers: Record<string, string>) {
64-
let lastError: unknown = null;
65-
for (const url of urls) {
66-
try {
67-
const text = await fetchText(url, headers);
68-
return { url, text };
69-
} catch (error) {
70-
lastError = error;
71-
}
72-
}
73-
if (lastError) throw lastError;
74-
throw new Error("No URLs to fetch.");
75-
}
76-
77-
function extractScriptUrls(html: string): string[] {
78-
const urls = new Set<string>();
79-
const regex = /<script[^>]+src=["']([^"']+)["'][^>]*>/gi;
80-
let match: RegExpExecArray | null;
81-
while ((match = regex.exec(html))) {
82-
let src = match[1];
83-
if (src.startsWith("//")) src = `https:${src}`;
84-
if (src.startsWith("/")) src = `${BASE_URL}${src}`;
85-
if (src.startsWith("http")) urls.add(src);
86-
}
87-
return [...urls];
88-
}
89-
90-
function findCandidates(text: string): string[] {
91-
const candidates = new Set<string>();
92-
93-
const headerRegex = /x-apikey\s*['"]?\s*[:=]\s*['"]([^'"]{10,})['"]/gi;
94-
let match: RegExpExecArray | null;
95-
while ((match = headerRegex.exec(text))) {
96-
candidates.add(match[1]);
97-
}
98-
99-
const apiKeyRegex = /apiKey\s*[:=]\s*['"]([^'"]{10,})['"]/gi;
100-
while ((match = apiKeyRegex.exec(text))) {
101-
candidates.add(match[1]);
102-
}
103-
104-
const base64Regex = /[A-Za-z0-9+/]{40,80}={0,2}/g;
105-
while ((match = base64Regex.exec(text))) {
106-
const value = match[0];
107-
if (value.length >= 40 && value.length <= 60 && value.includes("=")) {
108-
candidates.add(value);
109-
}
110-
}
111-
112-
return [...candidates];
113-
}
114-
115-
async function validateKey(apiKey: string): Promise<boolean> {
116-
const url = `${API_BASE}/offers/search?as=web&q=test&limit=1&zipCode=${DEFAULT_ZIP_CODE}`;
117-
const controller = new AbortController();
118-
const timeout = setTimeout(() => controller.abort(), 15000);
119-
try {
120-
const res = await fetch(url, {
121-
headers: {
122-
"x-apikey": apiKey,
123-
"accept": "application/json",
124-
},
125-
signal: controller.signal,
126-
});
127-
return res.ok;
128-
} finally {
129-
clearTimeout(timeout);
130-
}
131-
}
132-
13325
export async function login(options: LoginOptions): Promise<void> {
13426
const json = options.json ?? false;
13527
const log = (msg: string) => !json && console.log(msg);
13628

13729
log("Extracting Marktguru API key (HTTP-only)...\n");
13830

13931
try {
140-
const headers = await maybeGetHeaders();
141-
142-
const entryUrls = [
143-
`${BASE_URL}/`,
144-
`${BASE_URL}/search`,
145-
`${BASE_URL}/search?q=test`,
146-
`${BASE_URL}/suche`,
147-
`${BASE_URL}/suche?q=test`,
148-
];
149-
150-
log("→ Fetching entry HTML...");
151-
const { url: entryUrl, text: html } = await fetchFirstOk(entryUrls, headers);
152-
log(`✓ Using entry URL: ${entryUrl}`);
153-
154-
const candidates = new Set(findCandidates(html));
155-
156-
const scripts = extractScriptUrls(html).slice(0, MAX_SCRIPTS);
157-
if (scripts.length === 0) {
158-
throw new Error("No scripts found to scan for API keys.");
159-
}
160-
161-
log(`→ Scanning ${scripts.length} script(s)...`);
162-
for (const scriptUrl of scripts) {
163-
try {
164-
const text = await fetchText(scriptUrl, headers);
165-
for (const candidate of findCandidates(text)) {
166-
candidates.add(candidate);
167-
}
168-
} catch {
169-
// Ignore script fetch failures
170-
}
171-
}
172-
173-
for (const candidate of candidates) {
174-
if (await validateKey(candidate)) {
175-
await saveConfig({ apiKey: candidate });
176-
output({ success: true, apiKey: candidate }, json);
177-
return;
178-
}
179-
}
180-
181-
output(
182-
{
183-
success: false,
184-
error: "Failed to capture a valid API key. The site may have changed.",
185-
},
186-
json
187-
);
188-
process.exit(1);
32+
const apiKey = await extractApiKey({ log: json ? undefined : log });
33+
await saveConfig({ apiKey });
34+
output({ success: true, apiKey }, json);
18935
} catch (e) {
19036
output({ success: false, error: (e as Error).message }, json);
19137
process.exit(1);

0 commit comments

Comments
 (0)