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.
- emko_mcp-0.1.0/PKG-INFO +84 -0
- emko_mcp-0.1.0/README.md +64 -0
- emko_mcp-0.1.0/pyproject.toml +39 -0
- emko_mcp-0.1.0/src/emko_mcp/__init__.py +6 -0
- emko_mcp-0.1.0/src/emko_mcp/server.py +430 -0
emko_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
emko_mcp-0.1.0/README.md
ADDED
|
@@ -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,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
|
+
# [](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()
|