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,227 @@
|
|
|
1
|
+
"""Render a ResearchResult as a cited Markdown brief, JSON, or a standalone HTML page."""
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
from agent_wire.compression.dam_formatter import relative_age
|
|
9
|
+
from agent_wire.research.engine import ResearchResult
|
|
10
|
+
|
|
11
|
+
MAX_CLUSTERS = {"quick": 8, "default": 12, "deep": 20}
|
|
12
|
+
_ENG_LABEL = (("score", "▲"), ("likes", "♥"), ("reposts", "↻"), ("comments", "💬"), ("stars", "★"),
|
|
13
|
+
("views", "views "), ("volume24h", "$24h ")) # fmt: skip
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _num(n: int) -> str:
|
|
17
|
+
for size, suffix in ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "k")):
|
|
18
|
+
if n >= size:
|
|
19
|
+
return f"{n / size:.1f}".rstrip("0").rstrip(".") + suffix
|
|
20
|
+
return str(n)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def engagement_line(eng: dict[str, int]) -> str:
|
|
24
|
+
return " ".join(f"{label}{_num(eng[k])}" for k, label in _ENG_LABEL if eng.get(k))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def title_of(r: ResearchResult) -> str:
|
|
28
|
+
p = r.plan
|
|
29
|
+
if p.mode == "discover":
|
|
30
|
+
return f"What's trending · last {p.days} days"
|
|
31
|
+
if p.mode == "person":
|
|
32
|
+
return f"{p.github_user} on GitHub · last {p.days} days"
|
|
33
|
+
return f"{p.topic} · last {p.days} days"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def headline(r: ResearchResult) -> str:
|
|
37
|
+
if r.topics:
|
|
38
|
+
return ", ".join(t["topic"] for t in r.topics[:3])
|
|
39
|
+
return r.clusters[0].title if r.clusters else "no stories"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _take_line(t) -> str:
|
|
43
|
+
who = f"@{t.comment.author_handle}" if t.comment.author_handle else "someone"
|
|
44
|
+
unit = "replies" if t.comment.score_kind == "replies" else "pts"
|
|
45
|
+
score = f" ({t.comment.score} {unit})" if t.comment.score else ""
|
|
46
|
+
return f" > {who}{score}: {' '.join(t.comment.body.split())[:240]}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def to_markdown(r: ResearchResult, max_clusters: int | None = None) -> str:
|
|
50
|
+
p = r.plan
|
|
51
|
+
now = p.until
|
|
52
|
+
n = max_clusters or MAX_CLUSTERS[p.depth]
|
|
53
|
+
out = [f"# {title_of(r)}", ""]
|
|
54
|
+
srcs = ", ".join(f"{s} {c}" for s, c in sorted(r.counts.items(), key=lambda kv: -kv[1]))
|
|
55
|
+
if p.mode == "person":
|
|
56
|
+
out.append(f"Window {p.since.date()} → {p.until.date()}")
|
|
57
|
+
else:
|
|
58
|
+
out.append(f"Window {p.since.date()} → {p.until.date()} · {len(r.items)} items in "
|
|
59
|
+
f"{len(r.clusters)} stories · {srcs or 'no sources answered'}") # fmt: skip
|
|
60
|
+
|
|
61
|
+
if r.topics:
|
|
62
|
+
out += ["", "## Trending topics"]
|
|
63
|
+
for i, t in enumerate(r.topics, 1):
|
|
64
|
+
ex = " · ".join(f"[{e['title'][:70]}]({e['url']})" for e in t["examples"])
|
|
65
|
+
out.append(f"{i}. **{t['topic']}** · {t['momentum']} · {t['mentions']} stories on "
|
|
66
|
+
f"{', '.join(t['sources'])}\n {ex}") # fmt: skip
|
|
67
|
+
|
|
68
|
+
if r.subjects:
|
|
69
|
+
out += ["", "## Head to head", "", "| | items | heat | sources | top story |",
|
|
70
|
+
"|---|---|---|---|---|"] # fmt: skip
|
|
71
|
+
for s in r.subjects:
|
|
72
|
+
top = f"[{s['top']['title'][:60]}]({s['top']['url']})" if s["top"] else "-"
|
|
73
|
+
out.append(f"| **{s['subject']}** | {s['items']} | {_num(s['heat'])} | "
|
|
74
|
+
f"{', '.join(s['sources']) or '-'} | {top} |") # fmt: skip
|
|
75
|
+
if both := max((s["head_to_head"] for s in r.subjects), default=0):
|
|
76
|
+
out.append(f"\n{both} items discuss them side by side.")
|
|
77
|
+
|
|
78
|
+
if r.person:
|
|
79
|
+
out += ["", "## GitHub activity", r.person]
|
|
80
|
+
|
|
81
|
+
takes: dict[int, list] = {}
|
|
82
|
+
for t in r.takes:
|
|
83
|
+
takes.setdefault(t.cluster, []).append(t)
|
|
84
|
+
if r.clusters and p.mode != "person":
|
|
85
|
+
out += ["", "## Biggest stories" if p.mode == "discover" else "## Top stories"]
|
|
86
|
+
for idx, c in enumerate(r.clusters[:n]):
|
|
87
|
+
when = relative_age(c.first_seen, now) if c.first_seen else "undated"
|
|
88
|
+
bits = (", ".join(c.sources), engagement_line(c.engagement), when, c.momentum(now))
|
|
89
|
+
out.append(
|
|
90
|
+
f"{idx + 1}. **[{c.title}]({c.lead.hit.url})** · "
|
|
91
|
+
+ " · ".join(b for b in bits if b)
|
|
92
|
+
)
|
|
93
|
+
seen = {c.lead.hit.url}
|
|
94
|
+
cites = []
|
|
95
|
+
for i in sorted(c.items, key=lambda i: -i.score):
|
|
96
|
+
if i.hit.url not in seen and len(cites) < 4:
|
|
97
|
+
seen.add(i.hit.url)
|
|
98
|
+
cites.append(f"[{i.source}]({i.hit.url})")
|
|
99
|
+
if c.link and c.link not in seen:
|
|
100
|
+
cites.append(f"[article]({c.link})")
|
|
101
|
+
if cites:
|
|
102
|
+
out.append(" also: " + " · ".join(cites))
|
|
103
|
+
# the platform's own blurb beats the start of a page (a repo's file list, a nav bar)
|
|
104
|
+
if snippet := (c.lead.hit.snippet or r.excerpts.get(idx) or "").strip():
|
|
105
|
+
out.append(" " + " ".join(snippet.split())[:280])
|
|
106
|
+
out += [_take_line(t) for t in takes.get(idx, [])]
|
|
107
|
+
|
|
108
|
+
markets = r.markets
|
|
109
|
+
if markets:
|
|
110
|
+
out += ["", "## Where the money is" if p.mode == "discover" else "## What the markets say"]
|
|
111
|
+
out += [f"- [{i.hit.title}]({i.hit.url}) · {i.hit.snippet} · {engagement_line(i.hit.engagement)}"
|
|
112
|
+
for i in markets[:4]] # fmt: skip
|
|
113
|
+
|
|
114
|
+
if r.library:
|
|
115
|
+
out += ["", "## From your library"]
|
|
116
|
+
out += [f"- {x['created_at'][:10]} · run #{x['id']} · {x['topic']}: {x['headline']}"
|
|
117
|
+
for x in r.library] # fmt: skip
|
|
118
|
+
|
|
119
|
+
if r.gaps or p.notes:
|
|
120
|
+
out += ["", "## Not covered"]
|
|
121
|
+
out += [f"- {s}: {why}" for s, why in sorted(r.gaps.items())]
|
|
122
|
+
out += [f"- {note}" for note in p.notes]
|
|
123
|
+
return "\n".join(out).strip()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def to_dict(r: ResearchResult) -> dict:
|
|
127
|
+
p = r.plan
|
|
128
|
+
|
|
129
|
+
def item(i):
|
|
130
|
+
h = i.hit
|
|
131
|
+
return {"url": h.url, "title": h.title, "source": h.source, "author": h.author, "link": h.link,
|
|
132
|
+
"timestamp": h.timestamp.isoformat() if h.timestamp else None,
|
|
133
|
+
"engagement": h.engagement, "score": i.score, "relevance": round(i.relevance, 3),
|
|
134
|
+
"freshness": round(i.freshness, 3), "snippet": h.snippet[:300]} # fmt: skip
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
"topic": p.topic, "mode": p.mode, "depth": p.depth, "days": p.days,
|
|
138
|
+
"window": {"since": p.since.isoformat(), "until": p.until.isoformat()},
|
|
139
|
+
"created_at": r.created_at.isoformat(), "elapsed_ms": r.elapsed_ms, "run_id": r.run_id,
|
|
140
|
+
"counts": r.counts, "gaps": r.gaps, "subjects": r.subjects, "topics": r.topics,
|
|
141
|
+
"person": r.person, "library": r.library, "headline": headline(r),
|
|
142
|
+
"markets": [item(i) for i in r.markets],
|
|
143
|
+
"clusters": [{"title": c.title, "score": c.score, "momentum": c.momentum(p.until),
|
|
144
|
+
"sources": c.sources, "engagement": c.engagement, "link": c.link,
|
|
145
|
+
"excerpt": r.excerpts.get(idx),
|
|
146
|
+
"takes": [{"author": t.comment.author_handle, "score": t.comment.score,
|
|
147
|
+
"body": t.comment.body[:600], "url": t.url}
|
|
148
|
+
for t in r.takes if t.cluster == idx],
|
|
149
|
+
"items": [item(i) for i in c.items]}
|
|
150
|
+
for idx, c in enumerate(r.clusters)],
|
|
151
|
+
} # fmt: skip
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def to_json(r: ResearchResult) -> str:
|
|
155
|
+
return json.dumps(to_dict(r), ensure_ascii=False, indent=2)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
PAGE_CSS = """
|
|
159
|
+
:root{--bg:#fbfaf7;--fg:#1c1b19;--muted:#6b6862;--line:#e4e1da;--accent:#0b6e4f}
|
|
160
|
+
@media (prefers-color-scheme:dark){:root{--bg:#141413;--fg:#ecebe6;--muted:#9c998f;--line:#2c2b28;--accent:#4fc39a}}
|
|
161
|
+
body{background:var(--bg);color:var(--fg);font:16px/1.55 system-ui,sans-serif;margin:0 auto;max-width:860px;padding:24px 16px}
|
|
162
|
+
a{color:var(--accent)}h1{font-size:1.6rem;margin:.2em 0}h2{font-size:1.15rem;margin-top:2em;border-bottom:1px solid var(--line)}
|
|
163
|
+
blockquote{margin:.3em 0 .3em 1em;padding-left:.8em;border-left:3px solid var(--line);color:var(--muted)}
|
|
164
|
+
.meta{color:var(--muted);font-size:.9rem}table{border-collapse:collapse;width:100%;display:block;overflow-x:auto}
|
|
165
|
+
td,th{border-bottom:1px solid var(--line);padding:4px 6px;text-align:left}
|
|
166
|
+
"""
|
|
167
|
+
_BOLD = re.compile(r"\*\*(.+?)\*\*")
|
|
168
|
+
_LINK = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _inline(text: str) -> str:
|
|
172
|
+
text = html.escape(text, quote=False)
|
|
173
|
+
text = _BOLD.sub(r"<strong>\1</strong>", text)
|
|
174
|
+
return _LINK.sub(
|
|
175
|
+
lambda m: f'<a href="{html.escape(m[2])}" rel="noopener nofollow">{m[1]}</a>', text
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def md_to_html(md: str) -> str:
|
|
180
|
+
"""Renderer for the brief's own Markdown subset (headings, quotes, links, bold, tables)."""
|
|
181
|
+
out, in_table = [], False
|
|
182
|
+
for line in md.splitlines():
|
|
183
|
+
s = line.strip()
|
|
184
|
+
if s.startswith("|"):
|
|
185
|
+
if set(s) <= set("|-: "):
|
|
186
|
+
continue
|
|
187
|
+
if not in_table:
|
|
188
|
+
out.append("<table>")
|
|
189
|
+
in_table = True
|
|
190
|
+
out.append(
|
|
191
|
+
"<tr>"
|
|
192
|
+
+ "".join(f"<td>{_inline(c.strip())}</td>" for c in s.strip("|").split("|"))
|
|
193
|
+
+ "</tr>"
|
|
194
|
+
)
|
|
195
|
+
continue
|
|
196
|
+
if in_table:
|
|
197
|
+
out.append("</table>")
|
|
198
|
+
in_table = False
|
|
199
|
+
if s.startswith("## "):
|
|
200
|
+
out.append(f"<h2>{_inline(s[3:])}</h2>")
|
|
201
|
+
elif s.startswith("# "):
|
|
202
|
+
out.append(f"<h1>{_inline(s[2:])}</h1>")
|
|
203
|
+
elif s.startswith("> "):
|
|
204
|
+
out.append(f"<blockquote>{_inline(s[2:])}</blockquote>")
|
|
205
|
+
elif s:
|
|
206
|
+
out.append(f"<p>{_inline(s)}</p>")
|
|
207
|
+
if in_table:
|
|
208
|
+
out.append("</table>")
|
|
209
|
+
return "\n".join(out)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def page(markdown: str, title: str) -> str:
|
|
213
|
+
stamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
|
|
214
|
+
return (f"<!doctype html><html lang=en><head><meta charset=utf-8>"
|
|
215
|
+
f"<meta name=viewport content='width=device-width,initial-scale=1'>"
|
|
216
|
+
f"<title>{html.escape(title)}</title><style>{PAGE_CSS}</style></head><body>"
|
|
217
|
+
f"{md_to_html(markdown)}<p class=meta>Generated by agent-wire · {stamp}</p></body></html>") # fmt: skip
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def render(r: ResearchResult, fmt: str = "md") -> str:
|
|
221
|
+
if fmt == "json":
|
|
222
|
+
return to_json(r)
|
|
223
|
+
if fmt == "html":
|
|
224
|
+
return page(to_markdown(r), title_of(r))
|
|
225
|
+
if fmt != "md":
|
|
226
|
+
raise ValueError("emit must be md, json or html")
|
|
227
|
+
return to_markdown(r)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Daily / weekly digest across every watched topic, plus what is trending overall."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
|
|
5
|
+
from agent_wire.research.brief import engagement_line
|
|
6
|
+
|
|
7
|
+
PERIODS = {"daily": 1, "weekly": 7}
|
|
8
|
+
TOP_PER_TOPIC = 5
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def digest(wire, period: str = "daily", *, trending: bool = True, save: bool = True) -> str:
|
|
12
|
+
if period not in PERIODS:
|
|
13
|
+
raise ValueError("period must be daily or weekly")
|
|
14
|
+
days = PERIODS[period]
|
|
15
|
+
stamp = datetime.now(UTC)
|
|
16
|
+
out = [f"# {period.title()} briefing · {stamp.date()}"]
|
|
17
|
+
watches = wire.store.watches()
|
|
18
|
+
if not watches and not trending:
|
|
19
|
+
return "no watched topics (wire watch add <topic>)"
|
|
20
|
+
for w in watches:
|
|
21
|
+
r, _, _ = await wire.research_result(w["topic"], days=days, platforms=w.get("sources"),
|
|
22
|
+
depth="quick", store=True) # fmt: skip
|
|
23
|
+
out += ["", f"## {w['topic']}"]
|
|
24
|
+
if not r.clusters:
|
|
25
|
+
out.append("- nothing in this window")
|
|
26
|
+
for c in r.clusters[:TOP_PER_TOPIC]:
|
|
27
|
+
out.append(f"- [{c.title}]({c.lead.hit.url}) · {', '.join(c.sources)} · "
|
|
28
|
+
f"{engagement_line(c.engagement)} · {c.momentum(r.plan.until)}") # fmt: skip
|
|
29
|
+
if trending:
|
|
30
|
+
r, _, _ = await wire.research_result("", days=days, depth="quick", store=False)
|
|
31
|
+
out += ["", "## Trending everywhere"]
|
|
32
|
+
out += [f"- **{t['topic']}** · {t['momentum']} · {', '.join(t['sources'])}"
|
|
33
|
+
for t in r.topics[:8]] # fmt: skip
|
|
34
|
+
text = "\n".join(out)
|
|
35
|
+
if save:
|
|
36
|
+
folder = wire.library.dir / "briefings"
|
|
37
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
path = folder / f"{stamp.date()}-{period}.md"
|
|
39
|
+
path.write_text(text, encoding="utf-8")
|
|
40
|
+
text += f"\n\n_saved to {path}_"
|
|
41
|
+
return text
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Cross-source dedup: one story told on HN, Reddit and Techmeme becomes one cluster."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from agent_wire.channels.tier1_global.search import url_key
|
|
8
|
+
from agent_wire.research.scoring import Item, raw_engagement
|
|
9
|
+
|
|
10
|
+
TITLE_JACCARD = 0.6
|
|
11
|
+
_WORD = re.compile(r"[a-z0-9]+")
|
|
12
|
+
_NOISE = frozenset(
|
|
13
|
+
[
|
|
14
|
+
"show",
|
|
15
|
+
"ask",
|
|
16
|
+
"hn",
|
|
17
|
+
"tell",
|
|
18
|
+
"the",
|
|
19
|
+
"a",
|
|
20
|
+
"an",
|
|
21
|
+
"of",
|
|
22
|
+
"to",
|
|
23
|
+
"in",
|
|
24
|
+
"on",
|
|
25
|
+
"for",
|
|
26
|
+
"and",
|
|
27
|
+
"is",
|
|
28
|
+
"are",
|
|
29
|
+
"with",
|
|
30
|
+
"by",
|
|
31
|
+
"at",
|
|
32
|
+
"from",
|
|
33
|
+
]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Cluster:
|
|
39
|
+
items: list[Item] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def lead(self) -> Item:
|
|
43
|
+
return max(self.items, key=lambda i: i.score)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def title(self) -> str:
|
|
47
|
+
return self.lead.hit.title
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def sources(self) -> list[str]:
|
|
51
|
+
return sorted({i.source for i in self.items})
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def score(self) -> float:
|
|
55
|
+
# agreement across platforms is the strongest signal there is
|
|
56
|
+
return round(self.lead.score * (1 + 0.25 * (len(self.sources) - 1)), 4)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def engagement(self) -> dict[str, int]:
|
|
60
|
+
total: dict[str, int] = {}
|
|
61
|
+
for i in self.items:
|
|
62
|
+
for k, v in i.hit.engagement.items():
|
|
63
|
+
total[k] = total.get(k, 0) + v
|
|
64
|
+
return total
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def first_seen(self) -> datetime | None:
|
|
68
|
+
dates = [i.hit.timestamp for i in self.items if i.hit.timestamp]
|
|
69
|
+
return min(dates) if dates else None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def link(self) -> str | None:
|
|
73
|
+
return next((i.hit.link for i in self.items if i.hit.link), None)
|
|
74
|
+
|
|
75
|
+
def momentum(self, now: datetime | None = None) -> str:
|
|
76
|
+
now = now or datetime.now(UTC)
|
|
77
|
+
first = self.first_seen
|
|
78
|
+
heat = sum(raw_engagement(i.hit) for i in self.items)
|
|
79
|
+
if first and now - first < timedelta(days=3):
|
|
80
|
+
return "breaking" if heat > 200 or len(self.sources) > 1 else "new"
|
|
81
|
+
if len(self.sources) >= 3 or heat > 2000:
|
|
82
|
+
return "hot"
|
|
83
|
+
return "steady"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _title_words(title: str) -> frozenset[str]:
|
|
87
|
+
return frozenset(w for w in _WORD.findall(title.lower()) if w not in _NOISE)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _jaccard(a: frozenset, b: frozenset) -> float:
|
|
91
|
+
return len(a & b) / len(a | b) if a and b else 0.0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cluster(items: list[Item]) -> list[Cluster]:
|
|
95
|
+
"""Union-find over shared URL, shared outbound link, or near-identical titles."""
|
|
96
|
+
parent = list(range(len(items)))
|
|
97
|
+
|
|
98
|
+
def find(i):
|
|
99
|
+
while parent[i] != i:
|
|
100
|
+
parent[i] = parent[parent[i]]
|
|
101
|
+
i = parent[i]
|
|
102
|
+
return i
|
|
103
|
+
|
|
104
|
+
def union(a, b):
|
|
105
|
+
parent[find(a)] = find(b)
|
|
106
|
+
|
|
107
|
+
by_key: dict[str, int] = {}
|
|
108
|
+
for idx, it in enumerate(items):
|
|
109
|
+
for u in (it.hit.url, it.hit.link):
|
|
110
|
+
if not u:
|
|
111
|
+
continue
|
|
112
|
+
key = url_key(u)
|
|
113
|
+
if key in by_key:
|
|
114
|
+
union(idx, by_key[key])
|
|
115
|
+
else:
|
|
116
|
+
by_key[key] = idx
|
|
117
|
+
words = [_title_words(i.hit.title) for i in items]
|
|
118
|
+
for a in range(len(items)):
|
|
119
|
+
if len(words[a]) < 4:
|
|
120
|
+
continue # short titles ("@user", "ZED and python") collide by accident
|
|
121
|
+
for b in range(a):
|
|
122
|
+
if len(words[b]) >= 4 and _jaccard(words[a], words[b]) >= TITLE_JACCARD:
|
|
123
|
+
union(a, b)
|
|
124
|
+
|
|
125
|
+
groups: dict[int, Cluster] = {}
|
|
126
|
+
for idx, it in enumerate(items):
|
|
127
|
+
groups.setdefault(find(idx), Cluster()).items.append(it)
|
|
128
|
+
return sorted(groups.values(), key=lambda c: -c.score)
|