internal-web-reader 0.2.1__tar.gz → 0.2.3__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.
- {internal_web_reader-0.2.1 → internal_web_reader-0.2.3}/PKG-INFO +1 -1
- {internal_web_reader-0.2.1 → internal_web_reader-0.2.3}/pyproject.toml +1 -1
- internal_web_reader-0.2.3/src/internal_web_reader/__main__.py +54 -0
- internal_web_reader-0.2.3/src/internal_web_reader/reader.py +182 -0
- {internal_web_reader-0.2.1 → internal_web_reader-0.2.3}/src/internal_web_reader.egg-info/PKG-INFO +1 -1
- internal_web_reader-0.2.1/src/internal_web_reader/__main__.py +0 -56
- internal_web_reader-0.2.1/src/internal_web_reader/reader.py +0 -166
- {internal_web_reader-0.2.1 → internal_web_reader-0.2.3}/README.md +0 -0
- {internal_web_reader-0.2.1 → internal_web_reader-0.2.3}/setup.cfg +0 -0
- {internal_web_reader-0.2.1 → internal_web_reader-0.2.3}/src/internal_web_reader/__init__.py +0 -0
- {internal_web_reader-0.2.1 → internal_web_reader-0.2.3}/src/internal_web_reader.egg-info/SOURCES.txt +0 -0
- {internal_web_reader-0.2.1 → internal_web_reader-0.2.3}/src/internal_web_reader.egg-info/dependency_links.txt +0 -0
- {internal_web_reader-0.2.1 → internal_web_reader-0.2.3}/src/internal_web_reader.egg-info/entry_points.txt +0 -0
- {internal_web_reader-0.2.1 → internal_web_reader-0.2.3}/src/internal_web_reader.egg-info/requires.txt +0 -0
- {internal_web_reader-0.2.1 → internal_web_reader-0.2.3}/src/internal_web_reader.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "internal-web-reader"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "MCP Server - let Claude Code read internal web pages via browser cookies"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""MCP Server 入口。"""
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from .reader import read_page, close_browser
|
|
9
|
+
|
|
10
|
+
logging.basicConfig(
|
|
11
|
+
level=logging.INFO,
|
|
12
|
+
format="[web-reader] %(message)s",
|
|
13
|
+
handlers=[logging.StreamHandler()],
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
mcp = FastMCP("internal-web-reader")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@mcp.tool()
|
|
20
|
+
async def read_web_page(url: str) -> str:
|
|
21
|
+
"""[READ WEB PAGES] Open any URL in a local Chrome browser and return Markdown content.
|
|
22
|
+
USE THIS TOOL for all web page reading, especially internal/intranet sites
|
|
23
|
+
(GitLab, Wiki, docs requiring VPN/SSO). Do NOT use the built-in Fetch tool for web pages.
|
|
24
|
+
First visit to a login-required site will pop up a browser window for you to log in.
|
|
25
|
+
Session is saved automatically for future visits.
|
|
26
|
+
"""
|
|
27
|
+
page = await read_page(url)
|
|
28
|
+
|
|
29
|
+
lines = [f"# {page.title}", f"> {page.url}", "", page.content]
|
|
30
|
+
if 0 < len(page.links) <= 30:
|
|
31
|
+
lines += ["", "---", "## Links"]
|
|
32
|
+
for lk in page.links:
|
|
33
|
+
lines.append(f"- [{lk['text']}]({lk['href']})")
|
|
34
|
+
return "\n".join(lines)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@mcp.tool()
|
|
38
|
+
async def list_web_links(url: str) -> str:
|
|
39
|
+
"""List all links on a web page for navigation."""
|
|
40
|
+
page = await read_page(url)
|
|
41
|
+
if not page.links:
|
|
42
|
+
return f"No links found on {url}."
|
|
43
|
+
lines = [f"# {page.title} - Links", f"> {len(page.links)} links", ""]
|
|
44
|
+
for lk in page.links:
|
|
45
|
+
lines.append(f"- [{lk['text']}]({lk['href']})")
|
|
46
|
+
return "\n".join(lines)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main():
|
|
50
|
+
mcp.run()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
main()
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""用 Playwright (async) 启动浏览器读网页。登录一次,session 自动保存。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from urllib.parse import urljoin
|
|
9
|
+
|
|
10
|
+
from bs4 import BeautifulSoup
|
|
11
|
+
import html2text
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger("internal_web_reader")
|
|
14
|
+
|
|
15
|
+
_H2T = html2text.HTML2Text()
|
|
16
|
+
_H2T.body_width = 0
|
|
17
|
+
_H2T.ignore_links = False
|
|
18
|
+
_H2T.ignore_images = False
|
|
19
|
+
_H2T.protect_links = True
|
|
20
|
+
_H2T.wrap_links = False
|
|
21
|
+
_H2T.unicode_snob = True
|
|
22
|
+
|
|
23
|
+
_USER_DATA_DIR = os.path.join(
|
|
24
|
+
os.environ.get("APPDATA", os.path.expanduser("~")),
|
|
25
|
+
"internal-web-reader",
|
|
26
|
+
"browser-profile",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class PageResult:
|
|
32
|
+
url: str
|
|
33
|
+
title: str
|
|
34
|
+
content: str
|
|
35
|
+
links: list[dict] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_context = None
|
|
39
|
+
_pw = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _stderr(msg: str):
|
|
43
|
+
sys.stderr.write(f"\n{'='*60}\n{msg}\n{'='*60}\n")
|
|
44
|
+
sys.stderr.flush()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _ensure_browser():
|
|
48
|
+
global _context, _pw
|
|
49
|
+
if _context is not None:
|
|
50
|
+
return _context
|
|
51
|
+
|
|
52
|
+
from playwright.async_api import async_playwright
|
|
53
|
+
|
|
54
|
+
_pw = await async_playwright().start()
|
|
55
|
+
os.makedirs(_USER_DATA_DIR, exist_ok=True)
|
|
56
|
+
|
|
57
|
+
_context = await _pw.chromium.launch_persistent_context(
|
|
58
|
+
_USER_DATA_DIR,
|
|
59
|
+
headless=False,
|
|
60
|
+
channel="chrome",
|
|
61
|
+
viewport={"width": 1280, "height": 900},
|
|
62
|
+
locale="zh-CN",
|
|
63
|
+
args=["--disable-blink-features=AutomationControlled"],
|
|
64
|
+
)
|
|
65
|
+
return _context
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_login_url(url: str) -> bool:
|
|
69
|
+
url_lower = url.lower()
|
|
70
|
+
keywords = ["/login", "/signin", "/sso", "/cas", "/oauth", "/auth", "passport"]
|
|
71
|
+
return any(k in url_lower for k in keywords)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _wait_for_login(page, timeout_s: int = 300):
|
|
75
|
+
_stderr(
|
|
76
|
+
" 需要登录!请切换到弹出的浏览器窗口完成登录。\n"
|
|
77
|
+
" 登录成功后会自动继续,不用回来操作终端。"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
await page.evaluate("""() => {
|
|
81
|
+
const b = document.createElement('div');
|
|
82
|
+
b.id = '__iwr__';
|
|
83
|
+
b.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:999999;'
|
|
84
|
+
+ 'background:#ff6b35;color:white;padding:16px;text-align:center;'
|
|
85
|
+
+ 'font-size:18px;font-weight:bold;font-family:sans-serif;'
|
|
86
|
+
+ 'box-shadow:0 4px 12px rgba(0,0,0,0.3);';
|
|
87
|
+
b.textContent = 'Internal Web Reader: Please log in here. Auto-continuing after login...';
|
|
88
|
+
document.body.prepend(b);
|
|
89
|
+
}""")
|
|
90
|
+
|
|
91
|
+
elapsed = 0
|
|
92
|
+
while elapsed < timeout_s:
|
|
93
|
+
await asyncio.sleep(2)
|
|
94
|
+
elapsed += 2
|
|
95
|
+
|
|
96
|
+
current_url = page.url.lower()
|
|
97
|
+
has_pw = await page.query_selector('input[type="password"]')
|
|
98
|
+
|
|
99
|
+
if not _is_login_url(current_url) and not has_pw:
|
|
100
|
+
await page.evaluate("() => { const e = document.getElementById('__iwr__'); if(e) e.remove(); }")
|
|
101
|
+
_stderr(" 登录成功!正在读取页面...")
|
|
102
|
+
await page.wait_for_load_state("domcontentloaded", timeout=30_000)
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
_stderr(" 登录等待超时,尝试继续...")
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def read_page(url: str, timeout: float = 60) -> PageResult:
|
|
110
|
+
ctx = await _ensure_browser()
|
|
111
|
+
page = ctx.pages[0] if ctx.pages else await ctx.new_page()
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
await page.bring_to_front()
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
await page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
|
|
119
|
+
|
|
120
|
+
# 登录检测
|
|
121
|
+
if _is_login_url(page.url) or await page.query_selector('input[type="password"]'):
|
|
122
|
+
await _wait_for_login(page)
|
|
123
|
+
|
|
124
|
+
final_url = page.url
|
|
125
|
+
html = await page.content()
|
|
126
|
+
title = await page.title() or url
|
|
127
|
+
|
|
128
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
129
|
+
|
|
130
|
+
for tag in soup.find_all(["script", "style", "noscript", "iframe", "svg", "nav", "footer", "aside"]):
|
|
131
|
+
tag.decompose()
|
|
132
|
+
|
|
133
|
+
main = None
|
|
134
|
+
for sel in ("article", "main", '[role="main"]', ".content", ".article-content", ".markdown-body", "#content", "#main"):
|
|
135
|
+
el = soup.select_one(sel)
|
|
136
|
+
if el and len(el.get_text(strip=True)) > 100:
|
|
137
|
+
main = el
|
|
138
|
+
break
|
|
139
|
+
target = main or soup.body or soup
|
|
140
|
+
|
|
141
|
+
links = _extract_links(target, final_url)
|
|
142
|
+
markdown = _H2T.handle(str(target)).strip()
|
|
143
|
+
|
|
144
|
+
if len(markdown) > 200_000:
|
|
145
|
+
markdown = markdown[:200_000] + "\n\n... [truncated]"
|
|
146
|
+
|
|
147
|
+
return PageResult(url=final_url, title=title, content=markdown, links=links)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def close_browser():
|
|
151
|
+
global _context, _pw
|
|
152
|
+
if _context:
|
|
153
|
+
try:
|
|
154
|
+
await _context.close()
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
if _pw:
|
|
158
|
+
try:
|
|
159
|
+
await _pw.stop()
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
_context = None
|
|
163
|
+
_pw = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _extract_links(soup, base_url: str) -> list[dict]:
|
|
167
|
+
seen: set[str] = set()
|
|
168
|
+
links: list[dict] = []
|
|
169
|
+
for a in soup.find_all("a", href=True):
|
|
170
|
+
href = a["href"].strip()
|
|
171
|
+
if not href or href.startswith("#") or href.startswith("javascript:") or href.startswith("mailto:"):
|
|
172
|
+
continue
|
|
173
|
+
try:
|
|
174
|
+
absolute = urljoin(base_url, href)
|
|
175
|
+
except Exception:
|
|
176
|
+
continue
|
|
177
|
+
if absolute in seen:
|
|
178
|
+
continue
|
|
179
|
+
seen.add(absolute)
|
|
180
|
+
text = a.get_text(strip=True)[:100] or href
|
|
181
|
+
links.append({"text": text, "href": absolute})
|
|
182
|
+
return links
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
"""MCP Server 入口。用 Playwright 浏览器读网页,登录一次自动保存。"""
|
|
2
|
-
|
|
3
|
-
import atexit
|
|
4
|
-
import logging
|
|
5
|
-
|
|
6
|
-
from mcp.server.fastmcp import FastMCP
|
|
7
|
-
|
|
8
|
-
from .reader import read_page, close_browser
|
|
9
|
-
|
|
10
|
-
logging.basicConfig(
|
|
11
|
-
level=logging.INFO,
|
|
12
|
-
format="[web-reader] %(message)s",
|
|
13
|
-
handlers=[logging.StreamHandler()],
|
|
14
|
-
)
|
|
15
|
-
log = logging.getLogger("internal_web_reader")
|
|
16
|
-
|
|
17
|
-
atexit.register(close_browser)
|
|
18
|
-
|
|
19
|
-
mcp = FastMCP("internal-web-reader")
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@mcp.tool()
|
|
23
|
-
def read_web_page(url: str) -> str:
|
|
24
|
-
"""【读网页用这个】通过本地浏览器打开任意 URL 并返回 Markdown 内容。
|
|
25
|
-
专门用于访问公司内网页面(GitLab、Wiki、文档中心等需要 VPN 的网站)。
|
|
26
|
-
也适用于任何公开网页。首次访问需登录的站点会弹出浏览器窗口完成登录,
|
|
27
|
-
之后 session 自动保存。优先使用此工具而非内置 Fetch 来读取网页。
|
|
28
|
-
"""
|
|
29
|
-
page = read_page(url)
|
|
30
|
-
|
|
31
|
-
lines = [f"# {page.title}", f"> {page.url}", "", page.content]
|
|
32
|
-
if 0 < len(page.links) <= 30:
|
|
33
|
-
lines += ["", "---", "## 页面链接"]
|
|
34
|
-
for lk in page.links:
|
|
35
|
-
lines.append(f"- [{lk['text']}]({lk['href']})")
|
|
36
|
-
return "\n".join(lines)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
@mcp.tool()
|
|
40
|
-
def list_web_links(url: str) -> str:
|
|
41
|
-
"""列出网页上的所有链接,用于浏览内网站点结构。"""
|
|
42
|
-
page = read_page(url)
|
|
43
|
-
if not page.links:
|
|
44
|
-
return f"页面 {url} 上未找到链接。"
|
|
45
|
-
lines = [f"# {page.title} 的链接", f"> {len(page.links)} 个", ""]
|
|
46
|
-
for lk in page.links:
|
|
47
|
-
lines.append(f"- [{lk['text']}]({lk['href']})")
|
|
48
|
-
return "\n".join(lines)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def main():
|
|
52
|
-
mcp.run()
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if __name__ == "__main__":
|
|
56
|
-
main()
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
"""用 Playwright 启动浏览器读网页。登录一次,session 自动保存。"""
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
import os
|
|
5
|
-
import re
|
|
6
|
-
from dataclasses import dataclass, field
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from urllib.parse import urljoin
|
|
9
|
-
|
|
10
|
-
from bs4 import BeautifulSoup
|
|
11
|
-
import html2text
|
|
12
|
-
|
|
13
|
-
log = logging.getLogger("internal_web_reader")
|
|
14
|
-
|
|
15
|
-
_H2T = html2text.HTML2Text()
|
|
16
|
-
_H2T.body_width = 0
|
|
17
|
-
_H2T.ignore_links = False
|
|
18
|
-
_H2T.ignore_images = False
|
|
19
|
-
_H2T.protect_links = True
|
|
20
|
-
_H2T.wrap_links = False
|
|
21
|
-
_H2T.unicode_snob = True
|
|
22
|
-
|
|
23
|
-
# 持久化浏览器 profile 目录
|
|
24
|
-
_USER_DATA_DIR = os.path.join(
|
|
25
|
-
os.environ.get("APPDATA", os.path.expanduser("~")),
|
|
26
|
-
"internal-web-reader",
|
|
27
|
-
"browser-profile",
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@dataclass
|
|
32
|
-
class PageResult:
|
|
33
|
-
url: str
|
|
34
|
-
title: str
|
|
35
|
-
content: str
|
|
36
|
-
links: list[dict] = field(default_factory=list)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
# 全局浏览器实例(进程内复用)
|
|
40
|
-
_browser = None
|
|
41
|
-
_context = None
|
|
42
|
-
_pw = None
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _ensure_browser():
|
|
46
|
-
"""启动 Playwright 浏览器(进程内单例)。"""
|
|
47
|
-
global _browser, _context, _pw
|
|
48
|
-
if _context is not None:
|
|
49
|
-
return _context
|
|
50
|
-
|
|
51
|
-
from playwright.sync_api import sync_playwright
|
|
52
|
-
|
|
53
|
-
_pw = sync_playwright().start()
|
|
54
|
-
os.makedirs(_USER_DATA_DIR, exist_ok=True)
|
|
55
|
-
|
|
56
|
-
_context = _pw.chromium.launch_persistent_context(
|
|
57
|
-
_USER_DATA_DIR,
|
|
58
|
-
headless=False,
|
|
59
|
-
channel="chrome",
|
|
60
|
-
viewport={"width": 1280, "height": 900},
|
|
61
|
-
locale="zh-CN",
|
|
62
|
-
args=["--disable-blink-features=AutomationControlled"],
|
|
63
|
-
)
|
|
64
|
-
log.info("浏览器已启动")
|
|
65
|
-
return _context
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _is_login_page(page) -> bool:
|
|
69
|
-
"""判断当前页面是否是登录页。"""
|
|
70
|
-
url = page.url.lower()
|
|
71
|
-
if any(k in url for k in ("/login", "/signin", "/sso", "/auth", "/cas/", "/oauth")):
|
|
72
|
-
return True
|
|
73
|
-
# 检查页面是否有密码输入框
|
|
74
|
-
pw_inputs = page.query_selector_all('input[type="password"]')
|
|
75
|
-
if pw_inputs and len(pw_inputs) > 0:
|
|
76
|
-
return True
|
|
77
|
-
return False
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def read_page(url: str, timeout: float = 60) -> PageResult:
|
|
81
|
-
"""用浏览器打开 URL,返回清洗后的 Markdown。"""
|
|
82
|
-
ctx = _ensure_browser()
|
|
83
|
-
|
|
84
|
-
# 用现有 page 或新建
|
|
85
|
-
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
|
86
|
-
|
|
87
|
-
page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
|
|
88
|
-
|
|
89
|
-
# 如果是登录页,等用户手动登录
|
|
90
|
-
if _is_login_page(page):
|
|
91
|
-
log.info("检测到登录页面,请在浏览器窗口中完成登录...")
|
|
92
|
-
# 等 URL 变化(登录成功后会跳转)或者最多等 120 秒
|
|
93
|
-
try:
|
|
94
|
-
page.wait_for_url(
|
|
95
|
-
lambda u: u != page.url and "login" not in u.lower(),
|
|
96
|
-
timeout=120_000,
|
|
97
|
-
)
|
|
98
|
-
# 等页面加载完
|
|
99
|
-
page.wait_for_load_state("domcontentloaded", timeout=30_000)
|
|
100
|
-
log.info(f"登录成功,当前页面: {page.url}")
|
|
101
|
-
except Exception:
|
|
102
|
-
log.warning("等待登录超时,尝试继续...")
|
|
103
|
-
|
|
104
|
-
# 获取页面内容
|
|
105
|
-
final_url = page.url
|
|
106
|
-
html = page.content()
|
|
107
|
-
title = page.title() or url
|
|
108
|
-
|
|
109
|
-
soup = BeautifulSoup(html, "html.parser")
|
|
110
|
-
|
|
111
|
-
# 去噪
|
|
112
|
-
for tag in soup.find_all(["script", "style", "noscript", "iframe", "svg", "nav", "footer", "aside"]):
|
|
113
|
-
tag.decompose()
|
|
114
|
-
|
|
115
|
-
# 找主体
|
|
116
|
-
main = None
|
|
117
|
-
for sel in ("article", "main", '[role="main"]', ".content", ".article-content", ".markdown-body", "#content", "#main"):
|
|
118
|
-
el = soup.select_one(sel)
|
|
119
|
-
if el and len(el.get_text(strip=True)) > 100:
|
|
120
|
-
main = el
|
|
121
|
-
break
|
|
122
|
-
target = main or soup.body or soup
|
|
123
|
-
|
|
124
|
-
links = _extract_links(target, final_url)
|
|
125
|
-
markdown = _H2T.handle(str(target)).strip()
|
|
126
|
-
|
|
127
|
-
if len(markdown) > 200_000:
|
|
128
|
-
markdown = markdown[:200_000] + "\n\n... [已截断]"
|
|
129
|
-
|
|
130
|
-
return PageResult(url=final_url, title=title, content=markdown, links=links)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def close_browser():
|
|
134
|
-
"""关闭浏览器(进程退出时调用)。"""
|
|
135
|
-
global _browser, _context, _pw
|
|
136
|
-
if _context:
|
|
137
|
-
try:
|
|
138
|
-
_context.close()
|
|
139
|
-
except Exception:
|
|
140
|
-
pass
|
|
141
|
-
if _pw:
|
|
142
|
-
try:
|
|
143
|
-
_pw.stop()
|
|
144
|
-
except Exception:
|
|
145
|
-
pass
|
|
146
|
-
_context = None
|
|
147
|
-
_pw = None
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def _extract_links(soup, base_url: str) -> list[dict]:
|
|
151
|
-
seen: set[str] = set()
|
|
152
|
-
links: list[dict] = []
|
|
153
|
-
for a in soup.find_all("a", href=True):
|
|
154
|
-
href = a["href"].strip()
|
|
155
|
-
if not href or href.startswith("#") or href.startswith("javascript:") or href.startswith("mailto:"):
|
|
156
|
-
continue
|
|
157
|
-
try:
|
|
158
|
-
absolute = urljoin(base_url, href)
|
|
159
|
-
except Exception:
|
|
160
|
-
continue
|
|
161
|
-
if absolute in seen:
|
|
162
|
-
continue
|
|
163
|
-
seen.add(absolute)
|
|
164
|
-
text = a.get_text(strip=True)[:100] or href
|
|
165
|
-
links.append({"text": text, "href": absolute})
|
|
166
|
-
return links
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internal_web_reader-0.2.1 → internal_web_reader-0.2.3}/src/internal_web_reader.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|