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,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
|