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