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.
Files changed (87) hide show
  1. agent_wire/__init__.py +1 -0
  2. agent_wire/__main__.py +3 -0
  3. agent_wire/assets/SKILL.md +26 -0
  4. agent_wire/assets/cursor_rule.mdc +13 -0
  5. agent_wire/assets/hermes_skill.md +28 -0
  6. agent_wire/assets/windsurf_rules.md +14 -0
  7. agent_wire/assets/wire-worker.md +20 -0
  8. agent_wire/auth/__init__.py +0 -0
  9. agent_wire/auth/browser_paths.py +143 -0
  10. agent_wire/auth/cookie_vault.py +181 -0
  11. agent_wire/auth/rate_limiter.py +26 -0
  12. agent_wire/cache/__init__.py +0 -0
  13. agent_wire/cache/ring_buffer.py +94 -0
  14. agent_wire/cache/sqlite_ledger.py +132 -0
  15. agent_wire/channels/__init__.py +41 -0
  16. agent_wire/channels/base.py +249 -0
  17. agent_wire/channels/tier1_global/__init__.py +0 -0
  18. agent_wire/channels/tier1_global/github.py +371 -0
  19. agent_wire/channels/tier1_global/hackernews.py +135 -0
  20. agent_wire/channels/tier1_global/reddit.py +371 -0
  21. agent_wire/channels/tier1_global/rss.py +129 -0
  22. agent_wire/channels/tier1_global/search.py +160 -0
  23. agent_wire/channels/tier1_global/stackoverflow.py +94 -0
  24. agent_wire/channels/tier1_global/web.py +98 -0
  25. agent_wire/channels/tier1_global/wikipedia.py +91 -0
  26. agent_wire/channels/tier1_global/x_twitter.py +380 -0
  27. agent_wire/channels/tier1_global/youtube.py +223 -0
  28. agent_wire/channels/tier2_pro/__init__.py +0 -0
  29. agent_wire/channels/tier2_pro/archive_bypass.py +66 -0
  30. agent_wire/channels/tier2_pro/arxiv_papers.py +220 -0
  31. agent_wire/channels/tier2_pro/devto.py +114 -0
  32. agent_wire/channels/tier2_pro/fediverse.py +181 -0
  33. agent_wire/channels/tier2_pro/hubs.py +161 -0
  34. agent_wire/channels/tier2_pro/linkedin.py +146 -0
  35. agent_wire/channels/tier2_pro/markets.py +207 -0
  36. agent_wire/channels/tier2_pro/meta_social.py +104 -0
  37. agent_wire/channels/tier2_pro/news.py +173 -0
  38. agent_wire/channels/tier2_pro/polymarket.py +105 -0
  39. agent_wire/channels/tier2_pro/publishing.py +84 -0
  40. agent_wire/channels/tier2_pro/stocktwits.py +79 -0
  41. agent_wire/channels/tier3_regional/bilibili.py +171 -0
  42. agent_wire/channels/tier3_regional/boss_zhipin.py +35 -0
  43. agent_wire/channels/tier3_regional/douyin_tiktok.py +85 -0
  44. agent_wire/channels/tier3_regional/v2ex.py +70 -0
  45. agent_wire/channels/tier3_regional/wechat.py +67 -0
  46. agent_wire/channels/tier3_regional/xiaohongshu.py +58 -0
  47. agent_wire/channels/tier3_regional/xiaoyuzhou.py +70 -0
  48. agent_wire/channels/tier3_regional/xueqiu.py +77 -0
  49. agent_wire/channels/tier3_regional/zhihu.py +57 -0
  50. agent_wire/cli.py +403 -0
  51. agent_wire/compression/__init__.py +0 -0
  52. agent_wire/compression/anchor_slicer.py +45 -0
  53. agent_wire/compression/bm25_slicer.py +151 -0
  54. agent_wire/compression/dam_formatter.py +74 -0
  55. agent_wire/compression/url_compactor.py +45 -0
  56. agent_wire/config.py +52 -0
  57. agent_wire/engine/__init__.py +0 -0
  58. agent_wire/engine/challenge.py +58 -0
  59. agent_wire/engine/http_pool.py +136 -0
  60. agent_wire/engine/stealth_browser.py +90 -0
  61. agent_wire/engine/waterfall.py +159 -0
  62. agent_wire/firewall/__init__.py +0 -0
  63. agent_wire/firewall/inbound_shield.py +116 -0
  64. agent_wire/firewall/outbound_privacy.py +120 -0
  65. agent_wire/installers/__init__.py +0 -0
  66. agent_wire/installers/client_installer.py +315 -0
  67. agent_wire/media/__init__.py +0 -0
  68. agent_wire/media/audio_transcriber.py +77 -0
  69. agent_wire/media/subtitle_cleaner.py +102 -0
  70. agent_wire/models.py +60 -0
  71. agent_wire/research/__init__.py +1 -0
  72. agent_wire/research/brief.py +227 -0
  73. agent_wire/research/briefing.py +41 -0
  74. agent_wire/research/cluster.py +128 -0
  75. agent_wire/research/engine.py +632 -0
  76. agent_wire/research/library.py +74 -0
  77. agent_wire/research/planner.py +232 -0
  78. agent_wire/research/scoring.py +89 -0
  79. agent_wire/research/store.py +149 -0
  80. agent_wire/research/watch.py +70 -0
  81. agent_wire/router.py +107 -0
  82. agent_wire/server.py +468 -0
  83. agent_wire_cli-0.2.1.dist-info/METADATA +258 -0
  84. agent_wire_cli-0.2.1.dist-info/RECORD +87 -0
  85. agent_wire_cli-0.2.1.dist-info/WHEEL +4 -0
  86. agent_wire_cli-0.2.1.dist-info/entry_points.txt +4 -0
  87. 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