Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <value>`: Add a term (repeatable)
- `--phrase <value>`: Add an exact phrase (repeatable)
Expand Down
45 changes: 30 additions & 15 deletions SKILL.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 <code>` | Set default ZIP code |
| `set-country <code>` | 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 |

---

Expand All @@ -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
Expand Down Expand Up @@ -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 | | ✓ | |

---

Expand Down Expand Up @@ -209,6 +222,7 @@ npx marktguru-cli config --json
"apiKey": "pCcm1AVCYa...",
"apiKeySet": true,
"zipCode": "1010",
"country": "at",
"configPath": "/Users/.../.marktguru/config.json"
}
```
Expand All @@ -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. |

---

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
13 changes: 10 additions & 3 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -43,6 +48,7 @@ export interface SearchResult {
export interface SearchOptions {
query: string;
zipCode?: string;
country?: string;
limit?: number;
offset?: number;
retailerId?: number;
Expand All @@ -57,6 +63,7 @@ export async function search(options: SearchOptions): Promise<SearchResult> {
}

const zipCode = options.zipCode || config.zipCode || DEFAULT_ZIP_CODE;
const country = options.country || config.country || DEFAULT_COUNTRY;

const params = new URLSearchParams({
as: "web",
Expand All @@ -70,7 +77,7 @@ export async function search(options: SearchOptions): Promise<SearchResult> {
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: {
Expand Down
33 changes: 20 additions & 13 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -55,14 +56,14 @@ async function fetchFirstOk(urls: string[], headers: Record<string, string>) {
throw new Error("No URLs to fetch.");
}

function extractScriptUrls(html: string): string[] {
function extractScriptUrls(html: string, baseUrl: string): string[] {
const urls = new Set<string>();
const regex = /<script[^>]+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];
Expand Down Expand Up @@ -93,8 +94,8 @@ function findCandidates(text: string): string[] {
return [...candidates];
}

async function validateKey(apiKey: string): Promise<boolean> {
const url = `${API_BASE}/offers/search?as=web&q=test&limit=1&zipCode=${DEFAULT_ZIP_CODE}`;
async function validateKey(apiKey: string, apiBase: string): Promise<boolean> {
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 {
Expand All @@ -113,14 +114,20 @@ async function validateKey(apiKey: string): Promise<boolean> {

export async function extractApiKey(options: ExtractOptions = {}): Promise<string> {
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...");
Expand All @@ -129,7 +136,7 @@ export async function extractApiKey(options: ExtractOptions = {}): Promise<strin

const candidates = new Set(findCandidates(html));

const scripts = extractScriptUrls(html).slice(0, MAX_SCRIPTS);
const scripts = extractScriptUrls(html, baseUrl).slice(0, MAX_SCRIPTS);
if (scripts.length === 0) {
throw new Error("No scripts found to scan for API keys.");
}
Expand All @@ -147,7 +154,7 @@ export async function extractApiKey(options: ExtractOptions = {}): Promise<strin
}

for (const candidate of candidates) {
if (await validateKey(candidate)) {
if (await validateKey(candidate, apiBase)) {
return candidate;
}
}
Expand Down
32 changes: 29 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
import { Command, InvalidArgumentError } from "commander";
import { login } from "./commands/login.js";
import { searchBuildCommand, searchRawCommand } from "./commands/search.js";
import { getConfig, saveConfig, DEFAULT_ZIP_CODE } from "./config.js";
import { getConfig, saveConfig, DEFAULT_ZIP_CODE, DEFAULT_COUNTRY, VALID_COUNTRIES } from "./config.js";
import { QUERY_SYNTAX_HELP } from "./query.js";

const program = new Command();

program
.name("marktguru")
.description("CLI for Austrian Marktguru supermarket deals")
.description("CLI for Marktguru supermarket deals (AT/DE)")
.version("0.1.0")
.option("-j, --json", "Output JSON (for all commands)");

Expand Down Expand Up @@ -44,7 +44,7 @@ const collectValues = (value: string, previous: string[]): string[] => {

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) });
Expand Down Expand Up @@ -107,6 +107,30 @@ program
}
});

program
.command("set-country <code>")
.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")
Expand All @@ -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);
}
});
Expand Down
5 changes: 3 additions & 2 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { saveConfig } from "../config.js";
import { saveConfig, getConfig } from "../config.js";
import { extractApiKey } from "../auth.js";

interface LoginOptions {
Expand Down Expand Up @@ -29,7 +29,8 @@ export async function login(options: LoginOptions): Promise<void> {
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) {
Expand Down
8 changes: 5 additions & 3 deletions src/commands/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ interface SimpleSearchResult {

export interface SearchCommandOptions {
zip?: string;
country?: string;
limit?: number;
retailer?: string;
json?: boolean;
Expand Down Expand Up @@ -153,27 +154,28 @@ function emitWarnings(warnings: string[]): void {
}
}

async function ensureApiKey(json?: boolean): Promise<string | undefined> {
async function ensureApiKey(json?: boolean, country?: string): Promise<string | undefined> {
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<void> {
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;

const result = await apiSearch({
query,
zipCode: options.zip,
country: options.country,
limit: fetchLimit,
apiKey,
});
Expand Down
Loading