Skip to content

Commit 41c4b46

Browse files
committed
feat: added web_fetch tool as free web_extract
1 parent 0bc1c4f commit 41c4b46

3 files changed

Lines changed: 245 additions & 0 deletions

File tree

api/.hermes/config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ providers:
1212
request_timeout_seconds: 3600 # hard cap: 1 h per LLM call
1313
stale_timeout_seconds: 900 # declare a stream stale after 15 min of silence
1414

15+
plugins:
16+
enabled:
17+
- web_fetch
18+
1519
browser:
1620
camofox:
1721
# When the agent runs inside Docker and navigates to localhost/127.0.0.1 page URLs,
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
"""web_fetch plugin — retrieve a web page and return it as markdown."""
2+
3+
import json
4+
import logging
5+
import re
6+
import urllib.error
7+
import urllib.request
8+
from html.parser import HTMLParser
9+
10+
logger = logging.getLogger(__name__)
11+
12+
# Tags whose content (including children) is silently dropped.
13+
_STRIP_TAGS = frozenset({"script", "style", "iframe", "header", "footer"})
14+
15+
_HEADING_TAGS = frozenset({"h1", "h2", "h3", "h4", "h5", "h6"})
16+
17+
# Inline tags that wrap content with a markdown marker on both sides.
18+
_INLINE_MARKERS: dict[str, str] = {
19+
"strong": "**",
20+
"b": "**",
21+
"em": "*",
22+
"i": "*",
23+
}
24+
25+
# Maximum characters returned to the model.
26+
_MAX_CHARS = 50_000
27+
28+
29+
class _HtmlToMarkdown(HTMLParser):
30+
"""Streaming HTML → Markdown converter.
31+
32+
Converts a strict subset of HTML to readable markdown. Content inside
33+
_STRIP_TAGS is silently discarded. Link text is buffered so that the
34+
full [text](href) form can be assembled after </a>.
35+
"""
36+
37+
def __init__(self) -> None:
38+
super().__init__(convert_charrefs=True)
39+
self._buf: list[str] = []
40+
self._skip: int = 0 # depth inside a stripped tag
41+
self._in_pre: bool = False
42+
self._list_stack: list[str] = [] # "ul" or "ol" per nesting level
43+
self._list_counts: list[int] = [] # ordered-list counters
44+
self._link_href: str | None = None # href of the current <a>
45+
self._link_buf: list[str] = [] # text collected inside <a>
46+
47+
# ------------------------------------------------------------------
48+
def _emit(self, text: str) -> None:
49+
"""Append text to the link buffer (if inside <a>) or main buffer."""
50+
if self._link_href is not None:
51+
self._link_buf.append(text)
52+
else:
53+
self._buf.append(text)
54+
55+
# ------------------------------------------------------------------
56+
def handle_starttag(self, tag: str, attrs: list) -> None:
57+
if self._skip:
58+
self._skip += 1
59+
return
60+
if tag in _STRIP_TAGS:
61+
self._skip += 1
62+
return
63+
64+
a = dict(attrs)
65+
66+
if tag in _HEADING_TAGS:
67+
self._emit(f"\n\n{'#' * int(tag[1])} ")
68+
elif tag == "p":
69+
self._emit("\n\n")
70+
elif tag == "br":
71+
self._emit(" \n")
72+
elif tag in _INLINE_MARKERS:
73+
self._emit(_INLINE_MARKERS[tag])
74+
elif tag == "code" and not self._in_pre:
75+
self._emit("`")
76+
elif tag == "pre":
77+
self._emit("\n\n```\n")
78+
self._in_pre = True
79+
elif tag == "a":
80+
self._link_href = a.get("href", "")
81+
self._link_buf = []
82+
elif tag in ("ul", "ol"):
83+
self._list_stack.append(tag)
84+
self._list_counts.append(0)
85+
elif tag == "li":
86+
indent = " " * (len(self._list_stack) - 1)
87+
if self._list_stack and self._list_stack[-1] == "ol":
88+
self._list_counts[-1] += 1
89+
self._emit(f"\n{indent}{self._list_counts[-1]}. ")
90+
else:
91+
self._emit(f"\n{indent}- ")
92+
elif tag == "img":
93+
alt = a.get("alt", "")
94+
src = a.get("src", "")
95+
self._emit(f"![{alt}]({src})")
96+
elif tag == "hr":
97+
self._emit("\n\n---\n\n")
98+
elif tag == "blockquote":
99+
self._emit("\n\n> ")
100+
elif tag in ("div", "section", "article", "main", "aside"):
101+
self._emit("\n\n")
102+
elif tag == "table":
103+
self._emit("\n\n")
104+
elif tag in ("td", "th"):
105+
self._emit(" | ")
106+
107+
def handle_endtag(self, tag: str) -> None:
108+
if self._skip:
109+
self._skip -= 1
110+
return
111+
112+
if tag in _HEADING_TAGS:
113+
self._emit("\n\n")
114+
elif tag in _INLINE_MARKERS:
115+
self._emit(_INLINE_MARKERS[tag])
116+
elif tag == "code" and not self._in_pre:
117+
self._emit("`")
118+
elif tag == "pre":
119+
self._emit("\n```\n\n")
120+
self._in_pre = False
121+
elif tag == "a":
122+
text = "".join(self._link_buf).strip()
123+
href = self._link_href or ""
124+
if text and href:
125+
self._buf.append(f"[{text}]({href})")
126+
elif text:
127+
self._buf.append(text)
128+
self._link_href = None
129+
self._link_buf = []
130+
elif tag in ("ul", "ol"):
131+
if self._list_stack:
132+
self._list_stack.pop()
133+
self._list_counts.pop()
134+
self._emit("\n")
135+
elif tag == "p":
136+
self._emit("\n\n")
137+
elif tag in ("div", "section", "article", "main", "aside"):
138+
self._emit("\n\n")
139+
elif tag == "tr":
140+
self._emit("\n")
141+
142+
def handle_data(self, data: str) -> None:
143+
if self._skip:
144+
return
145+
self._emit(data)
146+
147+
def result(self) -> str:
148+
text = "".join(self._buf)
149+
text = re.sub(r"[ \t]+", " ", text)
150+
text = re.sub(r"\n{3,}", "\n\n", text)
151+
return text.strip()
152+
153+
154+
def _fetch_as_markdown(url: str, timeout: int) -> str:
155+
req = urllib.request.Request(
156+
url,
157+
headers={
158+
"User-Agent": "Mozilla/5.0 (compatible; HermesBot/1.0)",
159+
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
160+
"Accept-Language": "en-US,en;q=0.9",
161+
},
162+
)
163+
try:
164+
with urllib.request.urlopen(req, timeout=timeout) as resp:
165+
content_type = resp.headers.get("Content-Type", "text/html")
166+
charset = "utf-8"
167+
if "charset=" in content_type:
168+
charset = content_type.split("charset=")[-1].strip().split(";")[0]
169+
170+
# For non-HTML responses return the raw body as-is.
171+
if "text/html" not in content_type and "application/xhtml" not in content_type:
172+
body = resp.read(_MAX_CHARS).decode(charset, errors="replace")
173+
return json.dumps({"url": url, "content": body})
174+
175+
html = resp.read(_MAX_CHARS * 4).decode(charset, errors="replace")
176+
177+
except urllib.error.HTTPError as exc:
178+
return json.dumps({"error": f"HTTP {exc.code}: {exc.reason}", "url": url})
179+
except urllib.error.URLError as exc:
180+
return json.dumps({"error": f"Request failed: {exc.reason}", "url": url})
181+
182+
parser = _HtmlToMarkdown()
183+
parser.feed(html)
184+
markdown = parser.result()
185+
186+
if len(markdown) > _MAX_CHARS:
187+
markdown = markdown[:_MAX_CHARS] + "\n\n[... content truncated ...]"
188+
189+
return json.dumps({"url": url, "content": markdown})
190+
191+
192+
def register(ctx) -> None:
193+
schema = {
194+
"name": "web_fetch",
195+
"description": (
196+
"Fetch a web page and return its main content as markdown. "
197+
"Strips scripts, styles, iframes, navigation headers, and footers. "
198+
"Use this when you already have a URL and want to read its content. "
199+
"Prefer web_extract for complex pages that require JavaScript rendering."
200+
),
201+
"parameters": {
202+
"type": "object",
203+
"properties": {
204+
"url": {
205+
"type": "string",
206+
"description": "Full URL to fetch (must start with http:// or https://)",
207+
},
208+
"timeout": {
209+
"type": "integer",
210+
"description": "Request timeout in seconds (default: 15, max: 60)",
211+
"default": 15,
212+
},
213+
},
214+
"required": ["url"],
215+
},
216+
}
217+
218+
def handler(args: dict, **kwargs) -> str:
219+
url = args.get("url", "").strip()
220+
if not url:
221+
return json.dumps({"error": "url is required"})
222+
if not url.startswith(("http://", "https://")):
223+
return json.dumps({"error": "url must start with http:// or https://"})
224+
timeout = max(1, min(int(args.get("timeout", 15)), 60))
225+
try:
226+
return _fetch_as_markdown(url, timeout=timeout)
227+
except Exception as exc:
228+
logger.exception("web_fetch: unexpected error for %s", url)
229+
return json.dumps({"error": str(exc), "url": url})
230+
231+
ctx.register_tool(
232+
name="web_fetch",
233+
toolset="web_fetch",
234+
schema=schema,
235+
handler=handler,
236+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
name: web_fetch
2+
version: 1.0.0
3+
description: Fetch a web page and return its main content as markdown
4+
provides_tools:
5+
- web_fetch

0 commit comments

Comments
 (0)