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,632 @@
|
|
|
1
|
+
"""wire_research: fan out across key-free sources inside a time window, score, cluster, brief."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from itertools import pairwise
|
|
11
|
+
|
|
12
|
+
from curl_cffi.requests.exceptions import RequestException
|
|
13
|
+
|
|
14
|
+
from agent_wire.channels.base import WireUnsupported, relative_ts, ts
|
|
15
|
+
from agent_wire.compression.bm25_slicer import rank_comments
|
|
16
|
+
from agent_wire.engine.waterfall import WireFetchError
|
|
17
|
+
from agent_wire.firewall.inbound_shield import shield_comments
|
|
18
|
+
from agent_wire.firewall.outbound_privacy import WirePrivacyBlockError, scrub
|
|
19
|
+
from agent_wire.models import WireComment, WireSearchHit
|
|
20
|
+
from agent_wire.research.cluster import Cluster, cluster
|
|
21
|
+
from agent_wire.research.planner import Plan, make_plan, terms
|
|
22
|
+
from agent_wire.research.scoring import RELEVANCE_FLOOR, Item, raw_engagement, score_items
|
|
23
|
+
|
|
24
|
+
SOURCE_CHANNEL = {"reddit": "reddit", "hackernews": "hackernews", "youtube": "youtube",
|
|
25
|
+
"github": "github", "news": "news", "bluesky": "fediverse",
|
|
26
|
+
"mastodon": "fediverse", "polymarket": "polymarket", "web": "search",
|
|
27
|
+
"devto": "devto", "stackoverflow": "stackoverflow", "arxiv": "arxiv",
|
|
28
|
+
"stocktwits": "stocktwits", "x": "x", "lobsters": "hackernews"} # fmt: skip
|
|
29
|
+
SOURCE_TIMEOUT_S = {"reddit": 60, "web": 30} # Arctic Shift and engine fan-out are slower
|
|
30
|
+
DEFAULT_TIMEOUT_S = 25
|
|
31
|
+
EXPECTED = (WireFetchError, WireUnsupported, WirePrivacyBlockError, RequestException, ValueError,
|
|
32
|
+
KeyError, TimeoutError, TypeError) # fmt: skip
|
|
33
|
+
READABLE = frozenset({"hackernews", "reddit", "youtube", "github", "devto", "bluesky", "mastodon",
|
|
34
|
+
"stackoverflow", "lobsters", "polymarket"}) # fmt: skip
|
|
35
|
+
ALGOLIA = "https://hn.algolia.com/api/v1"
|
|
36
|
+
COMPARE_FLOOR = 0.2 # "A vs B" items only need to be on-topic for one side
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Take:
|
|
41
|
+
cluster: int
|
|
42
|
+
url: str
|
|
43
|
+
comment: WireComment
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ResearchResult:
|
|
48
|
+
plan: Plan
|
|
49
|
+
clusters: list[Cluster] = field(default_factory=list)
|
|
50
|
+
counts: dict[str, int] = field(default_factory=dict) # source -> items kept
|
|
51
|
+
gaps: dict[str, str] = field(default_factory=dict) # source -> why it returned nothing
|
|
52
|
+
takes: list[Take] = field(default_factory=list)
|
|
53
|
+
excerpts: dict[int, str] = field(default_factory=dict) # cluster -> deep-read excerpt
|
|
54
|
+
subjects: list[dict] = field(default_factory=list) # compare mode
|
|
55
|
+
topics: list[dict] = field(default_factory=list) # discover mode
|
|
56
|
+
person: str | None = None
|
|
57
|
+
library: list[dict] = field(default_factory=list)
|
|
58
|
+
markets: list[Item] = field(default_factory=list) # Polymarket odds, shown on their own
|
|
59
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
60
|
+
elapsed_ms: int = 0
|
|
61
|
+
run_id: int | None = None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def items(self) -> list[Item]:
|
|
65
|
+
return [i for c in self.clusters for i in c.items]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _aware(hit: WireSearchHit) -> WireSearchHit:
|
|
69
|
+
if hit.timestamp and hit.timestamp.tzinfo is None:
|
|
70
|
+
return hit.model_copy(update={"timestamp": hit.timestamp.replace(tzinfo=UTC)})
|
|
71
|
+
return hit
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_MONTHS = {m: i for i, m in enumerate(
|
|
75
|
+
("jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"), 1)} # fmt: skip
|
|
76
|
+
_DMY = re.compile(r"^\s*(\d{1,2}) ([A-Za-z]{3})[a-z]* (\d{4})")
|
|
77
|
+
_MDY = re.compile(r"^\s*([A-Za-z]{3})[a-z]* (\d{1,2}), (\d{4})")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def snippet_date(snippet: str) -> datetime | None:
|
|
81
|
+
"""Search engines prefix snippets with '2 days ago -' or '16 April 2026 -' / 'Apr 16, 2026 -'."""
|
|
82
|
+
head = snippet[:40]
|
|
83
|
+
if " ago" in head:
|
|
84
|
+
return relative_ts(head)
|
|
85
|
+
for rx, order in ((_DMY, (0, 1, 2)), (_MDY, (1, 0, 2))):
|
|
86
|
+
if m := rx.match(head):
|
|
87
|
+
day, mon, year = (m.groups()[i] for i in order)
|
|
88
|
+
if month := _MONTHS.get(mon.lower()):
|
|
89
|
+
try:
|
|
90
|
+
return datetime(int(year), month, int(day), tzinfo=UTC)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return None
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _accepts_since(fn) -> bool:
|
|
97
|
+
try:
|
|
98
|
+
return "since" in inspect.signature(fn).parameters
|
|
99
|
+
except (TypeError, ValueError):
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def mentions(item: Item, subjects: list[str]) -> list[str]:
|
|
104
|
+
text = f"{item.hit.title} {item.hit.snippet}".lower()
|
|
105
|
+
return [s for s in subjects if all(t in text for t in terms(s))]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Researcher:
|
|
109
|
+
def __init__(self, wire):
|
|
110
|
+
self.wire = wire
|
|
111
|
+
self.ctx = wire.ctx
|
|
112
|
+
|
|
113
|
+
# ---- collection --------------------------------------------------------------------------
|
|
114
|
+
async def _search(self, source: str, query: str, plan: Plan, limit: int) -> list[WireSearchHit]:
|
|
115
|
+
ch = self.ctx.channel(SOURCE_CHANNEL[source])
|
|
116
|
+
if source == "mastodon":
|
|
117
|
+
words = terms(query)
|
|
118
|
+
return await ch.mastodon_tag(max(words, key=len), limit=limit) if words else []
|
|
119
|
+
if source == "lobsters":
|
|
120
|
+
return await self._lobsters(query, limit)
|
|
121
|
+
kw = {"limit": limit}
|
|
122
|
+
if _accepts_since(ch.search):
|
|
123
|
+
kw["since"] = plan.since
|
|
124
|
+
return await ch.search(query, **kw)
|
|
125
|
+
|
|
126
|
+
async def _lobsters(self, query: str, limit: int) -> list[WireSearchHit]:
|
|
127
|
+
"""Lobsters search is bot-walled; its hottest JSON is open, so filter that."""
|
|
128
|
+
data, _ = await self.ctx.channel("hackernews").fetch_json("https://lobste.rs/hottest.json")
|
|
129
|
+
want = set(terms(query))
|
|
130
|
+
hits = []
|
|
131
|
+
for s in data:
|
|
132
|
+
text = f"{s.get('title', '')} {' '.join(s.get('tags') or [])}".lower()
|
|
133
|
+
if want and not any(w in text for w in want):
|
|
134
|
+
continue
|
|
135
|
+
hits.append(WireSearchHit(
|
|
136
|
+
url=s.get("short_id_url") or s.get("comments_url") or "", title=s.get("title") or "",
|
|
137
|
+
snippet=", ".join(s.get("tags") or []), source="lobsters", link=s.get("url") or None,
|
|
138
|
+
timestamp=ts(s.get("created_at")), rank_score=float(s.get("score") or 0),
|
|
139
|
+
engagement={"score": s.get("score") or 0, "comments": s.get("comment_count") or 0},
|
|
140
|
+
)) # fmt: skip
|
|
141
|
+
return hits[:limit]
|
|
142
|
+
|
|
143
|
+
async def _collect(self, plan: Plan, limit: int, result: ResearchResult) -> list[Item]:
|
|
144
|
+
async def one(source: str, q: str):
|
|
145
|
+
timeout = SOURCE_TIMEOUT_S.get(source, DEFAULT_TIMEOUT_S)
|
|
146
|
+
try:
|
|
147
|
+
hits = await asyncio.wait_for(self._search(source, q, plan, limit), timeout)
|
|
148
|
+
return source, q, hits, None
|
|
149
|
+
except EXPECTED as e:
|
|
150
|
+
return source, q, [], f"{type(e).__name__}: {scrub(str(e))[:160]}"
|
|
151
|
+
|
|
152
|
+
jobs = [one(s, q) for s in plan.sources for q in plan.queries.get(s) or []]
|
|
153
|
+
items, ok = [], set()
|
|
154
|
+
for source, q, hits, err in await asyncio.gather(*jobs):
|
|
155
|
+
if err:
|
|
156
|
+
result.gaps.setdefault(source, err)
|
|
157
|
+
continue
|
|
158
|
+
ok.add(source)
|
|
159
|
+
for h in hits:
|
|
160
|
+
h = _aware(h)
|
|
161
|
+
if h.timestamp is None and source == "web":
|
|
162
|
+
h = h.model_copy(update={"timestamp": snippet_date(h.snippet)})
|
|
163
|
+
if h.source != source and source not in ("news", "arxiv"):
|
|
164
|
+
h = h.model_copy(update={"source": source}) # web engines, bsky -> source name
|
|
165
|
+
items.append(Item(hit=h, query=q, origin=source))
|
|
166
|
+
for s in ok:
|
|
167
|
+
result.gaps.pop(s, None)
|
|
168
|
+
return items
|
|
169
|
+
|
|
170
|
+
# ---- modes -------------------------------------------------------------------------------
|
|
171
|
+
async def run(self, topic: str = "", *, days: int = 30, sources=None, mode: str = "auto",
|
|
172
|
+
depth: str = "default", as_of=None, github_user: str | None = None,
|
|
173
|
+
library=None) -> ResearchResult: # fmt: skip
|
|
174
|
+
t0 = time.perf_counter()
|
|
175
|
+
has_x = bool(self.ctx.vault and self.ctx.vault.get("x.com"))
|
|
176
|
+
plan = make_plan(topic, days=days, sources=sources, mode=mode, depth=depth, as_of=as_of,
|
|
177
|
+
github_user=github_user, has_x_session=has_x) # fmt: skip
|
|
178
|
+
result = ResearchResult(plan=plan)
|
|
179
|
+
if plan.mode == "discover":
|
|
180
|
+
await self._discover(plan, result)
|
|
181
|
+
elif plan.mode == "person":
|
|
182
|
+
await self._person(plan, result)
|
|
183
|
+
else:
|
|
184
|
+
await self._topic(plan, result)
|
|
185
|
+
if plan.github_user:
|
|
186
|
+
await self._person(plan, result)
|
|
187
|
+
if library is not None and plan.topic:
|
|
188
|
+
result.library = library.related(plan.topic, limit=3)
|
|
189
|
+
result.elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
async def _topic(self, plan: Plan, result: ResearchResult) -> None:
|
|
193
|
+
items = await self._collect(plan, plan.per_source, result)
|
|
194
|
+
compare = plan.mode == "compare"
|
|
195
|
+
if compare:
|
|
196
|
+
result.subjects = self._compare(plan, items)
|
|
197
|
+
floor = COMPARE_FLOOR if compare else None
|
|
198
|
+
scored = score_items(items, plan.topic, plan.since, plan.until, plan.days, floor=floor)
|
|
199
|
+
if compare: # keep items that are clearly about at least one side
|
|
200
|
+
scored = [
|
|
201
|
+
i for i in scored if i.relevance >= RELEVANCE_FLOOR or mentions(i, plan.subjects)
|
|
202
|
+
]
|
|
203
|
+
result.counts = dict(Counter(i.source for i in scored))
|
|
204
|
+
answered = {i.origin for i in scored}
|
|
205
|
+
for s in plan.sources:
|
|
206
|
+
if s not in answered and s not in result.gaps:
|
|
207
|
+
result.gaps[s] = "answered, but nothing on-topic inside the window"
|
|
208
|
+
result.markets = [i for i in scored if i.source == "polymarket"]
|
|
209
|
+
result.clusters = cluster([i for i in scored if i.source != "polymarket"])
|
|
210
|
+
await self._deep_read(plan, result)
|
|
211
|
+
|
|
212
|
+
def _compare(self, plan: Plan, items: list[Item]) -> list[dict]:
|
|
213
|
+
out = []
|
|
214
|
+
for subj in plan.subjects:
|
|
215
|
+
pool = [Item(hit=i.hit, query=subj) for i in items
|
|
216
|
+
if i.query == subj or mentions(i, [subj])] # fmt: skip
|
|
217
|
+
mine = score_items(pool, subj, plan.since, plan.until, plan.days)
|
|
218
|
+
eng: Counter = Counter()
|
|
219
|
+
for i in mine:
|
|
220
|
+
eng.update(i.hit.engagement)
|
|
221
|
+
top = mine[0].hit if mine else None
|
|
222
|
+
out.append({
|
|
223
|
+
"subject": subj, "items": len(mine), "sources": sorted({i.source for i in mine}),
|
|
224
|
+
"engagement": dict(eng), "heat": round(sum(raw_engagement(i.hit) for i in mine)),
|
|
225
|
+
"top": {"title": top.title, "url": top.url, "source": top.source} if top else None,
|
|
226
|
+
"head_to_head": sum(1 for i in mine if len(mentions(i, plan.subjects)) > 1),
|
|
227
|
+
}) # fmt: skip
|
|
228
|
+
return out
|
|
229
|
+
|
|
230
|
+
async def _deep_read(self, plan: Plan, result: ResearchResult) -> None:
|
|
231
|
+
"""'Best takes': read the top clusters and keep their strongest on-topic comments."""
|
|
232
|
+
picks = []
|
|
233
|
+
for idx, c in enumerate(result.clusters):
|
|
234
|
+
if len(picks) >= plan.deep_reads:
|
|
235
|
+
break
|
|
236
|
+
ranked = sorted(c.items, key=lambda i: -i.score)
|
|
237
|
+
lead = next((i for i in ranked if i.source in READABLE), None)
|
|
238
|
+
if lead:
|
|
239
|
+
picks.append((idx, lead.hit.url))
|
|
240
|
+
if not picks:
|
|
241
|
+
return
|
|
242
|
+
reads = await asyncio.gather(
|
|
243
|
+
*(
|
|
244
|
+
asyncio.wait_for(self.wire._read_one(u, plan.topic, None, True), 40)
|
|
245
|
+
for _, u in picks
|
|
246
|
+
),
|
|
247
|
+
return_exceptions=True,
|
|
248
|
+
)
|
|
249
|
+
for (idx, url), r in zip(picks, reads, strict=True):
|
|
250
|
+
if isinstance(r, BaseException):
|
|
251
|
+
if not isinstance(r, EXPECTED):
|
|
252
|
+
raise r
|
|
253
|
+
continue
|
|
254
|
+
doc, _ = r
|
|
255
|
+
if body := " ".join(scrub(doc.body).split()):
|
|
256
|
+
result.excerpts[idx] = body[:400]
|
|
257
|
+
best = rank_comments(shield_comments(doc.comments), plan.topic, 400)
|
|
258
|
+
for c in sorted(best, key=lambda c: -c.score)[:2]:
|
|
259
|
+
flat = c.model_copy(update={"replies": [], "body": scrub(c.body)})
|
|
260
|
+
result.takes.append(Take(idx, url, flat))
|
|
261
|
+
|
|
262
|
+
async def _person(self, plan: Plan, result: ResearchResult) -> None:
|
|
263
|
+
try:
|
|
264
|
+
doc = await self.ctx.channel("github").person(plan.github_user, since=plan.since)
|
|
265
|
+
result.person = scrub(doc.body)
|
|
266
|
+
except EXPECTED as e:
|
|
267
|
+
result.gaps["github-person"] = f"{type(e).__name__}: {scrub(str(e))[:160]}"
|
|
268
|
+
|
|
269
|
+
async def _discover(self, plan: Plan, result: ResearchResult) -> None:
|
|
270
|
+
"""Topic-less: what is rising across sources in the window, with momentum labels."""
|
|
271
|
+
hn, gh, news = (self.ctx.channel(n) for n in ("hackernews", "github", "news"))
|
|
272
|
+
day, since_s = plan.since.date().isoformat(), int(plan.since.timestamp())
|
|
273
|
+
|
|
274
|
+
async def hn_top():
|
|
275
|
+
data, _ = await hn.fetch_json(f"{ALGOLIA}/search?tags=story&hitsPerPage=40"
|
|
276
|
+
f"&numericFilters=created_at_i>{since_s},points>50") # fmt: skip
|
|
277
|
+
return [WireSearchHit(url=f"https://news.ycombinator.com/item?id={h['objectID']}",
|
|
278
|
+
title=h.get("title") or "", source="hackernews", link=h.get("url"),
|
|
279
|
+
timestamp=ts(h.get("created_at_i")),
|
|
280
|
+
engagement={"score": h.get("points") or 0,
|
|
281
|
+
"comments": h.get("num_comments") or 0})
|
|
282
|
+
for h in data.get("hits", [])] # fmt: skip
|
|
283
|
+
|
|
284
|
+
async def mastodon_trends():
|
|
285
|
+
data, _ = await hn.fetch_json("https://mastodon.social/api/v1/trends/tags?limit=10")
|
|
286
|
+
return [WireSearchHit(url=t.get("url") or "", title=f"#{t.get('name')}", source="mastodon",
|
|
287
|
+
engagement={"comments": sum(int(d.get("uses", 0))
|
|
288
|
+
for d in t.get("history") or [])})
|
|
289
|
+
for t in data] # fmt: skip
|
|
290
|
+
|
|
291
|
+
makers = {
|
|
292
|
+
"hackernews": hn_top,
|
|
293
|
+
"github": lambda: gh.search(f"created:>{day} stars:>20", limit=15, sort="stars"),
|
|
294
|
+
"news": lambda: news.techmeme(since=plan.since),
|
|
295
|
+
"polymarket": lambda: self.ctx.channel("polymarket").trending_hits(10),
|
|
296
|
+
"mastodon": mastodon_trends,
|
|
297
|
+
"lobsters": lambda: self._lobsters("", 25),
|
|
298
|
+
}
|
|
299
|
+
for s in plan.sources:
|
|
300
|
+
if s not in makers:
|
|
301
|
+
result.gaps[s] = "not used in discover mode"
|
|
302
|
+
names = [s for s in plan.sources if s in makers]
|
|
303
|
+
done = await asyncio.gather(*(asyncio.wait_for(makers[s](), DEFAULT_TIMEOUT_S) for s in names),
|
|
304
|
+
return_exceptions=True) # fmt: skip
|
|
305
|
+
items = []
|
|
306
|
+
for source, r in zip(names, done, strict=True):
|
|
307
|
+
if isinstance(r, BaseException):
|
|
308
|
+
if not isinstance(r, EXPECTED):
|
|
309
|
+
raise r
|
|
310
|
+
result.gaps[source] = f"{type(r).__name__}: {scrub(str(r))[:160]}"
|
|
311
|
+
continue
|
|
312
|
+
items += [Item(hit=_aware(h).model_copy(update={"source": source})) for h in r]
|
|
313
|
+
scored = score_items(items, "", plan.since, plan.until, plan.days, floor=0.0)
|
|
314
|
+
result.counts = dict(Counter(i.source for i in scored))
|
|
315
|
+
result.markets = [i for i in scored if i.source == "polymarket"]
|
|
316
|
+
result.clusters = cluster([i for i in scored if i.source != "polymarket"])
|
|
317
|
+
result.topics = rising_topics(result.clusters, plan.until)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
_TOPIC_STOP = frozenset(
|
|
321
|
+
["the", "a", "an", "and", "or", "of", "for", "to", "in", "on", "with", "by", "at", "from", "is", "are", "was", "be", "it", "its", "this", "that", "how", "why", "what", "show", "ask", "hn", "new", "you", "your", "we", "our", "my", "i", "can", "will", "not", "now", "just", "over", "after", "into", "about", "more", "than", "first", "one", "two", "us", "vs", "via", "using", "use", "get", "has", "have", "make", "made", "says", "said", "year", "years", "day", "days", "week", "launches", "launched", "introducing", "announces", "released", "release", "open", "source"]
|
|
322
|
+
) # fmt: skip
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
_COMMON = frozenset(
|
|
326
|
+
[
|
|
327
|
+
"about",
|
|
328
|
+
"above",
|
|
329
|
+
"across",
|
|
330
|
+
"after",
|
|
331
|
+
"again",
|
|
332
|
+
"against",
|
|
333
|
+
"all",
|
|
334
|
+
"almost",
|
|
335
|
+
"also",
|
|
336
|
+
"always",
|
|
337
|
+
"among",
|
|
338
|
+
"another",
|
|
339
|
+
"any",
|
|
340
|
+
"anyone",
|
|
341
|
+
"anything",
|
|
342
|
+
"around",
|
|
343
|
+
"away",
|
|
344
|
+
"back",
|
|
345
|
+
"bad",
|
|
346
|
+
"because",
|
|
347
|
+
"been",
|
|
348
|
+
"before",
|
|
349
|
+
"behind",
|
|
350
|
+
"being",
|
|
351
|
+
"best",
|
|
352
|
+
"better",
|
|
353
|
+
"between",
|
|
354
|
+
"big",
|
|
355
|
+
"both",
|
|
356
|
+
"but",
|
|
357
|
+
"came",
|
|
358
|
+
"cannot",
|
|
359
|
+
"case",
|
|
360
|
+
"change",
|
|
361
|
+
"come",
|
|
362
|
+
"could",
|
|
363
|
+
"did",
|
|
364
|
+
"does",
|
|
365
|
+
"doing",
|
|
366
|
+
"done",
|
|
367
|
+
"down",
|
|
368
|
+
"during",
|
|
369
|
+
"each",
|
|
370
|
+
"early",
|
|
371
|
+
"even",
|
|
372
|
+
"ever",
|
|
373
|
+
"every",
|
|
374
|
+
"few",
|
|
375
|
+
"find",
|
|
376
|
+
"for",
|
|
377
|
+
"found",
|
|
378
|
+
"free",
|
|
379
|
+
"from",
|
|
380
|
+
"full",
|
|
381
|
+
"gets",
|
|
382
|
+
"give",
|
|
383
|
+
"given",
|
|
384
|
+
"going",
|
|
385
|
+
"gone",
|
|
386
|
+
"good",
|
|
387
|
+
"great",
|
|
388
|
+
"had",
|
|
389
|
+
"half",
|
|
390
|
+
"hand",
|
|
391
|
+
"hard",
|
|
392
|
+
"has",
|
|
393
|
+
"have",
|
|
394
|
+
"help",
|
|
395
|
+
"her",
|
|
396
|
+
"here",
|
|
397
|
+
"high",
|
|
398
|
+
"him",
|
|
399
|
+
"his",
|
|
400
|
+
"home",
|
|
401
|
+
"how",
|
|
402
|
+
"however",
|
|
403
|
+
"into",
|
|
404
|
+
"its",
|
|
405
|
+
"itself",
|
|
406
|
+
"just",
|
|
407
|
+
"keep",
|
|
408
|
+
"kind",
|
|
409
|
+
"know",
|
|
410
|
+
"large",
|
|
411
|
+
"last",
|
|
412
|
+
"later",
|
|
413
|
+
"least",
|
|
414
|
+
"less",
|
|
415
|
+
"let",
|
|
416
|
+
"life",
|
|
417
|
+
"like",
|
|
418
|
+
"little",
|
|
419
|
+
"long",
|
|
420
|
+
"look",
|
|
421
|
+
"lost",
|
|
422
|
+
"made",
|
|
423
|
+
"make",
|
|
424
|
+
"making",
|
|
425
|
+
"many",
|
|
426
|
+
"may",
|
|
427
|
+
"maybe",
|
|
428
|
+
"might",
|
|
429
|
+
"more",
|
|
430
|
+
"most",
|
|
431
|
+
"much",
|
|
432
|
+
"must",
|
|
433
|
+
"need",
|
|
434
|
+
"never",
|
|
435
|
+
"next",
|
|
436
|
+
"nice",
|
|
437
|
+
"nothing",
|
|
438
|
+
"now",
|
|
439
|
+
"off",
|
|
440
|
+
"often",
|
|
441
|
+
"old",
|
|
442
|
+
"once",
|
|
443
|
+
"only",
|
|
444
|
+
"other",
|
|
445
|
+
"our",
|
|
446
|
+
"out",
|
|
447
|
+
"over",
|
|
448
|
+
"own",
|
|
449
|
+
"part",
|
|
450
|
+
"people",
|
|
451
|
+
"place",
|
|
452
|
+
"point",
|
|
453
|
+
"put",
|
|
454
|
+
"real",
|
|
455
|
+
"really",
|
|
456
|
+
"right",
|
|
457
|
+
"run",
|
|
458
|
+
"said",
|
|
459
|
+
"same",
|
|
460
|
+
"say",
|
|
461
|
+
"see",
|
|
462
|
+
"seems",
|
|
463
|
+
"should",
|
|
464
|
+
"show",
|
|
465
|
+
"side",
|
|
466
|
+
"since",
|
|
467
|
+
"small",
|
|
468
|
+
"some",
|
|
469
|
+
"something",
|
|
470
|
+
"still",
|
|
471
|
+
"such",
|
|
472
|
+
"sure",
|
|
473
|
+
"take",
|
|
474
|
+
"than",
|
|
475
|
+
"that",
|
|
476
|
+
"the",
|
|
477
|
+
"their",
|
|
478
|
+
"them",
|
|
479
|
+
"then",
|
|
480
|
+
"there",
|
|
481
|
+
"these",
|
|
482
|
+
"they",
|
|
483
|
+
"thing",
|
|
484
|
+
"things",
|
|
485
|
+
"think",
|
|
486
|
+
"this",
|
|
487
|
+
"those",
|
|
488
|
+
"though",
|
|
489
|
+
"through",
|
|
490
|
+
"time",
|
|
491
|
+
"today",
|
|
492
|
+
"together",
|
|
493
|
+
"too",
|
|
494
|
+
"took",
|
|
495
|
+
"top",
|
|
496
|
+
"toward",
|
|
497
|
+
"try",
|
|
498
|
+
"two",
|
|
499
|
+
"under",
|
|
500
|
+
"until",
|
|
501
|
+
"upon",
|
|
502
|
+
"use",
|
|
503
|
+
"used",
|
|
504
|
+
"very",
|
|
505
|
+
"want",
|
|
506
|
+
"was",
|
|
507
|
+
"way",
|
|
508
|
+
"ways",
|
|
509
|
+
"well",
|
|
510
|
+
"went",
|
|
511
|
+
"were",
|
|
512
|
+
"what",
|
|
513
|
+
"when",
|
|
514
|
+
"where",
|
|
515
|
+
"whether",
|
|
516
|
+
"which",
|
|
517
|
+
"while",
|
|
518
|
+
"who",
|
|
519
|
+
"whole",
|
|
520
|
+
"why",
|
|
521
|
+
"will",
|
|
522
|
+
"with",
|
|
523
|
+
"within",
|
|
524
|
+
"without",
|
|
525
|
+
"work",
|
|
526
|
+
"works",
|
|
527
|
+
"world",
|
|
528
|
+
"would",
|
|
529
|
+
"wrong",
|
|
530
|
+
"year",
|
|
531
|
+
"years",
|
|
532
|
+
"yet",
|
|
533
|
+
"you",
|
|
534
|
+
"your",
|
|
535
|
+
"why",
|
|
536
|
+
"what's",
|
|
537
|
+
"it's",
|
|
538
|
+
"i'm",
|
|
539
|
+
"don't",
|
|
540
|
+
"doesn't",
|
|
541
|
+
"can't",
|
|
542
|
+
"won't",
|
|
543
|
+
"isn't",
|
|
544
|
+
"vs",
|
|
545
|
+
"via",
|
|
546
|
+
"new",
|
|
547
|
+
"now",
|
|
548
|
+
"how",
|
|
549
|
+
"ask",
|
|
550
|
+
"show",
|
|
551
|
+
"tell",
|
|
552
|
+
"hn",
|
|
553
|
+
"launch",
|
|
554
|
+
"launches",
|
|
555
|
+
"launched",
|
|
556
|
+
"introducing",
|
|
557
|
+
"announces",
|
|
558
|
+
"released",
|
|
559
|
+
"release",
|
|
560
|
+
"open",
|
|
561
|
+
"source",
|
|
562
|
+
"state",
|
|
563
|
+
"data",
|
|
564
|
+
"says",
|
|
565
|
+
"first",
|
|
566
|
+
"one",
|
|
567
|
+
"report",
|
|
568
|
+
"reports",
|
|
569
|
+
"amid",
|
|
570
|
+
"series",
|
|
571
|
+
]
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def entities(title: str) -> set[str]:
|
|
576
|
+
"""Named things in a headline: Capitalised / MixedCase / ACRONYM / v2-style tokens and pairs."""
|
|
577
|
+
tokens = re.findall(r"[A-Za-z0-9][A-Za-z0-9+#.'\-]*[A-Za-z0-9+#]|[A-Za-z0-9]", title)
|
|
578
|
+
named = []
|
|
579
|
+
for i, tok in enumerate(tokens):
|
|
580
|
+
low = tok.lower()
|
|
581
|
+
if low in _COMMON or low in _TOPIC_STOP or len(tok) < 3 or not any(map(str.isalpha, tok)):
|
|
582
|
+
named.append(None)
|
|
583
|
+
continue
|
|
584
|
+
is_named = tok[0].isupper() and (i > 0 or not tok[1:].islower() or len(tokens) > 1)
|
|
585
|
+
is_named = is_named or any(ch.isdigit() for ch in tok) or tok.isupper()
|
|
586
|
+
named.append(low if is_named else None)
|
|
587
|
+
out = {t for t in named if t}
|
|
588
|
+
out |= {f"{a} {b}" for a, b in pairwise(named) if a and b}
|
|
589
|
+
return out
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def rising_topics(clusters: list[Cluster], now: datetime, n: int = 10) -> list[dict]:
|
|
593
|
+
"""Rank recurring title words/phrases by cross-source spread and engagement."""
|
|
594
|
+
weight: Counter = Counter()
|
|
595
|
+
where: dict[str, set] = {}
|
|
596
|
+
examples: dict[str, list[Cluster]] = {}
|
|
597
|
+
for c in clusters:
|
|
598
|
+
if c.sources == ["polymarket"]:
|
|
599
|
+
continue # trending markets are mostly tonight's games; they get their own list
|
|
600
|
+
for g in entities(c.title):
|
|
601
|
+
weight[g] += c.score
|
|
602
|
+
where.setdefault(g, set()).update(c.sources)
|
|
603
|
+
examples.setdefault(g, []).append(c)
|
|
604
|
+
|
|
605
|
+
def rank(g: str) -> float:
|
|
606
|
+
return weight[g] * (1 + 0.5 * (len(where[g]) - 1)) * (1.2 if " " in g else 1.0)
|
|
607
|
+
|
|
608
|
+
out, used, stories = [], [], []
|
|
609
|
+
for g in sorted(weight, key=lambda g: -rank(g)):
|
|
610
|
+
if len(examples[g]) < 2:
|
|
611
|
+
continue # a phrase from one story is that story (already in "Biggest stories")
|
|
612
|
+
if any(g in u or u in g for u in used):
|
|
613
|
+
continue
|
|
614
|
+
mine = {id(c) for c in examples[g]}
|
|
615
|
+
same = next((i for i, s in enumerate(stories) if len(mine & s) * 3 >= len(mine) * 2), None)
|
|
616
|
+
if same is not None:
|
|
617
|
+
if out[same]["topic"].count(" / ") < 2: # "gemini" + "argon": the same stories
|
|
618
|
+
out[same]["topic"] += f" / {g}"
|
|
619
|
+
used.append(g)
|
|
620
|
+
continue
|
|
621
|
+
used.append(g)
|
|
622
|
+
stories.append(mine)
|
|
623
|
+
ex = sorted(examples[g], key=lambda c: -c.score)[:3]
|
|
624
|
+
fresh = sum(1 for c in examples[g] if c.momentum(now) in ("new", "breaking"))
|
|
625
|
+
label = ("rising" if fresh * 2 >= len(examples[g])
|
|
626
|
+
else "hot" if len(where[g]) >= 3 else "steady") # fmt: skip
|
|
627
|
+
out.append({"topic": g, "mentions": len(examples[g]), "sources": sorted(where[g]),
|
|
628
|
+
"momentum": label,
|
|
629
|
+
"examples": [{"title": c.title, "url": c.lead.hit.url} for c in ex]}) # fmt: skip
|
|
630
|
+
if len(out) >= n:
|
|
631
|
+
break
|
|
632
|
+
return out
|