hurriyet-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
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.5
2
+ Name: hurriyet-mcp
3
+ Version: 0.1.0
4
+ Summary: Unofficial read-only MCP server for Hürriyet news (headlines, full articles, search, latest)
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
+ # hurriyet-mcp 🇹🇷
14
+
15
+ mcp-name: io.github.mohammad-abbasi-sh/hurriyet-mcp
16
+
17
+ Unofficial **read-only** MCP server for **Hürriyet** (hurriyet.com.tr) — Türkiye's news: section headlines, full article text, keyword search and the homepage's latest news.
18
+
19
+ > Part of the "Turkey MCP factory" (Sepehr-style pipeline). Not affiliated with Hürriyet.
20
+
21
+ ## For AI agents
22
+
23
+ Add to your MCP client config (stdio):
24
+
25
+ ```json
26
+ {
27
+ "mcpServers": {
28
+ "hurriyet": {
29
+ "command": "uvx",
30
+ "args": ["hurriyet-mcp"]
31
+ }
32
+ }
33
+ }
34
+ ```
35
+
36
+ Or run locally: `python -m hurriyet_mcp.server` (needs `mcp<2`, `curl-cffi`, `requests`).
37
+
38
+ ## Tools
39
+
40
+ | Tool | Description |
41
+ |------|-------------|
42
+ | `hurriyet_get_headlines` | Latest headlines from a section — `section` (e.g. `gundem`, `ekonomi`, `dunya`, `sporarena`), `limit` (1..30) |
43
+ | `hurriyet_get_article` | Full article text — `url` (hurriyet.com.tr article URL or path) |
44
+ | `hurriyet_search_news` | Search news by keyword — `query`, `limit` (1..30) |
45
+ | `hurriyet_get_latest` | Latest news from the homepage, `limit` (1..30) |
46
+ | `hurriyet_list_categories` | News sections (slug, Turkish name, url) |
47
+
48
+ Example output (`hurriyet_get_headlines` / "gundem"):
49
+
50
+ ```json
51
+ {"section": "gundem", "count": 10,
52
+ "articles": [{"title": "Afyonkarahisar'da 8 kişiyi katletmişti! ...",
53
+ "url": "https://www.hurriyet.com.tr/gundem/...-43369434",
54
+ "id": "43369434", "section": "gundem", "kind": "article"}]}
55
+ ```
56
+
57
+ Example output (`hurriyet_get_article`):
58
+
59
+ ```json
60
+ {"headline": "6 ilde Telegram üzerinden asılsız ihbar ve şantaj yapan 7 şüpheli tutuklandı",
61
+ "section": "Gündem", "date_published": "2026-11-07T09:38:51+03:00",
62
+ "word_count": 143, "body": "İzmir Cumhuriyet Başsavcılığı koordinesinde ..."}
63
+ ```
64
+
65
+ Notes:
66
+ - Articles come from schema.org NewsArticle data (headline, dates, section, word count, body).
67
+ - Search is `/arama/<keyword>`; known keywords redirect to a topic hub (e.g. `/haberleri/futbol`) — the tool reports the `landed_url`.
68
+ - The `/arama/` path is slow under TLS impersonation — the server falls back to plain requests there.
69
+ - Data: hurriyet.com.tr public pages — no key, no login, read-only.
70
+
71
+ ## Development
72
+
73
+ ```bash
74
+ python3 -m venv .venv && .venv/bin/pip install "mcp<2" curl-cffi requests pytest
75
+ .venv/bin/python -m pytest tests/ -q
76
+ ```
77
+
78
+ - `tests/test_news.py` — offline, parses real captured fixtures
79
+ - `tests/test_live.py` — 5 cautious live requests against hurriyet.com.tr
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,39 @@
1
+ # hurriyet-mcp 🇹🇷
2
+
3
+ سرور MCP غیررسمی و **فقط‌خواندنی** برای **Hürriyet** (hurriyet.com.tr) — سایت خبری ترکیه: تیترهای بخش‌ها، متن کامل خبر، جست‌وجوی خبری و آخرین خبرهای صفحه‌ی اصلی.
4
+
5
+ > بخشی از «کارخانه‌ی MCP ترکیه» (پایپ‌لاین به سبک سپهر). هیچ وابستگی به Hürriyet ندارد.
6
+
7
+ ## برای ایجنت‌های هوش مصنوعی
8
+
9
+ ```json
10
+ {
11
+ "mcpServers": {
12
+ "hurriyet": {
13
+ "command": "uvx",
14
+ "args": ["hurriyet-mcp"]
15
+ }
16
+ }
17
+ }
18
+ ```
19
+
20
+ ## ابزارها
21
+
22
+ | ابزار | توضیح |
23
+ |------|-------|
24
+ | `hurriyet_get_headlines` | آخرین تیترهای یک بخش — `section` (مثلاً `gundem`، ‏`ekonomi`، ‏`dunya`، ‏`sporarena`)، ‏`limit` (۱ تا ۳۰) |
25
+ | `hurriyet_get_article` | متن کامل خبر — `url` (URL خبر hurriyet.com.tr یا مسیر آن) |
26
+ | `hurriyet_search_news` | جست‌وجوی اخبار با کلیدواژه — `query`، ‏`limit` (۱ تا ۳۰) |
27
+ | `hurriyet_get_latest` | آخرین خبرهای صفحه‌ی اصلی، `limit` (۱ تا ۳۰) |
28
+ | `hurriyet_list_categories` | بخش‌های خبری (slug، نام ترکی، url) |
29
+
30
+ مثال (`hurriyet_get_headlines` / «gundem»): ۳۰ تیتر؛ اولین خبر «Afyonkarahisar'da 8 kişiyi katletmişti! ...». مثال (`hurriyet_get_article`): «6 ilde Telegram üzerinden asılsız ihbar ve şantaj yapan 7 şüpheli tutuklandı» — ۷ نوامبر ۲۰۲۶، ۱۴۳ کلمه.
31
+
32
+ نکته‌ها:
33
+ - خبرها از داده‌ی schema.org NewsArticle می‌آیند (تیتر، تاریخ‌ها، بخش، تعداد کلمات، متن).
34
+ - جست‌وجو `/arama/<کلیدواژه>` است؛ کلیدواژه‌های شناخته‌شده به صفحه‌ی موضوعی هدایت می‌شوند (مثلاً `/haberleri/futbol`) — ابزار `landed_url` را گزارش می‌کند.
35
+ - داده: صفحات عمومی hurriyet.com.tr — بدون لاگین، بدون کلید، فقط‌خواندنی.
36
+
37
+ ## قوانین
38
+
39
+ غیررسمی و فقط‌خواندنی — بدون لاگین، بدون کلید، بدون نوشتن. تست‌ها: آفلاین با فیکسچر + تست زنده (۱۲/۱۲ سبز).
@@ -0,0 +1,41 @@
1
+ # hurriyet-mcp 🇹🇷
2
+
3
+ **Hürriyet** (hurriyet.com.tr) için gayriresmî, **salt-okunur** MCP sunucusu — Türkiye'nin haber sitesi: bölüm manşetleri, tam haber metni, anahtar kelime araması ve ana sayfanın son haberleri.
4
+
5
+ > "Türkiye MCP fabrikası" parçası (Sepehr tarzı pipeline). Hürriyet 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
+ "hurriyet": {
15
+ "command": "uvx",
16
+ "args": ["hurriyet-mcp"]
17
+ }
18
+ }
19
+ }
20
+ ```
21
+
22
+ ## Araçlar
23
+
24
+ | Araç | Açıklama |
25
+ |------|----------|
26
+ | `hurriyet_get_headlines` | Bir bölümün son manşetleri — `section` (örn. `gundem`, `ekonomi`, `dunya`, `sporarena`), `limit` (1..30) |
27
+ | `hurriyet_get_article` | Tam haber metni — `url` (hurriyet.com.tr haber URL'si veya yolu) |
28
+ | `hurriyet_search_news` | Anahtar kelimeyle haber ara — `query`, `limit` (1..30) |
29
+ | `hurriyet_get_latest` | Ana sayfanın son haberleri, `limit` (1..30) |
30
+ | `hurriyet_list_categories` | Haber bölümleri (slug, Türkçe ad, url) |
31
+
32
+ Örnek (`hurriyet_get_headlines` / "gundem"): 30 manşet; ilk haber "Afyonkarahisar'da 8 kişiyi katletmişti! ..." Örnek (`hurriyet_get_article`): "6 ilde Telegram üzerinden asılsız ihbar ve şantaj yapan 7 şüpheli tutuklandı" — 7 Kasım 2026 09:38, 143 kelime.
33
+
34
+ Notlar:
35
+ - Haberler schema.org NewsArticle verisinden gelir (başlık, tarihler, bölüm, kelime sayısı, metin).
36
+ - Arama `/arama/<kelime>`; bilinen kelimeler konu sayfasına yönlenir (örn. `/haberleri/futbol`) — araç `landed_url`'yi bildirir.
37
+ - Veri: hurriyet.com.tr herkese açık sayfalar — giriş yok, API anahtarı yok, salt-okunur.
38
+
39
+ ## Kurallar
40
+
41
+ Gayriresmî ve salt-okunur — giriş yok, API anahtarı yok, yazma yok. Testler: çevrimdışı fixture + canlı testler (12/12 yeşil).
@@ -0,0 +1,71 @@
1
+ # hurriyet-mcp 🇹🇷
2
+
3
+ mcp-name: io.github.mohammad-abbasi-sh/hurriyet-mcp
4
+
5
+ Unofficial **read-only** MCP server for **Hürriyet** (hurriyet.com.tr) — Türkiye's news: section headlines, full article text, keyword search and the homepage's latest news.
6
+
7
+ > Part of the "Turkey MCP factory" (Sepehr-style pipeline). Not affiliated with Hürriyet.
8
+
9
+ ## For AI agents
10
+
11
+ Add to your MCP client config (stdio):
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "hurriyet": {
17
+ "command": "uvx",
18
+ "args": ["hurriyet-mcp"]
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ Or run locally: `python -m hurriyet_mcp.server` (needs `mcp<2`, `curl-cffi`, `requests`).
25
+
26
+ ## Tools
27
+
28
+ | Tool | Description |
29
+ |------|-------------|
30
+ | `hurriyet_get_headlines` | Latest headlines from a section — `section` (e.g. `gundem`, `ekonomi`, `dunya`, `sporarena`), `limit` (1..30) |
31
+ | `hurriyet_get_article` | Full article text — `url` (hurriyet.com.tr article URL or path) |
32
+ | `hurriyet_search_news` | Search news by keyword — `query`, `limit` (1..30) |
33
+ | `hurriyet_get_latest` | Latest news from the homepage, `limit` (1..30) |
34
+ | `hurriyet_list_categories` | News sections (slug, Turkish name, url) |
35
+
36
+ Example output (`hurriyet_get_headlines` / "gundem"):
37
+
38
+ ```json
39
+ {"section": "gundem", "count": 10,
40
+ "articles": [{"title": "Afyonkarahisar'da 8 kişiyi katletmişti! ...",
41
+ "url": "https://www.hurriyet.com.tr/gundem/...-43369434",
42
+ "id": "43369434", "section": "gundem", "kind": "article"}]}
43
+ ```
44
+
45
+ Example output (`hurriyet_get_article`):
46
+
47
+ ```json
48
+ {"headline": "6 ilde Telegram üzerinden asılsız ihbar ve şantaj yapan 7 şüpheli tutuklandı",
49
+ "section": "Gündem", "date_published": "2026-11-07T09:38:51+03:00",
50
+ "word_count": 143, "body": "İzmir Cumhuriyet Başsavcılığı koordinesinde ..."}
51
+ ```
52
+
53
+ Notes:
54
+ - Articles come from schema.org NewsArticle data (headline, dates, section, word count, body).
55
+ - Search is `/arama/<keyword>`; known keywords redirect to a topic hub (e.g. `/haberleri/futbol`) — the tool reports the `landed_url`.
56
+ - The `/arama/` path is slow under TLS impersonation — the server falls back to plain requests there.
57
+ - Data: hurriyet.com.tr public pages — no key, no login, read-only.
58
+
59
+ ## Development
60
+
61
+ ```bash
62
+ python3 -m venv .venv && .venv/bin/pip install "mcp<2" curl-cffi requests pytest
63
+ .venv/bin/python -m pytest tests/ -q
64
+ ```
65
+
66
+ - `tests/test_news.py` — offline, parses real captured fixtures
67
+ - `tests/test_live.py` — 5 cautious live requests against hurriyet.com.tr
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "hurriyet-mcp"
7
+ version = "0.1.0"
8
+ description = "Unofficial read-only MCP server for Hürriyet news (headlines, full articles, search, latest)"
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
+ hurriyet-mcp = "hurriyet_mcp.server:main"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/hurriyet_mcp"]
@@ -0,0 +1 @@
1
+ {"$schema":"https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json","description":"Unofficial read-only MCP server for Hürriyet news — headlines, full articles, search.","name":"io.github.mohammad-abbasi-sh/hurriyet-mcp","packages":[{"identifier":"hurriyet-mcp","registryBaseUrl":"https://pypi.org","registryType":"pypi","transport":{"type":"stdio"},"version":"0.1.0"}],"repository":{"source":"github","url":"https://github.com/mohammad-abbasi-sh/hurriyet-mcp"},"title":"Hürriyet News","version":"0.1.0","websiteUrl":"https://github.com/mohammad-abbasi-sh/hurriyet-mcp"}
@@ -0,0 +1,2 @@
1
+ """hurriyet_mcp — unofficial read-only MCP server for Hürriyet news."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,98 @@
1
+ """HTTP layer for hurriyet_mcp.
2
+
3
+ hurriyet.com.tr serves its pages server-rendered with no login and
4
+ no API key. Search is a plain path (``/arama/<keyword>``) — known
5
+ keywords redirect to a topic hub (e.g. /haberleri/futbol), which the
6
+ parser handles the same way. Transport uses curl-cffi chrome120
7
+ impersonation (the proven Turkey-factory pattern) with a
8
+ plain-requests fallback: the /arama/ path timed out twice under
9
+ curl-cffi while plain requests answered in ~3s, so the fallback is
10
+ load-bearing here, not decorative. Requests are kept gentle (one at
11
+ a time, ~1s apart) and read-only.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from urllib.parse import quote
17
+
18
+ from curl_cffi import requests as _creq
19
+ import requests as _plain
20
+
21
+ BASE = "https://www.hurriyet.com.tr"
22
+ TIMEOUT = 60.0
23
+ MIN_INTERVAL = 1.0 # seconds between requests — be polite
24
+
25
+ _HEADERS = {
26
+ "Accept-Language": "tr-TR,tr;q=0.9,en;q=0.8",
27
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
28
+ }
29
+
30
+ _UA = (
31
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
32
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
33
+ )
34
+
35
+ _last_fetch = 0.0
36
+ last_url: str = ""
37
+
38
+
39
+ class HurriyetError(RuntimeError):
40
+ pass
41
+
42
+
43
+ def _throttle() -> None:
44
+ global _last_fetch
45
+ wait = MIN_INTERVAL - (time.monotonic() - _last_fetch)
46
+ if wait > 0:
47
+ time.sleep(wait)
48
+ _last_fetch = time.monotonic()
49
+
50
+
51
+ def _get(url: str) -> str:
52
+ global last_url
53
+ _throttle()
54
+ try:
55
+ resp = _creq.get(url, impersonate="chrome120", headers=_HEADERS, timeout=TIMEOUT)
56
+ status, text, final = resp.status_code, resp.text, str(resp.url)
57
+ except Exception:
58
+ # Fallback transport: plain requests with a browser UA.
59
+ try:
60
+ resp = _plain.get(
61
+ url, headers={**_HEADERS, "User-Agent": _UA}, timeout=TIMEOUT
62
+ )
63
+ status, text, final = resp.status_code, resp.text, resp.url
64
+ except Exception as e:
65
+ raise HurriyetError(f"network error fetching {url}: {e}") from e
66
+ last_url = final
67
+ if status == 403:
68
+ raise HurriyetError(
69
+ "hurriyet.com.tr refused the request (HTTP 403, bot wall) — try again later"
70
+ )
71
+ if status == 429:
72
+ raise HurriyetError(
73
+ "hurriyet.com.tr rate-limited the request (HTTP 429) — try again later"
74
+ )
75
+ if status == 404:
76
+ raise HurriyetError(f"hurriyet.com.tr has no page at {url} (HTTP 404)")
77
+ if status != 200:
78
+ raise HurriyetError(f"hurriyet.com.tr returned HTTP {status} for {url}")
79
+ return text
80
+
81
+
82
+ def fetch_home() -> str:
83
+ return _get(f"{BASE}/")
84
+
85
+
86
+ def fetch_section(section: str) -> str:
87
+ return _get(f"{BASE}/{section.strip('/')}/")
88
+
89
+
90
+ def fetch_search(query: str) -> str:
91
+ return _get(f"{BASE}/arama/{quote(query)}")
92
+
93
+
94
+ def fetch_article(path: str) -> str:
95
+ path = path.strip()
96
+ if path.startswith("http"):
97
+ return _get(path)
98
+ return _get(f"{BASE}/{path.lstrip('/')}")
@@ -0,0 +1,153 @@
1
+ """Domain logic: parse Hürriyet pages into compact dicts.
2
+
3
+ Data sources (all public, no login, no key):
4
+ - headlines: /<section>/ (gundem, ekonomi, dunya, sporarena, ...) —
5
+ server-rendered article links, IDs in the URL slug
6
+ - article: /<section>/<slug>-<id> — schema.org NewsArticle JSON-LD
7
+ (headline, dates, section, word count) + articleBody
8
+ - search: /arama/<keyword> — keyword page; known keywords redirect
9
+ to a topic hub (/haberleri/<keyword>), parsed the same way
10
+ - latest: / homepage — the day's article links
11
+ Stdlib parsing only (re / json / html).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import html as _html
16
+ import json
17
+ import re
18
+
19
+ BASE = "https://www.hurriyet.com.tr"
20
+
21
+ # Curated news sections (slug -> Turkish display name). Verified present
22
+ # in the homepage nav on 2026-11-07.
23
+ SECTIONS = {
24
+ "gundem": "Gündem",
25
+ "ekonomi": "Ekonomi",
26
+ "dunya": "Dünya",
27
+ "sporarena": "Spor Arena",
28
+ "kelebek-magazin": "Kelebek Magazin",
29
+ "teknoloji": "Teknoloji",
30
+ "egitim": "Eğitim",
31
+ "seyahat": "Seyahat",
32
+ "yasam": "Yaşam",
33
+ "video": "Video",
34
+ "astroloji": "Astroloji",
35
+ "son-dakika-haberleri": "Son Dakika",
36
+ "avrupa": "Avrupa",
37
+ "hayat": "Hayat",
38
+ "kitap-sanat": "Kitap & Sanat",
39
+ }
40
+
41
+ _ARTICLE_RE = re.compile(r"^/[^\"?]+-\d{6,}/?$")
42
+
43
+
44
+ def _clean(text: str | None) -> str:
45
+ if not text:
46
+ return ""
47
+ return re.sub(r"\s+", " ", _html.unescape(str(text))).strip()
48
+
49
+
50
+ def _ld_json(html: str, want_type: str) -> dict | None:
51
+ for m in re.finditer(
52
+ r'<script type="application/ld\+json">(.*?)</script>', html, re.S
53
+ ):
54
+ try:
55
+ data = json.loads(m.group(1))
56
+ except (json.JSONDecodeError, ValueError):
57
+ continue
58
+ items = data if isinstance(data, list) else [data]
59
+ for item in items:
60
+ if not isinstance(item, dict):
61
+ continue
62
+ t = item.get("@type")
63
+ types = t if isinstance(t, list) else [t]
64
+ if want_type in types:
65
+ return item
66
+ return None
67
+
68
+
69
+ def parse_article_links(html: str) -> list[dict]:
70
+ """Titled article links: href /<section>/<slug>-<id> with link text.
71
+
72
+ Skips nav/footer chrome (short/empty titles) and dedupes by URL.
73
+ Gallery links (/galeri-...) are kept and marked kind='gallery'.
74
+ """
75
+ out, seen = [], set()
76
+ for href, inner in re.findall(
77
+ r'href="(/[^"]+-\d{6,}/?)"[^>]*>(.*?)</a>', html, re.S
78
+ ):
79
+ if not _ARTICLE_RE.match(href):
80
+ continue
81
+ title = _clean(re.sub(r"<[^>]+>", "", inner))
82
+ if len(title) < 15 or href in seen:
83
+ continue
84
+ seen.add(href)
85
+ m = re.search(r"-(\d{6,})/?$", href)
86
+ section = href.strip("/").split("/", 1)[0]
87
+ out.append(
88
+ {
89
+ "title": title,
90
+ "url": f"{BASE}{href}",
91
+ "id": m.group(1) if m else "",
92
+ "section": section,
93
+ "kind": "gallery" if section.startswith("galeri") else "article",
94
+ }
95
+ )
96
+ return out
97
+
98
+
99
+ def parse_article_page(html: str) -> dict:
100
+ """Parse an article page via its NewsArticle JSON-LD + articleBody."""
101
+ ld = _ld_json(html, "NewsArticle")
102
+ if ld is None:
103
+ raise ValueError("no NewsArticle structured data found on this page")
104
+ body = _clean(ld.get("articleBody"))
105
+ if not body:
106
+ # Fallback: long <p> paragraphs, skipping script/nav junk.
107
+ paras = []
108
+ for p in re.findall(r"<p[^>]*>(.*?)</p>", html, re.S):
109
+ t = _clean(re.sub(r"<[^>]+>", "", p))
110
+ if len(t) > 120 and "addEventListener" not in t:
111
+ paras.append(t)
112
+ body = "\n\n".join(paras)
113
+ image = ld.get("image")
114
+ if isinstance(image, dict):
115
+ image = image.get("url") or ""
116
+ elif isinstance(image, list):
117
+ image = image[0] if image else ""
118
+ author = ld.get("author")
119
+ if isinstance(author, dict):
120
+ author = author.get("name") or ""
121
+ elif isinstance(author, list) and author and isinstance(author[0], dict):
122
+ author = author[0].get("name") or ""
123
+ wc = ld.get("wordCount")
124
+ try:
125
+ wc = int(wc) if wc is not None else None
126
+ except (TypeError, ValueError):
127
+ pass
128
+ return {
129
+ "headline": _clean(ld.get("headline")),
130
+ "section": _clean(ld.get("articleSection")),
131
+ "date_published": ld.get("datePublished") or "",
132
+ "date_modified": ld.get("dateModified") or "",
133
+ "word_count": wc,
134
+ "keywords": _clean(ld.get("keywords")),
135
+ "author": _clean(author),
136
+ "image": image or "",
137
+ "body": body,
138
+ }
139
+
140
+
141
+ def list_categories(html: str | None = None) -> list[dict]:
142
+ """News sections. If homepage HTML is given, only sections actually
143
+ linked from it are returned; otherwise the full curated list."""
144
+ if html is None:
145
+ wanted = set(SECTIONS)
146
+ else:
147
+ found = set(re.findall(r'href="(?:https://www\.hurriyet\.com\.tr)?/([^"/]+)/"', html))
148
+ wanted = set(SECTIONS) & found
149
+ return [
150
+ {"slug": slug, "name": SECTIONS[slug], "url": f"{BASE}/{slug}/"}
151
+ for slug in SECTIONS
152
+ if slug in wanted
153
+ ]
@@ -0,0 +1,128 @@
1
+ """MCP server: unofficial read-only access to Hürriyet news."""
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 news as _N
10
+
11
+ mcp = FastMCP("hurriyet-mcp")
12
+
13
+ INSTRUCTIONS = """\
14
+ Unofficial read-only MCP server for Hürriyet (hurriyet.com.tr), \
15
+ Türkiye's news site — section headlines, full articles, keyword \
16
+ search and the homepage's latest news. Articles come from the \
17
+ site's server-rendered pages with schema.org NewsArticle data \
18
+ (headline, dates, section, word count, body). Search is \
19
+ /arama/<keyword>; known keywords redirect to a topic hub \
20
+ (e.g. /haberleri/futbol) and the tool reports the final URL it \
21
+ landed on. No login, no API key. Read-only; transport uses Chrome \
22
+ TLS impersonation with a plain-requests fallback (the /arama/ path \
23
+ is slow under impersonation); requests are spaced ~1s apart. Built \
24
+ by Mohammad Abbasi (mohammad-abbasi-sh) — Turkey MCP factory.\
25
+ """
26
+
27
+
28
+ def _err(e: Exception) -> str:
29
+ return json.dumps({"error": str(e)}, ensure_ascii=False)
30
+
31
+
32
+ def _validate_limit(limit: int, lo: int = 1, hi: int = 30) -> int:
33
+ if not lo <= limit <= hi:
34
+ raise ValueError(f"limit must be between {lo} and {hi}")
35
+ return limit
36
+
37
+
38
+ @mcp.tool()
39
+ def hurriyet_get_headlines(section: str = "gundem", limit: int = 10) -> str:
40
+ """Latest headlines from a Hürriyet section.
41
+ section: e.g. "gundem", "ekonomi", "dunya", "sporarena",
42
+ "kelebek-magazin", "teknoloji" (see hurriyet_list_categories).
43
+ limit 1..30."""
44
+ try:
45
+ s = section.strip().strip("/").lower()
46
+ if s not in _N.SECTIONS:
47
+ raise ValueError(
48
+ f"unknown section {section!r}; known: {', '.join(_N.SECTIONS)}"
49
+ )
50
+ _validate_limit(limit)
51
+ rows = _N.parse_article_links(_http.fetch_section(s))
52
+ except (ValueError, _http.HurriyetError) as e:
53
+ return _err(e)
54
+ return json.dumps(
55
+ {"section": s, "count": len(rows[:limit]), "articles": rows[:limit]},
56
+ ensure_ascii=False,
57
+ )
58
+
59
+
60
+ @mcp.tool()
61
+ def hurriyet_get_article(url: str) -> str:
62
+ """Full article text. url: a hurriyet.com.tr article URL
63
+ (or its path, e.g. /gundem/<slug>-<id>)."""
64
+ try:
65
+ if not url or not url.strip():
66
+ raise ValueError("url must not be empty")
67
+ art = _N.parse_article_page(_http.fetch_article(url.strip()))
68
+ except (ValueError, _http.HurriyetError) as e:
69
+ return _err(e)
70
+ return json.dumps({"url": url.strip(), **art}, ensure_ascii=False)
71
+
72
+
73
+ @mcp.tool()
74
+ def hurriyet_search_news(query: str, limit: int = 10) -> str:
75
+ """Search Hürriyet news by keyword.
76
+ query: e.g. "deprem", "futbol", "ekonomi". limit 1..30.
77
+ Known keywords redirect to a topic hub — the returned
78
+ `landed_url` shows where the search actually landed."""
79
+ try:
80
+ if not query or not query.strip():
81
+ raise ValueError("query must not be empty")
82
+ _validate_limit(limit)
83
+ rows = _N.parse_article_links(_http.fetch_search(query.strip()))
84
+ landed = _http.last_url
85
+ except (ValueError, _http.HurriyetError) as e:
86
+ return _err(e)
87
+ return json.dumps(
88
+ {
89
+ "query": query.strip(),
90
+ "landed_url": landed,
91
+ "count": len(rows[:limit]),
92
+ "articles": rows[:limit],
93
+ },
94
+ ensure_ascii=False,
95
+ )
96
+
97
+
98
+ @mcp.tool()
99
+ def hurriyet_get_latest(limit: int = 10) -> str:
100
+ """Latest news from the Hürriyet homepage. limit 1..30."""
101
+ try:
102
+ _validate_limit(limit)
103
+ rows = _N.parse_article_links(_http.fetch_home())
104
+ except (ValueError, _http.HurriyetError) as e:
105
+ return _err(e)
106
+ return json.dumps(
107
+ {"count": len(rows[:limit]), "articles": rows[:limit]}, ensure_ascii=False
108
+ )
109
+
110
+
111
+ @mcp.tool()
112
+ def hurriyet_list_categories() -> str:
113
+ """Hürriyet news sections (slug, Turkish name, url)."""
114
+ try:
115
+ cats = _N.list_categories(_http.fetch_home())
116
+ except (ValueError, _http.HurriyetError) as e:
117
+ return _err(e)
118
+ return json.dumps({"count": len(cats), "categories": cats}, ensure_ascii=False)
119
+
120
+
121
+ def main() -> None:
122
+ mcp.run()
123
+
124
+
125
+ if __name__ == "__main__":
126
+ import sys
127
+
128
+ sys.exit(main())
File without changes
@@ -0,0 +1,7 @@
1
+ <html><head><title>6 ilde Telegram üzerinden asılsız ihbar ve şantaj yapan 7 şüpheli tutuklandı - Son Dakika Haber</title></head><body>
2
+ <script type="application/ld+json">{"@context": "https://schema.org","@type": "NewsArticle","inLanguage": "tr-TR","articleSection": "Gündem","mainEntityOfPage": {"@type": "WebPage","@id": "https://www.hurriyet.com.tr/gundem/6-ilde-telegram-uzerinden-asilsiz-ihbar-ve-santaj-yapan-7-supheli-tutuklandi-43369105"},"headline": "6 ilde Telegram üzerinden asılsız ihbar ve şantaj yapan 7 şüpheli tutuklandı","wordCount":"143","keywords":["asılsız ihbar","şantaj","tehdit","Telegram"],"image": {"@type": "ImageObject","url": "https://image.hurimg.com/i/hurriyet/90/1200x675/6aeec764aa7d7bef19766538.jpg","width": "1200","height": "675"},"datePublished":"2026-11-07T09:38:51+03:00","dateModified":"2026-11-07T09:38:51+03:00","copyrightYear": "2026","description": "İzmir merkezli İstanbul, Antalya, Afyonkarahisar, Diyarbakır, Şanlıurfa ve Muğla'da düzenlenen operasyonda, Telegram üzerinden asılsız ihbar ve şantaj yaptıkları belirlenen 9 şüpheli yakalandı. Adliyeye sevk edilen şüphelilerden 7'si tutuklandı.","articleBody": "İzmir Cumhuriyet Başsavcılığı koordinesinde, İzmir Emniyet Müdürlüğü Organize Suçlarla Mücadele Şube Müdürlüğü ve İstihbarat Şube Müdürlüğü ekipleri çalışma yürüttü. Yapılan çalışmalarda şüphelilerin Telegram uygulamasında grup oluşturduğu tespit edildi.Bu grup üzerinden örgütlü hareket eden şüphelilerin, para karşılığında toplumda infial oluşturabilecek bomba, fuhuş ve terör saldırısı içerikli asılsız ihbarlarda bulunduğu belirlendi. Farklı illerde toplam 45 asılsız ihbar gerçekleştiren şüphelilerin, müştekilere yönelik şantaj ve tehdit eylemleriyle haksız ekonomik menfaat sağladığı tespit edildi.7 TUTUKLAMAİzmir merkezli İstanbul, Antalya, Afyonkarahisar, Diyarbakır, Şanlıurfa ve Muğla'nın Fethiye ilçesinde hava destekli müşterek operasyonlar yapıldı. Operasyonlarda 9 şüpheli yakalanarak gözaltına alındı. Emniyetteki işlemlerinin ardından adli makamlara sevk edilen şüphelilerden 7'si tutuklandı.Bülten Abonelik Formu","author": {"@type": "Person", "name": "Tayibe Önel"},"publisher": {"@type": "Organization","name":"www.hurriyet.com.tr","logo": {"@type": "ImageObject","url": "https://image.hurimg.com/i/hurriyet/100/230x60/590c24950f25442978242248.jpg","width": 230,"height": 60}}} </script>
3
+ <p>document.addEventListener(&#x27;DOMContentLoaded&#x27;, () =&gt; {const isLezizz = document.querySelector(&quot;.header-lezizz&quot;);const loginText = isLezizz ? &quot;Giriş&quot; : &quot;GİRİŞ&quot;;try {if (DM) {DM.auth.oidc.init(&#x27;btn-dm-signin&#x27;, {&#x27;storage&#x27;: &#x27;nostorage&#x27;,&#x27;acr_values&#x27;: &#x27;hurriyet&#x27;,&#x27;onsuccess&#x27;: onSuccess,&#x27;onfailure&#x27;: onFailure});function loadGoogleTapOneJs(src) {let script = document.createElement(&#x27;script&#x27;);script.src = src;script.async = true;script.defer = true;document.head.appendChild(script);}function onSuccess(dmUser) {let full_name = `${dmUser.profile.firstName?.trim()} ${dmUser.profile.lastName?.trim()}`;let parent = DM.auth.clickElement.parentElement;if (parent) {parent.removeChild(DM.auth.clickElement);parent.appendChild(DM.auth.clickElement.cloneNode(true));}const sidebarPremiumBox = document.querySelector(&quot;.sidebar__external--signin&quot;),loginAccount = document.querySelector(&#x27;.header__external--account&#x27;),gmConnectBtn = document.querySelector(&quot;.gm-connect-btn&quot;),gmLoginAfter = document.querySelector(&quot;.login-after&quot;),gmLoginInfoElements = document.querySelectorAll(&quot;#gmUserName&quot;),gmLogoutButtons = document.querySelectorAll(&quot;.gm-connect-box .gm-link&quot;),gmLoginContainers = document.querySelectorAll(&quot;.gm-login-container&quot;),h16Header = document.querySelector(&quot;.h16-header&quot;),backdrop = document.querySelector(&#x27;.header__backdrop&#x27;);let profileLink = &quot;https://hesabim.hurriyet.com.tr/#/profil&quot;;let loginUrl = &quot;https://u.hurriyet.com.tr&quot;;let lastUrl = &quot;?lasturl=https://www.hurriyet.com.tr/gundem/6-ilde-telegram-uzerinden-asilsiz-ihbar-ve-santaj-yapan-7-supheli-tutuklandi-43369105&quot;;if (sidebarPremiumBox) {sidebarPremiumBox.innerHTML = GetShortName(full_name);sidebarPremiumBox.setAttribute(&quot;href&quot;, profileLink);}if (loginAccount) {let parent = loginAccount.parentElement;parent.removeChild(loginAccount);parent.appendChild(loginAccount);const advancedHeader = document.querySelector(&quot;.category-advanced-header&quot;);let userProfile = null;if (advancedHeader) {let shortName = full_name.split(&#x27; &#x27;).map(name =&gt; name[0] + &#x27;&#x27;).join(&#x27;&#x27;);const capitalizeFullName = full_name.toLocaleLowerCase(&#x27;tr-TR&#x27;).split(&#x27; &#x27;).map(word =&gt; word.charAt(0).toLocaleUpperCase(&#x27;tr-TR&#x27;) + word.slice(1)).join(&#x27; &#x27;);userProfile = `${shortName}${capitalizeFullName}Kullanıcı BilgilerimÇıkış Yap`;}else {userProfile = `Profili GörÇıkış Yap`;}loginAccount.classList.add(&#x27;login&#x27;);let shortOrFullName;if (IsMobile()) {shortOrFullName = full_name.split(&#x27; &#x27;).map(name =&gt; name[0] + &#x27;.&#x27;).join(&#x27;&#x27;);} else {shortOrFullName = full_name;}loginAccount.innerHTML = GetShortName(shortOrFullName);loginAccount.setAttribute(&quot;href&quot;, &quot;javascript:;&quot;);loginAccount.innerHTML += userProfile;const mobileUserTarget = document.querySelector(&#x27;.mobile-user-profile-menu&#x27;);if (mobileUserTarget) {mobileUserTarget.innerHTML = userProfile;}loginAccount.addEventListener(&quot;click&quot;, () =&gt; {const isOpened = loginAccount.classList.toggle(&quot;opened&quot;);if (window.innerWidth {setCookie(&quot;firstLogin&quot;, false, -1);setCookie(&quot;isPopupShow&quot;, true, 7);document.location = loginUrl +&quot;/accounts/logout&quot; + lastUrl;});}}if (h16Header) {h16Header.setAttribute(&quot;href&quot;, profileLink);let loginPlaces = document.querySelectorAll(&quot;.login-user&quot;);[].forEach.call(loginPlaces, element =&gt; {element.innerText = GetShortName(full_name)});}trackFirstLogin(dmUser);if (gmConnectBtn &amp;&amp; gmLoginAfter) {gmConnectBtn.remove();gmLoginAfter.remove();[].forEach.call(gmLoginContainers, element =&gt; {element.classList.add(&#x27;gm-show&#x27;);});[].forEach.call(gmLoginInfoElements, element =&gt; {element.innerHTML = GetShortName(full_name);});[].forEach.call(gmLogoutButtons, element =&gt; {element.setAttribute(&quot;href&quot;, &quot;javascript:void(0);&quot;);});}}function onFailure(error) {let loginUrl = &quot;https://u.hurriyet.com.tr&quot;;let lastUrl = &quot;?lasturl=https://www.hurriyet.com.tr/gundem/6-ilde-telegram-uzerinden-asilsiz-ihbar-ve-santaj-yapan-7-supheli-tutuklandi-43369105&quot;;popupShowRule();setCookie(&quot;firstLogin&quot;, false, -1);if (document.getElementById(&#x27;logoutbtn&#x27;) != null) {document.getElementById(&#x27;logoutbtn&#x27;).style.display = &#x27;none&#x27;;}document.getElementById(&#x27;btn-dm-signin&#x27;).innerText = loginText;document.getElementById(&#x27;btn-dm-signin&#x27;).href = loginUrl + &quot;/accounts/login&quot; + lastUrl;let loginAccount = document.querySelector(&#x27;.header__external--account&#x27;);let h16Header = document.querySelector(&quot;.h16-header&quot;);if (loginAccount) {loginAccount.classList.remove(&#x27;login&#x27;);}if (h16Header) {loginAccount.setAttribute(&quot;href&quot;, &quot;javascript:void(0)&quot;);let loginPlaces = document.querySelectorAll(&quot;.login-user&quot;);[].forEach.call(loginPlaces, element =&gt; {element.innerText = loginText;});}window.consoleLog?.(&#x27;onFailure&#x27;, error);}function trackFirstLogin(dmUser) {if (getCookie(&quot;firstLogin&quot;) !== &quot;&quot;) {return;}const actionNames = {7: &quot;google&quot;,5: &quot;twitter&quot;,4: &quot;facebook&quot;,17: &quot;apple&quot;};let action = &quot;manuel&quot;;try {const referrer = dmUser.profile.referrer;action = actionNames[referrer] || action;} catch (e) {window.consoleLog?.(&quot;Error referrer&quot;, e);}const script = document.createElement(&quot;script&quot;);script.innerHTML = `window.dataLayer = window.dataLayer || [];window.dataLayer.push({&#x27;event&#x27;: &#x27;login&#x27;,&#x27;Category&#x27;: &#x27;login&#x27;,&#x27;Action&#x27;: &#x27;${action}&#x27;,&#x27;Label&#x27;: &#x27;hurriyet.com&#x27;});`;document.getElementsByTagName(&#x27;head&#x27;)[0].appendChild(script);setCookie(&quot;firstLogin&quot;, true, 15);};function popupShowRule() {let isReferralValid = false;let isWebSiteFirstVisit = false;let isMainPageAutoReload = false;let documentReferrer = document.referrer;if (documentReferrer.includes(&quot;.hurriyet.com.tr&quot;) || documentReferrer.includes(&quot;localhost&quot;) || documentReferrer == &quot;&quot;) {isReferralValid = true;}if (isReferralValid) {if (getCookie(&quot;isWebSiteFirstVisit&quot;) == &quot;&quot;) {setCookie(&quot;isWebSiteFirstVisit&quot;, true, 1);isWebSiteFirstVisit = true;}if (isWebSiteFirstVisit == false &amp;&amp; getCookie(&quot;isPopupShow&quot;) == &quot;&quot;) {setCookie(&quot;isPopupShow&quot;, true, 7);let googleFastLoginControlText = document.getElementById(&quot;googleFastLoginControl&quot;).value;if (googleFastLoginControlText == 1) {window.consoleLog?.(&quot;OneTap: Mobile, Web On&quot;);loadGoogleTapOneJs(&quot;https://accounts.google.com/gsi/client&quot;);}else if (googleFastLoginControlText == 2 &amp;&amp; IsMobile()) {window.consoleLog?.(&quot;OneTap: Mobile On, Web Off&quot;);loadGoogleTapOneJs(&quot;https://accounts.google.com/gsi/client&quot;);}else if (googleFastLoginControlText == 3 &amp;&amp; !IsMobile()) {window.consoleLog?.(&quot;OneTap: Mobile Off, Web On&quot;);loadGoogleTapOneJs(&quot;https://accounts.google.com/gsi/client&quot;);}}}}function setCookie(cname, cvalue, exdays) {const d = new Date();d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));let expires = &quot;expires=&quot; + d.toUTCString();document.cookie = cname + &quot;=&quot; + cvalue + &quot;;&quot; + expires + &quot;;path=/&quot;;}function getCookie(cname) {let name = cname + &quot;=&quot;;let decodedCookie = decodeURIComponent(document.cookie);let ca = decodedCookie.split(&#x27;;&#x27;);for (let i = 0; i 1) {for (var i = 1; i #credential_picker_container {z-index: 9999999 !important;}.dm-modal-container {font-family: &#x27;Metropolis Medium&#x27;;}.adserviceOop{position: fixed;top: 0px;left: 0px;width: 0px;height: 0px;}.news-inline-banner {img {@media screen and (max-width: 767px) {max-height: 165px;height: auto;}@media screen and (min-width: 768px) {max-height: 200px;height: auto;width: auto;max-width: 100%;}}}HABERLERGündem Haberleri6 ilde Telegram üzerinden asılsız ihbar ve şantaj yapan 7 şüpheli tutuklandıGüncelleme Tarihi: Kasım 07, 2026 09:38</p>
4
+ <p>Font BoyutuYazıyı BüyütYazıyı KüçültPaylaşNSosyalTwitterFacebookLinkedinWhatsappFlipboardLinki KopyalaİHAOluşturulma Tarihi: Kasım 07, 2026 09:38İzmir merkezli İstanbul, Antalya, Afyonkarahisar, Diyarbakır, Şanlıurfa ve Muğla&#x27;da düzenlenen operasyonda, Telegram üzerinden asılsız ihbar ve şantaj yaptıkları belirlenen 9 şüpheli yakalandı. Adliyeye sevk edilen şüphelilerden 7&#x27;si tutuklandı.Haberlerimizi Google’da Takip EdinGelişmelerden anında haberdar olun.Google’da tercih edilenkaynak olarak ekleyinHaberin Devamıİzmir Cumhuriyet Başsavcılığı koordinesinde, İzmir Emniyet Müdürlüğü Organize Suçlarla Mücadele Şube Müdürlüğü ve İstihbarat Şube Müdürlüğü ekipleri çalışma yürüttü. Yapılan çalışmalarda şüphelilerin Telegram uygulamasında grup oluşturduğu tespit edildi.</p>
5
+ <p>Bu grup üzerinden örgütlü hareket eden şüphelilerin, para karşılığında toplumda infial oluşturabilecek bomba, fuhuş ve terör saldırısı içerikli asılsız ihbarlarda bulunduğu belirlendi. Farklı illerde toplam 45 asılsız ihbar gerçekleştiren şüphelilerin, müştekilere yönelik şantaj ve tehdit eylemleriyle haksız ekonomik menfaat sağladığı tespit edildi.</p>
6
+ <p>İzmir merkezli İstanbul, Antalya, Afyonkarahisar, Diyarbakır, Şanlıurfa ve Muğla&#x27;nın Fethiye ilçesinde hava destekli müşterek operasyonlar yapıldı. Operasyonlarda 9 şüpheli yakalanarak gözaltına alındı. Emniyetteki işlemlerinin ardından adli makamlara sevk edilen şüphelilerden 7&#x27;si tutuklandı.</p>
7
+ </body></html>
@@ -0,0 +1,35 @@
1
+ <html><head><title>Güncel Haberler - Bugün Yaşanan Son Olaylar, Son Dakika Gündem Haber</title></head><body>
2
+ <a href="/gundem/afyonkarahisarda-8-kisiyi-katletmisti-saldirganin-silahi-12-bin-tlye-aldigi-ortaya-cikti-43369434">Afyonkarahisar&#x27;da 8 kişiyi katletmişti! Saldırganın silahı 12 bin TL&#x27;ye aldığı ortaya çıktı</a>
3
+ <a href="/gundem/universitelerde-4-yillik-egitim-suresi-dusuyor-mu-yok-baskani-ozvardan-yeni-aciklama-43369429">Üniversitelerde 4 yıllık eğitim süresi düşüyor mu? YÖK Başkanı Özvar&#x27;dan yeni açıklama</a>
4
+ <a href="/gundem/manisada-annesini-ve-kardesini-olduren-zanli-tutuklandi-neden-tartistigimizi-ve-oldurdugumu-hatirlamiyorum-43369415">Manisa&#x27;da annesini ve kardeşini öldüren zanlı tutuklandı! &#x27;Neden tartıştığımızı ve öldürdüğümü hatırlamıyorum&#x27;</a>
5
+ <a href="/gundem/aydinda-mobilya-fabrikasinda-yangin-ekipler-mudahale-ediyor-43369356">Aydın&#x27;da mobilya fabrikasında yangın: Ekipler müdahale ediyor</a>
6
+ <a href="/gundem/adana-9-gun-boyunca-festival-coskusu-yasayacak-43369338">Adana, 9 gün boyunca festival coşkusu yaşayacak!</a>
7
+ <a href="/galeri-istanbulun-yani-basinda-kar-yagisi-evler-kara-gomuldu-sabah-kalktik-her-taraf-bembeyaz-olmus-43369214">İstanbul&#x27;un yanı başında kar yağışı! Evler kara gömüldü: &#x27;Sabah kalktık her taraf bembeyaz olmuş&#x27;</a>
8
+ <a href="/gundem/emine-erdogandan-81-il-81-kadin-81-hikaye-bize-ozgu-sergisi-acilis-programina-iliskin-paylasim-43369264">Emine Erdoğan&#x27;dan 81 İl, 81 Kadın, 81 Hikaye: Bize Özgü Sergisi Açılış Programı&#x27;na ilişkin paylaşım</a>
9
+ <a href="/gundem/zeytinburnunda-trafikte-yol-verme-kavgasi-otomobilden-inen-surucu-diger-aracin-camini-yumrukladi-43369234">Zeytinburnu&#x27;nda trafikte yol verme kavgası! Otomobilden inen sürücü, diğer aracın camını yumrukladı</a>
10
+ <a href="/gundem/fon-sorusturmasinda-yeni-gelisme-eski-spk-baskan-vekili-enver-usca-dahil-4-kisi-tutuklandi-43369236">Fon soruşturmasında yeni gelişme: Eski SPK Başkan Vekili Enver Usca dahil 4 kişi tutuklandı</a>
11
+ <a href="/gundem/kepengin-uzaktan-kumanda-sifresini-kopyalayip-kuyumcuya-girdi-caldigi-200-yuzuk-sahte-cikti-43369177">Kepengin uzaktan kumanda şifresini kopyalayıp kuyumcuya girdi! Çaldığı 200 yüzük sahte çıktı</a>
12
+ <a href="/galeri-asi-nehrinde-oltaya-takildi-ilk-kez-boyle-bir-turle-karsilastim-ben-yemem-43369179">Asi Nehri&#x27;nde oltaya takıldı: &#x27;İlk kez böyle bir türle karşılaştım, ben yemem&#x27;</a>
13
+ <a href="/gundem/sokak-saticilarina-yonelik-uyusturucu-operasyonu-82-kisi-tutuklandi-43369147">Sokak satıcılarına yönelik uyuşturucu operasyonu: 82 kişi tutuklandı</a>
14
+ <a href="/gundem/super-lotoda-buyuk-ikramiye-heyecani-850-milyon-312-bin-836-tlye-ulasti-43369133">Süper Loto’da büyük ikramiye heyecanı! 850 milyon 312 bin 836 TL’ye ulaştı</a>
15
+ <a href="/gundem/6-ilde-telegram-uzerinden-asilsiz-ihbar-ve-santaj-yapan-7-supheli-tutuklandi-43369105">6 ilde Telegram üzerinden asılsız ihbar ve şantaj yapan 7 şüpheli tutuklandı</a>
16
+ <a href="/gundem/istanbullulara-sabah-surprizi-yagmur-sonrasi-gokkusagi-cikti-43369098">İstanbullulara sabah sürprizi: Yağmur sonrası gökkuşağı çıktı</a>
17
+ <a href="/gundem/kirmizi-bultenle-araniyordu-uyusturucu-baronu-mersinde-yakalandi-43369078">Kırmızı bültenle aranıyordu: Uyuşturucu baronu Mersin&#x27;de yakalandı</a>
18
+ <a href="/gundem/bursada-ayni-yone-giden-otomobiller-carpisti-kaza-ani-kamerada-43369071">Bursa’da aynı yöne giden otomobiller çarpıştı: Kaza anı kamerada</a>
19
+ <a href="/gundem/turkiye-sibirya-soguklarinin-etkisinde-sicakliklar-dusuyor-meteorolojiden-uyari-ustune-uyari-saganak-yagis-kar-zirai-don-ve-43369067">Türkiye Sibirya soğuklarının etkisinde: Sıcaklıklar düşüyor! Meteoroloji&#x27;den uyarı üstüne uyarı: Sağanak yağış, kar, zirai don ve fırtına alarmı</a>
20
+ <a href="/gundem/bursada-eglence-mekanina-tufekli-saldiri-43369065">Bursa’da eğlence mekanına tüfekli saldırı</a>
21
+ <a href="/gundem/tutuklama-gerekcesi-tasfiyeyi-bilerek-islem-yapti-para-ayni-gun-yatti-43369033">Tutuklama gerekçesi: Tasfiyeyi bilerek işlem yaptı para aynı gün yattı</a>
22
+ <a href="/gundem/erdogan-uluslararasi-sistem-iflas-bayragi-cekti-43369044">Cumhurbaşkanı Erdoğan: Uluslararası sistem iflas bayrağı çekti</a>
23
+ <a href="/gundem/tarihi-gecmis-ilaclari-piyasaya-suren-21-supheli-tutuklandi-43369012">Tarihi geçmiş ilaçları piyasaya süren 21 şüpheli tutuklandı</a>
24
+ <a href="/gundem/cinayeti-tum-detaylariyla-anlatti-annem-ve-sevgilisi-beni-zorladi-43369016">Cinayeti tüm detaylarıyla anlattı: Annem ve sevgilisi beni zorladı</a>
25
+ <a href="/gundem/denizolgunun-olumu-bassavcilik-ek-rapor-istedi-43369024">Denizolgun’un ölümü... Başsavcılık ek rapor istedi</a>
26
+ <a href="/gundem/hakan-bahcetepeye-ev-hapsiyle-tahliye-43369026">Hakan Bahçetepe’ye ev hapsiyle tahliye</a>
27
+ <a href="/gundem/kurtulmus-hayati-bir-mesele-43369030">Kurtulmuş: Hayati bir mesele</a>
28
+ <a href="/gundem/yeni-parti-yarin-sandiga-gidiyor-genel-baskanlik-icin-on-secim-43369031">YENİ Parti yarın sandığa gidiyor... Genel Başkanlık için ön seçim</a>
29
+ <a href="/gundem/tedmemden-yesil-egitim-onerileri-43369041">TEDMEM’DEN ‘Yeşil Eğitim’ önerileri</a>
30
+ <a href="/gundem/doluluk-orani-yuzde-61e-cikti-son-10-yilin-rekoru-43369050">Doluluk oranı yüzde 61’e çıktı... Son 10 yılın rekoru</a>
31
+ <a href="/gundem/secil-erzana-212-yil-istendi-43369010">Seçil Erzan’a 212 yıl istendi</a>
32
+ <a href="/galeri-ayetel-kursi-okunusu-ayetel-kursi-duasi-turkce-anlami-ve-diyanet-meali-arapca-yazilisi-faziletleri-ve-bilgiler-42300047">Ayetel Kürsi</a>
33
+ <a href="/galeri-guzel-sozler-2023-ozlu-sozler-ile-en-anlamli-sevgiliye-etkileyici-romantik-resimli-ve-kisa-mesajlar-40792109">Güzel Sözler</a>
34
+ <a href="/ramazan-ozel/namaz-sureleri/yasin-suresi-oku-dinle-yasin-suresi-arapca-turkce-okunusu-ve-meali-6-sayfa-tamami-42681788">Yasin Suresi</a>
35
+ </body></html>
@@ -0,0 +1,47 @@
1
+ <html><head><title>Futbol Haberleri - Son Dakika Futbol Hakkında Güncel Haber ve Bilgiler </title></head><body>
2
+ <a href="/sporarena/fredden-galatasaray-derbisi-aciklamasi-cilginlik-43369101">Fred&#x27;den Galatasaray derbisi açıklaması: &#x27;Çılgınlık!&#x27;</a>
3
+ <a href="/sporarena/jose-mourinho-yarinki-mac-beni-korkutuyor-43368494">Jose Mourinho: &#x27;Yarınki maç beni korkutuyor!&#x27;</a>
4
+ <a href="/sporarena/senol-gunesten-arda-guler-sozleri-dunyanin-en-iyi-5-oyuncusu-arasinda-gosterilebilecek-bir-yildiz-43368178">Şenol Güneş&#x27;ten Arda Güler sözleri: &#x27;Dünyanın en iyi 5 oyuncusu arasında gösterilebilecek bir yıldız!&#x27;</a>
5
+ <a href="/sporarena/leroy-sane-icin-olay-sozler-galatasaraya-katkisi-cok-sinirli-43366904">Leroy Sane için olay sözler: &#x27;Galatasaray&#x27;a katkısı çok sınırlı!&#x27;</a>
6
+ <a href="/sporarena/aleksey-batrakov-galatasaray-kariyerine-gorkemli-bir-baslangic-yapti-43365723">Aleksey Batrakov, Galatasaray kariyerine görkemli bir başlangıç yaptı</a>
7
+ <a href="/sporarena/galatasaray-2-2-stuttgart-mac-ozeti-uefa-genclik-ligi-43364740">Galatasaray 2-2 Stuttgart maç özeti (UEFA Gençlik Ligi)</a>
8
+ <a href="/sporarena/dort-buyuk-kulubun-futbolculari-kktcli-oyuncularla-dostluk-macina-cikacak-43364532">Dört büyük kulübün futbolcuları KKTC’li oyuncularla dostluk maçına çıkacak</a>
9
+ <a href="/sporarena/osimhen-formunu-yukseltiyor-raphinha-gollerini-ikiser-ikiser-siralamaya-devam-ediyor-iste-misliden-gunun-oyuncu-bahisleri-43364451">Osimhen formunu yükseltiyor, Raphinha gollerini ikişer ikişer sıralamaya devam ediyor! İşte Misli’den günün oyuncu bahisleri</a>
10
+ <a href="/sporarena/vitor-pereira-gecmiste-kararlarimda-bazi-hatalar-yaptim-43363600">Vitor Pereira: &#x27;Geçmişte kararlarımda bazı hatalar yaptım&#x27;</a>
11
+ <a href="/sporarena/genclerbirliginde-ve-atalantada-kotu-gidis-suruyor-kayserisporun-maclari-bol-gollu-geciyor-iste-misliden-gunun-tuyolari-43363160">Gençlerbirliği’nde ve Atalanta’da Kötü Gidiş Sürüyor, Kayserispor’un Maçları Bol Gollü Geçiyor… İşte Misli’den Günün Tüyoları!</a>
12
+ <a href="/sporarena/fernando-musleradan-buyuk-hata-43362367">Fernando Muslera&#x27;dan büyük hata!</a>
13
+ <a href="/sporarena/besiktas-kasimpasaya-konuk-olacak-macin-heyecani-canli-sohbet-ile-mislide-43362231">Beşiktaş, Kasımpaşa&#x27;ya konuk olacak! Maçın heyecanı canlı sohbet ile Misli&#x27;de</a>
14
+ <a href="/avrupa/besiktas-avrupadaki-genclere-sahip-cikacak-43362164">Beşiktaş Avrupa’daki gençlere sahip çıkacak</a>
15
+ <a href="/avrupa/bundesligada-zirve-yarisi-iyice-kizisti-43362159">Bundesliga&#x27;da zirve yarışı iyice kızıştı</a>
16
+ <a href="/sporarena/arda-turan-yasadigimiz-zorluklarin-tamamen-farkindayim-43361407">Arda Turan: &#x27;Yaşadığımız zorlukların tamamen farkındayım!&#x27;</a>
17
+ <a href="/sporarena/trabzonspor-teknik-direktoru-thomas-reis-baskiyi-seviyorum-43360334">Trabzonspor Teknik Direktörü Thomas Reis: &#x27;Baskıyı seviyorum!&#x27;</a>
18
+ <a href="/sporarena/fenerbahcede-samandira-cikarmasi-kuyta-destek-icin-toplandilar-43360099">Fenerbahçe&#x27;de Samandıra çıkarması! Kuyt&#x27;a destek için toplandılar</a>
19
+ <a href="/sporarena/a-milli-takim-teknik-direktoru-sergen-yalcindan-iddialara-yanit-o-aciklamalari-sov-amaciyla-yapmistim-futbolcular-da-bunu-biliyor-43360031">A Milli Takım teknik direktörü Sergen Yalçın&#x27;dan iddialara yanıt: &#x27;O açıklamaları şov amacıyla yapmıştım, futbolcular da bunu biliyor!&#x27;</a>
20
+ <a href="/sporarena/besiktasta-orkun-kokcu-gelismesi-43359519">Beşiktaş&#x27;ta Orkun Kökçü gelişmesi!</a>
21
+ <a href="/sporarena/super-ligde-10-hafta-heyecani-baslayacak-43359077">Süper Lig&#x27;de 10. hafta heyecanı başlayacak</a>
22
+ <a href="/sporarena/ronaldonun-milli-takima-vedasinda-jorge-jesus-etkisi-43369358">Ronaldo&#x27;nun milli takıma vedasında Jorge Jesus etkisi!</a>
23
+ <a href="/sporarena/gedson-fernandesten-batrakov-sozleri-cok-iyi-bir-kulube-gitti-43369229">Gedson Fernandes&#x27;ten Batrakov sözleri: &#x27;Çok iyi bir kulübe gitti!&#x27;</a>
24
+ <a href="/gundem/secil-erzana-212-yil-istendi-43369010">Seçil Erzan’a 212 yıl istendi</a>
25
+ <a href="/sporarena/tff-tahkim-kurulundan-archie-brown-karari-43368940">TFF Tahkim Kurulu&#x27;ndan Archie Brown kararı!</a>
26
+ <a href="/sporarena/fenerbahceden-4-nisan-saldirisiyla-ilgili-aciklama-43368859">Fenerbahçe&#x27;den 4 Nisan saldırısıyla ilgili açıklama!</a>
27
+ <a href="/gundem/son-dakika-fenerbahce-otobusune-saldiriya-iddianame-5-supheli-hakkinda-ceza-istendi-43368834">Son dakika : Fenerbahçe otobüsüne saldırıya iddianame… 5 şüpheli hakkında ceza istendi</a>
28
+ <a href="/sporarena/ahmetcan-kaplan-kimdir-nereli-kac-yasinda-ahmetcan-kaplan-hangi-takimlarda-oynadi-43368718">Ahmetcan Kaplan kimdir, nereli, kaç yaşında? Ahmetcan Kaplan hangi takımlarda oynadı?</a>
29
+ <a href="/spor/videolar/super-lig/video-emre-belozoglu-fenerbahceye-imza-atti-5-sene-onceki-emre-degilim-43368679">Emre Belözoğlu, Fenerbahçe&#x27;ye imza attı: 5 sene önceki Emre değilim</a>
30
+ <a href="/sporarena/serhat-oztasdelen-kimdir-nereli-kac-yasinda-serhat-oztasdelen-hangi-takimlarda-oynadi-43368614">Serhat Öztaşdelen kimdir, nereli, kaç yaşında? Milli Takım Aday Kadrosuna çağrılan Serhat Öztaşdelen hangi takımlarda oynadı?</a>
31
+ <a href="/sporarena/emre-belozoglu-fenerbahceye-imza-atti-5-sene-onceki-emre-degilim-43368560">Emre Belözoğlu, Fenerbahçe&#x27;ye imza attı: 5 sene önceki Emre değilim</a>
32
+ <a href="/spor/videolar/diger/video-sergen-yalcin-a-milli-takim-aday-kadrosunu-acikladi-43368423">Sergen Yalçın, A Milli Takım aday kadrosunu açıkladı</a>
33
+ <a href="/sporarena/sergen-yalcin-a-milli-takim-aday-kadrosunu-acikladi-43368357">Sergen Yalçın, A Milli Takım aday kadrosunu açıkladı! İşte kadroda yer almayan isimler</a>
34
+ <a href="/spor/videolar/diger/video-senol-gunesten-arda-guler-sozleri-dunyanin-en-iyi-5-oyuncusu-arasinda-gosterilebilecek-bir-yildiz-43368340">Şenol Güneş&#x27;ten Arda Güler sözleri: &#x27;Dünyanın en iyi 5 oyuncusu arasında gösterilebilecek bir yıldız!&#x27;</a>
35
+ <a href="/sporarena/fifa-baskani-infantinodan-portekiz-milli-takimini-birakan-ronaldoya-mesaj-43368058">FIFA Başkanı Infantino&#x27;dan Portekiz Milli Takımı&#x27;nı bırakan Ronaldo&#x27;ya mesaj</a>
36
+ <a href="/sporarena/galatasaray-super-ligde-amedsporu-agirlayacak-43368015">Galatasaray, Süper Lig&#x27;de Amedspor&#x27;u ağırlayacak</a>
37
+ <a href="/sporarena/berke-ozerden-sergen-yalcin-ve-milli-takim-aciklamasi-43367578">Berke Özer&#x27;den Sergen Yalçın ve Milli Takım açıklaması!</a>
38
+ <a href="/gundem/bakanliktan-giyilmis-coraplarla-bagis-paylasimina-suc-duyurusu-43367469">Bakanlıktan giyilmiş çoraplarla bağış paylaşımına suç duyurusu</a>
39
+ <a href="/sporarena/galeri-cristiano-ronaldo-milli-takimi-birakti-mi-ronaldodan-portekiz-milli-takim-karari-43367179">Cristiano Ronaldo Portekiz Milli Takımı&#x27;nı bıraktı mı? Ronaldo’dan milli takım kararı!</a>
40
+ <a href="/sporarena/sergen-yalcinin-54-yas-gunu-rivada-kutlandi-43367390">Sergen Yalçın&#x27;ın 54. yaş günü Riva&#x27;da kutlandı</a>
41
+ <a href="/sporarena/burak-yilmazdan-fenerbahce-maci-oncesi-iddiali-aciklama-kazanmak-icin-elimizden-gelenin-fazlasini-yapacagiz-43367209">Burak Yılmaz&#x27;dan Fenerbahçe maçı öncesi iddialı açıklama: &#x27;Kazanmak için elimizden gelenin fazlasını yapacağız!&#x27;</a>
42
+ <a href="/spor/videolar/super-lig/video-burak-yilmaz-fenerbahce-macini-kazanmak-icin-sahaya-cikacagiz-43367195">Burak Yılmaz&#x27;dan Fenerbahçe maçı öncesi iddialı açıklama: &#x27;Kazanmak için elimizden gelenin fazlasını yapacağız!&#x27;</a>
43
+ <a href="/sporarena/fenerbahce-paylasti-emre-belozoglu-ilk-antrenmanina-cikti-43367128">Fenerbahçe paylaştı! Emre Belözoğlu ilk antrenmanına çıktı</a>
44
+ <a href="/sporarena/freiburg-nerenin-takimi-hangi-ligde-oynuyor-freiburg-guncel-kadro-degeri-2026-43367119">Freiburg nerenin takımı, hangi ligde oynuyor? Freiburg güncel kadro değeri 2026!</a>
45
+ <a href="/sporarena/cristiano-ronaldo-milli-takim-kariyerini-noktaladi-43367037">Başbakan ve Federasyon Başkanı kararından döndüremedi: Cristiano Ronaldo milli takım kariyerini noktaladı!</a>
46
+ <a href="/sporarena/bayern-munih-harry-kane-ile-sozlesme-yeniledi-43366995">Bayern Münih, Harry Kane ile sözleşme yeniledi!</a>
47
+ </body></html>
@@ -0,0 +1,58 @@
1
+ """Live tests — hit the real hurriyet.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 HURRIYET_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 hurriyet_mcp import http as _http
17
+ from hurriyet_mcp import news as _N
18
+
19
+ pytestmark = pytest.mark.live
20
+ SKIP = os.environ.get("HURRIYET_SKIP_LIVE") == "1"
21
+
22
+
23
+ @pytest.mark.skipif(SKIP, reason="HURRIYET_SKIP_LIVE=1")
24
+ class TestLive(unittest.TestCase):
25
+ def test_gundem_is_real(self):
26
+ rows = _N.parse_article_links(_http.fetch_section("gundem"))
27
+ self.assertGreaterEqual(len(rows), 10)
28
+ print(f"\nLIVE gundem: {len(rows)} headlines, first = {rows[0]['title'][:80]}")
29
+ time.sleep(1)
30
+
31
+ def test_article_is_real(self):
32
+ rows = _N.parse_article_links(_http.fetch_section("gundem"))
33
+ art = _N.parse_article_page(_http.fetch_article(rows[0]["url"]))
34
+ self.assertTrue(art["headline"])
35
+ self.assertTrue(art["date_published"])
36
+ self.assertGreater(len(art["body"]), 100)
37
+ print(f"\nLIVE article: {art['headline'][:80]} ({art['date_published']}, "
38
+ f"{len(art['body'])} chars body)")
39
+ time.sleep(1)
40
+
41
+ def test_search_is_real(self):
42
+ rows = _N.parse_article_links(_http.fetch_search("futbol"))
43
+ self.assertGreaterEqual(len(rows), 5)
44
+ print(f"\nLIVE search 'futbol' -> {_http.last_url}: {len(rows)} articles")
45
+ time.sleep(1)
46
+
47
+ def test_home_and_categories_are_real(self):
48
+ home = _http.fetch_home()
49
+ rows = _N.parse_article_links(home)
50
+ cats = _N.list_categories(home)
51
+ self.assertGreaterEqual(len(rows), 20)
52
+ self.assertGreaterEqual(len(cats), 12)
53
+ print(f"\nLIVE home: {len(rows)} links, {len(cats)} categories")
54
+ time.sleep(1)
55
+
56
+ def test_unknown_section_404s(self):
57
+ with self.assertRaises(_http.HurriyetError):
58
+ _http.fetch_section("boyle-bir-bolum-yok-xyz123")
@@ -0,0 +1,71 @@
1
+ """Offline tests — parse real captured Hürriyet 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 hurriyet_mcp import news as N
9
+
10
+ FIX = pathlib.Path(__file__).resolve().parent / "fixtures"
11
+
12
+
13
+ class TestArticleLinks(unittest.TestCase):
14
+ def test_gundem(self):
15
+ rows = N.parse_article_links((FIX / "gundem.html").read_text(encoding="utf-8"))
16
+ self.assertEqual(len(rows), 30)
17
+ first = rows[0]
18
+ self.assertEqual(first["id"], "43369434")
19
+ self.assertEqual(first["section"], "gundem")
20
+ self.assertEqual(first["kind"], "article")
21
+ self.assertTrue(first["url"].startswith("https://www.hurriyet.com.tr/gundem/"))
22
+ self.assertIn("Afyonkarahisar", first["title"])
23
+
24
+ def test_search_page(self):
25
+ rows = N.parse_article_links((FIX / "search.html").read_text(encoding="utf-8"))
26
+ self.assertEqual(len(rows), 45)
27
+ self.assertTrue(all(r["id"] for r in rows))
28
+
29
+ def test_dedupe_and_short_titles(self):
30
+ html = (
31
+ '<a href="/gundem/x-y-12345678">Short</a>'
32
+ '<a href="/gundem/uzun-baslik-haber-ornegi-12345678">Uzun başlıklı haber örneği burada</a>'
33
+ '<a href="/gundem/uzun-baslik-haber-ornegi-12345678">Uzun başlıklı haber örneği burada</a>'
34
+ )
35
+ rows = N.parse_article_links(html)
36
+ self.assertEqual(len(rows), 1)
37
+ self.assertEqual(rows[0]["id"], "12345678")
38
+
39
+
40
+ class TestArticlePage(unittest.TestCase):
41
+ @classmethod
42
+ def setUpClass(cls):
43
+ cls.a = N.parse_article_page((FIX / "article.html").read_text(encoding="utf-8"))
44
+
45
+ def test_meta(self):
46
+ self.assertTrue(self.a["headline"].startswith("6 ilde Telegram"))
47
+ self.assertEqual(self.a["section"], "Gündem")
48
+ self.assertEqual(self.a["date_published"], "2026-11-07T09:38:51+03:00")
49
+ self.assertEqual(self.a["word_count"], 143)
50
+
51
+ def test_body(self):
52
+ self.assertEqual(len(self.a["body"]), 931)
53
+ self.assertTrue(self.a["body"].startswith("İzmir Cumhuriyet Başsavcılığı"))
54
+
55
+ def test_no_newsarticle_raises(self):
56
+ with self.assertRaises(ValueError):
57
+ N.parse_article_page("<html><body>no ld here</body></html>")
58
+
59
+
60
+ class TestCategories(unittest.TestCase):
61
+ def test_curated_list(self):
62
+ cats = N.list_categories()
63
+ self.assertEqual(len(cats), 15)
64
+ slugs = [c["slug"] for c in cats]
65
+ for want in ("gundem", "ekonomi", "dunya", "sporarena", "teknoloji"):
66
+ self.assertIn(want, slugs)
67
+ self.assertEqual(cats[0]["name"], "Gündem")
68
+
69
+
70
+ if __name__ == "__main__":
71
+ unittest.main()