sok-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ id-token: write # required for PyPI trusted publishing (OIDC)
12
+ contents: read
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.12"
18
+ - run: pip install build
19
+ - run: python -m build
20
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,61 @@
1
+ name: Publish to MCP Registry
2
+
3
+ # Official MCP Registry (registry.modelcontextprotocol.io) via GitHub OIDC.
4
+ # No secret, no interactive login: the runner's OIDC token proves ownership
5
+ # of the io.github.mohammad-abbasi-sh/* namespace.
6
+ # Refs: https://github.com/modelcontextprotocol/registry
7
+
8
+ on:
9
+ push:
10
+ tags: ["v*"]
11
+ workflow_dispatch:
12
+
13
+ jobs:
14
+ publish:
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ id-token: write # required for OIDC -> MCP Registry
18
+ contents: read
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - name: Sync server.json version to tag
23
+ # Only sync when running on a version tag (v*). On workflow_dispatch
24
+ # (ref = refs/heads/main) keep the version committed in server.json.
25
+ run: |
26
+ VER=""
27
+ if [[ "$GITHUB_REF" == refs/tags/* ]]; then
28
+ TAG="${GITHUB_REF#refs/tags/}"
29
+ VER="${TAG#v}"
30
+ fi
31
+ if [ -n "$VER" ]; then
32
+ python3 - "$VER" << 'EOF'
33
+ import json, sys
34
+ ver = sys.argv[1]
35
+ d = json.load(open('server.json'))
36
+ d['version'] = ver
37
+ for pkg in d.get('packages', []):
38
+ pkg['version'] = ver
39
+ json.dump(d, open('server.json', 'w'), indent=2, ensure_ascii=False)
40
+ print('server.json version ->', ver)
41
+ EOF
42
+ else
43
+ echo "No version tag ($GITHUB_REF); keeping server.json version as-is"
44
+ fi
45
+
46
+ - name: Install mcp-publisher
47
+ run: |
48
+ curl -sL "https://github.com/modelcontextprotocol/registry/releases/download/v1.8.7/mcp-publisher_linux_amd64.tar.gz" -o /tmp/mcp-publisher.tar.gz
49
+ tar -xzf /tmp/mcp-publisher.tar.gz -C /tmp
50
+ sudo mv /tmp/mcp-publisher /usr/local/bin/mcp-publisher
51
+ mcp-publisher --version
52
+
53
+ - name: Validate server.json
54
+ run: mcp-publisher validate server.json
55
+
56
+ - name: Publish to MCP Registry (OIDC)
57
+ run: |
58
+ mcp-publisher login github-oidc
59
+ mcp-publisher publish server.json
60
+ # A registry outage must never break the release; re-run from Actions if needed.
61
+ continue-on-error: true
sok_mcp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.5
2
+ Name: sok-mcp
3
+ Version: 0.1.0
4
+ Summary: Unofficial read-only MCP server for ŞOK grocery (sokmarket.com.tr) products, categories, campaigns, prices
5
+ Author: Mohammad Abbasi
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: curl-cffi>=0.7
9
+ Requires-Dist: mcp<2
10
+ Requires-Dist: requests>=2.32
11
+ Description-Content-Type: text/markdown
12
+
13
+ # sok-mcp 🇹🇷
14
+
15
+ mcp-name: io.github.mohammad-abbasi-sh/sok-mcp
16
+
17
+ Unofficial **read-only** MCP server for **ŞOK** (sokmarket.com.tr) — one of Türkiye's big discount grocery chains: product search, product details, categories, campaign groups and price statistics.
18
+
19
+ > Part of the "Turkey MCP factory" (Sepehr-style pipeline). Not affiliated with ŞOK.
20
+
21
+ ## For AI agents
22
+
23
+ Add to your MCP client config (stdio):
24
+
25
+ ```json
26
+ {
27
+ "mcpServers": {
28
+ "sok": {
29
+ "command": "uvx",
30
+ "args": ["sok-mcp"]
31
+ }
32
+ }
33
+ }
34
+ ```
35
+
36
+ Or run locally: `python -m sok_mcp.server` (needs `mcp<2`, `curl-cffi`, `requests`).
37
+
38
+ ## Tools
39
+
40
+ | Tool | Description |
41
+ |------|-------------|
42
+ | `sok_search_products` | Search products by name — `query`, `limit` (1..30, first page only) |
43
+ | `sok_get_product` | Product detail — `slug_or_url` (e.g. `...-p-320230` or a full sokmarket.com.tr URL) |
44
+ | `sok_list_categories` | Store categories (name, slug, id, url) |
45
+ | `sok_category_products` | Products in one category — `category` slug or URL, `limit` (1..30) |
46
+ | `sok_list_promotions` | Current campaign groups from `/kampanyalar` |
47
+ | `sok_price_stats` | Price stats for a search — count, min, max, median, mean in TRY |
48
+
49
+ Example output (`sok_search_products` / "süt"):
50
+
51
+ ```json
52
+ {"query": "süt", "total": 207,
53
+ "products": [{"title": "Mis Uht Süt Yarım Yağlı 1 L", "id": "5834",
54
+ "url": "https://www.sokmarket.com.tr/mis-uht-sut-yarim-yagli-1-l-p-5834",
55
+ "price_try": 43.5}]}
56
+ ```
57
+
58
+ Example output (`sok_price_stats` / "süt", first page of 10):
59
+
60
+ ```json
61
+ {"query": "süt", "total_matches": 207, "count": 10,
62
+ "min_try": 16.5, "max_try": 88.5, "median_try": 55.75, "mean_try": 52.05}
63
+ ```
64
+
65
+ Notes:
66
+ - Prices are Turkish Lira (TRY); the site writes them Turkish-style (`43,50₺`).
67
+ - Search/category pages return the first page (~20 products); `total` reports all matches.
68
+ - Data: sokmarket.com.tr public server-rendered pages — no key, no login, read-only.
69
+
70
+ ## Development
71
+
72
+ ```bash
73
+ python3 -m venv .venv && .venv/bin/pip install "mcp<2" curl-cffi requests pytest
74
+ .venv/bin/python -m pytest tests/ -q
75
+ ```
76
+
77
+ - `tests/test_products.py` — offline, parses real captured fixtures
78
+ - `tests/test_live.py` — 5 cautious live requests against sokmarket.com.tr
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,40 @@
1
+ # sok-mcp 🇹🇷
2
+
3
+ سرور MCP غیررسمی و **فقط‌خواندنی** برای **ŞOK** (sokmarket.com.tr) — یکی از زنجیره‌های بزرگ تخفیفی ترکیه: جست‌وجوی محصول، جزئیات محصول، دسته‌ها، گروه‌های کمپین و آمار قیمت.
4
+
5
+ > بخشی از «کارخانه‌ی MCP ترکیه» (پایپ‌لاین به سبک سپهر). هیچ وابستگی به ŞOK ندارد.
6
+
7
+ ## برای ایجنت‌های هوش مصنوعی
8
+
9
+ ```json
10
+ {
11
+ "mcpServers": {
12
+ "sok": {
13
+ "command": "uvx",
14
+ "args": ["sok-mcp"]
15
+ }
16
+ }
17
+ }
18
+ ```
19
+
20
+ ## ابزارها
21
+
22
+ | ابزار | توضیح |
23
+ |------|-------|
24
+ | `sok_search_products` | جست‌وجوی محصول با نام — `query`، ‏`limit` (۱ تا ۳۰، فقط صفحه‌ی اول) |
25
+ | `sok_get_product` | جزئیات محصول — `slug_or_url` (مثلاً `...-p-320230` یا URL کامل sokmarket.com.tr) |
26
+ | `sok_list_categories` | دسته‌های فروشگاه (نام، slug، id، url) |
27
+ | `sok_category_products` | محصولات یک دسته — `category` (slug یا URL)، ‏`limit` (۱ تا ۳۰) |
28
+ | `sok_list_promotions` | گروه‌های کمپین فعلی (`/kampanyalar`) |
29
+ | `sok_price_stats` | آمار قیمت یک جست‌وجو — تعداد، کمترین، بیشترین، میانه، میانگین (لیر) |
30
+
31
+ مثال (`sok_search_products` / «süt»): ۲۰۷ محصول؛ اولین نتیجه «Mis Uht Süt Yarım Yağlı 1 L» — ۴۳٫۵۰ لیر. مثال (`sok_price_stats` / «süt»): صفحه‌ی اول ۱۰ محصول، کمترین ۱۶٫۵۰ لیر، بیشترین ۸۸٫۵۰ لیر، میانه ۵۵٫۷۵ لیر.
32
+
33
+ نکته‌ها:
34
+ - قیمت‌ها به لیر ترکیه (TRY) هستند؛ سایت به سبک ترکی می‌نویسد (`43,50₺`).
35
+ - صفحه‌های جست‌وجو/دسته فقط صفحه‌ی اول را برمی‌گردانند (~۲۰ محصول)؛ `total` همه‌ی تطابق‌ها را گزارش می‌کند.
36
+ - داده: صفحات عمومی sokmarket.com.tr — بدون لاگین، بدون کلید، فقط‌خواندنی.
37
+
38
+ ## قوانین
39
+
40
+ غیررسمی و فقط‌خواندنی — بدون لاگین، بدون کلید، بدون نوشتن. تست‌ها: آفلاین با فیکسچر + تست زنده (۱۵/۱۵ سبز).
@@ -0,0 +1,42 @@
1
+ # sok-mcp 🇹🇷
2
+
3
+ **ŞOK** (sokmarket.com.tr) için gayriresmî, **salt-okunur** MCP sunucusu — Türkiye'nin büyük indirim market zincirlerinden biri: ürün arama, ürün detayı, kategoriler, kampanya grupları ve fiyat istatistikleri.
4
+
5
+ > "Türkiye MCP fabrikası" parçası (Sepehr tarzı pipeline). ŞOK ile bağlantısı yoktur.
6
+
7
+ ## Yapay zekâ ajanları için
8
+
9
+ MCP istemcinize ekleyin (stdio):
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "sok": {
15
+ "command": "uvx",
16
+ "args": ["sok-mcp"]
17
+ }
18
+ }
19
+ }
20
+ ```
21
+
22
+ ## Araçlar
23
+
24
+ | Araç | Açıklama |
25
+ |------|----------|
26
+ | `sok_search_products` | İsme göre ürün ara — `query`, `limit` (1..30, yalnızca ilk sayfa) |
27
+ | `sok_get_product` | Ürün detayı — `slug_or_url` (örn. `...-p-320230` veya tam sokmarket.com.tr URL'si) |
28
+ | `sok_list_categories` | Mağaza kategorileri (ad, slug, id, url) |
29
+ | `sok_category_products` | Bir kategorideki ürünler — `category` slug veya URL, `limit` (1..30) |
30
+ | `sok_list_promotions` | Güncel kampanya grupları (`/kampanyalar`) |
31
+ | `sok_price_stats` | Bir aramanın fiyat istatistikleri — adet, min, max, medyan, ortalama (TRY) |
32
+
33
+ Örnek (`sok_search_products` / "süt"): 207 ürün; ilk sonuç "Mis Uht Süt Yarım Yağlı 1 L" — 43,50₺. Örnek (`sok_price_stats` / "süt"): ilk sayfada 10 ürün, min 16,50₺, max 88,50₺, medyan 55,75₺.
34
+
35
+ Notlar:
36
+ - Fiyatlar Türk Lirası (TRY); site Türkçe yazar (`43,50₺`).
37
+ - Arama/kategori sayfaları ilk sayfayı döner (~20 ürün); `total` tüm eşleşmeleri bildirir.
38
+ - Veri: sokmarket.com.tr herkese açık sayfalar — giriş yok, API anahtarı yok, salt-okunur.
39
+
40
+ ## Kurallar
41
+
42
+ Gayriresmî ve salt-okunur — giriş yok, API anahtarı yok, yazma yok. Testler: çevrimdışı fixture + canlı testler (15/15 yeşil).
@@ -0,0 +1,70 @@
1
+ # sok-mcp 🇹🇷
2
+
3
+ mcp-name: io.github.mohammad-abbasi-sh/sok-mcp
4
+
5
+ Unofficial **read-only** MCP server for **ŞOK** (sokmarket.com.tr) — one of Türkiye's big discount grocery chains: product search, product details, categories, campaign groups and price statistics.
6
+
7
+ > Part of the "Turkey MCP factory" (Sepehr-style pipeline). Not affiliated with ŞOK.
8
+
9
+ ## For AI agents
10
+
11
+ Add to your MCP client config (stdio):
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "sok": {
17
+ "command": "uvx",
18
+ "args": ["sok-mcp"]
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ Or run locally: `python -m sok_mcp.server` (needs `mcp<2`, `curl-cffi`, `requests`).
25
+
26
+ ## Tools
27
+
28
+ | Tool | Description |
29
+ |------|-------------|
30
+ | `sok_search_products` | Search products by name — `query`, `limit` (1..30, first page only) |
31
+ | `sok_get_product` | Product detail — `slug_or_url` (e.g. `...-p-320230` or a full sokmarket.com.tr URL) |
32
+ | `sok_list_categories` | Store categories (name, slug, id, url) |
33
+ | `sok_category_products` | Products in one category — `category` slug or URL, `limit` (1..30) |
34
+ | `sok_list_promotions` | Current campaign groups from `/kampanyalar` |
35
+ | `sok_price_stats` | Price stats for a search — count, min, max, median, mean in TRY |
36
+
37
+ Example output (`sok_search_products` / "süt"):
38
+
39
+ ```json
40
+ {"query": "süt", "total": 207,
41
+ "products": [{"title": "Mis Uht Süt Yarım Yağlı 1 L", "id": "5834",
42
+ "url": "https://www.sokmarket.com.tr/mis-uht-sut-yarim-yagli-1-l-p-5834",
43
+ "price_try": 43.5}]}
44
+ ```
45
+
46
+ Example output (`sok_price_stats` / "süt", first page of 10):
47
+
48
+ ```json
49
+ {"query": "süt", "total_matches": 207, "count": 10,
50
+ "min_try": 16.5, "max_try": 88.5, "median_try": 55.75, "mean_try": 52.05}
51
+ ```
52
+
53
+ Notes:
54
+ - Prices are Turkish Lira (TRY); the site writes them Turkish-style (`43,50₺`).
55
+ - Search/category pages return the first page (~20 products); `total` reports all matches.
56
+ - Data: sokmarket.com.tr public server-rendered pages — no key, no login, read-only.
57
+
58
+ ## Development
59
+
60
+ ```bash
61
+ python3 -m venv .venv && .venv/bin/pip install "mcp<2" curl-cffi requests pytest
62
+ .venv/bin/python -m pytest tests/ -q
63
+ ```
64
+
65
+ - `tests/test_products.py` — offline, parses real captured fixtures
66
+ - `tests/test_live.py` — 5 cautious live requests against sokmarket.com.tr
67
+
68
+ ## License
69
+
70
+ MIT
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sok-mcp"
7
+ version = "0.1.0"
8
+ description = "Unofficial read-only MCP server for ŞOK grocery (sokmarket.com.tr) products, categories, campaigns, prices"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "mcp<2",
14
+ "curl-cffi>=0.7",
15
+ "requests>=2.32",
16
+ ]
17
+ authors = [{ name = "Mohammad Abbasi" }]
18
+
19
+ [project.scripts]
20
+ sok-mcp = "sok_mcp.server:main"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/sok_mcp"]
@@ -0,0 +1 @@
1
+ {"$schema":"https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json","description":"Unofficial read-only MCP server for ŞOK grocery — products, categories, campaigns, prices.","name":"io.github.mohammad-abbasi-sh/sok-mcp","packages":[{"identifier":"sok-mcp","registryBaseUrl":"https://pypi.org","registryType":"pypi","transport":{"type":"stdio"},"version":"0.1.0"}],"repository":{"source":"github","url":"https://github.com/mohammad-abbasi-sh/sok-mcp"},"title":"ŞOK Grocery","version":"0.1.0","websiteUrl":"https://github.com/mohammad-abbasi-sh/sok-mcp"}
@@ -0,0 +1,2 @@
1
+ """sok_mcp — unofficial read-only MCP server for ŞOK grocery (sokmarket.com.tr)."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,93 @@
1
+ """HTTP layer for sok_mcp.
2
+
3
+ sokmarket.com.tr is a Next.js storefront that serves its search,
4
+ category and product pages fully server-rendered with no login and
5
+ no API key. Transport uses curl-cffi chrome120 impersonation (the
6
+ proven Turkey-factory pattern) with a plain-requests fallback.
7
+ Requests are kept gentle (one at a time, ~0.8s apart) and read-only.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from urllib.parse import quote
13
+
14
+ from curl_cffi import requests as _creq
15
+ import requests as _plain
16
+
17
+ BASE = "https://www.sokmarket.com.tr"
18
+ TIMEOUT = 30.0
19
+ MIN_INTERVAL = 0.8 # seconds between requests — be polite
20
+
21
+ _HEADERS = {
22
+ "Accept-Language": "tr-TR,tr;q=0.9,en;q=0.8",
23
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
24
+ }
25
+
26
+ _UA = (
27
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
28
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
29
+ )
30
+
31
+ _last_fetch = 0.0
32
+
33
+
34
+ class SokError(RuntimeError):
35
+ pass
36
+
37
+
38
+ def _throttle() -> None:
39
+ global _last_fetch
40
+ wait = MIN_INTERVAL - (time.monotonic() - _last_fetch)
41
+ if wait > 0:
42
+ time.sleep(wait)
43
+ _last_fetch = time.monotonic()
44
+
45
+
46
+ def _get(url: str) -> str:
47
+ _throttle()
48
+ try:
49
+ resp = _creq.get(url, impersonate="chrome120", headers=_HEADERS, timeout=TIMEOUT)
50
+ status, text = resp.status_code, resp.text
51
+ except Exception:
52
+ # Fallback transport: plain requests with a browser UA.
53
+ try:
54
+ resp = _plain.get(url, headers={**_HEADERS, "User-Agent": _UA}, timeout=TIMEOUT)
55
+ status, text = resp.status_code, resp.text
56
+ except Exception as e:
57
+ raise SokError(f"network error fetching {url}: {e}") from e
58
+ if status == 403:
59
+ raise SokError(
60
+ "sokmarket.com.tr refused the request (HTTP 403, bot wall) — try again later"
61
+ )
62
+ if status == 429:
63
+ raise SokError(
64
+ "sokmarket.com.tr rate-limited the request (HTTP 429) — try again later"
65
+ )
66
+ if status == 404:
67
+ raise SokError(f"sokmarket.com.tr has no page at {url} (HTTP 404)")
68
+ if status != 200:
69
+ raise SokError(f"sokmarket.com.tr returned HTTP {status} for {url}")
70
+ return text
71
+
72
+
73
+ def fetch_search(query: str) -> str:
74
+ return _get(f"{BASE}/arama?q={quote(query)}")
75
+
76
+
77
+ def fetch_product(path: str) -> str:
78
+ path = path.strip()
79
+ if path.startswith("http"):
80
+ return _get(path)
81
+ return _get(f"{BASE}/{path.lstrip('/')}")
82
+
83
+
84
+ def fetch_home() -> str:
85
+ return _get(f"{BASE}/")
86
+
87
+
88
+ def fetch_category(path: str) -> str:
89
+ return _get(f"{BASE}/{path.strip().lstrip('/')}")
90
+
91
+
92
+ def fetch_campaigns() -> str:
93
+ return _get(f"{BASE}/kampanyalar")
@@ -0,0 +1,156 @@
1
+ """Domain logic: parse ŞOK (sokmarket.com.tr) pages into compact dicts.
2
+
3
+ Data sources (all public, no login, no key):
4
+ - search: /arama?q=<query> — server-rendered product cards
5
+ ("207 adet ürün listelendi"), title + price per card
6
+ - product: /<slug>-p-<id> — h1 title, price, related products
7
+ - category: /<slug>-c-<id> — same product-card grid as search
8
+ - home: / — 19 categories (-c- links) + promo groups (-pgrp-)
9
+ - promos: /kampanyalar — campaign groups (-pgrp- links)
10
+ Prices are Turkish format ("43,50₺", thousands "1.234,56₺").
11
+ Stdlib parsing only (re / html).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import html as _html
16
+ import re
17
+ import statistics
18
+
19
+ BASE = "https://www.sokmarket.com.tr"
20
+
21
+ _CARD_TITLE = "CProductCard-module_title__u8bMW"
22
+
23
+
24
+ def _clean(text: str | None) -> str:
25
+ if not text:
26
+ return ""
27
+ return re.sub(r"\s+", " ", _html.unescape(str(text))).strip()
28
+
29
+
30
+ def parse_price(text: str | None) -> float | None:
31
+ """'43,50' / '1.234,56' (Turkish) -> 43.5 / 1234.56."""
32
+ if not text:
33
+ return None
34
+ s = text.strip().replace("₺", "").replace("\xa0", "").strip()
35
+ s = s.replace(".", "").replace(",", ".")
36
+ try:
37
+ return float(s)
38
+ except ValueError:
39
+ return None
40
+
41
+
42
+ def _price_in(block: str) -> float | None:
43
+ m = re.search(
44
+ r'data-testid="(?:discountedPrice|price)">([\d.,]+)<!-- -->\s*₺', block
45
+ )
46
+ return parse_price(m.group(1)) if m else None
47
+
48
+
49
+ def parse_product_cards(html: str) -> dict:
50
+ """Product-card grid -> {total, products[{title, slug, id, url, price_try}]}.
51
+
52
+ total comes from the "N adet ürün listelendi" line when present.
53
+ """
54
+ total = None
55
+ m = re.search(r"(\d+)(?:<!-- -->)?\s*adet ürün", html)
56
+ if m:
57
+ total = int(m.group(1))
58
+ products, seen = [], set()
59
+ # Each card is an <a href="/<slug>-p-<id>"> wrapping title + price.
60
+ for link in re.finditer(r'<a href="(/[^"]+-p-(\d+))">', html):
61
+ href, pid = link.group(1), link.group(2)
62
+ if pid in seen:
63
+ continue
64
+ block = html[link.start(): link.start() + 4000]
65
+ t = re.search(
66
+ rf'<h2 class="{re.escape(_CARD_TITLE)}">(.*?)</h2>', block, re.S
67
+ )
68
+ if not t:
69
+ continue
70
+ seen.add(pid)
71
+ products.append(
72
+ {
73
+ "title": _clean(t.group(1)),
74
+ "slug": href.strip("/").rsplit("-p-", 1)[0],
75
+ "id": pid,
76
+ "url": f"{BASE}{href}",
77
+ "price_try": _price_in(block),
78
+ }
79
+ )
80
+ return {"total": total if total is not None else len(products), "products": products}
81
+
82
+
83
+ def parse_product_page(html: str) -> dict:
84
+ """Product detail page: h1 title, price, related products."""
85
+ h1 = re.search(r"<h1[^>]*>(.*?)</h1>", html, re.S)
86
+ title = _clean(re.sub(r"<[^>]+>", "", h1.group(1))) if h1 else ""
87
+ if not title:
88
+ raise ValueError("no product title found on this page")
89
+ related = [
90
+ _clean(t)
91
+ for t in re.findall(
92
+ rf'<h2 class="{re.escape(_CARD_TITLE)}">(.*?)</h2>', html, re.S
93
+ )
94
+ ]
95
+ return {
96
+ "title": title,
97
+ "price_try": _price_in(html),
98
+ "related_products": related,
99
+ }
100
+
101
+
102
+ def parse_categories(html: str) -> list[dict]:
103
+ """Homepage category links: /<slug>-c-<id> with Turkish link text."""
104
+ out, seen = [], set()
105
+ for href, _cid, inner in re.findall(
106
+ r'<a[^>]+href="(/[^"]+-c-(\d+))"[^>]*>(.*?)</a>', html, re.S
107
+ ):
108
+ name = _clean(re.sub(r"<[^>]+>", "", inner))
109
+ if not name or href in seen:
110
+ continue
111
+ seen.add(href)
112
+ out.append(
113
+ {
114
+ "name": name,
115
+ "slug": href.strip("/").rsplit("-c-", 1)[0],
116
+ "id": href.rsplit("-c-", 1)[-1],
117
+ "url": f"{BASE}{href}",
118
+ }
119
+ )
120
+ return out
121
+
122
+
123
+ def parse_promotions(html: str) -> list[dict]:
124
+ """Campaign groups: /<slug>-pgrp-<uuid> links with link text."""
125
+ out, seen = [], set()
126
+ for href, inner in re.findall(
127
+ r'<a[^>]+href="(/[^"]+-pgrp-[^"]+)"[^>]*>(.*?)</a>', html, re.S
128
+ ):
129
+ name = _clean(re.sub(r"<[^>]+>", "", inner))
130
+ if not name or href in seen:
131
+ continue
132
+ seen.add(href)
133
+ out.append({"name": name, "slug": href.strip("/").rsplit("-pgrp-", 1)[0], "url": f"{BASE}{href}"})
134
+ # The /kampanyalar page also links promos without inner text in some
135
+ # spots; fall back to bare hrefs with a slug-derived name.
136
+ if not out:
137
+ for href in sorted(set(re.findall(r'href="(/[^"]+-pgrp-[^"]+)"', html))):
138
+ slug = href.strip("/").rsplit("-pgrp-", 1)[0]
139
+ out.append(
140
+ {"name": slug.replace("-", " "), "slug": slug, "url": f"{BASE}{href}"}
141
+ )
142
+ return out
143
+
144
+
145
+ def price_stats(products: list[dict]) -> dict:
146
+ """min / max / median / mean over products that have a price."""
147
+ prices = sorted(p["price_try"] for p in products if p.get("price_try") is not None)
148
+ if not prices:
149
+ return {"count": 0, "min_try": None, "max_try": None, "median_try": None, "mean_try": None}
150
+ return {
151
+ "count": len(prices),
152
+ "min_try": prices[0],
153
+ "max_try": prices[-1],
154
+ "median_try": statistics.median(prices),
155
+ "mean_try": round(statistics.fmean(prices), 2),
156
+ }
@@ -0,0 +1,130 @@
1
+ """MCP server: unofficial read-only access to ŞOK grocery (sokmarket.com.tr)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ from mcp.server.fastmcp import FastMCP
7
+
8
+ from . import http as _http
9
+ from . import products as _P
10
+
11
+ mcp = FastMCP("sok-mcp")
12
+
13
+ INSTRUCTIONS = """\
14
+ Unofficial read-only MCP server for ŞOK (sokmarket.com.tr), one of \
15
+ Türkiye's big discount grocery chains — product search, product \
16
+ details, categories, campaign groups and price statistics. All \
17
+ pages are server-rendered Next.js with no login and no API key; \
18
+ prices are in Turkish Lira (TRY, Turkish format "43,50₺"). \
19
+ Search/category pages list the first page of results (~20 \
20
+ products); the `total` field reports how many products matched \
21
+ overall. Read-only; transport uses Chrome TLS impersonation with \
22
+ a plain-requests fallback; requests are spaced ~0.8s apart. Built \
23
+ by Mohammad Abbasi (mohammad-abbasi-sh) — Turkey MCP factory.\
24
+ """
25
+
26
+
27
+ def _err(e: Exception) -> str:
28
+ return json.dumps({"error": str(e)}, ensure_ascii=False)
29
+
30
+
31
+ def _validate_limit(limit: int, lo: int = 1, hi: int = 30) -> int:
32
+ if not lo <= limit <= hi:
33
+ raise ValueError(f"limit must be between {lo} and {hi}")
34
+ return limit
35
+
36
+
37
+ @mcp.tool()
38
+ def sok_search_products(query: str, limit: int = 20) -> str:
39
+ """Search ŞOK products by name.
40
+ query: e.g. "süt", "çay", "peynir". limit 1..30 (first page only;
41
+ `total` reports all matches). Returns title, slug, id, url and
42
+ price in TRY per product."""
43
+ try:
44
+ if not query or not query.strip():
45
+ raise ValueError("query must not be empty")
46
+ _validate_limit(limit)
47
+ res = _P.parse_product_cards(_http.fetch_search(query.strip()))
48
+ except (ValueError, _http.SokError) as e:
49
+ return _err(e)
50
+ res["products"] = res["products"][:limit]
51
+ return json.dumps({"query": query.strip(), **res}, ensure_ascii=False)
52
+
53
+
54
+ @mcp.tool()
55
+ def sok_get_product(slug_or_url: str) -> str:
56
+ """Product detail: title, price in TRY, related products.
57
+ slug_or_url: e.g. "ahmad-tea-classic-earl-grey-demlik-poset-40-li-p-320230"
58
+ or a full sokmarket.com.tr product URL."""
59
+ try:
60
+ s = slug_or_url.strip()
61
+ if not s:
62
+ raise ValueError("slug_or_url must not be empty")
63
+ page = _P.parse_product_page(_http.fetch_product(s))
64
+ except (ValueError, _http.SokError) as e:
65
+ return _err(e)
66
+ return json.dumps({"url": slug_or_url.strip(), **page}, ensure_ascii=False)
67
+
68
+
69
+ @mcp.tool()
70
+ def sok_list_categories() -> str:
71
+ """ŞOK store categories (name, slug, id, url) from the homepage."""
72
+ try:
73
+ cats = _P.parse_categories(_http.fetch_home())
74
+ except (ValueError, _http.SokError) as e:
75
+ return _err(e)
76
+ return json.dumps({"count": len(cats), "categories": cats}, ensure_ascii=False)
77
+
78
+
79
+ @mcp.tool()
80
+ def sok_category_products(category: str, limit: int = 20) -> str:
81
+ """Products in one ŞOK category.
82
+ category: slug like "sut-ve-sut-urunleri-c-460" or a full
83
+ sokmarket.com.tr category URL. limit 1..30 (first page only)."""
84
+ try:
85
+ if not category or not category.strip():
86
+ raise ValueError("category must not be empty")
87
+ _validate_limit(limit)
88
+ res = _P.parse_product_cards(_http.fetch_category(category.strip()))
89
+ except (ValueError, _http.SokError) as e:
90
+ return _err(e)
91
+ res["products"] = res["products"][:limit]
92
+ return json.dumps({"category": category.strip(), **res}, ensure_ascii=False)
93
+
94
+
95
+ @mcp.tool()
96
+ def sok_list_promotions() -> str:
97
+ """Current ŞOK campaign groups from /kampanyalar
98
+ (name, slug, url — each links to its discounted products)."""
99
+ try:
100
+ promos = _P.parse_promotions(_http.fetch_campaigns())
101
+ except (ValueError, _http.SokError) as e:
102
+ return _err(e)
103
+ return json.dumps({"count": len(promos), "promotions": promos}, ensure_ascii=False)
104
+
105
+
106
+ @mcp.tool()
107
+ def sok_price_stats(query: str) -> str:
108
+ """Price statistics for a search: count, min, max, median, mean in
109
+ TRY over the first results page. query: e.g. "süt"."""
110
+ try:
111
+ if not query or not query.strip():
112
+ raise ValueError("query must not be empty")
113
+ res = _P.parse_product_cards(_http.fetch_search(query.strip()))
114
+ stats = _P.price_stats(res["products"])
115
+ except (ValueError, _http.SokError) as e:
116
+ return _err(e)
117
+ return json.dumps(
118
+ {"query": query.strip(), "total_matches": res["total"], **stats},
119
+ ensure_ascii=False,
120
+ )
121
+
122
+
123
+ def main() -> None:
124
+ mcp.run()
125
+
126
+
127
+ if __name__ == "__main__":
128
+ import sys
129
+
130
+ sys.exit(main())
File without changes
@@ -0,0 +1,23 @@
1
+ <html><body>
2
+ <a href="/yemeklik-malzemeler-c-1770">Yemeklik Malzemeler</a>
3
+ <a href="/et-ve-tavuk-ve-sarkuteri-c-160">Et &amp; Tavuk &amp; Şarküteri</a>
4
+ <a href="/meyve-ve-sebze-c-20">Meyve &amp; Sebze</a>
5
+ <a href="/sut-ve-sut-urunleri-c-460">Süt &amp; Süt Ürünleri</a>
6
+ <a href="/kahvaltilik-c-890">Kahvaltılık</a>
7
+ <a href="/atistirmaliklar-c-20376">Atıştırmalık</a>
8
+ <a href="/icecek-c-20505">İçecek</a>
9
+ <a href="/ekmek-ve-pastane-c-1250">Ekmek &amp; Pastane</a>
10
+ <a href="/dondurulmus-urunler-c-1550">Dondurulmuş Ürünler</a>
11
+ <a href="/dondurma-c-31102">Dondurma</a>
12
+ <a href="/temizlik-c-20647">Temizlik</a>
13
+ <a href="/kagit-urunler-c-20875">Kağıt Ürünleri</a>
14
+ <a href="/kisisel-bakim-ve-kozmetik-c-20395">Kişisel Bakım &amp; Kozmetik</a>
15
+ <a href="/anne-bebek-ve-cocuk-c-20634">Anne - Bebek &amp; Çocuk</a>
16
+ <a href="/oyuncak-c-20644">Oyuncak</a>
17
+ <a href="/ev-ve-yasam-c-20898">Ev &amp; Yaşam</a>
18
+ <a href="/evcil-dostlar-c-20880">Evcil Dostlar</a>
19
+ <a href="/giyim-ve-ayakkabi-ve-aksesuar-c-20886">Giyim &amp; Aksesuar</a>
20
+ <a href="/elektronik-c-22769">Elektronik</a>
21
+ <a href="/win-kazandiran-urunler-pgrp-f353cf31-f728-425e-a453-5774219a76b8">Kazandırır Alışverişin</a>
22
+ <a href="/50-tl-ve-uzeri-indirimli-urunler-pgrp-11d42a6b-df28-4fe6-b1a3-7ad6b8d7f9a0">Kasa Arkası Ürünler</a>
23
+ </body></html>
@@ -0,0 +1,4 @@
1
+ <html><head><title>Kampanyalar</title></head><body>
2
+ <a href="/win-kazandiran-urunler-pgrp-f353cf31-f728-425e-a453-5774219a76b8">Kazandırır Alışverişin</a>
3
+ <a href="/50-tl-ve-uzeri-indirimli-urunler-pgrp-11d42a6b-df28-4fe6-b1a3-7ad6b8d7f9a0">Kasa Arkası Ürünler</a>
4
+ </body></html>
@@ -0,0 +1,10 @@
1
+ <html><head><title>Ahmad Tea Classic Earl Grey Demlik Poşet 40&#x27;lı - Cepte Şok</title></head><body>
2
+ <h1 class="ProductMainInfoArea_productName__bKRVD">Ahmad Tea Classic Earl Grey Demlik Poşet 40&#x27;lı</h1>
3
+ <span style="font-size:22px;line-height:20px;font-weight:800" class="CPriceBox-module_price__bYk-c" data-testid="discountedPrice">149,00<!-- -->₺</span>
4
+
5
+ <h2>Deren Premium Çay 1000 g</h2>
6
+ <h2>Deren Harman Çay 1000 g</h2>
7
+ <h2>Deren Rize Çay 1000 g</h2>
8
+ <h2>Deren Filiz Çay 500 g</h2>
9
+ <h2>Deren Bergamot Aromalı Çay 500 g</h2>
10
+ </body></html>
@@ -0,0 +1,13 @@
1
+ <html><head><title>Cepte Şok</title></head><body>
2
+ <p>207<!-- --> adet ürün listelendi</p>
3
+ <a href="/mis-uht-sut-yarim-yagli-1-l-p-5834"><div class="CProductCard-module_productCardWrapper__okAmT CProductCard-module_PLPCard__rB4tw"><div class="CQuantityChangerCapsule-module_buttonWrapper__Bed1d CQuantityChangerCapsule-module_singleButtonWrapper__oGhYN CQuantityChangerCapsule-module_small__2YPFl" style="position:absolute;right:8px;top:8px"><button class="CQuantityChangerCapsule-module_plusButton__ABlui CQuantityChangerCapsule-module_small__2YPFl" type="button" style="background-color:#e6eef6;border:1px solid #cddcec"><svg width="14" height="14" viewBox="0 0 12 12" fill="#0551a0" xmlns="http://www.w3.org/2000/svg"><path d="M10.9286 4.92857H7.28571C7.16738 4.92857 7.07143 4.83262 7.07143 4.71429V1.07143C7.07143 0.479736 6.59169 0 6 0C5.40831 0 4.92857 0.479736 4.92857 1.07143V4.71429C4.92857 4.83262 4.83262 4.92857 4.71429 4.92857H1.07143C0.479736 4.92857 0 5.40831 0 6C0 6.59169 0.479736 7.07143 1.07143 7.07143H4.71429C4.83262 7.07143 4.92857 7.16738 4.92857 7.28571V10.9286C4.92857 11.5203 5.40831 12 6 12C6.59169 12 7.07143 11.5203 7.07143 10.9286V7.28571C7.07143 7.16738 7.16738 7.07143 7.28571 7.07143H10.9286C11.5203 7.07143 12 6.59169 12 6C12 5.40831 11.5203 4.92857 10.9286 4.92857Z" fill="#0551a0"></path></svg></button></div><div class="CProductCard-module_productCardContainer__mwUl7"><div class="CProductCard-module_containerTop__35Ma3"><div class="CProductCard-module_imageContainer__aTMdz"><img src="https://images.ceptesok.com/cdn-cgi/image/width=216,height=216,fit=pad,quality=80,format=webp/product-assets/sub-folder/20b8a4b2-4a67-4bb0-a863-e55df8db3631.jpg" alt="product-thumb" style="width:108px;height:108px"/></div><div class="CProductCard-module_infoContainer__F8uxY"><h2 class="CProductCard-module_title__u8bMW">Mis Uht Süt Yarım Yağlı 1 L</h2><div class="CPriceBox-module_cPriceBox__1OWBR CPriceBox-module_productCardPrice__yjBVO"><div class="CPriceBox-module_priceContainer__ZROpc" style="height:26px"><span style="font-size:14px;line-height:16px;font-weight:800" class="CPriceBox-module_price__bYk-c" data-testid="discountedPrice">43,50<!-- -->₺</span></div></div></div></div></div></div></a></div></div>
4
+ <a href="/icim-organik-sut-1-l-p-7796"><div class="CProductCard-module_productCardWrapper__okAmT CProductCard-module_PLPCard__rB4tw"><div class="CQuantityChangerCapsule-module_buttonWrapper__Bed1d CQuantityChangerCapsule-module_singleButtonWrapper__oGhYN CQuantityChangerCapsule-module_small__2YPFl" style="position:absolute;right:8px;top:8px"><button class="CQuantityChangerCapsule-module_plusButton__ABlui CQuantityChangerCapsule-module_small__2YPFl" type="button" style="background-color:#e6eef6;border:1px solid #cddcec"><svg width="14" height="14" viewBox="0 0 12 12" fill="#0551a0" xmlns="http://www.w3.org/2000/svg"><path d="M10.9286 4.92857H7.28571C7.16738 4.92857 7.07143 4.83262 7.07143 4.71429V1.07143C7.07143 0.479736 6.59169 0 6 0C5.40831 0 4.92857 0.479736 4.92857 1.07143V4.71429C4.92857 4.83262 4.83262 4.92857 4.71429 4.92857H1.07143C0.479736 4.92857 0 5.40831 0 6C0 6.59169 0.479736 7.07143 1.07143 7.07143H4.71429C4.83262 7.07143 4.92857 7.16738 4.92857 7.28571V10.9286C4.92857 11.5203 5.40831 12 6 12C6.59169 12 7.07143 11.5203 7.07143 10.9286V7.28571C7.07143 7.16738 7.16738 7.07143 7.28571 7.07143H10.9286C11.5203 7.07143 12 6.59169 12 6C12 5.40831 11.5203 4.92857 10.9286 4.92857Z" fill="#0551a0"></path></svg></button></div><div class="CProductCard-module_productCardContainer__mwUl7"><div class="CProductCard-module_containerTop__35Ma3"><div class="CProductCard-module_imageContainer__aTMdz"><img src="https://images.ceptesok.com/cdn-cgi/image/width=216,height=216,fit=pad,quality=80,format=webp/product-assets/sub-folder/642caf4f-b52d-493f-9813-f5d533f778e5.png" alt="product-thumb" style="width:108px;height:108px"/></div><div class="CProductCard-module_infoContainer__F8uxY"><h2 class="CProductCard-module_title__u8bMW">İçim Organik Süt 1 L</h2><div class="CPriceBox-module_cPriceBox__1OWBR CPriceBox-module_productCardPrice__yjBVO"><div class="CPriceBox-module_priceContainer__ZROpc" style="height:26px"><span style="font-size:14px;line-height:16px;font-weight:800" class="CPriceBox-module_price__bYk-c" data-testid="discountedPrice">88,50<!-- -->₺</span></div></div></div></div></div></div></a></div></div>
5
+ <a href="/mis-uht-sut-yagsiz-1-l-p-5179"><div class="CProductCard-module_productCardWrapper__okAmT CProductCard-module_PLPCard__rB4tw"><div class="CQuantityChangerCapsule-module_buttonWrapper__Bed1d CQuantityChangerCapsule-module_singleButtonWrapper__oGhYN CQuantityChangerCapsule-module_small__2YPFl" style="position:absolute;right:8px;top:8px"><button class="CQuantityChangerCapsule-module_plusButton__ABlui CQuantityChangerCapsule-module_small__2YPFl" type="button" style="background-color:#e6eef6;border:1px solid #cddcec"><svg width="14" height="14" viewBox="0 0 12 12" fill="#0551a0" xmlns="http://www.w3.org/2000/svg"><path d="M10.9286 4.92857H7.28571C7.16738 4.92857 7.07143 4.83262 7.07143 4.71429V1.07143C7.07143 0.479736 6.59169 0 6 0C5.40831 0 4.92857 0.479736 4.92857 1.07143V4.71429C4.92857 4.83262 4.83262 4.92857 4.71429 4.92857H1.07143C0.479736 4.92857 0 5.40831 0 6C0 6.59169 0.479736 7.07143 1.07143 7.07143H4.71429C4.83262 7.07143 4.92857 7.16738 4.92857 7.28571V10.9286C4.92857 11.5203 5.40831 12 6 12C6.59169 12 7.07143 11.5203 7.07143 10.9286V7.28571C7.07143 7.16738 7.16738 7.07143 7.28571 7.07143H10.9286C11.5203 7.07143 12 6.59169 12 6C12 5.40831 11.5203 4.92857 10.9286 4.92857Z" fill="#0551a0"></path></svg></button></div><div class="CProductCard-module_productCardContainer__mwUl7"><div class="CProductCard-module_containerTop__35Ma3"><div class="CProductCard-module_imageContainer__aTMdz"><img src="https://images.ceptesok.com/cdn-cgi/image/width=216,height=216,fit=pad,quality=80,format=webp/product-assets/sub-folder/c87d0ff9-d53f-4956-8def-4099f523abea.jpg" alt="product-thumb" style="width:108px;height:108px"/></div><div class="CProductCard-module_infoContainer__F8uxY"><h2 class="CProductCard-module_title__u8bMW">Mis Uht Süt Yağsız 1 L</h2><div class="CPriceBox-module_cPriceBox__1OWBR CPriceBox-module_productCardPrice__yjBVO"><div class="CPriceBox-module_priceContainer__ZROpc" style="height:26px"><span style="font-size:14px;line-height:16px;font-weight:800" class="CPriceBox-module_price__bYk-c" data-testid="discountedPrice">36,00<!-- -->₺</span></div></div></div></div></div></div></a></div></div>
6
+ <a href="/mis-uht-sut-3-1-yagli-1-l-p-8533"><div class="CProductCard-module_productCardWrapper__okAmT CProductCard-module_PLPCard__rB4tw"><div class="CQuantityChangerCapsule-module_buttonWrapper__Bed1d CQuantityChangerCapsule-module_singleButtonWrapper__oGhYN CQuantityChangerCapsule-module_small__2YPFl" style="position:absolute;right:8px;top:8px"><button class="CQuantityChangerCapsule-module_plusButton__ABlui CQuantityChangerCapsule-module_small__2YPFl" type="button" style="background-color:#e6eef6;border:1px solid #cddcec"><svg width="14" height="14" viewBox="0 0 12 12" fill="#0551a0" xmlns="http://www.w3.org/2000/svg"><path d="M10.9286 4.92857H7.28571C7.16738 4.92857 7.07143 4.83262 7.07143 4.71429V1.07143C7.07143 0.479736 6.59169 0 6 0C5.40831 0 4.92857 0.479736 4.92857 1.07143V4.71429C4.92857 4.83262 4.83262 4.92857 4.71429 4.92857H1.07143C0.479736 4.92857 0 5.40831 0 6C0 6.59169 0.479736 7.07143 1.07143 7.07143H4.71429C4.83262 7.07143 4.92857 7.16738 4.92857 7.28571V10.9286C4.92857 11.5203 5.40831 12 6 12C6.59169 12 7.07143 11.5203 7.07143 10.9286V7.28571C7.07143 7.16738 7.16738 7.07143 7.28571 7.07143H10.9286C11.5203 7.07143 12 6.59169 12 6C12 5.40831 11.5203 4.92857 10.9286 4.92857Z" fill="#0551a0"></path></svg></button></div><div class="CProductCard-module_productCardContainer__mwUl7"><div class="CProductCard-module_containerTop__35Ma3"><div class="CProductCard-module_imageContainer__aTMdz"><img src="https://images.ceptesok.com/cdn-cgi/image/width=216,height=216,fit=pad,quality=80,format=webp/product-assets/sub-folder/691ac110-fa10-46fe-a58a-9126d2c09c64.jpg" alt="product-thumb" style="width:108px;height:108px"/></div><div class="CProductCard-module_infoContainer__F8uxY"><h2 class="CProductCard-module_title__u8bMW">Mis Uht Süt %3.1 Yağlı 1 L</h2><div class="CPriceBox-module_cPriceBox__1OWBR CPriceBox-module_productCardPrice__yjBVO"><div class="CPriceBox-module_priceContainer__ZROpc" style="height:26px"><span style="font-size:14px;line-height:16px;font-weight:800" class="CPriceBox-module_price__bYk-c" data-testid="discountedPrice">52,50<!-- -->₺</span></div></div></div></div></div></div></a></div></div>
7
+ <a href="/mis-bakraclik-sut-tam-yagli-1-l-p-7501"><div class="CProductCard-module_productCardWrapper__okAmT CProductCard-module_PLPCard__rB4tw"><div class="CQuantityChangerCapsule-module_buttonWrapper__Bed1d CQuantityChangerCapsule-module_singleButtonWrapper__oGhYN CQuantityChangerCapsule-module_small__2YPFl" style="position:absolute;right:8px;top:8px"><button class="CQuantityChangerCapsule-module_plusButton__ABlui CQuantityChangerCapsule-module_small__2YPFl" type="button" style="background-color:#e6eef6;border:1px solid #cddcec"><svg width="14" height="14" viewBox="0 0 12 12" fill="#0551a0" xmlns="http://www.w3.org/2000/svg"><path d="M10.9286 4.92857H7.28571C7.16738 4.92857 7.07143 4.83262 7.07143 4.71429V1.07143C7.07143 0.479736 6.59169 0 6 0C5.40831 0 4.92857 0.479736 4.92857 1.07143V4.71429C4.92857 4.83262 4.83262 4.92857 4.71429 4.92857H1.07143C0.479736 4.92857 0 5.40831 0 6C0 6.59169 0.479736 7.07143 1.07143 7.07143H4.71429C4.83262 7.07143 4.92857 7.16738 4.92857 7.28571V10.9286C4.92857 11.5203 5.40831 12 6 12C6.59169 12 7.07143 11.5203 7.07143 10.9286V7.28571C7.07143 7.16738 7.16738 7.07143 7.28571 7.07143H10.9286C11.5203 7.07143 12 6.59169 12 6C12 5.40831 11.5203 4.92857 10.9286 4.92857Z" fill="#0551a0"></path></svg></button></div><div class="CProductCard-module_productCardContainer__mwUl7"><div class="CProductCard-module_containerTop__35Ma3"><div class="CProductCard-module_imageContainer__aTMdz"><img src="https://images.ceptesok.com/cdn-cgi/image/width=216,height=216,fit=pad,quality=80,format=webp/product-assets/sub-folder/ddb7fb6c-e722-4e47-a34b-c925ec43a74f.png" alt="product-thumb" style="width:108px;height:108px"/></div><div class="CProductCard-module_infoContainer__F8uxY"><h2 class="CProductCard-module_title__u8bMW">Mis Bakraçlık Süt Tam Yağlı 1 L</h2><div class="CPriceBox-module_cPriceBox__1OWBR CPriceBox-module_productCardPrice__yjBVO"><div class="CPriceBox-module_priceContainer__ZROpc" style="height:26px"><span style="font-size:14px;line-height:16px;font-weight:800" class="CPriceBox-module_price__bYk-c" data-testid="discountedPrice">64,00<!-- -->₺</span></div></div></div></div></div></div></a></div></div>
8
+ <a href="/mis-pastorize-dogal-icme-sutu-1-l-p-6044"><div class="CProductCard-module_productCardWrapper__okAmT CProductCard-module_PLPCard__rB4tw"><div class="CQuantityChangerCapsule-module_buttonWrapper__Bed1d CQuantityChangerCapsule-module_singleButtonWrapper__oGhYN CQuantityChangerCapsule-module_small__2YPFl" style="position:absolute;right:8px;top:8px"><button class="CQuantityChangerCapsule-module_plusButton__ABlui CQuantityChangerCapsule-module_small__2YPFl" type="button" style="background-color:#e6eef6;border:1px solid #cddcec"><svg width="14" height="14" viewBox="0 0 12 12" fill="#0551a0" xmlns="http://www.w3.org/2000/svg"><path d="M10.9286 4.92857H7.28571C7.16738 4.92857 7.07143 4.83262 7.07143 4.71429V1.07143C7.07143 0.479736 6.59169 0 6 0C5.40831 0 4.92857 0.479736 4.92857 1.07143V4.71429C4.92857 4.83262 4.83262 4.92857 4.71429 4.92857H1.07143C0.479736 4.92857 0 5.40831 0 6C0 6.59169 0.479736 7.07143 1.07143 7.07143H4.71429C4.83262 7.07143 4.92857 7.16738 4.92857 7.28571V10.9286C4.92857 11.5203 5.40831 12 6 12C6.59169 12 7.07143 11.5203 7.07143 10.9286V7.28571C7.07143 7.16738 7.16738 7.07143 7.28571 7.07143H10.9286C11.5203 7.07143 12 6.59169 12 6C12 5.40831 11.5203 4.92857 10.9286 4.92857Z" fill="#0551a0"></path></svg></button></div><div class="CProductCard-module_productCardContainer__mwUl7"><div class="CProductCard-module_containerTop__35Ma3"><div class="CProductCard-module_imageContainer__aTMdz"><img src="https://images.ceptesok.com/cdn-cgi/image/width=216,height=216,fit=pad,quality=80,format=webp/product-assets/sub-folder/e32a6c94-6dd4-487a-a29b-de954e04c9ed.png" alt="product-thumb" style="width:108px;height:108px"/></div><div class="CProductCard-module_infoContainer__F8uxY"><h2 class="CProductCard-module_title__u8bMW">Mis Pastörize Doğal İçme Sütü 1 L</h2><div class="CPriceBox-module_cPriceBox__1OWBR CPriceBox-module_productCardPrice__yjBVO"><div class="CPriceBox-module_priceContainer__ZROpc" style="height:26px"><span style="font-size:14px;line-height:16px;font-weight:800" class="CPriceBox-module_price__bYk-c" data-testid="discountedPrice">59,00<!-- -->₺</span></div></div></div></div></div></div></a></div></div>
9
+ <a href="/sutas-laktozsuz-sut-200-ml-p-635179"><div class="CProductCard-module_productCardWrapper__okAmT CProductCard-module_PLPCard__rB4tw"><div class="CQuantityChangerCapsule-module_buttonWrapper__Bed1d CQuantityChangerCapsule-module_singleButtonWrapper__oGhYN CQuantityChangerCapsule-module_small__2YPFl" style="position:absolute;right:8px;top:8px"><button class="CQuantityChangerCapsule-module_plusButton__ABlui CQuantityChangerCapsule-module_small__2YPFl" type="button" style="background-color:#e6eef6;border:1px solid #cddcec"><svg width="14" height="14" viewBox="0 0 12 12" fill="#0551a0" xmlns="http://www.w3.org/2000/svg"><path d="M10.9286 4.92857H7.28571C7.16738 4.92857 7.07143 4.83262 7.07143 4.71429V1.07143C7.07143 0.479736 6.59169 0 6 0C5.40831 0 4.92857 0.479736 4.92857 1.07143V4.71429C4.92857 4.83262 4.83262 4.92857 4.71429 4.92857H1.07143C0.479736 4.92857 0 5.40831 0 6C0 6.59169 0.479736 7.07143 1.07143 7.07143H4.71429C4.83262 7.07143 4.92857 7.16738 4.92857 7.28571V10.9286C4.92857 11.5203 5.40831 12 6 12C6.59169 12 7.07143 11.5203 7.07143 10.9286V7.28571C7.07143 7.16738 7.16738 7.07143 7.28571 7.07143H10.9286C11.5203 7.07143 12 6.59169 12 6C12 5.40831 11.5203 4.92857 10.9286 4.92857Z" fill="#0551a0"></path></svg></button></div><div class="CProductCard-module_productCardContainer__mwUl7"><div class="CProductCard-module_containerTop__35Ma3"><div class="CProductCard-module_imageContainer__aTMdz"><img src="https://images.ceptesok.com/cdn-cgi/image/width=216,height=216,fit=pad,quality=80,format=webp/product-assets/sub-folder/328bd4bd-f60b-4526-862e-9bb0599a8ece.png" alt="product-thumb" style="width:108px;height:108px"/></div><div class="CProductCard-module_infoContainer__F8uxY"><h2 class="CProductCard-module_title__u8bMW">Sütaş Laktozsuz Süt 200 ml</h2><div class="CPriceBox-module_cPriceBox__1OWBR CPriceBox-module_productCardPrice__yjBVO"><div class="CPriceBox-module_priceContainer__ZROpc" style="height:26px"><span style="font-size:14px;line-height:16px;font-weight:800" class="CPriceBox-module_price__bYk-c" data-testid="discountedPrice">18,50<!-- -->₺</span></div></div></div></div></div></div></a></div></div>
10
+ <a href="/mis-uht-sut-laktozsuz-1-l-p-5764"><div class="CProductCard-module_productCardWrapper__okAmT CProductCard-module_PLPCard__rB4tw"><div class="CQuantityChangerCapsule-module_buttonWrapper__Bed1d CQuantityChangerCapsule-module_singleButtonWrapper__oGhYN CQuantityChangerCapsule-module_small__2YPFl" style="position:absolute;right:8px;top:8px"><button class="CQuantityChangerCapsule-module_plusButton__ABlui CQuantityChangerCapsule-module_small__2YPFl" type="button" style="background-color:#e6eef6;border:1px solid #cddcec"><svg width="14" height="14" viewBox="0 0 12 12" fill="#0551a0" xmlns="http://www.w3.org/2000/svg"><path d="M10.9286 4.92857H7.28571C7.16738 4.92857 7.07143 4.83262 7.07143 4.71429V1.07143C7.07143 0.479736 6.59169 0 6 0C5.40831 0 4.92857 0.479736 4.92857 1.07143V4.71429C4.92857 4.83262 4.83262 4.92857 4.71429 4.92857H1.07143C0.479736 4.92857 0 5.40831 0 6C0 6.59169 0.479736 7.07143 1.07143 7.07143H4.71429C4.83262 7.07143 4.92857 7.16738 4.92857 7.28571V10.9286C4.92857 11.5203 5.40831 12 6 12C6.59169 12 7.07143 11.5203 7.07143 10.9286V7.28571C7.07143 7.16738 7.16738 7.07143 7.28571 7.07143H10.9286C11.5203 7.07143 12 6.59169 12 6C12 5.40831 11.5203 4.92857 10.9286 4.92857Z" fill="#0551a0"></path></svg></button></div><div class="CProductCard-module_productCardContainer__mwUl7"><div class="CProductCard-module_containerTop__35Ma3"><div class="CProductCard-module_imageContainer__aTMdz"><img src="https://images.ceptesok.com/cdn-cgi/image/width=216,height=216,fit=pad,quality=80,format=webp/product-assets/sub-folder/d2400ad5-2c0a-4f50-af14-e3b763a582f0.jpg" alt="product-thumb" style="width:108px;height:108px"/></div><div class="CProductCard-module_infoContainer__F8uxY"><h2 class="CProductCard-module_title__u8bMW">Mis Uht Süt Laktozsuz 1 L</h2><div class="CPriceBox-module_cPriceBox__1OWBR CPriceBox-module_productCardPrice__yjBVO"><div class="CPriceBox-module_priceContainer__ZROpc" style="height:26px"><span style="font-size:14px;line-height:16px;font-weight:800" class="CPriceBox-module_price__bYk-c" data-testid="discountedPrice">62,50<!-- -->₺</span></div></div></div></div></div></div></a></div></div>
11
+ <a href="/icim-rahat-laktozsuz-sut-1-l-p-8430"><div class="CProductCard-module_productCardWrapper__okAmT CProductCard-module_PLPCard__rB4tw"><div class="CQuantityChangerCapsule-module_buttonWrapper__Bed1d CQuantityChangerCapsule-module_singleButtonWrapper__oGhYN CQuantityChangerCapsule-module_small__2YPFl" style="position:absolute;right:8px;top:8px"><button class="CQuantityChangerCapsule-module_plusButton__ABlui CQuantityChangerCapsule-module_small__2YPFl" type="button" style="background-color:#e6eef6;border:1px solid #cddcec"><svg width="14" height="14" viewBox="0 0 12 12" fill="#0551a0" xmlns="http://www.w3.org/2000/svg"><path d="M10.9286 4.92857H7.28571C7.16738 4.92857 7.07143 4.83262 7.07143 4.71429V1.07143C7.07143 0.479736 6.59169 0 6 0C5.40831 0 4.92857 0.479736 4.92857 1.07143V4.71429C4.92857 4.83262 4.83262 4.92857 4.71429 4.92857H1.07143C0.479736 4.92857 0 5.40831 0 6C0 6.59169 0.479736 7.07143 1.07143 7.07143H4.71429C4.83262 7.07143 4.92857 7.16738 4.92857 7.28571V10.9286C4.92857 11.5203 5.40831 12 6 12C6.59169 12 7.07143 11.5203 7.07143 10.9286V7.28571C7.07143 7.16738 7.16738 7.07143 7.28571 7.07143H10.9286C11.5203 7.07143 12 6.59169 12 6C12 5.40831 11.5203 4.92857 10.9286 4.92857Z" fill="#0551a0"></path></svg></button></div><div class="CProductCard-module_productCardContainer__mwUl7"><div class="CProductCard-module_containerTop__35Ma3"><div class="CProductCard-module_imageContainer__aTMdz"><img src="https://images.ceptesok.com/cdn-cgi/image/width=216,height=216,fit=pad,quality=80,format=webp/product-assets/sub-folder/4124dddf-9975-4384-aa45-0f5bce002055.png" alt="product-thumb" style="width:108px;height:108px"/></div><div class="CProductCard-module_infoContainer__F8uxY"><h2 class="CProductCard-module_title__u8bMW">İçim Rahat Laktozsuz Süt 1 L</h2><div class="CPriceBox-module_cPriceBox__1OWBR CPriceBox-module_productCardPrice__yjBVO"><div class="CPriceBox-module_priceContainer__ZROpc" style="height:26px"><span style="font-size:14px;line-height:16px;font-weight:800" class="CPriceBox-module_price__bYk-c" data-testid="discountedPrice">79,50<!-- -->₺</span></div></div></div></div></div></div></a></div></div>
12
+ <a href="/mis-kakaolu-sut-200-ml-p-7284"><div class="CProductCard-module_productCardWrapper__okAmT CProductCard-module_PLPCard__rB4tw"><div class="CQuantityChangerCapsule-module_buttonWrapper__Bed1d CQuantityChangerCapsule-module_singleButtonWrapper__oGhYN CQuantityChangerCapsule-module_small__2YPFl" style="position:absolute;right:8px;top:8px"><button class="CQuantityChangerCapsule-module_plusButton__ABlui CQuantityChangerCapsule-module_small__2YPFl" type="button" style="background-color:#e6eef6;border:1px solid #cddcec"><svg width="14" height="14" viewBox="0 0 12 12" fill="#0551a0" xmlns="http://www.w3.org/2000/svg"><path d="M10.9286 4.92857H7.28571C7.16738 4.92857 7.07143 4.83262 7.07143 4.71429V1.07143C7.07143 0.479736 6.59169 0 6 0C5.40831 0 4.92857 0.479736 4.92857 1.07143V4.71429C4.92857 4.83262 4.83262 4.92857 4.71429 4.92857H1.07143C0.479736 4.92857 0 5.40831 0 6C0 6.59169 0.479736 7.07143 1.07143 7.07143H4.71429C4.83262 7.07143 4.92857 7.16738 4.92857 7.28571V10.9286C4.92857 11.5203 5.40831 12 6 12C6.59169 12 7.07143 11.5203 7.07143 10.9286V7.28571C7.07143 7.16738 7.16738 7.07143 7.28571 7.07143H10.9286C11.5203 7.07143 12 6.59169 12 6C12 5.40831 11.5203 4.92857 10.9286 4.92857Z" fill="#0551a0"></path></svg></button></div><div class="CProductCard-module_productCardContainer__mwUl7"><div class="CProductCard-module_containerTop__35Ma3"><div class="CProductCard-module_imageContainer__aTMdz"><img src="https://images.ceptesok.com/cdn-cgi/image/width=216,height=216,fit=pad,quality=80,format=webp/product-assets/sub-folder/964c914f-6190-4eaa-b8a7-dd0dd9c4e92f.jpg" alt="product-thumb" style="width:108px;height:108px"/></div><div class="CProductCard-module_infoContainer__F8uxY"><h2 class="CProductCard-module_title__u8bMW">Mis Kakaolu Süt 200 ml</h2><div class="CPriceBox-module_cPriceBox__1OWBR CPriceBox-module_productCardPrice__yjBVO"><div class="CPriceBox-module_discountedPriceContainer__nsaTN"><div class="CPriceBox-module_price__bYk-c"><span style="font-size:12px;line-height:16px" data-testid="price">16,50<!-- -->₺</span></div><span style="font-size:18px;line-height:16px" class="CPriceBox-module_discountedPrice__15Ffw" data-testid="discountedPrice">15,00<!-- -->₺</span></div></div></div></div></div></div></a></div></div>
13
+ </body></html>
@@ -0,0 +1,64 @@
1
+ """Live tests — hit the real sokmarket.com.tr. Cautious: a few requests, then stop.
2
+
3
+ Run: pytest tests/test_live.py -v
4
+ Skip: pytest -m "not live" (or set SOK_SKIP_LIVE=1)
5
+ """
6
+ import os
7
+ import pathlib
8
+ import sys
9
+ import time
10
+ import unittest
11
+
12
+ import pytest
13
+
14
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
15
+
16
+ from sok_mcp import http as _http
17
+ from sok_mcp import products as _P
18
+
19
+ pytestmark = pytest.mark.live
20
+ SKIP = os.environ.get("SOK_SKIP_LIVE") == "1"
21
+
22
+
23
+ @pytest.mark.skipif(SKIP, reason="SOK_SKIP_LIVE=1")
24
+ class TestLive(unittest.TestCase):
25
+ def test_search_is_real(self):
26
+ res = _P.parse_product_cards(_http.fetch_search("süt"))
27
+ self.assertGreaterEqual(res["total"], 50)
28
+ self.assertGreaterEqual(len(res["products"]), 10)
29
+ self.assertTrue(all(p["price_try"] for p in res["products"]))
30
+ print(f"\nLIVE search 'süt': {res['total']} products, "
31
+ f"first = {res['products'][0]['title']} @ {res['products'][0]['price_try']}₺")
32
+ time.sleep(1)
33
+
34
+ def test_product_is_real(self):
35
+ res = _P.parse_product_cards(_http.fetch_search("süt"))
36
+ first = res["products"][0]
37
+ page = _P.parse_product_page(_http.fetch_product(first["url"]))
38
+ self.assertEqual(page["title"], first["title"])
39
+ self.assertEqual(page["price_try"], first["price_try"])
40
+ print(f"\nLIVE product: {page['title']} @ {page['price_try']}₺, "
41
+ f"{len(page['related_products'])} related")
42
+ time.sleep(1)
43
+
44
+ def test_home_categories_are_real(self):
45
+ cats = _P.parse_categories(_http.fetch_home())
46
+ self.assertGreaterEqual(len(cats), 15)
47
+ print(f"\nLIVE home: {len(cats)} categories, first = {cats[0]['name']}")
48
+ time.sleep(1)
49
+
50
+ def test_campaigns_are_real(self):
51
+ promos = _P.parse_promotions(_http.fetch_campaigns())
52
+ self.assertGreaterEqual(len(promos), 1)
53
+ print(f"\nLIVE campaigns: {len(promos)} — {[p['name'] for p in promos]}")
54
+ time.sleep(1)
55
+
56
+ def test_category_page_is_real(self):
57
+ res = _P.parse_product_cards(_http.fetch_category("sut-ve-sut-urunleri-c-460"))
58
+ self.assertGreaterEqual(len(res["products"]), 10)
59
+ print(f"\nLIVE category süt: {len(res['products'])} products on first page")
60
+ time.sleep(1)
61
+
62
+
63
+ if __name__ == "__main__":
64
+ unittest.main()
@@ -0,0 +1,84 @@
1
+ """Offline tests — parse real captured ŞOK fixtures, no network."""
2
+ import pathlib
3
+ import sys
4
+ import unittest
5
+
6
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
7
+
8
+ from sok_mcp import products as P
9
+
10
+ FIX = pathlib.Path(__file__).resolve().parent / "fixtures"
11
+
12
+
13
+ class TestSearchCards(unittest.TestCase):
14
+ @classmethod
15
+ def setUpClass(cls):
16
+ cls.res = P.parse_product_cards((FIX / "search.html").read_text(encoding="utf-8"))
17
+
18
+ def test_total(self):
19
+ self.assertEqual(self.res["total"], 207)
20
+
21
+ def test_card_count_and_first(self):
22
+ self.assertEqual(len(self.res["products"]), 10)
23
+ first = self.res["products"][0]
24
+ self.assertEqual(first["title"], "Mis Uht Süt Yarım Yağlı 1 L")
25
+ self.assertEqual(first["id"], "5834")
26
+ self.assertEqual(first["price_try"], 43.5)
27
+ self.assertTrue(first["url"].endswith("-p-5834"))
28
+
29
+ def test_prices(self):
30
+ by_id = {p["id"]: p for p in self.res["products"]}
31
+ self.assertEqual(by_id["7796"]["price_try"], 88.5)
32
+ self.assertEqual(by_id["5179"]["price_try"], 36.0)
33
+
34
+ def test_stats(self):
35
+ s = P.price_stats(self.res["products"])
36
+ self.assertEqual(s["count"], 10)
37
+ self.assertEqual(s["min_try"], 16.5)
38
+ self.assertEqual(s["max_try"], 88.5)
39
+ self.assertEqual(s["median_try"], 55.75)
40
+ self.assertEqual(s["mean_try"], 52.05)
41
+
42
+ def test_empty_stats(self):
43
+ self.assertEqual(P.price_stats([])["count"], 0)
44
+ self.assertIsNone(P.price_stats([])["min_try"])
45
+
46
+
47
+ class TestProductPage(unittest.TestCase):
48
+ @classmethod
49
+ def setUpClass(cls):
50
+ cls.p = P.parse_product_page((FIX / "product.html").read_text(encoding="utf-8"))
51
+
52
+ def test_title_and_price(self):
53
+ self.assertEqual(self.p["title"], "Ahmad Tea Classic Earl Grey Demlik Poşet 40'lı")
54
+ self.assertEqual(self.p["price_try"], 149.0)
55
+
56
+ def test_no_title_raises(self):
57
+ with self.assertRaises(ValueError):
58
+ P.parse_product_page("<html><body>no h1</body></html>")
59
+
60
+
61
+ class TestHomeAndCampaigns(unittest.TestCase):
62
+ def test_categories(self):
63
+ cats = P.parse_categories((FIX / "home.html").read_text(encoding="utf-8"))
64
+ self.assertEqual(len(cats), 19)
65
+ by_id = {c["id"]: c for c in cats}
66
+ self.assertEqual(by_id["460"]["name"], "Süt & Süt Ürünleri")
67
+ self.assertEqual(by_id["20"]["slug"], "meyve-ve-sebze")
68
+
69
+ def test_promotions(self):
70
+ promos = P.parse_promotions((FIX / "kampanyalar.html").read_text(encoding="utf-8"))
71
+ self.assertEqual(len(promos), 2)
72
+ names = [p["name"] for p in promos]
73
+ self.assertIn("Kazandırır Alışverişin", names)
74
+ self.assertIn("Kasa Arkası Ürünler", names)
75
+
76
+ def test_price_parsing(self):
77
+ self.assertEqual(P.parse_price("43,50"), 43.5)
78
+ self.assertEqual(P.parse_price("1.234,56"), 1234.56)
79
+ self.assertIsNone(P.parse_price("fiyat yok"))
80
+ self.assertIsNone(P.parse_price(None))
81
+
82
+
83
+ if __name__ == "__main__":
84
+ unittest.main()