From 9d890a290f3ae7e1a9cdb701be389e12e3ebdb9e Mon Sep 17 00:00:00 2001 From: Moritz Graf Date: Mon, 5 Oct 2026 21:20:16 +0200 Subject: [PATCH] feat(skills): add marktguru supermarket offer and price comparison skill Introduce the marktguru agent skill and MCP integration for searching supermarket deals, comparing product prices, and querying supported retail chains: - Add Marktguru API client and MCP server script - Add skill definition (SKILL.md) and supported supermarkets reference - Add test suite covering Marktguru API integration and offer queries --- .agents/skills/marktguru/SKILL.md | 142 +++++ .../marktguru/references/supermarkets.md | 94 +++ .../marktguru_client.cpython-312.pyc | Bin 0 -> 27927 bytes .../__pycache__/marktguru_mcp.cpython-312.pyc | Bin 0 -> 11167 bytes .../marktguru/scripts/marktguru_client.py | 577 ++++++++++++++++++ .../skills/marktguru/scripts/marktguru_mcp.py | 319 ++++++++++ .../skills/marktguru/tests/test_marktguru.py | 180 ++++++ 7 files changed, 1312 insertions(+) create mode 100644 .agents/skills/marktguru/SKILL.md create mode 100644 .agents/skills/marktguru/references/supermarkets.md create mode 100644 .agents/skills/marktguru/scripts/__pycache__/marktguru_client.cpython-312.pyc create mode 100644 .agents/skills/marktguru/scripts/__pycache__/marktguru_mcp.cpython-312.pyc create mode 100755 .agents/skills/marktguru/scripts/marktguru_client.py create mode 100755 .agents/skills/marktguru/scripts/marktguru_mcp.py create mode 100644 .agents/skills/marktguru/tests/test_marktguru.py diff --git a/.agents/skills/marktguru/SKILL.md b/.agents/skills/marktguru/SKILL.md new file mode 100644 index 0000000..ce11287 --- /dev/null +++ b/.agents/skills/marktguru/SKILL.md @@ -0,0 +1,142 @@ +--- +name: marktguru +license: Apache-2.0 +description: Query weekly supermarket offers, flyer discounts, and price comparisons across German supermarkets (ALDI Süd, ALDI Nord, Lidl, EDEKA, REWE, Kaufland, PENNY, Netto, dm, ROSSMANN, etc.) via the Marktguru API and local MCP server. Use when the user asks to check supermarket discounts, find deals in brochures/prospekte, compare grocery prices for a postal code (PLZ), or create shopping lists from current promotions without needing fragile PDF OCR. +--- + +# Marktguru Supermarket Offers & MCP Server + +> Query live promotional flyers and circular offers from German supermarkets and drugstores without brittle PDF scraping or layout OCR. + +This skill is inspired by and expands upon the Home Assistant integration pattern (`thorschtn/ha-marktguru`), leveraging Marktguru's backend API (`api.marktguru.de`) to retrieve structured deal data for any German postal code. + +--- + +## Supported Supermarkets & Retailers + +Marktguru covers **over 450 retail chains** across Germany. The primary supported chains are: + +* **Discounter**: + * **ALDI SÜD** (`127153`) & **ALDI NORD** (`127139`) + * **Lidl** (`126679`) + * **PENNY** (`126765`) + * **Netto Marken-Discount** (`126735`) & **Netto (mit Hund)** (`126734`) + * **NORMA** (`126745`) +* **Supermärkte & Vollsortimenter**: + * **EDEKA** (`127093`) & **E center** (`126699`) + regionale Kaufleute (Simmel, Zurheide, etc.) + * **REWE** (`126802`) & **REWE Center** (`126804`) + * **nahkauf** (`137320`), **HIT** (`126598`), **tegut** (`126866`) +* **Großfläche / Hypermarket**: + * **Kaufland** (`126654`) + * **Globus** (`127177`) +* **Drogerien & Reform**: + * **dm-drogerie markt** (`127057`) + * **ROSSMANN** (`126811`) + * **Müller Drogeriemarkt** (`126720`) + +Full details and ID mappings are documented in [`references/supermarkets.md`](references/supermarkets.md). + +--- + +## Components + +The skill provides two primary components: + +1. **CLI & Python Client (`scripts/marktguru_client.py`)**: Zero external dependencies (uses standard library `urllib` and `json`). +2. **MCP Server (`scripts/marktguru_mcp.py`)**: Standard JSON-RPC 2.0 stdio server providing AI tools for search, comparison, and catalog retrieval. + +--- + +## 1. CLI Usage + +Run directly with standard `python3` (uses default postal code **`93059` Regensburg** if `--zip` is omitted): + +### A. Search for Deals +```bash +# Search for butter in Regensburg (93059 default) across Aldi, Lidl, and Edeka +python3 scripts/marktguru_client.py search "Butter" --supermarket "Aldi,Lidl,Edeka" + +# Search for coffee with raw JSON output +python3 scripts/marktguru_client.py search "Kaffee" --json +``` + +### B. Price Comparison (Sorted Ascending) +```bash +# Compare chocolate prices across all supermarkets in 93059 +python3 scripts/marktguru_client.py compare "Schokolade" + +# Compare beer prices between Lidl and Rewe in another postal code (e.g. 80331) +python3 scripts/marktguru_client.py compare "Bier" --zip 80331 --supermarket "Lidl,Rewe" +``` + +### C. List Supported Chains +```bash +python3 scripts/marktguru_client.py supermarkets +``` + +--- + +## 2. Model Context Protocol (MCP) Integration + +The MCP server exposes three tools to any MCP-compatible AI agent: + +* `search_supermarket_offers(query, zip_code="93059", supermarkets=None, limit=20)` +* `compare_supermarket_prices(query, zip_code="93059", supermarkets=None)` +* `list_supported_supermarkets()` + +### MCP Server Configuration (`mcp_config.json`) + +To enable the server in Antigravity or other MCP hosts: + +```json +{ + "mcpServers": { + "marktguru": { + "command": "python3", + "args": [ + "/Users/moritz/src/infrapuzzle/.agents/skills/marktguru/scripts/marktguru_mcp.py" + ] + } + } +} +``` + +--- + +## 3. Python Integration + +You can import the module directly in Python scripts or agent sidecars: + +```python +import sys +sys.path.append("/Users/moritz/src/infrapuzzle/.agents/skills/marktguru/scripts") +from marktguru_client import search_offers, compare_prices, DEFAULT_ZIP_CODE + +# Search deals (defaults to 93059) +results = search_offers("Kaffee", supermarkets=["Aldi", "Lidl"]) +for offer in results["offers"]: + print(f"[{offer['supermarket']}] {offer['product_name'] or offer['description']}: {offer['price']} €") + +# Compare prices +comparison = compare_prices("Milch") +cheapest = comparison["cheapest_offer"] +print(f"Cheapest milk: {cheapest['price']} € at {cheapest['supermarket']}") +``` + +--- + +## Data Extracted Per Offer + +Each item returned from the Marktguru API includes: +* `id`: Internal Marktguru offer ID. +* `supermarket`: Retailer name (e.g. "ALDI SÜD", "Lidl", "EDEKA"). +* `product_name`: Generic product name (e.g. "Butter", "Filterkaffee"). +* `brand`: Brand name (e.g. "Kerrygold", "Melitta", "Milka"). +* `description`: Promotional description / pack size (e.g. "je 250g Packung"). +* `price`: Promotional sale price in Euros (€). +* `old_price`: Regular price or crossed-out price (UVP). +* `discount_percent`: Calculated percentage savings (e.g. 28%). +* `reference_price`: Unit price (e.g. 5.16 € / kg). +* `loyalty_app_required`: Boolean flag indicating if a digital store app (Lidl Plus, Edeka App, Penny App) is required to receive the discount. +* `valid_from` & `valid_to`: Start and end dates of the promotion. +* `image_url`: High-resolution product image hosted on CDN. diff --git a/.agents/skills/marktguru/references/supermarkets.md b/.agents/skills/marktguru/references/supermarkets.md new file mode 100644 index 0000000..2f8a415 --- /dev/null +++ b/.agents/skills/marktguru/references/supermarkets.md @@ -0,0 +1,94 @@ +# Unterstützte Supermärkte & Händler (Marktguru) + +Über die Marktguru-Schnittstelle können Werbeprospekte, Wochenangebote und Rabattaktionen von über 450 Handelsketten und Einzelhändlern in Deutschland abgefragt werden. + +--- + +## 1. Discounter + +Discounter machen einen Großteil der wöchentlich wechselnden Prospektaktionen aus. Marktguru unterscheidet sauber zwischen den Ketten: + +| Kette | Marktguru ID | Typischer Aktionsrhythmus | App-Vorteile erfasst? | +| :--- | :--- | :--- | :--- | +| **ALDI SÜD** | `127153` | Mo. & Do. / Fr. & Sa. | Ja (ALDI App Angebote) | +| **ALDI NORD** | `127139` | Mo. & Do. / Fr. & Sa. | Ja | +| **Lidl** | `126679` | Mo.–Sa. / Do.–Sa. / Fr.–Sa. | Ja (`loyalty_app_required` für Lidl Plus) | +| **PENNY** | `126765` | Mo.–Sa. / Do.–Sa. | Ja (Penny App / Payback) | +| **Netto Marken-Discount** | `126735` | Mo.–Sa. / Do.–Sa. | Ja (Netto App / DeutschlandCard) | +| **Netto (mit Hund)** | `126734` | Mo.–Sa. (primär Ost-/Norddeutschland) | Ja | +| **NORMA** | `126745` | Mo. & Mi. / Fr. & Sa. | Ja | + +--- + +## 2. Vollsortimenter & Supermärkte + +Hier gibt es oft filialgenaue oder regionale Prospekte. Die API filtert automatisch anhand der übergebenen Postleitzahl (PLZ): + +| Kette | Marktguru ID | Besonderheiten | +| :--- | :--- | :--- | +| **EDEKA** | `127093` | Nationale & regionale Angebote; Marktguru aggregiert auch selbstständige Kaufleute (z.B. EDEKA Simmel, Zurheide, Niemerszein). | +| **E center** | `126699` | Großformatige EDEKA-Märkte mit erweitertem Non-Food- und Frischeangebot. | +| **REWE** | `126802` | Bundesweite Angebote sowie regionale Aktionen. | +| **REWE Center** | `126804` | Großflächenmärkte von REWE. | +| **nahkauf** | `137320` | Nachbarschaftsmärkte der REWE Group. | +| **HIT** | `126598` | Große Frische-Supermärkte (Dohle Handelsgruppe). | +| **tegut** | `126866` | Fokus auf Bio, Nachhaltigkeit und Regionales (Hessen, Bayern, Thüringen). | + +--- + +## 3. Großfläche & SB-Warenhäuser (Hypermarkets) + +| Kette | Marktguru ID | Besonderheiten | +| :--- | :--- | :--- | +| **Kaufland** | `126654` | Riesiges wöchentliches Prospektangebot (Lebensmittel + Non-Food, Kaufland Card). | +| **Globus** | `127177` | SB-Warenhäuser mit eigener Metzgerei und Bäckerei. | + +--- + +## 4. Drogeriemärkte + +Drogerieprospekte enthalten neben Pflege- und Haushaltsartikeln auch viele Bio-Lebensmittel, Snacks, Kaffee und Babynahrung: + +| Kette | Marktguru ID | Typische Angebote | +| :--- | :--- | :--- | +| **dm-drogerie markt** | `127057` | dmBio, Drogeriewaren, Tiernahrung. | +| **ROSSMANN** | `126811` | EnerBio, Kaffeeangebote, wöchentliche 10%-Coupons. | +| **Müller Drogeriemarkt** | `126720` | Drogerie, Parfümerie, Spielwaren, Naturshop. | +| **budni** | `126944` | Primär im Norden (Metropolregion Hamburg). | + +--- + +## 5. Getränkemärkte & Bio-Fachmärkte + +| Kette | Marktguru ID | Sortiment | +| :--- | :--- | :--- | +| **Netto Getränke-Discount** | `134009` | Bier, Mineralwasser, Softdrinks. | +| **Denns BioMarkt / Alnatura** | Diverse | Bio-Vollsortiment, Wochenaktionen. | + +--- + +## 6. Unterstützte Aliase im Client & MCP Server + +Wenn im Tool-Aufruf der Parameter `supermarkets` übergeben wird, werden folgende umgangssprachliche Bezeichnungen automatisch auf die korrekten Retailer-IDs gemappt: + +```json +{ + "aldi": [127153, 127139], // Findet automatisch ALDI SÜD und ALDI NORD + "aldi süd" / "aldi sued": [127153], + "aldi nord": [127139], + "lidl": [126679], + "edeka": [127093, 126699], // EDEKA und E center + "rewe": [126802, 126804], // REWE und REWE Center + "kaufland": [126654], + "penny": [126765], + "netto": [126735, 126734], // Netto Marken-Discount und Netto (mit Hund) + "norma": [126745], + "globus": [127177], + "dm": [127057], + "rossmann": [126811], + "müller" / "mueller": [126720], + "hit": [126598], + "tegut": [126866], + "nahkauf": [137320] +} +``` diff --git a/.agents/skills/marktguru/scripts/__pycache__/marktguru_client.cpython-312.pyc b/.agents/skills/marktguru/scripts/__pycache__/marktguru_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9b3f97c12d650469989f7cceee3a464d28cd2039 GIT binary patch literal 27927 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 LZ6Reb+3Wv*0HL2d literal 0 HcmV?d00001 diff --git a/.agents/skills/marktguru/scripts/__pycache__/marktguru_mcp.cpython-312.pyc b/.agents/skills/marktguru/scripts/__pycache__/marktguru_mcp.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..12e1f25abebfb5c6fd6a0ecd96706fe4c8f49bba GIT binary patch literal 11167 zcmd@)TWlNGl{4h{OQINVXo1r8tS2EIa00Nh9->nW1g5 za;hv^)W&*Y2W?>obr2N@PzT9}itTP)w8gr0ignR`P?icaQvvJUEf$+UI&si8{pg-^ zheJ}X8#iD3u>JZQ^KHd9vjuavo9$vP+1|6iYc<~U5Ic*bW=nFs z%NyT>G)4kuN-X=U$~QkCSBd8t~exoVYa zr8?GIe^rngW_hU*`kLUsS=z#2Z2e3agu!Tm`K4>ONv%?u%t>vqJHVQ?PS;EA)FExB zF6yKW^?)?~&j?b-*M#XtsT0P6)J_|yW5$9bFtg-s#x6Yu^P1Sa%_D-e14f$v1_)g+ zZ;RAT8yFTP(DRstV0^38^EGaoNWD!bU##D)mQ6kYKjY|~9VU#?F*cISLUMF9#R9i?y$^o*>*8Oh|xGb7_uU06>I9UC()U})k{{^(SUkr6c!WgGU9 z{%Dj$6SFEioFt30@r0_$5tS%_0Yx&vO;APYB5}Or@d{nF}XoW`JRIpTQe6%s55W8>6rw z44jorC>oWpW%8J+2L^`@^*=W{6`meB8XlM!9Mb(`{f8%xg^vwQ^^c4W9h=kxW;5JB zI?_KmG@0Jn-YKfU#xWXH$(PzYqx2Mw#zlO6nY4Ertv+bQsH%#y&>ghuc2!e=V7iUX z*6qj}qLwa0Zf7FY9g8H>?jn<Q!*pltMv$qHc?T;1*H;hH2q_fX} z5fut76p5xJDusepb_U%hYc!@RRd62NA%mHirAoSQOgtmUQZbU(VQ{iMbArkMfGH(T zIwgZpePmZ}7m0~y`pDzGKzBj6y^^BJg6>Sq$uRPq?k(YR6~s*jOqDv=(L}-xECDJ& z1O2W$75Yj_R;UCLDj*FF+&pp|R3=<8jfr}LsZdG)=aozPe^BG&QB6rRlnz105(~U1 zMid~Q$p$50RFpE2%z~DWmkLe@SRKy|x|6DmKUAi?$c&PRfs#E@^Bq8j2zcEF3^g;sl9`AVvzEO+`h;m=1FH-0`D9L#dl&0N4v+R9hfoGD%_-ZHhW4 zC)H5uI8g8bd74!c5uiQ6OGRw*bTTEXM2-rwWlGdU6pwuKU!ov9>F578&fX0EQG5-2 z3Kx#|uAqmbG+^O^BJ}|jXhaz6mRv^B)u3Rb=0KeDu82V*NlDGBS_1FdP+PFnFexZ( zo7E%gYIIYIIy_i=FJNz*=QUEn1pZoV}Wz4C9A}4r^szsF z33@Mci=4_Y@LFNUBBz0iXFTubTT1^TH^^P&BbHe%VtM&-jyub1Hgnb@Z~Ei&4zsUV zn;xXfUugckYhJfpctU_3&+?PpCJY4=egVS+i`+b3^8=4$F>x!wzZLOcVm;lv!7jxUhZo`6g5f%6a z)T;3sgcTrWV%|@}`Jr*0?+V&ioi$!WUD%~HBxzgOU2eGQ5w?ms-0S%2k*qx@3m)xmW~%LkpkYyN?EoXb0Z zFmRp!r$av+{r>1bkAKU0ssEey%ih<$-}YT|uKEWu&H-c1z`BEETX%f#z;_R?0DVrZC9ps@2Y<=;~dO-{D1t&!375SKmMnGP^|$h zu53BjCj77wil6PPJ{S~!?yGS9cd6hr`)AX77ec?ud3MDrjb&@vv$ zB3UIt=4HMpHd=u_B^!i-b_f$4SFMv=&^i986+#o;#)22!gEuxXC&p)~bO^3#3xL!e z5YedM?Sil!97; zy+P@usd51N7jfQ`P%LqGE9R<$fYp0ipSDson#*>~T|)wgXl2JhLd zjlO$+Po?{w!|%1{IKR#QF)Zdb=_e!}BR8sUgWQs(NN|YYt&Et$xY7$Vl$}^S0Y#A4 zZBr8yqmx0NokB&OMmFQm2p>keD+gipBK)hUGfn*OdMm&69AJ(p+7U&&&8|ER6E<-K zrd1eSM=GervzEUy!y(Zv`dzd>6eGL2?hLPjnkptz9;P}?#eG&-6x1@wxyVT_$t`)V zS|D8li55(;;6*%gSAWT_cAnGBq!efNf9CEst<0ptUpZC4gk#oc?JiF9KVnt6IXh2_ z3iJJ}L#r;V6&7ua_C?2gmG2^3!gJQC)fz31n@2a}foIVPX{PG;YRoj&Sy%kO;f8A2 z4ZZhBTK&JdA+6CQ+#;j#dvyhheSQi*4iv6u6mHd;O}GXA{rvC@Zf}qpnanjo0)<)D?*0_NUYh1r_1v~db>y;O15@gjGLztD4R&&9k+vMf>HnXo-gBICV!iR;q2=zhfj#yrV zr14qzt8O?st55SL{=wF9!(zF_e4L(p`vqB|LYM+Z2%yK~3CL?vLSsp7q2T%+ z0=QEUkvk%oh>U9xX{rn+GY%!u0CL`k7>h!nP>c~+FPeR@#JXL^RG_ANS;~rrA@3uD z?~ZFMC2P>({P25AfBthiFh;exgoNn^G%=(j?d>3l<_zR8rO-Q&C}2^lBcB_3$Exgu z#R}R^-3HmQgpzI+MKeJAj(CjL60A84SIV%$7b9tkkWu z6mlVuk%OQ?7r?PgC>^*JX3`bZz057st>9GZ?&D%KMH!WJ*A#?42Eovd=mI(vx=l*O zlB(jwb$DFeRlrhrQ4?V!%y)*v7?gy=K~K?*&|P>6HlPd00S^XeNO)b4L`~E^$O&P? zchPyOdSHkYpNE-pPREemoNxTo;CN!X$5VXFXM!y4|-uyEm$8uLy5B z-*7J1uGIx^*Y#XGwpP`9r)uwN)!wzLedmTgsI0rYwJqB~?so6a>_5HQb|%xhkZtV% zKWA&rJ%^*NBF8z(EACftb&Xf{ytVI*eanZ}8hdUx4y^AUSgRepQ+s%|_V8Nm=(*vI z#+@r|KkWQ|=k)_?yI|6hbE7$nwXAUiRt~;3{KoL|^J`7rx0`maRIJtQxn#TV2A}uY|GA-;7a55(ahEnpmak8P`b(o zlrHn-s<^u5Y(*W+Zt2L99WY#7le5~IDso;}f6wo%_1$xL1MVE>b-Dj=e~jml(gk%UPlIr~7j}=dJ#T^V!@RPTw78-Kw)L)A-c&7q1_=q2Ahgt2I+U zzV4j3TiaCFZrhVTX}kW)k2~)49bWA_oO$xdtzEy4WR6Y#YUb7p8FG9xE~JRdCy2|R zayHoNQ}qSl;-7I(4>xf?=lb1H-Sli37Pyv(L}vv!kfv{7 zcT>ap?3vHIMCLoziv_2SMc4%=tT?CWbe^?>Tl}yYPSbpW>-q2*t`f)DwkRw;xoDN# z%mJv;@J#HPFE?AoTJo5%;fodrtGsMg-6K|&E!x057IF#swLZ4it6`p&J?P4q4*euT z_p=$EQisyqpN$9d5DG5kUqC2lGwhOOQGhRt70d;Jwgg`@F%*!h!8yqXnUfs26llTN zGmm+m2Oe-Uo;YhMIV&pI2ObD-A)zBVM2c&NTPWI}4UqEzDVfy2M$i z8BaHliBZgYK<>)?)&rJ3LLnsx){FT9@*o=1vZJD+f_XO_0%aU9RGx)Gx5t>3Wmc2L zsOfsJvBmFakj5~>Qz_j&5S8&Gh!{B;Pn-q|2~jp=QVJ8$JQtRR(zT_pY7clB5y<&d zDczKZQ|KX>M$Uiyf(%RnV%m+CPVK?y(rlx7(uE|Xa||zJ433d@=ADI(bY%yA1VcZe zkW~x?y@siN5l?K3CPYcq{YbDuiY|m37^2firGRitsXxb(+f{QA}1#}K|A^? zF3z8yzf!g4+j7pDtKfX)7oR`>{G}Jx%C;;W%9aJ#+tfe2Tzq-qin3O=ZRt?X&N+Qc zuY!HfRQKNY^lo@7E*?35Bvbdyx_1}`164U2=c~%p_TDZl3^wdtIkhsEse5|e`$fb7 zVO(mu?P%WcljWLgHEaIfjI%fE@?U!DwreZvT=REkoSmDxO6Tpp?W)f4PFE0!-3|*f#zdB=MCKPY+dzi{Rr{|Xecgv z&wDeqdsd!Z8OT)cTX#Om3`9+KOWVr+_bYEad$Z<-GqZDKwfgWqhY)ao!U;_E!{Sd> z3|zihJDsjMZh%om!WaFBq23l(9bZCe=ubQ zy2A3N)zm%^h8L0y57QJ<$;<^VD2%6@j^#y_!1uD49A^RmiAwe*Ib9thL-6$&)7-Q9 zugvhNTtRIV6com-$exg%Tu^lZKQx1vZH3jk4HA#QY&etTlQrHj41|r0lcFdv(hAI{ zwn1Zw+c4d?&^7fM&Fmh|1V+|{!yAI*8^f;+pL_P}M~r+8OfEd=&oW~D0JZ^Q6|yz+ zg(<~GG1WAWNImcrb9<(u2U7m6@E)R)9}2H%!XB!iHLr;jUaICRd9%K0D_$`e>q102 zUx-M}*&L+wU!H}G23KO9Hkt1w{zCa9vK{cFp!W(UPqL>0I2f4Xu9(An;F&K(=*5A> zf;SVn41bpO6B>TvR$gSy);zxb_ z4UdsZChA-YewuMQ&c5kIsYXM=YWS|sgj}~9zYAdksXOvN4k^mE@)W>T_G1x8G~gqI zB*&QnQP7qt)7T3i>G8K8y6rT4d!Qiw(Udx;m&1>AP9)$bJR=agD@rP<>HdRC;v|h9 zmCc~^O9KCIY^m^#+u63a&A241DUOjSU|24~d2QrWK zWx&G^99b7ebArcK1yR~tuB$Ex<7&4rpINKzJvW^7*RJ|oH|m?;YIvhzdHDL{>-GIN z23PBcFWEN&l^CAh36RwQ*sS$H=cb|J?fC6cM{TX%k8!|A!`Jny_ze$jK@1Mvk+{8Vj+ zQ~zu)8FX+zukEjg>V1cQsMh*^ofAvaJyc=+MTHI8It24bFbL*bbw@ZXB_iQ4yIA8g zPXQp^fmsINNZk&4fh47(^nmh@FoZ&)_JIL_c#!As32t8axQ^pJzvbYs?6;in-?^?euIu-9 z2XFa32L;5QeASZU4p+I#Rc1Xtbd$2~@{3*PyRx1GpEv}V;^y3q=W6e`8dhBmSDs#T z?O3wkt=yX70$E?%k|$eUx8#G+wY)1Q@MV8zfo63xI3vyNISbb|VPU~8z-VsIwohfA zqnW9h%uFiNdI|=bD{!C<9F662v|!}hygl0*W~=WtS2X%^+qs68E3SJx zYUk#z8j>s{A1DvMkwh;}$0$Sf0#rP+farMY_KG`|@h{7eB`LTup$tUy0;6sIKi< zv93(6INv$IUaw0xcHgqz*!QFO$2gkX?l5LTwJcrRy<)w#@4GMruequhdESjsur39I z@1=0?J$J^k17^48IH;C8i_=%P^?i(eAHBp|_`pZ*O8yJEE>7^COW(HEe;_!&ekf<< Jga*c2{|gTQ_FVt~ literal 0 HcmV?d00001 diff --git a/.agents/skills/marktguru/scripts/marktguru_client.py b/.agents/skills/marktguru/scripts/marktguru_client.py new file mode 100755 index 0000000..135c5c3 --- /dev/null +++ b/.agents/skills/marktguru/scripts/marktguru_client.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python3 +""" +Marktguru API Client (Production Grade) +Fetches promotional offers and discounts from German supermarkets via the Marktguru API. + +Key Design Principles: +- Zero external dependencies (uses standard library only: urllib, json, os, time, datetime). +- Resilient: Retries with exponential backoff on transient errors. +- Low Maintenance: Configurable keys and postal codes via environment variables. +- Fail-Safe: Caching with stale fallback if upstream API is temporarily unreachable. +- Defensive: Robust parsing protecting against upstream schema shifts and malformed payloads. +""" + +import os +import sys +import time +import json +import logging +import hashlib +import tempfile +import urllib.request +import urllib.parse +import urllib.error +from datetime import datetime, timezone +from typing import List, Dict, Any, Optional, Set, Tuple + +# Logger writing to stderr to keep stdout pure for JSON / CLI +logger = logging.getLogger("marktguru_client") +if not logger.handlers: + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + +API_BASE = "https://api.marktguru.de/api/v1" + +# Environment variable overrides for zero-code configuration changes +DEFAULT_ZIP_CODE = os.environ.get("MARKTGURU_ZIP_CODE", "93059") +CLIENT_KEY = os.environ.get("MARKTGURU_CLIENT_KEY", "WU/RH+PMGDi+gkZer3WbMelt6zcYHSTytNB7VpTia90=") +API_KEY = os.environ.get("MARKTGURU_API_KEY", "8Kk+pmbf7TgJ9nVj2cXeA7P5zBGv8iuutVVMRfOfvNE=") +CACHE_TTL = int(os.environ.get("MARKTGURU_CACHE_TTL", "1800")) # 30 minutes +CACHE_ENABLED = os.environ.get("MARKTGURU_CACHE_ENABLED", "true").lower() in ("true", "1", "yes") + +HEADERS = { + "x-clientkey": CLIENT_KEY, + "x-apikey": API_KEY, + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko)", + "Accept": "application/json", +} + +# Major supermarket chains and their Marktguru retailer IDs +MAJOR_RETAILERS: Dict[str, Dict[str, Any]] = { + "ALDI SÜD": {"id": 127153, "type": "Discounter", "major": True}, + "ALDI NORD": {"id": 127139, "type": "Discounter", "major": True}, + "Lidl": {"id": 126679, "type": "Discounter", "major": True}, + "EDEKA": {"id": 127093, "type": "Supermarkt", "major": True}, + "E center": {"id": 126699, "type": "Supermarkt", "major": True}, + "REWE": {"id": 126802, "type": "Supermarkt", "major": True}, + "REWE Center": {"id": 126804, "type": "Supermarkt", "major": True}, + "Kaufland": {"id": 126654, "type": "Großfläche/Hypermarkt", "major": True}, + "PENNY": {"id": 126765, "type": "Discounter", "major": True}, + "Netto Marken-Discount": {"id": 126735, "type": "Discounter", "major": True}, + "Netto (mit Hund)": {"id": 126734, "type": "Discounter", "major": False}, + "NORMA": {"id": 126745, "type": "Discounter", "major": True}, + "Globus": {"id": 127177, "type": "Großfläche/Hypermarkt", "major": True}, + "HIT": {"id": 126598, "type": "Supermarkt", "major": False}, + "tegut": {"id": 126866, "type": "Supermarkt", "major": False}, + "nahkauf": {"id": 137320, "type": "Supermarkt", "major": False}, + "dm-drogerie markt": {"id": 127057, "type": "Drogerie", "major": True}, + "ROSSMANN": {"id": 126811, "type": "Drogerie", "major": True}, + "Müller Drogeriemarkt": {"id": 126720, "type": "Drogerie", "major": True}, +} + +# Aliases mapping user query strings to Marktguru retailer IDs +RETAILER_ALIASES: Dict[str, List[int]] = { + "aldi": [127153, 127139], + "aldi süd": [127153], + "aldi sued": [127153], + "aldi nord": [127139], + "lidl": [126679], + "edeka": [127093, 126699], + "e center": [126699], + "ecenter": [126699], + "rewe": [126802, 126804], + "kaufland": [126654], + "penny": [126765], + "netto": [126735, 126734], + "norma": [126745], + "globus": [127177], + "hit": [126598], + "tegut": [126866], + "nahkauf": [137320], + "dm": [127057], + "rossmann": [126811], + "müller": [126720], + "mueller": [126720], +} + + +def _get_cache_dir() -> str: + """Returns directory path for local cached responses.""" + cache_dir = os.path.join(tempfile.gettempdir(), "marktguru_cache") + try: + os.makedirs(cache_dir, exist_ok=True) + except Exception: + pass + return cache_dir + + +def _read_cache(cache_key: str, max_age_seconds: int = CACHE_TTL) -> Tuple[Optional[Dict[str, Any]], bool]: + """ + Reads cache file. Returns (data, is_fresh). + If cache is expired, returns (data, False) so caller can use it as emergency fallback. + """ + if not CACHE_ENABLED: + return None, False + + cache_file = os.path.join(_get_cache_dir(), f"{cache_key}.json") + if not os.path.isfile(cache_file): + return None, False + + try: + with open(cache_file, "r", encoding="utf-8") as f: + payload = json.load(f) + cached_time = payload.get("_cached_at", 0) + age = time.time() - cached_time + data = payload.get("data") + is_fresh = age < max_age_seconds + return data, is_fresh + except Exception as e: + logger.debug(f"Failed to read cache {cache_key}: {e}") + return None, False + + +def _write_cache(cache_key: str, data: Dict[str, Any]) -> None: + """Writes data to cache file atomically.""" + if not CACHE_ENABLED: + return + + cache_file = os.path.join(_get_cache_dir(), f"{cache_key}.json") + temp_file = f"{cache_file}.tmp.{os.getpid()}" + try: + payload = { + "_cached_at": time.time(), + "data": data, + } + with open(temp_file, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False) + os.replace(temp_file, cache_file) + except Exception as e: + logger.debug(f"Failed to write cache {cache_key}: {e}") + if os.path.exists(temp_file): + try: + os.remove(temp_file) + except Exception: + pass + + +def resolve_supermarket_ids(supermarkets: Optional[List[str]]) -> Optional[str]: + """Resolves human supermarket names/aliases to comma-separated Marktguru retailer IDs.""" + if not supermarkets: + return None + + # Handle string passed instead of list (e.g. "Aldi, Lidl") + if isinstance(supermarkets, str): + supermarkets = [s.strip() for s in supermarkets.split(",") if s.strip()] + + resolved_ids: Set[int] = set() + for name in supermarkets: + if not isinstance(name, str): + continue + cleaned = name.strip().lower() + if not cleaned: + continue + + if cleaned in RETAILER_ALIASES: + resolved_ids.update(RETAILER_ALIASES[cleaned]) + elif cleaned.isdigit(): + resolved_ids.add(int(cleaned)) + else: + found = False + for r_name, r_info in MAJOR_RETAILERS.items(): + if cleaned in r_name.lower(): + resolved_ids.add(r_info["id"]) + found = True + if not found: + logger.warning(f"Unknown supermarket name '{name}' skipped.") + + return ",".join(str(i) for i in sorted(resolved_ids)) if resolved_ids else None + + +def format_iso_date(iso_str: Optional[str]) -> str: + """Formats an ISO-8601 date string to human-readable DD.MM.YYYY format.""" + if not iso_str or not isinstance(iso_str, str): + return "-" + try: + dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00")) + return dt.strftime("%d.%m.%Y") + except Exception: + return iso_str[:10] if len(iso_str) >= 10 else iso_str + + +def parse_offer(raw: Any, zip_code: str) -> Optional[Dict[str, Any]]: + """ + Defensively parses and standardizes an offer payload from Marktguru. + Protects against null fields, malformed dates, and missing keys. + """ + if not isinstance(raw, dict): + return None + + try: + offer_id = raw.get("id") + price = raw.get("price") + if isinstance(price, (int, float)): + price = round(float(price), 2) + else: + price = None + + old_price = raw.get("oldPrice") + if isinstance(old_price, (int, float)): + old_price = round(float(old_price), 2) + else: + old_price = None + + ref_price = raw.get("referencePrice") + if isinstance(ref_price, (int, float)): + ref_price = round(float(ref_price), 2) + else: + ref_price = None + + discount_pct = None + if old_price and price and old_price > price: + discount_pct = round(((old_price - price) / old_price) * 100) + + # Validity dates + validity = raw.get("validityDates") + valid_from_raw = None + valid_to_raw = None + is_valid_now = True + days_remaining = None + + if isinstance(validity, list) and len(validity) > 0 and isinstance(validity[0], dict): + valid_from_raw = validity[0].get("from") + valid_to_raw = validity[0].get("to") + + try: + now = datetime.now(timezone.utc) + if valid_to_raw and isinstance(valid_to_raw, str): + dt_to = datetime.fromisoformat(valid_to_raw.replace("Z", "+00:00")) + diff = (dt_to - now).days + days_remaining = max(0, diff) + if dt_to < now: + is_valid_now = False + + if valid_from_raw and isinstance(valid_from_raw, str): + dt_from = datetime.fromisoformat(valid_from_raw.replace("Z", "+00:00")) + if dt_from > now: + is_valid_now = False + except Exception: + pass + + # Supermarket / advertiser extraction + supermarket = "Unbekannt" + supermarket_id = None + advertisers = raw.get("advertisers") + if isinstance(advertisers, list) and len(advertisers) > 0 and isinstance(advertisers[0], dict): + supermarket = advertisers[0].get("name") or "Unbekannt" + supermarket_id = advertisers[0].get("id") + + # High resolution CDN image + image_url = None + if offer_id: + image_url = f"https://mg2de.b-cdn.net/api/v1/offers/{offer_id}/images/default/0/medium.webp" + + # Unit & volume + unit_info = raw.get("unit") + unit_name = None + if isinstance(unit_info, dict): + unit_name = unit_info.get("shortName") or unit_info.get("name") + + product_info = raw.get("product") + product_name = None + if isinstance(product_info, dict): + product_name = product_info.get("name") + + brand_info = raw.get("brand") + brand_name = None + if isinstance(brand_info, dict): + brand_name = brand_info.get("name") + + return { + "id": offer_id, + "supermarket": supermarket, + "supermarket_id": supermarket_id, + "product_name": product_name, + "brand": brand_name, + "description": raw.get("description"), + "price": price, + "old_price": old_price, + "reference_price": ref_price, + "discount_percent": discount_pct, + "unit": unit_name, + "volume": raw.get("volume"), + "loyalty_app_required": bool(raw.get("requiresLoyalityMembership", False)), + "is_valid_now": is_valid_now, + "days_remaining": days_remaining, + "valid_from": format_iso_date(valid_from_raw), + "valid_to": format_iso_date(valid_to_raw), + "image_url": image_url, + "zip_code": str(zip_code), + } + except Exception as e: + logger.debug(f"Error parsing single offer {raw.get('id') if isinstance(raw, dict) else raw}: {e}") + return None + + +def _fetch_with_retry(url: str, max_retries: int = 2) -> Dict[str, Any]: + """ + Executes HTTP request with exponential backoff on transient errors (5xx / network blips). + """ + last_err: Optional[Exception] = None + + for attempt in range(max_retries + 1): + req = urllib.request.Request(url, headers=HEADERS) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + raw_bytes = resp.read() + return json.loads(raw_bytes.decode("utf-8")) + except urllib.error.HTTPError as e: + last_err = e + # Retry only on transient server errors (500, 502, 503, 504) or rate limits (429) + if e.code in (429, 500, 502, 503, 504) and attempt < max_retries: + backoff = 0.5 * (2 ** attempt) + logger.warning(f"Marktguru HTTP {e.code}. Retrying in {backoff:.1f}s (attempt {attempt+1}/{max_retries})...") + time.sleep(backoff) + continue + raise + except (urllib.error.URLError, TimeoutError, OSError) as e: + last_err = e + if attempt < max_retries: + backoff = 0.5 * (2 ** attempt) + logger.warning(f"Connection issue ({e}). Retrying in {backoff:.1f}s...") + time.sleep(backoff) + continue + raise + + raise last_err or RuntimeError("Max retries exceeded") + + +def search_offers( + query: str, + zip_code: str = DEFAULT_ZIP_CODE, + supermarkets: Optional[List[str]] = None, + limit: int = 24, + offset: int = 0, + force_refresh: bool = False, +) -> Dict[str, Any]: + """ + Searches current supermarket offers in Germany via the Marktguru API. + + Args: + query: Search term (e.g. 'Butter', 'Kaffee', 'Bier', 'Milch') + zip_code: German postal code (default: '93059' Regensburg) + supermarkets: List of supermarket names or aliases (e.g. ['Aldi', 'Lidl', 'Edeka']) + limit: Max number of items to return (default: 24, max: 50) + offset: Result pagination offset + force_refresh: If True, bypasses local TTL cache + """ + cleaned_query = (query or "").strip() + cleaned_zip = str(zip_code or DEFAULT_ZIP_CODE).strip() + limit = max(1, min(int(limit), 50)) + offset = max(0, int(offset)) + + retailer_ids = resolve_supermarket_ids(supermarkets) + + # Compute deterministic cache key + cache_raw = f"{cleaned_query.lower()}:{cleaned_zip}:{retailer_ids or ''}:{limit}:{offset}" + cache_key = hashlib.sha256(cache_raw.encode("utf-8")).hexdigest()[:16] + + if not force_refresh: + cached_data, is_fresh = _read_cache(cache_key) + if is_fresh and cached_data: + return cached_data + + params: Dict[str, Any] = { + "as": "web", + "limit": limit, + "offset": offset, + "q": cleaned_query, + "zipCode": cleaned_zip, + } + if retailer_ids: + params["retailers"] = retailer_ids + + url = f"{API_BASE}/offers/search?{urllib.parse.urlencode(params)}" + + try: + data = _fetch_with_retry(url) + except Exception as e: + logger.error(f"Marktguru API query failed: {e}") + # Emergency fail-safe: Check if stale cache is available + cached_data, _ = _read_cache(cache_key, max_age_seconds=86400 * 7) + if cached_data: + logger.warning("Serving stale cached data due to API failure.") + cached_data["warning"] = "Stale cache returned due to temporary upstream API issue." + return cached_data + + return { + "error": f"API request failed: {str(e)}", + "query": cleaned_query, + "zip_code": cleaned_zip, + "total_results": 0, + "returned_count": 0, + "offers": [], + "available_retailers": [], + } + + raw_results = data.get("results") or [] + parsed_offers: List[Dict[str, Any]] = [] + for item in raw_results: + parsed = parse_offer(item, cleaned_zip) + if parsed is not None: + parsed_offers.append(parsed) + + filters = data.get("filters") or {} + raw_retailers = filters.get("retailers") or [] + available_retailers = [ + {"name": r.get("name"), "id": r.get("id"), "count": r.get("resultsCount")} + for r in raw_retailers if isinstance(r, dict) and r.get("name") + ] + + result = { + "query": cleaned_query, + "zip_code": cleaned_zip, + "total_results": data.get("totalResults", len(parsed_offers)), + "returned_count": len(parsed_offers), + "available_retailers": available_retailers, + "offers": parsed_offers, + } + + _write_cache(cache_key, result) + return result + + +def compare_prices( + query: str, + zip_code: str = DEFAULT_ZIP_CODE, + supermarkets: Optional[List[str]] = None, + force_refresh: bool = False, +) -> Dict[str, Any]: + """ + Compares prices for a given product query across supermarkets, sorted from cheapest to most expensive. + """ + data = search_offers(query, zip_code=zip_code, supermarkets=supermarkets, limit=50, force_refresh=force_refresh) + offers = data.get("offers", []) + + # Filter out items without price and sort ascending + priced_offers = [o for o in offers if o.get("price") is not None] + priced_offers.sort(key=lambda x: x["price"]) + + return { + "query": query, + "zip_code": str(zip_code), + "total_compared": len(priced_offers), + "cheapest_offer": priced_offers[0] if priced_offers else None, + "all_offers_sorted": priced_offers, + } + + +def list_supported_supermarkets() -> Dict[str, Any]: + """Returns a structured catalog of all major supermarket chains supported by Marktguru.""" + categories: Dict[str, List[Dict[str, Any]]] = { + "Discounter": [], + "Supermarkt": [], + "Großfläche / Hypermarkt": [], + "Drogerie": [], + } + + for name, info in MAJOR_RETAILERS.items(): + cat = info.get("type", "Sonstige") + if cat not in categories: + categories[cat] = [] + categories[cat].append({ + "name": name, + "id": info["id"], + "major": info.get("major", False) + }) + + return { + "default_zip_code": DEFAULT_ZIP_CODE, + "cache_ttl_seconds": CACHE_TTL, + "supported_chains_count": len(MAJOR_RETAILERS), + "categories": categories, + "supported_aliases": list(RETAILER_ALIASES.keys()), + } + + +def main(): + import argparse + parser = argparse.ArgumentParser(description="Query German supermarket flyers/discounts via Marktguru") + subparsers = parser.add_subparsers(dest="command", required=True) + + # Search + search_p = subparsers.add_parser("search", help="Search offers for a product") + search_p.add_argument("query", help="Product to search (e.g. Butter, Kaffee)") + search_p.add_argument("--zip", default=DEFAULT_ZIP_CODE, help=f"Postal code (default: {DEFAULT_ZIP_CODE})") + search_p.add_argument("--supermarket", help="Comma-separated supermarkets (e.g. 'Aldi,Lidl,Edeka')") + search_p.add_argument("--limit", type=int, default=15, help="Max results (default 15)") + search_p.add_argument("--refresh", action="store_true", help="Bypass local cache") + search_p.add_argument("--json", action="store_true", help="Output raw JSON") + + # Compare + comp_p = subparsers.add_parser("compare", help="Compare prices across supermarkets") + comp_p.add_argument("query", help="Product to compare") + comp_p.add_argument("--zip", default=DEFAULT_ZIP_CODE, help=f"Postal code (default: {DEFAULT_ZIP_CODE})") + comp_p.add_argument("--supermarket", help="Comma-separated supermarkets") + comp_p.add_argument("--refresh", action="store_true", help="Bypass local cache") + comp_p.add_argument("--json", action="store_true", help="Output raw JSON") + + # Supermarkets + subparsers.add_parser("supermarkets", help="List all supported supermarkets and chains") + + args = parser.parse_args() + + if args.command == "supermarkets": + data = list_supported_supermarkets() + print(json.dumps(data, indent=2, ensure_ascii=False)) + return + + sm_list = [s.strip() for s in args.supermarket.split(",")] if getattr(args, "supermarket", None) else None + + if args.command == "search": + result = search_offers( + args.query, + zip_code=args.zip, + supermarkets=sm_list, + limit=args.limit, + force_refresh=args.refresh + ) + if args.json: + print(json.dumps(result, indent=2, ensure_ascii=False)) + else: + if "error" in result and not result.get("offers"): + print(f"❌ Fehler: {result['error']}") + return + + print(f"\n🔍 Angebote für '{args.query}' in PLZ {args.zip} (Gefunden: {result['total_results']}):\n") + for o in result["offers"]: + disc_str = f" (-{o['discount_percent']}%)" if o.get("discount_percent") else "" + loyalty_str = " 📱 [App Coupon]" if o.get("loyalty_app_required") else "" + timing_str = f"noch {o['days_remaining']} Tage" if o.get("days_remaining") is not None else f"bis {o['valid_to']}" + print(f" • [{o['supermarket']}] {o['description'] or o['product_name'] or 'Angebot'}") + print(f" Preis: {o['price']:.2f} €{disc_str}{loyalty_str} | Gültig: {timing_str}") + if o.get("reference_price"): + print(f" Grundpreis: {o['reference_price']:.2f} € / {o.get('unit') or 'Einheit'}") + print() + + elif args.command == "compare": + result = compare_prices( + args.query, + zip_code=args.zip, + supermarkets=sm_list, + force_refresh=args.refresh + ) + if args.json: + print(json.dumps(result, indent=2, ensure_ascii=False)) + else: + print(f"\n🏷️ Preisvergleich für '{args.query}' in PLZ {args.zip}:\n") + for idx, o in enumerate(result["all_offers_sorted"], 1): + disc_str = f" (UVP {o['old_price']:.2f}€)" if o.get("old_price") else "" + loyalty_str = " [App erforderlich]" if o.get("loyalty_app_required") else "" + print(f" {idx:2d}. {o['price']:.2f} € - [{o['supermarket']}] {o['description'] or o['product_name']}{disc_str}{loyalty_str}") + print() + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/marktguru/scripts/marktguru_mcp.py b/.agents/skills/marktguru/scripts/marktguru_mcp.py new file mode 100755 index 0000000..c07072b --- /dev/null +++ b/.agents/skills/marktguru/scripts/marktguru_mcp.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +""" +Marktguru MCP (Model Context Protocol) Server (Production Grade) +Exposes German supermarket flyers, discounts, and price comparison tools over standard JSON-RPC 2.0 stdio. + +Key Design Principles: +- Zero external dependencies: Uses standard library only. +- Strict Protocol Compliance: Conforms to MCP (2024-11-05) and JSON-RPC 2.0 specifications. +- Defensive Argument Coercion: Tolerates string/number variations commonly output by LLMs. +- Graceful Signal Handling: Clean exit on SIGINT, SIGTERM, and EOF. +- Stream Isolation: All logs and diagnostics route to stderr, ensuring stdout is 100% clean JSON-RPC. +""" + +import sys +import json +import signal +import logging +from typing import Dict, Any, List, Optional + +# Import business logic from hardened client script +from marktguru_client import ( + search_offers, + compare_prices, + list_supported_supermarkets, + DEFAULT_ZIP_CODE, + MAJOR_RETAILERS, + RETAILER_ALIASES, +) + +# Configure logging strictly to stderr +logging.basicConfig( + stream=sys.stderr, + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s" +) +logger = logging.getLogger("marktguru-mcp") + +PROTOCOL_VERSION = "2024-11-05" +SERVER_NAME = "marktguru-mcp-server" +SERVER_VERSION = "1.1.0" + +TOOLS = [ + { + "name": "search_supermarket_offers", + "description": ( + "Search current weekly supermarket offers and promotional discounts in Germany via the Marktguru API. " + "Supports filtering by postal code (PLZ) and specific supermarket chains (e.g. Aldi Süd, Aldi Nord, Lidl, Edeka, Rewe, Kaufland, Penny, Netto)." + ), + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The product or keyword to search for (e.g. 'Butter', 'Kaffee', 'Bier', 'Milch', 'Nutella', 'Schokolade')." + }, + "zip_code": { + "type": "string", + "description": f"5-digit German postal code (PLZ). Defaults to '{DEFAULT_ZIP_CODE}' (Regensburg)." + }, + "supermarkets": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional list of supermarket names to filter by (e.g. ['Aldi', 'Lidl', 'Edeka', 'Rewe', 'Kaufland', 'Penny', 'Netto', 'dm', 'Rossmann']). If omitted, all available retailers are included." + }, + "limit": { + "type": "integer", + "description": "Maximum number of offers to return (default: 20, max: 50)." + } + }, + "required": ["query"] + } + }, + { + "name": "compare_supermarket_prices", + "description": ( + "Compare current prices for a product across German supermarkets in a given postal code. " + "Returns results sorted from lowest to highest price, highlighting the cheapest deal, regular prices (UVP), and required loyalty app memberships." + ), + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The product or grocery item to compare (e.g. 'Kaffee', 'Butter', 'Bier', 'Olivenöl')." + }, + "zip_code": { + "type": "string", + "description": f"5-digit German postal code (PLZ). Defaults to '{DEFAULT_ZIP_CODE}' (Regensburg)." + }, + "supermarkets": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional list of supermarket names to compare (e.g. ['Aldi', 'Lidl', 'Edeka', 'Rewe'])." + } + }, + "required": ["query"] + } + }, + { + "name": "list_supported_supermarkets", + "description": ( + "List all supermarket, discounter, hypermarket, and drugstore chains supported by the Marktguru service in Germany, " + "including their categories (Discounter, Supermarkt, Großfläche, Drogerie) and accepted name aliases." + ), + "inputSchema": { + "type": "object", + "properties": {} + } + } +] + + +def _sanitize_supermarkets(sm_input: Any) -> Optional[List[str]]: + """Coerces various LLM input formats (list, comma-string, single string) into a clean list.""" + if not sm_input: + return None + if isinstance(sm_input, list): + return [str(s).strip() for s in sm_input if str(s).strip()] + if isinstance(sm_input, str): + return [s.strip() for s in sm_input.split(",") if s.strip()] + return None + + +def handle_initialize(req_id: Any, params: Dict[str, Any]) -> Dict[str, Any]: + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": { + "tools": { + "listChanged": False + } + }, + "serverInfo": { + "name": SERVER_NAME, + "version": SERVER_VERSION + } + } + } + + +def handle_tools_list(req_id: Any) -> Dict[str, Any]: + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "tools": TOOLS + } + } + + +def handle_tools_call(req_id: Any, params: Dict[str, Any]) -> Dict[str, Any]: + tool_name = params.get("name") + args = params.get("arguments", {}) + + logger.info(f"Tool call requested: {tool_name} with args={args}") + + try: + if tool_name == "search_supermarket_offers": + query = str(args.get("query") or "").strip() + if not query: + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [{"type": "text", "text": "Error: 'query' argument is required and cannot be empty."}], + "isError": True + } + } + + zip_code = str(args.get("zip_code") or DEFAULT_ZIP_CODE).strip() + supermarkets = _sanitize_supermarkets(args.get("supermarkets")) + try: + limit = int(args.get("limit") or 20) + except (ValueError, TypeError): + limit = 20 + + data = search_offers(query, zip_code=zip_code, supermarkets=supermarkets, limit=limit) + text_result = json.dumps(data, indent=2, ensure_ascii=False) + + elif tool_name == "compare_supermarket_prices": + query = str(args.get("query") or "").strip() + if not query: + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [{"type": "text", "text": "Error: 'query' argument is required and cannot be empty."}], + "isError": True + } + } + + zip_code = str(args.get("zip_code") or DEFAULT_ZIP_CODE).strip() + supermarkets = _sanitize_supermarkets(args.get("supermarkets")) + + data = compare_prices(query, zip_code=zip_code, supermarkets=supermarkets) + text_result = json.dumps(data, indent=2, ensure_ascii=False) + + elif tool_name == "list_supported_supermarkets": + data = list_supported_supermarkets() + text_result = json.dumps(data, indent=2, ensure_ascii=False) + + else: + return { + "jsonrpc": "2.0", + "id": req_id, + "error": { + "code": -32601, + "message": f"Method or tool '{tool_name}' not found." + } + } + + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [ + { + "type": "text", + "text": text_result + } + ], + "isError": False + } + } + + except Exception as e: + logger.exception(f"Exception during tool execution '{tool_name}': {e}") + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [ + { + "type": "text", + "text": f"Tool execution failed: {type(e).__name__}: {str(e)}" + } + ], + "isError": True + } + } + + +def process_message(line: str) -> Optional[Dict[str, Any]]: + if not line.strip(): + return None + + try: + msg = json.loads(line) + except json.JSONDecodeError as e: + logger.error(f"Malformed JSON-RPC received: {e}") + return { + "jsonrpc": "2.0", + "id": None, + "error": {"code": -32700, "message": f"Parse error: {str(e)}"} + } + + method = msg.get("method") + req_id = msg.get("id") + params = msg.get("params", {}) + + # Notifications do not return a response + if req_id is None: + if method == "notifications/initialized": + logger.info("Client acknowledged initialized notification.") + elif method == "notifications/cancelled": + logger.info(f"Client cancelled operation: {params}") + return None + + # Standard Requests + if method == "initialize": + return handle_initialize(req_id, params) + elif method == "tools/list": + return handle_tools_list(req_id) + elif method == "tools/call": + return handle_tools_call(req_id, params) + elif method == "ping": + return {"jsonrpc": "2.0", "id": req_id, "result": {}} + else: + return { + "jsonrpc": "2.0", + "id": req_id, + "error": { + "code": -32601, + "message": f"Method '{method}' is not supported." + } + } + + +def _sig_handler(sig, frame): + logger.info(f"Received termination signal {sig}. Exiting gracefully.") + sys.exit(0) + + +def main(): + signal.signal(signal.SIGINT, _sig_handler) + signal.signal(signal.SIGTERM, _sig_handler) + + logger.info(f"Starting {SERVER_NAME} v{SERVER_VERSION} (stdio mode)...") + + try: + for raw_line in sys.stdin: + response = process_message(raw_line) + if response is not None: + output = json.dumps(response, ensure_ascii=False) + "\n" + sys.stdout.write(output) + sys.stdout.flush() + except (KeyboardInterrupt, BrokenPipeError): + pass + except Exception as e: + logger.exception(f"Fatal error in stdio loop: {e}") + sys.exit(1) + + logger.info("Marktguru MCP server shutdown complete.") + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/marktguru/tests/test_marktguru.py b/.agents/skills/marktguru/tests/test_marktguru.py new file mode 100644 index 0000000..0833201 --- /dev/null +++ b/.agents/skills/marktguru/tests/test_marktguru.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +""" +Unit tests for the Marktguru skill & MCP Server. +Runs completely offline using standard library unittest. +""" + +import sys +import os +import json +import unittest +from datetime import datetime, timezone, timedelta + +# Add scripts directory to path +SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts")) +if SCRIPTS_DIR not in sys.path: + sys.path.insert(0, SCRIPTS_DIR) + +from marktguru_client import ( + resolve_supermarket_ids, + format_iso_date, + parse_offer, + list_supported_supermarkets, + DEFAULT_ZIP_CODE, + _read_cache, + _write_cache, + _get_cache_dir, +) +from marktguru_mcp import process_message, SERVER_NAME, PROTOCOL_VERSION + + +class TestMarktguruClient(unittest.TestCase): + def test_default_zip_code(self): + self.assertEqual(DEFAULT_ZIP_CODE, "93059") + + def test_resolve_supermarket_ids_aliases(self): + # Aldi maps to Aldi Nord and Aldi Süd + aldi_ids = resolve_supermarket_ids(["aldi"]) + self.assertIn("127153", aldi_ids) + self.assertIn("127139", aldi_ids) + + # Edeka maps to Edeka and E center + edeka_ids = resolve_supermarket_ids(["edeka"]) + self.assertIn("127093", edeka_ids) + self.assertIn("126699", edeka_ids) + + # Lidl + lidl_ids = resolve_supermarket_ids(["lidl"]) + self.assertEqual(lidl_ids, "126679") + + # Comma-separated string support + combined = resolve_supermarket_ids("Lidl, Kaufland") + self.assertIn("126679", combined) + self.assertIn("126654", combined) + + # Unknown supermarket is ignored gracefully + unknown = resolve_supermarket_ids(["NonExistentMarket123"]) + self.assertIsNone(unknown) + + def test_format_iso_date(self): + self.assertEqual(format_iso_date("2026-10-15T12:00:00Z"), "15.10.2026") + self.assertEqual(format_iso_date(None), "-") + self.assertEqual(format_iso_date(""), "-") + self.assertEqual(format_iso_date("invalid-date-format"), "invalid-da") + + def test_parse_offer_defensive(self): + # 1. Complete payload + now = datetime.now(timezone.utc) + from_date = (now - timedelta(days=1)).isoformat() + to_date = (now + timedelta(days=4)).isoformat() + + raw_complete = { + "id": 99999, + "price": 1.49, + "oldPrice": 2.29, + "referencePrice": 5.96, + "requiresLoyalityMembership": True, + "description": "250g Packung", + "product": {"name": "Süßrahmbutter"}, + "brand": {"name": "Landliebe"}, + "advertisers": [{"name": "EDEKA", "id": "retailers/127093"}], + "unit": {"shortName": "kg"}, + "volume": 0.25, + "validityDates": [{"from": from_date, "to": to_date}] + } + + parsed = parse_offer(raw_complete, "93059") + self.assertIsNotNone(parsed) + self.assertEqual(parsed["id"], 99999) + self.assertEqual(parsed["supermarket"], "EDEKA") + self.assertEqual(parsed["price"], 1.49) + self.assertEqual(parsed["old_price"], 2.29) + self.assertEqual(parsed["discount_percent"], 35) # (2.29 - 1.49) / 2.29 = 34.9% -> 35% + self.assertTrue(parsed["loyalty_app_required"]) + self.assertTrue(parsed["is_valid_now"]) + self.assertGreaterEqual(parsed["days_remaining"], 3) + self.assertIn("https://mg2de.b-cdn.net/api/v1/offers/99999", parsed["image_url"]) + + # 2. Minimal / empty payload + raw_empty = {} + parsed_min = parse_offer(raw_empty, "93059") + self.assertIsNotNone(parsed_min) + self.assertEqual(parsed_min["supermarket"], "Unbekannt") + self.assertIsNone(parsed_min["price"]) + self.assertIsNone(parsed_min["discount_percent"]) + + # 3. Invalid types (not a dict) + self.assertIsNone(parse_offer("not a dict", "93059")) + self.assertIsNone(parse_offer(None, "93059")) + + def test_cache_mechanism(self): + test_key = "test_unit_key_123" + test_data = {"hello": "world", "timestamp": 12345} + + _write_cache(test_key, test_data) + data, is_fresh = _read_cache(test_key, max_age_seconds=60) + self.assertTrue(is_fresh) + self.assertEqual(data, test_data) + + # Stale cache test + data_stale, is_fresh_stale = _read_cache(test_key, max_age_seconds=-1) + self.assertFalse(is_fresh_stale) + self.assertEqual(data_stale, test_data) # still returned as fallback! + + def test_list_supported_supermarkets(self): + catalog = list_supported_supermarkets() + self.assertEqual(catalog["default_zip_code"], "93059") + self.assertIn("Discounter", catalog["categories"]) + self.assertIn("Supermarkt", catalog["categories"]) + self.assertIn("aldi", catalog["supported_aliases"]) + self.assertIn("edeka", catalog["supported_aliases"]) + + +class TestMarktguruMCP(unittest.TestCase): + def test_mcp_initialize(self): + req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + resp = process_message(req) + self.assertEqual(resp["id"], 1) + self.assertEqual(resp["result"]["protocolVersion"], PROTOCOL_VERSION) + self.assertEqual(resp["result"]["serverInfo"]["name"], SERVER_NAME) + + def test_mcp_tools_list(self): + req = json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + resp = process_message(req) + tools = [t["name"] for t in resp["result"]["tools"]] + self.assertIn("search_supermarket_offers", tools) + self.assertIn("compare_supermarket_prices", tools) + self.assertIn("list_supported_supermarkets", tools) + + def test_mcp_tools_call_supermarket_catalog(self): + req = json.dumps({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "list_supported_supermarkets", "arguments": {}} + }) + resp = process_message(req) + self.assertFalse(resp["result"]["isError"]) + content_text = resp["result"]["content"][0]["text"] + data = json.loads(content_text) + self.assertEqual(data["default_zip_code"], "93059") + + def test_mcp_tools_call_missing_query(self): + req = json.dumps({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": {"name": "search_supermarket_offers", "arguments": {"query": ""}} + }) + resp = process_message(req) + self.assertTrue(resp["result"]["isError"]) + self.assertIn("required", resp["result"]["content"][0]["text"]) + + def test_mcp_notification_silence(self): + req = json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + resp = process_message(req) + self.assertIsNone(resp) # Notifications must not produce responses + + +if __name__ == "__main__": + unittest.main()