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,84 @@
1
+ """Substack (posts API + comments) and Medium (reader with Wayback fallback on member-only stories)."""
2
+
3
+ from urllib.parse import urlsplit
4
+
5
+ from agent_wire.channels.base import Channel, WireUnsupported, html_to_markdown, ts
6
+ from agent_wire.channels.tier1_global.web import extract_article
7
+ from agent_wire.engine.waterfall import WireFetchError, wayback_snapshot
8
+ from agent_wire.models import WireComment
9
+
10
+ _PAYWALL = ("member-only story", "this post is for paid subscribers", "upgrade to paid")
11
+
12
+
13
+ def parse_substack_comments(items: list[dict], depth: int = 0) -> list[WireComment]:
14
+ return [
15
+ WireComment(id=str(c.get("id")), body=c.get("body") or "", author_handle=c.get("name"),
16
+ score=c.get("reaction_count") or sum((c.get("reactions") or {}).values()),
17
+ timestamp=ts(c.get("date")), depth=depth,
18
+ replies=parse_substack_comments(c.get("children") or [], depth + 1))
19
+ for c in items if c.get("body")
20
+ ] # fmt: skip
21
+
22
+
23
+ class PublishingChannel(Channel):
24
+ name = "publishing"
25
+
26
+ async def read(self, url, *, query=None, section=None, include_comments=True):
27
+ p = urlsplit(url)
28
+ host = p.hostname or ""
29
+ segs = [s for s in p.path.split("/") if s]
30
+ if segs[:1] == ["p"] and len(segs) > 1 and "medium.com" not in host:
31
+ return await self._substack(url, host, segs[1], include_comments)
32
+ return await self._medium(url, query, section)
33
+
34
+ async def _substack(self, url, host, slug, include_comments):
35
+ post, f = await self.fetch_json(f"https://{host}/api/v1/posts/{slug}")
36
+ body = html_to_markdown(post.get("body_html") or "")
37
+ if post.get("audience") == "only_paid" and len(body) < 2000:
38
+ body += "\n\n_Paid post: only the free preview is public._"
39
+ comments = []
40
+ if include_comments and post.get("id") and post.get("comment_count"):
41
+ try:
42
+ data, _ = await self.fetch_json(
43
+ f"https://{host}/api/v1/post/{post['id']}/comments?all_comments=true&sort=best_first"
44
+ )
45
+ comments = parse_substack_comments(data.get("comments", []))
46
+ except (WireFetchError, ValueError):
47
+ comments = []
48
+ bylines = post.get("publishedBylines") or [{}]
49
+ return self.doc(
50
+ f, url, title=post.get("title"), author_handle=bylines[0].get("name"),
51
+ timestamp=ts(post.get("post_date")), body=f"{post.get('subtitle') or ''}\n\n{body}".strip(),
52
+ comments=comments, score=post.get("reaction_count"),
53
+ metadata={"comments": post.get("comment_count"), "audience": post.get("audience")},
54
+ ) # fmt: skip
55
+
56
+ async def _medium(self, url, query, section):
57
+ doc = await self.ctx.channel("web").read(url, query=query, section=section)
58
+ gated = any(m in doc.body.lower()[:4000] for m in _PAYWALL) or len(doc.body) < 1200
59
+ if gated and (snap := await wayback_snapshot(self.ctx.pool, url)):
60
+ f = await self.fetch(snap, archive=False, browser=False)
61
+ body, info = extract_article(f.text)
62
+ if len(body) > len(doc.body):
63
+ return self.doc(f, url, title=info["title"] or doc.title, author_handle=info["author"],
64
+ body=body, metadata={"archived": snap}) # fmt: skip
65
+ return doc.model_copy(update={"channel": self.name})
66
+
67
+ async def feed(self, target, *, sort="hot", limit=10):
68
+ t = target.strip().rstrip("/")
69
+ if t.startswith("@") or "medium.com" in t:
70
+ handle = t.split("medium.com/")[-1]
71
+ return await self.ctx.channel("rss").feed(
72
+ f"https://medium.com/feed/{handle}", limit=limit
73
+ )
74
+ host = (
75
+ urlsplit(t).hostname if "//" in t else f"{t.removesuffix('.substack.com')}.substack.com"
76
+ )
77
+ if not host:
78
+ raise WireUnsupported(
79
+ "publishing feeds: '@medium_user', 'name' (Substack) or a newsletter URL"
80
+ )
81
+ return await self.ctx.channel("rss").feed(f"https://{host}/feed", limit=limit)
82
+
83
+
84
+ CHANNEL = PublishingChannel
@@ -0,0 +1,79 @@
1
+ """StockTwits symbol streams (public API, no key): retail chatter and bull/bear sentiment."""
2
+
3
+ import re
4
+ from collections import Counter
5
+
6
+ from agent_wire.channels.base import Channel, WireUnsupported, listing, ts
7
+ from agent_wire.models import WireSearchHit
8
+
9
+ API = "https://api.stocktwits.com/api/2"
10
+ _SYMBOL = re.compile(r"^\$?([A-Za-z][A-Za-z0-9.\-]{0,11})$")
11
+
12
+
13
+ def symbol_of(text: str) -> str:
14
+ m = _SYMBOL.match(text.strip().removeprefix("https://stocktwits.com/symbol/").strip("/"))
15
+ if not m:
16
+ raise WireUnsupported("stocktwits: give a ticker like $NVDA or BTC.X")
17
+ return m[1].upper()
18
+
19
+
20
+ def _mood(msg: dict) -> str | None:
21
+ return ((msg.get("entities") or {}).get("sentiment") or {}).get("basic")
22
+
23
+
24
+ def message_hits(data: dict) -> list[WireSearchHit]:
25
+ hits = []
26
+ for msg in data.get("messages") or []:
27
+ user = (msg.get("user") or {}).get("username")
28
+ mood, likes = _mood(msg), (msg.get("likes") or {}).get("total") or 0
29
+ hits.append(WireSearchHit(
30
+ url=f"https://stocktwits.com/{user}/message/{msg.get('id')}",
31
+ title=f"@{user}" + (f" · {mood}" if mood else ""), snippet=msg.get("body") or "",
32
+ source="stocktwits", rank_score=float(likes), timestamp=ts(msg.get("created_at")),
33
+ author=user, engagement={"likes": likes},
34
+ )) # fmt: skip
35
+ return hits
36
+
37
+
38
+ def sentiment(data: dict) -> str:
39
+ c = Counter(_mood(m) for m in data.get("messages") or [])
40
+ bull, bear = c.get("Bullish", 0), c.get("Bearish", 0)
41
+ return f"{bull} bullish / {bear} bearish" if bull + bear else "no tagged sentiment"
42
+
43
+
44
+ class StockTwitsChannel(Channel):
45
+ name = "stocktwits"
46
+
47
+ async def _stream(self, symbol: str):
48
+ return await self.fetch_json(f"{API}/streams/symbol/{symbol}.json")
49
+
50
+ async def search(self, query, *, limit=5, sort="relevance", since=None):
51
+ data, _ = await self._stream(symbol_of(query))
52
+ hits = message_hits(data)
53
+ if since:
54
+ hits = [h for h in hits if h.timestamp is None or h.timestamp >= since]
55
+ return hits[:limit]
56
+
57
+ async def feed(self, target, *, sort="hot", limit=10):
58
+ if target.strip().lower() in ("trending", "hot"):
59
+ data, f = await self.fetch_json(f"{API}/trending/symbols.json")
60
+ items = [{"title": s.get("symbol"), "url": f"https://stocktwits.com/symbol/{s.get('symbol')}",
61
+ "meta": [s.get("title"), f"{s.get('watchlist_count', 0):,} watchers"]}
62
+ for s in (data.get("symbols") or [])[:limit]] # fmt: skip
63
+ return self.doc(f, "https://stocktwits.com/sentiment", title="StockTwits · trending",
64
+ body=listing(items)) # fmt: skip
65
+ sym = symbol_of(target)
66
+ data, f = await self._stream(sym)
67
+ info = data.get("symbol") or {}
68
+ items = [{"title": h.title, "url": h.url, "snippet": h.snippet,
69
+ "meta": [h.timestamp.isoformat(timespec="minutes") if h.timestamp else None]}
70
+ for h in message_hits(data)[:limit]] # fmt: skip
71
+ head = f"{info.get('title') or sym} · {info.get('watchlist_count', 0):,} watchers · {sentiment(data)}"
72
+ return self.doc(f, f"https://stocktwits.com/symbol/{sym}", title=f"StockTwits · ${sym}",
73
+ body=f"{head}\n\n{listing(items)}", metadata={"sentiment": sentiment(data)}) # fmt: skip
74
+
75
+ async def read(self, url, *, query=None, section=None, include_comments=True):
76
+ return await self.feed(url, limit=20)
77
+
78
+
79
+ CHANNEL = StockTwitsChannel
@@ -0,0 +1,171 @@
1
+ """Bilibili: video info, CC subtitles, hot comments, search, popular feed — in-process WBI signing."""
2
+
3
+ import hashlib
4
+ import re
5
+ import time
6
+ from urllib.parse import urlencode, urlsplit
7
+
8
+ from agent_wire.channels.base import Channel, WireUnsupported, listing, ts
9
+ from agent_wire.engine.http_pool import Fetched
10
+ from agent_wire.engine.waterfall import WireFetchError
11
+ from agent_wire.firewall.outbound_privacy import parse_json
12
+ from agent_wire.media.subtitle_cleaner import to_paragraphs
13
+ from agent_wire.models import WireComment, WireSearchHit
14
+
15
+ API = "https://api.bilibili.com"
16
+ HEADERS = {"Referer": "https://www.bilibili.com/", "Origin": "https://www.bilibili.com"}
17
+ # Permutation table from Bilibili's web client for deriving the WBI mixin key.
18
+ MIXIN = [46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19,
19
+ 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
20
+ 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52] # fmt: skip
21
+ KEY_TTL_S = 6 * 3600
22
+ _BVID = re.compile(r"(BV[0-9A-Za-z]{10})")
23
+ _TAG = re.compile(r"<[^>]+>")
24
+
25
+
26
+ def mixin_key(img_key: str, sub_key: str) -> str:
27
+ raw = img_key + sub_key
28
+ return "".join(raw[i] for i in MIXIN)[:32]
29
+
30
+
31
+ def wbi_sign(params: dict, key: str, now: int | None = None) -> dict:
32
+ signed = {**params, "wts": now if now is not None else int(time.time())}
33
+ clean = {k: re.sub(r"[!'()*]", "", str(v)) for k, v in sorted(signed.items())}
34
+ query = urlencode(clean)
35
+ return {**clean, "w_rid": hashlib.md5((query + key).encode()).hexdigest()}
36
+
37
+
38
+ def parse_replies(replies: list[dict], depth: int = 0) -> list[WireComment]:
39
+ return [
40
+ WireComment(id=str(r.get("rpid")), body=(r.get("content") or {}).get("message", ""),
41
+ author_handle=(r.get("member") or {}).get("uname"), score=r.get("like") or 0,
42
+ timestamp=ts(r.get("ctime")), depth=depth,
43
+ replies=parse_replies(r.get("replies") or [], depth + 1))
44
+ for r in replies or []
45
+ ] # fmt: skip
46
+
47
+
48
+ class BilibiliChannel(Channel):
49
+ name = "bilibili"
50
+
51
+ def __init__(self, ctx):
52
+ super().__init__(ctx)
53
+ self._key: str | None = None
54
+ self._key_at = 0.0
55
+ self._primed = False
56
+
57
+ async def _get(self, url: str) -> Fetched:
58
+ # One shared anonymous chrome session; the API answers 412 until the homepage has set the
59
+ # buvid3/b_nut visitor cookies, so visit it once first.
60
+ if not self._primed:
61
+ await self.ctx.pool.get(
62
+ "https://www.bilibili.com/", impersonate="chrome", headers=HEADERS
63
+ )
64
+ self._primed = True
65
+ f = await self.ctx.pool.get(url, impersonate="chrome", headers=HEADERS)
66
+ if not f.ok:
67
+ raise WireFetchError(f"{url}: HTTP {f.status}")
68
+ return f
69
+
70
+ async def _api(self, path: str, params: dict, sign: bool = False) -> tuple[dict, Fetched]:
71
+ if sign:
72
+ params = wbi_sign(params, await self._wbi_key())
73
+ f = await self._get(f"{API}{path}?{urlencode(params)}")
74
+ data = parse_json(f.content)
75
+ if data.get("code") not in (0, None):
76
+ raise WireUnsupported(f"bilibili {path}: {data.get('message') or data.get('code')}")
77
+ return data.get("data") or {}, f
78
+
79
+ async def _wbi_key(self) -> str:
80
+ if not self._key or time.time() - self._key_at > KEY_TTL_S:
81
+ f = await self._get(f"{API}/x/web-interface/nav")
82
+ img = parse_json(f.content)["data"]["wbi_img"] # present even when logged out
83
+ stem = lambda u: u.rsplit("/", 1)[-1].split(".")[0]
84
+ self._key, self._key_at = (
85
+ mixin_key(stem(img["img_url"]), stem(img["sub_url"])),
86
+ time.time(),
87
+ )
88
+ return self._key
89
+
90
+ async def read(self, url, *, query=None, section=None, include_comments=True):
91
+ if (urlsplit(url).hostname or "").endswith("b23.tv"):
92
+ url = (await self._get(url)).url # short link -> video page
93
+ m = _BVID.search(url)
94
+ if not m:
95
+ raise WireUnsupported("bilibili: expected a /video/BV... URL")
96
+ view, f = await self._api("/x/web-interface/wbi/view", {"bvid": m[1]}, sign=True)
97
+ transcript = await self._subtitles(m[1], view.get("cid"))
98
+ comments = []
99
+ if include_comments and view.get("aid"):
100
+ try:
101
+ rep, _ = await self._api(
102
+ "/x/v2/reply/wbi/main",
103
+ {"oid": view["aid"], "type": 1, "mode": 3, "ps": 20},
104
+ sign=True,
105
+ )
106
+ comments = parse_replies(rep.get("replies") or [])
107
+ except (WireFetchError, WireUnsupported, ValueError, KeyError):
108
+ comments = []
109
+ stat = view.get("stat", {})
110
+ parts = [
111
+ view.get("desc") or "",
112
+ f"## 字幕 Transcript\n{transcript}"
113
+ if transcript
114
+ else "_No CC subtitles (AI subtitles need a login)._",
115
+ ]
116
+ return self.doc(
117
+ f, f"https://www.bilibili.com/video/{m[1]}", title=view.get("title"),
118
+ author_handle=(view.get("owner") or {}).get("name"), timestamp=ts(view.get("pubdate")),
119
+ score=stat.get("like"), body="\n\n".join(p for p in parts if p), comments=comments,
120
+ metadata={"views": stat.get("view"), "likes": stat.get("like"), "coins": stat.get("coin"),
121
+ "favorites": stat.get("favorite"), "danmaku": stat.get("danmaku"),
122
+ "length": f"{(view.get('duration') or 0) // 60}m"},
123
+ ) # fmt: skip
124
+
125
+ async def _subtitles(self, bvid: str, cid) -> str:
126
+ if not cid:
127
+ return ""
128
+ try:
129
+ player, _ = await self._api("/x/player/wbi/v2", {"bvid": bvid, "cid": cid}, sign=True)
130
+ subs = (player.get("subtitle") or {}).get("subtitles") or []
131
+ if not subs:
132
+ return ""
133
+ sub = next((s for s in subs if s.get("lan", "").startswith("zh")), subs[0])
134
+ url = sub["subtitle_url"]
135
+ f = await self._get(url if url.startswith("http") else f"https:{url}")
136
+ lines = parse_json(f.content).get("body", [])
137
+ return to_paragraphs([(float(x.get("from", 0)), x.get("content", "")) for x in lines])
138
+ except (WireFetchError, WireUnsupported, ValueError, KeyError):
139
+ return ""
140
+
141
+ async def search(self, query, *, limit=5, sort="relevance"):
142
+ await self._get("https://www.bilibili.com/") # sets buvid3, required by search
143
+ order = {"new": "pubdate", "top": "click"}.get(sort, "totalrank")
144
+ data, _ = await self._api("/x/web-interface/wbi/search/type",
145
+ {"search_type": "video", "keyword": query, "order": order}, sign=True) # fmt: skip
146
+ return [WireSearchHit(url=f"https://www.bilibili.com/video/{v['bvid']}", title=_TAG.sub("", v.get("title", "")),
147
+ snippet=f"{v.get('author')} · {v.get('play')} plays · {v.get('description', '')[:160]}",
148
+ source="bilibili", rank_score=float(v.get("play") or 0))
149
+ for v in (data.get("result") or [])[:limit]] # fmt: skip
150
+
151
+ async def feed(self, target, *, sort="hot", limit=10):
152
+ t = target.strip().lower()
153
+ if t in ("popular", "hot", "trending", ""):
154
+ data, f = await self._api("/x/web-interface/popular", {"ps": limit, "pn": 1})
155
+ videos = data.get("list", [])
156
+ elif t.isdigit():
157
+ data, f = await self._api(
158
+ "/x/space/wbi/arc/search", {"mid": t, "ps": limit, "pn": 1}, sign=True
159
+ )
160
+ videos = (data.get("list") or {}).get("vlist", [])
161
+ else:
162
+ raise WireUnsupported("bilibili feeds: 'popular' or an uploader's numeric mid")
163
+ items = [{"title": v.get("title"), "url": f"https://www.bilibili.com/video/{v.get('bvid')}",
164
+ "meta": [(v.get("owner") or {}).get("name") or v.get("author"),
165
+ f"{(v.get('stat') or {}).get('view', v.get('play'))} plays"],
166
+ "snippet": v.get("desc") or v.get("description") or ""} for v in videos[:limit]] # fmt: skip
167
+ return self.doc(f, "https://www.bilibili.com/v/popular/all", title=f"Bilibili · {t or 'popular'}",
168
+ body=listing(items)) # fmt: skip
169
+
170
+
171
+ CHANNEL = BilibiliChannel
@@ -0,0 +1,35 @@
1
+ """Boss Zhipin job descriptions (heavy anti-bot: local session or the stage-4 browser usually needed)."""
2
+
3
+ from selectolax.lexbor import LexborHTMLParser
4
+
5
+ from agent_wire.channels.base import Channel, WireUnsupported, html_to_markdown
6
+
7
+
8
+ def parse_job(html: str) -> dict:
9
+ tree = LexborHTMLParser(html)
10
+ desc = tree.css_first(".job-sec-text, .job-detail-section .text")
11
+ if not desc:
12
+ raise WireUnsupported(
13
+ "zhipin served a verification page; run `wire auth login zhipin.com` or install the stealth extra"
14
+ )
15
+
16
+ def text(sel):
17
+ n = tree.css_first(sel)
18
+ return n.text(strip=True) if n else None
19
+
20
+ tags = [t.text(strip=True) for t in tree.css(".job-tags span, .job-keyword-list li")]
21
+ return {"title": text(".name h1, .job-name"), "author_handle": text(".company-info a[ka], .company-name"),
22
+ "body": html_to_markdown(desc.html),
23
+ "metadata": {"salary": text(".salary, .job-salary"), "city": text(".text-city, .job-area"),
24
+ "tags": ", ".join(tags) or None}} # fmt: skip
25
+
26
+
27
+ class BossZhipinChannel(Channel):
28
+ name = "boss_zhipin"
29
+
30
+ async def read(self, url, *, query=None, section=None, include_comments=True):
31
+ f = await self.fetch(url, archive=False)
32
+ return self.doc(f, url, **parse_job(f.text))
33
+
34
+
35
+ CHANNEL = BossZhipinChannel
@@ -0,0 +1,85 @@
1
+ """TikTok (rehydration JSON + WebVTT captions, oEmbed fallback) and Douyin (iesdouyin share page)."""
2
+
3
+ import re
4
+ from urllib.parse import quote, urlsplit
5
+
6
+ from agent_wire.channels.base import Channel, WireUnsupported, assigned_json, script_json, ts
7
+ from agent_wire.engine.waterfall import WireFetchError
8
+ from agent_wire.media.subtitle_cleaner import parse_captions, to_paragraphs
9
+
10
+ MOBILE_UA = ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 "
11
+ "(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1") # fmt: skip
12
+
13
+
14
+ def parse_tiktok(data: dict) -> dict:
15
+ scope = data.get("__DEFAULT_SCOPE__") or {}
16
+ item = ((scope.get("webapp.video-detail") or {}).get("itemInfo") or {}).get("itemStruct") or {}
17
+ if not item:
18
+ raise WireUnsupported("tiktok video data missing (removed, private or region-blocked)")
19
+ stats = item.get("stats") or {}
20
+ subs = (item.get("video") or {}).get("subtitleInfos") or []
21
+ return {"title": item.get("desc", "")[:80], "author_handle": (item.get("author") or {}).get("uniqueId"),
22
+ "timestamp": ts(item.get("createTime")), "score": stats.get("diggCount"), "body": item.get("desc", ""),
23
+ "metadata": {"plays": stats.get("playCount"), "likes": stats.get("diggCount"),
24
+ "comments": stats.get("commentCount"), "shares": stats.get("shareCount")},
25
+ "_subs": subs} # fmt: skip
26
+
27
+
28
+ def parse_douyin(data: dict) -> dict:
29
+ loader = (data or {}).get("loaderData") or {}
30
+ page = next((v for k, v in loader.items() if "video" in k and isinstance(v, dict)), {})
31
+ items = ((page.get("videoInfoRes") or {}).get("item_list")) or []
32
+ if not items:
33
+ raise WireUnsupported("douyin video data missing from the share page")
34
+ it = items[0]
35
+ st = it.get("statistics") or {}
36
+ return {"title": it.get("desc", "")[:80], "author_handle": (it.get("author") or {}).get("nickname"),
37
+ "timestamp": ts(it.get("create_time")), "score": st.get("digg_count"), "body": it.get("desc", ""),
38
+ "metadata": {"likes": st.get("digg_count"), "comments": st.get("comment_count"),
39
+ "shares": st.get("share_count"), "collects": st.get("collect_count")}} # fmt: skip
40
+
41
+
42
+ class DouyinTikTokChannel(Channel):
43
+ name = "douyin_tiktok"
44
+
45
+ async def read(self, url, *, query=None, section=None, include_comments=True):
46
+ host = urlsplit(url).hostname or ""
47
+ if "tiktok.com" in host:
48
+ return await self._tiktok(url)
49
+ if host.startswith("v.douyin.com"):
50
+ url = (await self.fetch(url, archive=False, browser=False)).url
51
+ m = re.search(r"/(?:video|note)/(\d+)", urlsplit(url).path)
52
+ if not m:
53
+ raise WireUnsupported("douyin: expected a /video/<id> URL")
54
+ f = await self.fetch(f"https://www.iesdouyin.com/share/video/{m[1]}/",
55
+ headers={"User-Agent": MOBILE_UA}, archive=False) # fmt: skip
56
+ data = assigned_json(f.text, "window._ROUTER_DATA", js_undefined=True)
57
+ return self.doc(f, url, **parse_douyin(data))
58
+
59
+ async def _tiktok(self, url):
60
+ try:
61
+ f = await self.fetch(url, archive=False)
62
+ info = parse_tiktok(
63
+ script_json(f.text, "script#__UNIVERSAL_DATA_FOR_REHYDRATION__") or {}
64
+ )
65
+ except (WireFetchError, WireUnsupported):
66
+ data, f = await self.fetch_json(
67
+ f"https://www.tiktok.com/oembed?url={quote(url, safe='')}"
68
+ )
69
+ return self.doc(f, url, title=data.get("title"), author_handle=data.get("author_unique_id"),
70
+ body=data.get("title", ""), metadata={"via": "oembed"}) # fmt: skip
71
+ subs = info.pop("_subs")
72
+ sub = next(
73
+ (s for s in subs if str(s.get("LanguageCodeName", "")).startswith("en")),
74
+ subs[0] if subs else None,
75
+ )
76
+ if sub and sub.get("Url"):
77
+ try:
78
+ vtt = await self.fetch(sub["Url"], archive=False, browser=False)
79
+ info["body"] += "\n\n## Captions\n" + to_paragraphs(parse_captions(vtt.content))
80
+ except (WireFetchError, ValueError):
81
+ pass
82
+ return self.doc(f, url, **info)
83
+
84
+
85
+ CHANNEL = DouyinTikTokChannel
@@ -0,0 +1,70 @@
1
+ """V2EX: native public API for topics, replies and hot/latest/node feeds; SoV2EX for search."""
2
+
3
+ import re
4
+ from urllib.parse import quote, urlsplit
5
+
6
+ from agent_wire.channels.base import Channel, WireUnsupported, listing, ts
7
+ from agent_wire.models import WireComment, WireSearchHit
8
+
9
+ API = "https://www.v2ex.com/api"
10
+
11
+
12
+ def parse_replies(replies: list[dict], op: str | None) -> list[WireComment]:
13
+ return [WireComment(id=str(r.get("id")), body=r.get("content") or "",
14
+ author_handle=(r.get("member") or {}).get("username"),
15
+ timestamp=ts(r.get("created")), is_op=(r.get("member") or {}).get("username") == op)
16
+ for r in replies] # fmt: skip
17
+
18
+
19
+ def topic_items(topics: list[dict]) -> list[dict]:
20
+ return [{"title": t.get("title"), "url": t.get("url"),
21
+ "meta": [(t.get("node") or {}).get("title"), f"{t.get('replies', 0)} replies",
22
+ (t.get("member") or {}).get("username")]} for t in topics] # fmt: skip
23
+
24
+
25
+ class V2exChannel(Channel):
26
+ name = "v2ex"
27
+
28
+ async def read(self, url, *, query=None, section=None, include_comments=True):
29
+ m = re.search(r"/t/(\d+)", urlsplit(url).path)
30
+ if not m:
31
+ node = re.search(r"/go/([\w-]+)", urlsplit(url).path)
32
+ if node:
33
+ return await self.feed(node[1])
34
+ raise WireUnsupported("v2ex: expected /t/<id> or /go/<node>")
35
+ topics, f = await self.fetch_json(f"{API}/topics/show.json?id={m[1]}")
36
+ if not topics:
37
+ raise WireUnsupported(f"v2ex topic {m[1]} not found")
38
+ t = topics[0]
39
+ op = (t.get("member") or {}).get("username")
40
+ comments = []
41
+ if include_comments and t.get("replies"):
42
+ replies, _ = await self.fetch_json(f"{API}/replies/show.json?topic_id={m[1]}")
43
+ comments = parse_replies(replies, op)
44
+ return self.doc(f, url, title=t.get("title"), author_handle=op, timestamp=ts(t.get("created")),
45
+ body=t.get("content") or "", comments=comments,
46
+ metadata={"node": (t.get("node") or {}).get("title"), "replies": t.get("replies")}) # fmt: skip
47
+
48
+ async def feed(self, target, *, sort="hot", limit=10):
49
+ t = target.strip().lower() or "hot"
50
+ path = {"hot": "topics/hot.json", "latest": "topics/latest.json"}.get(
51
+ t, f"topics/show.json?node_name={t}"
52
+ )
53
+ topics, f = await self.fetch_json(f"{API}/{path}")
54
+ return self.doc(
55
+ f,
56
+ f"https://www.v2ex.com/?tab={t}",
57
+ title=f"V2EX · {t}",
58
+ body=listing(topic_items(topics[:limit])),
59
+ )
60
+
61
+ async def search(self, query, *, limit=5, sort="relevance"):
62
+ data, _ = await self.fetch_json(
63
+ f"https://www.sov2ex.com/api/search?q={quote(query)}&size={limit}"
64
+ )
65
+ return [WireSearchHit(url=f"https://www.v2ex.com/t/{h['_source']['id']}", title=h["_source"].get("title", ""),
66
+ snippet=(h["_source"].get("content") or "")[:200], source="v2ex")
67
+ for h in data.get("hits", [])] # fmt: skip
68
+
69
+
70
+ CHANNEL = V2exChannel
@@ -0,0 +1,67 @@
1
+ """WeChat official-account articles (mp.weixin.qq.com) and Sogou WeChat search."""
2
+
3
+ import re
4
+ from urllib.parse import quote
5
+
6
+ from selectolax.lexbor import LexborHTMLParser
7
+
8
+ from agent_wire.channels.base import Channel, WireUnsupported, html_to_markdown, ts
9
+ from agent_wire.models import WireSearchHit
10
+
11
+ WECHAT_UA = ("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) "
12
+ "Chrome/124.0 Mobile Safari/537.36 MicroMessenger/8.0.49") # fmt: skip
13
+
14
+
15
+ def parse_article(html: str) -> dict:
16
+ tree = LexborHTMLParser(html)
17
+ content = tree.css_first("#js_content")
18
+ if not content:
19
+ raise WireUnsupported("wechat article body missing (deleted, or a verification page)")
20
+ # WeChat ships the body as visibility:hidden and reveals it with JS; the shield would drop it,
21
+ # so convert the children, not the hidden wrapper.
22
+ inner = "".join(child.html or "" for child in content.iter(include_text=True))
23
+ title = tree.css_first("#activity-name, h1.rich_media_title")
24
+ author = tree.css_first("#js_name")
25
+ ct = re.search(r'\bvar ct\s*=\s*"(\d+)"', html) or re.search(
26
+ r"create_time\s*[:=]\s*['\"]?(\d{9,})", html
27
+ )
28
+ return {"title": title.text(strip=True) if title else None,
29
+ "author_handle": author.text(strip=True) if author else None,
30
+ "timestamp": ts(ct[1]) if ct else None, "body": html_to_markdown(inner, links=False)} # fmt: skip
31
+
32
+
33
+ def parse_sogou(html: str) -> list[WireSearchHit]:
34
+ hits = []
35
+ for li in LexborHTMLParser(html).css("ul.news-list li"):
36
+ a = li.css_first("h3 a")
37
+ if not a:
38
+ continue
39
+ href = a.attributes.get("href") or ""
40
+ snippet = li.css_first("p.txt-info")
41
+ account = li.css_first(".s-p .all-time-y2, .s-p a.account")
42
+ hits.append(WireSearchHit(
43
+ url=href if href.startswith("http") else f"https://weixin.sogou.com{href}", title=a.text(strip=True),
44
+ snippet=" · ".join(x for x in (account.text(strip=True) if account else "",
45
+ snippet.text(strip=True) if snippet else "") if x),
46
+ source="wechat")) # fmt: skip
47
+ return hits
48
+
49
+
50
+ class WeChatChannel(Channel):
51
+ name = "wechat"
52
+
53
+ async def read(self, url, *, query=None, section=None, include_comments=True):
54
+ f = await self.fetch(url, headers={"User-Agent": WECHAT_UA}, archive=False)
55
+ return self.doc(f, url, **parse_article(f.text))
56
+
57
+ async def search(self, query, *, limit=5, sort="relevance"):
58
+ f = await self.fetch(
59
+ f"https://weixin.sogou.com/weixin?type=2&query={quote(query)}", archive=False
60
+ )
61
+ hits = parse_sogou(f.text)
62
+ if not hits and "antispider" in f.url:
63
+ raise WireUnsupported("Sogou WeChat search showed a CAPTCHA; try again later")
64
+ return hits[:limit]
65
+
66
+
67
+ CHANNEL = WeChatChannel
@@ -0,0 +1,58 @@
1
+ """Xiaohongshu (RED): note body, stats, tags and any server-rendered comments from __INITIAL_STATE__."""
2
+
3
+ import re
4
+ from urllib.parse import urlsplit
5
+
6
+ from agent_wire.channels.base import Channel, WireUnsupported, assigned_json, compact_count, ts
7
+ from agent_wire.models import WireComment
8
+
9
+ _NOTE = re.compile(r"/(?:explore|discovery/item|search_result)/([0-9a-f]{24})")
10
+
11
+
12
+ def parse_note(state: dict, note_id: str) -> dict:
13
+ detail = ((state.get("note") or {}).get("noteDetailMap") or {}).get(note_id) or {}
14
+ note = detail.get("note") or {}
15
+ if not note:
16
+ raise WireUnsupported("xiaohongshu note not in page state (needs xsec_token or a login)")
17
+ inter = note.get("interactInfo") or {}
18
+ comments = [
19
+ WireComment(id=str(c.get("id")), body=c.get("content", ""),
20
+ author_handle=(c.get("userInfo") or {}).get("nickname"),
21
+ score=compact_count(c.get("likeCount")), timestamp=ts(c.get("createTime")))
22
+ for c in ((detail.get("comments") or {}).get("list") or [])
23
+ ] # fmt: skip
24
+ tags = " ".join(f"#{t.get('name')}" for t in note.get("tagList") or [])
25
+ images = len(note.get("imageList") or [])
26
+ body = (note.get("desc") or "") + (f"\n\n{tags}" if tags else "")
27
+ return {
28
+ "title": note.get("title") or (note.get("desc") or "")[:40],
29
+ "author_handle": (note.get("user") or {}).get("nickname"),
30
+ "timestamp": ts(note.get("time")), "score": compact_count(inter.get("likedCount")),
31
+ "body": body, "comments": comments,
32
+ "metadata": {"likes": compact_count(inter.get("likedCount")),
33
+ "collects": compact_count(inter.get("collectedCount")),
34
+ "comments": compact_count(inter.get("commentCount")),
35
+ "type": note.get("type"), "images": images or None},
36
+ } # fmt: skip
37
+
38
+
39
+ class XiaohongshuChannel(Channel):
40
+ name = "xiaohongshu"
41
+
42
+ async def read(self, url, *, query=None, section=None, include_comments=True):
43
+ f = await self.fetch(
44
+ url, archive=False
45
+ ) # login-only channel: local session attached if present
46
+ m = _NOTE.search(urlsplit(f.url).path) or _NOTE.search(urlsplit(url).path)
47
+ if not m:
48
+ raise WireUnsupported("xiaohongshu: expected an /explore/<note id> URL")
49
+ state = assigned_json(f.text, "window.__INITIAL_STATE__", js_undefined=True) or {}
50
+ return self.doc(f, url, **parse_note(state, m[1]))
51
+
52
+ async def search(self, query, *, limit=5, sort="relevance"):
53
+ raise WireUnsupported(
54
+ "xiaohongshu search needs its signed web API; share a note URL instead"
55
+ )
56
+
57
+
58
+ CHANNEL = XiaohongshuChannel