agent-wire-cli 0.2.1__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.
- agent_wire/__init__.py +1 -0
- agent_wire/__main__.py +3 -0
- agent_wire/assets/SKILL.md +26 -0
- agent_wire/assets/cursor_rule.mdc +13 -0
- agent_wire/assets/hermes_skill.md +28 -0
- agent_wire/assets/windsurf_rules.md +14 -0
- agent_wire/assets/wire-worker.md +20 -0
- agent_wire/auth/__init__.py +0 -0
- agent_wire/auth/browser_paths.py +143 -0
- agent_wire/auth/cookie_vault.py +181 -0
- agent_wire/auth/rate_limiter.py +26 -0
- agent_wire/cache/__init__.py +0 -0
- agent_wire/cache/ring_buffer.py +94 -0
- agent_wire/cache/sqlite_ledger.py +132 -0
- agent_wire/channels/__init__.py +41 -0
- agent_wire/channels/base.py +249 -0
- agent_wire/channels/tier1_global/__init__.py +0 -0
- agent_wire/channels/tier1_global/github.py +371 -0
- agent_wire/channels/tier1_global/hackernews.py +135 -0
- agent_wire/channels/tier1_global/reddit.py +371 -0
- agent_wire/channels/tier1_global/rss.py +129 -0
- agent_wire/channels/tier1_global/search.py +160 -0
- agent_wire/channels/tier1_global/stackoverflow.py +94 -0
- agent_wire/channels/tier1_global/web.py +98 -0
- agent_wire/channels/tier1_global/wikipedia.py +91 -0
- agent_wire/channels/tier1_global/x_twitter.py +380 -0
- agent_wire/channels/tier1_global/youtube.py +223 -0
- agent_wire/channels/tier2_pro/__init__.py +0 -0
- agent_wire/channels/tier2_pro/archive_bypass.py +66 -0
- agent_wire/channels/tier2_pro/arxiv_papers.py +220 -0
- agent_wire/channels/tier2_pro/devto.py +114 -0
- agent_wire/channels/tier2_pro/fediverse.py +181 -0
- agent_wire/channels/tier2_pro/hubs.py +161 -0
- agent_wire/channels/tier2_pro/linkedin.py +146 -0
- agent_wire/channels/tier2_pro/markets.py +207 -0
- agent_wire/channels/tier2_pro/meta_social.py +104 -0
- agent_wire/channels/tier2_pro/news.py +173 -0
- agent_wire/channels/tier2_pro/polymarket.py +105 -0
- agent_wire/channels/tier2_pro/publishing.py +84 -0
- agent_wire/channels/tier2_pro/stocktwits.py +79 -0
- agent_wire/channels/tier3_regional/bilibili.py +171 -0
- agent_wire/channels/tier3_regional/boss_zhipin.py +35 -0
- agent_wire/channels/tier3_regional/douyin_tiktok.py +85 -0
- agent_wire/channels/tier3_regional/v2ex.py +70 -0
- agent_wire/channels/tier3_regional/wechat.py +67 -0
- agent_wire/channels/tier3_regional/xiaohongshu.py +58 -0
- agent_wire/channels/tier3_regional/xiaoyuzhou.py +70 -0
- agent_wire/channels/tier3_regional/xueqiu.py +77 -0
- agent_wire/channels/tier3_regional/zhihu.py +57 -0
- agent_wire/cli.py +403 -0
- agent_wire/compression/__init__.py +0 -0
- agent_wire/compression/anchor_slicer.py +45 -0
- agent_wire/compression/bm25_slicer.py +151 -0
- agent_wire/compression/dam_formatter.py +74 -0
- agent_wire/compression/url_compactor.py +45 -0
- agent_wire/config.py +52 -0
- agent_wire/engine/__init__.py +0 -0
- agent_wire/engine/challenge.py +58 -0
- agent_wire/engine/http_pool.py +136 -0
- agent_wire/engine/stealth_browser.py +90 -0
- agent_wire/engine/waterfall.py +159 -0
- agent_wire/firewall/__init__.py +0 -0
- agent_wire/firewall/inbound_shield.py +116 -0
- agent_wire/firewall/outbound_privacy.py +120 -0
- agent_wire/installers/__init__.py +0 -0
- agent_wire/installers/client_installer.py +315 -0
- agent_wire/media/__init__.py +0 -0
- agent_wire/media/audio_transcriber.py +77 -0
- agent_wire/media/subtitle_cleaner.py +102 -0
- agent_wire/models.py +60 -0
- agent_wire/research/__init__.py +1 -0
- agent_wire/research/brief.py +227 -0
- agent_wire/research/briefing.py +41 -0
- agent_wire/research/cluster.py +128 -0
- agent_wire/research/engine.py +632 -0
- agent_wire/research/library.py +74 -0
- agent_wire/research/planner.py +232 -0
- agent_wire/research/scoring.py +89 -0
- agent_wire/research/store.py +149 -0
- agent_wire/research/watch.py +70 -0
- agent_wire/router.py +107 -0
- agent_wire/server.py +468 -0
- agent_wire_cli-0.2.1.dist-info/METADATA +258 -0
- agent_wire_cli-0.2.1.dist-info/RECORD +87 -0
- agent_wire_cli-0.2.1.dist-info/WHEEL +4 -0
- agent_wire_cli-0.2.1.dist-info/entry_points.txt +4 -0
- agent_wire_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Reddit: .json threads/listings (official app OAuth when configured, old.reddit mirror).
|
|
2
|
+
|
|
3
|
+
When anonymous .json is blocked, search tries Reddit's own search RSS (live, no scores), then the
|
|
4
|
+
PullPush and Arctic Shift archives (free, volunteer APIs: small requests only, copies are captured
|
|
5
|
+
shortly after posting so scores lag the live site). Thread reads fall back to Arctic Shift.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import base64
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
import xml.etree.ElementTree as ET
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
16
|
+
|
|
17
|
+
from agent_wire.channels.base import Channel, WireUnsupported, html_text, listing, ts
|
|
18
|
+
from agent_wire.engine.waterfall import WireFetchError
|
|
19
|
+
from agent_wire.firewall.outbound_privacy import parse_json
|
|
20
|
+
from agent_wire.models import WireComment, WireSearchHit
|
|
21
|
+
|
|
22
|
+
WWW = "https://www.reddit.com"
|
|
23
|
+
ARCTIC = "https://arctic-shift.photon-reddit.com/api"
|
|
24
|
+
PULLPUSH = "https://api.pullpush.io/reddit/search/submission/"
|
|
25
|
+
ATOM = "{http://www.w3.org/2005/Atom}"
|
|
26
|
+
ARCTIC_NOTE = "Arctic Shift archive copy (scores/comments as captured, not live)"
|
|
27
|
+
_STOP = frozenset({"the", "a", "an", "and", "or", "of", "for", "to", "in", "on", "with", "vs",
|
|
28
|
+
"versus", "what", "is", "are", "how", "why", "best", "new"}) # fmt: skip
|
|
29
|
+
PRUNE_DEPTH, PRUNE_SCORE = 3, 2
|
|
30
|
+
BLOCKED_HINT = (
|
|
31
|
+
"Reddit blocks anonymous access from this network. Set REDDIT_CLIENT_ID (a free 'installed app' "
|
|
32
|
+
"at reddit.com/prefs/apps) or run `wire auth login reddit.com`."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_comments(children: list, depth: int = 0) -> list[WireComment]:
|
|
37
|
+
out = []
|
|
38
|
+
for ch in children:
|
|
39
|
+
if ch.get("kind") != "t1":
|
|
40
|
+
continue # "more" stubs
|
|
41
|
+
d = ch["data"]
|
|
42
|
+
score = d.get("score") or 0
|
|
43
|
+
if depth > PRUNE_DEPTH and score < PRUNE_SCORE:
|
|
44
|
+
continue
|
|
45
|
+
replies = d.get("replies")
|
|
46
|
+
kids = replies["data"]["children"] if isinstance(replies, dict) else []
|
|
47
|
+
out.append(
|
|
48
|
+
WireComment(
|
|
49
|
+
id=d["id"], body=d.get("body") or "", author_handle=d.get("author"),
|
|
50
|
+
score=score, depth=depth, is_op=bool(d.get("is_submitter")),
|
|
51
|
+
timestamp=ts(d.get("created_utc")), replies=parse_comments(kids, depth + 1),
|
|
52
|
+
)
|
|
53
|
+
) # fmt: skip
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_thread(data: list) -> dict:
|
|
58
|
+
post = data[0]["data"]["children"][0]["data"]
|
|
59
|
+
body = post.get("selftext") or ""
|
|
60
|
+
if not post.get("is_self") and post.get("url"):
|
|
61
|
+
link = WWW + post["url"] if post["url"].startswith("/") else post["url"] # crossposts
|
|
62
|
+
body = f"Link: {link}\n\n{body}".strip()
|
|
63
|
+
return {
|
|
64
|
+
"title": post.get("title"),
|
|
65
|
+
"author_handle": post.get("author"),
|
|
66
|
+
"timestamp": ts(post.get("created_utc")),
|
|
67
|
+
"score": post.get("score"),
|
|
68
|
+
"body": body,
|
|
69
|
+
"comments": parse_comments(data[1]["data"]["children"]) if len(data) > 1 else [],
|
|
70
|
+
"metadata": {
|
|
71
|
+
"subreddit": post.get("subreddit_name_prefixed"),
|
|
72
|
+
"comments": post.get("num_comments"),
|
|
73
|
+
"upvote_ratio": post.get("upvote_ratio"),
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def listing_items(data: dict) -> list[dict]:
|
|
79
|
+
items = []
|
|
80
|
+
for ch in data.get("data", {}).get("children", []):
|
|
81
|
+
d = ch.get("data", {})
|
|
82
|
+
if ch.get("kind") == "t1": # a comment in a user listing
|
|
83
|
+
items.append({"title": f"comment in {d.get('link_title', '')}",
|
|
84
|
+
"url": WWW + d.get("permalink", ""), "meta": [f"+{d.get('score', 0)}"],
|
|
85
|
+
"snippet": d.get("body", "")}) # fmt: skip
|
|
86
|
+
continue
|
|
87
|
+
items.append({
|
|
88
|
+
"title": d.get("title"), "url": WWW + d.get("permalink", ""),
|
|
89
|
+
"meta": [f"+{d.get('score', 0)}", f"{d.get('num_comments', 0)} comments",
|
|
90
|
+
d.get("subreddit_name_prefixed"), f"u/{d.get('author')}"],
|
|
91
|
+
"snippet": d.get("selftext", ""),
|
|
92
|
+
}) # fmt: skip
|
|
93
|
+
return items
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def post_hit(d: dict, archived: bool = False) -> WireSearchHit:
|
|
97
|
+
link = None if d.get("is_self") or "reddit.com" in (d.get("url") or "") else d.get("url")
|
|
98
|
+
return WireSearchHit(
|
|
99
|
+
url=WWW + d.get("permalink", ""), title=d.get("title") or "",
|
|
100
|
+
snippet="" if d.get("selftext") in ("[removed]", "[deleted]") else (d.get("selftext") or "")[:300],
|
|
101
|
+
source="reddit",
|
|
102
|
+
rank_score=float(d.get("score") or 0), timestamp=ts(d.get("created_utc")),
|
|
103
|
+
author=d.get("author"), link=link,
|
|
104
|
+
engagement={"score": d.get("score") or 0, "comments": d.get("num_comments") or 0,
|
|
105
|
+
**({"archived": 1} if archived else {})},
|
|
106
|
+
) # fmt: skip
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def rss_hits(content: bytes) -> list[WireSearchHit]:
|
|
110
|
+
"""Reddit search .rss (Atom): posts only (t3_), no score or comment counts in the feed."""
|
|
111
|
+
hits = []
|
|
112
|
+
for e in ET.fromstring(content).iter(f"{ATOM}entry"):
|
|
113
|
+
if not (e.findtext(f"{ATOM}id") or "").startswith("t3_"):
|
|
114
|
+
continue # t5_ = subreddit results
|
|
115
|
+
link = e.find(f"{ATOM}link")
|
|
116
|
+
body = e.findtext(f"{ATOM}content") or ""
|
|
117
|
+
out = re.search(r'href="([^"]+)">\[link\]', body)
|
|
118
|
+
author = (e.findtext(f"{ATOM}author/{ATOM}name") or "").removeprefix("/u/")
|
|
119
|
+
hits.append(WireSearchHit(
|
|
120
|
+
url=link.get("href", "") if link is not None else "", title=e.findtext(f"{ATOM}title") or "",
|
|
121
|
+
snippet=html_text(body).split("submitted by")[0].strip()[:300], source="reddit",
|
|
122
|
+
timestamp=ts(e.findtext(f"{ATOM}published") or e.findtext(f"{ATOM}updated")),
|
|
123
|
+
author=author or None,
|
|
124
|
+
link=out.group(1) if out and "reddit.com" not in out.group(1) else None,
|
|
125
|
+
)) # fmt: skip
|
|
126
|
+
return hits
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def rss_window(since: datetime | None) -> str:
|
|
130
|
+
if since is None:
|
|
131
|
+
return "all"
|
|
132
|
+
days = (datetime.now(UTC) - since).days
|
|
133
|
+
return next((t for n, t in ((1, "day"), (7, "week"), (31, "month"), (365, "year")) if days <= n),
|
|
134
|
+
"all") # fmt: skip
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _path(url: str) -> str:
|
|
138
|
+
parts = urlsplit(url)
|
|
139
|
+
path = parts.path.rstrip("/")
|
|
140
|
+
if (parts.hostname or "").endswith("redd.it"):
|
|
141
|
+
return f"/comments{path}"
|
|
142
|
+
return path.removesuffix(".json")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class RedditChannel(Channel):
|
|
146
|
+
name = "reddit"
|
|
147
|
+
|
|
148
|
+
def __init__(self, ctx):
|
|
149
|
+
super().__init__(ctx)
|
|
150
|
+
self._token: str | None = None
|
|
151
|
+
self._token_expires = 0.0
|
|
152
|
+
|
|
153
|
+
async def _app_token(self) -> str | None:
|
|
154
|
+
"""Reddit's official app-only OAuth (no user login) when REDDIT_CLIENT_ID is set.
|
|
155
|
+
|
|
156
|
+
Reddit blocks anonymous .json from many networks; an app token restores live reads.
|
|
157
|
+
"""
|
|
158
|
+
client_id = os.environ.get("REDDIT_CLIENT_ID")
|
|
159
|
+
if not client_id:
|
|
160
|
+
return None
|
|
161
|
+
if self._token and time.time() < self._token_expires:
|
|
162
|
+
return self._token
|
|
163
|
+
secret = os.environ.get("REDDIT_CLIENT_SECRET", "")
|
|
164
|
+
basic = base64.b64encode(f"{client_id}:{secret}".encode()).decode()
|
|
165
|
+
grant = (
|
|
166
|
+
"client_credentials" if secret else "https://oauth.reddit.com/grants/installed_client"
|
|
167
|
+
)
|
|
168
|
+
f = await self.ctx.pool.get(
|
|
169
|
+
"https://www.reddit.com/api/v1/access_token", method="POST", impersonate=None,
|
|
170
|
+
headers={"Authorization": f"Basic {basic}"},
|
|
171
|
+
data={"grant_type": grant, "device_id": "DO_NOT_TRACK_THIS_DEVICE"},
|
|
172
|
+
) # fmt: skip
|
|
173
|
+
body = parse_json(f.content) if f.ok else {}
|
|
174
|
+
self._token = body.get("access_token")
|
|
175
|
+
self._token_expires = time.time() + int(body.get("expires_in", 3600)) - 60
|
|
176
|
+
return self._token
|
|
177
|
+
|
|
178
|
+
async def _json(self, path: str, query: str = "", **kw):
|
|
179
|
+
sep = "&" if query else ""
|
|
180
|
+
if kw.get("tool") != "wire_me" and (token := await self._app_token()):
|
|
181
|
+
try:
|
|
182
|
+
return await self.fetch_json(
|
|
183
|
+
f"https://oauth.reddit.com{path}?raw_json=1{sep}{query}",
|
|
184
|
+
headers={"Authorization": f"Bearer {token}"}, **kw,
|
|
185
|
+
) # fmt: skip
|
|
186
|
+
except (WireFetchError, ValueError):
|
|
187
|
+
pass # fall through to the public endpoints
|
|
188
|
+
url = f"{WWW}{path}.json?raw_json=1{sep}{query}"
|
|
189
|
+
mirror = f"https://old.reddit.com{path}.json?raw_json=1{sep}{query}"
|
|
190
|
+
try:
|
|
191
|
+
return await self.fetch_json(url, mirrors=[mirror], **kw)
|
|
192
|
+
except WireFetchError as e:
|
|
193
|
+
raise WireFetchError(f"{e} {BLOCKED_HINT}") from e
|
|
194
|
+
except ValueError as e: # an HTML block page where JSON should be
|
|
195
|
+
raise WireFetchError(f"{url}: {BLOCKED_HINT}") from e
|
|
196
|
+
|
|
197
|
+
async def read(self, url, *, query=None, section=None, include_comments=True):
|
|
198
|
+
path = _path(url)
|
|
199
|
+
segs = [s for s in path.split("/") if s]
|
|
200
|
+
if "s" in segs[2:3]: # /r/sub/s/<code> share link: let Reddit resolve it
|
|
201
|
+
f = await self.fetch(url, archive=False)
|
|
202
|
+
path, segs = _path(f.url), [s for s in _path(f.url).split("/") if s]
|
|
203
|
+
if "comments" in segs:
|
|
204
|
+
try:
|
|
205
|
+
data, f = await self._json(path, "limit=200&sort=top")
|
|
206
|
+
except WireFetchError:
|
|
207
|
+
return await self._arctic_thread(url, segs[segs.index("comments") + 1])
|
|
208
|
+
return self.doc(f, url, **parse_thread(data))
|
|
209
|
+
if segs[:1] in (["r"], ["user"], ["u"]):
|
|
210
|
+
return await self.feed("/".join(segs), limit=25)
|
|
211
|
+
return await self.ctx.channel("web").read(url, query=query, section=section)
|
|
212
|
+
|
|
213
|
+
async def search(self, query, *, limit=5, sort="relevance", since=None):
|
|
214
|
+
window = "&t=month" if since else ""
|
|
215
|
+
try:
|
|
216
|
+
data, _ = await self._json(
|
|
217
|
+
"/search", f"q={quote(query)}&sort={sort}&limit={limit}{window}"
|
|
218
|
+
)
|
|
219
|
+
except WireFetchError as blocked:
|
|
220
|
+
for fallback in (self._rss_search, self._pullpush_search, self._arctic_search):
|
|
221
|
+
try:
|
|
222
|
+
if hits := await fallback(query, limit, since, sort):
|
|
223
|
+
return hits
|
|
224
|
+
except (WireFetchError, ValueError, ET.ParseError):
|
|
225
|
+
continue
|
|
226
|
+
raise WireFetchError(
|
|
227
|
+
f"reddit search: no results from any fallback. {BLOCKED_HINT}"
|
|
228
|
+
) from blocked
|
|
229
|
+
return [post_hit(ch.get("data", {})) for ch in data.get("data", {}).get("children", [])
|
|
230
|
+
if ch.get("kind") == "t3"][:limit] # fmt: skip
|
|
231
|
+
|
|
232
|
+
async def _rss_search(self, query, limit, since, sort="relevance"):
|
|
233
|
+
"""Live search via the .rss endpoint, which stays open when .json is blocked."""
|
|
234
|
+
params = urlencode({"q": query, "sort": sort, "t": rss_window(since), "type": "link",
|
|
235
|
+
"limit": min(limit * 2, 25)}) # fmt: skip
|
|
236
|
+
f = await self.fetch(f"{WWW}/search.rss?{params}", archive=False)
|
|
237
|
+
if not f.ok:
|
|
238
|
+
raise WireFetchError(f"reddit search.rss: HTTP {f.status}")
|
|
239
|
+
hits = [
|
|
240
|
+
h for h in rss_hits(f.content) if not since or not h.timestamp or h.timestamp >= since
|
|
241
|
+
]
|
|
242
|
+
return hits[:limit]
|
|
243
|
+
|
|
244
|
+
async def _pullpush_search(self, query, limit, since, sort="relevance"):
|
|
245
|
+
params = {"q": query, "size": min(limit * 2, 25), "sort_type": "score", "sort": "desc"}
|
|
246
|
+
if since:
|
|
247
|
+
params["after"] = int(since.timestamp())
|
|
248
|
+
data, _ = await self.fetch_json(f"{PULLPUSH}?{urlencode(params)}")
|
|
249
|
+
posts = data.get("data") if isinstance(data, dict) else None
|
|
250
|
+
if not isinstance(posts, list):
|
|
251
|
+
raise WireFetchError("pullpush: no data")
|
|
252
|
+
return [post_hit(p, archived=True) for p in posts if p.get("permalink")][:limit]
|
|
253
|
+
|
|
254
|
+
# ---- Arctic Shift fallback --------------------------------------------------------------
|
|
255
|
+
async def _arctic(self, path: str, **params):
|
|
256
|
+
url = f"{ARCTIC}{path}?{urlencode({k: v for k, v in params.items() if v is not None})}"
|
|
257
|
+
data: dict = {}
|
|
258
|
+
for attempt in range(2):
|
|
259
|
+
try:
|
|
260
|
+
data, f = await self.fetch_json(url, archive=False)
|
|
261
|
+
except ValueError as e: # HTML error page instead of JSON
|
|
262
|
+
raise WireFetchError(f"arctic-shift unavailable. {BLOCKED_HINT}") from e
|
|
263
|
+
if isinstance(data, dict) and data.get("data") is not None:
|
|
264
|
+
return data["data"], f
|
|
265
|
+
if attempt == 0: # "Timeout. Maybe slow down a bit": one polite retry
|
|
266
|
+
await asyncio.sleep(3)
|
|
267
|
+
raise WireFetchError(f"arctic-shift: {data.get('error') or 'no data'} ({BLOCKED_HINT})")
|
|
268
|
+
|
|
269
|
+
async def _arctic_thread(self, url: str, post_id: str):
|
|
270
|
+
posts, f = await self._arctic("/posts/ids", ids=post_id)
|
|
271
|
+
if not posts:
|
|
272
|
+
raise WireFetchError(f"{url}: not in the Arctic Shift archive. {BLOCKED_HINT}")
|
|
273
|
+
tree, _ = await self._arctic("/comments/tree", link_id=f"t3_{post_id}", limit=300)
|
|
274
|
+
data = [{"data": {"children": [{"kind": "t3", "data": posts[0]}]}},
|
|
275
|
+
{"data": {"children": tree}}] # fmt: skip
|
|
276
|
+
fields = parse_thread(data)
|
|
277
|
+
fields["metadata"]["source"] = ARCTIC_NOTE
|
|
278
|
+
return self.doc(f, url, **fields)
|
|
279
|
+
|
|
280
|
+
async def subreddits_for(self, query: str, n: int = 3) -> list[str]:
|
|
281
|
+
"""Dedicated-subreddit discovery: subreddits whose names start with a topic word."""
|
|
282
|
+
words = [
|
|
283
|
+
w for w in re.findall(r"[a-z0-9]+", query.lower()) if len(w) > 2 and w not in _STOP
|
|
284
|
+
]
|
|
285
|
+
if m := re.findall(r"\br/(\w+)", query):
|
|
286
|
+
return m[:n]
|
|
287
|
+
found: dict[str, int] = {}
|
|
288
|
+
for w in words[:3]:
|
|
289
|
+
try:
|
|
290
|
+
subs, _ = await self._arctic("/subreddits/search", subreddit_prefix=w, limit=5)
|
|
291
|
+
except WireFetchError:
|
|
292
|
+
continue
|
|
293
|
+
for sub in subs:
|
|
294
|
+
name = sub.get("display_name") or ""
|
|
295
|
+
if name:
|
|
296
|
+
found[name] = max(found.get(name, 0), sub.get("subscribers") or 0)
|
|
297
|
+
return sorted(found, key=lambda k: -found[k])[:n]
|
|
298
|
+
|
|
299
|
+
async def _arctic_search(self, query, limit, since: datetime | None, sort="relevance"):
|
|
300
|
+
subs = await self.subreddits_for(query)
|
|
301
|
+
if not subs:
|
|
302
|
+
raise WireFetchError(f"reddit search: no matching subreddits found. {BLOCKED_HINT}")
|
|
303
|
+
words = [w for w in re.findall(r"[A-Za-z0-9+#.]+", re.sub(r"\br/\w+", "", query))
|
|
304
|
+
if w.lower() not in _STOP] # fmt: skip
|
|
305
|
+
after = since.date().isoformat() if since else None
|
|
306
|
+
cap = min(limit * 2, 25)
|
|
307
|
+
hits, seen = [], set()
|
|
308
|
+
for sub in subs:
|
|
309
|
+
# every topic word (title search ANDs them), then the word that named this subreddit:
|
|
310
|
+
# r/rust is about rust, so "rust programming" there only needs "rust"
|
|
311
|
+
tries = [" ".join(words)] if words else [None]
|
|
312
|
+
own = [w for w in words if sub.lower().startswith(w.lower())]
|
|
313
|
+
if own and own[0] != tries[0]:
|
|
314
|
+
tries.append(own[0])
|
|
315
|
+
for title in tries:
|
|
316
|
+
try:
|
|
317
|
+
posts, _ = await self._arctic("/posts/search", subreddit=sub, title=title,
|
|
318
|
+
after=after, limit=cap, sort="desc") # fmt: skip
|
|
319
|
+
except WireFetchError:
|
|
320
|
+
break
|
|
321
|
+
for p in posts:
|
|
322
|
+
if p.get("id") not in seen:
|
|
323
|
+
seen.add(p.get("id"))
|
|
324
|
+
hits.append(post_hit(p, archived=True))
|
|
325
|
+
if len(posts) >= 3:
|
|
326
|
+
break
|
|
327
|
+
hits.sort(key=lambda h: -h.rank_score)
|
|
328
|
+
return hits[:limit]
|
|
329
|
+
|
|
330
|
+
async def feed(self, target, *, sort="hot", limit=10):
|
|
331
|
+
t = target.strip().strip("/").removeprefix("https://www.reddit.com/")
|
|
332
|
+
if t.startswith(("u/", "user/", "@")):
|
|
333
|
+
user = t.split("/", 1)[-1].lstrip("@")
|
|
334
|
+
path, title = f"/user/{user}/submitted", f"u/{user}"
|
|
335
|
+
else:
|
|
336
|
+
sub = t.removeprefix("r/").split("/")[0]
|
|
337
|
+
path, title = f"/r/{sub}/{sort}", f"r/{sub} · {sort}"
|
|
338
|
+
try:
|
|
339
|
+
data, f = await self._json(path, f"limit={limit}")
|
|
340
|
+
except WireFetchError:
|
|
341
|
+
who = {"author": user} if t.startswith(("u/", "user/", "@")) else {"subreddit": sub}
|
|
342
|
+
posts, f = await self._arctic("/posts/search", limit=limit, sort="desc", **who)
|
|
343
|
+
data = {"data": {"children": [{"kind": "t3", "data": p} for p in posts]}}
|
|
344
|
+
title += f" · {ARCTIC_NOTE}"
|
|
345
|
+
return self.doc(f, f"{WWW}{path}", title=title, body=listing(listing_items(data)[:limit]))
|
|
346
|
+
|
|
347
|
+
async def me(self, target="latest_posts", *, limit=5):
|
|
348
|
+
paths = {"latest_posts": "submitted", "likes": "upvoted", "bookmarks": "saved",
|
|
349
|
+
"comments": "comments", "stats": "about"} # fmt: skip
|
|
350
|
+
if target not in paths:
|
|
351
|
+
raise WireUnsupported(
|
|
352
|
+
"reddit targets: " + ", ".join(paths) + " (notifications live in the private inbox,"
|
|
353
|
+
" which agent-wire never reads)"
|
|
354
|
+
)
|
|
355
|
+
me, _ = await self.fetch_json(f"{WWW}/api/me.json", tool="wire_me")
|
|
356
|
+
name = (me.get("data") or me).get("name")
|
|
357
|
+
if not name:
|
|
358
|
+
raise WireUnsupported(
|
|
359
|
+
"not logged in to reddit locally; run `wire auth login reddit.com`"
|
|
360
|
+
)
|
|
361
|
+
data, f = await self.fetch_json(f"{WWW}/user/{name}/{paths[target]}.json?limit={limit}&raw_json=1",
|
|
362
|
+
tool="wire_me") # fmt: skip
|
|
363
|
+
if target == "stats":
|
|
364
|
+
d = data.get("data", {})
|
|
365
|
+
body = f"u/{name}: {d.get('link_karma')} post karma · {d.get('comment_karma')} comment karma"
|
|
366
|
+
else:
|
|
367
|
+
body = listing(listing_items(data)[:limit])
|
|
368
|
+
return self.doc(f, f"{WWW}/user/{name}", title=f"my reddit {target}", body=body)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
CHANNEL = RedditChannel
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""RSS 2.0 / Atom / JSON Feed reader (stdlib expat: no external entities, billion-laughs guarded)."""
|
|
2
|
+
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
|
|
5
|
+
import orjson
|
|
6
|
+
|
|
7
|
+
from agent_wire.channels.base import Channel, WireUnsupported, html_text, listing, ts
|
|
8
|
+
from agent_wire.engine.http_pool import Fetched
|
|
9
|
+
from agent_wire.models import WireDocument
|
|
10
|
+
|
|
11
|
+
MAX_ITEMS = 50
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NotAFeed(WireUnsupported):
|
|
15
|
+
"""The URL answered with something other than RSS / Atom / JSON Feed (often an HTML page)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _local(tag: str) -> str:
|
|
19
|
+
return tag.rsplit("}", 1)[-1]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _child(el, *names):
|
|
23
|
+
for c in el:
|
|
24
|
+
if _local(c.tag) in names:
|
|
25
|
+
return c
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _text(el, *names) -> str:
|
|
30
|
+
c = _child(el, *names)
|
|
31
|
+
return (c.text or "").strip() if c is not None and c.text else ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_feed(content: bytes) -> tuple[str, list[dict]]:
|
|
35
|
+
if content.lstrip().startswith(b"{"):
|
|
36
|
+
try:
|
|
37
|
+
data = orjson.loads(content)
|
|
38
|
+
except orjson.JSONDecodeError as e:
|
|
39
|
+
raise NotAFeed("not a feed (bad JSON)") from e
|
|
40
|
+
items = [
|
|
41
|
+
{
|
|
42
|
+
"title": it.get("title") or (it.get("content_text") or "")[:80],
|
|
43
|
+
"url": it.get("url") or it.get("external_url") or it.get("id"),
|
|
44
|
+
"date": ts(it.get("date_published")),
|
|
45
|
+
"summary": it.get("summary") or it.get("content_text") or html_text(it.get("content_html", "")),
|
|
46
|
+
}
|
|
47
|
+
for it in data.get("items", [])
|
|
48
|
+
] # fmt: skip
|
|
49
|
+
return data.get("title", ""), items
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
root = ET.fromstring(content)
|
|
53
|
+
except ET.ParseError as e:
|
|
54
|
+
raise NotAFeed(f"not a feed ({e})") from e
|
|
55
|
+
if _local(root.tag).lower() not in ("rss", "rdf", "feed"):
|
|
56
|
+
raise NotAFeed(f"not a feed (root element <{_local(root.tag)}>)")
|
|
57
|
+
channel = _child(root, "channel")
|
|
58
|
+
if channel is not None: # RSS 2.0 / 0.9x
|
|
59
|
+
entries, title = [e for e in channel if _local(e.tag) == "item"], _text(channel, "title")
|
|
60
|
+
if not entries: # RSS 1.0 puts items beside the channel
|
|
61
|
+
entries = [e for e in root if _local(e.tag) == "item"]
|
|
62
|
+
else: # Atom
|
|
63
|
+
entries, title = [e for e in root if _local(e.tag) == "entry"], _text(root, "title")
|
|
64
|
+
|
|
65
|
+
items = []
|
|
66
|
+
for e in entries[:MAX_ITEMS]:
|
|
67
|
+
link = _text(e, "link")
|
|
68
|
+
if not link:
|
|
69
|
+
for c in e:
|
|
70
|
+
if _local(c.tag) == "link" and c.get("rel", "alternate") == "alternate":
|
|
71
|
+
link = c.get("href", "")
|
|
72
|
+
break
|
|
73
|
+
summary = _text(e, "description", "summary", "encoded", "content")
|
|
74
|
+
items.append(
|
|
75
|
+
{
|
|
76
|
+
"title": _text(e, "title"),
|
|
77
|
+
"url": link or _text(e, "guid", "id"),
|
|
78
|
+
"date": ts(_rfc822(_text(e, "pubDate", "published", "updated", "date"))),
|
|
79
|
+
"summary": html_text(summary) if "<" in summary else summary,
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
return title, items
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _rfc822(value: str):
|
|
86
|
+
from email.utils import parsedate_to_datetime
|
|
87
|
+
|
|
88
|
+
if not value:
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
return parsedate_to_datetime(value).isoformat()
|
|
92
|
+
except (TypeError, ValueError):
|
|
93
|
+
return value # already ISO-8601 (Atom)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def render_items(items: list[dict]) -> str:
|
|
97
|
+
return listing(
|
|
98
|
+
[
|
|
99
|
+
{
|
|
100
|
+
"title": it["title"],
|
|
101
|
+
"url": it["url"],
|
|
102
|
+
"meta": [it["date"].date().isoformat() if it["date"] else None],
|
|
103
|
+
"snippet": it["summary"],
|
|
104
|
+
}
|
|
105
|
+
for it in items
|
|
106
|
+
]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class RssChannel(Channel):
|
|
111
|
+
name = "rss"
|
|
112
|
+
|
|
113
|
+
def document(self, f: Fetched, url: str, limit: int = MAX_ITEMS) -> WireDocument:
|
|
114
|
+
title, items = parse_feed(f.content)
|
|
115
|
+
return self.doc(f, url, title=title or url, body=render_items(items[:limit]),
|
|
116
|
+
metadata={"items": len(items)}) # fmt: skip
|
|
117
|
+
|
|
118
|
+
async def read(self, url, *, query=None, section=None, include_comments=True):
|
|
119
|
+
f = await self.fetch(url, browser=False)
|
|
120
|
+
try:
|
|
121
|
+
return self.document(f, url)
|
|
122
|
+
except NotAFeed: # a /feed-looking URL that serves a page: read it as one
|
|
123
|
+
return await self.ctx.channel("web").read(url, query=query, section=section)
|
|
124
|
+
|
|
125
|
+
async def feed(self, target, *, sort="hot", limit=10):
|
|
126
|
+
return self.document(await self.fetch(target, browser=False), target, limit)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
CHANNEL = RssChannel
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Concurrent meta-search: DuckDuckGo + Brave (+ SearXNG / Exa when configured), consensus-ranked."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from urllib.parse import parse_qs, quote_plus, urlsplit
|
|
6
|
+
|
|
7
|
+
from curl_cffi.requests.exceptions import RequestException
|
|
8
|
+
from selectolax.lexbor import LexborHTMLParser
|
|
9
|
+
|
|
10
|
+
from agent_wire.channels.base import Channel
|
|
11
|
+
from agent_wire.engine.waterfall import WireFetchError
|
|
12
|
+
from agent_wire.firewall.outbound_privacy import parse_json
|
|
13
|
+
from agent_wire.models import WireSearchHit
|
|
14
|
+
|
|
15
|
+
ENGINE_TIMEOUT_S = 8
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def url_key(url: str) -> str:
|
|
19
|
+
p = urlsplit(url)
|
|
20
|
+
host = (p.hostname or "").removeprefix("www.")
|
|
21
|
+
return f"{host}{p.path.rstrip('/')}{'?' + p.query if p.query else ''}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_ddg_html(html: str) -> list[WireSearchHit]:
|
|
25
|
+
hits = []
|
|
26
|
+
for res in LexborHTMLParser(html).css("div.result"):
|
|
27
|
+
if "result--ad" in (res.attributes.get("class") or ""):
|
|
28
|
+
continue
|
|
29
|
+
a = res.css_first("a.result__a")
|
|
30
|
+
if not a:
|
|
31
|
+
continue
|
|
32
|
+
href = a.attributes.get("href") or ""
|
|
33
|
+
if "uddg=" in href:
|
|
34
|
+
href = parse_qs(urlsplit(href).query).get("uddg", [href])[0]
|
|
35
|
+
if not href.startswith("http"):
|
|
36
|
+
continue
|
|
37
|
+
snip = res.css_first(".result__snippet")
|
|
38
|
+
hits.append(WireSearchHit(url=href, title=a.text(separator=" ", strip=True),
|
|
39
|
+
snippet=snip.text(separator=" ", strip=True) if snip else "", source="ddg")) # fmt: skip
|
|
40
|
+
return hits
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_ddg_lite(html: str) -> list[WireSearchHit]:
|
|
44
|
+
tree = LexborHTMLParser(html)
|
|
45
|
+
links = tree.css("a.result-link")
|
|
46
|
+
snippets = tree.css("td.result-snippet")
|
|
47
|
+
hits = []
|
|
48
|
+
for i, a in enumerate(links):
|
|
49
|
+
href = a.attributes.get("href") or ""
|
|
50
|
+
if "uddg=" in href:
|
|
51
|
+
href = parse_qs(urlsplit(href).query).get("uddg", [href])[0]
|
|
52
|
+
if href.startswith("http"):
|
|
53
|
+
snip = snippets[i].text(separator=" ", strip=True) if i < len(snippets) else ""
|
|
54
|
+
hits.append(
|
|
55
|
+
WireSearchHit(
|
|
56
|
+
url=href, title=a.text(separator=" ", strip=True), snippet=snip, source="ddg"
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
return hits
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_brave(html: str) -> list[WireSearchHit]:
|
|
63
|
+
hits = []
|
|
64
|
+
for res in LexborHTMLParser(html).css('div.snippet[data-type="web"], #results .snippet'):
|
|
65
|
+
a = res.css_first("a[href^='http']")
|
|
66
|
+
if not a:
|
|
67
|
+
continue
|
|
68
|
+
title = res.css_first(".title, .snippet-title, .search-snippet-title")
|
|
69
|
+
desc = res.css_first(".snippet-description, .generic-snippet .content, .snippet-content")
|
|
70
|
+
hits.append(
|
|
71
|
+
WireSearchHit(
|
|
72
|
+
url=a.attributes["href"],
|
|
73
|
+
title=(title or a).text(separator=" ", strip=True),
|
|
74
|
+
snippet=desc.text(separator=" ", strip=True) if desc else "",
|
|
75
|
+
source="brave",
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return hits
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fuse(result_lists: list[list[WireSearchHit]], limit: int) -> list[WireSearchHit]:
|
|
82
|
+
"""Reciprocal-rank consensus: a URL several engines agree on beats one engine's #1."""
|
|
83
|
+
merged: dict[str, WireSearchHit] = {}
|
|
84
|
+
for hits in result_lists:
|
|
85
|
+
seen = set()
|
|
86
|
+
for rank, h in enumerate(hits):
|
|
87
|
+
key = url_key(h.url)
|
|
88
|
+
if key in seen:
|
|
89
|
+
continue
|
|
90
|
+
seen.add(key)
|
|
91
|
+
if key in merged:
|
|
92
|
+
m = merged[key]
|
|
93
|
+
m.rank_score += 1 / (rank + 1)
|
|
94
|
+
if h.source not in m.source.split("+"):
|
|
95
|
+
m.source += f"+{h.source}"
|
|
96
|
+
if len(h.snippet) > len(m.snippet):
|
|
97
|
+
m.snippet = h.snippet
|
|
98
|
+
else:
|
|
99
|
+
merged[key] = h.model_copy(update={"rank_score": 1 / (rank + 1)})
|
|
100
|
+
return sorted(merged.values(), key=lambda h: -h.rank_score)[:limit]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SearchChannel(Channel):
|
|
104
|
+
name = "search"
|
|
105
|
+
|
|
106
|
+
async def _html(self, url: str) -> str:
|
|
107
|
+
f = await self.fetch(url, archive=False, browser=False)
|
|
108
|
+
return f.text
|
|
109
|
+
|
|
110
|
+
async def _ddg(self, query: str) -> list[WireSearchHit]:
|
|
111
|
+
hits = parse_ddg_html(
|
|
112
|
+
await self._html(f"https://html.duckduckgo.com/html/?q={quote_plus(query)}")
|
|
113
|
+
)
|
|
114
|
+
return hits or parse_ddg_lite(
|
|
115
|
+
await self._html(f"https://lite.duckduckgo.com/lite/?q={quote_plus(query)}")
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
async def _brave(self, query: str) -> list[WireSearchHit]:
|
|
119
|
+
return parse_brave(
|
|
120
|
+
await self._html(f"https://search.brave.com/search?q={quote_plus(query)}&source=web")
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
async def _searxng(self, query: str) -> list[WireSearchHit]:
|
|
124
|
+
base = os.environ["WIRE_SEARXNG_URL"].rstrip("/")
|
|
125
|
+
data, _ = await self.fetch_json(f"{base}/search?q={quote_plus(query)}&format=json")
|
|
126
|
+
return [WireSearchHit(url=r["url"], title=r.get("title", ""), snippet=r.get("content", ""),
|
|
127
|
+
source="searxng") for r in data.get("results", [])] # fmt: skip
|
|
128
|
+
|
|
129
|
+
async def _exa(self, query: str) -> list[WireSearchHit]:
|
|
130
|
+
f = await self.ctx.pool.get(
|
|
131
|
+
"https://api.exa.ai/search",
|
|
132
|
+
method="POST",
|
|
133
|
+
impersonate=None,
|
|
134
|
+
headers={"x-api-key": os.environ["EXA_API_KEY"], "content-type": "application/json"},
|
|
135
|
+
json_body={"query": query, "numResults": 10, "contents": {"highlights": True}},
|
|
136
|
+
)
|
|
137
|
+
data = parse_json(f.content)
|
|
138
|
+
return [
|
|
139
|
+
WireSearchHit(url=r["url"], title=r.get("title") or r["url"],
|
|
140
|
+
snippet=" ".join(r.get("highlights") or []), source="exa")
|
|
141
|
+
for r in data.get("results", [])
|
|
142
|
+
] # fmt: skip
|
|
143
|
+
|
|
144
|
+
async def search(self, query, *, limit=5, sort="relevance"):
|
|
145
|
+
engines = [self._ddg, self._brave]
|
|
146
|
+
if os.environ.get("WIRE_SEARXNG_URL"):
|
|
147
|
+
engines.append(self._searxng)
|
|
148
|
+
if os.environ.get("EXA_API_KEY"):
|
|
149
|
+
engines.append(self._exa)
|
|
150
|
+
|
|
151
|
+
async def safe(engine):
|
|
152
|
+
try:
|
|
153
|
+
return await asyncio.wait_for(engine(query), ENGINE_TIMEOUT_S)
|
|
154
|
+
except (TimeoutError, RequestException, WireFetchError, ValueError, KeyError):
|
|
155
|
+
return [] # one engine down must not sink the search
|
|
156
|
+
|
|
157
|
+
return fuse(await asyncio.gather(*(safe(e) for e in engines)), limit)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
CHANNEL = SearchChannel
|