返回 last30days-skill
techmeme.py
根目录 / skills / last30days / scripts / lib / techmeme.py
1 """Techmeme tech-news source for last30days.
2
3 Shells out to ``techmeme-pp-cli`` (no auth). The CLI's ``search`` command hits
4 Techmeme's live archive search endpoint (results back to ~2005) -- it never
5 reads the locally synced headline cache, so the adapter performs no ``sync``.
6
7 Activation gate: only available when ``techmeme-pp-cli`` is on PATH.
8 ``pipeline.available_sources`` checks ``shutil.which`` before including
9 ``techmeme``. The functions below also detect the missing-binary case.
10
11 Surface choice: ``search "<topic>" --json`` (NOT ``--agent``). ``--agent``
12 implies ``--compact``, which on older binaries stripped headline records to
13 ``{}`` (fixed upstream in printing-press-library PR #1383); ``--json`` without
14 ``--compact`` returns the populated record shape on every binary version, so
15 the adapter is robust regardless of the installed build.
16
17 Dates: current binaries emit ``{num, source, headline, link, date}`` where
18 ``date`` is ISO ``YYYY-MM-DD`` (or ``""`` when Techmeme's markup was
19 unparseable). The adapter windows records to the research range
20 (``from_date <= date <= to_date``) so archive hits from years past never
21 masquerade as current news. Records with no usable date -- old binaries emit
22 no ``date`` key at all -- are kept but flow downstream with no date, so
23 ``normalize._normalize_techmeme`` assigns ``date_confidence: low``. Headlines
24 are never stamped with today's date. (This deliberately diverges from
25 ``lib/arxiv.py``, which drops entries with unparseable dates -- arXiv's feed
26 reliably carries dates, so an unparseable one is anomalous; Techmeme's old
27 binaries emit no ``date`` key at all, so dropping would zero out the source
28 for every user on an old binary.) Old binaries also print prose
29 (``No results for "q"``) to stdout on zero hits; that parses as an empty
30 result set, not a decode failure. Publication-name header rows (very short
31 ``headline`` values) are dropped; ranking is topic relevance plus rank decay.
32 """
33
34 from __future__ import annotations
35
36 import json
37 import re
38 import shutil
39 from typing import Any, Dict, List
40
41 from . import log, subproc
42 from .relevance import token_overlap_relevance
43
44
45 CLI_BIN = "techmeme-pp-cli"
46
47 DEPTH_CONFIG = {
48 "quick": 8,
49 "default": 16,
50 "deep": 30,
51 }
52
53 # A real story headline is a sentence; bare publication-name rows ("TechCrunch",
54 # "New York Times") are section headers in the feed, not stories. Require at
55 # least this many words to keep a record.
56 MIN_HEADLINE_WORDS = 4
57
58 SEARCH_TIMEOUT = 30
59
60 # Old binaries print this prose to stdout (exit 0) on zero hits, even in JSON
61 # mode. It is a zero-result response, not malformed output.
62 _NO_RESULTS_PREFIX = "No results"
63
64 _ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
65
66
67 def _log(msg: str) -> None:
68 log.source_log("Techmeme", msg, tty_only=False)
69
70
71 def _is_available() -> bool:
72 """True when the techmeme-pp-cli binary is on PATH."""
73 return shutil.which(CLI_BIN) is not None
74
75
76 def _build_search_args(topic: str) -> List[str]:
77 # --json (not --agent) avoids --compact, which blanks headline records on
78 # pre-PR-1383 binaries. Techmeme's `search` has no result-limit flag, so the
79 # depth cap is applied client-side after windowing.
80 return [CLI_BIN, "search", topic, "--json"]
81
82
83 def _coerce_list(data: Any) -> List[Dict[str, Any]]:
84 """Techmeme search returns a bare JSON array; tolerate a results-wrapped
85 envelope too."""
86 if isinstance(data, list):
87 return [r for r in data if isinstance(r, dict)]
88 if isinstance(data, dict):
89 results = data.get("results")
90 if isinstance(results, list):
91 return [r for r in results if isinstance(r, dict)]
92 return []
93
94
95 def _record_iso_date(rec: Dict[str, Any]) -> str | None:
96 """The record's ``date`` as a valid ISO YYYY-MM-DD string, else None.
97
98 Old binaries emit no ``date`` key; current binaries emit ``""`` when
99 Techmeme's markup was unparseable. Anything that isn't a clean ISO date is
100 treated as absent."""
101 value = rec.get("date")
102 if isinstance(value, str) and _ISO_DATE_RE.match(value.strip()):
103 return value.strip()
104 return None
105
106
107 def _run_cli(cmd: List[str], timeout: int) -> Dict[str, Any]:
108 """Invoke techmeme-pp-cli and return ``{"results": [...records...]}``.
109 Never raises."""
110 if not _is_available():
111 return {"results": [], "error": f"{CLI_BIN} not on PATH"}
112 try:
113 result = subproc.run_with_timeout(cmd, timeout=timeout)
114 except subproc.SubprocTimeout as exc:
115 _log(f"Timeout: {exc}")
116 return {"results": [], "error": str(exc)}
117 except FileNotFoundError as exc:
118 _log(f"Binary missing: {exc}")
119 return {"results": [], "error": str(exc)}
120 except OSError as exc:
121 _log(f"Spawn failed: {exc}")
122 return {"results": [], "error": str(exc)}
123
124 if result.returncode != 0:
125 snippet = (result.stderr or "").strip().splitlines()[:1]
126 first = snippet[0] if snippet else f"exit {result.returncode}"
127 _log(f"CLI exit {result.returncode}: {first}")
128 return {"results": [], "error": first}
129
130 stdout = result.stdout or ""
131 if not stdout.strip():
132 return {"results": []}
133 # Old binaries print `No results for "q"` prose (exit 0) even in JSON
134 # mode: a legitimate zero-hit response, not a decode failure.
135 if stdout.strip().startswith(_NO_RESULTS_PREFIX):
136 return {"results": []}
137 try:
138 data = json.loads(stdout)
139 except json.JSONDecodeError as exc:
140 _log(f"JSON decode failed: {exc}")
141 return {"results": [], "error": f"json decode: {exc}"}
142
143 return {"results": _coerce_list(data)}
144
145
146 def search_techmeme(
147 topic: str,
148 from_date: str,
149 to_date: str,
150 depth: str = "default",
151 ) -> Dict[str, Any]:
152 """Search Techmeme's live archive via techmeme-pp-cli.
153
154 Windows records to ``from_date..to_date`` on each record's own ISO date
155 (lexicographic compare is exact for ISO strings). Records with no usable
156 date are kept -- their recency is resolved downstream as low confidence.
157 There is deliberately no keep-all fallback when nothing is in-window:
158 Techmeme's archive reaches back decades, so zero in-window records means
159 zero results, not "serve stale news". Returns a dict with a ``results``
160 list of raw records; on failure ``results`` is empty.
161 """
162 if not topic or not topic.strip():
163 return {"results": []}
164 if not _is_available():
165 return {"results": [], "error": f"{CLI_BIN} not on PATH"}
166 limit = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
167 cmd = _build_search_args(topic)
168 _log(f"search '{topic}' (cap={limit})")
169 response = _run_cli(cmd, timeout=SEARCH_TIMEOUT)
170 records = response.get("results") or []
171 if isinstance(records, list):
172 # Hard date window: drop records whose date falls outside the research
173 # range; keep undated records (old binaries / unparseable markup).
174 dated_in_window = []
175 undated = []
176 dropped = 0
177 for rec in records:
178 iso = _record_iso_date(rec)
179 if iso is None:
180 undated.append(rec)
181 elif from_date <= iso <= to_date:
182 dated_in_window.append(rec)
183 else:
184 dropped += 1
185 if dropped:
186 _log(f"dropped {dropped} records outside {from_date}..{to_date}")
187 if records and not dated_in_window and not dropped:
188 # Every record lacks a usable date: old techmeme-pp-cli (no date
189 # key) or a Techmeme markup change upstream. Windowing is inactive.
190 _log(
191 "no records carry usable dates; date windowing inactive "
192 "(old techmeme-pp-cli binary or upstream markup change; upgrade "
193 "via `npx -y @mvanhorn/printing-press-library install techmeme "
194 "--cli-only`)"
195 )
196 # Techmeme returns all matches; apply the depth cap after windowing.
197 # Dated in-window records take cap slots first so undated archive
198 # hits can never evict confirmed-fresh stories; undated records fill
199 # whatever slots remain.
200 response["results"] = (dated_in_window + undated)[:limit]
201 _log(f"found {len(response.get('results') or [])} records")
202 return response
203
204
205 def _is_story_headline(headline: str, source: str) -> bool:
206 """Reject bare publication-name header rows; keep sentence-shaped stories."""
207 if not headline:
208 return False
209 if len(headline.split()) < MIN_HEADLINE_WORDS:
210 return False
211 # A row whose headline is just the publication name is a header.
212 if source and headline.strip().lower() == source.strip().lower():
213 return False
214 return True
215
216
217 def parse_techmeme_response(
218 response: Dict[str, Any],
219 query: str = "",
220 ) -> List[Dict[str, Any]]:
221 """Parse a Techmeme search envelope into normalized item dicts.
222
223 Drops publication-name header rows and records missing a link. Each item
224 carries the record's own ISO date, or None when the record has no usable
225 date (never today's date -- undated items get ``date_confidence: low``
226 downstream). Computes a token-overlap relevance hint. Returns dicts ready
227 for ``normalize._normalize_techmeme``.
228 """
229 raw = response.get("results") if isinstance(response, dict) else None
230 if not isinstance(raw, list):
231 return []
232
233 items: List[Dict[str, Any]] = []
234 for i, rec in enumerate(raw):
235 if not isinstance(rec, dict):
236 continue
237 headline = " ".join(str(rec.get("headline") or "").split()).strip()
238 source_name = str(rec.get("source") or "").strip()
239 if not _is_story_headline(headline, source_name):
240 continue
241 link = str(rec.get("link") or "").strip()
242 if not link:
243 continue
244
245 rank_decay = max(0.3, 1.0 - (i * 0.03))
246 content_score = token_overlap_relevance(query, headline) if query else 0.5
247 relevance = min(1.0, 0.55 * rank_decay + 0.45 * content_score)
248
249 items.append(
250 {
251 "id": link,
252 "title": headline,
253 "url": link,
254 "source_name": source_name,
255 "date": _record_iso_date(rec),
256 "engagement": {},
257 "relevance": round(relevance, 2),
258 "why_relevant": (
259 f"Techmeme headline ({source_name})" if source_name else "Techmeme headline"
260 ),
261 }
262 )
263
264 return items
265
265 lines PYTHON