emko-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,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: emko-mcp
3
+ Version: 0.1.0
4
+ Summary: Emko Elektronik ürün manuellerini (PDF) arayıp okuyan MCP sunucusu
5
+ Project-URL: Homepage, https://github.com/OGUZHANCOSKUN/emko-mcp
6
+ Project-URL: Issues, https://github.com/OGUZHANCOSKUN/emko-mcp/issues
7
+ Author: Oğuzhan Coşkun
8
+ License: MIT
9
+ Keywords: claude,datasheet,emko,manual,mcp,pdf
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Documentation
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: httpx>=0.27
17
+ Requires-Dist: mcp>=1.2.0
18
+ Requires-Dist: pypdf>=4.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # emko-mcp
22
+
23
+ Emko Elektronik (www.emkoelektronik.com.tr) ürün manuellerini, broşürlerini ve
24
+ firmware dokümanlarını arayıp okuyabilen bir **MCP (Model Context Protocol)**
25
+ sunucusu. Claude Desktop, Claude Code veya MCP destekleyen herhangi bir istemciye
26
+ bağlanır; PDF'leri arka planda indirip metnini çıkararak modele verir.
27
+
28
+ ## Kurulum
29
+
30
+ ```bash
31
+ pip install emko-mcp
32
+ ```
33
+
34
+ Kurulduktan sonra `emko-mcp` komutu kullanılabilir hale gelir.
35
+
36
+ ## Claude Desktop'a bağlama
37
+
38
+ `claude_desktop_config.json` dosyanıza şu girişi ekleyin:
39
+
40
+ ```json
41
+ {
42
+ "mcpServers": {
43
+ "emko": {
44
+ "command": "emko-mcp"
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ Config dosyasının yeri:
51
+ - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
52
+ - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
53
+
54
+ Kaydedip Claude Desktop'ı tamamen kapatıp yeniden açın.
55
+
56
+ > `emko-mcp` komutu bulunamazsa, tam yolu kullanabilirsiniz. Yolu bulmak için:
57
+ > Windows'ta `where emko-mcp`, macOS/Linux'ta `which emko-mcp`.
58
+
59
+ ## Sağladığı araçlar (tools)
60
+
61
+ - **`emko_search_manuals`** — Bir anahtar kelimeyle (örn. "Proop", "EtherCAT",
62
+ "IPERTU") eşleşen dosyaları listeler. İndirmez, sadece adları döndürür.
63
+ - **`emko_get_manual`** — Eşleşen ilk PDF'i indirip metnini çıkarır ve döndürür.
64
+ - **`emko_crawl_category`** — Verilen ürün kategori sayfalarına tek tek girip
65
+ manuelleri indeksler (Yükleme Merkezi'nde henüz listelenmemiş yeni ürünler için).
66
+
67
+ ## Örnek kullanım
68
+
69
+ Claude'a şunları sorabilirsiniz:
70
+
71
+ - "emko_search_manuals ile Proop panellerinin kılavuzlarını bul"
72
+ - "EtherCAT Bus Coupler'ın SDO parametrelerine bak" (Claude uygun tool'u seçer)
73
+ - "IPERTU Slim Plus PLC modülünün kullanım kılavuzunu özetle"
74
+
75
+ ## Notlar
76
+
77
+ - Bu paket, herkese açık `www.emkoelektronik.com.tr` sayfalarını okur;
78
+ kimlik doğrulama gerektirmez.
79
+ - Bazı eski broşürler taranmış (görsel) PDF olabilir; bu durumda metin çıkarımı
80
+ sınırlı olur.
81
+
82
+ ## Lisans
83
+
84
+ MIT
@@ -0,0 +1,64 @@
1
+ # emko-mcp
2
+
3
+ Emko Elektronik (www.emkoelektronik.com.tr) ürün manuellerini, broşürlerini ve
4
+ firmware dokümanlarını arayıp okuyabilen bir **MCP (Model Context Protocol)**
5
+ sunucusu. Claude Desktop, Claude Code veya MCP destekleyen herhangi bir istemciye
6
+ bağlanır; PDF'leri arka planda indirip metnini çıkararak modele verir.
7
+
8
+ ## Kurulum
9
+
10
+ ```bash
11
+ pip install emko-mcp
12
+ ```
13
+
14
+ Kurulduktan sonra `emko-mcp` komutu kullanılabilir hale gelir.
15
+
16
+ ## Claude Desktop'a bağlama
17
+
18
+ `claude_desktop_config.json` dosyanıza şu girişi ekleyin:
19
+
20
+ ```json
21
+ {
22
+ "mcpServers": {
23
+ "emko": {
24
+ "command": "emko-mcp"
25
+ }
26
+ }
27
+ }
28
+ ```
29
+
30
+ Config dosyasının yeri:
31
+ - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
32
+ - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
33
+
34
+ Kaydedip Claude Desktop'ı tamamen kapatıp yeniden açın.
35
+
36
+ > `emko-mcp` komutu bulunamazsa, tam yolu kullanabilirsiniz. Yolu bulmak için:
37
+ > Windows'ta `where emko-mcp`, macOS/Linux'ta `which emko-mcp`.
38
+
39
+ ## Sağladığı araçlar (tools)
40
+
41
+ - **`emko_search_manuals`** — Bir anahtar kelimeyle (örn. "Proop", "EtherCAT",
42
+ "IPERTU") eşleşen dosyaları listeler. İndirmez, sadece adları döndürür.
43
+ - **`emko_get_manual`** — Eşleşen ilk PDF'i indirip metnini çıkarır ve döndürür.
44
+ - **`emko_crawl_category`** — Verilen ürün kategori sayfalarına tek tek girip
45
+ manuelleri indeksler (Yükleme Merkezi'nde henüz listelenmemiş yeni ürünler için).
46
+
47
+ ## Örnek kullanım
48
+
49
+ Claude'a şunları sorabilirsiniz:
50
+
51
+ - "emko_search_manuals ile Proop panellerinin kılavuzlarını bul"
52
+ - "EtherCAT Bus Coupler'ın SDO parametrelerine bak" (Claude uygun tool'u seçer)
53
+ - "IPERTU Slim Plus PLC modülünün kullanım kılavuzunu özetle"
54
+
55
+ ## Notlar
56
+
57
+ - Bu paket, herkese açık `www.emkoelektronik.com.tr` sayfalarını okur;
58
+ kimlik doğrulama gerektirmez.
59
+ - Bazı eski broşürler taranmış (görsel) PDF olabilir; bu durumda metin çıkarımı
60
+ sınırlı olur.
61
+
62
+ ## Lisans
63
+
64
+ MIT
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "emko-mcp"
7
+ version = "0.1.0"
8
+ description = "Emko Elektronik ürün manuellerini (PDF) arayıp okuyan MCP sunucusu"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Oğuzhan Coşkun" },
14
+ ]
15
+ keywords = ["mcp", "emko", "manual", "datasheet", "pdf", "claude"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Intended Audience :: Developers",
21
+ "Topic :: Documentation",
22
+ ]
23
+ dependencies = [
24
+ "mcp>=1.2.0",
25
+ "httpx>=0.27",
26
+ "pypdf>=4.0",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/OGUZHANCOSKUN/emko-mcp"
31
+ Issues = "https://github.com/OGUZHANCOSKUN/emko-mcp/issues"
32
+
33
+ # Kurulunca `emko-mcp` komutu PATH'e eklenir; MCP config'inde command olarak
34
+ # doğrudan bu kullanılabilir.
35
+ [project.scripts]
36
+ emko-mcp = "emko_mcp:main"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/emko_mcp"]
@@ -0,0 +1,6 @@
1
+ """Emko Elektronik ürün manuelleri için MCP sunucusu."""
2
+
3
+ from .server import main, mcp
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["main", "mcp", "__version__"]
@@ -0,0 +1,430 @@
1
+ """
2
+ emko_mcp.py — Emko Elektronik ürün manuellerini arayıp getiren MCP sunucusu.
3
+
4
+ Bulgular (bkz. emko_pdf_indirici.py):
5
+ - Site tamamen statik render ediyor, tarayıcı otomasyonuna gerek yok.
6
+ - "İNDİR" butonları <a href> değil, onclick="location.href='downloads?<TOKEN>'"
7
+ şeklinde JS ile çalışıyor. Sayfadaki <base href> yüzünden bu KÖK dizine
8
+ çözülüyor -> gerçek adres:
9
+ https://www.emkoelektronik.com.tr/downloads?<TOKEN>
10
+
11
+ Bu sunucu iki tool sağlar:
12
+ 1. emko_search_manuals — bir sayfadaki (varsayılan: Yükleme Merkezi) dosyaları
13
+ anahtar kelimeyle arar, indirmeden sadece eşleşenleri listeler.
14
+ 2. emko_get_manual — eşleşen ilk PDF'i indirir, metnini çıkarır ve döner.
15
+
16
+ Kurulum:
17
+ pip install "mcp[cli]" httpx pypdf
18
+
19
+ Çalıştırma (stdio, örn. Claude Desktop / Claude Code'a bağlamak için):
20
+ python emko_mcp.py
21
+ """
22
+
23
+ import re
24
+ import io
25
+ import os
26
+ from typing import List, Optional
27
+
28
+ import httpx
29
+ from pydantic import BaseModel, Field, ConfigDict
30
+ from mcp.server.fastmcp import FastMCP
31
+
32
+ try:
33
+ from pypdf import PdfReader
34
+ except ImportError:
35
+ PdfReader = None # metin çıkarımı olmadan da tool'lar çalışabilsin diye
36
+
37
+ mcp = FastMCP("emko_mcp")
38
+
39
+ BASE = "https://www.emkoelektronik.com.tr"
40
+ DEFAULT_INDEX_URL = f"{BASE}/tr/e-destek/yukleme-merkezi"
41
+ # İndirme linkleri sayfada onclick="location.href='downloads?<token>'" olarak duruyor.
42
+ # Sitedeki <base href="https://www.emkoelektronik.com.tr/"> etiketi yüzünden bu göreli
43
+ # link KÖK dizine göre çözülüyor -> https://www.emkoelektronik.com.tr/downloads?<token>
44
+ DOWNLOAD_URL = f"{BASE}/downloads"
45
+
46
+ # Manuel olarak (tarayıcıdan indirip) bu klasöre koyduğunuz PDF'ler için.
47
+ # Ortam değişkeni EMKO_MANUALS_DIR ile değiştirilebilir; yoksa script'in
48
+ # bulunduğu yerdeki "manuals" klasörü kullanılır.
49
+ LOCAL_MANUALS_DIR = os.environ.get(
50
+ "EMKO_MANUALS_DIR",
51
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "manuals"),
52
+ )
53
+
54
+ HEADERS = {
55
+ "User-Agent": (
56
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
57
+ "(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
58
+ )
59
+ }
60
+
61
+ DOWNLOAD_RE = re.compile(r"""location\.href=['"]downloads\?([A-F0-9]+)['"]""")
62
+
63
+ # Satır içindeki İLK anlamlı <b>...</b> metnini dosya adı olarak alıyoruz.
64
+ # Hem ürün sayfalarında ("Dosya Adı : <b>X.pdf</b> ... <b onclick=...>İNDİR</b>")
65
+ # hem de Yükleme Merkezi'nin tablo satırlarında ("<td><b>X</b></td> ... <button
66
+ # onclick=...><b>İNDİR</b></button>") dosya adı, token'dan önceki ilk bold metindir;
67
+ # sadece "İNDİR", "·" ve "12.34 Mb/Kb" gibi boyut ifadelerini eliyoruz.
68
+ BOLD_RE = re.compile(r"<b>\s*([^<]+?)\s*</b>", re.IGNORECASE)
69
+ SIZE_LIKE_RE = re.compile(r"^[\d.,]+\s*(Kb|Mb|Gb)\.?$", re.IGNORECASE)
70
+
71
+ # Kategori sayfalarında her ürün kutucuğu bu görselle sarmalanıyor:
72
+ # [![](.../spacer.gif)](https://www.emkoelektronik.com.tr/tr/urunler/<slug>)
73
+ PRODUCT_LINK_RE = re.compile(
74
+ r"""spacer\.gif['"]?\)\]\((https://www\.emkoelektronik\.com\.tr/tr/urunler/[^)\s]+)\)"""
75
+ )
76
+
77
+ # Basit bellek-içi önbellek: {index_key: [(token, filename), ...]}
78
+ # index_key ya gerçek bir URL (doğrudan fetch edilir) ya da emko_crawl_category'nin
79
+ # doldurduğu bir "cache_key" ismi olabilir.
80
+ _index_cache: dict[str, list[tuple[str, str]]] = {}
81
+
82
+
83
+ def _extract_product_links(html: str) -> list[str]:
84
+ seen = set()
85
+ links = []
86
+ for m in PRODUCT_LINK_RE.finditer(html):
87
+ url = m.group(1)
88
+ if url not in seen:
89
+ seen.add(url)
90
+ links.append(url)
91
+ return links
92
+
93
+
94
+ async def _crawl_index(
95
+ start_urls: list[str], max_depth: int = 3
96
+ ) -> list[tuple[str, str, str]]:
97
+ """Kategori sayfalarından başlayıp gerçek ürünlere inene kadar dalar.
98
+
99
+ Döndürür: [(token, dosya_adı, bulunduğu_sayfa_url), ...] — aynı token birden
100
+ fazla üründe geçse bile sadece bir kez döner.
101
+ """
102
+ visited: set[str] = set()
103
+ seen_tokens: set[str] = set()
104
+ results: list[tuple[str, str, str]] = []
105
+
106
+ async def visit(url: str, depth: int):
107
+ if url in visited or depth > max_depth:
108
+ return
109
+ visited.add(url)
110
+ try:
111
+ html = await _fetch(url)
112
+ except httpx.HTTPError:
113
+ return
114
+
115
+ pairs = _extract_entries(html)
116
+ if pairs:
117
+ for token, filename in pairs:
118
+ if token not in seen_tokens:
119
+ seen_tokens.add(token)
120
+ results.append((token, filename, url))
121
+ else:
122
+ for link in _extract_product_links(html):
123
+ await visit(link, depth + 1)
124
+
125
+ for u in start_urls:
126
+ await visit(u, depth=1)
127
+ return results
128
+
129
+
130
+ async def _fetch(url: str) -> str:
131
+ async with httpx.AsyncClient(headers=HEADERS, timeout=30, follow_redirects=True) as client:
132
+ resp = await client.get(url)
133
+ resp.raise_for_status()
134
+ return resp.text
135
+
136
+
137
+ def _first_meaningful_bold(window: str) -> Optional[str]:
138
+ for m in BOLD_RE.finditer(window):
139
+ text = m.group(1).strip()
140
+ if not text or text == "İNDİR" or text == "·" or SIZE_LIKE_RE.match(text):
141
+ continue
142
+ return text
143
+ return None
144
+
145
+
146
+ def _extract_entries(html: str) -> list[tuple[str, str]]:
147
+ """(token, dosya_adı) çiftlerini döner.
148
+
149
+ Her token için, bir önceki token'ın bittiği yerden bu token'a kadarki
150
+ pencerede ilk anlamlı <b> metnini dosya adı olarak alır. Bu hem ürün
151
+ sayfalarında ("Dosya Adı : <b>X.pdf</b>") hem de Yükleme Merkezi'nin tablo
152
+ satırlarında ("<td><b>X</b></td>") doğru çalışır.
153
+ """
154
+ entries = []
155
+ prev_end = 0
156
+ for m in DOWNLOAD_RE.finditer(html):
157
+ token = m.group(1)
158
+ window = html[prev_end:m.start()]
159
+ filename = _first_meaningful_bold(window) or f"dosya-{token[:10]}.pdf"
160
+ entries.append((token, filename))
161
+ prev_end = m.end()
162
+ return entries
163
+
164
+
165
+ async def _get_index(index_url: str, force_refresh: bool = False) -> list[tuple[str, str]]:
166
+ if force_refresh or index_url not in _index_cache:
167
+ html = await _fetch(index_url)
168
+ _index_cache[index_url] = _extract_entries(html)
169
+ return _index_cache[index_url]
170
+
171
+
172
+ def _extract_pdf_text(pdf_bytes: bytes, max_chars: int = 200000) -> str:
173
+ if PdfReader is None:
174
+ return "[pypdf kurulu değil — metin çıkarılamadı. `pip install pypdf` çalıştırın.]"
175
+ # Gelen içerik geçerli bir PDF değilse (site hata sayfası döndürdüyse vs.)
176
+ # pypdf istisna fırlatır; bunu yakalayıp anlamlı bir mesaja çeviriyoruz.
177
+ if not pdf_bytes[:5].startswith(b"%PDF"):
178
+ snippet = pdf_bytes[:200].decode("utf-8", errors="replace")
179
+ return (
180
+ "[İndirilen içerik geçerli bir PDF değil (site beklenen dosyayı "
181
+ f"döndürmedi). İlk baytlar: {snippet!r}]"
182
+ )
183
+ try:
184
+ reader = PdfReader(io.BytesIO(pdf_bytes))
185
+ parts = [page.extract_text() or "" for page in reader.pages]
186
+ except Exception as e:
187
+ return f"[PDF okunamadı: {type(e).__name__}: {e}]"
188
+ text = "\n".join(parts).strip()
189
+ if len(text) > max_chars:
190
+ text = text[:max_chars] + f"\n\n[...metin {len(text)} karakter, {max_chars} karakterde kesildi...]"
191
+ return text or "[PDF'ten metin çıkarılamadı — muhtemelen taranmış/görsel bir belge.]"
192
+
193
+
194
+ class SearchManualsInput(BaseModel):
195
+ """emko_search_manuals için girdi modeli."""
196
+
197
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
198
+
199
+ query: str = Field(
200
+ ...,
201
+ description="Dosya adında veya ilişkili ürün bilgisinde aranacak anahtar kelime "
202
+ "(örn. 'Proop 7 Lite', 'PIREG', 'EtherCAT').",
203
+ min_length=1,
204
+ max_length=200,
205
+ )
206
+ index_url: Optional[str] = Field(
207
+ default=None,
208
+ description="Aranacak liste sayfası. Boş bırakılırsa Yükleme Merkezi "
209
+ "(tüm kategoriler) kullanılır.",
210
+ )
211
+ limit: int = Field(default=10, description="Döndürülecek maksimum sonuç sayısı.", ge=1, le=50)
212
+
213
+
214
+ @mcp.tool(
215
+ name="emko_search_manuals",
216
+ annotations={
217
+ "title": "Emko manuel/dosya ara",
218
+ "readOnlyHint": True,
219
+ "destructiveHint": False,
220
+ "idempotentHint": True,
221
+ "openWorldHint": True,
222
+ },
223
+ )
224
+ async def emko_search_manuals(params: SearchManualsInput) -> str:
225
+ """Emko Elektronik sitesindeki manuel/broşür/firmware PDF'lerini anahtar kelimeyle arar.
226
+
227
+ Dosyayı indirmez — sadece eşleşen dosya adlarını listeler. Belirli bir dosyanın
228
+ içeriğini okumak için sonradan emko_get_manual çağırın.
229
+
230
+ Args:
231
+ params (SearchManualsInput): query (aranacak kelime), index_url (opsiyonel
232
+ liste sayfası), limit (maksimum sonuç).
233
+
234
+ Returns:
235
+ str: Eşleşen dosyaların "- <dosya adı>" biçiminde listesi, ya da eşleşme
236
+ yoksa bunu belirten bir mesaj.
237
+ """
238
+ index_url = params.index_url or DEFAULT_INDEX_URL
239
+ try:
240
+ entries = await _get_index(index_url)
241
+ except httpx.HTTPError as e:
242
+ return f"Hata: Sayfa çekilemedi ({index_url}): {e}"
243
+
244
+ q = params.query.lower()
245
+ matches = [fname for _, fname in entries if q in fname.lower()]
246
+ # tekrarları kaldır, sırayı koru
247
+ seen = set()
248
+ unique_matches = []
249
+ for m in matches:
250
+ if m not in seen:
251
+ seen.add(m)
252
+ unique_matches.append(m)
253
+
254
+ if not unique_matches:
255
+ return f"'{params.query}' için eşleşen dosya bulunamadı ({len(entries)} dosya tarandı)."
256
+
257
+ unique_matches = unique_matches[: params.limit]
258
+ return "Eşleşen dosyalar:\n" + "\n".join(f"- {m}" for m in unique_matches)
259
+
260
+
261
+ class GetManualInput(BaseModel):
262
+ """emko_get_manual için girdi modeli."""
263
+
264
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
265
+
266
+ query: str = Field(
267
+ ...,
268
+ description="İndirilecek dosyayı bulmak için anahtar kelime (dosya adının "
269
+ "bir parçası yeterli, örn. 'Proop 7 Lite Hızlı Başlangıç').",
270
+ min_length=1,
271
+ max_length=200,
272
+ )
273
+ index_url: Optional[str] = Field(
274
+ default=None,
275
+ description="Aranacak liste sayfası. Boş bırakılırsa Yükleme Merkezi kullanılır.",
276
+ )
277
+ max_chars: int = Field(
278
+ default=200000,
279
+ description="Döndürülecek maksimum metin uzunluğu. Belge daha uzunsa bu "
280
+ "noktada kesilir. Çok büyük bir kılavuzun tamamı gerekiyorsa "
281
+ "artırılabilir.",
282
+ ge=1000,
283
+ le=1000000,
284
+ )
285
+
286
+
287
+ @mcp.tool(
288
+ name="emko_get_manual",
289
+ annotations={
290
+ "title": "Emko manuel getir ve metnini çıkar",
291
+ "readOnlyHint": True,
292
+ "destructiveHint": False,
293
+ "idempotentHint": True,
294
+ "openWorldHint": True,
295
+ },
296
+ )
297
+ async def emko_get_manual(params: GetManualInput) -> str:
298
+ """Sorguya en iyi uyan PDF'i indirir ve metnini çıkarıp döner.
299
+
300
+ Birden fazla dosya eşleşirse ilkini kullanır ve diğer eşleşen dosya adlarını
301
+ da bilgi amaçlı listeler, böylece gerekirse daha spesifik bir sorguyla tekrar
302
+ çağrılabilir.
303
+
304
+ Args:
305
+ params (GetManualInput): query (dosya adı/ürün anahtar kelimesi),
306
+ index_url (opsiyonel liste sayfası).
307
+
308
+ Returns:
309
+ str: Bulunan dosyanın adı, kaç alternatif daha bulunduğu ve PDF'ten
310
+ çıkarılan metin (çok uzunsa kesilmiş olarak).
311
+ """
312
+ index_url = params.index_url or DEFAULT_INDEX_URL
313
+ try:
314
+ entries = await _get_index(index_url)
315
+ except httpx.HTTPError as e:
316
+ return f"Hata: Sayfa çekilemedi ({index_url}): {e}"
317
+
318
+ q = params.query.lower()
319
+ matches = [(token, fname) for token, fname in entries if q in fname.lower()]
320
+
321
+ if not matches:
322
+ return f"'{params.query}' için eşleşen dosya bulunamadı."
323
+
324
+ token, filename = matches[0]
325
+ download_url = f"{DOWNLOAD_URL}?{token}"
326
+ # Site, sayfadan gelmeyen doğrudan istekleri 503 ile engelleyebiliyor;
327
+ # gerçek bir ürün sayfasından tıklanmış gibi görünmek için Referer ekliyoruz.
328
+ dl_headers = {**HEADERS, "Referer": index_url}
329
+
330
+ try:
331
+ async with httpx.AsyncClient(headers=dl_headers, timeout=60, follow_redirects=True) as client:
332
+ resp = await client.get(download_url)
333
+ resp.raise_for_status()
334
+ pdf_bytes = resp.content
335
+ except httpx.HTTPError as e:
336
+ return f"Hata: '{filename}' indirilemedi: {e}"
337
+
338
+ text = _extract_pdf_text(pdf_bytes, max_chars=params.max_chars)
339
+
340
+ extra = ""
341
+ if len(matches) > 1:
342
+ others = ", ".join(f"'{m[1]}'" for m in matches[1:5])
343
+ extra = f"\n\n(Not: {len(matches) - 1} alternatif dosya daha eşleşti: {others}...)"
344
+
345
+ return f"# {filename}\n\n{text}{extra}"
346
+
347
+
348
+ class CrawlCategoryInput(BaseModel):
349
+ """emko_crawl_category için girdi modeli."""
350
+
351
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
352
+
353
+ category_urls: List[str] = Field(
354
+ ...,
355
+ description="Bir veya daha fazla kategori/alt kategori sayfası URL'i "
356
+ "(örn. '.../plc-ve-hmi-paneller/plc-cpu-moduller'). Üst "
357
+ "kategori verilirse alt kategoriler otomatik keşfedilir.",
358
+ min_length=1,
359
+ max_length=10,
360
+ )
361
+ cache_key: str = Field(
362
+ default="son_tarama",
363
+ description="Bu taramanın sonucuna daha sonra emko_search_manuals / "
364
+ "emko_get_manual çağrılarında index_url olarak vermek için "
365
+ "bir isim (örn. 'plc_hmi').",
366
+ min_length=1,
367
+ max_length=100,
368
+ )
369
+ max_depth: int = Field(
370
+ default=3,
371
+ description="Kategori içine ne kadar derine inilebileceği (üst kategori "
372
+ "-> alt kategori -> ürün = 3 seviye).",
373
+ ge=1,
374
+ le=4,
375
+ )
376
+
377
+
378
+ @mcp.tool(
379
+ name="emko_crawl_category",
380
+ annotations={
381
+ "title": "Emko kategori tara ve manuelleri indeksle",
382
+ "readOnlyHint": True,
383
+ "destructiveHint": False,
384
+ "idempotentHint": True,
385
+ "openWorldHint": True,
386
+ },
387
+ )
388
+ async def emko_crawl_category(params: CrawlCategoryInput) -> str:
389
+ """Verilen kategori sayfa(lar)ındaki her ürüne tek tek girip manuellerini indeksler.
390
+
391
+ Yükleme Merkezi'nden farklı olarak, doğrudan ürün kategori sayfalarını (örn.
392
+ "PLC-CPU Modülleri", "I/O Genişleme Modülleri") tarar — bu sayede Yükleme
393
+ Merkezi'nde henüz listelenmemiş yeni ürünler de bulunabilir. PDF'leri indirmez,
394
+ sadece hangi dosyanın hangi üründe olduğunu bulup önbelleğe alır. Aynı manuel
395
+ birden fazla üründe paylaşılıyorsa yalnızca bir kez listelenir.
396
+
397
+ Sonraki adım: bulunan bir dosyanın gerçek içeriğini almak için
398
+ emko_get_manual(query=..., index_url=<cache_key>) çağırın.
399
+
400
+ Args:
401
+ params (CrawlCategoryInput): category_urls (taranacak sayfalar),
402
+ cache_key (sonucu saklamak için isim), max_depth (tarama derinliği).
403
+
404
+ Returns:
405
+ str: Bulunan benzersiz manuellerin "- <dosya adı> (kaynak: <ürün url>)"
406
+ biçiminde listesi, kaç tanesinin bulunduğu ve sonraki adım bilgisi.
407
+ """
408
+ entries = await _crawl_index(params.category_urls, max_depth=params.max_depth)
409
+
410
+ _index_cache[params.cache_key] = [(t, f) for t, f, _ in entries]
411
+
412
+ if not entries:
413
+ return "Taranan sayfalarda hiç manuel bulunamadı."
414
+
415
+ lines = "\n".join(f"- {filename} (kaynak: {src})" for _, filename, src in entries)
416
+ return (
417
+ f"{len(entries)} benzersiz manuel bulundu ve '{params.cache_key}' adıyla "
418
+ f"önbelleğe alındı.\n"
419
+ f"İçeriğini almak için: emko_get_manual(query=\"...\", index_url=\"{params.cache_key}\")\n\n"
420
+ f"{lines}"
421
+ )
422
+
423
+
424
+ def main():
425
+ """Konsol giriş noktası (pyproject.toml -> [project.scripts] emko-mcp)."""
426
+ mcp.run()
427
+
428
+
429
+ if __name__ == "__main__":
430
+ main()