Files
mop-skills/skills/shopping/scripts/geizhals_mcp.py
T

400 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Geizhals MCP Server & CLI Tool
Searches Geizhals (and Skinflint) price comparison data: best prices across shops,
merchant counts, star ratings, test review counts, and price history trends.
Communicates directly with the official Geizhals mobile app API (api.geizhals.net/gh/v9)
using offline HMAC-SHA256 request signing (bypassing Cloudflare JS challenges).
Requires zero external Python dependencies (pure standard library).
"""
import os
import sys
import json
import time
import hmac
import hashlib
import base64
import urllib.request
import urllib.parse
from typing import Dict, Any, List, Optional
BASE_URL = "https://api.geizhals.net/gh/v9"
API_HOST = "api.geizhals.net"
GH_TOKEN_ID = "mobileapp-2026-android-3.12.7"
GH_AUTH_ROLE = "mobileapp-2026-android-3.12.7"
GH_HMAC_SECRET = "2Uq.p2gD1z5Im,m73u5eSVzeuFZJpcQVtzx.M3OtyW.4LvzO"
GH_CLIENT = "Geizhals/3.13.1 (Android 14; sdk_gphone64_x86_64; emu64xa; x86_64; de)"
DEFAULT_COUNTRY = os.environ.get("GEIZHALS_DEFAULT_LOC", "de")
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def _auth_header(body_bytes: bytes, method: str = "POST", query_string: str = "") -> str:
now = int(time.time())
if method == "POST":
s = f"POST|{API_HOST}||{body_bytes.decode('utf-8')}"
else:
s = f"{method}|{API_HOST}|{query_string}|"
fp = hashlib.sha256(s.encode("utf-8")).hexdigest()
header = {"alg": "HS256", "typ": "JWT"}
payload = {
"token_id": GH_TOKEN_ID,
"timestamp": now,
"request_fingerprint": fp,
"iat": now,
}
header_b64 = _b64url(json.dumps(header, separators=(",", ":")).encode())
payload_b64 = _b64url(json.dumps(payload, separators=(",", ":")).encode())
signing_input = f"{header_b64}.{payload_b64}"
sig = hmac.new(GH_HMAC_SECRET.encode("utf-8"), signing_input.encode("utf-8"), hashlib.sha256).digest()
return f"Bearer {signing_input}.{_b64url(sig)}"
def _post(endpoint: str, payload: dict) -> dict:
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
req = urllib.request.Request(
f"{BASE_URL}/{endpoint}",
data=body,
headers={
"User-Agent": GH_CLIENT,
"Authorization": _auth_header(body),
"auth-role": GH_AUTH_ROLE,
"Content-Type": "application/json"
}
)
with urllib.request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read().decode("utf-8"))
class GeizhalsClient:
def search(self, query: str, country: Optional[str] = None, limit: int = 8) -> Dict[str, Any]:
loc = (country or DEFAULT_COUNTRY).lower()
# Geizhals strictly requires pagesize to be in [1, 5, 10, 30, 100, 300, 1000]
valid_pagesizes = [1, 5, 10, 30, 100]
pagesize = 10
for ps in valid_pagesizes:
if ps >= limit:
pagesize = ps
break
payload = {
"query": query,
"params": {
"loc": loc,
"hloc": [loc],
"lang": "de",
"pagesize": pagesize,
"n_offers": 1
}
}
try:
raw = _post("search_product", payload)
except Exception as e:
return {"error": f"Geizhals search error: {str(e)}", "query": query, "results": []}
resp = raw.get("response", {})
products = resp.get("products", [])
total = raw.get("total", len(products))
results = []
domain = "geizhals.de" if loc == "de" else "geizhals.at"
for p in products[:limit]:
prices = p.get("prices") or {}
best_price = prices.get("best")
avg_price = prices.get("avg")
offers_list = p.get("offers") or []
offer_count = p.get("offer_count", len(offers_list))
# If best_price is missing in prices dict, check first offer
cheapest_shop = None
if offers_list:
first_offer = offers_list[0]
cheapest_shop = first_offer.get("shop", {}).get("name")
if best_price is None:
best_price = first_offer.get("pricing", {}).get("eur_value") or first_offer.get("price", {}).get("value")
# Determine product link
urls = p.get("urls") or {}
overview_path = urls.get("overview")
web_url = f"https://{domain}{overview_path}" if overview_path else None
# Test review count & metascore
reviews = p.get("test_reviews") or []
metascore = p.get("average_test_reviews_metascore")
# Image handling (already starts with https://gzhls.at or relative)
raw_images = p.get("images") or []
image_url = None
if raw_images:
first_img = raw_images[0]
if isinstance(first_img, str):
image_url = first_img if first_img.startswith("http") else f"https://gzhls.at/{first_img}"
elif isinstance(first_img, dict):
image_url = first_img.get("url")
results.append({
"id": p.get("gzhid"),
"name": p.get("product") or p.get("product_for_sort"),
"manufacturer": p.get("manufacturer_name"),
"best_price": float(best_price) if best_price is not None else None,
"best_price_formatted": f"{float(best_price):.2f} €".replace(".", ",") if best_price is not None else "Kein Live-Angebot",
"average_price": float(avg_price) if avg_price is not None else None,
"cheapest_shop": cheapest_shop,
"offer_count": offer_count,
"available": bool(best_price is not None and offer_count > 0),
"rating_stars": p.get("rating_stars"),
"rating_count": p.get("rating_count"),
"test_reviews_count": len(reviews),
"test_reviews_metascore": metascore,
"url": web_url,
"image_url": image_url
})
return {
"query": query,
"country": loc,
"total_found": total,
"returned_count": len(results),
"results": results
}
def price_history(self, product_id: int, days: int = 31, country: Optional[str] = None) -> Dict[str, Any]:
loc = (country or DEFAULT_COUNTRY).lower()
# Days must be one of 31, 91, 183, 365
valid_days = [31, 91, 183, 365]
closest_days = min(valid_days, key=lambda x: abs(x - days))
payload = {
"id": product_id,
"params": {
"days": closest_days,
"loc": loc
}
}
try:
raw = _post("price_history", payload)
except Exception as e:
return {"error": f"Price history error: {str(e)}", "product_id": product_id}
meta = raw.get("meta") or {}
series = raw.get("response") or []
min_price = meta.get("min")
max_price = meta.get("max")
current_best = meta.get("current_best")
is_all_time_low = False
if current_best and min_price:
is_all_time_low = float(current_best) <= float(min_price) + 0.05
return {
"product_id": product_id,
"country": loc,
"period_days": closest_days,
"current_best_price": float(current_best) if current_best is not None else None,
"period_min_price": float(min_price) if min_price is not None else None,
"period_max_price": float(max_price) if max_price is not None else None,
"is_all_time_low": is_all_time_low,
"data_points_count": len(series)
}
# ==============================================================================
# Model Context Protocol (MCP) stdio Server
# ==============================================================================
MCP_TOOLS = [
{
"name": "geizhals_search",
"description": "Search products on Geizhals (Germany/Austria price comparison). Returns lowest price, offer count across all stores, rating, and product link.",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Product search query, e.g. 'MacBook Air M3', 'Sony WH-1000XM5', 'Mutti Tomaten'"
},
"country": {
"type": "string",
"description": "Country code ('de' for Germany, 'at' for Austria). Defaults to 'de'.",
"default": "de"
},
"limit": {
"type": "integer",
"description": "Max number of items to return (default 8)",
"default": 8
}
},
"required": ["query"]
}
},
{
"name": "geizhals_price_history",
"description": "Get price history trends for a Geizhals product ID over 31, 91, 183, or 365 days to check if the current price is a good deal or all-time low.",
"inputSchema": {
"type": "object",
"properties": {
"product_id": {
"type": "integer",
"description": "Geizhals product ID (gzhid) obtained from geizhals_search"
},
"days": {
"type": "integer",
"description": "History timeframe in days (31, 91, 183, or 365). Defaults to 31.",
"default": 31
},
"country": {
"type": "string",
"description": "Country code ('de' or 'at'). Defaults to 'de'.",
"default": "de"
}
},
"required": ["product_id"]
}
}
]
def run_mcp_server():
client = GeizhalsClient()
while True:
line = sys.stdin.readline()
if not line:
break
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except Exception as e:
sys.stderr.write(f"Invalid JSON received: {e}\n")
continue
msg_id = req.get("id")
method = req.get("method")
params = req.get("params", {})
if method == "initialize":
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "geizhals-mcp", "version": "1.0.0"}
}
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
elif method == "notifications/initialized":
pass
elif method == "ping":
resp = {"jsonrpc": "2.0", "id": msg_id, "result": {}}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
elif method == "tools/list":
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {"tools": MCP_TOOLS}
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
elif method == "tools/call":
tool_name = params.get("name")
args = params.get("arguments", {})
try:
if tool_name == "geizhals_search":
data = client.search(
query=args.get("query", ""),
country=args.get("country"),
limit=args.get("limit", 8)
)
elif tool_name == "geizhals_price_history":
data = client.price_history(
product_id=int(args.get("product_id")),
days=int(args.get("days", 31)),
country=args.get("country")
)
else:
raise ValueError(f"Unknown tool: {tool_name}")
text_result = json.dumps(data, indent=2, ensure_ascii=False)
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"content": [{"type": "text", "text": text_result}]
}
}
except Exception as e:
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"isError": True,
"content": [{"type": "text", "text": f"Error executing {tool_name}: {str(e)}"}]
}
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
else:
if msg_id is not None:
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"error": {"code": -32601, "message": f"Method not found: {method}"}
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
def run_cli():
import argparse
parser = argparse.ArgumentParser(description="Geizhals Price Comparison MCP & CLI")
subparsers = parser.add_subparsers(dest="command")
sp_search = subparsers.add_parser("search", help="Search products on Geizhals")
sp_search.add_argument("query", help="Product query")
sp_search.add_argument("--country", default="de", help="Country (de or at)")
sp_search.add_argument("--limit", type=int, default=8, help="Max results")
sp_hist = subparsers.add_parser("history", help="Get price history for product ID")
sp_hist.add_argument("product_id", type=int, help="Geizhals product ID (gzhid)")
sp_hist.add_argument("--days", type=int, default=31, help="History days (31, 91, 183, 365)")
sp_hist.add_argument("--country", default="de", help="Country (de or at)")
args = parser.parse_args()
client = GeizhalsClient()
if args.command == "search":
print(json.dumps(client.search(args.query, country=args.country, limit=args.limit), indent=2, ensure_ascii=False))
elif args.command == "history":
print(json.dumps(client.price_history(args.product_id, days=args.days, country=args.country), indent=2, ensure_ascii=False))
else:
parser.print_help()
if __name__ == "__main__":
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
run_mcp_server()
else:
run_cli()