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,223 @@
1
+ """YouTube: InnerTube metadata + in-memory transcript, chapters, top comments, search, channel feeds."""
2
+
3
+ import re
4
+ from urllib.parse import parse_qs, urlsplit
5
+
6
+ from agent_wire.channels.base import (
7
+ Channel,
8
+ WireUnsupported,
9
+ assigned_json,
10
+ compact_count,
11
+ listing,
12
+ relative_ts,
13
+ )
14
+ from agent_wire.engine.waterfall import WireFetchError
15
+ from agent_wire.media.subtitle_cleaner import parse_captions, parse_chapters, to_paragraphs
16
+ from agent_wire.models import WireComment, WireSearchHit
17
+
18
+ INNERTUBE = "https://www.youtube.com/youtubei/v1"
19
+ ANDROID = {"clientName": "ANDROID", "clientVersion": "20.10.38", "androidSdkVersion": 30,
20
+ "hl": "en", "gl": "US"} # fmt: skip
21
+ ANDROID_UA = "com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip"
22
+ WEB = {"clientName": "WEB", "clientVersion": "2.20250925.01.00", "hl": "en", "gl": "US"}
23
+ MAX_COMMENTS = 40
24
+ _ID = re.compile(r"^[\w-]{11}$")
25
+
26
+
27
+ def video_id(url: str) -> str | None:
28
+ p = urlsplit(url)
29
+ host, segs = (p.hostname or ""), [s for s in p.path.split("/") if s]
30
+ if host.endswith("youtu.be") and segs:
31
+ cand = segs[0]
32
+ elif segs[:1] in (["shorts"], ["embed"], ["live"], ["v"]) and len(segs) > 1:
33
+ cand = segs[1]
34
+ else:
35
+ cand = parse_qs(p.query).get("v", [""])[0]
36
+ return cand if _ID.match(cand or "") else None
37
+
38
+
39
+ def pick_track(tracks: list[dict], lang: str = "en") -> dict | None:
40
+ def rank(t):
41
+ manual = t.get("kind") != "asr"
42
+ same = t.get("languageCode", "").startswith(lang)
43
+ return (manual and same, same, manual)
44
+
45
+ return max(tracks, key=rank) if tracks else None
46
+
47
+
48
+ def find_all(obj, key: str):
49
+ """Yield every value stored under `key` anywhere in a nested InnerTube response."""
50
+ if isinstance(obj, dict):
51
+ for k, v in obj.items():
52
+ if k == key:
53
+ yield v
54
+ yield from find_all(v, key)
55
+ elif isinstance(obj, list):
56
+ for v in obj:
57
+ yield from find_all(v, key)
58
+
59
+
60
+ def _runs(obj) -> str:
61
+ if not obj:
62
+ return ""
63
+ return obj.get("simpleText") or "".join(r.get("text", "") for r in obj.get("runs", []))
64
+
65
+
66
+ def parse_search(data: dict, limit: int) -> list[WireSearchHit]:
67
+ hits = []
68
+ for v in find_all(data, "videoRenderer"):
69
+ if not v.get("videoId"):
70
+ continue
71
+ hits.append(
72
+ WireSearchHit(
73
+ url=f"https://www.youtube.com/watch?v={v['videoId']}", title=_runs(v.get("title")),
74
+ snippet=" · ".join(x for x in (_runs(v.get("ownerText")), _runs(v.get("viewCountText")),
75
+ _runs(v.get("lengthText")), _runs(v.get("publishedTimeText"))) if x),
76
+ source="youtube", rank_score=float(compact_count(_runs(v.get("viewCountText")))),
77
+ timestamp=relative_ts(_runs(v.get("publishedTimeText"))), author=_runs(v.get("ownerText")),
78
+ engagement={"views": compact_count(_runs(v.get("viewCountText")))},
79
+ )
80
+ ) # fmt: skip
81
+ if len(hits) >= limit:
82
+ break
83
+ return hits
84
+
85
+
86
+ def parse_lockups(data: dict, limit: int) -> list[dict]:
87
+ """Channel Videos tab (2025+ `lockupViewModel` layout) -> listing items."""
88
+ items = []
89
+ for lv in find_all(data, "lockupViewModel"):
90
+ meta = (lv.get("metadata") or {}).get("lockupMetadataViewModel") or {}
91
+ parts = [p.get("text", {}).get("content") for rows in find_all(meta, "metadataRows")
92
+ for row in rows for p in row.get("metadataParts") or []] # fmt: skip
93
+ if lv.get("contentId"):
94
+ items.append({"title": (meta.get("title") or {}).get("content"),
95
+ "url": f"https://www.youtube.com/watch?v={lv['contentId']}", "meta": parts}) # fmt: skip
96
+ if len(items) >= limit:
97
+ break
98
+ return items
99
+
100
+
101
+ def parse_comment_mutations(data: dict) -> list[WireComment]:
102
+ out = []
103
+ for p in find_all(data, "commentEntityPayload"):
104
+ props, author = p.get("properties", {}), p.get("author", {})
105
+ toolbar = p.get("toolbar", {})
106
+ out.append(
107
+ WireComment(
108
+ id=props.get("commentId", str(len(out))), body=props.get("content", {}).get("content", ""),
109
+ author_handle=(author.get("displayName") or "").lstrip("@") or None,
110
+ score=compact_count(toolbar.get("likeCountNotliked") or toolbar.get("likeCountA11y")),
111
+ is_op=bool(author.get("isCreator")),
112
+ )
113
+ ) # fmt: skip
114
+ return out[:MAX_COMMENTS]
115
+
116
+
117
+ class YouTubeChannel(Channel):
118
+ name = "youtube"
119
+
120
+ async def _innertube(self, endpoint: str, payload: dict, client: dict = WEB):
121
+ headers = {"Content-Type": "application/json"}
122
+ if client is ANDROID:
123
+ headers["User-Agent"] = ANDROID_UA
124
+ data, f = await self.fetch_json(
125
+ f"{INNERTUBE}/{endpoint}?prettyPrint=false", method="POST",
126
+ json_body={"context": {"client": client}, **payload}, headers=headers,
127
+ ) # fmt: skip
128
+ return data, f
129
+
130
+ async def read(self, url, *, query=None, section=None, include_comments=True):
131
+ vid = video_id(url)
132
+ if not vid:
133
+ return await self.feed(url)
134
+ player, f = await self._innertube("player", {"videoId": vid}, ANDROID)
135
+ details = player.get("videoDetails") or {}
136
+ if not details:
137
+ reason = (player.get("playabilityStatus") or {}).get("reason", "unavailable")
138
+ raise WireUnsupported(f"youtube {vid}: {reason}")
139
+ desc = details.get("shortDescription", "")
140
+ chapters = parse_chapters(desc)
141
+ transcript = await self._transcript(player, chapters)
142
+ parts = [desc.strip()[:1500]]
143
+ parts.append(f"## Transcript\n{transcript}" if transcript else "_No captions available._")
144
+ comments = await self._comments(vid) if include_comments else []
145
+ micro = (player.get("microformat") or {}).get("playerMicroformatRenderer") or {}
146
+ return self.doc(
147
+ f, f"https://www.youtube.com/watch?v={vid}", title=details.get("title"),
148
+ author_handle=details.get("author"), body="\n\n".join(p for p in parts if p),
149
+ comments=comments,
150
+ metadata={"views": int(details.get("viewCount") or 0),
151
+ "length": f"{int(details.get('lengthSeconds') or 0) // 60}m",
152
+ "uploaded": micro.get("uploadDate") or micro.get("publishDate"),
153
+ "chapters": len(chapters) or None},
154
+ ) # fmt: skip
155
+
156
+ async def _transcript(self, player: dict, chapters) -> str:
157
+ tracks = (
158
+ (player.get("captions") or {})
159
+ .get("playerCaptionsTracklistRenderer", {})
160
+ .get("captionTracks", [])
161
+ )
162
+ track = pick_track(tracks)
163
+ if not track:
164
+ return ""
165
+ base = re.sub(r"&fmt=[^&]*", "", track["baseUrl"])
166
+ try:
167
+ f = await self.fetch(f"{base}&fmt=json3", archive=False, browser=False)
168
+ segs = parse_captions(f.content) if f.content.strip() else []
169
+ except (WireFetchError, ValueError):
170
+ return ""
171
+ return to_paragraphs(segs, chapters)
172
+
173
+ async def _comments(self, vid: str) -> list[WireComment]:
174
+ try:
175
+ nxt, _ = await self._innertube("next", {"videoId": vid})
176
+ token = next(
177
+ (c["continuationCommand"]["token"]
178
+ for sec in find_all(nxt, "itemSectionRenderer")
179
+ if sec.get("sectionIdentifier") == "comment-item-section"
180
+ for c in find_all(sec, "continuationEndpoint") if "continuationCommand" in c),
181
+ None,
182
+ ) # fmt: skip
183
+ if not token:
184
+ return []
185
+ page, _ = await self._innertube("next", {"continuation": token})
186
+ return parse_comment_mutations(page)
187
+ except (WireFetchError, ValueError, KeyError):
188
+ return [] # comments are a bonus; the transcript is the payload
189
+
190
+ async def search(self, query, *, limit=5, sort="relevance"):
191
+ data, _ = await self._innertube("search", {"query": query})
192
+ return parse_search(data, limit)
193
+
194
+ async def feed(self, target, *, sort="hot", limit=10):
195
+ t = target.strip()
196
+ channel_id = t if re.match(r"^UC[\w-]{22}$", t) else None
197
+ if not channel_id:
198
+ page_url = (
199
+ t
200
+ if t.startswith("http")
201
+ else f"https://www.youtube.com/{t if t.startswith('@') else '@' + t}"
202
+ )
203
+ f = await self.fetch(page_url, archive=False)
204
+ # canonical / externalId name the page's own channel; a bare "channelId" can be a recommendation
205
+ m = (re.search(r'<link rel="canonical" href="https://www\.youtube\.com/channel/(UC[\w-]{22})"', f.text)
206
+ or re.search(r'"externalId":"(UC[\w-]{22})"', f.text)) # fmt: skip
207
+ if not m:
208
+ raise WireUnsupported(f"couldn't resolve YouTube channel for {target!r}")
209
+ channel_id = m[1]
210
+ rss = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
211
+ try:
212
+ return await self.ctx.channel("rss").feed(rss, limit=limit)
213
+ except WireFetchError: # the RSS endpoint 500s intermittently; read the Videos tab instead
214
+ url = f"https://www.youtube.com/channel/{channel_id}/videos"
215
+ f = await self.fetch(url, archive=False)
216
+ data = assigned_json(f.text, "var ytInitialData") or {}
217
+ name = next(find_all(data, "channelMetadataRenderer"), {}).get("title") or channel_id
218
+ return self.doc(
219
+ f, url, title=f"{name} · videos", body=listing(parse_lockups(data, limit))
220
+ )
221
+
222
+
223
+ CHANNEL = YouTubeChannel
File without changes
@@ -0,0 +1,66 @@
1
+ """Wayback Machine and Archive.today: read snapshots, list a URL's capture history."""
2
+
3
+ import re
4
+ from urllib.parse import quote, urlsplit
5
+
6
+ from agent_wire.channels.base import Channel, WireUnsupported, listing
7
+ from agent_wire.channels.tier1_global.web import extract_article
8
+ from agent_wire.engine.waterfall import wayback_snapshot
9
+ from agent_wire.models import WireSearchHit
10
+
11
+ _WAYBACK = re.compile(r"^/web/(\d{1,14})(?:[a-z_]{2,3}_)?/(.+)$")
12
+
13
+
14
+ def raw_wayback(url: str) -> str:
15
+ """Wayback page URL -> the `id_` form, which serves the original bytes without the toolbar."""
16
+ m = _WAYBACK.match(
17
+ urlsplit(url).path + (f"?{urlsplit(url).query}" if urlsplit(url).query else "")
18
+ )
19
+ return f"https://web.archive.org/web/{m[1]}id_/{m[2]}" if m else url
20
+
21
+
22
+ def parse_cdx(rows: list) -> list[dict]:
23
+ if not rows:
24
+ return []
25
+ header, *data = rows
26
+ return [dict(zip(header, r, strict=False)) for r in data]
27
+
28
+
29
+ class ArchiveChannel(Channel):
30
+ name = "archive"
31
+
32
+ async def read(self, url, *, query=None, section=None, include_comments=True):
33
+ host = urlsplit(url).hostname or ""
34
+ if host == "web.archive.org":
35
+ target = raw_wayback(url)
36
+ elif host in ("archive.ph", "archive.today", "archive.is"):
37
+ target = url
38
+ else: # any other URL: read its newest Wayback snapshot
39
+ target = await wayback_snapshot(self.ctx.pool, url)
40
+ if not target:
41
+ raise WireUnsupported(f"no Wayback snapshot of {url}")
42
+ f = await self.fetch(target, archive=False)
43
+ body, info = extract_article(f.text)
44
+ return self.doc(f, url, title=info["title"], author_handle=info["author"], body=body,
45
+ metadata={"snapshot": target}) # fmt: skip
46
+
47
+ async def search(self, query, *, limit=5, sort="relevance"):
48
+ """Capture history of a URL (newest first)."""
49
+ if "." not in query:
50
+ raise WireUnsupported("archive search takes a URL and lists its Wayback captures")
51
+ data, _ = await self.fetch_json(
52
+ f"https://web.archive.org/cdx/search/cdx?url={quote(query)}&output=json&limit=-{limit}"
53
+ "&fl=timestamp,original,statuscode&filter=statuscode:200"
54
+ )
55
+ caps = list(reversed(parse_cdx(data)))
56
+ return [WireSearchHit(url=f"https://web.archive.org/web/{c['timestamp']}/{c['original']}",
57
+ title=f"{c['original']} @ {c['timestamp'][:8]}", snippet=f"HTTP {c.get('statuscode')}",
58
+ source="wayback") for c in caps[:limit]] # fmt: skip
59
+
60
+ async def feed(self, target, *, sort="hot", limit=10):
61
+ hits = await self.search(target, limit=limit)
62
+ return self.doc(None, target, title=f"Wayback captures of {target}",
63
+ body=listing([{"title": h.title, "url": h.url} for h in hits])) # fmt: skip
64
+
65
+
66
+ CHANNEL = ArchiveChannel
@@ -0,0 +1,220 @@
1
+ """arXiv: full paper as Markdown (arxiv.org/html, ar5iv), abstract fallback, Semantic Scholar stats."""
2
+
3
+ import asyncio
4
+ import re
5
+ import xml.etree.ElementTree as ET
6
+ from datetime import UTC
7
+ from urllib.parse import quote, urlsplit
8
+
9
+ from curl_cffi.requests.exceptions import RequestException
10
+
11
+ from agent_wire.channels.base import Channel, WireUnsupported, listing, ts
12
+ from agent_wire.channels.tier1_global.web import extract_article
13
+ from agent_wire.compression.anchor_slicer import slice_section
14
+ from agent_wire.engine.waterfall import WireFetchError
15
+ from agent_wire.models import WireSearchHit
16
+
17
+ API = "https://export.arxiv.org/api/query"
18
+ EXPORT_BUDGET_S = 8 # past this OpenAlex / the arxiv.org search page answer faster
19
+ OPENALEX = "https://api.openalex.org/works"
20
+ OPENALEX_ARXIV = "S4306400194" # OpenAlex source id of arXiv
21
+
22
+
23
+ def _abstract(inverted: dict | None) -> str:
24
+ """OpenAlex ships abstracts as {word: [positions]}."""
25
+ pos = sorted((i, w) for w, idx in (inverted or {}).items() for i in idx)
26
+ return " ".join(w for _, w in pos)
27
+
28
+
29
+ def openalex_hit(w: dict) -> WireSearchHit | None:
30
+ url = next((loc.get("landing_page_url") for loc in w.get("locations") or []
31
+ if "arxiv.org/abs/" in (loc.get("landing_page_url") or "")), None) # fmt: skip
32
+ if not url:
33
+ return None
34
+ authors = [(a.get("author") or {}).get("display_name") for a in w.get("authorships") or []]
35
+ when = ts(w.get("publication_date")) # a bare date parses naive
36
+ return WireSearchHit(
37
+ url=url.replace("http://", "https://"), title=w.get("title") or "", source="arxiv",
38
+ snippet=_abstract(w.get("abstract_inverted_index"))[:300],
39
+ timestamp=when.replace(tzinfo=UTC) if when and when.tzinfo is None else when,
40
+ author=", ".join(a for a in authors[:3] if a) or None,
41
+ engagement={"citations": w.get("cited_by_count") or 0},
42
+ ) # fmt: skip
43
+
44
+
45
+ _SUBMITTED = re.compile(r"Submitted\s+(\d{1,2})\s+([A-Za-z]+),?\s+(\d{4})")
46
+
47
+
48
+ def _words(node) -> str:
49
+ return " ".join(node.text(separator=" ").split()) if node else ""
50
+
51
+
52
+ def parse_search_html(html: str) -> list[WireSearchHit]:
53
+ """arxiv.org/search results (used when the export API times out)."""
54
+ from datetime import UTC, datetime
55
+
56
+ from selectolax.lexbor import LexborHTMLParser
57
+
58
+ hits = []
59
+ for li in LexborHTMLParser(html).css("li.arxiv-result"):
60
+ a = li.css_first("p.list-title a")
61
+ if not a or not (href := a.attributes.get("href")):
62
+ continue
63
+ when = None
64
+ if m := _SUBMITTED.search(_words(li.css_first("p.is-size-7"))):
65
+ try:
66
+ when = datetime.strptime(f"{m[1]} {m[2]} {m[3]}", "%d %B %Y").replace(tzinfo=UTC)
67
+ except ValueError:
68
+ when = None
69
+ hits.append(WireSearchHit(
70
+ url=href, title=_words(li.css_first("p.title")), source="arxiv", timestamp=when,
71
+ snippet=_words(li.css_first("span.abstract-full")).removesuffix("△ Less").strip()[:300],
72
+ author=", ".join(_words(x) for x in li.css("p.authors a")[:3]),
73
+ )) # fmt: skip
74
+ return hits
75
+
76
+
77
+ S2 = "https://api.semanticscholar.org/graph/v1/paper/arXiv:{}?fields=title,year,venue,citationCount,tldr"
78
+ _ID = re.compile(r"(\d{4}\.\d{4,5}(?:v\d+)?|[a-z\-]+(?:\.[A-Z]{2})?/\d{7}(?:v\d+)?)")
79
+ _ATOM = "{http://www.w3.org/2005/Atom}"
80
+
81
+
82
+ def arxiv_id(url: str) -> str | None:
83
+ path = urlsplit(url).path
84
+ m = _ID.search(path.removesuffix(".pdf"))
85
+ return m[1] if m else None
86
+
87
+
88
+ def parse_atom(content: bytes) -> list[dict]:
89
+ root = ET.fromstring(content)
90
+ out = []
91
+ for e in root.findall(f"{_ATOM}entry"):
92
+ link = e.findtext(f"{_ATOM}id") or ""
93
+ out.append({
94
+ "id": link.rsplit("/abs/", 1)[-1],
95
+ "title": " ".join((e.findtext(f"{_ATOM}title") or "").split()),
96
+ "summary": " ".join((e.findtext(f"{_ATOM}summary") or "").split()),
97
+ "authors": [a.findtext(f"{_ATOM}name") for a in e.findall(f"{_ATOM}author")],
98
+ "published": ts(e.findtext(f"{_ATOM}published")),
99
+ "url": link,
100
+ "categories": [c.get("term") for c in e.findall(f"{_ATOM}category")],
101
+ }) # fmt: skip
102
+ return out
103
+
104
+
105
+ class ArxivChannel(Channel):
106
+ name = "arxiv"
107
+
108
+ async def _citations(self, pid: str) -> dict:
109
+ try:
110
+ data, _ = await self.fetch_json(S2.format(pid.split("v")[0]))
111
+ except (WireFetchError, RequestException, ValueError):
112
+ return {}
113
+ tldr = (data.get("tldr") or {}).get("text")
114
+ return {"citations": data.get("citationCount"), "venue": data.get("venue") or None,
115
+ "tldr": tldr} # fmt: skip
116
+
117
+ async def read(self, url, *, query=None, section=None, include_comments=True):
118
+ pid = arxiv_id(url)
119
+ if not pid:
120
+ raise WireUnsupported("arxiv: expected an /abs/, /pdf/ or /html/ paper URL")
121
+ abs_data, abs_f = await self.fetch_json_atom(pid)
122
+ meta = await self._citations(pid)
123
+ try: # full text: arXiv's own HTML render, ar5iv as mirror
124
+ f = await self.fetch(f"https://arxiv.org/html/{pid}", archive=False, browser=False,
125
+ mirrors=[f"https://ar5iv.labs.arxiv.org/html/{pid}"]) # fmt: skip
126
+ body, _ = extract_article(f.text)
127
+ except WireFetchError:
128
+ f, body = abs_f, ""
129
+ if not body.strip() and abs_data:
130
+ body = f"## Abstract\n{abs_data['summary']}\n\n_Full text not available as HTML; abstract only._"
131
+ if section:
132
+ body = slice_section(body, section) or body
133
+ if meta.get("tldr"):
134
+ body = f"TL;DR: {meta['tldr']}\n\n{body}"
135
+ return self.doc(
136
+ f, f"https://arxiv.org/abs/{pid}", title=abs_data.get("title") if abs_data else pid,
137
+ author_handle=", ".join(abs_data.get("authors", [])[:6]) if abs_data else None,
138
+ timestamp=abs_data.get("published") if abs_data else None, body=body,
139
+ metadata={"arxiv": pid, "categories": ", ".join(abs_data.get("categories", [])[:4]) if abs_data else None,
140
+ "citations": meta.get("citations"), "venue": meta.get("venue")},
141
+ ) # fmt: skip
142
+
143
+ async def fetch_json_atom(self, pid: str):
144
+ f = await self.fetch(f"{API}?id_list={pid}", archive=False, browser=False)
145
+ entries = parse_atom(f.content)
146
+ return (entries[0] if entries else {}), f
147
+
148
+ async def _query(self, q: str, limit: int, by_date: bool):
149
+ sort = "submittedDate" if by_date else "relevance"
150
+ f = await self.fetch(f"{API}?search_query={quote(q)}&max_results={limit}&sortBy={sort}&sortOrder=descending",
151
+ archive=False, browser=False) # fmt: skip
152
+ return parse_atom(f.content), f
153
+
154
+ async def search(self, query, *, limit=5, sort="relevance"):
155
+ try:
156
+ entries, _ = await asyncio.wait_for(
157
+ self._query(f"all:{query}", limit, sort in ("new", "date", "recent")),
158
+ EXPORT_BUDGET_S,
159
+ )
160
+ except (WireFetchError, TimeoutError, ET.ParseError): # export.arxiv.org stalls / 429s
161
+ by_date = sort in ("new", "date", "recent")
162
+ for fallback in (self._openalex_search, self._html_search):
163
+ try:
164
+ if hits := await fallback(query, limit, by_date):
165
+ return hits
166
+ except (WireFetchError, RequestException, ValueError, KeyError, TypeError):
167
+ continue
168
+ return await self._s2_search(query, limit)
169
+ return [WireSearchHit(url=e["url"], title=e["title"], snippet=e["summary"][:300], source="arxiv",
170
+ timestamp=e["published"], author=", ".join(a for a in e["authors"][:3] if a))
171
+ for e in entries] # fmt: skip
172
+
173
+ async def _openalex_search(self, query: str, limit: int, by_date: bool) -> list[WireSearchHit]:
174
+ order = "publication_date:desc" if by_date else "relevance_score:desc"
175
+ data, _ = await self.fetch_json(
176
+ f"{OPENALEX}?search={quote(query)}&filter=locations.source.id:{OPENALEX_ARXIV}"
177
+ f"&sort={order}&per_page={limit}&select=title,publication_date,authorships,locations,"
178
+ "cited_by_count,abstract_inverted_index"
179
+ )
180
+ return [h for h in map(openalex_hit, data.get("results") or []) if h][:limit]
181
+
182
+ async def _html_search(self, query: str, limit: int, by_date: bool) -> list[WireSearchHit]:
183
+ order = "&order=-announced_date_first" if by_date else ""
184
+ f = await self.fetch(f"https://arxiv.org/search/?query={quote(query)}&searchtype=all"
185
+ f"&size=25{order}", archive=False, browser=False) # fmt: skip
186
+ return parse_search_html(f.text)[:limit]
187
+
188
+ async def _s2_search(self, query: str, limit: int) -> list[WireSearchHit]:
189
+ data, _ = await self.fetch_json(
190
+ f"https://api.semanticscholar.org/graph/v1/paper/search?query={quote(query)}&limit={limit}"
191
+ "&fields=title,abstract,externalIds,year,citationCount"
192
+ )
193
+ hits = []
194
+ for p in data.get("data", []):
195
+ ax = (p.get("externalIds") or {}).get("ArXiv")
196
+ url = (
197
+ f"https://arxiv.org/abs/{ax}"
198
+ if ax
199
+ else f"https://www.semanticscholar.org/paper/{p['paperId']}"
200
+ )
201
+ hits.append(WireSearchHit(url=url, title=p.get("title") or "", source="semanticscholar",
202
+ snippet=f"{p.get('year')} · {p.get('citationCount')} citations · "
203
+ f"{(p.get('abstract') or '')[:240]}")) # fmt: skip
204
+ return hits
205
+
206
+ async def feed(self, target, *, sort="hot", limit=10):
207
+ cat = target.strip().removeprefix("arxiv:")
208
+ entries, f = await self._query(f"cat:{cat}", limit, True)
209
+ items = [{"title": e["title"], "url": e["url"], "meta": [", ".join(e["authors"][:3]),
210
+ e["published"].date().isoformat() if e["published"] else None],
211
+ "snippet": e["summary"]} for e in entries] # fmt: skip
212
+ return self.doc(
213
+ f,
214
+ f"https://arxiv.org/list/{cat}/recent",
215
+ title=f"arXiv {cat} · newest",
216
+ body=listing(items),
217
+ )
218
+
219
+
220
+ CHANNEL = ArxivChannel
@@ -0,0 +1,114 @@
1
+ """DEV Community (dev.to public API, no key): developer write-ups by tag, with reactions."""
2
+
3
+ import re
4
+ from datetime import UTC, datetime
5
+ from urllib.parse import quote, urlsplit
6
+
7
+ from agent_wire.channels.base import Channel, WireUnsupported, html_to_markdown, listing, ts
8
+ from agent_wire.engine.waterfall import WireFetchError
9
+ from agent_wire.models import WireComment, WireSearchHit
10
+
11
+ API = "https://dev.to/api"
12
+
13
+
14
+ _STOP = frozenset({"the", "a", "an", "and", "or", "of", "for", "to", "in", "on", "with", "vs",
15
+ "versus", "what", "is", "are", "how", "why", "best", "new"}) # fmt: skip
16
+
17
+
18
+ def tag_of(text: str) -> str:
19
+ """dev.to tags are single lower-case alphanumeric words; use the longest topic word."""
20
+ return tag_candidates(text)[-1]
21
+
22
+
23
+ def tag_candidates(text: str) -> list[str]:
24
+ """Most to least specific: acronym ('model context protocol' -> mcp), joined words, longest word."""
25
+ words = [w for w in re.findall(r"[a-z0-9]+", text.lower()) if w not in _STOP]
26
+ if not words:
27
+ raise WireUnsupported("devto: give a topic word, e.g. 'rust' or 'webdev'")
28
+ out = []
29
+ if 2 < len(words) < 6:
30
+ out.append("".join(w[0] for w in words))
31
+ if len(words) > 1 and len(joined := "".join(words)) <= 30:
32
+ out.append(joined)
33
+ out.append(max(words, key=len))
34
+ return list(dict.fromkeys(out))
35
+
36
+
37
+ def article_hit(a: dict) -> WireSearchHit:
38
+ return WireSearchHit(
39
+ url=a.get("url") or "", title=a.get("title") or "", snippet=a.get("description") or "",
40
+ source="devto", rank_score=float(a.get("public_reactions_count") or 0),
41
+ timestamp=ts(a.get("published_at")), author=(a.get("user") or {}).get("username"),
42
+ engagement={"likes": a.get("public_reactions_count") or 0,
43
+ "comments": a.get("comments_count") or 0},
44
+ ) # fmt: skip
45
+
46
+
47
+ def _tags(a: dict) -> str | None:
48
+ """The list endpoint sends tag_list as a list, the article endpoint as 'a, b' (and tags as a list)."""
49
+ raw = a.get("tag_list") or a.get("tags")
50
+ return ", ".join(raw) if isinstance(raw, list) else raw or None
51
+
52
+
53
+ def _comments(items: list, depth: int = 0) -> list[WireComment]:
54
+ return [WireComment(id=c.get("id_code") or str(i), body=html_to_markdown(c.get("body_html") or ""),
55
+ author_handle=(c.get("user") or {}).get("username"),
56
+ timestamp=ts(c.get("created_at")), depth=depth,
57
+ replies=_comments(c.get("children") or [], depth + 1))
58
+ for i, c in enumerate(items)] # fmt: skip
59
+
60
+
61
+ class DevToChannel(Channel):
62
+ name = "devto"
63
+
64
+ async def search(self, query, *, limit=5, sort="relevance", since=None):
65
+ days = 30 if since is None else max(1, (datetime.now(UTC) - since).days)
66
+ words = {w for w in re.findall(r"[a-z0-9]+", query.lower()) if w not in _STOP}
67
+ tags = tag_candidates(query)
68
+ for tag in tags:
69
+ last = tag == tags[-1]
70
+ try:
71
+ data, _ = await self.fetch_json(
72
+ f"{API}/articles?tag={quote(tag)}&top={days}&per_page={limit}"
73
+ )
74
+ except (WireFetchError, ValueError):
75
+ if last:
76
+ raise
77
+ continue
78
+ text = set(re.findall(r"[a-z0-9]+", " ".join(
79
+ f"{a.get('title')} {a.get('description')}" for a in data).lower())) # fmt: skip
80
+ # a guessed tag must bring on-topic articles; the last (plain word) tag is taken as is
81
+ if last or (data and (tag in text or len(words & text) > 1)):
82
+ return [article_hit(a) for a in data[:limit]]
83
+ return []
84
+
85
+ async def feed(self, target, *, sort="hot", limit=10):
86
+ tag = tag_of(target)
87
+ data, f = await self.fetch_json(f"{API}/articles?tag={quote(tag)}&top=7&per_page={limit}")
88
+ items = [{"title": h.title, "url": h.url, "snippet": h.snippet,
89
+ "meta": [f"♥{h.engagement['likes']}", f"{h.engagement['comments']} comments", h.author]}
90
+ for h in map(article_hit, data)] # fmt: skip
91
+ return self.doc(
92
+ f, f"https://dev.to/t/{tag}/top/week", title=f"dev.to · #{tag}", body=listing(items)
93
+ )
94
+
95
+ async def read(self, url, *, query=None, section=None, include_comments=True):
96
+ parts = [p for p in urlsplit(url).path.split("/") if p]
97
+ if parts[:1] == ["t"] and len(parts) >= 2: # /t/<tag>[/top/week]: the tag's feed
98
+ return await self.feed(parts[1])
99
+ if len(parts) != 2:
100
+ return await self.ctx.channel("web").read(url, query=query, section=section)
101
+ a, f = await self.fetch_json(f"{API}/articles/{parts[0]}/{parts[1]}")
102
+ comments = []
103
+ if include_comments and a.get("id"):
104
+ raw, _ = await self.fetch_json(f"{API}/comments?a_id={a['id']}")
105
+ comments = _comments(raw)
106
+ return self.doc(
107
+ f, url, title=a.get("title"), author_handle=(a.get("user") or {}).get("username"),
108
+ timestamp=ts(a.get("published_at")), score=a.get("public_reactions_count"),
109
+ body=a.get("body_markdown") or html_to_markdown(a.get("body_html") or ""),
110
+ comments=comments, metadata={"tags": _tags(a), "comments": a.get("comments_count")},
111
+ ) # fmt: skip
112
+
113
+
114
+ CHANNEL = DevToChannel