diff --git a/README.md b/README.md index 0a68fe7..3e00bd7 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![npm](https://img.shields.io/npm/v/marktguru-cli.svg)](https://www.npmjs.com/package/marktguru-cli) [![license](https://img.shields.io/github/license/manmal/marktguru-cli.svg)](https://github.com/manmal/marktguru-cli/blob/main/LICENSE) -CLI for Austrian Marktguru supermarket deals. +CLI for Marktguru supermarket deals in Austria and Germany. ## AI Agent Skill See [SKILL.md](SKILL.md) for a comprehensive reference designed for AI coding agents. @@ -46,6 +46,11 @@ Set a default ZIP code: marktguru set-zip 1010 ``` +Set a default country (`at` or `de`, default: `at`): +```bash +marktguru set-country de +``` + Show config: ```bash marktguru config @@ -73,6 +78,8 @@ Available for both `search raw` and `search build`: If no API key is configured, `search` will automatically run `login` to extract one. +Note: API keys are country-specific. After switching country with `set-country`, run `login` again to fetch the matching key. + Builder-only: - `--term `: Add a term (repeatable) - `--phrase `: Add an exact phrase (repeatable) diff --git a/SKILL.md b/SKILL.md index 36af9a6..bfc77dd 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,11 +1,11 @@ --- name: marktguru-grocery-deals -description: Look up grocery deals and offers via Marktguru CLI/API. Use when user asks about supermarket discounts, product prices, current promotions, or comparing deals across Austrian retailers (Hofer, Billa, Spar, Lidl, etc.). +description: Look up grocery deals and offers via Marktguru CLI/API. Use when user asks about supermarket discounts, product prices, current promotions, or comparing deals across Austrian or German retailers (Hofer, Billa, Spar, Lidl, Penny, REWE, Kaufland, etc.). --- # Marktguru Grocery Deals -Query Austrian grocery deals from Marktguru. Supports raw queries, structured search building, retailer filtering, and ZIP-code location targeting. +Query grocery deals from Marktguru in Austria and Germany. Supports raw queries, structured search building, retailer filtering, ZIP-code location targeting, and country selection (AT/DE). ## Quick Reference @@ -15,8 +15,9 @@ Query Austrian grocery deals from Marktguru. Supports raw queries, structured se | `search build` | Build query from structured flags | | `search syntax` | Show supported query syntax | | `set-zip ` | Set default ZIP code | +| `set-country ` | Set default country (`at` or `de`, default: `at`) | | `config` | Show current configuration | -| `login` | Extract API key from marktguru.at | +| `login` | Extract API key from marktguru.at/de | --- @@ -32,8 +33,16 @@ Scans site HTML and boot scripts for embedded API keys. No browser automation re ```bash npx marktguru-cli set-zip 1010 npx marktguru-cli set-zip 8010 # Graz +npx marktguru-cli set-zip 10115 # Berlin (DE) ``` +### Set Default Country +```bash +npx marktguru-cli set-country at # Austria (default) +npx marktguru-cli set-country de # Germany +``` +After switching country, re-run `login` — API keys are country-specific. + ### Check Config ```bash npx marktguru-cli config @@ -118,18 +127,22 @@ npx marktguru-cli search raw '"Coca Cola"' ## Known Retailers -| Retailer | Notes | -|----------|-------| -| SPAR | | -| INTERSPAR | Larger SPAR format | -| SPAR-Gourmet | Premium SPAR | -| BILLA | | -| BILLA PLUS | Larger BILLA format | -| HOFER | Austrian Aldi | -| Lidl | | -| PENNY | | -| dm drogerie markt | Drugstore (some food items) | -| BIPA | Drugstore | +| Retailer | AT | DE | Notes | +|-----------------------|----|----|-----------------------------| +| Lidl | ✓ | ✓ | | +| PENNY | ✓ | ✓ | | +| dm drogerie markt | ✓ | ✓ | Drugstore (some food items) | +| SPAR | ✓ | | | +| INTERSPAR | ✓ | | Larger SPAR format | +| SPAR-Gourmet | ✓ | | Premium SPAR | +| BILLA | ✓ | | | +| BILLA PLUS | ✓ | | Larger BILLA format | +| HOFER | ✓ | | Austrian Aldi | +| BIPA | ✓ | | Drugstore | +| Kaufland | | ✓ | | +| REWE | | ✓ | | +| Netto Marken-Discount | | ✓ | | +| ALDI | | ✓ | | --- @@ -209,6 +222,7 @@ npx marktguru-cli config --json "apiKey": "pCcm1AVCYa...", "apiKeySet": true, "zipCode": "1010", + "country": "at", "configPath": "/Users/.../.marktguru/config.json" } ``` @@ -223,6 +237,7 @@ npx marktguru-cli config --json | No results | Try broader terms, wildcards (`*`), or alternative spellings. | | Wrong location | Set ZIP code with `set-zip` or use `--zip` flag. | | API key expired | Re-run `npx marktguru-cli login` to refresh. | +| Wrong country results | Run `set-country de` (or `at`), then `login` again — keys are country-specific. | --- diff --git a/package.json b/package.json index 4c55f5e..de5382f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "marktguru-cli", "version": "0.1.0", - "description": "CLI for Austrian Marktguru supermarket deals", + "description": "CLI for Marktguru supermarket deals in Austria and Germany", "type": "module", "bin": { "marktguru": "dist/cli.js" diff --git a/src/api.ts b/src/api.ts index 2836205..05da5e0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,11 @@ -import { getConfig, DEFAULT_ZIP_CODE } from "./config.js"; +import { getConfig, DEFAULT_ZIP_CODE, DEFAULT_COUNTRY, VALID_COUNTRIES } from "./config.js"; -const API_BASE = "https://api.marktguru.at/api/v1"; +export function getApiBase(country: string): string { + if (!(VALID_COUNTRIES as readonly string[]).includes(country)) { + throw new Error(`Unsupported country "${country}". Valid options: ${VALID_COUNTRIES.join(", ")}`); + } + return `https://api.marktguru.${country}/api/v1`; +} export interface Offer { id: number; @@ -43,6 +48,7 @@ export interface SearchResult { export interface SearchOptions { query: string; zipCode?: string; + country?: string; limit?: number; offset?: number; retailerId?: number; @@ -57,6 +63,7 @@ export async function search(options: SearchOptions): Promise { } const zipCode = options.zipCode || config.zipCode || DEFAULT_ZIP_CODE; + const country = options.country || config.country || DEFAULT_COUNTRY; const params = new URLSearchParams({ as: "web", @@ -70,7 +77,7 @@ export async function search(options: SearchOptions): Promise { params.set("retailerIds", String(options.retailerId)); } - const url = `${API_BASE}/offers/search?${params}`; + const url = `${getApiBase(country)}/offers/search?${params}`; const response = await fetch(url, { headers: { diff --git a/src/auth.ts b/src/auth.ts index 7dc4de7..f3aceff 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,9 +1,10 @@ +import { VALID_COUNTRIES } from "./config.js"; + interface ExtractOptions { log?: (message: string) => void; + country?: string; } -const BASE_URL = "https://www.marktguru.at"; -const API_BASE = "https://api.marktguru.at/api/v1"; const DEFAULT_ZIP_CODE = "1010"; const MAX_SCRIPTS = 20; @@ -55,14 +56,14 @@ async function fetchFirstOk(urls: string[], headers: Record) { throw new Error("No URLs to fetch."); } -function extractScriptUrls(html: string): string[] { +function extractScriptUrls(html: string, baseUrl: string): string[] { const urls = new Set(); const regex = /]+src=["']([^"']+)["'][^>]*>/gi; let match: RegExpExecArray | null; while ((match = regex.exec(html))) { let src = match[1]; if (src.startsWith("//")) src = `https:${src}`; - if (src.startsWith("/")) src = `${BASE_URL}${src}`; + if (src.startsWith("/")) src = `${baseUrl}${src}`; if (src.startsWith("http")) urls.add(src); } return [...urls]; @@ -93,8 +94,8 @@ function findCandidates(text: string): string[] { return [...candidates]; } -async function validateKey(apiKey: string): Promise { - const url = `${API_BASE}/offers/search?as=web&q=test&limit=1&zipCode=${DEFAULT_ZIP_CODE}`; +async function validateKey(apiKey: string, apiBase: string): Promise { + const url = `${apiBase}/offers/search?as=web&q=test&limit=1&zipCode=${DEFAULT_ZIP_CODE}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); try { @@ -113,14 +114,20 @@ async function validateKey(apiKey: string): Promise { export async function extractApiKey(options: ExtractOptions = {}): Promise { const log = options.log; + const country = options.country ?? "at"; + if (!(VALID_COUNTRIES as readonly string[]).includes(country)) { + throw new Error(`Unsupported country "${country}". Valid options: ${VALID_COUNTRIES.join(", ")}`); + } + const baseUrl = `https://www.marktguru.${country}`; + const apiBase = `https://api.marktguru.${country}/api/v1`; const headers = await maybeGetHeaders(); const entryUrls = [ - `${BASE_URL}/`, - `${BASE_URL}/search`, - `${BASE_URL}/search?q=test`, - `${BASE_URL}/suche`, - `${BASE_URL}/suche?q=test`, + `${baseUrl}/`, + `${baseUrl}/search`, + `${baseUrl}/search?q=test`, + `${baseUrl}/suche`, + `${baseUrl}/suche?q=test`, ]; log?.("→ Fetching entry HTML..."); @@ -129,7 +136,7 @@ export async function extractApiKey(options: ExtractOptions = {}): Promise { program .command("login") - .description("Extract API key from marktguru.at via HTTP") + .description("Extract API key from marktguru.at/de via HTTP") .option("-j, --json", "Output JSON") .action(async (options) => { await login({ ...options, json: getJsonFlag(options) }); @@ -107,6 +107,30 @@ program } }); +program + .command("set-country ") + .description("Set default country for searches (at, de)") + .option("-j, --json", "Output JSON") + .action(async (code: string, options) => { + const normalized = code.toLowerCase(); + if (!(VALID_COUNTRIES as readonly string[]).includes(normalized)) { + console.error(`Error: Invalid country "${code}". Valid options: ${VALID_COUNTRIES.join(", ")}`); + process.exit(1); + } + const existing = await getConfig(); + const countryChanged = existing.country !== normalized; + await saveConfig({ country: normalized, ...(countryChanged && { apiKey: undefined }) }); + const json = getJsonFlag(options); + if (json) { + console.log(JSON.stringify({ success: true, country: normalized, apiKeyCleared: countryChanged && !!existing.apiKey })); + } else { + console.log(`✓ Default country set to: ${normalized}`); + if (countryChanged && existing.apiKey) { + console.log(" API key cleared — run 'marktguru login' to fetch a matching key."); + } + } + }); + program .command("config") .description("Show current configuration") @@ -119,12 +143,14 @@ program apiKey: config.apiKey ? config.apiKey.substring(0, 10) + "..." : null, apiKeySet: !!config.apiKey, zipCode: config.zipCode || DEFAULT_ZIP_CODE, + country: config.country || DEFAULT_COUNTRY, configPath: config.configPath, })); } else { console.log("Configuration:"); console.log(" API Key:", config.apiKey ? config.apiKey.substring(0, 10) + "..." : "(not set)"); console.log(" ZIP Code:", config.zipCode || `(default: ${DEFAULT_ZIP_CODE})`); + console.log(" Country:", config.country || `(default: ${DEFAULT_COUNTRY})`); console.log(" Config file:", config.configPath); } }); diff --git a/src/commands/login.ts b/src/commands/login.ts index a4a6fb9..33218da 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,4 +1,4 @@ -import { saveConfig } from "../config.js"; +import { saveConfig, getConfig } from "../config.js"; import { extractApiKey } from "../auth.js"; interface LoginOptions { @@ -29,7 +29,8 @@ export async function login(options: LoginOptions): Promise { log("Extracting Marktguru API key (HTTP-only)...\n"); try { - const apiKey = await extractApiKey({ log: json ? undefined : log }); + const config = await getConfig(); + const apiKey = await extractApiKey({ log: json ? undefined : log, country: config.country }); await saveConfig({ apiKey }); output({ success: true, apiKey }, json); } catch (e) { diff --git a/src/commands/search.ts b/src/commands/search.ts index f4dc142..21f3005 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -57,6 +57,7 @@ interface SimpleSearchResult { export interface SearchCommandOptions { zip?: string; + country?: string; limit?: number; retailer?: string; json?: boolean; @@ -153,20 +154,20 @@ function emitWarnings(warnings: string[]): void { } } -async function ensureApiKey(json?: boolean): Promise { +async function ensureApiKey(json?: boolean, country?: string): Promise { const config = await getConfig(); if (config.apiKey) return config.apiKey; const log = json ? (msg: string) => console.error(msg) : (msg: string) => console.log(msg); log("No API key configured. Running login..."); - const apiKey = await extractApiKey({ log }); + const apiKey = await extractApiKey({ log, country: country ?? config.country }); await saveConfig({ apiKey }); return apiKey; } async function runSearch(query: string, options: SearchCommandOptions): Promise { - const apiKey = await ensureApiKey(options.json); + const apiKey = await ensureApiKey(options.json, options.country); // Fetch more results if filtering by retailer (we'll filter client-side) const limit = normalizeLimit(options.limit); const fetchLimit = options.retailer ? Math.max(limit, 100) : limit; @@ -174,6 +175,7 @@ async function runSearch(query: string, options: SearchCommandOptions): Promise< const result = await apiSearch({ query, zipCode: options.zip, + country: options.country, limit: fetchLimit, apiKey, }); diff --git a/src/config.ts b/src/config.ts index cf04f5e..42a1fb7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,10 +5,14 @@ import { readFile, writeFile, mkdir } from "fs/promises"; export interface Config { apiKey?: string; zipCode?: string; + country?: string; configPath: string; } export const DEFAULT_ZIP_CODE = "1010"; // Vienna +export const DEFAULT_COUNTRY = "at"; +export const VALID_COUNTRIES = ["at", "de"] as const; +export type Country = (typeof VALID_COUNTRIES)[number]; const CONFIG_DIR = join(homedir(), ".marktguru"); const CONFIG_FILE = join(CONFIG_DIR, "config.json"); @@ -16,9 +20,10 @@ const CONFIG_FILE = join(CONFIG_DIR, "config.json"); export async function getConfig(): Promise { try { const data = await readFile(CONFIG_FILE, "utf-8"); - return { ...JSON.parse(data), configPath: CONFIG_FILE }; + const parsed = JSON.parse(data); + return { country: DEFAULT_COUNTRY, ...parsed, configPath: CONFIG_FILE }; } catch { - return { configPath: CONFIG_FILE }; + return { country: DEFAULT_COUNTRY, configPath: CONFIG_FILE }; } } diff --git a/tests/api.test.js b/tests/api.test.js new file mode 100644 index 0000000..6f25342 --- /dev/null +++ b/tests/api.test.js @@ -0,0 +1,15 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getApiBase } from "../dist/api.js"; + +test("getApiBase returns correct URL for AT", () => { + assert.equal(getApiBase("at"), "https://api.marktguru.at/api/v1"); +}); + +test("getApiBase returns correct URL for DE", () => { + assert.equal(getApiBase("de"), "https://api.marktguru.de/api/v1"); +}); + +test("getApiBase throws on unsupported country", () => { + assert.throws(() => getApiBase("fr"), /Unsupported country/); +});