internal-web-reader 0.1.0__py3-none-any.whl
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.
- internal_web_reader/__init__.py +3 -0
- internal_web_reader/__main__.py +129 -0
- internal_web_reader/cookies.py +44 -0
- internal_web_reader/reader.py +114 -0
- internal_web_reader-0.1.0.dist-info/METADATA +45 -0
- internal_web_reader-0.1.0.dist-info/RECORD +9 -0
- internal_web_reader-0.1.0.dist-info/WHEEL +5 -0
- internal_web_reader-0.1.0.dist-info/entry_points.txt +2 -0
- internal_web_reader-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""MCP Server 入口。Cookie 懒加载:首次访问某域名时自动从浏览器提取。"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from .cookies import get_cookie_header
|
|
9
|
+
from .reader import read_page
|
|
10
|
+
|
|
11
|
+
logging.basicConfig(
|
|
12
|
+
level=logging.INFO,
|
|
13
|
+
format="[web-reader] %(message)s",
|
|
14
|
+
handlers=[logging.StreamHandler()],
|
|
15
|
+
)
|
|
16
|
+
log = logging.getLogger("internal_web_reader")
|
|
17
|
+
|
|
18
|
+
# domain → cookie header string(内存缓存)
|
|
19
|
+
_cookie_cache: dict[str, str] = {}
|
|
20
|
+
|
|
21
|
+
mcp = FastMCP("internal-web-reader")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_cookie(url: str) -> str | None:
|
|
25
|
+
"""获取 URL 对应的 Cookie,没有就自动从浏览器提取并缓存。"""
|
|
26
|
+
try:
|
|
27
|
+
host = urlparse(url).hostname or ""
|
|
28
|
+
except Exception:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
if not host:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
# 1. 缓存命中
|
|
35
|
+
if host in _cookie_cache:
|
|
36
|
+
return _cookie_cache[host]
|
|
37
|
+
|
|
38
|
+
# 2. 父域名
|
|
39
|
+
parts = host.split(".")
|
|
40
|
+
for i in range(1, len(parts) - 1):
|
|
41
|
+
parent = ".".join(parts[i:])
|
|
42
|
+
if parent in _cookie_cache:
|
|
43
|
+
return _cookie_cache[parent]
|
|
44
|
+
|
|
45
|
+
# 3. 自动提取
|
|
46
|
+
header = get_cookie_header(host)
|
|
47
|
+
if header:
|
|
48
|
+
_cookie_cache[host] = header
|
|
49
|
+
return header
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _refresh_cookie(host: str) -> str | None:
|
|
53
|
+
"""强制刷新某域名的 Cookie(401/403 时调用)。"""
|
|
54
|
+
_cookie_cache.pop(host, None)
|
|
55
|
+
parts = host.split(".")
|
|
56
|
+
for i in range(1, len(parts) - 1):
|
|
57
|
+
_cookie_cache.pop(".".join(parts[i:]), None)
|
|
58
|
+
|
|
59
|
+
header = get_cookie_header(host)
|
|
60
|
+
if header:
|
|
61
|
+
_cookie_cache[host] = header
|
|
62
|
+
return header
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Tools ────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@mcp.tool()
|
|
69
|
+
def read_page_tool(url: str) -> str:
|
|
70
|
+
"""读取任意网页,返回干净的 Markdown。
|
|
71
|
+
Cookie 自动从浏览器提取,不需要预配置。
|
|
72
|
+
支持内网 GitLab、Wiki、文档中心等。
|
|
73
|
+
"""
|
|
74
|
+
cookie = _get_cookie(url)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
page = read_page(url, cookie=cookie)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
# 401/403 → 刷新 Cookie 重试
|
|
80
|
+
msg = str(e)
|
|
81
|
+
if any(k in msg for k in ("401", "403", "Unauthorized", "Forbidden")):
|
|
82
|
+
try:
|
|
83
|
+
host = urlparse(url).hostname or ""
|
|
84
|
+
except Exception:
|
|
85
|
+
raise
|
|
86
|
+
log.info(f"鉴权失败,刷新 {host} Cookie...")
|
|
87
|
+
cookie = _refresh_cookie(host)
|
|
88
|
+
if cookie:
|
|
89
|
+
log.info("重试中...")
|
|
90
|
+
page = read_page(url, cookie=cookie)
|
|
91
|
+
else:
|
|
92
|
+
raise
|
|
93
|
+
else:
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
lines = [f"# {page.title}", f"> {page.url}", "", page.content]
|
|
97
|
+
|
|
98
|
+
if 0 < len(page.links) <= 30:
|
|
99
|
+
lines += ["", "---", "## 页面链接"]
|
|
100
|
+
for lk in page.links:
|
|
101
|
+
lines.append(f"- [{lk['text']}]({lk['href']})")
|
|
102
|
+
|
|
103
|
+
return "\n".join(lines)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@mcp.tool()
|
|
107
|
+
def list_links(url: str) -> str:
|
|
108
|
+
"""列出页面上的所有链接,用于浏览内网站点。"""
|
|
109
|
+
cookie = _get_cookie(url)
|
|
110
|
+
page = read_page(url, cookie=cookie)
|
|
111
|
+
|
|
112
|
+
if not page.links:
|
|
113
|
+
return f"页面 {url} 上未找到链接。"
|
|
114
|
+
|
|
115
|
+
lines = [f"# {page.title} 的链接", f"> {len(page.links)} 个", ""]
|
|
116
|
+
for lk in page.links:
|
|
117
|
+
lines.append(f"- [{lk['text']}]({lk['href']})")
|
|
118
|
+
return "\n".join(lines)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── Entry ────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main():
|
|
125
|
+
mcp.run()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""从本地浏览器自动提取 Cookie。"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
log = logging.getLogger("internal_web_reader")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_cookie_header(domain: str) -> str | None:
|
|
9
|
+
"""
|
|
10
|
+
从 Chrome / Edge / Firefox 提取指定域名的 Cookie,
|
|
11
|
+
返回 "name1=val1; name2=val2" 格式的字符串。
|
|
12
|
+
找不到返回 None。
|
|
13
|
+
"""
|
|
14
|
+
cookie_jars = []
|
|
15
|
+
|
|
16
|
+
# 依次尝试各浏览器
|
|
17
|
+
try:
|
|
18
|
+
import browser_cookie3
|
|
19
|
+
|
|
20
|
+
for loader in (browser_cookie3.chrome, browser_cookie3.edge, browser_cookie3.firefox):
|
|
21
|
+
try:
|
|
22
|
+
cj = loader(domain_name=domain)
|
|
23
|
+
cookie_jars.append(cj)
|
|
24
|
+
except Exception as e:
|
|
25
|
+
log.debug(f"{loader.__name__} 跳过: {e}")
|
|
26
|
+
except ImportError:
|
|
27
|
+
log.warning("browser_cookie3 未安装,无法自动提取 Cookie")
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
# 合并 + 去重
|
|
31
|
+
seen: dict[str, str] = {}
|
|
32
|
+
for cj in cookie_jars:
|
|
33
|
+
for c in cj:
|
|
34
|
+
# 匹配: 精确域名 或 父域名 (.example.com 匹配 sub.example.com)
|
|
35
|
+
cdomain = c.domain.lstrip(".")
|
|
36
|
+
if domain == cdomain or domain.endswith("." + cdomain):
|
|
37
|
+
seen[c.name] = c.value
|
|
38
|
+
|
|
39
|
+
if not seen:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
header = "; ".join(f"{k}={v}" for k, v in seen.items())
|
|
43
|
+
log.info(f"从浏览器提取 {len(seen)} 个 Cookie ({domain})")
|
|
44
|
+
return header
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""抓取网页,HTML → 干净 Markdown。"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from urllib.parse import urljoin
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from bs4 import BeautifulSoup
|
|
9
|
+
import html2text
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger("internal_web_reader")
|
|
12
|
+
|
|
13
|
+
_H2T = html2text.HTML2Text()
|
|
14
|
+
_H2T.body_width = 0 # 不自动换行
|
|
15
|
+
_H2T.ignore_links = False
|
|
16
|
+
_H2T.ignore_images = False
|
|
17
|
+
_H2T.protect_links = True
|
|
18
|
+
_H2T.wrap_links = False
|
|
19
|
+
_H2T.unicode_snob = True
|
|
20
|
+
|
|
21
|
+
_UA = (
|
|
22
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
23
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
24
|
+
"Chrome/124.0.0.0 Safari/537.36"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class PageResult:
|
|
30
|
+
url: str
|
|
31
|
+
title: str
|
|
32
|
+
content: str
|
|
33
|
+
links: list[dict] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_page(url: str, cookie: str | None = None, timeout: float = 30) -> PageResult:
|
|
37
|
+
"""抓取一个 URL,返回清洗后的 Markdown。"""
|
|
38
|
+
|
|
39
|
+
headers = {
|
|
40
|
+
"User-Agent": _UA,
|
|
41
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
42
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
43
|
+
}
|
|
44
|
+
if cookie:
|
|
45
|
+
headers["Cookie"] = cookie
|
|
46
|
+
|
|
47
|
+
resp = httpx.get(url, headers=headers, follow_redirects=True, timeout=timeout)
|
|
48
|
+
resp.raise_for_status()
|
|
49
|
+
|
|
50
|
+
ct = resp.headers.get("content-type", "")
|
|
51
|
+
if "html" not in ct and "text" not in ct:
|
|
52
|
+
return PageResult(url=url, title=url, content=f"[非 HTML] Content-Type: {ct}")
|
|
53
|
+
|
|
54
|
+
# 检测编码
|
|
55
|
+
resp.encoding = resp.charset_encoding or "utf-8"
|
|
56
|
+
html = resp.text
|
|
57
|
+
|
|
58
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
59
|
+
|
|
60
|
+
# 标题
|
|
61
|
+
title = ""
|
|
62
|
+
if soup.title and soup.title.string:
|
|
63
|
+
title = soup.title.string.strip()
|
|
64
|
+
if not title:
|
|
65
|
+
h1 = soup.find("h1")
|
|
66
|
+
if h1:
|
|
67
|
+
title = h1.get_text(strip=True)
|
|
68
|
+
title = title or url
|
|
69
|
+
|
|
70
|
+
# 去噪
|
|
71
|
+
for tag in soup.find_all(["script", "style", "noscript", "iframe", "svg", "nav", "footer", "aside"]):
|
|
72
|
+
tag.decompose()
|
|
73
|
+
|
|
74
|
+
# 找主体内容
|
|
75
|
+
main = None
|
|
76
|
+
for sel in ("article", "main", '[role="main"]', ".content", ".article-content", ".markdown-body", "#content", "#main"):
|
|
77
|
+
el = soup.select_one(sel)
|
|
78
|
+
if el and len(el.get_text(strip=True)) > 100:
|
|
79
|
+
main = el
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
target = main or soup.body or soup
|
|
83
|
+
|
|
84
|
+
# 提取链接
|
|
85
|
+
links = _extract_links(target, url)
|
|
86
|
+
|
|
87
|
+
# HTML → Markdown
|
|
88
|
+
content_html = str(target)
|
|
89
|
+
markdown = _H2T.handle(content_html).strip()
|
|
90
|
+
|
|
91
|
+
# 截断
|
|
92
|
+
if len(markdown) > 200_000:
|
|
93
|
+
markdown = markdown[:200_000] + "\n\n... [内容过长,已截断]"
|
|
94
|
+
|
|
95
|
+
return PageResult(url=url, title=title, content=markdown, links=links)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _extract_links(soup, base_url: str) -> list[dict]:
|
|
99
|
+
seen: set[str] = set()
|
|
100
|
+
links: list[dict] = []
|
|
101
|
+
for a in soup.find_all("a", href=True):
|
|
102
|
+
href = a["href"].strip()
|
|
103
|
+
if not href or href.startswith("#") or href.startswith("javascript:") or href.startswith("mailto:"):
|
|
104
|
+
continue
|
|
105
|
+
try:
|
|
106
|
+
absolute = urljoin(base_url, href)
|
|
107
|
+
except Exception:
|
|
108
|
+
continue
|
|
109
|
+
if absolute in seen:
|
|
110
|
+
continue
|
|
111
|
+
seen.add(absolute)
|
|
112
|
+
text = a.get_text(strip=True)[:100] or href
|
|
113
|
+
links.append({"text": text, "href": absolute})
|
|
114
|
+
return links
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: internal-web-reader
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP Server - let Claude Code read internal web pages via browser cookies
|
|
5
|
+
Author: zhanghaoran18006
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: mcp,claude,internal,wiki,confluence,gitlab
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: mcp[cli]<2.0.0,>=1.2.0
|
|
15
|
+
Requires-Dist: httpx>=0.27.0
|
|
16
|
+
Requires-Dist: beautifulsoup4>=4.12.0
|
|
17
|
+
Requires-Dist: html2text>=2024.2.26
|
|
18
|
+
Requires-Dist: browser_cookie3>=0.19.1
|
|
19
|
+
|
|
20
|
+
# Internal Web Reader
|
|
21
|
+
|
|
22
|
+
让 Claude Code 能读内网网页。
|
|
23
|
+
|
|
24
|
+
## 安装
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
pip install internal-web-reader
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## 配置 Claude Code
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
claude mcp add -s user internal-web-reader -- internal-web-reader
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 使用
|
|
37
|
+
|
|
38
|
+
在 Claude Code 里直接给链接:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
> 看看 https://git.corpautohome.com/xxx/-/issues/123
|
|
42
|
+
> 读一下 https://doc.autohome.com.cn/docapi/page/share/share_1Hx8joJJIfo
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Cookie 自动从浏览器提取。前提:浏览器里登录过、VPN 连着。
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
internal_web_reader/__init__.py,sha256=EUOrBX7nMn6Rt-PgPHWBEjuORtXuWfQuuey2hNFzFT8,81
|
|
2
|
+
internal_web_reader/__main__.py,sha256=YxSIfjaCroshuvjqaSqcZG-7iRzYnVOn_gwLBuMOjak,3628
|
|
3
|
+
internal_web_reader/cookies.py,sha256=3HEA1fSjor_BqrnntRpQlLKUERCuBewy9sJRoPl7pFI,1363
|
|
4
|
+
internal_web_reader/reader.py,sha256=t1hmIMRONSyBHftghpqkCbM-PPt0Z6uGNdGJgxQzG70,3246
|
|
5
|
+
internal_web_reader-0.1.0.dist-info/METADATA,sha256=hifEi16FrM3aTJ3m8zzUGDy2J5n-W3yYkc8NGGF-_zs,1211
|
|
6
|
+
internal_web_reader-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
internal_web_reader-0.1.0.dist-info/entry_points.txt,sha256=iEhJm-03JedWF3i1Awbegyg1u2-YEF_TYC0QGORzV24,74
|
|
8
|
+
internal_web_reader-0.1.0.dist-info/top_level.txt,sha256=jhE9xwuy3_-TQU3BWs3xkH32SA_1bYFDEA1ArtYGnZ8,20
|
|
9
|
+
internal_web_reader-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
internal_web_reader
|