Files
last30days-skill/scripts/watchlist.py
Matt Van Horn 5e5d586f7d fix: triage all 16 open GitHub issues — close 9, fix 6, comment 1
Batch 1 (closed): #43 spam, #34 dup, #19 resolved, #2 resolved, #41 answered
Batch 2: Added MIT LICENSE file (#35), closed #42 (license question)
Batch 3 code fixes:
  - #29: YouTube skip reason shows "0 results" instead of "not installed"
  - #30: Bird source mapping handles reddit-web + Bird combo
  - #39: watchlist.py extracts YouTube + TikTok findings, run-one prints output
  - #40: watchlist.py uses search_queries field when available
Batch 4:
  - #32: marketplace.json source "." → "./" with $schema ref
  - #36: commented with investigation plan ($ARGUMENTS forwarding)
  - #4: Added SSL troubleshooting section to README
Also commented on #22 (Bird features) and #31 (skills.sh audit).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:06:55 -08:00

320 lines
10 KiB
Python

#!/usr/bin/env python3
"""Topic watchlist management for last30days.
CLI for adding, removing, and listing watched topics with auto-bootstrap.
On first `add`, creates the SQLite database.
Usage:
python3 watchlist.py add "AI video tools" [--schedule "0 8 * * *"]
python3 watchlist.py add "NVIDIA news" --weekly
python3 watchlist.py remove "AI video tools"
python3 watchlist.py list
python3 watchlist.py run-all
python3 watchlist.py run-one "AI video tools"
python3 watchlist.py config delivery telegram
python3 watchlist.py config budget 10.00
"""
import argparse
import json
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve()
sys.path.insert(0, str(SCRIPT_DIR))
import store
def cmd_add(args):
"""Add a topic to the watchlist."""
schedule = "0 8 * * 1" if args.weekly else (args.schedule or "0 8 * * *")
queries = args.queries.split(",") if args.queries else None
topic = store.add_topic(args.topic, search_queries=queries, schedule=schedule)
sched_desc = "weekly (Mondays 8am)" if args.weekly else f"daily ({schedule})"
result = {
"action": "added",
"topic": topic["name"],
"schedule": sched_desc,
"message": f'Added "{topic["name"]}" to watchlist. Schedule: {sched_desc}.',
}
print(json.dumps(result, default=str))
def cmd_remove(args):
"""Remove a topic from the watchlist."""
removed = store.remove_topic(args.topic)
if not removed:
print(json.dumps({"action": "not_found", "topic": args.topic, "message": f'Topic not found: "{args.topic}"'}))
return
remaining = store.list_topics()
print(json.dumps({
"action": "removed",
"topic": args.topic,
"message": f'Removed "{args.topic}" from watchlist.',
"remaining": len(remaining),
}))
def cmd_list(args):
"""List all watched topics with stats."""
topics = store.list_topics()
budget_used = store.get_daily_cost()
budget_limit = store.get_setting("daily_budget", "5.00")
result = {
"topics": topics,
"budget_used": budget_used,
"budget_limit": float(budget_limit),
}
print(json.dumps(result, default=str))
def cmd_run_one(args):
"""Run research for a single topic."""
topic = store.get_topic(args.topic)
if not topic:
print(json.dumps({"error": f'Topic not found: "{args.topic}"'}))
sys.exit(1)
result = _run_topic(topic)
print(json.dumps(result, default=str))
def cmd_run_all(args):
"""Run research for all enabled topics with budget guard."""
topics = store.list_topics()
enabled = [t for t in topics if t["enabled"]]
if not enabled:
print(json.dumps({"message": "No enabled topics to research."}))
return
budget_limit = float(store.get_setting("daily_budget", "5.00"))
results = []
for topic in enabled:
# Budget guard
daily_cost = store.get_daily_cost()
if daily_cost >= budget_limit:
results.append({
"topic": topic["name"],
"status": "skipped",
"reason": f"Budget exceeded: ${daily_cost:.2f}/${budget_limit:.2f}",
})
continue
result = _run_topic(topic)
results.append(result)
print(json.dumps({
"action": "run_all",
"results": results,
"budget_used": store.get_daily_cost(),
"budget_limit": budget_limit,
}, default=str))
def _run_topic(topic: dict) -> dict:
"""Run research for a single topic and store findings."""
start_time = time.time()
topic_id = topic["id"]
# Record the run
run_id = store.record_run(topic_id, source_mode="both", status="running")
try:
# Prefer custom search_queries over topic name (#40)
search_queries = json.loads(topic["search_queries"]) if topic.get("search_queries") else None
search_term = search_queries[0] if search_queries else topic["name"]
cmd = [
sys.executable,
str(SCRIPT_DIR / "last30days.py"),
search_term,
"--emit=json",
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300,
)
duration = time.time() - start_time
if result.returncode != 0:
store.update_run(
run_id,
status="failed",
error_message=result.stderr[:500],
duration_seconds=duration,
)
return {
"topic": topic["name"],
"status": "failed",
"error": result.stderr[:200],
"duration": duration,
}
# Parse research output
data = json.loads(result.stdout)
# Convert research items to findings format
findings = []
for item in data.get("reddit", []):
findings.append({
"source": "reddit",
"url": item.get("url", ""),
"title": item.get("title", ""),
"author": item.get("author", ""),
"content": item.get("title", ""),
"summary": item.get("top_comments_summary", ""),
"engagement_score": item.get("upvotes", 0),
"relevance_score": item.get("relevance", 0),
})
for item in data.get("x", []):
findings.append({
"source": "x",
"url": item.get("url", ""),
"title": item.get("text", "")[:100],
"author": item.get("author_handle", ""),
"content": item.get("text", ""),
"engagement_score": (item.get("engagement") or {}).get("likes", 0),
"relevance_score": item.get("relevance", 0),
})
for item in data.get("youtube", []):
findings.append({
"source": "youtube",
"url": item.get("url", ""),
"title": item.get("title", ""),
"author": item.get("channel_name", item.get("channel", "")),
"content": item.get("transcript_snippet", "") or item.get("title", ""),
"engagement_score": (item.get("engagement") or {}).get("views", 0),
"relevance_score": item.get("relevance", 0),
})
for item in data.get("tiktok", []):
findings.append({
"source": "tiktok",
"url": item.get("url", ""),
"title": (item.get("caption_snippet", "") or "")[:120],
"author": item.get("author", ""),
"content": item.get("caption_snippet", ""),
"engagement_score": (item.get("engagement") or {}).get("views", 0),
"relevance_score": item.get("relevance", 0),
})
# Store with dedup
counts = store.store_findings(run_id, topic_id, findings)
store.update_run(
run_id,
status="completed",
duration_seconds=duration,
findings_new=counts["new"],
findings_updated=counts["updated"],
)
return {
"topic": topic["name"],
"status": "completed",
"new": counts["new"],
"updated": counts["updated"],
"duration": duration,
}
except subprocess.TimeoutExpired:
duration = time.time() - start_time
store.update_run(
run_id, status="failed",
error_message="Research timed out after 300s",
duration_seconds=duration,
)
return {"topic": topic["name"], "status": "failed", "error": "timeout"}
except json.JSONDecodeError as e:
duration = time.time() - start_time
store.update_run(
run_id, status="failed",
error_message=f"Invalid JSON output: {e}",
duration_seconds=duration,
)
return {"topic": topic["name"], "status": "failed", "error": f"parse error: {e}"}
except Exception as e:
duration = time.time() - start_time
store.update_run(
run_id, status="failed",
error_message=str(e)[:500],
duration_seconds=duration,
)
return {"topic": topic["name"], "status": "failed", "error": str(e)}
def cmd_config(args):
"""Configure watchlist settings."""
if args.setting == "delivery":
store.set_setting("delivery_channel", args.value)
print(json.dumps({"action": "config", "setting": "delivery_channel", "value": args.value}))
elif args.setting == "budget":
store.set_setting("daily_budget", args.value)
print(json.dumps({"action": "config", "setting": "daily_budget", "value": args.value}))
else:
print(json.dumps({"error": f"Unknown setting: {args.setting}. Use 'delivery' or 'budget'."}))
def main():
parser = argparse.ArgumentParser(description="Manage last30days topic watchlist")
sub = parser.add_subparsers(dest="command")
# add
a = sub.add_parser("add", help="Add a topic to the watchlist")
a.add_argument("topic", help="Topic name")
a.add_argument("--schedule", help="Cron expression (default: 0 8 * * *)")
a.add_argument("--weekly", action="store_true", help="Run weekly instead of daily")
a.add_argument("--queries", help="Comma-separated custom search queries")
a.set_defaults(func=cmd_add)
# remove
r = sub.add_parser("remove", help="Remove a topic from the watchlist")
r.add_argument("topic", help="Topic name")
r.set_defaults(func=cmd_remove)
# list
l = sub.add_parser("list", help="List all watched topics")
l.set_defaults(func=cmd_list)
# run-all
ra = sub.add_parser("run-all", help="Run research for all enabled topics")
ra.set_defaults(func=cmd_run_all)
# run-one
ro = sub.add_parser("run-one", help="Run research for a single topic")
ro.add_argument("topic", help="Topic name")
ro.set_defaults(func=cmd_run_one)
# config
c = sub.add_parser("config", help="Configure watchlist settings")
c.add_argument("setting", help="Setting name (delivery, budget)")
c.add_argument("value", help="Setting value")
c.set_defaults(func=cmd_config)
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
args.func(args)
if __name__ == "__main__":
main()