analyze_searches.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""Aggregate ``episodes/*.searches.jsonl`` to surface high-frequency
queries and frequently-cited domains.
Two outputs:
1. **Top queries** — normalized (lowercased, stripped of punctuation
and whitespace runs) and counted. Surfaces the patterns Claude
re-asks across editions; high-frequency stems are RSS-feed
replacement candidates.
2. **Top domains** — extracted from result URLs and counted. Surfaces
the sites Anthropic's ``web_search`` keeps citing back; the top
hosts are direct ``web_fetch`` replacement candidates
(``web_fetch`` is billed at $0/call vs ``web_search`` at
$0.01/call, and tighter source control shrinks the search-result
token volume that dominates per-edition cost — see
``CHANGELOG.md`` under "0.1.1rcN — web_search telemetry").
Usage::
python analyze_searches.py [--episodes-dir DIR] [--top N]
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter
from pathlib import Path
from urllib.parse import urlparse
_PUNCT = re.compile(r"[^\w\s]")
_WS = re.compile(r"\s+")
def _normalize_query(q: str) -> str:
"""Lowercase + strip punctuation + collapse whitespace.
Aggressive but lossy: "S&P 500 Close Today!" and "s&p 500 close
today?" both normalize to "sp 500 close today". The point is
counting pattern frequency, not preserving exact phrasing.
"""
q = q.lower()
q = _PUNCT.sub(" ", q)
q = _WS.sub(" ", q).strip()
return q
def _domain(url: str) -> str | None:
"""Return ``host`` from a URL, stripping a leading ``www.`` if present."""
try:
host = urlparse(url).netloc.lower()
except ValueError:
return None
if not host:
return None
if host.startswith("www."):
host = host[4:]
return host
def load_searches(episodes_dir: Path) -> list[dict]:
"""Read every ``*.searches.jsonl`` under ``episodes_dir`` and return
a flat list of records. Skips blank lines; raises on malformed JSON
so corruption surfaces immediately rather than being silently dropped.
"""
records: list[dict] = []
for path in sorted(episodes_dir.glob("*.searches.jsonl")):
for i, line in enumerate(path.read_text().splitlines(), start=1):
line = line.strip()
if not line:
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError as e:
raise RuntimeError(
f"Malformed JSON in {path.name}:{i}: {e}"
) from e
return records
def summarize(records: list[dict], top_n: int) -> dict:
"""Compute top-N query and top-N domain counters, plus header stats."""
query_counter: Counter[str] = Counter()
domain_counter: Counter[str] = Counter()
editions: set[tuple[str, str]] = set()
error_count = 0
for rec in records:
editions.add((rec.get("date", ""), rec.get("edition", "")))
if rec.get("error"):
error_count += 1
q = rec.get("query", "")
if q:
query_counter[_normalize_query(q)] += 1
for url in rec.get("urls", []):
d = _domain(url)
if d:
domain_counter[d] += 1
return {
"total_searches": len(records),
"editions": len(editions),
"error_count": error_count,
"top_queries": query_counter.most_common(top_n),
"top_domains": domain_counter.most_common(top_n),
}
def render(summary: dict) -> str:
"""Render the summary as a human-readable report."""
lines: list[str] = []
lines.append("# morning-signal web_search telemetry summary")
lines.append("")
lines.append(f"- Editions analyzed: {summary['editions']}")
lines.append(f"- Total searches: {summary['total_searches']}")
lines.append(f"- Errored searches: {summary['error_count']}")
avg = (
summary["total_searches"] / summary["editions"]
if summary["editions"] else 0
)
lines.append(f"- Avg per edition: {avg:.1f}")
lines.append("")
lines.append("## Top normalized queries (RSS / curated-source candidates)")
if not summary["top_queries"]:
lines.append(" (none)")
for q, c in summary["top_queries"]:
lines.append(f" {c:4d} {q}")
lines.append("")
lines.append("## Top cited domains (web_fetch replacement candidates)")
if not summary["top_domains"]:
lines.append(" (none)")
for d, c in summary["top_domains"]:
lines.append(f" {c:4d} {d}")
lines.append("")
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--episodes-dir",
type=Path,
default=Path(__file__).parent / "episodes",
help="Directory containing *.searches.jsonl files (default: ./episodes)",
)
parser.add_argument(
"--top",
type=int,
default=20,
help="How many top queries / domains to report (default: 20)",
)
args = parser.parse_args(argv)
if not args.episodes_dir.exists():
print(
f"error: {args.episodes_dir} does not exist", file=sys.stderr,
)
return 1
records = load_searches(args.episodes_dir)
if not records:
print(
f"No *.searches.jsonl records found under {args.episodes_dir}. "
"Has search_telemetry shipped to production yet?",
file=sys.stderr,
)
return 1
summary = summarize(records, top_n=args.top)
print(render(summary))
return 0
if __name__ == "__main__":
raise SystemExit(main())