feat(skills): add shopping skill with supermarket deals, geizhals, and ebay MCP servers
This commit is contained in:
@@ -41,6 +41,11 @@ Seamless Jellyfin and Jellyseerr integration. Enables natural-language discovery
|
|||||||
- **Features:** Smart search cross-referenced with library availability, discover with genre & rating filters, direct stream & download links, Jellyfin active sessions & remote play, Jellyseerr requests.
|
- **Features:** Smart search cross-referenced with library availability, discover with genre & rating filters, direct stream & download links, Jellyfin active sessions & remote play, Jellyseerr requests.
|
||||||
- **Tools:** Standalone MCP server and CLI at `scripts/jelly_media.py`.
|
- **Tools:** Standalone MCP server and CLI at `scripts/jelly_media.py`.
|
||||||
|
|
||||||
|
### [shopping](skills/shopping/SKILL.md)
|
||||||
|
Smart shopping advisor and Vorrats-Concierge.
|
||||||
|
- **Features:** German supermarket & discounter weekly discounts (Edeka, Rewe, Aldi, Lidl, Kaufland, Netto, Penny, dm, Rossmann), Vorratskauf multi-brand comparison (Mutti / Oro di Parma), diaper piece-price calculator (ct/Windel), Geizhals price comparison & all-time-low price history, and eBay marketplace search.
|
||||||
|
- **Tools:** Standalone MCP servers and CLIs at `scripts/supermarket_deals.py`, `scripts/geizhals_mcp.py`, and `scripts/ebay_mcp.py`.
|
||||||
|
|
||||||
### [skill-creator](skills/skill-creator/SKILL.md)
|
### [skill-creator](skills/skill-creator/SKILL.md)
|
||||||
Guidelines and scripts to help developers build high-quality, token-efficient Claude skills.
|
Guidelines and scripts to help developers build high-quality, token-efficient Claude skills.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
name: shopping
|
||||||
|
description: Intelligent shopping advisory, price comparisons across German supermarkets (Edeka, Rewe, Aldi, Lidl, Kaufland, Netto, Penny, dm, Rossmann), Geizhals retail best prices & price history, and eBay marketplace searches. Use when the user asks for shopping advice, supermarket offers (e.g. Mutti/Oro Tomatensaucen, Windeln Größe X, Vorratskäufe), price checks, or second-hand/refurbished deals.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🛒 Smart Shopper & Vorrats-Concierge
|
||||||
|
|
||||||
|
Comprehensive shopping advisor for the Graf family. Combines live German supermarket weekly brochures (Regensburg default), Geizhals retail price comparison & price histories, and eBay marketplace searches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ MCP Tools Overview
|
||||||
|
|
||||||
|
### 1. Supermarket & Grocery Deals (`supermarket`)
|
||||||
|
- **`supermarket_search(query, city="Regensburg", lat=49.0134, lng=12.1016)`**:
|
||||||
|
- Searches live weekly offers and digital brochures across Edeka, Rewe, Aldi Süd, Lidl, Kaufland, Netto Marken-Discount, Penny, dm, Rossmann, V-Markt, Müller.
|
||||||
|
- Returns price in EUR, retailer, brand, validity dates, distance in km, and brochure page.
|
||||||
|
- **`supermarket_compare_staples(staples=["Mutti", "Oro di Parma"], city="Regensburg")`**:
|
||||||
|
- Batch search for multiple staple brands or product categories to compare discounts across all local supermarkets in one view.
|
||||||
|
|
||||||
|
### 2. Retail Price Comparison & History (`geizhals`)
|
||||||
|
- **`geizhals_search(query, country="de", limit=8)`**:
|
||||||
|
- Full-text search across Geizhals.de (and .at). Returns the best price, average price, offer count across all web shops, cheapest shop name, star ratings, and test review count & metascore.
|
||||||
|
- **`geizhals_price_history(product_id, days=31, country="de")`**:
|
||||||
|
- Analyzes 31, 91, 183, or 365-day price trends. Reports `period_min_price`, `current_best_price`, and flags if it is an `is_all_time_low`.
|
||||||
|
|
||||||
|
### 3. Marketplace & Second-Hand (`ebay`)
|
||||||
|
- **`ebay_search(query, condition, buying_format, min_price, max_price, limit=10)`**:
|
||||||
|
- Searches eBay.de using the official Browse API. Supports filtering by condition (`used`/`refurbished`/`new`) and buying format (`fixed_price`/`auction`).
|
||||||
|
- **`ebay_get_item(item_id)`**:
|
||||||
|
- Fetches listing details, photos, location, and seller reputation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🍅 Vorratskauf & Supermarkt Protocol
|
||||||
|
|
||||||
|
When the user asks for groceries, weekly offers, or staples to stock up on:
|
||||||
|
|
||||||
|
### 1. Multi-Brand / Equivalency Queries (e.g. „Mutti oder Oro Tomatensauce“)
|
||||||
|
- Always run a comparative search (using `supermarket_compare_staples` or parallel queries).
|
||||||
|
- Group results by retailer and rank by best price.
|
||||||
|
- **Price Benchmarks for Staples**:
|
||||||
|
- **Tomaten / Passata / Polpa (Mutti / Oro di Parma)**:
|
||||||
|
- Normal: 1,69 € – 1,99 €
|
||||||
|
- 🟢 **Top-Vorratspreis**: $\le$ 1,10 € (z.B. Netto 0,88 € oder Penny 1,19 €)
|
||||||
|
- **Marken-Pasta (Barilla / De Cecco)**:
|
||||||
|
- Normal: 1,89 € – 2,29 €
|
||||||
|
- 🟢 **Top-Vorratspreis**: $\le$ 0,99 €
|
||||||
|
- **Kaffee ganze Bohnen (Lavazza, Segafredo, Melitta)**:
|
||||||
|
- Normal: 14,99 € – 17,99 € / kg
|
||||||
|
- 🟢 **Top-Vorratspreis**: $\le$ 11,99 € / kg
|
||||||
|
|
||||||
|
### 2. Windeln Größe X (Stückpreis-Berechnung)
|
||||||
|
Windel-Packungsgrößen variieren stark (Single-Pack vs. XXL-Box vs. Monatsbox).
|
||||||
|
- **Berechne immer den Cent-Preis pro Windel**:
|
||||||
|
$$\text{Stückpreis} = \frac{\text{Gesamtpreis}}{\text{Stückzahl}} \times 100\ \text{ct}$$
|
||||||
|
- **Orientierungswerte**:
|
||||||
|
- **Eigenmarke** (Lupilu / babylove): **11 – 14 ct / Windel**
|
||||||
|
- **Markenwindeln** (Pampers Baby-Dry): **18 – 24 ct / Windel** (über 26 ct ist teuer)
|
||||||
|
- Beispiel-Antwort: *„Bei Lidl gibt es Lupilu Gr. 4 (84 Stück) für 10,35 € (12,3 ct/Windel). Bei V-Markt sind Pampers Pants für 6,99 € im Angebot.“*
|
||||||
|
|
||||||
|
### 3. Location & Store Distance
|
||||||
|
- Default location is **Regensburg** (Lat: `49.0134`, Lng: `12.1016`).
|
||||||
|
- Mention the retailer and approximate distance (e.g. *„Netto Marken-Discount (0,5 km)“* oder *„Penny (1,4 km)“*).
|
||||||
|
- Mention the validity dates (z.B. *„Gültig bis Samstag, 12.09.“*).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 Tech & Electronics Advisory Protocol
|
||||||
|
|
||||||
|
When the user asks whether to buy a gadget, phone, laptop, or appliance:
|
||||||
|
1. **Model & Quality Check**:
|
||||||
|
- Inspect `test_reviews_count` and `test_reviews_metascore` in `geizhals_search`.
|
||||||
|
- If needed, run a quick web search on Stiftung Warentest / Chip / Reddit for common faults.
|
||||||
|
2. **Timing & Price History Check**:
|
||||||
|
- Run `geizhals_price_history(product_id)`.
|
||||||
|
- If `is_all_time_low` is true: *„🟢 Aktuell auf Tiefstpreis-Niveau!“*
|
||||||
|
- If significantly higher than `period_min_price`: advise waiting or setting a price alert.
|
||||||
|
3. **Refurbished / B-Ware Option**:
|
||||||
|
- Query `ebay_search` with `condition="refurbished"` or `"used"` to compare savings against brand-new retail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Presentation Rules
|
||||||
|
- Follow OpenClaw's SOUL protocol: Hide hyperlinks behind descriptive text (`[Händler (Preis €)](url)`).
|
||||||
|
- Never send raw search URLs or unverified Google links.
|
||||||
Executable
+477
@@ -0,0 +1,477 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
eBay Browse API MCP Server & CLI Tool
|
||||||
|
Connects to eBay's official Browse API using OAuth 2.0 Client Credentials Grant.
|
||||||
|
Allows AI assistants to search items on eBay.de, filter by condition (NEW, USED, REFURBISHED),
|
||||||
|
buying format (Sofort-Kaufen / Auktion), price range, and fetch listing details.
|
||||||
|
Runs standalone over MCP stdio or direct CLI commands.
|
||||||
|
Requires zero external dependencies (pure Python standard library).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import base64
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
EBAY_TOKEN_URL = "https://api.ebay.com/identity/v1/oauth2/token"
|
||||||
|
EBAY_BROWSE_URL = "https://api.ebay.com/buy/browse/v1"
|
||||||
|
EBAY_OAUTH_SCOPE = "https://api.ebay.com/oauth/api_scope"
|
||||||
|
|
||||||
|
EBAY_APP_ID = os.environ.get("EBAY_APP_ID", "").strip()
|
||||||
|
EBAY_CERT_ID = os.environ.get("EBAY_CERT_ID", "").strip()
|
||||||
|
EBAY_MARKETPLACE_ID = os.environ.get("EBAY_MARKETPLACE_ID", "EBAY_DE")
|
||||||
|
|
||||||
|
# Condition mapping for user-friendly terms
|
||||||
|
CONDITION_MAP = {
|
||||||
|
"new": "NEW",
|
||||||
|
"neu": "NEW",
|
||||||
|
"used": "USED",
|
||||||
|
"gebraucht": "USED",
|
||||||
|
"refurbished": "CERTIFIED_REFURBISHED,EXCELLENT_REFURBISHED,VERY_GOOD_REFURBISHED,GOOD_REFURBISHED,SELLER_REFURBISHED",
|
||||||
|
"generalüberholt": "CERTIFIED_REFURBISHED,EXCELLENT_REFURBISHED,VERY_GOOD_REFURBISHED,GOOD_REFURBISHED,SELLER_REFURBISHED",
|
||||||
|
}
|
||||||
|
|
||||||
|
BUYING_FORMAT_MAP = {
|
||||||
|
"fixed_price": "FIXED_PRICE",
|
||||||
|
"sofortkauf": "FIXED_PRICE",
|
||||||
|
"auction": "AUCTION",
|
||||||
|
"auktion": "AUCTION",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EbayClient:
|
||||||
|
def __init__(self, app_id: Optional[str] = None, cert_id: Optional[str] = None, marketplace_id: Optional[str] = None):
|
||||||
|
self.app_id = app_id or EBAY_APP_ID
|
||||||
|
self.cert_id = cert_id or EBAY_CERT_ID
|
||||||
|
self.marketplace_id = marketplace_id or EBAY_MARKETPLACE_ID
|
||||||
|
self._access_token: Optional[str] = None
|
||||||
|
self._token_expires_at: float = 0.0
|
||||||
|
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
dummy_values = ["", "dummy-ebay-app-id", "dummy-ebay-cert-id", "your-ebay-app-id", "your-ebay-cert-id"]
|
||||||
|
return bool(self.app_id and self.cert_id and self.app_id not in dummy_values and self.cert_id not in dummy_values)
|
||||||
|
|
||||||
|
def get_token(self) -> str:
|
||||||
|
"""Retrieves or refreshes eBay OAuth 2.0 application access token."""
|
||||||
|
if not self.is_configured():
|
||||||
|
raise ValueError(
|
||||||
|
"eBay Developer credentials (EBAY_APP_ID and EBAY_CERT_ID) are not configured. "
|
||||||
|
"Please add them to openclaw.secret.yaml under ebay-app-id and ebay-cert-id."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reuse cached token if valid (with 60-second buffer)
|
||||||
|
if self._access_token and time.time() < self._token_expires_at - 60:
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
creds = f"{self.app_id}:{self.cert_id}".encode("utf-8")
|
||||||
|
basic_auth = base64.b64encode(creds).decode("utf-8")
|
||||||
|
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": EBAY_OAUTH_SCOPE
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
EBAY_TOKEN_URL,
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Basic {basic_auth}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
body = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
self._access_token = body.get("access_token")
|
||||||
|
expires_in = body.get("expires_in", 7200)
|
||||||
|
self._token_expires_at = time.time() + expires_in
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
condition: Optional[str] = None,
|
||||||
|
buying_format: Optional[str] = None,
|
||||||
|
min_price: Optional[float] = None,
|
||||||
|
max_price: Optional[float] = None,
|
||||||
|
limit: int = 15
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Searches items on eBay via the Browse API."""
|
||||||
|
if not self.is_configured():
|
||||||
|
return {
|
||||||
|
"configured": False,
|
||||||
|
"error": "eBay credentials not configured. Please set EBAY_APP_ID and EBAY_CERT_ID in openclaw.secret.yaml.",
|
||||||
|
"query": query,
|
||||||
|
"results": []
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = self.get_token()
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"configured": False,
|
||||||
|
"error": f"eBay authentication failed: {str(e)}",
|
||||||
|
"query": query,
|
||||||
|
"results": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build query filters
|
||||||
|
filters = []
|
||||||
|
if min_price is not None or max_price is not None:
|
||||||
|
min_str = f"{min_price:.2f}" if min_price is not None else ""
|
||||||
|
max_str = f"{max_price:.2f}" if max_price is not None else ""
|
||||||
|
filters.append(f"price:[{min_str}..{max_str}]")
|
||||||
|
|
||||||
|
if condition:
|
||||||
|
cond_lower = condition.lower().strip()
|
||||||
|
ebay_cond = CONDITION_MAP.get(cond_lower, condition.upper())
|
||||||
|
filters.append(f"conditions:{{{ebay_cond}}}")
|
||||||
|
|
||||||
|
if buying_format:
|
||||||
|
fmt_lower = buying_format.lower().strip()
|
||||||
|
ebay_fmt = BUYING_FORMAT_MAP.get(fmt_lower, buying_format.upper())
|
||||||
|
filters.append(f"buyingOptions:{{{ebay_fmt}}}")
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"q": query,
|
||||||
|
"limit": str(min(max(limit, 1), 50))
|
||||||
|
}
|
||||||
|
if filters:
|
||||||
|
params["filter"] = ",".join(filters)
|
||||||
|
|
||||||
|
url = f"{EBAY_BROWSE_URL}/item_summary/search?{urllib.parse.urlencode(params)}"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"X-EBAY-C-MARKETPLACE-ID": self.marketplace_id,
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
err_text = e.read().decode("utf-8", errors="replace")
|
||||||
|
return {"error": f"eBay API HTTP error {e.code}: {err_text}", "query": query, "results": []}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"eBay search error: {str(e)}", "query": query, "results": []}
|
||||||
|
|
||||||
|
items = data.get("itemSummaries", [])
|
||||||
|
total = data.get("total", len(items))
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for it in items:
|
||||||
|
price_dict = it.get("price") or {}
|
||||||
|
price_val = price_dict.get("value")
|
||||||
|
price_curr = price_dict.get("currency", "EUR")
|
||||||
|
|
||||||
|
price_str = f"{float(price_val):.2f} {price_curr}" if price_val is not None else "Kein Preis"
|
||||||
|
if price_curr == "EUR" and price_val is not None:
|
||||||
|
price_str = f"{float(price_val):.2f} €".replace(".", ",")
|
||||||
|
|
||||||
|
# Shipping options
|
||||||
|
shipping_list = it.get("shippingOptions") or []
|
||||||
|
shipping_info = "Versand nicht angegeben"
|
||||||
|
shipping_cost = None
|
||||||
|
if shipping_list:
|
||||||
|
s_cost_dict = shipping_list[0].get("shippingCost") or {}
|
||||||
|
s_val = s_cost_dict.get("value")
|
||||||
|
if s_val == "0.00" or s_val == "0" or s_val is None:
|
||||||
|
shipping_info = "Kostenloser Versand"
|
||||||
|
shipping_cost = 0.0
|
||||||
|
else:
|
||||||
|
shipping_cost = float(s_val)
|
||||||
|
shipping_info = f"{shipping_cost:.2f} € Versand".replace(".", ",")
|
||||||
|
|
||||||
|
# Buying options (Auktion vs Sofort-Kaufen)
|
||||||
|
buying_opts = it.get("buyingOptions") or []
|
||||||
|
is_auction = "AUCTION" in buying_opts
|
||||||
|
is_fixed_price = "FIXED_PRICE" in buying_opts
|
||||||
|
format_label = "Sofort-Kaufen" if is_fixed_price else ("Auktion" if is_auction else ", ".join(buying_opts))
|
||||||
|
|
||||||
|
seller = it.get("seller") or {}
|
||||||
|
results.append({
|
||||||
|
"item_id": it.get("itemId"),
|
||||||
|
"title": it.get("title"),
|
||||||
|
"price": price_str,
|
||||||
|
"price_numeric": float(price_val) if price_val is not None else None,
|
||||||
|
"shipping": shipping_info,
|
||||||
|
"shipping_cost": shipping_cost,
|
||||||
|
"condition": it.get("condition"),
|
||||||
|
"format": format_label,
|
||||||
|
"seller_username": seller.get("username"),
|
||||||
|
"seller_feedback_percent": seller.get("feedbackPercentage"),
|
||||||
|
"url": it.get("itemWebUrl"),
|
||||||
|
"image_url": it.get("image", {}).get("imageUrl")
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"query": query,
|
||||||
|
"marketplace": self.marketplace_id,
|
||||||
|
"total_found": total,
|
||||||
|
"returned_count": len(results),
|
||||||
|
"results": results
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_item(self, item_id: str) -> Dict[str, Any]:
|
||||||
|
"""Fetches full details for a specific eBay listing."""
|
||||||
|
if not self.is_configured():
|
||||||
|
return {"configured": False, "error": "eBay credentials not configured."}
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = self.get_token()
|
||||||
|
except Exception as e:
|
||||||
|
return {"configured": False, "error": f"eBay authentication failed: {str(e)}"}
|
||||||
|
|
||||||
|
url = f"{EBAY_BROWSE_URL}/item/{urllib.parse.quote(item_id)}"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"X-EBAY-C-MARKETPLACE-ID": self.marketplace_id,
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
item = json.loads(resp.read().decode("utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to fetch eBay item {item_id}: {str(e)}"}
|
||||||
|
|
||||||
|
images = []
|
||||||
|
if item.get("image", {}).get("imageUrl"):
|
||||||
|
images.append(item["image"]["imageUrl"])
|
||||||
|
for add_img in item.get("additionalImages", []):
|
||||||
|
if add_img.get("imageUrl"):
|
||||||
|
images.append(add_img["imageUrl"])
|
||||||
|
|
||||||
|
price_dict = item.get("price") or {}
|
||||||
|
price_val = price_dict.get("value")
|
||||||
|
price_curr = price_dict.get("currency", "EUR")
|
||||||
|
price_str = f"{float(price_val):.2f} €".replace(".", ",") if price_val else "Kein Preis"
|
||||||
|
|
||||||
|
seller = item.get("seller") or {}
|
||||||
|
location = item.get("itemLocation") or {}
|
||||||
|
loc_str = ", ".join(filter(None, [location.get("city"), location.get("stateOrProvince"), location.get("country")]))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"item_id": item.get("itemId"),
|
||||||
|
"title": item.get("title"),
|
||||||
|
"price": price_str,
|
||||||
|
"condition": item.get("condition"),
|
||||||
|
"description": item.get("shortDescription") or (item.get("description", "")[:400] if item.get("description") else None),
|
||||||
|
"location": loc_str or None,
|
||||||
|
"seller": seller.get("username"),
|
||||||
|
"seller_feedback_score": seller.get("feedbackScore"),
|
||||||
|
"seller_feedback_percent": seller.get("feedbackPercentage"),
|
||||||
|
"images": images,
|
||||||
|
"url": item.get("itemWebUrl")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Model Context Protocol (MCP) stdio Server
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
MCP_TOOLS = [
|
||||||
|
{
|
||||||
|
"name": "ebay_search",
|
||||||
|
"description": "Search items on eBay.de via official Browse API. Supports filtering by condition (NEW, USED, REFURBISHED), format (AUCTION, FIXED_PRICE/Sofort-Kaufen), and price bounds.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Item query, e.g. 'ThinkPad T14', 'iPhone 13 gebraucht', 'Sonos One'"
|
||||||
|
},
|
||||||
|
"condition": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Item condition: 'used'/'gebraucht', 'refurbished'/'generalüberholt', or 'new'/'neu'",
|
||||||
|
"enum": ["used", "gebraucht", "refurbished", "generalüberholt", "new", "neu"]
|
||||||
|
},
|
||||||
|
"buying_format": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Buying format: 'fixed_price'/'sofortkauf' or 'auction'/'auktion'",
|
||||||
|
"enum": ["fixed_price", "sofortkauf", "auction", "auktion"]
|
||||||
|
},
|
||||||
|
"min_price": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Minimum price in EUR"
|
||||||
|
},
|
||||||
|
"max_price": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Maximum price in EUR"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max number of items to return (default 10)",
|
||||||
|
"default": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ebay_get_item",
|
||||||
|
"description": "Fetch detailed information for a specific eBay listing (description, item location, additional photos, return policy).",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"item_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "eBay listing item ID, obtained from ebay_search"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["item_id"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_mcp_server():
|
||||||
|
client = EbayClient()
|
||||||
|
|
||||||
|
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": "ebay-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 == "ebay_search":
|
||||||
|
data = client.search(
|
||||||
|
query=args.get("query", ""),
|
||||||
|
condition=args.get("condition"),
|
||||||
|
buying_format=args.get("buying_format"),
|
||||||
|
min_price=args.get("min_price"),
|
||||||
|
max_price=args.get("max_price"),
|
||||||
|
limit=args.get("limit", 10)
|
||||||
|
)
|
||||||
|
elif tool_name == "ebay_get_item":
|
||||||
|
data = client.get_item(item_id=args.get("item_id", ""))
|
||||||
|
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="eBay Browse API MCP & CLI")
|
||||||
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
sp_search = subparsers.add_parser("search", help="Search items on eBay")
|
||||||
|
sp_search.add_argument("query", help="Item search query")
|
||||||
|
sp_search.add_argument("--condition", choices=["used", "gebraucht", "refurbished", "new", "neu"])
|
||||||
|
sp_search.add_argument("--format", choices=["fixed_price", "sofortkauf", "auction", "auktion"])
|
||||||
|
sp_search.add_argument("--min-price", type=float)
|
||||||
|
sp_search.add_argument("--max-price", type=float)
|
||||||
|
sp_search.add_argument("--limit", type=int, default=10)
|
||||||
|
|
||||||
|
sp_item = subparsers.add_parser("item", help="Get eBay item details")
|
||||||
|
sp_item.add_argument("item_id", help="eBay Item ID")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
client = EbayClient()
|
||||||
|
|
||||||
|
if args.command == "search":
|
||||||
|
print(json.dumps(client.search(
|
||||||
|
query=args.query,
|
||||||
|
condition=args.condition,
|
||||||
|
buying_format=args.format,
|
||||||
|
min_price=args.min_price,
|
||||||
|
max_price=args.max_price,
|
||||||
|
limit=args.limit
|
||||||
|
), indent=2, ensure_ascii=False))
|
||||||
|
elif args.command == "item":
|
||||||
|
print(json.dumps(client.get_item(args.item_id), 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()
|
||||||
Executable
+399
@@ -0,0 +1,399 @@
|
|||||||
|
#!/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()
|
||||||
Executable
+385
@@ -0,0 +1,385 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Supermarket Deals MCP Server & CLI Tool
|
||||||
|
Fetches live supermarket discounts and brochures across German retailers
|
||||||
|
(Edeka, Rewe, Aldi Süd/Nord, Lidl, Kaufland, Netto, Penny, dm, Rossmann, V-Markt).
|
||||||
|
Optimized for staple goods (Mutti, Oro di Parma, Barilla, coffee) and diapers (Windeln piece-price calculation).
|
||||||
|
Runs standalone over MCP stdio or direct CLI commands.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
# Default Coordinates: Regensburg, Germany
|
||||||
|
DEFAULT_LAT = float(os.environ.get("SUPERMARKET_DEFAULT_LAT", "49.0134"))
|
||||||
|
DEFAULT_LNG = float(os.environ.get("SUPERMARKET_DEFAULT_LNG", "12.1016"))
|
||||||
|
DEFAULT_CITY = os.environ.get("SUPERMARKET_DEFAULT_CITY", "Regensburg")
|
||||||
|
|
||||||
|
CITY_COORDINATES = {
|
||||||
|
"regensburg": (49.0134, 12.1016),
|
||||||
|
"münchen": (48.1351, 11.5820),
|
||||||
|
"munich": (48.1351, 11.5820),
|
||||||
|
"nürnberg": (49.4521, 11.0767),
|
||||||
|
"berlin": (52.5200, 13.4050),
|
||||||
|
"hamburg": (53.5511, 9.9937),
|
||||||
|
"frankfurt": (50.1109, 8.6821),
|
||||||
|
"köln": (50.9375, 6.9603),
|
||||||
|
"stuttgart": (48.7758, 9.1829),
|
||||||
|
}
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"User-Agent": "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",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Accept-Language": "de-DE,de;q=0.9,en;q=0.8"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_coordinates(city: Optional[str] = None, lat: Optional[float] = None, lng: Optional[float] = None) -> tuple:
|
||||||
|
if lat is not None and lng is not None:
|
||||||
|
return (lat, lng)
|
||||||
|
if city:
|
||||||
|
norm = city.lower().strip()
|
||||||
|
if norm in CITY_COORDINATES:
|
||||||
|
return CITY_COORDINATES[norm]
|
||||||
|
return (DEFAULT_LAT, DEFAULT_LNG)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_piece_price(title: str, description: Optional[str], price_float: float) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Calculates piece price (e.g. ct/Windel or ct/Stück) from title/description."""
|
||||||
|
if price_float <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = f"{title} {description or ''}"
|
||||||
|
# Match patterns like: "84 Stück", "84 Stk.", "84-St.-Packg.", "114er", "24 ct"
|
||||||
|
match = re.search(r"(\d+)\s*(?:stück|stk\.?|st\.-packg\.?|ct\b|pants\b|windeln\b|er\s*packung)", text, re.IGNORECASE)
|
||||||
|
if not match:
|
||||||
|
# Also check for "Größe 3/4/5, Je 92/84/72 Stück"
|
||||||
|
multi_match = re.search(r"(\d+)/(\d+)/(\d+)\s*stück", text, re.IGNORECASE)
|
||||||
|
if multi_match:
|
||||||
|
counts = [int(g) for g in multi_match.groups()]
|
||||||
|
avg_count = sum(counts) / len(counts)
|
||||||
|
cent_piece = round((price_float / avg_count) * 100, 1)
|
||||||
|
return {
|
||||||
|
"piece_counts": counts,
|
||||||
|
"approx_cents_per_piece": cent_piece,
|
||||||
|
"formatted": f"ca. {cent_piece} ct / Stück"
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
count = int(match.group(1))
|
||||||
|
if count <= 0 or count > 500:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cent_piece = round((price_float / count) * 100, 1)
|
||||||
|
return {
|
||||||
|
"pieces": count,
|
||||||
|
"cents_per_piece": cent_piece,
|
||||||
|
"formatted": f"{cent_piece} ct / Stück"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_deal(brand: str, title: str, price: float) -> str:
|
||||||
|
"""Evaluates whether an offer is a standout bargain for known staple brands."""
|
||||||
|
norm = f"{brand} {title}".lower()
|
||||||
|
if any(k in norm for k in ["mutti", "oro di parma", "oro"]):
|
||||||
|
if any(t in norm for t in ["polpa", "stückig", "passata", "geschält", "pelati", "tomaten"]):
|
||||||
|
if price <= 1.05:
|
||||||
|
return "TOP_DEAL_VORRAT"
|
||||||
|
elif price <= 1.29:
|
||||||
|
return "GUTER_ANGEBOTSPREIS"
|
||||||
|
return "NORMALER_PREIS"
|
||||||
|
if "barilla" in norm:
|
||||||
|
if price <= 0.89:
|
||||||
|
return "TOP_DEAL_VORRAT"
|
||||||
|
elif price <= 1.09:
|
||||||
|
return "GUTER_ANGEBOTSPREIS"
|
||||||
|
return "ANGEBOT"
|
||||||
|
|
||||||
|
|
||||||
|
class SupermarketClient:
|
||||||
|
def search(self, query: str, city: Optional[str] = None, lat: Optional[float] = None, lng: Optional[float] = None) -> Dict[str, Any]:
|
||||||
|
use_lat, use_lng = resolve_coordinates(city, lat, lng)
|
||||||
|
url = f"https://www.kaufda.de/api/search?lat={use_lat}&lng={use_lng}&query={urllib.parse.quote(query)}"
|
||||||
|
req = urllib.request.Request(url, headers=HEADERS)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=12) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"query": query,
|
||||||
|
"city": city or DEFAULT_CITY,
|
||||||
|
"coordinates": {"lat": use_lat, "lng": use_lng},
|
||||||
|
"error": f"Failed to fetch offers: {str(e)}",
|
||||||
|
"offers": [],
|
||||||
|
"brochures": []
|
||||||
|
}
|
||||||
|
|
||||||
|
search_results = data.get("searchResults", {})
|
||||||
|
contents = search_results.get("contents", {})
|
||||||
|
raw_offers = contents.get("offers", [])
|
||||||
|
raw_brochures = contents.get("brochures", [])
|
||||||
|
|
||||||
|
formatted_offers = []
|
||||||
|
for o in raw_offers:
|
||||||
|
prices = o.get("prices", {})
|
||||||
|
price_fmt = prices.get("mainPriceFormatted") or ""
|
||||||
|
price_num = float(prices.get("mainPrice") or 0.0)
|
||||||
|
if not price_fmt and price_num > 0:
|
||||||
|
price_fmt = f"{price_num:.2f} €".replace(".", ",")
|
||||||
|
|
||||||
|
title = o.get("title") or ""
|
||||||
|
brand = o.get("brand") or ""
|
||||||
|
desc = o.get("description") or ""
|
||||||
|
retailer = o.get("publisherName") or "Supermarkt"
|
||||||
|
|
||||||
|
piece_info = calculate_piece_price(title, desc, price_num)
|
||||||
|
deal_rating = evaluate_deal(brand, title, price_num)
|
||||||
|
|
||||||
|
offer_dict = {
|
||||||
|
"retailer": retailer,
|
||||||
|
"brand": brand,
|
||||||
|
"title": title,
|
||||||
|
"price": price_fmt,
|
||||||
|
"price_numeric": price_num,
|
||||||
|
"description": desc,
|
||||||
|
"valid_from": (o.get("validFrom") or "")[:10],
|
||||||
|
"valid_until": (o.get("validUntil") or "")[:10],
|
||||||
|
"deal_rating": deal_rating,
|
||||||
|
"image_url": o.get("offerImages", {}).get("url", {}).get("normal")
|
||||||
|
}
|
||||||
|
if piece_info:
|
||||||
|
offer_dict["piece_price"] = piece_info
|
||||||
|
|
||||||
|
formatted_offers.append(offer_dict)
|
||||||
|
|
||||||
|
# Sort offers: top deals first, then by price
|
||||||
|
formatted_offers.sort(key=lambda x: (
|
||||||
|
0 if x.get("deal_rating") == "TOP_DEAL_VORRAT" else (1 if x.get("deal_rating") == "GUTER_ANGEBOTSPREIS" else 2),
|
||||||
|
x.get("price_numeric", 9999) if x.get("price_numeric", 0) > 0 else 9999
|
||||||
|
))
|
||||||
|
|
||||||
|
formatted_brochures = []
|
||||||
|
for b in raw_brochures:
|
||||||
|
bc = b.get("content", {})
|
||||||
|
formatted_brochures.append({
|
||||||
|
"retailer": bc.get("publisher", {}).get("name"),
|
||||||
|
"brochure_title": bc.get("title"),
|
||||||
|
"page": bc.get("page"),
|
||||||
|
"distance_km": round(bc.get("distance", 0), 1),
|
||||||
|
"valid_until": (bc.get("validUntil") or "")[:10]
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"query": query,
|
||||||
|
"city": city or DEFAULT_CITY,
|
||||||
|
"coordinates": {"lat": use_lat, "lng": use_lng},
|
||||||
|
"total_offers": len(formatted_offers),
|
||||||
|
"offers": formatted_offers,
|
||||||
|
"matching_brochures": formatted_brochures[:5]
|
||||||
|
}
|
||||||
|
|
||||||
|
def compare_staples(self, staples: List[str], city: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""Searches multiple staple products and aggregates results by product and store."""
|
||||||
|
summary = {}
|
||||||
|
for s in staples:
|
||||||
|
s_clean = s.strip()
|
||||||
|
if s_clean:
|
||||||
|
summary[s_clean] = self.search(s_clean, city=city)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"city": city or DEFAULT_CITY,
|
||||||
|
"staples_searched": list(summary.keys()),
|
||||||
|
"results": summary
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Model Context Protocol (MCP) stdio Server
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
MCP_TOOLS = [
|
||||||
|
{
|
||||||
|
"name": "supermarket_search",
|
||||||
|
"description": "Search current regional supermarket, discounter, and drugstore discounts (Edeka, Rewe, Aldi Süd, Lidl, Kaufland, Netto, Penny, dm, Rossmann, V-Markt). Ideal for brand staples (Mutti, Oro di Parma, Barilla, Lavazza) and baby essentials (Windeln, Pampers). Defaults to Regensburg.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Product or brand name to search (e.g. 'Mutti', 'Oro di Parma', 'Windeln', 'Pampers', 'Kaffee')"
|
||||||
|
},
|
||||||
|
"city": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "City name for regional offers (e.g. 'Regensburg', 'München', 'Nürnberg'). Defaults to Regensburg."
|
||||||
|
},
|
||||||
|
"lat": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Optional latitude coordinate override"
|
||||||
|
},
|
||||||
|
"lng": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Optional longitude coordinate override"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "supermarket_compare_staples",
|
||||||
|
"description": "Batch search multiple staple goods (e.g. ['Mutti', 'Oro di Parma'] or ['Windeln', 'Pampers']) to compare discounts across all local supermarkets in one call.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"staples": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "List of staple brands or product categories to search, e.g. ['Mutti', 'Oro di Parma']"
|
||||||
|
},
|
||||||
|
"city": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "City name for regional offers. Defaults to Regensburg."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["staples"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_mcp_server():
|
||||||
|
client = SupermarketClient()
|
||||||
|
|
||||||
|
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": "supermarket-deals-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 == "supermarket_search":
|
||||||
|
data = client.search(
|
||||||
|
query=args.get("query", ""),
|
||||||
|
city=args.get("city"),
|
||||||
|
lat=args.get("lat"),
|
||||||
|
lng=args.get("lng")
|
||||||
|
)
|
||||||
|
elif tool_name == "supermarket_compare_staples":
|
||||||
|
data = client.compare_staples(
|
||||||
|
staples=args.get("staples", []),
|
||||||
|
city=args.get("city")
|
||||||
|
)
|
||||||
|
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="Supermarket Deals Tool & MCP Server")
|
||||||
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
sp_search = subparsers.add_parser("search", help="Search supermarket offers")
|
||||||
|
sp_search.add_argument("query", help="Search query (e.g. Mutti, Pampers, Windeln)")
|
||||||
|
sp_search.add_argument("--city", default="Regensburg", help="City name (default: Regensburg)")
|
||||||
|
|
||||||
|
sp_comp = subparsers.add_parser("compare", help="Compare multiple staple items")
|
||||||
|
sp_comp.add_argument("items", help="Comma-separated items, e.g. 'Mutti,Oro di Parma'")
|
||||||
|
sp_comp.add_argument("--city", default="Regensburg", help="City name (default: Regensburg)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
client = SupermarketClient()
|
||||||
|
|
||||||
|
if args.command == "search":
|
||||||
|
print(json.dumps(client.search(args.query, city=args.city), indent=2, ensure_ascii=False))
|
||||||
|
elif args.command == "compare":
|
||||||
|
items = [i.strip() for i in args.items.split(",") if i.strip()]
|
||||||
|
print(json.dumps(client.compare_staples(items, city=args.city), 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()
|
||||||
Reference in New Issue
Block a user