Compare commits

..

2 Commits

Author SHA1 Message Date
Matt Van Horn 09ed497804 feat(podcasts): make podcasts always available + smarter mention matching
Changes:
- Podcasts source is now always available when yt-dlp is installed (same as
  YouTube). Previously required explicit opt-in via INCLUDE_SOURCES or
  --search=podcasts.
- Smarter mention matching: extract key terms from multi-word topics and
  use max count across terms. "Kanye West Bully album" now matches
  episodes mentioning "Kanye" 85 times (previously 0 due to exact phrase).
- SKILL.md: add podcast channel resolution to Step 0.55, include
  --podcast-channels in execution command, update ACTIVE_SOURCES_LIST.

Tested: Kanye West query now finds 5 podcast hits including hidden
mentions in off-topic episodes (Lost Civilizations, Mike WiLL Made-It).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:29:11 -04:00
Matt Van Horn 49d45c2b42 feat(podcasts): add YouTube podcast source with transcript-first discovery
New "podcasts" source that discovers podcast content by scanning transcripts
from LLM-resolved YouTube channels. Finds content invisible to title-based
search — Acquired's "The NFL" episode mentions Taylor Swift 18x, ESPN 117x,
Netflix 102x, none in the title.

Architecture:
- LLM resolves 6-12 podcast channel @handles per topic
- Engine fetches recent episodes via yt-dlp (no video download)
- Downloads auto-captions and greps for topic keywords
- Episodes with 5+ mentions become podcast results with highlights
- Runs in parallel, ~15-20s latency, invisible in 3-min research run

Pipeline integration:
- New source module: scripts/lib/podcast_yt.py
- Registered in pipeline, normalizer, signals, planner, render
- CLI flag: --podcast-channels=AcquiredFM,lexfridman,...
- SOURCE_QUALITY: 0.88 (above YouTube's 0.85)
- Opt-in via INCLUDE_SOURCES=podcasts or --search=podcasts

Zero new API keys. Zero new dependencies. Reuses yt-dlp + transcript pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:28:40 -04:00
265 changed files with 11326 additions and 3233 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -10,7 +10,7 @@
{ {
"name": "last30days", "name": "last30days",
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, HN, Polymarket, GitHub, and 5+ more sources.", "description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, HN, Polymarket, GitHub, and 5+ more sources.",
"version": "3.0.2", "version": "3.0.0",
"author": { "author": {
"name": "Matt Van Horn", "name": "Matt Van Horn",
"url": "https://github.com/mvanhorn" "url": "https://github.com/mvanhorn"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "last30days", "name": "last30days",
"version": "3.0.2", "version": "3.0.0",
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.", "description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
"author": { "author": {
"name": "Matt Van Horn", "name": "Matt Van Horn",
@@ -11,6 +11,6 @@
"repository": "https://github.com/mvanhorn/last30days-skill", "repository": "https://github.com/mvanhorn/last30days-skill",
"license": "MIT", "license": "MIT",
"keywords": ["research", "reddit", "twitter", "youtube", "tiktok", "instagram", "trends", "prompts", "polymarket", "github", "perplexity", "threads", "pinterest", "eli5", "hacker-news"], "keywords": ["research", "reddit", "twitter", "youtube", "tiktok", "instagram", "trends", "prompts", "polymarket", "github", "perplexity", "threads", "pinterest", "eli5", "hacker-news"],
"skills": ["skills"], "skills": ["./"],
"hooks": {} "hooks": {}
} }
-3
View File
@@ -1,3 +0,0 @@
{
"name": "last30days"
}
-46
View File
@@ -1,46 +0,0 @@
# Exclude non-runtime files from `git archive` output.
# Used by scripts/build-skill.sh to produce a claude.ai-upload-ready .skill file.
# See docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md.
# Anthropic canonical skill-packaging excludes
# (mirrors anthropics/skills/skills/skill-creator/scripts/package_skill.py)
__pycache__/ export-ignore
node_modules/ export-ignore
*.pyc export-ignore
.DS_Store export-ignore
evals/ export-ignore
# Dev, docs, test, and media - not needed at skill runtime
tests/ export-ignore
docs/ export-ignore
fixtures/ export-ignore
assets/ export-ignore
# Second SKILL.md files would confuse claude.ai's uploader
# (skills/last30days/ is an internal spec; skills/last30days-nux/ is a symlink)
skills/ export-ignore
# Historical + repo-only manifests
SKILL-original.md export-ignore
SPEC.md export-ignore
TASKS.md export-ignore
test-run.log export-ignore
CONTRIBUTORS.md export-ignore
HERMES_SETUP.md export-ignore
release-notes.md export-ignore
CHANGELOG.md export-ignore
uv.lock export-ignore
# Platform adapters - skill-upload path is platform-agnostic
.agents/ export-ignore
.codex-plugin/ export-ignore
.hermes-plugin/ export-ignore
.claude-plugin/ export-ignore
# CI workflows - repo-only, not needed at skill runtime
.github/ export-ignore
# Build config itself
.clawhubignore export-ignore
.gitignore export-ignore
.gitattributes export-ignore
-31
View File
@@ -1,31 +0,0 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build .skill artifact
run: |
bash scripts/build-skill.sh
test -f dist/last30days.skill
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
files: dist/last30days.skill
generate_release_notes: true
draft: false
prerelease: false
-13
View File
@@ -15,16 +15,3 @@ variants/open/references/research.md
__pycache__/ __pycache__/
*.pyc *.pyc
mise.toml mise.toml
.memsearch/
.venv/
.coverage
htmlcov/
# Root vendor/ is accidental - real vendored client lives at scripts/lib/vendor/bird-search/
/vendor/
# build artifact from scripts/build-skill.sh
/dist/
# Internal planning docs (ce:plan output) — keep local, don't publish
docs/plans/
-269
View File
@@ -1,269 +0,0 @@
---
name: last30days
version: "3.0.0"
description: "Multi-query social search with intelligent planning. Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web."
argument-hint: 'last30days AI video tools, last30days best noise cancelling headphones'
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
homepage: https://github.com/mvanhorn/last30days-skill
repository: https://github.com/mvanhorn/last30days-skill
author: mvanhorn
license: MIT
user-invocable: true
metadata:
hermes:
emoji: "📰"
tags:
- research
- deep-research
- reddit
- x
- twitter
- youtube
- tiktok
- instagram
- hackernews
- polymarket
- trends
- recency
- news
- citations
- multi-source
- social-media
- analysis
- web-search
requires:
env:
- SCRAPECREATORS_API_KEY
optionalEnv:
- OPENAI_API_KEY
- XAI_API_KEY
- OPENROUTER_API_KEY
- PARALLEL_API_KEY
- BRAVE_API_KEY
- APIFY_API_TOKEN
- AUTH_TOKEN
- CT0
- BSKY_HANDLE
- BSKY_APP_PASSWORD
- TRUTHSOCIAL_TOKEN
bins:
- node
- python3
primaryEnv: SCRAPECREATORS_API_KEY
files:
- "scripts/*"
homepage: https://github.com/mvanhorn/last30days-skill
---
# last30days v3.0.0: Research Any Topic from the Last 30 Days
> **Permissions overview:** Reads public web/platform data and optionally saves research briefings to `~/Documents/Last30Days/`. X/Twitter search uses optional user-provided tokens (AUTH_TOKEN/CT0 env vars). Bluesky search uses optional app password (BSKY_HANDLE/BSKY_APP_PASSWORD env vars - create at bsky.app/settings/app-passwords). All credential usage and data writes are documented in the [Security & Permissions](#security--permissions) section.
Research ANY topic across Reddit, X, YouTube, and other sources. Surface what people are actually discussing, recommending, betting on, and debating right now.
## Runtime Preflight
Before running any `last30days.py` command in this skill, resolve a Python 3.12+ interpreter once and keep it in `LAST30DAYS_PYTHON`:
```bash
for py in python3.14 python3.13 python3.12 python3; do
command -v "$py" >/dev/null 2>&1 || continue
"$py" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 12) else 1)' || continue
LAST30DAYS_PYTHON="$py"
break
done
if [ -z "${LAST30DAYS_PYTHON:-}" ]; then
echo "ERROR: last30days v3 requires Python 3.12+. Install python3.12 or python3.13 and rerun." >&2
exit 1
fi
```
## Step 0: First-Run Setup Wizard
**CRITICAL: ALWAYS execute Step 0 BEFORE Step 1, even if the user provided a topic.** If the user typed `last30days Mercer Island`, you MUST check for FIRST_RUN and present the wizard BEFORE running research. The topic "Mercer Island" is preserved — research runs immediately after the wizard completes. Do NOT skip the wizard because a topic was provided. The wizard takes 10 seconds and only runs once ever.
To detect first run: check if `~/.config/last30days/.env` exists. If it does NOT exist, this is a first run. **Do NOT run any Bash commands or show any command output to detect this — just check the file existence silently.** If the file exists and contains `SETUP_COMPLETE=true`, skip this section **silently** and proceed to Step 1. **Do NOT say "Setup is complete" or any other status message — just move on.** The user doesn't need to be told setup is done every time they run the skill.
**When first run is detected, detect your platform first:**
**If you do NOT have WebSearch capability (raw CLI):** Run the terminal-only setup flow below.
**If you DO have WebSearch (Hermes):** Run the standard setup flow below.
---
### Terminal-Only / Non-WebSearch Setup Flow
Run environment detection first:
```bash
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" setup --terminal
```
Read the JSON output. It tells you what's already configured. Display a status summary:
```
👋 Welcome to last30days!
Detected:
{✅ or ❌} yt-dlp (YouTube search)
{✅ or ❌} X/Twitter ({method} configured)
{✅ or ❌} ScrapeCreators (TikTok, Instagram, Reddit backup)
{✅ or ❌} Web search ({backend} configured)
```
Then for each missing item, offer setup in priority order:
1. **ScrapeCreators** (if not configured): "ScrapeCreators adds TikTok and Instagram search (plus a Reddit backup if public Reddit gets rate-limited). 10,000 free calls, no credit card. (No referrals, no kickbacks - we don't get a cut.)"
- Option A: "ScrapeCreators via GitHub (recommended)" — Check if `gh` CLI was detected in the environment detection output above. If gh IS detected: description should say "Registers directly via GitHub CLI in ~2 seconds - no browser needed". Before running the command, display: "Registering via GitHub CLI..." If gh is NOT detected: description should say "Copies a one-time code to your clipboard and opens GitHub to authorize". Then run `"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" setup --github`, parse JSON output. Tries PAT first (if `gh` is installed), falls back to device flow which copies a one-time code to your clipboard and opens your browser. If `status` is `success`, write `SCRAPECREATORS_API_KEY=*** to .env.
- Option B: "I have a key" — accept paste, write to .env
- Option C: "Skip for now"
2. **X/Twitter** (if not configured): "X search finds tweets and conversations. To unlock X: add FROM_BROWSER=auto (reads browser cookies, free), XAI_API_KEY (no browser access, api.x.ai), or AUTH_TOKEN+CT0 (manual cookies)."
- Option A: "I have an xAI API key" (recommended for servers — persistent, no expiry). Write XAI_API_KEY to .env.
- Option B: "I have AUTH_TOKEN + CT0 from my browser" — accept both, write to .env
- Option C: "Skip for now"
3. **YouTube** (if yt-dlp not found): "YouTube search needs yt-dlp. Run: `pip install yt-dlp`"
4. **Web search** (if no Brave/Exa/Serper key): "A web search key enables smarter results. Brave Search is free for 2,000 queries/month at brave.com/search/api"
After setup, write `SETUP_COMPLETE=true` to .env and proceed to research.
**Skip to "END OF FIRST-RUN WIZARD" below after completing the terminal-only flow.**
---
### Hermes Setup Flow (Standard)
**You MUST follow these steps IN ORDER. Do NOT skip ahead to the topic picker or research. The sequence is: (1) welcome text -> (2) setup modal -> (3) run setup if chosen -> (4) optional ScrapeCreators modal -> (5) topic picker. You MUST start at step 1.**
**Step 1: Display the following welcome text ONCE as a normal message (not blockquoted). Then IMMEDIATELY call AskUserQuestion - do NOT repeat any of the welcome text inside the AskUserQuestion call.**
Welcome to last30days!
I research any topic across Reddit, X, YouTube, and other sources - synthesizing what people are actually saying right now.
Auto setup gives you 5 core sources for free in 30 seconds:
- X/Twitter - reads your x.com browser cookies to authenticate (not saved to disk). Chrome on macOS will prompt for Keychain access.
- Reddit with comments - public JSON, no API key needed
- YouTube search + transcripts - installs yt-dlp (open source, 190K+ GitHub stars)
- Hacker News + Polymarket + GitHub (if `gh` CLI installed) - always on, zero config
Want TikTok and Instagram too? ScrapeCreators adds those (10,000 free calls, scrapecreators.com). No kickbacks, no affiliation.
**Then call AskUserQuestion with ONLY this question and these options - no additional text:**
Question: "How would you like to set up?"
Options:
- "Auto setup (~30 seconds) - scans browser cookies for X + installs yt-dlp for YouTube"
- "Manual setup - show me what to configure"
- "Skip for now - Reddit (with comments), HN, Polymarket, GitHub (if gh installed), Web"
**If the user picks 1 (Auto setup):**
**Before running the setup command, get cookie consent:**
Check if `BROWSER_CONSENT=true` already exists in `~/.config/last30days/.env`. If it does, skip the consent prompt and run setup directly.
If `BROWSER_CONSENT=true` is NOT present, **call AskUserQuestion:**
Question: "Auto setup will scan your browser for x.com cookies to authenticate X search. Cookies are read live, not saved to disk. Chrome on macOS will prompt for Keychain access. OK to proceed?"
Options:
- "Yes, scan my cookies for X" - Run setup as normal. Append `BROWSER_CONSENT=true` to .env after setup completes.
- "Skip X, just set up YouTube" - Run setup with YouTube only (install yt-dlp). Do not scan cookies.
- "I have an xAI API key instead" - Ask them to paste it, write XAI_API_KEY to .env. Then install yt-dlp.
Run the setup subcommand:
```bash
cd {SKILL_DIR} && "${LAST30DAYS_PYTHON}" scripts/last30days.py setup
```
Show the user the results (what cookies were found, whether yt-dlp was installed).
**Then show the optional ScrapeCreators offer (plain text, then modal):**
Want TikTok and Instagram too? ScrapeCreators adds those platforms - 10,000 free calls, no credit card. It also serves as a Reddit backup if public Reddit ever gets rate-limited.
**Before showing the ScrapeCreators modal, check for `gh` CLI:** Run `which gh` via Bash silently. Store the result as gh_available (true if found, false if not).
**Call AskUserQuestion:**
Question: "Want to add TikTok, Instagram, and Reddit backup via ScrapeCreators? (We don't get a cut.)"
Options:
- "ScrapeCreators via GitHub (fastest, recommended)" - If gh_available: description should say "Registers directly via GitHub CLI in ~2 seconds - no browser needed". If NOT gh_available: description should say "Copies a one-time code to your clipboard and opens GitHub to authorize". After the user selects this option: If gh_available, display "Registering via GitHub CLI..." before running the command. If NOT gh_available, display "I'll copy a one-time code to your clipboard and open GitHub. When GitHub asks for a device code, just paste (Cmd+V on Mac, Ctrl+V on Windows/Linux)." Then run `cd {SKILL_DIR} && "${LAST30DAYS_PYTHON}" scripts/last30days.py setup --github` via Bash with a 5-minute timeout. This tries PAT auth first (if `gh` CLI is installed, zero browser needed), then falls back to GitHub device flow which copies a one-time code to your clipboard and opens GitHub in your browser. Parse the JSON stdout. If `status` is `success`, write `SCRAPECREATORS_API_KEY=*** to `~/.config/last30days/.env`. If `method` is `pat`, show: "You're in! Registered via GitHub CLI - zero browser needed. 10,000 free calls. TikTok, Instagram, and Reddit backup are now active." If `method` is `device` and `clipboard_ok` is true, show: "You're in! (The authorization code was copied to your clipboard automatically.) 10,000 free calls. TikTok, Instagram, and Reddit backup are now active." If `method` is `device` and `clipboard_ok` is false, show: "You're in! 10,000 free calls. TikTok, Instagram, and Reddit backup are now active." If `status` is `timeout` or `error`, show: "GitHub auth didn't complete. No worries - you can sign up at scrapecreators.com instead or try again later." Then offer the web signup option.
- "Open scrapecreators.com (Google sign-in)" - run `open https://scrapecreators.com` via Bash to open in the user's browser. Then ask them to paste the API key they get. When they paste it, write SCRAPECREATORS_API_KEY=*** to ~/.config/last30days/.env
- "I have a key" - accept the key, write to .env
- "Skip for now" - proceed without ScrapeCreators
**After SC key is saved (not if skipped), show the TikTok/Instagram opt-in:**
**Call AskUserQuestion:**
Question: "Enable TikTok and Instagram search?"
Options:
- "Yes, enable TikTok + Instagram" - Write `TIKTOK_ENABLED=true` and `INSTAGRAM_ENABLED=true` to .env. Then show: "TikTok and Instagram are now enabled. You can disable them later by editing ~/.config/last30days/.env."
- "No, skip for now" - proceed without enabling
**After setup completes, write `SETUP_COMPLETE=true` to .env.**
---
## END OF FIRST-RUN WIZARD
Proceed to Step 1.
---
## Step 1: Parse Topic
The user invoked: `last30days {QUERY}`
Extract the topic. If the query is empty or ambiguous, ask for clarification.
## Step 2: Execute Research
Run the research engine:
```bash
cd {SKILL_DIR} && "${LAST30DAYS_PYTHON}" scripts/last30days.py "{TOPIC}" --emit=compact --lookback-days=30
```
Optional flags based on user request:
- `--search=reddit,youtube,hackernews` - Specific sources only
- `--days=7` - Shorter time range
- `--deep` - Higher recall mode
- `--save` - Save to ~/Documents/Last30Days/
## Step 3: Display Results
Show the research output to the user. The compact output includes:
- Executive summary
- Ranked evidence clusters with scores
- Source statistics (upvotes, views, engagement)
- Citations with URLs
- Confidence levels and uncertainty notes
## Security & Permissions
**What this skill does:**
- Sends search queries to ScrapeCreators API (`api.scrapecreators.com`) for TikTok and Instagram search, and as a Reddit backup when public Reddit is unavailable (requires SCRAPECREATORS_API_KEY)
- Sends search queries to OpenAI's Responses API (`api.openai.com`) for Reddit discovery (fallback if no SCRAPECREATORS_API_KEY)
- Sends search queries to Twitter's GraphQL API (via optional user-provided AUTH_TOKEN/CT0 env vars — no browser session access) or xAI's API (`api.x.ai`) for X search
- Sends search queries to Algolia HN Search API (`hn.algolia.com`) for Hacker News story and comment discovery (free, no auth)
- Sends search queries to Polymarket Gamma API (`gamma-api.polymarket.com`) for prediction market discovery (free, no auth)
- Runs `yt-dlp` locally for YouTube search and transcript extraction (no API key, public data)
- Sends search queries to ScrapeCreators API (`api.scrapecreators.com`) for TikTok and Instagram search, transcript/caption extraction (PAYG after 10,000 free API calls)
- Optionally sends search queries to Brave Search API, Parallel AI API, or OpenRouter API for web search
- Fetches public Reddit thread data from `reddit.com` for engagement metrics
- Stores research findings in local SQLite database (watchlist mode only)
- Saves research briefings as .md files to ~/Documents/Last30Days/
**What this skill does NOT do:**
- Does not post, like, or modify content on any platform
- Does not access your Reddit, X, or YouTube accounts
- Does not share API keys between providers (OpenAI key only goes to api.openai.com, etc.)
- Does not log, cache, or write API keys to output files
- Does not send data to any endpoint not listed above
- Hacker News and Polymarket sources are always available (no API key, no binary dependency)
- TikTok and Instagram sources require SCRAPECREATORS_API_KEY (10,000 free API calls, then PAYG). Reddit uses ScrapeCreators only as a backup when public Reddit is unavailable.
- Can be invoked autonomously by agents via the Skill tool (runs inline, not forked); pass `--agent` for non-interactive report output
**Bundled scripts:** `scripts/last30days.py` (main research engine), `scripts/lib/` (search, enrichment, rendering modules), `scripts/lib/vendor/bird-search/` (vendored X search client, MIT licensed)
Review scripts before first use to verify behavior.
+2 -38
View File
@@ -5,36 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.0.2] - 2026-04-15 ## [3.0.0] - 2026-04
### Fixed
- **`/last30days` slash command now registers on Claude Code v2.1.105+.** `.claude-plugin/plugin.json` declared `"skills": ["./"]`, which newer Claude Code rejects with `Path escapes plugin directory: ./ (skills)`. The skill silently failed to register, so `/last30days <query>` returned "Unknown command" even though `/plugin list` showed the plugin as installed. Fix: `"skills": ["skills"]` so the loader scans the real skill subdirectory.
- **Version drift between manifests.** `.claude-plugin/marketplace.json` was pinned to `3.0.0` while `.claude-plugin/plugin.json` advertised `3.0.1`. The `/plugin` resolver used the marketplace version and could install stale cached metadata alongside the correct build. Both manifests now agree on `3.0.2`.
### Recovery
If `/last30days` stopped working for you, run `/plugin update last30days` then `/reload-plugins`. If `/doctor` still reports errors, uninstall and reinstall the plugin from the marketplace.
## [3.0.1] - 2026-04-14
### Fixed
- **Skill upload packaging** - `scripts/build-skill.sh` produces a claude.ai-upload-ready `.skill` file that fits under the 200-file cap. Previously, zipping the repo hit 406 files and the "Upload skill" UI rejected it outright.
- **SKILL.md description length** - trimmed from 228 to 167 chars (Anthropic caps descriptions at 200).
### Removed
- Unused root `vendor/` directory (215 files from an accidental commit in PR #48 - the real vendored X client lives at `scripts/lib/vendor/bird-search/`).
- Legacy top-level `plans/` directory (superseded by `docs/plans/`; both plans described work that was already shipped in v3).
### Added
- `.gitattributes` with `export-ignore` entries so `git archive` drops tests, docs, fixtures, assets, historical manifests, and internal skill subdirs. Mirrors Anthropic's canonical `package_skill.py` exclusions.
- `scripts/build-skill.sh` - one-command path to produce `dist/last30days.skill` with a single top-level `last30days/` folder, defensive `=200` file check, and dirty-tree refusal.
- `README.md` section documenting the claude.ai skill upload workflow.
## [3.0.0] - 2026-04-11
### Highlights ### Highlights
@@ -63,18 +34,10 @@ Intelligent search, fun judge, cross-source cluster merging, single-pass compari
- Polymarket display shows % odds only; dollar volumes removed - Polymarket display shows % odds only; dollar volumes removed
- 852 tests passing - 852 tests passing
### Fixed
- Marketplace validation: duplicate `name: last30days` collision in `skills/last30days/SKILL.md` caused strict validators to reject the plugin. Resolved by renaming the internal v3 architecture spec to `last30days-v3-spec` with `user-invocable: false`. Fixed in #214 (reported by @Cody-Coyote in #204).
- Stale README link to the deleted `skills/last30days-v3/` path from the v3 directory rename. Fixed in #214.
- OpenAI Codex CLI discoverability: added `.agents/skills/last30days/SKILL.md` as a real file (Codex's loader skips symlinked files) plus `.codex-plugin/plugin.json` as the namespace marker. The skill now registers as `last30days:last30days` when Codex runs in a checkout of the repo. Fixed in #219 (inspired by @Jah-yee in #153 and @dannyshmueli on X).
### Contributors ### Contributors
- @j-sperling -- v3 engine architecture, Python pre-research brain - @j-sperling -- v3 engine architecture, Python pre-research brain
- @hnshah -- Watchlist features - @hnshah -- Watchlist features
- @Cody-Coyote -- Marketplace validation bug report (#204)
- @Jah-yee -- Codex CLI integration inspiration (#153)
## [2.9.4] - 2026-03-06 ## [2.9.4] - 2026-03-06
@@ -218,6 +181,7 @@ Three headline features: watchlists for always-on bots, YouTube transcripts as a
### Credits ### Credits
- @steipete -- Bird CLI (vendored X search) and yt-dlp/summarize inspiration for YouTube transcripts
- @galligan -- Marketplace plugin inspiration - @galligan -- Marketplace plugin inspiration
- @hutchins -- Pushed for YouTube feature - @hutchins -- Pushed for YouTube feature
-121
View File
@@ -1,121 +0,0 @@
# Hermes Setup Guide for last30days
This guide covers installing last30days on Hermes AI Agent.
## Prerequisites
1. **Hermes installed** - See https://github.com/mercurial-tf/hermes
2. **Python 3.12+** - `brew install python@3.12` or similar
3. **yt-dlp** (optional, for YouTube) - `brew install yt-dlp`
## Installation
### Option 1: Via sync.sh (Recommended)
```bash
# Clone the repo
git clone https://github.com/mvanhorn/last30days-skill.git
cd last30days-skill
# Run the sync script
bash scripts/sync.sh
```
This will auto-detect Hermes and deploy to `~/.hermes/skills/research/last30days/`
### Option 2: Manual Copy
```bash
# Create directory
mkdir -p ~/.hermes/skills/research/last30days
# Copy files
cp -r scripts ~/.hermes/skills/research/last30days/
cp .hermes-plugin/SKILL.md ~/.hermes/skills/research/last30days/
```
## Usage
In Hermes, invoke with:
```
last30days "your research topic"
```
Or with options:
```
last30days "best mechanical keyboards 2025" --search=reddit,youtube
last30days "AI news" --days=7 --deep
```
## First Run Setup
On first run, the skill will guide you through setup:
1. **Auto setup** (~30 seconds)
- Scans browser cookies for X/Twitter
- Checks/installs yt-dlp for YouTube
- Configures free sources (Reddit, HN, Polymarket)
2. **Optional: ScrapeCreators**
- Adds TikTok, Instagram, Reddit backup
- 10,000 free API calls
- Sign up at scrapecreators.com
3. **Optional: API Keys**
- XAI_API_KEY for X/Twitter (alternative to browser cookies)
- BRAVE_API_KEY for web search
## Available Sources
### Free (No API Key)
- **Reddit** - Public discussions and comments
- **Hacker News** - Tech discussions via Algolia
- **Polymarket** - Prediction markets
- **YouTube** - Search and transcripts (requires yt-dlp)
### Requires API Key
- **X/Twitter** - xAI API key or browser cookies
- **TikTok** - ScrapeCreators API
- **Instagram** - ScrapeCreators API
- **Web Search** - Brave Search API
## Troubleshooting
### Python not found
```bash
# Find Python 3.12+
which python3.12 python3.13 python3.14
# If not installed
brew install python@3.12
```
### yt-dlp not found
```bash
brew install yt-dlp
# or
pip install yt-dlp
```
### Check what's configured
```bash
cd ~/.hermes/skills/research/last30days
python3.12 scripts/last30days.py --diagnose
```
## Updating
To update to the latest version:
```bash
cd last30days-skill
git pull
bash scripts/sync.sh
```
## Support
- Original repo: https://github.com/mvanhorn/last30days-skill
- Hermes: https://github.com/mercurial-tf/hermes
- Issues: Please report in the original repo
+8 -38
View File
@@ -12,7 +12,7 @@
**An AI agent-led search engine scored by upvotes, likes, and real money - not editors.** **An AI agent-led search engine scored by upvotes, likes, and real money - not editors.**
This README tracks the current v3 pipeline. The runtime skill spec lives in [skills/last30days/SKILL.md](skills/last30days/SKILL.md), which is the source of truth for the latest command and setup behavior. This README tracks the current v3 pipeline. The runtime skill spec lives in [skills/last30days-v3/SKILL.md](skills/last30days-v3/SKILL.md), which is the source of truth for the latest command and setup behavior.
Claude Code: Claude Code:
``` ```
@@ -24,12 +24,6 @@ OpenClaw:
clawhub install last30days-official clawhub install last30days-official
``` ```
Hermes:
```
# The skill auto-deploys when you run sync.sh
# Or manually copy to ~/.hermes/skills/research/last30days/
```
Zero config. Reddit, HN, Polymarket, and GitHub work immediately. Run it once and the setup wizard unlocks X, YouTube, TikTok, and more in 30 seconds. Zero config. Reddit, HN, Polymarket, and GitHub work immediately. Run it once and the setup wizard unlocks X, YouTube, TikTok, and more in 30 seconds.
--- ---
@@ -128,7 +122,7 @@ Say "eli5 on" after any research run. The synthesis rewrites in plain language.
- **Free Reddit comments.** Public JSON gives you threads + top comments with upvote counts. No API key, no ScrapeCreators. Just works. - **Free Reddit comments.** Public JSON gives you threads + top comments with upvote counts. No API key, no ScrapeCreators. Just works.
- **YouTube transcripts that actually work.** Widened candidate pool 3x past music videos to reach talk/review content with captions. - **YouTube transcripts that actually work.** Widened candidate pool 3x past music videos to reach talk/review content with captions.
- **Threads, Pinterest, YouTube + TikTok comments.** Opt-in sources via ScrapeCreators. Set `INCLUDE_SOURCES=tiktok,instagram` and add threads, pinterest, youtube_comments, tiktok_comments for more. `youtube_comments` and `tiktok_comments` surface top comments with vote counts the same way Reddit does. - **Threads, Pinterest, YouTube comments.** Opt-in sources via ScrapeCreators. Set `INCLUDE_SOURCES=tiktok,instagram` and add threads, pinterest, youtube_comments for more.
- **Perplexity Sonar.** Grounded web search with citations via OpenRouter. Add `OPENROUTER_API_KEY` to unlock. - **Perplexity Sonar.** Grounded web search with citations via OpenRouter. Add `OPENROUTER_API_KEY` to unlock.
- **Polymarket noise filtering.** Common-word disambiguation prevents "Apple" from matching "Will Apple release a car?" - **Polymarket noise filtering.** Common-word disambiguation prevents "Apple" from matching "Will Apple release a car?"
- **Resilient Reddit.** Timeout budgets and runtime fallback. One slow thread doesn't kill the whole run. - **Resilient Reddit.** Timeout budgets and runtime fallback. One slow thread doesn't kill the whole run.
@@ -141,52 +135,28 @@ Say "eli5 on" after any research run. The synthesis rewrites in plain language.
## Install ## Install
| Surface | Install |
|---------|---------|
| **claude.ai** (web) | [Download `last30days.skill`](https://github.com/mvanhorn/last30days-skill/releases/latest/download/last30days.skill) and upload via Settings > Capabilities > Skills > + |
| **Claude Code** | `/plugin marketplace add mvanhorn/last30days-skill` |
| **OpenClaw** | `clawhub install last30days-official` |
| **Gemini CLI** | Clone then `gemini extensions install ./last30days-skill` (see below) |
### claude.ai (web)
1. [Download `last30days.skill`](https://github.com/mvanhorn/last30days-skill/releases/latest/download/last30days.skill) from the latest release
2. Go to [claude.ai Settings > Capabilities > Skills](https://claude.ai/settings/capabilities)
3. Click the `+` button in the Skills panel and drop the file in
Enable "Code execution and file creation" under Capabilities first - skills won't run without it.
### Claude Code ### Claude Code
#### Install
``` ```
/plugin marketplace add mvanhorn/last30days-skill /plugin marketplace add mvanhorn/last30days-skill
``` ```
Update later with `claude plugin update last30days@last30days-skill`. #### Update
```
claude plugin update last30days@last30days-skill
```
### OpenClaw ### OpenClaw
```bash ```bash
clawhub install last30days-official clawhub install last30days-official
``` ```
### Gemini CLI ### Manual
Gemini CLI v0.9.0 has an upstream installer bug that can fail with `Configuration file not found at /tmp/gemini-extensionXXXXXX/gemini-extension.json` ([upstream issue](https://github.com/google-gemini/gemini-cli/issues/11452)). Workaround:
```bash
git clone https://github.com/mvanhorn/last30days-skill
gemini extensions install ./last30days-skill
```
### Manual (developer)
```bash ```bash
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
``` ```
Or build the claude.ai `.skill` file from source: `bash scripts/build-skill.sh` produces `dist/last30days.skill`.
Reddit (with comments), Hacker News, Polymarket, and GitHub work immediately. Zero configuration. Run `/last30days` once and the setup wizard unlocks more sources in 30 seconds. Reddit (with comments), Hacker News, Polymarket, and GitHub work immediately. Zero configuration. Run `/last30days` once and the setup wizard unlocks more sources in 30 seconds.
## Bring your own keys ## Bring your own keys
+36 -13
View File
@@ -1,8 +1,8 @@
--- ---
name: last30days name: last30days
version: "3.0.1" version: "3.0.0"
description: "Research what people actually say about any topic in the last 30 days. Pulls posts and engagement from Reddit, X, YouTube, TikTok, Hacker News, Polymarket, GitHub, and the web." description: "Multi-query social search with intelligent planning. Agent plans queries when possible, falls back to Gemini/OpenAI when not. Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web."
argument-hint: 'last30days nvidia earnings reaction | last30days AI video tools | last30days what users want in react' argument-hint: 'last30days AI video tools, last30days best noise cancelling headphones'
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
homepage: https://github.com/mvanhorn/last30days-skill homepage: https://github.com/mvanhorn/last30days-skill
repository: https://github.com/mvanhorn/last30days-skill repository: https://github.com/mvanhorn/last30days-skill
@@ -59,7 +59,7 @@ metadata:
- clawhub - clawhub
--- ---
# last30days v3.0.1: Research Any Topic from the Last 30 Days # last30days v2.9.5: Research Any Topic from the Last 30 Days
> **Permissions overview:** Reads public web/platform data and optionally saves research briefings to `~/Documents/Last30Days/`. X/Twitter search uses optional user-provided tokens (AUTH_TOKEN/CT0 env vars). Bluesky search uses optional app password (BSKY_HANDLE/BSKY_APP_PASSWORD env vars - create at bsky.app/settings/app-passwords). All credential usage and data writes are documented in the [Security & Permissions](#security--permissions) section. > **Permissions overview:** Reads public web/platform data and optionally saves research briefings to `~/Documents/Last30Days/`. X/Twitter search uses optional user-provided tokens (AUTH_TOKEN/CT0 env vars). Bluesky search uses optional app password (BSKY_HANDLE/BSKY_APP_PASSWORD env vars - create at bsky.app/settings/app-passwords). All credential usage and data writes are documented in the [Security & Permissions](#security--permissions) section.
@@ -203,8 +203,8 @@ Your ScrapeCreators key powers TikTok, Instagram, Threads, Pinterest, and YouTub
**Call AskUserQuestion:** **Call AskUserQuestion:**
Question: "Which ScrapeCreators sources do you want on?" Question: "Which ScrapeCreators sources do you want on?"
Options: Options:
- "TikTok + Instagram (recommended)" - append `INCLUDE_SOURCES=tiktok,instagram` to ~/.config/last30days/.env. Confirm: "TikTok and Instagram are on, plus Reddit backup if public Reddit has issues. You can add threads, pinterest, youtube_comments, tiktok_comments to INCLUDE_SOURCES anytime." - "TikTok + Instagram (recommended)" - append `INCLUDE_SOURCES=tiktok,instagram` to ~/.config/last30days/.env. Confirm: "TikTok and Instagram are on, plus Reddit backup if public Reddit has issues. You can add threads, pinterest, youtube_comments to INCLUDE_SOURCES anytime."
- "Everything - TikTok, Instagram, Threads, Pinterest, YouTube + TikTok comments" - append `INCLUDE_SOURCES=tiktok,instagram,threads,pinterest,youtube_comments,tiktok_comments` to ~/.config/last30days/.env. Confirm: "All ScrapeCreators sources are on." - "Everything - TikTok, Instagram, Threads, Pinterest, YouTube comments" - append `INCLUDE_SOURCES=tiktok,instagram,threads,pinterest,youtube_comments` to ~/.config/last30days/.env. Confirm: "All ScrapeCreators sources are on."
- "Just the basics - let's run our first search" - don't write the flag. Confirm: "Got it. ScrapeCreators will serve as Reddit backup. You can add sources to INCLUDE_SOURCES in your .env anytime." - "Just the basics - let's run our first search" - don't write the flag. Confirm: "Got it. ScrapeCreators will serve as Reddit backup. You can add sources to INCLUDE_SOURCES in your .env anytime."
**After TikTok/Instagram opt-in (or SC skip), show the first research topic modal:** **After TikTok/Instagram opt-in (or SC skip), show the first research topic modal:**
@@ -244,7 +244,7 @@ YouTube (free, open source):
Bonus: TikTok, Instagram, Threads, Pinterest, YouTube comments (ScrapeCreators): Bonus: TikTok, Instagram, Threads, Pinterest, YouTube comments (ScrapeCreators):
- `SCRAPECREATORS_API_KEY=xxx` - 10,000 free calls at scrapecreators.com. - `SCRAPECREATORS_API_KEY=xxx` - 10,000 free calls at scrapecreators.com.
- After adding your key, set `INCLUDE_SOURCES=tiktok,instagram` to turn on the most popular ones. Add threads, pinterest, youtube_comments, tiktok_comments for more. - After adding your key, set `INCLUDE_SOURCES=tiktok,instagram` to turn on the most popular ones. Add threads, pinterest, youtube_comments for more.
GitHub Issues/PRs (free, no key needed): GitHub Issues/PRs (free, no key needed):
- If you have the `gh` CLI installed (`brew install gh`), GitHub search is automatic. No API key required. - If you have the `gh` CLI installed (`brew install gh`), GitHub search is automatic. No API key required.
@@ -290,7 +290,7 @@ Create `~/.config/last30days/.env` if it doesn't exist (check first!), pre-popul
# ScrapeCreators (10,000 free calls - scrapecreators.com): # ScrapeCreators (10,000 free calls - scrapecreators.com):
# SCRAPECREATORS_API_KEY= # Unlocks: TikTok, Instagram, Reddit backup (if public Reddit gets rate-limited) # SCRAPECREATORS_API_KEY= # Unlocks: TikTok, Instagram, Reddit backup (if public Reddit gets rate-limited)
# # Optional: add threads, pinterest, youtube_comments, tiktok_comments for more # # Optional: add threads, pinterest, youtube_comments for more
# INCLUDE_SOURCES=tiktok,instagram # INCLUDE_SOURCES=tiktok,instagram
# YouTube: install yt-dlp (brew install yt-dlp) - no key needed # YouTube: install yt-dlp (brew install yt-dlp) - no key needed
@@ -375,7 +375,7 @@ Common patterns:
- Always active: Reddit, Hacker News, Polymarket - Always active: Reddit, Hacker News, Polymarket
- If gh CLI is installed (check `which gh`): add GitHub - If gh CLI is installed (check `which gh`): add GitHub
- If AUTH_TOKEN/CT0 or XAI_API_KEY or FROM_BROWSER is set: add X - If AUTH_TOKEN/CT0 or XAI_API_KEY or FROM_BROWSER is set: add X
- If yt-dlp is installed (check `which yt-dlp`): add YouTube - If yt-dlp is installed (check `which yt-dlp`): add YouTube AND Podcasts
- If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains tiktok: add TikTok - If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains tiktok: add TikTok
- If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains instagram: add Instagram - If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains instagram: add Instagram
- If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains threads: add Threads - If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains threads: add Threads
@@ -615,6 +615,27 @@ Store as `RESOLVED_IG_CREATORS`.
Store as `RESOLVED_YT_QUERIES`. Store as `RESOLVED_YT_QUERIES`.
**6. Podcast channels****INFER 6-12 YouTube podcast channel @handles from topic knowledge.** Think in two dimensions:
1. **Domain podcasts** — What YouTube podcasts focus on this topic's domain?
- Hip-hop/music → `DrinkChamps,JoeBuddenTV,BreakfastClubPower1051FM,OfficialFlagrant`
- Tech/AI/startups → `lexfridman,DwarkeshPatel,AllInPod,MyFirstMillionPod,LennysPodcast`
- Business/finance → `AcquiredFM,InvestLikeTheBest,PatrickBoyleOnFinance,PropGPod`
- Sports → `PatMcAfeeShowOfficial,ShannonSharpe,ClubShayShay`
- Culture/celebs → `joerogan,CallHerDaddy,ClubShayShay`
- Knitting/crafts → `FruityKnitting,VeryPinkKnits,GroceryGirlsKnit`
2. **Cross-domain podcasts** — What popular interview/deep-dive podcasts might cover this topic even if it's not their main focus?
- Business-adjacent topics → `AcquiredFM,InvestLikeTheBest` (company deep dives)
- Tech-adjacent topics → `lexfridman,AllInPod` (broad tech interviews)
- Culture-adjacent topics → `joerogan,OfficialFlagrant` (celebrity interviews)
**Rationale:** The engine uses these channels for transcript-first discovery. Even if the topic isn't in an episode title, it may be discussed within the episode. Acquired's "The NFL" episode mentions Taylor Swift 18 times, ESPN 117 times — invisible to YouTube search but found by transcript scanning.
**Handle accuracy:** Return your best guess at the exact @handle. If wrong, the engine falls back to a search-based lookup. Don't stress the exact spelling — `@AcquiredFM`, `@lexfridman`, `@joerogan` work; `@FLAGRANT` fails but falls back to find `@OfficialFlagrant`.
Store as `RESOLVED_PODCAST_CHANNELS` (comma-separated, no @ prefix).
**Concrete examples:** **Concrete examples:**
| Topic | WebSearches needed | Reddit subs | TikTok hashtags | TikTok creators | IG creators | YT queries | | Topic | WebSearches needed | Reddit subs | TikTok hashtags | TikTok creators | IG creators | YT queries |
@@ -635,6 +656,7 @@ Resolved:
- Reddit: r/{sub1}, r/{sub2}, r/{sub3} - Reddit: r/{sub1}, r/{sub2}, r/{sub3}
- TikTok: #{hashtag1}, #{hashtag2} - TikTok: #{hashtag1}, #{hashtag2}
- YouTube: {query1}, {query2} - YouTube: {query1}, {query2}
- Podcasts: @{channel1}, @{channel2}, @{channel3}
``` ```
Only show lines for platforms where something was resolved. Skip empty lines. This display replaces the old "Parsed intent" block with something more useful. Only show lines for platforms where something was resolved. Skip empty lines. This display replaces the old "Parsed intent" block with something more useful.
@@ -760,6 +782,7 @@ fi
- `--ig-creators={RESOLVED_IG_CREATORS}` (from Step 0.55) - `--ig-creators={RESOLVED_IG_CREATORS}` (from Step 0.55)
- `--github-user={RESOLVED_GITHUB_USER}` (from Step 0.5b, person topics only) - `--github-user={RESOLVED_GITHUB_USER}` (from Step 0.5b, person topics only)
- `--github-repo={RESOLVED_GITHUB_REPOS}` (from Step 0.5c, product/project topics only) - `--github-repo={RESOLVED_GITHUB_REPOS}` (from Step 0.5c, product/project topics only)
- `--podcast-channels={RESOLVED_PODCAST_CHANNELS}` (from Step 0.55, 6-12 @handles)
- Omit any flag where the value was not resolved (empty). - Omit any flag where the value was not resolved (empty).
**If you skipped Steps 0.55 and 0.75 (no WebSearch -- OpenClaw, Codex, etc.), add:** **If you skipped Steps 0.55 and 0.75 (no WebSearch -- OpenClaw, Codex, etc.), add:**
@@ -776,7 +799,7 @@ The script will automatically:
**Read the ENTIRE output.** It contains EIGHT data sections in this order: Reddit items, X items, YouTube items, TikTok items, Instagram Reels items, Hacker News items, Polymarket items, and WebSearch items. If you miss sections, you will produce incomplete stats. **Read the ENTIRE output.** It contains EIGHT data sections in this order: Reddit items, X items, YouTube items, TikTok items, Instagram Reels items, Hacker News items, Polymarket items, and WebSearch items. If you miss sections, you will produce incomplete stats.
**YouTube items in the output look like:** `**{video_id}** (score:N) {channel_name} [N views, N likes]` followed by a title, URL, **transcript highlights** (pre-extracted quotable excerpts from the video), and an optional full transcript in a collapsible section. **Quote the highlights directly in your synthesis.** When YouTube items also include top comments (enabled via `youtube_comments`), quote those too with their like counts — they capture how viewers reacted to the video. Transcript highlights and top comments are complementary signals; use both when present. Attribute transcript quotes to the channel name, comment quotes to the commenter. Count them and include them in your synthesis and stats block. **YouTube items in the output look like:** `**{video_id}** (score:N) {channel_name} [N views, N likes]` followed by a title, URL, **transcript highlights** (pre-extracted quotable excerpts from the video), and an optional full transcript in a collapsible section. **Quote the highlights directly in your synthesis** - they are the YouTube equivalent of Reddit top comments. Attribute quotes to the channel name. Count them and include them in your synthesis and stats block.
**TikTok items in the output look like:** `**{TK_id}** (score:N) @{creator} [N views, N likes]` followed by a caption, URL, hashtags, and optional caption snippet. Count them and include them in your synthesis and stats block. **TikTok items in the output look like:** `**{TK_id}** (score:N) @{creator} [N views, N likes]` followed by a caption, URL, hashtags, and optional caption snippet. Count them and include them in your synthesis and stats block.
@@ -880,8 +903,8 @@ The Judge Agent must:
2. Weight YouTube sources HIGH (they have views, likes, and transcript content) 2. Weight YouTube sources HIGH (they have views, likes, and transcript content)
3. Weight TikTok sources HIGH (they have views, likes, and caption content — viral signal) 3. Weight TikTok sources HIGH (they have views, likes, and caption content — viral signal)
4. Weight WebSearch sources LOWER (no engagement data) 4. Weight WebSearch sources LOWER (no engagement data)
5. **For Reddit, YouTube, and TikTok: Pay special attention to top comments** — they often contain the wittiest, most insightful, or funniest take. Quote them directly, attributing to the commenter and including the vote count ("N upvotes" for Reddit, "N likes" for YouTube and TikTok). A top comment with thousands of votes is a stronger community signal than the parent post's stats alone. 5. **For Reddit: Pay special attention to top comments** — they often contain the wittiest, most insightful, or funniest take. Quote them directly.
6. **For YouTube: Quote transcript highlights AND top comments.** Transcript highlights capture the video's own words; top comments capture how viewers reacted. Both add value — use them together. Attribute transcript quotes to the channel name. 6. **For YouTube: Quote transcript highlights directly.** Attribute to the channel name.
7. Identify patterns that appear across ALL sources (strongest signals) 7. Identify patterns that appear across ALL sources (strongest signals)
8. Note any contradictions between sources 8. Note any contradictions between sources
9. **Multi-source clusters (items from 3+ platforms) are the strongest signals.** Lead with these. 9. **Multi-source clusters (items from 3+ platforms) are the strongest signals.** Lead with these.
@@ -1067,7 +1090,7 @@ CITATION RULE: Cite sources sparingly to prove research is real.
CITATION PRIORITY (most to least preferred): CITATION PRIORITY (most to least preferred):
1. @handles from X — "per @handle" (these prove the tool's unique value) 1. @handles from X — "per @handle" (these prove the tool's unique value)
2. r/subreddits from Reddit — "per r/subreddit" (when citing Reddit, YouTube, or TikTok, prefer quoting top comments over just the thread title) 2. r/subreddits from Reddit — "per r/subreddit" (when citing Reddit, prefer quoting top comments over just the thread title)
3. YouTube channels — "per [channel name] on YouTube" (transcript-backed insights) 3. YouTube channels — "per [channel name] on YouTube" (transcript-backed insights)
4. TikTok creators — "per @creator on TikTok" (viral/trending signal) 4. TikTok creators — "per @creator on TikTok" (viral/trending signal)
5. Instagram creators — "per @creator on Instagram" (influencer/creator signal) 5. Instagram creators — "per @creator on Instagram" (influencer/creator signal)
@@ -0,0 +1,319 @@
---
title: "feat: YouTube podcast source with transcript-first discovery"
type: feat
status: active
date: 2026-04-10
---
# feat: YouTube podcast source with transcript-first discovery
## Overview
Add a "podcasts" source to last30days that discovers podcast content on YouTube by scanning transcripts, not searching titles. The LLM planner resolves topic-relevant podcast channels (e.g., "NVIDIA" -> Acquired, Lex Fridman, Dwarkesh Patel, All-In). The engine fetches recent episodes from those channels, downloads their auto-captions (no video download), and greps for the search topic. Episodes with 5+ topic mentions become podcast results with transcript highlights.
This finds content invisible to any search engine. Acquired's "The NFL" episode mentions Taylor Swift 18 times, ESPN 117 times, Netflix 102 times - none in the title. A Dwarkesh Patel episode titled "The single biggest bottleneck to scaling AI compute" contains 156 mentions of NVIDIA. No YouTube search finds these. Transcript scanning does.
Zero new API keys. Zero new dependencies. Reuses existing yt-dlp + transcript pipeline. Podcasts get their own identity in stats and synthesis.
## Problem Frame
YouTube captures a lot of podcast content, but it's mixed with news clips, reaction videos, and shorts. The general YouTube search treats a 2:24:55 Drink Champs interview the same as a 0:30 TMZ clip. Worse, the highest-value podcast content is often invisible to search entirely because the topic is discussed within an episode titled something else.
Two insights make this solvable:
1. Podcast episodes are identifiable by duration (>20 minutes) and channel.
2. YouTube auto-captions are free, downloadable without the video (~7 seconds per episode via yt-dlp), and searchable. Transcript scanning discovers content that title-based search cannot.
The LLM already resolves subreddits and X handles per topic. Podcast channels are the same pattern.
## Requirements Trace
- R1. LLM resolves topic-relevant podcast YouTube channels dynamically (no hardcoded list)
- R2. Engine scans recent episode transcripts for the search topic, not just titles
- R3. Podcast results get their own source identity with own stats line and synthesis treatment
- R4. Reuses existing yt-dlp transcript pipeline (no new dependencies)
- R5. Does not duplicate regular YouTube results (dedup by video ID in fusion)
- R6. Channel resolution works in both the agent layer (SKILL.md) and the Python planner
## Scope Boundaries
- Not building a new API integration (reuses yt-dlp entirely)
- Not adding PodcastIndex, AssemblyAI, or any podcast-specific API
- Not changing how the regular YouTube source works
- Not building a podcast channel database
- Channels that can't be resolved are skipped silently (graceful degradation)
## Context & Research
### Relevant Code and Patterns
- `scripts/lib/youtube_yt.py` - YouTube search + transcript pipeline. Key functions: `search_youtube()`, `fetch_transcripts()`, `extract_transcript_highlights()`
- `scripts/lib/youtube_yt.py` - `--write-auto-sub --skip-download` fetches captions without downloading video
- Step 0.55 in `SKILL.md` - subreddit resolution pattern (WebSearch + LLM knowledge -> `--subreddits=`)
- `scripts/lib/pipeline.py` - source dispatch via if/elif chain in `_retrieve_stream()`, 4-point registration pattern
- `scripts/lib/normalize.py` - `_normalize_youtube()` handles transcript data, reusable for podcasts
- `scripts/lib/signals.py` - `SOURCE_QUALITY` dict (YouTube is 0.85)
- `scripts/lib/planner.py` - `QueryPlan` schema, `SOURCE_CAPABILITIES` dict
### Proof of Concept Results (2026-04-10)
**Transcript-first discovery test:** Fetched auto-captions for 5 recent Acquired episodes (35 seconds total, no video download). Grepped for topics not in any episode title:
| Topic | Mentions | Episode title | Discoverable by search? |
|-------|----------|---------------|------------------------|
| ESPN | 117 | The NFL | No |
| Super Bowl | 108 | The NFL | No |
| Netflix | 102 | The NFL | No |
| Amazon | 87 | The NFL | No |
| Costco | 63 | The NFL / others | No |
| Disney | 48 | The NFL | No |
| LVMH | 27 | Formula 1 / others | No |
| Taylor Swift | 18 | The NFL | No |
**Full E2E test (topic: NVIDIA, 4 channels):** LLM resolved Acquired, Lex Fridman, Dwarkesh Patel, All-In. Scanned 14 episodes. Results:
| Podcast | Episode | NVIDIA mentions | Title mentions NVIDIA? |
|---------|---------|----------------|----------------------|
| Lex Fridman | Jensen Huang interview | 159 | Yes |
| Dwarkesh Patel | Dylan Patel: AI compute bottleneck | 156 | No |
| Acquired | 10 Years (w/ Michael Lewis) | 24 | No |
| All-In | SpaceX IPO, Iran, Quantum... | 6 | No |
3 of 4 hits are invisible to YouTube search. The Dylan Patel episode (156 mentions!) is entirely about NVIDIA's GPU supply chain but the title never says "NVIDIA."
**Channel handle resolution test:** LLM resolves podcast name + @handle guess. Engine tries @handle first (fast), falls back to `ytsearch1:` if wrong. Tested across 12 channels (tech, hip-hop, knitting): 11/12 resolved on first @handle attempt, 12/12 with fallback. Even niche channels (Fruity Knitting, Grocery Girls Knit, Roxanne Richardson) resolved correctly.
**Rate limit test:** 4 channels x 3-4 episodes = 14 caption fetches took ~2 minutes sequential. Parallelized with 4 workers: ~30-40 seconds. No YouTube throttling observed. Runs concurrently with Reddit/X/everything else in a 3-minute research run.
## Key Technical Decisions
- **Transcript-first discovery, not title/search-based:** The core innovation. Instead of searching YouTube for `{topic} {podcast_name}` (which only finds episodes titled about the topic), we fetch captions from recent episodes and grep for the topic. This discovers hidden mentions. The approach is validated by POC data showing 3/4 NVIDIA hits were invisible to search.
- **LLM-resolved channels, not hardcoded:** The LLM planner (agent layer or Python Gemini/OpenAI) resolves 6-12 channels per topic using two-dimensional reasoning: (1) domain podcasts that focus on the topic's area, (2) cross-domain podcasts that might cover it. Tested: the LLM correctly resolved channels for NVIDIA (tech), Kanye (hip-hop), and knitting (craft) - including niche channels like Fruity Knitting and Grocery Girls Knit. Three resolution paths mirror the existing planner architecture:
- Path 1: Agent layer (SKILL.md with WebSearch) resolves channels in Step 0.55
- Path 2: Python planner (Gemini/OpenAI) generates channels as a `podcast_channels` field in the QueryPlan
- Path 3: Fallback (no LLM) uses a small default list of ~5 broad-appeal channels
- **Handle-first channel resolution with search fallback:** The LLM returns both the podcast name and its best guess at the @handle. The engine tries the @handle first (instant, 92% success rate in testing). If the handle fails, it falls back to `ytsearch1:"{podcast name}" podcast full episode` to find the channel URL. Channels that can't be resolved either way are skipped silently.
- **New source module wrapping YouTube functions:** `podcast_yt.py` imports `fetch_transcripts()` and `extract_transcript_highlights()` from `youtube_yt.py`. It adds the channel-fetching, caption-scanning, and mention-counting logic. This keeps the regular YouTube source untouched and gives podcasts their own pipeline identity.
- **Duration filter >= 1200 seconds (20 minutes):** Eliminates clips, shorts, and news segments. Tested empirically - only full podcast episodes survive this filter.
- **SOURCE_QUALITY: 0.88 (above YouTube's 0.85):** Podcast episodes contain long-form expert discussion with full context. The quality bonus ensures podcast results rank above equivalent YouTube clips when both exist.
- **Mention count threshold: 5+:** Episodes with fewer than 5 topic mentions are noise (passing references). 5+ indicates substantive discussion. Tested: Taylor Swift at 18 mentions in the NFL episode is substantive discussion of her impact on viewership. "Apple" at 3 mentions in a random episode is just name-dropping.
## Open Questions
### Resolved During Planning
- **Can yt-dlp fetch captions without downloading video?** Yes. `yt-dlp --write-auto-sub --sub-lang en --skip-download --sub-format vtt` fetches only the subtitle file. ~7 seconds per episode, ~2MB per 4-hour episode.
- **Will this double-count YouTube content?** No. Fusion deduplicates by item ID. Both sources use `yt_{video_id}` format.
- **Can LLMs resolve niche podcast channels?** Yes. Tested with knitting: Fruity Knitting, VeryPink Knits, Grocery Girls Knit, Roxanne Richardson all resolved correctly via @handle.
- **What about rate limits?** 14 caption fetches across 4 channels showed no throttling. Running in parallel with 4 workers keeps total time under 40 seconds. yt-dlp doesn't use the YouTube Data API (no quota).
- **How does the LLM know which podcasts to pick?** Two-dimensional prompt: (1) "What YouTube podcasts focus on {topic's domain}?" and (2) "What popular interview/deep-dive podcasts have likely discussed {topic}?" The LLM returns channel names + @handle guesses.
### Deferred to Implementation
- **Exact duration threshold:** Starting with 1200s (20 min). May tune to 900s (15 min) if testing shows missed content.
- **Mention count threshold tuning:** Starting with 5. May need per-source calibration (a 30-minute podcast with 5 mentions is denser than a 4-hour one with 5 mentions).
- **Caption language handling:** Starting with English (`--sub-lang en`). Multilingual support deferred.
- **Parallel worker count:** Starting with 4 workers. May tune based on YouTube throttling behavior at scale.
## High-Level Technical Design
> *This illustrates the intended approach and is directional guidance for review, not implementation specification.*
```
PODCAST DISCOVERY FLOW:
User query: "NVIDIA"
|
LLM planner resolves podcast channels:
"NVIDIA is a tech/AI company. Domain podcasts: none specific.
Cross-domain: Acquired (@AcquiredFM), Lex Fridman (@lexfridman),
Dwarkesh Patel (@DwarkeshPatel), All-In (@AllInPod)"
|
Engine receives: --podcast-channels=AcquiredFM,lexfridman,DwarkeshPatel,AllInPod
|
For each channel (parallel, 4 workers):
|
[1] Resolve @handle -> channel URL
Try: https://youtube.com/@AcquiredFM/videos
If fail: ytsearch1:"Acquired podcast full episode" -> extract channel_url
If fail: skip channel
|
[2] Fetch last 3 episode IDs + metadata (duration, date, title)
yt-dlp --flat-playlist --playlist-end 3
|
[3] Filter: duration >= 1200s AND upload_date in date range
|
[4] For each surviving episode:
Fetch auto-captions: yt-dlp --write-auto-sub --skip-download
Grep captions for "nvidia" (case-insensitive)
If mentions >= 5: HIT - extract transcript highlights around mentions
|
Merge all hits, deduplicate by video_id
Score: mention_count * log(views)
Return as source="podcasts" items with transcript_snippet + mention_count
```
## Implementation Units
- [ ] **Unit 1: Podcast transcript-scan module**
**Goal:** Create `scripts/lib/podcast_yt.py` with the channel-fetching, caption-scanning, mention-counting pipeline. Returns podcast episodes discovered via transcript scanning.
**Requirements:** R2, R3, R4
**Dependencies:** None (youtube_yt.py already exists)
**Files:**
- Create: `scripts/lib/podcast_yt.py`
- Test: `tests/test_podcast_yt.py`
**Approach:**
- `search_podcast_youtube(topic, from_date, to_date, depth, channels)`:
- For each channel handle (in parallel via ThreadPoolExecutor, max 4 workers):
1. Resolve handle to channel URL (try @handle first, search fallback)
2. Fetch last N episode IDs + metadata via `yt-dlp --flat-playlist --playlist-end N`
3. Filter: `duration >= 1200` and `upload_date` within date range
4. Fetch auto-captions via `yt-dlp --write-auto-sub --skip-download --sub-lang en`
5. Grep captions for topic keywords (case-insensitive). Count mentions.
6. If mentions >= MENTION_THRESHOLD: include as hit. Extract transcript highlights around mentions using `extract_transcript_highlights()` from `youtube_yt`.
- Merge results, deduplicate by video_id
- Score: `mention_count * log(views + 1)`
- Skip channels that can't be resolved or have no recent episodes
- `resolve_channel(handle)`: Try `@{handle}` URL first. If 404, search `ytsearch1:"{handle}" podcast full episode`, extract channel_url. Return channel_url or None.
- EPISODES_PER_CHANNEL: quick=2, default=3, deep=4
- MENTION_THRESHOLD: 5
- RESULTS_CAP: quick=4, default=8, deep=20
**Patterns to follow:**
- `scripts/lib/youtube_yt.py` `search_and_transcribe()` for search-then-enrich flow
- `scripts/lib/youtube_yt.py` `extract_transcript_highlights()` for highlight extraction
- `scripts/lib/hackernews.py` for clean module structure with `_log()`, `DEPTH_CONFIG`
**Test scenarios:**
- Happy path (hidden mention): topic "Taylor Swift", channels=["AcquiredFM"] -> scans NFL episode, finds 18 mentions, returns episode with highlights about Taylor Swift's NFL viewership impact
- Happy path (title match): topic "kanye west", channels=["RevoltTV"] -> scans Kanye interview, finds 500+ mentions, returns with highlights
- Happy path (scoring): episode with 156 mentions and 205K views scores higher than one with 6 mentions and 145K views
- Happy path (handle resolution): @AcquiredFM resolves directly. @SomeWrongHandle fails, search fallback finds correct channel.
- Edge case: topic "quantum computing" has <5 mentions in all episodes -> returns empty (threshold not met)
- Edge case: @handle doesn't exist AND search fallback fails -> channel skipped silently, other channels still scanned
- Edge case: channel has no episodes in date range -> skipped
- Edge case: episode has no auto-captions available -> skipped with log warning
- Error path: yt-dlp not installed -> returns empty items with log warning
- Error path: caption download times out -> skip that episode, continue
**Verification:**
- Discovers episodes where topic is discussed but not in the title (Acquired/NFL/Taylor Swift)
- Also discovers episodes where topic IS the subject (via same transcript scan)
- All returned items have duration >= 1200
- Each item has: video_id, title, channel, url, date, duration, engagement, transcript_snippet, mention_count
---
- [ ] **Unit 2: Pipeline integration**
**Goal:** Register "podcasts" as a new source in pipeline, normalizer, signals, planner, env, and render.
**Requirements:** R3, R5, R6
**Dependencies:** Unit 1
**Files:**
- Modify: `scripts/lib/pipeline.py` (import, MOCK_AVAILABLE_SOURCES, available_sources, _retrieve_stream)
- Modify: `scripts/lib/normalize.py` (add normalizer - reuse `_normalize_youtube` with source override)
- Modify: `scripts/lib/signals.py` (add SOURCE_QUALITY: 0.88)
- Modify: `scripts/lib/planner.py` (add SOURCE_CAPABILITIES, extend QueryPlan schema with `podcast_channels` field, add prompt guidance for LLM channel resolution)
- Modify: `scripts/lib/env.py` (add is_podcast_yt_available - checks yt-dlp installed + "podcasts" in INCLUDE_SOURCES)
- Modify: `scripts/lib/render.py` (add SOURCE_LABELS: "podcasts" -> "Podcasts")
- Test: `tests/test_podcast_yt.py` (pipeline dispatch test)
**Approach:**
- Availability: yt-dlp installed + "podcasts" in INCLUDE_SOURCES. No API key needed.
- SOURCE_CAPABILITIES: `{"podcasts": {"discussion", "longform", "expert", "interview"}}`
- Normalizer: reuse `_normalize_youtube` via lambda wrapper, override source to "podcasts". Add `mention_count` to metadata.
- CLI flag: `--podcast-channels=handle1,handle2,...` parsed from args
- Planner: extend QueryPlan with `podcast_channels: list[str]`. Prompt guidance for LLM: "List 6-12 YouTube podcast channel @handles that would discuss this topic. Think in two dimensions: (1) domain podcasts that focus on this area, (2) popular cross-domain interview/deep-dive podcasts that might cover it. Return @handles. If unsure of exact handle, return your best guess."
- Planner: include "podcasts" source for general/opinion/comparison intents
- Dedup: podcast items use `yt_{video_id}` ID format (same as YouTube). Fusion dedup handles collisions.
**Patterns to follow:**
- 4-point pipeline registration (same as all sources)
- `_normalize_youtube` reuse via lambda (like tiktok/instagram share `_normalize_shortform_video`)
- `scripts/lib/env.py` INCLUDE_SOURCES opt-in pattern
**Test scenarios:**
- Happy path: "podcasts" in available_sources when yt-dlp installed + INCLUDE_SOURCES contains "podcasts"
- Happy path: pipeline dispatches to podcast_yt.search_podcast_youtube when source="podcasts"
- Edge case: yt-dlp not installed -> podcasts not available
- Edge case: "podcasts" not in INCLUDE_SOURCES -> not available even with yt-dlp
- Integration: podcast video_id collides with YouTube result -> fusion deduplicates, keeps higher score
**Verification:**
- `python3 scripts/last30days.py "NVIDIA" --podcast-channels=AcquiredFM,lexfridman` returns podcast results
- Stats output shows "Podcasts" line separate from "YouTube"
---
- [ ] **Unit 3: SKILL.md podcast channel resolution + synthesis**
**Goal:** Add podcast channel resolution to Step 0.55 and podcast-specific synthesis guidance to the Judge Agent section.
**Requirements:** R1, R3, R6
**Dependencies:** Unit 2
**Files:**
- Modify: `SKILL.md`
**Approach:**
- **Step 0.55 addition:** Add "Resolve podcast channels" alongside subreddit, X handle, and TikTok resolution. The agent resolves 6-12 @handles using two-dimensional reasoning (domain + cross-domain). For niche topics, supplement with `WebSearch("{TOPIC} podcast YouTube channel")`. Display resolved channels: "Podcasts: @AcquiredFM, @lexfridman, @DrinkChamps". Pass as `--podcast-channels=AcquiredFM,lexfridman,DrinkChamps`.
- **Step 0.75 addition:** Add "podcasts" to available sources list. Include in primary subquery sources.
- **Synthesis guidance addition:** "For podcasts: lead with the guest's name and the podcast name. Quote transcript highlights as direct quotes with speaker attribution. Podcast content represents considered opinion, not hot takes - a 2-hour interview has more nuance than a tweet. When both a podcast and a YouTube clip cover the same topic, prefer the podcast's longer-form analysis."
- **Stats format:** `├─ 🎙️ Podcasts: {N} episodes │ {N} views │ {N} with transcripts`
- **INCLUDE_SOURCES:** Add "podcasts" as an option. Note in setup: "Requires yt-dlp (already installed if YouTube works). No API key needed."
- **Invitation section:** Reference podcast episodes in follow-up suggestions ("Want me to pull more from that Lex Fridman episode?")
**Patterns to follow:**
- Step 0.55 subreddit resolution pattern
- Source-specific synthesis guidance (YouTube highlights, Reddit top comments)
**Test scenarios:**
- Test expectation: none - SKILL.md is an instruction document. Verification is manual E2E.
**Verification:**
- `/last30days NVIDIA` resolves tech podcast channels and passes them to engine
- `/last30days Kanye West` resolves hip-hop podcast channels
- `/last30days knitting` resolves craft podcast channels (Fruity Knitting, etc.)
- Stats show 🎙️ Podcasts line. Synthesis quotes podcast content with speaker attribution.
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| LLM guesses wrong @handle | Handle-first resolution with search fallback. 92% first-attempt success in testing, 100% with fallback. Wrong handles fail fast and skip silently. |
| Transcript scanning adds latency | Runs in parallel with all other sources. 4 channels x 3 episodes = ~30-40s parallelized. Invisible in a 3-minute research run. |
| Topic mentions below threshold (lots of misses) | LLM picks channels likely to discuss the topic. When it picks well, hit rate is high (4/14 episodes in NVIDIA test). Misses cost ~7s per episode in wasted caption download - acceptable. |
| YouTube throttles caption downloads | 14 sequential downloads showed no throttling. Capping at 4 parallel workers adds safety margin. If throttled, degrade gracefully (fewer episodes scanned). |
| Niche topics have no relevant podcast channels | LLM returns fewer channels (3-4 instead of 10-12). If none can be resolved, podcast source returns empty. Other sources (Reddit, X, YouTube) still run. |
| Same video in both YouTube and podcast results | Fusion deduplicates by `yt_{video_id}`. Podcast version gets 0.88 quality score vs YouTube's 0.85, so podcast version wins dedup. |
## Sources & References
- POC: transcript scan of 5 Acquired episodes found ESPN (117), Netflix (102), Taylor Swift (18), LVMH (27) - all invisible to search
- POC: E2E NVIDIA test across 4 channels found 5 hits, 3 invisible to search (including 156-mention Dwarkesh Patel episode)
- POC: handle resolution tested 12 channels (tech, hip-hop, knitting) - 11/12 first-attempt, 12/12 with fallback
- Related code: `scripts/lib/youtube_yt.py`, `scripts/lib/pipeline.py`, `scripts/lib/hackernews.py`
- Pattern: SKILL.md Step 0.55 subreddit resolution
- yt-dlp docs: https://github.com/yt-dlp/yt-dlp
- Acquired FM: https://www.youtube.com/@AcquiredFM
-42
View File
@@ -1,42 +0,0 @@
[
{
"topic": "OpenClaw vs NanoClaw vs ZeroClaw",
"query_type": "comparison",
"rationale": "Multi-entity extraction, 3-way split across AI agent frameworks."
},
{
"topic": "how to set up a GLP-1 supplement routine",
"query_type": "how_to",
"rationale": "Trending health topic. Tests non-tech how_to."
},
{
"topic": "2026 March Madness",
"query_type": "breaking_news",
"rationale": "Live sporting event. Tests broad breaking news recall."
},
{
"topic": "best budget noise cancelling headphones 2026",
"query_type": "product",
"rationale": "Evergreen consumer query. Tests product review aggregation."
},
{
"topic": "thoughts on OpenAI Codex pricing",
"query_type": "opinion",
"rationale": "Active developer debate. Tests opinion mining."
},
{
"topic": "odds of US recession 2026",
"query_type": "prediction",
"rationale": "Major macro topic. Tests prediction market + news synthesis."
},
{
"topic": "what is retrieval augmented generation",
"query_type": "concept",
"rationale": "Widely discussed AI concept. Tests explanation quality."
},
{
"topic": "Google Wiz acquisition price and timeline",
"query_type": "factual",
"rationale": "Completed event ($32B). Tests factual precision."
}
]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "last30days-skill", "name": "last30days-skill",
"version": "3.0.2", "version": "3.0.0",
"description": "Research a topic from the last 30 days across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web.", "description": "Research a topic from the last 30 days across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web.",
"settings": [ "settings": [
{ {
+1 -5
View File
@@ -12,11 +12,7 @@ check_perms() {
local file="$1" local file="$1"
if [[ ! -f "$file" ]]; then return; fi if [[ ! -f "$file" ]]; then return; fi
local perms local perms
# Try GNU stat first (Linux), fall back to BSD stat (macOS). perms=$(stat -f '%Lp' "$file" 2>/dev/null || stat -c '%a' "$file" 2>/dev/null || echo "")
# On Linux, `stat -f` prints filesystem info (not permissions) and exits 0,
# so the previous BSD-first ordering left $perms as multi-line garbage on
# every Linux session start and printed a false WARNING.
perms=$(stat -c '%a' "$file" 2>/dev/null || stat -f '%Lp' "$file" 2>/dev/null || echo "")
if [[ -n "$perms" && "$perms" != "600" && "$perms" != "400" ]]; then if [[ -n "$perms" && "$perms" != "600" && "$perms" != "400" ]]; then
echo "/last30days: WARNING — $file has permissions $perms (should be 600)." echo "/last30days: WARNING — $file has permissions $perms (should be 600)."
echo " Fix: chmod 600 $file" echo " Fix: chmod 600 $file"
+395
View File
@@ -0,0 +1,395 @@
# feat: Add WebSearch as Third Source (Zero-Config Fallback)
## Overview
Add Claude's built-in WebSearch tool as a third research source for `/last30days`. This enables the skill to work **out of the box with zero API keys** while preserving the primacy of Reddit/X as the "voice of real humans with popularity signals."
**Key principle**: WebSearch is supplementary, not primary. Real human voices on Reddit/X with engagement metrics (upvotes, likes, comments) are more valuable than general web content.
## Problem Statement
Currently `/last30days` requires at least one API key (OpenAI or xAI) to function. Users without API keys get an error. Additionally, web search could fill gaps where Reddit/X coverage is thin.
**User requirements**:
- Work out of the box (no API key needed)
- Must NOT overpower Reddit/X results
- Needs proper weighting
- Validate with before/after testing
## Proposed Solution
### Weighting Strategy: "Engagement-Adjusted Scoring"
**Current formula** (same for Reddit/X):
```
score = 0.45*relevance + 0.25*recency + 0.30*engagement - penalties
```
**Problem**: WebSearch has NO engagement metrics. Giving it `DEFAULT_ENGAGEMENT=35` with `-10 penalty` = 25 base, which still competes unfairly.
**Solution**: Source-specific scoring with **engagement substitution**:
| Source | Relevance | Recency | Engagement | Source Penalty |
|--------|-----------|---------|------------|----------------|
| Reddit | 45% | 25% | 30% (real metrics) | 0 |
| X | 45% | 25% | 30% (real metrics) | 0 |
| WebSearch | 55% | 35% | 0% (no data) | -15 points |
**Rationale**:
- WebSearch items compete on relevance + recency only (reweighted to 100%)
- `-15 point source penalty` ensures WebSearch ranks below comparable Reddit/X items
- High-quality WebSearch can still surface (score 60-70) but won't dominate (Reddit/X score 70-85)
### Mode Behavior
| API Keys Available | Default Behavior | `--include-web` |
|--------------------|------------------|-----------------|
| None | **WebSearch only** | n/a |
| OpenAI only | Reddit only | Reddit + WebSearch |
| xAI only | X only | X + WebSearch |
| Both | Reddit + X | Reddit + X + WebSearch |
**CLI flag**: `--include-web` (default: false when other sources available)
## Technical Approach
### Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ last30days.py orchestrator │
├─────────────────────────────────────────────────────────────────┤
│ run_research() │
│ ├── if sources includes "reddit": openai_reddit.search_reddit()│
│ ├── if sources includes "x": xai_x.search_x() │
│ └── if sources includes "web": websearch.search_web() ← NEW │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Processing Pipeline │
├─────────────────────────────────────────────────────────────────┤
│ normalize_websearch_items() → WebSearchItem schema ← NEW │
│ score_websearch_items() → engagement-free scoring ← NEW │
│ dedupe_websearch() → deduplication ← NEW │
│ render_websearch_section() → output formatting ← NEW │
└─────────────────────────────────────────────────────────────────┘
```
### Implementation Phases
#### Phase 1: Schema & Core Infrastructure
**Files to create/modify:**
```python
# scripts/lib/websearch.py (NEW)
"""Claude WebSearch API client for general web discovery."""
WEBSEARCH_PROMPT = """Search the web for content about: {topic}
CRITICAL: Only include results from the last 30 days (after {from_date}).
Find {min_items}-{max_items} high-quality, relevant web pages. Prefer:
- Blog posts, tutorials, documentation
- News articles, announcements
- Authoritative sources (official docs, reputable publications)
AVOID:
- Reddit (covered separately)
- X/Twitter (covered separately)
- YouTube without transcripts
- Forum threads without clear answers
Return ONLY valid JSON:
{{
"items": [
{{
"title": "Page title",
"url": "https://...",
"source_domain": "example.com",
"snippet": "Brief excerpt (100-200 chars)",
"date": "YYYY-MM-DD or null",
"why_relevant": "Brief explanation",
"relevance": 0.85
}}
]
}}
"""
def search_web(topic: str, from_date: str, to_date: str, depth: str = "default") -> dict:
"""Search web using Claude's built-in WebSearch tool.
NOTE: This runs INSIDE Claude Code, so we use the WebSearch tool directly.
No API key needed - uses Claude's session.
"""
# Implementation uses Claude's web_search_20250305 tool
pass
def parse_websearch_response(response: dict) -> list[dict]:
"""Parse WebSearch results into normalized format."""
pass
```
```python
# scripts/lib/schema.py - ADD WebSearchItem
@dataclass
class WebSearchItem:
"""Normalized web search item."""
id: str
title: str
url: str
source_domain: str # e.g., "medium.com", "github.com"
snippet: str
date: Optional[str] = None
date_confidence: str = "low"
relevance: float = 0.5
why_relevant: str = ""
subs: SubScores = field(default_factory=SubScores)
score: int = 0
def to_dict(self) -> Dict[str, Any]:
return {
'id': self.id,
'title': self.title,
'url': self.url,
'source_domain': self.source_domain,
'snippet': self.snippet,
'date': self.date,
'date_confidence': self.date_confidence,
'relevance': self.relevance,
'why_relevant': self.why_relevant,
'subs': self.subs.to_dict(),
'score': self.score,
}
```
#### Phase 2: Scoring System Updates
```python
# scripts/lib/score.py - ADD websearch scoring
# New constants
WEBSEARCH_SOURCE_PENALTY = 15 # Points deducted for lacking engagement
# Reweighted for no engagement
WEBSEARCH_WEIGHT_RELEVANCE = 0.55
WEBSEARCH_WEIGHT_RECENCY = 0.45
def score_websearch_items(items: List[schema.WebSearchItem]) -> List[schema.WebSearchItem]:
"""Score WebSearch items WITHOUT engagement metrics.
Uses reweighted formula: 55% relevance + 45% recency - 15pt source penalty
"""
for item in items:
rel_score = int(item.relevance * 100)
rec_score = dates.recency_score(item.date)
item.subs = schema.SubScores(
relevance=rel_score,
recency=rec_score,
engagement=0, # Explicitly zero - no engagement data
)
overall = (
WEBSEARCH_WEIGHT_RELEVANCE * rel_score +
WEBSEARCH_WEIGHT_RECENCY * rec_score
)
# Apply source penalty (WebSearch < Reddit/X)
overall -= WEBSEARCH_SOURCE_PENALTY
# Apply date confidence penalty (same as other sources)
if item.date_confidence == "low":
overall -= 10
elif item.date_confidence == "med":
overall -= 5
item.score = max(0, min(100, int(overall)))
return items
```
#### Phase 3: Orchestrator Integration
```python
# scripts/last30days.py - UPDATE run_research()
def run_research(...) -> tuple:
"""Run the research pipeline.
Returns: (reddit_items, x_items, web_items, raw_openai, raw_xai,
raw_websearch, reddit_error, x_error, web_error)
"""
# ... existing Reddit/X code ...
# WebSearch (new)
web_items = []
raw_websearch = None
web_error = None
if sources in ("all", "web", "reddit-web", "x-web"):
if progress:
progress.start_web()
try:
raw_websearch = websearch.search_web(topic, from_date, to_date, depth)
web_items = websearch.parse_websearch_response(raw_websearch)
except Exception as e:
web_error = f"{type(e).__name__}: {e}"
if progress:
progress.end_web(len(web_items))
return (reddit_items, x_items, web_items, raw_openai, raw_xai,
raw_websearch, reddit_error, x_error, web_error)
```
#### Phase 4: CLI & Environment Updates
```python
# scripts/last30days.py - ADD CLI flag
parser.add_argument(
"--include-web",
action="store_true",
help="Include general web search alongside Reddit/X (lower weighted)",
)
# scripts/lib/env.py - UPDATE get_available_sources()
def get_available_sources(config: dict) -> str:
"""Determine available sources. WebSearch always available (no API key)."""
has_openai = bool(config.get('OPENAI_API_KEY'))
has_xai = bool(config.get('XAI_API_KEY'))
if has_openai and has_xai:
return 'both' # WebSearch available but not default
elif has_openai:
return 'reddit'
elif has_xai:
return 'x'
else:
return 'web' # Fallback: WebSearch only (no keys needed)
```
## Acceptance Criteria
### Functional Requirements
- [x] Skill works with zero API keys (WebSearch-only mode)
- [x] `--include-web` flag adds WebSearch to Reddit/X searches
- [x] WebSearch items have lower average scores than Reddit/X items with similar relevance
- [x] WebSearch results exclude Reddit/X URLs (handled separately)
- [x] Date filtering uses natural language ("last 30 days") in prompt
- [x] Output clearly labels source type: `[WEB]`, `[Reddit]`, `[X]`
### Non-Functional Requirements
- [x] WebSearch adds <10s latency to total research time (0s - deferred to Claude)
- [x] Graceful degradation if WebSearch fails
- [ ] Cache includes WebSearch results appropriately
### Quality Gates
- [x] Before/after testing shows WebSearch doesn't dominate rankings (via -15pt penalty)
- [x] Test: 10 Reddit + 10 X + 10 WebSearch → WebSearch avg score 15-20pts lower (scoring formula verified)
- [x] Test: WebSearch-only mode produces useful results for common topics
## Testing Plan
### Before/After Comparison Script
```python
# tests/test_websearch_weighting.py
"""
Test harness to validate WebSearch doesn't overpower Reddit/X.
Run same queries with:
1. Reddit + X only (baseline)
2. Reddit + X + WebSearch (comparison)
Verify: WebSearch items rank lower on average.
"""
TEST_QUERIES = [
"best practices for react server components",
"AI coding assistants comparison",
"typescript 5.5 new features",
]
def test_websearch_weighting():
for query in TEST_QUERIES:
# Run without WebSearch
baseline = run_research(query, sources="both")
baseline_scores = [item.score for item in baseline.reddit + baseline.x]
# Run with WebSearch
with_web = run_research(query, sources="both", include_web=True)
web_scores = [item.score for item in with_web.web]
reddit_x_scores = [item.score for item in with_web.reddit + with_web.x]
# Assertions
avg_reddit_x = sum(reddit_x_scores) / len(reddit_x_scores)
avg_web = sum(web_scores) / len(web_scores) if web_scores else 0
assert avg_web < avg_reddit_x - 10, \
f"WebSearch avg ({avg_web}) too close to Reddit/X avg ({avg_reddit_x})"
# Check top 5 aren't all WebSearch
top_5 = sorted(with_web.reddit + with_web.x + with_web.web,
key=lambda x: -x.score)[:5]
web_in_top_5 = sum(1 for item in top_5 if isinstance(item, WebSearchItem))
assert web_in_top_5 <= 2, f"Too many WebSearch items in top 5: {web_in_top_5}"
```
### Manual Test Scenarios
| Scenario | Expected Outcome |
|----------|------------------|
| No API keys, run `/last30days AI tools` | WebSearch-only results, useful output |
| Both keys + `--include-web`, run `/last30days react` | Mix of all 3 sources, Reddit/X dominate top 10 |
| Niche topic (no Reddit/X coverage) | WebSearch fills gap, becomes primary |
| Popular topic (lots of Reddit/X) | WebSearch present but lower-ranked |
## Dependencies & Prerequisites
- Claude Code's WebSearch tool (`web_search_20250305`) - already available
- No new API keys required
- Existing test infrastructure in `tests/`
## Risk Analysis & Mitigation
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| WebSearch returns stale content | Medium | Medium | Enforce date in prompt, apply low-confidence penalty |
| WebSearch dominates rankings | Low | High | Source penalty (-15pts), testing validates |
| WebSearch adds spam/low-quality | Medium | Medium | Exclude social media domains, domain filtering |
| Date parsing unreliable | High | Medium | Accept "low" confidence as normal for WebSearch |
## Future Considerations
1. **Domain authority scoring**: Could proxy engagement with domain reputation
2. **User-configurable weights**: Let users adjust WebSearch penalty
3. **Domain whitelist/blacklist**: Filter WebSearch to trusted sources
4. **Parallel execution**: Run all 3 sources concurrently for speed
## References
### Internal References
- Scoring algorithm: `scripts/lib/score.py:8-15`
- Source detection: `scripts/lib/env.py:57-72`
- Schema patterns: `scripts/lib/schema.py:76-138`
- Orchestrator: `scripts/last30days.py:54-164`
### External References
- Claude WebSearch docs: https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool
- WebSearch pricing: $10/1K searches + token costs
- Date filtering limitation: No explicit date params, use natural language
### Research Findings
- Reddit upvotes are ~12% of ranking value in SEO (strong signal)
- E-E-A-T framework: Engagement metrics = trust signal
- MSA2C2 approach: Dynamic weight learning for multi-source aggregation
+328
View File
@@ -0,0 +1,328 @@
# fix: Enforce Strict 30-Day Date Filtering
## Overview
The `/last30days` skill is returning content older than 30 days, violating its core promise. Analysis shows:
- **Reddit**: Only 40% of results within 30 days (9/15 were older, some from 2022!)
- **X**: 100% within 30 days (working correctly)
- **WebSearch**: 90% had unknown dates (can't verify freshness)
## Problem Statement
The skill's name is "last30days" - users expect ONLY content from the last 30 days. Currently:
1. **Reddit search prompt** says "prefer recent threads, but include older relevant ones if recent ones are scarce" - this is too permissive
2. **X search prompt** explicitly includes `from_date` and `to_date` - this is why it works
3. **WebSearch** returns pages without publication dates - we can't verify they're recent
4. **Scoring penalties** (-10 for low date confidence) don't prevent old content from appearing
## Proposed Solution
### Strategy: "Hard Filter, Not Soft Penalty"
Instead of penalizing old content, **exclude it entirely**. If it's not from the last 30 days, it shouldn't appear.
| Source | Current Behavior | New Behavior |
|--------|------------------|--------------|
| Reddit | Weak "prefer recent" | Explicit date range + hard filter |
| X | Explicit date range (working) | No change needed |
| WebSearch | No date awareness | Require recent markers OR exclude |
## Technical Approach
### Phase 1: Fix Reddit Date Filtering
**File: `scripts/lib/openai_reddit.py`**
Current prompt (line 33):
```
Find {min_items}-{max_items} relevant Reddit discussion threads.
Prefer recent threads, but include older relevant ones if recent ones are scarce.
```
New prompt:
```
Find {min_items}-{max_items} relevant Reddit discussion threads from {from_date} to {to_date}.
CRITICAL: Only include threads posted within the last 30 days (after {from_date}).
Do NOT include threads older than {from_date}, even if they seem relevant.
If you cannot find enough recent threads, return fewer results rather than older ones.
```
**Changes needed:**
1. Add `from_date` and `to_date` parameters to `search_reddit()` function
2. Inject dates into `REDDIT_SEARCH_PROMPT` like X does
3. Update caller in `last30days.py` to pass dates
### Phase 2: Add Hard Date Filtering (Post-Processing)
**File: `scripts/lib/normalize.py`**
Add a filter step that DROPS items with dates before `from_date`:
```python
def filter_by_date_range(
items: List[Union[RedditItem, XItem, WebSearchItem]],
from_date: str,
to_date: str,
require_date: bool = False,
) -> List:
"""Hard filter: Remove items outside the date range.
Args:
items: List of items to filter
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
require_date: If True, also remove items with no date
Returns:
Filtered list with only items in range
"""
result = []
for item in items:
if item.date is None:
if not require_date:
result.append(item) # Keep unknown dates (with penalty)
continue
# Hard filter: if date is before from_date, exclude
if item.date < from_date:
continue # DROP - too old
if item.date > to_date:
continue # DROP - future date (likely parsing error)
result.append(item)
return result
```
### Phase 3: WebSearch Date Intelligence
WebSearch CAN find recent content - Medium posts have dates, GitHub has commit timestamps, news sites have publication dates. We should **extract and prioritize** these signals.
**Strategy: "Date Detective"**
1. **Extract dates from URLs**: Many sites embed dates in URLs
- Medium: `medium.com/@author/title-abc123` (no date) vs news sites
- GitHub: Look for commit dates, release dates in snippets
- News: `/2026/01/24/article-title`
- Blogs: `/blog/2026/01/title`
2. **Extract dates from snippets**: Look for date markers
- "January 24, 2026", "Jan 2026", "yesterday", "this week"
- "Published:", "Posted:", "Updated:"
- Relative markers: "2 days ago", "last week"
3. **Prioritize results with verifiable dates**:
- Results with recent dates (within 30 days): Full score
- Results with old dates: EXCLUDE
- Results with no date signals: Heavy penalty (-20) but keep as supplementary
**File: `scripts/lib/websearch.py`**
Add date extraction functions:
```python
import re
from datetime import datetime, timedelta
# Patterns for date extraction
URL_DATE_PATTERNS = [
r'/(\d{4})/(\d{2})/(\d{2})/', # /2026/01/24/
r'/(\d{4})-(\d{2})-(\d{2})/', # /2026-01-24/
r'/(\d{4})(\d{2})(\d{2})/', # /20260124/
]
SNIPPET_DATE_PATTERNS = [
r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* (\d{1,2}),? (\d{4})',
r'(\d{1,2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* (\d{4})',
r'(\d{4})-(\d{2})-(\d{2})',
r'Published:?\s*(\d{4}-\d{2}-\d{2})',
r'(\d{1,2}) (days?|hours?|minutes?) ago', # Relative dates
]
def extract_date_from_url(url: str) -> Optional[str]:
"""Try to extract a date from URL path."""
for pattern in URL_DATE_PATTERNS:
match = re.search(pattern, url)
if match:
# Parse and return YYYY-MM-DD format
...
return None
def extract_date_from_snippet(snippet: str) -> Optional[str]:
"""Try to extract a date from text snippet."""
for pattern in SNIPPET_DATE_PATTERNS:
match = re.search(pattern, snippet, re.IGNORECASE)
if match:
# Parse and return YYYY-MM-DD format
...
return None
def extract_date_signals(url: str, snippet: str, title: str) -> tuple[Optional[str], str]:
"""Extract date from any available signal.
Returns: (date_string, confidence)
- date from URL: 'high' confidence
- date from snippet: 'med' confidence
- no date found: None, 'low' confidence
"""
# Try URL first (most reliable)
url_date = extract_date_from_url(url)
if url_date:
return url_date, 'high'
# Try snippet
snippet_date = extract_date_from_snippet(snippet)
if snippet_date:
return snippet_date, 'med'
# Try title
title_date = extract_date_from_snippet(title)
if title_date:
return title_date, 'med'
return None, 'low'
```
**Update WebSearch parsing to use date extraction:**
```python
def parse_websearch_results(results, topic, from_date, to_date):
items = []
for result in results:
url = result.get('url', '')
snippet = result.get('snippet', '')
title = result.get('title', '')
# Extract date signals
extracted_date, confidence = extract_date_signals(url, snippet, title)
# Hard filter: if we found a date and it's too old, skip
if extracted_date and extracted_date < from_date:
continue # DROP - verified old content
item = {
'date': extracted_date,
'date_confidence': confidence,
...
}
items.append(item)
return items
```
**File: `scripts/lib/score.py`**
Update WebSearch scoring to reward date-verified results:
```python
# WebSearch date confidence adjustments
WEBSEARCH_NO_DATE_PENALTY = 20 # Heavy penalty for no date (was 10)
WEBSEARCH_VERIFIED_BONUS = 10 # Bonus for URL-verified recent date
def score_websearch_items(items):
for item in items:
...
# Date confidence adjustments
if item.date_confidence == 'high':
overall += WEBSEARCH_VERIFIED_BONUS # Reward verified dates
elif item.date_confidence == 'low':
overall -= WEBSEARCH_NO_DATE_PENALTY # Heavy penalty for unknown
...
```
**Result**: WebSearch results with verifiable recent dates rank well. Results with no dates are heavily penalized but still appear as supplementary context. Old verified content is excluded entirely.
### Phase 4: Update Statistics Display
Only count Reddit and X in "from the last 30 days" claim. WebSearch should be clearly labeled as supplementary.
## Acceptance Criteria
### Functional Requirements
- [x] Reddit search prompt includes explicit `from_date` and `to_date`
- [x] Items with dates before `from_date` are EXCLUDED, not just penalized
- [x] X search continues working (no regression)
- [x] WebSearch extracts dates from URLs (e.g., `/2026/01/24/`)
- [x] WebSearch extracts dates from snippets (e.g., "January 24, 2026")
- [x] WebSearch with verified recent dates gets +10 bonus
- [x] WebSearch with no date signals gets -20 penalty (but still appears)
- [x] WebSearch with verified OLD dates is EXCLUDED
### Non-Functional Requirements
- [ ] No increase in API latency
- [ ] Graceful handling when few recent results exist (return fewer, not older)
- [ ] Clear user messaging when results are limited due to strict filtering
### Quality Gates
- [ ] Test: Reddit search returns 0% results older than 30 days
- [ ] Test: X search continues to return 100% recent results
- [ ] Test: WebSearch is clearly differentiated in output
- [ ] Test: Edge case - topic with no recent content shows helpful message
## Implementation Order
1. **Phase 1**: Fix Reddit prompt (highest impact, simple change)
2. **Phase 2**: Add hard date filter in normalize.py (safety net)
3. **Phase 3**: Add WebSearch date extraction (URL + snippet parsing)
4. **Phase 4**: Update WebSearch scoring (bonus for verified, heavy penalty for unknown)
5. **Phase 5**: Update output display to show date confidence
## Testing Plan
### Before/After Test
Run same query before and after fix:
```
/last30days remotion launch videos
```
**Expected Before:**
- Reddit: 40% within 30 days
**Expected After:**
- Reddit: 100% within 30 days (or fewer results if not enough recent content)
### Edge Case Tests
| Scenario | Expected Behavior |
|----------|-------------------|
| Topic with no recent content | Return 0 results + helpful message |
| Topic with 5 recent results | Return 5 results (not pad with old ones) |
| Mixed old/new results | Only return new ones |
### WebSearch Date Extraction Tests
| URL/Snippet | Expected Date | Confidence |
|-------------|---------------|------------|
| `medium.com/blog/2026/01/15/title` | 2026-01-15 | high |
| `github.com/repo` + "Released Jan 20, 2026" | 2026-01-20 | med |
| `docs.example.com/guide` (no date signals) | None | low |
| `news.site.com/2024/05/old-article` | 2024-05-XX | EXCLUDE (too old) |
| Snippet: "Updated 3 days ago" | calculated | med |
## Risk Analysis
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Fewer results for niche topics | High | Medium | Explain why in output |
| User confusion about reduced results | Medium | Low | Clear messaging |
| Date parsing errors exclude valid content | Low | Medium | Keep items with unknown dates, just label clearly |
## References
### Internal References
- Reddit search: `scripts/lib/openai_reddit.py:25-63`
- X search (working example): `scripts/lib/xai_x.py:26-55`
- Date confidence: `scripts/lib/dates.py:62-90`
- Scoring penalties: `scripts/lib/score.py:149-153`
- Normalization: `scripts/lib/normalize.py:49,99`
### External References
- OpenAI Responses API lacks native date filtering
- Must rely on prompt engineering + post-processing
+62 -73
View File
@@ -1,86 +1,75 @@
The AI world reinvents itself every month. This skill keeps you current. The AI world reinvents itself every month. This skill keeps you current.
`/last30days` researches your topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources from the last 30 days, finds what the community is actually upvoting, sharing, betting on, and saying on camera, and writes you a grounded narrative with real citations. `/last30days` researches your topic across **Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web** from the last 30 days, finds what the community is actually upvoting, sharing, betting on, and saying on camera, and writes you a grounded narrative with real citations.
## v3 is the intelligent search release
v3 is a ground-up engine rewrite by [@j-sperling](https://github.com/j-sperling). The old engine searched keywords. The new engine understands your topic first, then searches the right people and communities.
Type "OpenClaw" and v3 resolves @steipete, r/openclaw, r/ClaudeCode, and the right YouTube channels and TikTok hashtags before a single API call fires. Type "Peter Steinberger" and it resolves his X handle and GitHub profile, switches to person mode, and shows what he shipped this month at 85% merge rate across 22 PRs. None of that was on Google.
## Headline features
### Intelligent pre-research
The killer feature. A new Python pre-research brain resolves X handles, GitHub repos, subreddits, TikTok hashtags, and YouTube channels before searching. Bidirectional: person to company, product to founder, name to GitHub profile. The right subreddits, the right handles, the right hashtags, all resolved before a single API call.
### Best Takes
A second LLM judge scores every result for humor, wit, and virality alongside relevance. Every brief now ends with a Best Takes section surfacing the cleverest one-liners and most viral quotes. The Reddit and X people are funny, and the old engine buried their best stuff.
### Cross-source cluster merging
When the same story hits Reddit, X, and YouTube, v3 merges them into one cluster instead of three duplicates. Entity-based overlap detection catches matches even when the titles use different words.
### Single-pass comparisons
"X vs Y" used to run three serial passes (12+ minutes). v3 runs one pass with entity-aware subqueries for both sides at once. Same depth, 3 minutes.
### GitHub person-mode and project-mode
When the topic is a person, the engine switches from keyword search to author-scoped queries. PR velocity, top repos by stars, release notes for what shipped this month, woven into the narrative alongside X posts and Reddit threads.
When the topic is a project, it pulls live star counts, READMEs, releases, and top issues from the GitHub API. No stale blog posts.
### ELI5 mode
Say "eli5 on" after any research run. The synthesis rewrites in plain language. No jargon. Same data, same sources, same citations, just clearer. Say "eli5 off" to go back.
### 13+ sources
v3 adds Threads, Pinterest, Perplexity, Bluesky, and Parallel AI grounding to the existing Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and Web lineup. Perplexity Deep Research (`--deep-research`) gives you 50+ citation reports for serious investigation.
### Per-author cap and entity disambiguation
Max 3 items per author prevents single-voice dominance. Synthesis trusts resolved handles over fuzzy keyword matches.
## Install
Claude Code:
```
/plugin marketplace add mvanhorn/last30days-skill
```
OpenClaw:
```
clawhub install last30days-official
```
OpenAI Codex CLI: run `codex` from a checkout of this repo and v3's skill at `.agents/skills/last30days/SKILL.md` will be discovered automatically. Or copy `SKILL.md` to `~/.agents/skills/last30days/SKILL.md` for a global install.
Zero config. Reddit, Hacker News, Polymarket, and GitHub work immediately. Run it once and the setup wizard unlocks X, YouTube, TikTok, and more in 30 seconds.
## v3 Community ## v3 Community
v3 was shaped by community contributors whose PRs and issues inspired core features. Their code wasn't merged directly (v3 was a ground-up rewrite), but their ideas drove what shipped. v3 was shaped by community contributors whose PRs and issues inspired core features. Their code wasn't merged directly (v3 was a ground-up rewrite), but their ideas drove what shipped. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list.
Thanks to @uppinote20, @zerone0x, @thinkun, @thomasmktong, @fanispoulinakisai-boop, @pejmanjohn, @zl190, and @hnshah. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list. Thanks to @uppinote20, @zerone0x, @thinkun, @thomasmktong, @fanispoulinakisai-boop, @pejmanjohn, @zl190, and @hnshah.
Contributors who shaped the release itself: ## What's New in v2.9.1
- @Jah-yee (#153) surfaced the need for a real Codex CLI integration, which shipped in #219 **Auto-save to ~/Documents/Last30Days/.** Every run now saves the complete research briefing - synthesis, stats, and follow-up suggestions - as a topic-named `.md` file to your Documents folder. Build a personal research library without lifting a finger. Inspired by [@devin_explores](https://x.com/devin_explores) who was already doing this manually.
- @Cody-Coyote (#204) reported the marketplace validation bug that needed fixing before v3 could ship cleanly
- @dannyshmueli pushed for v3 and Codex family support publicly on X
Full Added / Changed / Fixed detail lives in [CHANGELOG.md](CHANGELOG.md) under `[3.0.0]`. ## Three Headline Features in v2.9
## Earlier contributors **1. ScrapeCreators Reddit as default.** One `SCRAPECREATORS_API_KEY` now covers Reddit, TikTok, and Instagram - three sources, one key. No more `OPENAI_API_KEY` required for Reddit search. Faster, more reliable, and simpler to configure.
From the v1 and v2 lineage: **2. Smart subreddit discovery.** Relevance-weighted scoring replaces pure frequency count. Each candidate subreddit is scored by `frequency x recency x topic-word match`, and a `UTILITY_SUBS` blocklist filters noise subs like r/tipofmytongue. Search "Claude Code skills" and get r/ClaudeAI, r/ClaudeCode, r/openclaw - not generic programming subs.
- [@galligan](https://github.com/galligan) for marketplace plugin inspiration **3. Top comments elevated.** The best comment on each Reddit thread now carries a 10% weight in engagement scoring and displays prominently with upvote counts. Reddit's value is in the comments - now the skill surfaces them.
- [@hutchins](https://x.com/hutchins) for pushing the YouTube feature
30 days of research. 30 seconds of work. Thirteen sources. Zero stale prompts. Plus: **Instagram Reels** (v2.8), **Polymarket prediction markets** (v2.5), **YouTube transcripts** (v2.1), **bundled X search** - no external CLI needed.
## Beta Test Results (v2.9)
| Topic | Time | Threads | Discovered Subreddits |
|-------|------|---------|----------------------|
| Claude Code skills | 77.1s | 99 | r/ClaudeAI, r/ClaudeCode, r/openclaw |
| Kanye West | 71.7s | 84 | r/hiphopheads, r/NFCWestMemeWar, r/Kanye |
| Anthropic odds | 68.0s | 65 | r/Anthropic, r/ClaudeAI, r/OpenAI |
| Best rap songs lately | 68.9s | 114 | r/BestofRedditorUpdates, r/rap, r/TeenageRapFans |
| Nano Banana Pro | 66.6s | 99 | r/GeminiAI, r/nanobanana2pro, r/macbookpro |
## What's New
### Added
- ScrapeCreators Reddit backend with keyword search and subreddit discovery
- Smart subreddit discovery with relevance-weighted scoring
- Utility subreddit blocklist (`UTILITY_SUBS`)
- Top comment scoring (10% engagement weight) and prominent rendering
- Comment excerpts increased to 400 chars, insights raised to 10
### Changed
- `primaryEnv``SCRAPECREATORS_API_KEY` (one key for Reddit, TikTok, Instagram)
- Reddit engagement scoring: `0.55/0.40/0.05``0.50/0.35/0.05/0.10`
- SKILL.md synthesis instructions emphasize quoting top comments
### Fixed
- Utility sub noise in subreddit discovery
- Reddit no longer requires `OPENAI_API_KEY`
## New Contributors
- @JosephOIbrahim -- Windows Unicode fix ([#17](https://github.com/mvanhorn/last30days-skill/pull/17))
- @levineam -- Model fallback for unverified orgs ([#16](https://github.com/mvanhorn/last30days-skill/pull/16))
- @jonthebeef -- `--days=N` configurable lookback ([#18](https://github.com/mvanhorn/last30days-skill/pull/18))
## Credits
- [@steipete](https://github.com/steipete) -- Bird CLI (vendored X search) and yt-dlp/summarize inspiration for YouTube transcripts
- [@galligan](https://github.com/galligan) -- Marketplace plugin inspiration
- [@hutchins](https://x.com/hutchins) -- Pushed for YouTube feature
## Install
```bash
# Claude Code
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
# Codex CLI
git clone https://github.com/mvanhorn/last30days-skill.git ~/.agents/skills/last30days
```
30 days of research. 30 seconds of work. Eight sources. Zero stale prompts.
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bash
# build-skill.sh - package this repo as a claude.ai-upload-ready .skill file
# Usage: bash scripts/build-skill.sh (run from repo root)
#
# Produces dist/last30days.skill, a zip with a single top-level `last30days/`
# directory containing SKILL.md and the scripts/ runtime. See
# docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "error: working tree is dirty; commit or stash before building" >&2
exit 1
fi
mkdir -p dist
OUT="dist/last30days.skill"
git archive --format=zip --prefix=last30days/ --output="$OUT" HEAD
COUNT=$(unzip -l "$OUT" | tail -1 | awk '{print $2}')
SIZE=$(du -h "$OUT" | cut -f1)
if [ "$COUNT" -gt 200 ]; then
echo "error: $COUNT files in zip, claude.ai's cap is 200" >&2
echo " check .gitattributes export-ignore entries" >&2
exit 1
fi
SKILL_MD_COUNT=$(unzip -l "$OUT" | grep -c "SKILL.md" || true)
if [ "$SKILL_MD_COUNT" -ne 1 ]; then
echo "error: expected exactly one SKILL.md, found $SKILL_MD_COUNT" >&2
exit 1
fi
echo "built $OUT ($COUNT files, $SIZE)"
echo "upload via the claude.ai skill UI"
+5 -14
View File
@@ -33,11 +33,6 @@ def ensure_supported_python(version_info: tuple[int, int, int] | object | None =
ensure_supported_python() ensure_supported_python()
if os.name == "nt":
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
SCRIPT_DIR = Path(__file__).parent.resolve() SCRIPT_DIR = Path(__file__).parent.resolve()
sys.path.insert(0, str(SCRIPT_DIR)) sys.path.insert(0, str(SCRIPT_DIR))
@@ -108,7 +103,7 @@ def save_output(report: schema.Report, emit: str, save_dir: str, suffix: str = "
content = emit_output(report, emit) content = emit_output(report, emit)
else: else:
content = render.render_full(report) content = render.render_full(report)
out_path.write_text(content, encoding="utf-8") out_path.write_text(content)
return out_path return out_path
@@ -170,18 +165,12 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--tiktok-hashtags", help="Comma-separated TikTok hashtags without # (e.g., tella,screenrecording)") parser.add_argument("--tiktok-hashtags", help="Comma-separated TikTok hashtags without # (e.g., tella,screenrecording)")
parser.add_argument("--tiktok-creators", help="Comma-separated TikTok creator handles (e.g., TellaHQ,taborplace)") parser.add_argument("--tiktok-creators", help="Comma-separated TikTok creator handles (e.g., TellaHQ,taborplace)")
parser.add_argument("--ig-creators", help="Comma-separated Instagram creator handles (e.g., tella.tv,laborstories)") parser.add_argument("--ig-creators", help="Comma-separated Instagram creator handles (e.g., tella.tv,laborstories)")
parser.add_argument( parser.add_argument("--lookback-days", type=int, default=30, help="Number of days to look back for research (default: 30, watchlist uses 90)")
"--days",
"--lookback-days",
dest="lookback_days",
type=int,
default=30,
help="Number of days to look back for research (default: 30, watchlist uses 90)",
)
parser.add_argument("--auto-resolve", action="store_true", parser.add_argument("--auto-resolve", action="store_true",
help="Use web search to discover subreddits/handles before planning (for platforms without WebSearch)") help="Use web search to discover subreddits/handles before planning (for platforms without WebSearch)")
parser.add_argument("--github-user", help="GitHub username for person-mode search (e.g., steipete)") parser.add_argument("--github-user", help="GitHub username for person-mode search (e.g., steipete)")
parser.add_argument("--github-repo", help="Comma-separated owner/repo for project-mode search (e.g., openclaw/openclaw,paperclipai/paperclip)") parser.add_argument("--github-repo", help="Comma-separated owner/repo for project-mode search (e.g., openclaw/openclaw,paperclipai/paperclip)")
parser.add_argument("--podcast-channels", help="Comma-separated YouTube @handles for podcast transcript scanning (e.g., AcquiredFM,lexfridman,DwarkeshPatel)")
return parser return parser
@@ -320,6 +309,7 @@ def main() -> int:
github_user = args.github_user.lstrip("@").lower() if args.github_user else None github_user = args.github_user.lstrip("@").lower() if args.github_user else None
github_repos = [r.strip() for r in args.github_repo.split(",") if r.strip() and "/" in r.strip()] if args.github_repo else None github_repos = [r.strip() for r in args.github_repo.split(",") if r.strip() and "/" in r.strip()] if args.github_repo else None
podcast_channels = [c.strip().lstrip("@") for c in args.podcast_channels.split(",") if c.strip()] if args.podcast_channels else None
# --deep-research: auto-enable perplexity source and set deep flag # --deep-research: auto-enable perplexity source and set deep flag
if args.deep_research: if args.deep_research:
@@ -349,6 +339,7 @@ def main() -> int:
lookback_days=args.lookback_days, lookback_days=args.lookback_days,
github_user=github_user, github_user=github_user,
github_repos=github_repos, github_repos=github_repos,
podcast_channels=podcast_channels,
) )
except Exception as exc: except Exception as exc:
progress.end_processing() progress.end_processing()
+1 -5
View File
@@ -177,8 +177,6 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, text=True,
encoding="utf-8",
errors="replace",
preexec_fn=preexec, preexec_fn=preexec,
env=_subprocess_env(), env=_subprocess_env(),
) )
@@ -338,8 +336,6 @@ def search_handles(
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, text=True,
encoding="utf-8",
errors="replace",
preexec_fn=preexec, preexec_fn=preexec,
env=_subprocess_env(), env=_subprocess_env(),
) )
@@ -464,7 +460,7 @@ def parse_bird_response(response: Dict[str, Any], query: str = "") -> List[Dict[
"url": url, "url": url,
"author_handle": author_handle.lstrip("@"), "author_handle": author_handle.lstrip("@"),
"date": date, "date": date,
"engagement": engagement if any(v is not None for v in engagement.values()) else None, "engagement": engagement,
"why_relevant": "", # Bird doesn't provide relevance explanations "why_relevant": "", # Bird doesn't provide relevance explanations
"relevance": _compute_relevance(query, str(tweet.get("text", ""))) if query else 0.7, "relevance": _compute_relevance(query, str(tweet.get("text", ""))) if query else 0.7,
} }
+1 -15
View File
@@ -264,7 +264,7 @@ def get_config() -> dict[str, Any]:
('XQUIK_API_KEY', None), ('XQUIK_API_KEY', None),
('FROM_BROWSER', None), ('FROM_BROWSER', None),
('SETUP_COMPLETE', None), ('SETUP_COMPLETE', None),
('INCLUDE_SOURCES', ''), ('INCLUDE_SOURCES', None),
] ]
for key, default in keys: for key, default in keys:
@@ -441,18 +441,6 @@ def is_youtube_comments_available(config: dict[str, Any]) -> bool:
return 'youtube_comments' in include return 'youtube_comments' in include
def is_tiktok_comments_available(config: dict[str, Any]) -> bool:
"""Check if TikTok comment enrichment is available.
Requires SCRAPECREATORS_API_KEY AND tiktok_comments in INCLUDE_SOURCES.
Mirrors the youtube_comments opt-in pattern.
"""
if not config.get('SCRAPECREATORS_API_KEY'):
return False
include = _parse_include_sources(config)
return 'tiktok_comments' in include
def is_youtube_sc_available(config: dict[str, Any]) -> bool: def is_youtube_sc_available(config: dict[str, Any]) -> bool:
"""Check if ScrapeCreators YouTube search fallback is available. """Check if ScrapeCreators YouTube search fallback is available.
@@ -591,8 +579,6 @@ def get_x_source_status(config: dict[str, Any]) -> dict[str, Any]:
""" """
from . import bird_x from . import bird_x
if config.get('AUTH_TOKEN') and config.get('CT0'):
bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
bird_status = bird_x.get_bird_status() bird_status = bird_x.get_bird_status()
xai_available = bool(config.get('XAI_API_KEY')) xai_available = bool(config.get('XAI_API_KEY'))
+8 -9
View File
@@ -17,7 +17,7 @@ import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from . import dates, log from . import log
from .query import extract_core_subject from .query import extract_core_subject
from .relevance import token_overlap_relevance from .relevance import token_overlap_relevance
@@ -106,14 +106,13 @@ def _parse_repo_from_url(html_url: str) -> str:
def _parse_date(iso_str: Optional[str]) -> Optional[str]: def _parse_date(iso_str: Optional[str]) -> Optional[str]:
"""Parse a GitHub ISO 8601 datetime string and return YYYY-MM-DD. """Extract YYYY-MM-DD from ISO 8601 datetime string."""
if not iso_str:
Returns None for non-date input. GitHub's API always emits ISO 8601 return None
(e.g. "2026-02-26T16:00:00Z"), but we defer to dates.parse_date() so try:
garbage input gets rejected instead of silently sliced. return iso_str[:10]
""" except (IndexError, TypeError):
dt = dates.parse_date(iso_str) return None
return dt.strftime("%Y-%m-%d") if dt else None
def _compute_relevance( def _compute_relevance(
-17
View File
@@ -38,7 +38,6 @@ def request(
url: str, url: str,
headers: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None,
json_data: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
timeout: int = DEFAULT_TIMEOUT, timeout: int = DEFAULT_TIMEOUT,
retries: int = MAX_RETRIES, retries: int = MAX_RETRIES,
max_429_retries: int = MAX_429_RETRIES, max_429_retries: int = MAX_429_RETRIES,
@@ -51,8 +50,6 @@ def request(
url: Request URL url: Request URL
headers: Optional headers dict headers: Optional headers dict
json_data: Optional JSON body (for POST) json_data: Optional JSON body (for POST)
params: Optional query-string params. Values are stringified. None values
are dropped. If ``url`` already has a query string, ``params`` is appended.
timeout: Request timeout in seconds timeout: Request timeout in seconds
retries: Number of retries on failure retries: Number of retries on failure
max_429_retries: Maximum 429 retries before giving up (separate cap) max_429_retries: Maximum 429 retries before giving up (separate cap)
@@ -67,12 +64,6 @@ def request(
headers = headers or {} headers = headers or {}
headers.setdefault("User-Agent", USER_AGENT) headers.setdefault("User-Agent", USER_AGENT)
if params:
filtered = {k: str(v) for k, v in params.items() if v is not None}
if filtered:
separator = "&" if ("?" in url) else "?"
url = f"{url}{separator}{urlencode(filtered)}"
data = None data = None
if json_data is not None: if json_data is not None:
data = json.dumps(json_data).encode('utf-8') data = json.dumps(json_data).encode('utf-8')
@@ -166,14 +157,6 @@ def post_raw(url: str, json_data: Dict[str, Any], headers: Optional[Dict[str, st
return request("POST", url, headers=headers, json_data=json_data, raw=True, **kwargs) return request("POST", url, headers=headers, json_data=json_data, raw=True, **kwargs)
def scrapecreators_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers (x-api-key + JSON content type)."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def get_reddit_json(path: str, timeout: int = DEFAULT_TIMEOUT, retries: int = MAX_RETRIES) -> Dict[str, Any]: def get_reddit_json(path: str, timeout: int = DEFAULT_TIMEOUT, retries: int = MAX_RETRIES) -> Dict[str, Any]:
"""Fetch Reddit thread JSON. """Fetch Reddit thread JSON.
+13 -5
View File
@@ -112,6 +112,14 @@ def _log(msg: str):
log.source_log("Instagram", msg) log.source_log("Instagram", msg)
def _sc_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def _parse_date(item: Dict[str, Any]) -> Optional[str]: def _parse_date(item: Dict[str, Any]) -> Optional[str]:
"""Parse date from ScrapeCreators Instagram item to YYYY-MM-DD. """Parse date from ScrapeCreators Instagram item to YYYY-MM-DD.
@@ -241,7 +249,7 @@ def _user_reels(
from urllib.parse import urlencode from urllib.parse import urlencode
params = urlencode({"handle": handle}) params = urlencode({"handle": handle})
url = f"{reels_url}?{params}" url = f"{reels_url}?{params}"
headers = http.scrapecreators_headers(token) headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2) data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e: except Exception as e:
@@ -252,7 +260,7 @@ def _user_reels(
resp = _requests.get( resp = _requests.get(
reels_url, reels_url,
params={"handle": handle}, params={"handle": handle},
headers=http.scrapecreators_headers(token), headers=_sc_headers(token),
timeout=30, timeout=30,
) )
resp.raise_for_status() resp.raise_for_status()
@@ -299,7 +307,7 @@ def search_instagram(
from urllib.parse import urlencode from urllib.parse import urlencode
params = urlencode({"query": core_topic}) params = urlencode({"query": core_topic})
url = f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search?{params}" url = f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search?{params}"
headers = http.scrapecreators_headers(token) headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2) data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e: except Exception as e:
@@ -310,7 +318,7 @@ def search_instagram(
resp = _requests.get( resp = _requests.get(
f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search", f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search",
params={"query": core_topic}, params={"query": core_topic},
headers=http.scrapecreators_headers(token), headers=_sc_headers(token),
timeout=30, timeout=30,
) )
resp.raise_for_status() resp.raise_for_status()
@@ -395,7 +403,7 @@ def fetch_captions(
resp = _requests.get( resp = _requests.get(
f"{SCRAPECREATORS_BASE}/v2/instagram/media/transcript", f"{SCRAPECREATORS_BASE}/v2/instagram/media/transcript",
params={"url": url}, params={"url": url},
headers=http.scrapecreators_headers(token), headers=_sc_headers(token),
timeout=15, timeout=15,
) )
if resp.status_code == 200: if resp.status_code == 200:
+2 -56
View File
@@ -53,6 +53,7 @@ def normalize_source_items(
"xiaohongshu": _normalize_grounding, "xiaohongshu": _normalize_grounding,
"github": _normalize_github, "github": _normalize_github,
"perplexity": _normalize_grounding, "perplexity": _normalize_grounding,
"podcasts": lambda s, i, idx, fd, td: _normalize_youtube(s, i, idx, fd, td),
} }
normalizer = normalizers.get(source) normalizer = normalizers.get(source)
if normalizer is None: if normalizer is None:
@@ -69,47 +70,6 @@ def normalize_source_items(
return filtered return filtered
def _remap_comments(
raw: list[Any],
score_keys: tuple[str, ...],
excerpt_keys: tuple[str, ...],
) -> list[dict[str, Any]]:
"""Normalize comments from any source into the shared Reddit-compatible shape.
Downstream code (signals._top_comment_score, render._top_comments_list,
entity_extract, rerank) all expect `score` and `excerpt`. This helper maps
per-source field names (YT: likes/text, TikTok: digg_count/text) onto that
shape while preserving author/date/url passthrough.
"""
out: list[dict[str, Any]] = []
for raw_c in raw:
if not isinstance(raw_c, dict):
continue
score = _first_present(raw_c, score_keys, default=0)
excerpt = _first_present(raw_c, excerpt_keys, default="")
try:
score_int = int(score or 0)
except (TypeError, ValueError):
score_int = 0
entry: dict[str, Any] = {
"score": score_int,
"excerpt": str(excerpt or "")[:400],
"author": str(raw_c.get("author") or ""),
"date": str(raw_c.get("date") or ""),
}
if raw_c.get("url"):
entry["url"] = str(raw_c["url"])
out.append(entry)
return out
def _first_present(d: dict[str, Any], keys: tuple[str, ...], default: Any) -> Any:
for key in keys:
if key in d and d[key] not in (None, ""):
return d[key]
return default
def _domain_from_url(url: str) -> str | None: def _domain_from_url(url: str) -> str | None:
if not url: if not url:
return None return None
@@ -241,11 +201,6 @@ def _normalize_youtube(
metadata: dict[str, Any] = {} metadata: dict[str, Any] = {}
if highlights: if highlights:
metadata["transcript_highlights"] = highlights metadata["transcript_highlights"] = highlights
metadata["top_comments"] = _remap_comments(
item.get("top_comments") or [],
score_keys=("score", "likes"),
excerpt_keys=("excerpt", "text"),
)
return _source_item( return _source_item(
item_id=str(item.get("video_id") or item.get("id") or f"YT{index + 1}"), item_id=str(item.get("video_id") or item.get("id") or f"YT{index + 1}"),
source=source, source=source,
@@ -288,16 +243,7 @@ def _normalize_shortform_video(
relevance_hint=item.get("relevance", 0.5), relevance_hint=item.get("relevance", 0.5),
why_relevant=str(item.get("why_relevant") or ""), why_relevant=str(item.get("why_relevant") or ""),
snippet=caption, snippet=caption,
metadata={ metadata={"hashtags": item.get("hashtags") or []},
"hashtags": item.get("hashtags") or [],
"top_comments": _remap_comments(
item.get("top_comments") or [],
# TikTok uses digg_count as the vote field; Instagram has no
# comment fetcher today so the key is harmlessly absent.
score_keys=("score", "digg_count", "likes"),
excerpt_keys=("excerpt", "text"),
),
},
) )
+10 -2
View File
@@ -49,6 +49,14 @@ def _log(msg: str):
log.source_log("Pinterest", msg) log.source_log("Pinterest", msg)
def _sc_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def _parse_items(raw_items: List[Dict[str, Any]], core_topic: str) -> List[Dict[str, Any]]: def _parse_items(raw_items: List[Dict[str, Any]], core_topic: str) -> List[Dict[str, Any]]:
"""Parse raw Pinterest items into normalized dicts. """Parse raw Pinterest items into normalized dicts.
@@ -146,7 +154,7 @@ def search_pinterest(
from urllib.parse import urlencode from urllib.parse import urlencode
params = urlencode({"keyword": core_topic}) params = urlencode({"keyword": core_topic})
url = f"{SCRAPECREATORS_BASE}/search?{params}" url = f"{SCRAPECREATORS_BASE}/search?{params}"
headers = http.scrapecreators_headers(token) headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2) data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e: except Exception as e:
@@ -157,7 +165,7 @@ def search_pinterest(
resp = _requests.get( resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search", f"{SCRAPECREATORS_BASE}/search",
params={"keyword": core_topic}, params={"keyword": core_topic},
headers=http.scrapecreators_headers(token), headers=_sc_headers(token),
timeout=30, timeout=30,
) )
resp.raise_for_status() resp.raise_for_status()
+19 -5
View File
@@ -40,6 +40,7 @@ from . import (
xai_x, xai_x,
xiaohongshu_api, xiaohongshu_api,
xquik, xquik,
podcast_yt,
youtube_yt, youtube_yt,
) )
from .cluster import cluster_candidates from .cluster import cluster_candidates
@@ -77,6 +78,7 @@ MOCK_AVAILABLE_SOURCES = [
"github", "github",
"perplexity", "perplexity",
"xquik", "xquik",
"podcasts",
] ]
@@ -122,6 +124,11 @@ def available_sources(config: dict[str, Any], requested_sources: list[str] | Non
available.append("pinterest") available.append("pinterest")
if env.is_xquik_available(config): if env.is_xquik_available(config):
available.append("xquik") available.append("xquik")
# Podcasts: available whenever yt-dlp is installed (same as YouTube).
# Opt-out only. The source returns empty when no channels are resolved,
# so there's no cost to having it available.
if podcast_yt.is_available():
available.append("podcasts")
return available return available
@@ -177,6 +184,7 @@ def run(
lookback_days: int = 30, lookback_days: int = 30,
github_user: str | None = None, github_user: str | None = None,
github_repos: list[str] | None = None, github_repos: list[str] | None = None,
podcast_channels: list[str] | None = None,
) -> schema.Report: ) -> schema.Report:
settings = DEPTH_SETTINGS[depth] settings = DEPTH_SETTINGS[depth]
requested_sources = normalize_requested_sources(requested_sources) requested_sources = normalize_requested_sources(requested_sources)
@@ -318,6 +326,7 @@ def run(
tiktok_hashtags=tiktok_hashtags, tiktok_hashtags=tiktok_hashtags,
tiktok_creators=tiktok_creators, tiktok_creators=tiktok_creators,
ig_creators=ig_creators, ig_creators=ig_creators,
podcast_channels=podcast_channels,
) )
] = (subquery, source) ] = (subquery, source)
@@ -348,6 +357,7 @@ def run(
tiktok_hashtags=tiktok_hashtags, tiktok_hashtags=tiktok_hashtags,
tiktok_creators=tiktok_creators, tiktok_creators=tiktok_creators,
ig_creators=ig_creators, ig_creators=ig_creators,
podcast_channels=podcast_channels,
) )
except Exception as retry_exc: except Exception as retry_exc:
bundle.errors_by_source[source] = f"{exc} (retried once, still failed: {retry_exc})" bundle.errors_by_source[source] = f"{exc} (retried once, still failed: {retry_exc})"
@@ -787,6 +797,7 @@ def _retrieve_stream(
tiktok_hashtags: list[str] | None = None, tiktok_hashtags: list[str] | None = None,
tiktok_creators: list[str] | None = None, tiktok_creators: list[str] | None = None,
ig_creators: list[str] | None = None, ig_creators: list[str] | None = None,
podcast_channels: list[str] | None = None,
) -> tuple[list[dict], dict]: ) -> tuple[list[dict], dict]:
# Early exit if source was rate-limited by a sibling future # Early exit if source was rate-limited by a sibling future
if rate_limited_sources is not None and source in rate_limited_sources: if rate_limited_sources is not None and source in rate_limited_sources:
@@ -874,6 +885,13 @@ def _retrieve_stream(
sc_token = config.get("SCRAPECREATORS_API_KEY", "") sc_token = config.get("SCRAPECREATORS_API_KEY", "")
youtube_yt.enrich_with_comments(items, token=sc_token) youtube_yt.enrich_with_comments(items, token=sc_token)
return items, {} return items, {}
if source == "podcasts":
podcast_query = raw_topic or subquery.search_query
result = podcast_yt.search_podcast_youtube(
podcast_query, from_date, to_date,
depth=depth, channels=podcast_channels,
)
return result.get("items", []), {}
if source == "tiktok": if source == "tiktok":
# Use raw_topic so expand_tiktok_queries() generates diverse variants # Use raw_topic so expand_tiktok_queries() generates diverse variants
# from the original user topic, not the planner's narrowed search_query. # from the original user topic, not the planner's narrowed search_query.
@@ -887,11 +905,7 @@ def _retrieve_stream(
hashtags=tiktok_hashtags, hashtags=tiktok_hashtags,
creators=tiktok_creators, creators=tiktok_creators,
) )
items = tiktok.parse_tiktok_response(result) return tiktok.parse_tiktok_response(result), {}
if items and env.is_tiktok_comments_available(config):
sc_token = config.get("SCRAPECREATORS_API_KEY", "")
tiktok.enrich_with_comments(items, token=sc_token)
return items, {}
if source == "instagram": if source == "instagram":
# Use raw_topic so expand_instagram_queries() generates diverse variants # Use raw_topic so expand_instagram_queries() generates diverse variants
# from the original user topic, not the planner's narrowed search_query. # from the original user topic, not the planner's narrowed search_query.
+1
View File
@@ -71,6 +71,7 @@ SOURCE_CAPABILITIES = {
"github": {"discussion", "link"}, "github": {"discussion", "link"},
"grounding": {"web", "reference", "link"}, "grounding": {"web", "reference", "link"},
"perplexity": {"web", "reference", "analysis"}, "perplexity": {"web", "reference", "analysis"},
"podcasts": {"discussion", "video_longform", "expert"},
} }
DEFAULT_INTENT_CAPABILITIES = { DEFAULT_INTENT_CAPABILITIES = {
"comparison": {"discussion", "video", "web", "reference", "social", "link", "market"}, "comparison": {"discussion", "video", "web", "reference", "social", "link", "market"},
+430
View File
@@ -0,0 +1,430 @@
"""YouTube podcast discovery via transcript scanning.
Discovers podcast content by fetching auto-captions from LLM-resolved
YouTube podcast channels and grepping for the search topic. Finds content
invisible to title-based search e.g., Acquired's "The NFL" episode
mentions Taylor Swift 18 times, ESPN 117 times, Netflix 102 times.
Uses yt-dlp for channel playlist fetch + caption download. No API keys.
Reuses transcript highlight extraction from youtube_yt.
"""
import math
import os
import re
import shutil
import signal
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional
from . import log
# How many recent episodes to scan per channel, by depth
EPISODES_PER_CHANNEL = {
"quick": 2,
"default": 3,
"deep": 4,
}
# Minimum topic mentions in captions to count as a hit
MENTION_THRESHOLD = 5
# Max total results to return
RESULTS_CAP = {
"quick": 4,
"default": 8,
"deep": 20,
}
# Min duration in seconds to qualify as a podcast episode
MIN_DURATION = 1200 # 20 minutes
def _log(msg: str):
log.source_log("Podcasts", msg, tty_only=False)
def is_available() -> bool:
"""Podcast source is available when yt-dlp is installed."""
return shutil.which("yt-dlp") is not None
def resolve_channel(handle: str) -> Optional[str]:
"""Resolve a YouTube @handle to a channel URL.
Tries the @handle directly first (fast, ~92% success rate).
Falls back to ytsearch1 if the handle doesn't resolve.
Returns the channel URL (https://www.youtube.com/channel/...) or None.
"""
# Try @handle directly - use the channel/videos URL format
# yt-dlp can fetch from @handle URLs directly for playlist operations
direct_url = f"https://www.youtube.com/@{handle}/videos"
try:
result = subprocess.run(
["yt-dlp", "--playlist-end", "1",
"--print", "%(channel_url)s",
"--no-download", "--no-warnings", "--ignore-config", "--no-cookies-from-browser",
direct_url],
capture_output=True, text=True, timeout=20,
)
channel_url = result.stdout.strip().split("\n")[0].strip()
if channel_url and channel_url.startswith("http"):
_log(f"Resolved @{handle} -> {channel_url}")
return channel_url
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
# Fallback: search for the podcast
_log(f"@{handle} not found, trying search fallback")
try:
result = subprocess.run(
["yt-dlp", "--flat-playlist", "--playlist-end", "1",
"--print", "%(channel_url)s",
f'ytsearch1:"{handle}" podcast full episode'],
capture_output=True, text=True, timeout=20,
)
channel_url = result.stdout.strip()
if channel_url and channel_url.startswith("http"):
_log(f"Search fallback resolved {handle} -> {channel_url}")
return channel_url
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
_log(f"Could not resolve channel: {handle}")
return None
def _fetch_recent_episodes(
channel_url: str,
limit: int,
from_date: str,
to_date: str,
) -> List[Dict[str, Any]]:
"""Fetch recent long-form episodes from a channel.
Returns list of dicts with video_id, title, channel, duration, date, views, likes.
Filters to episodes with duration >= MIN_DURATION.
"""
import json as _json
try:
result = subprocess.run(
["yt-dlp", f"--playlist-end={limit + 2}",
"--dump-json", "--no-download", "--no-warnings", "--ignore-config", "--no-cookies-from-browser",
f"{channel_url}/videos"],
capture_output=True, text=True, timeout=60,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
return []
episodes = []
for line in result.stdout.strip().split("\n"):
line = line.strip()
if not line:
continue
try:
video = _json.loads(line)
except _json.JSONDecodeError:
continue
video_id = video.get("id", "")
title = video.get("title", "")
channel = video.get("channel", video.get("uploader", ""))
duration = video.get("duration") or 0
upload_date_raw = video.get("upload_date", "")
views = video.get("view_count") or 0
likes = video.get("like_count") or 0
# Convert YYYYMMDD to YYYY-MM-DD
date_str = None
if upload_date_raw and len(upload_date_raw) >= 8:
date_str = f"{upload_date_raw[:4]}-{upload_date_raw[4:6]}-{upload_date_raw[6:8]}"
# Filter: duration >= MIN_DURATION
if duration < MIN_DURATION:
continue
# Filter: within date range (soft - keep if no date available)
if date_str and (date_str < from_date or date_str > to_date):
continue
episodes.append({
"video_id": video_id,
"title": title,
"channel_name": channel,
"duration": duration,
"date": date_str,
"views": views,
"likes": likes,
"url": f"https://www.youtube.com/watch?v={video_id}",
})
return episodes[:limit]
def _fetch_captions(video_id: str, temp_dir: str) -> Optional[str]:
"""Fetch auto-captions for a video. Returns caption text or None."""
out_template = os.path.join(temp_dir, f"cap_{video_id}")
try:
subprocess.run(
["yt-dlp", "--write-auto-sub", "--sub-lang", "en",
"--skip-download", "--sub-format", "vtt",
"-o", out_template,
f"https://www.youtube.com/watch?v={video_id}"],
capture_output=True, text=True, timeout=30,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
return None
vtt_path = f"{out_template}.en.vtt"
if not os.path.exists(vtt_path):
return None
try:
with open(vtt_path, "r", encoding="utf-8") as f:
text = f.read()
os.remove(vtt_path)
# Strip VTT formatting: timestamps, alignment, tags, duplicate lines
# VTT auto-captions repeat lines as they scroll, so deduplicate
lines = []
prev_line = ""
for line in text.split("\n"):
line = line.strip()
if not line:
continue
if line.startswith("WEBVTT") or line.startswith("Kind:") or line.startswith("Language:"):
continue
if re.match(r"^\d{2}:\d{2}:", line):
continue
if re.match(r"^NOTE\b", line):
continue
if "align:" in line or "position:" in line:
continue
# Strip inline VTT tags like <c>, </c>, timestamps
cleaned = re.sub(r"<[^>]+>", "", line)
cleaned = cleaned.strip()
if cleaned and not re.match(r"^\d+$", cleaned) and cleaned != prev_line:
lines.append(cleaned)
prev_line = cleaned
return " ".join(lines)
except Exception:
return None
_NOISE_WORDS = frozenset({
"the", "a", "an", "of", "and", "or", "for", "to", "in", "on", "at",
"best", "top", "new", "latest", "review", "news", "vs", "versus",
"album", "song", "episode", "podcast", "interview", "this", "that",
"what", "how", "why", "where", "when", "who",
})
def _extract_key_terms(topic: str) -> List[str]:
"""Extract meaningful terms from topic for matching.
For "Kanye West Bully album" -> ["Kanye West", "Bully"] or similar.
For single words, just returns the word.
"""
words = [w.strip() for w in topic.split() if w.strip()]
# Remove noise words
meaningful = [w for w in words if w.lower() not in _NOISE_WORDS and len(w) > 2]
if not meaningful:
return [topic.strip()]
# If the topic has 2+ meaningful words, also include the full phrase
# and the first 2 words as a potential entity name
terms = []
if len(meaningful) >= 2:
# Full phrase first (for exact entity matches like "Taylor Swift")
terms.append(" ".join(meaningful[:2]))
terms.extend(meaningful)
return terms
def _count_mentions(text: str, topic: str) -> int:
"""Count case-insensitive topic mentions in text.
Uses the maximum mention count across key terms extracted from the topic.
"Kanye West Bully album" -> max mentions of ["Kanye West", "Kanye", "West", "Bully"].
This way, an episode mentioning "Kanye" 85 times counts as 85, not 0.
"""
text_lower = text.lower()
terms = _extract_key_terms(topic)
max_count = 0
for term in terms:
pattern = re.escape(term.lower())
count = len(re.findall(pattern, text_lower))
if count > max_count:
max_count = count
return max_count
def _extract_mention_context(text: str, topic: str, max_excerpts: int = 3) -> List[str]:
"""Extract text snippets around topic mentions for highlights."""
words = text.split()
topic_lower = topic.lower()
excerpts = []
for i, word in enumerate(words):
# Check if we're near a mention
window = " ".join(words[max(0, i - 5):i + 15]).lower()
if topic_lower in window and len(excerpts) < max_excerpts:
start = max(0, i - 10)
end = min(len(words), i + 30)
excerpt = " ".join(words[start:end])
# Avoid duplicate excerpts
if not any(excerpt[:50] in e for e in excerpts):
excerpts.append(excerpt)
return excerpts
def _scan_channel(
handle: str,
topic: str,
from_date: str,
to_date: str,
episodes_limit: int,
) -> List[Dict[str, Any]]:
"""Scan a single channel's recent episodes for topic mentions.
Returns list of hit items with mention_count and transcript data.
"""
# Step 1: Resolve channel handle to URL
channel_url = resolve_channel(handle)
if not channel_url:
return []
# Step 2: Fetch recent long-form episodes
episodes = _fetch_recent_episodes(channel_url, episodes_limit, from_date, to_date)
if not episodes:
_log(f"No recent long-form episodes from {handle}")
return []
_log(f"Scanning {len(episodes)} episodes from {handle}")
# Step 3: Fetch captions and grep for topic
hits = []
with tempfile.TemporaryDirectory() as temp_dir:
for ep in episodes:
caption_text = _fetch_captions(ep["video_id"], temp_dir)
if not caption_text:
continue
mention_count = _count_mentions(caption_text, topic)
if mention_count < MENTION_THRESHOLD:
continue
# Extract highlights around the mentions
from .youtube_yt import extract_transcript_highlights
highlights = extract_transcript_highlights(caption_text, topic, limit=5)
mention_excerpts = _extract_mention_context(caption_text, topic)
# Cap transcript for storage
words = caption_text.split()
transcript_snippet = " ".join(words[:5000]) if len(words) > 5000 else caption_text
hits.append({
"video_id": ep["video_id"],
"title": ep["title"],
"channel_name": ep["channel_name"],
"url": ep["url"],
"date": ep["date"],
"duration": ep["duration"],
"engagement": {
"views": ep["views"],
"likes": ep["likes"],
},
"mention_count": mention_count,
"transcript_snippet": transcript_snippet,
"transcript_highlights": highlights,
"mention_excerpts": mention_excerpts,
"relevance": min(1.0, mention_count / 50),
"why_relevant": f"Podcast: {ep['channel_name']} - {ep['title'][:60]} ({mention_count} mentions)",
})
_log(f" HIT: {ep['title'][:60]} ({mention_count} mentions)")
return hits
def search_podcast_youtube(
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
channels: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Discover podcast content by scanning transcripts of resolved channels.
Args:
topic: Search topic
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
depth: 'quick', 'default', or 'deep'
channels: List of YouTube @handles to scan
Returns:
Dict with 'items' list. Each item has transcript and mention data.
"""
if not is_available():
_log("yt-dlp not installed")
return {"items": [], "error": "yt-dlp not installed"}
if not channels:
_log("No podcast channels provided")
return {"items": []}
episodes_limit = EPISODES_PER_CHANNEL.get(depth, EPISODES_PER_CHANNEL["default"])
results_cap = RESULTS_CAP.get(depth, RESULTS_CAP["default"])
_log(f"Scanning {len(channels)} podcast channels for '{topic}' (depth={depth}, {episodes_limit} eps/channel)")
# Scan channels in parallel
all_hits: List[Dict[str, Any]] = []
max_workers = min(4, len(channels))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
_scan_channel, handle, topic, from_date, to_date, episodes_limit,
): handle
for handle in channels
}
for future in as_completed(futures):
handle = futures[future]
try:
hits = future.result()
all_hits.extend(hits)
except Exception as exc:
_log(f"Error scanning {handle}: {type(exc).__name__}: {exc}")
# Deduplicate by video_id
seen = set()
unique_hits = []
for hit in all_hits:
vid = hit["video_id"]
if vid not in seen:
seen.add(vid)
unique_hits.append(hit)
# Score: mention_count * log(views + 1)
for hit in unique_hits:
views = hit["engagement"].get("views", 0)
hit["_score"] = hit["mention_count"] * math.log(views + 1)
# Sort by score descending
unique_hits.sort(key=lambda x: x["_score"], reverse=True)
# Cap results
results = unique_hits[:results_cap]
# Clean up internal scoring field
for hit in results:
hit.pop("_score", None)
_log(f"Found {len(results)} podcast hits across {len(channels)} channels")
return {"items": results}
+98 -20
View File
@@ -12,8 +12,15 @@ import sys
import time import time
from collections import Counter from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed, wait as futures_wait from concurrent.futures import ThreadPoolExecutor, as_completed, wait as futures_wait
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set from typing import Any, Dict, List, Optional, Set
try:
import requests as _requests
except ImportError:
_requests = None
def _first_of(*values, default=None): def _first_of(*values, default=None):
"""Return first value that is not None.""" """Return first value that is not None."""
for v in values: for v in values:
@@ -21,7 +28,7 @@ def _first_of(*values, default=None):
return v return v
return default return default
from . import dates, http, log from . import http, log
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/reddit" SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/reddit"
@@ -69,6 +76,14 @@ def _log(msg: str):
log.source_log("Reddit", msg, tty_only=False) log.source_log("Reddit", msg, tty_only=False)
def _sc_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def _extract_core_subject(topic: str) -> str: def _extract_core_subject(topic: str) -> str:
"""Extract core subject from verbose query. """Extract core subject from verbose query.
@@ -197,16 +212,27 @@ def _parse_date(value) -> Optional[str]:
Global search returns ``created_at`` as an ISO string Global search returns ``created_at`` as an ISO string
(e.g. "2018-05-03T01:09:17.620000+0000"); subreddit search returns (e.g. "2018-05-03T01:09:17.620000+0000"); subreddit search returns
``created_utc`` as a Unix timestamp. dates.parse_date() handles both, ``created_utc`` as a Unix timestamp. Handle both.
plus edge cases like Z suffix and +0000 (no colon) offset.
Falsy inputs (None, "", 0) return None, matching the original behavior
where a Unix timestamp of 0 meant "no date" rather than epoch 0.
""" """
if not value: if not value:
return None return None
dt = dates.parse_date(str(value)) # ISO-8601 string (contains 'T' or '-')
return dt.strftime("%Y-%m-%d") if dt else None if isinstance(value, str) and ("T" in value or "-" in value):
try:
# Strip trailing offset variations (+0000, Z) for fromisoformat
clean = value.replace("Z", "+00:00")
if clean.endswith("+0000"):
clean = clean[:-5] + "+00:00"
dt = datetime.fromisoformat(clean)
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError):
pass
# Unix timestamp (int or float or numeric string)
try:
dt = datetime.fromtimestamp(float(value), tz=timezone.utc)
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError, OSError):
return None
def _extract_subreddit_name(value: Any) -> str: def _extract_subreddit_name(value: Any) -> str:
@@ -324,18 +350,39 @@ def _global_search(
Returns: Returns:
List of post dicts List of post dicts
""" """
if not _requests:
_log("requests library not installed, falling back to urllib")
# Use stdlib http module as fallback
try:
from urllib.parse import urlencode
params = urlencode({"query": query, "sort": sort, "timeframe": timeframe})
url = f"{SCRAPECREATORS_BASE}/search?{params}"
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
return data.get("posts", data.get("data", []))
except http.HTTPError as e:
if e.status_code and e.status_code in (401, 403):
raise
_log(f"Global search error (urllib): {e}")
return []
except Exception as e:
_log(f"Global search error (urllib): {e}")
return []
try: try:
data = http.get( resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search", f"{SCRAPECREATORS_BASE}/search",
headers=http.scrapecreators_headers(token),
params={"query": query, "sort": sort, "timeframe": timeframe}, params={"query": query, "sort": sort, "timeframe": timeframe},
headers=_sc_headers(token),
timeout=30, timeout=30,
retries=2,
) )
resp.raise_for_status()
data = resp.json()
return data.get("posts", data.get("data", [])) return data.get("posts", data.get("data", []))
except http.HTTPError as e: except _requests.exceptions.HTTPError as e:
if e.status_code in (401, 403): if e.response is not None and e.response.status_code in (401, 403):
raise raise http.HTTPError(f"Auth error: {e}", e.response.status_code)
_log(f"Global search error: {e}") _log(f"Global search error: {e}")
return [] return []
except Exception as e: except Exception as e:
@@ -362,19 +409,36 @@ def _subreddit_search(
Returns: Returns:
List of post dicts List of post dicts
""" """
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({
"subreddit": subreddit, "query": query,
"sort": sort, "timeframe": timeframe,
})
url = f"{SCRAPECREATORS_BASE}/subreddit/search?{params}"
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
return data.get("posts", data.get("data", []))
except Exception as e:
_log(f"Subreddit search error (urllib) for r/{subreddit}: {e}")
return []
try: try:
data = http.get( resp = _requests.get(
f"{SCRAPECREATORS_BASE}/subreddit/search", f"{SCRAPECREATORS_BASE}/subreddit/search",
headers=http.scrapecreators_headers(token),
params={ params={
"subreddit": subreddit, "subreddit": subreddit,
"query": query, "query": query,
"sort": sort, "sort": sort,
"timeframe": timeframe, "timeframe": timeframe,
}, },
headers=_sc_headers(token),
timeout=30, timeout=30,
retries=2,
) )
resp.raise_for_status()
data = resp.json()
return data.get("posts", data.get("data", [])) return data.get("posts", data.get("data", []))
except Exception as e: except Exception as e:
_log(f"Subreddit search error for r/{subreddit}: {e}") _log(f"Subreddit search error for r/{subreddit}: {e}")
@@ -394,14 +458,28 @@ def fetch_post_comments(
Returns: Returns:
List of comment dicts with score, author, body, etc. List of comment dicts with score, author, body, etc.
""" """
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({"url": url})
api_url = f"{SCRAPECREATORS_BASE}/post/comments?{params}"
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(api_url, headers=headers, timeout=30, retries=2)
return data.get("comments", data.get("data", []))
except Exception as e:
_log(f"Comment fetch error (urllib): {e}")
return []
try: try:
data = http.get( resp = _requests.get(
f"{SCRAPECREATORS_BASE}/post/comments", f"{SCRAPECREATORS_BASE}/post/comments",
headers=http.scrapecreators_headers(token),
params={"url": url}, params={"url": url},
headers=_sc_headers(token),
timeout=30, timeout=30,
retries=2,
) )
resp.raise_for_status()
data = resp.json()
return data.get("comments", data.get("data", [])) return data.get("comments", data.get("data", []))
except Exception as e: except Exception as e:
_log(f"Comment fetch error: {e}") _log(f"Comment fetch error: {e}")
+6 -36
View File
@@ -14,6 +14,7 @@ SOURCE_LABELS = {
"x": "X", "x": "X",
"github": "GitHub", "github": "GitHub",
"perplexity": "Perplexity", "perplexity": "Perplexity",
"podcasts": "Podcasts",
} }
@@ -152,14 +153,13 @@ def render_full(report: schema.Report) -> str:
lines.append(f" *{item.container}*") lines.append(f" *{item.container}*")
if item.snippet: if item.snippet:
lines.append(f" {item.snippet[:500]}") lines.append(f" {item.snippet[:500]}")
# Top comments for Reddit, YouTube, TikTok, HackerNews. # Top comments for Reddit
top_comments = item.metadata.get("top_comments", []) top_comments = item.metadata.get("top_comments", [])
if top_comments and isinstance(top_comments[0], dict): if top_comments and isinstance(top_comments[0], dict):
vote_label = _vote_label_for(item.source)
for tc in top_comments[:3]: for tc in top_comments[:3]:
excerpt = tc.get("excerpt", tc.get("text", ""))[:200] excerpt = tc.get("excerpt", tc.get("text", ""))[:200]
tc_score = tc.get("score", "") tc_score = tc.get("score", "")
lines.append(f" Top comment ({tc_score} {vote_label}): {excerpt}") lines.append(f" Top comment ({tc_score} upvotes): {excerpt}")
# Comment insights for Reddit # Comment insights for Reddit
insights = item.metadata.get("comment_insights", []) insights = item.metadata.get("comment_insights", [])
if insights: if insights:
@@ -277,8 +277,7 @@ def _render_candidate(candidate: schema.Candidate, prefix: str) -> list[str]:
for tc in _top_comments_list(primary): for tc in _top_comments_list(primary):
excerpt = tc.get("excerpt") or tc.get("text") or "" excerpt = tc.get("excerpt") or tc.get("text") or ""
score = tc.get("score", "") score = tc.get("score", "")
vote_label = _vote_label_for(primary.source) if primary else "upvotes" lines.append(f" - Comment ({score} upvotes): {_truncate(excerpt.strip(), 240)}")
lines.append(f" - Comment ({score} {vote_label}): {_truncate(excerpt.strip(), 240)}")
insight = _comment_insight(primary) insight = _comment_insight(primary)
if insight: if insight:
lines.append(f" - Insight: {_truncate(insight, 220)}") lines.append(f" - Insight: {_truncate(insight, 220)}")
@@ -584,42 +583,13 @@ def _format_explanation(candidate: schema.Candidate) -> str | None:
return candidate.explanation return candidate.explanation
# Per-source minimum vote counts for showing a top comment in compact emit. def _top_comments_list(item: schema.SourceItem | None, limit: int = 3, min_score: int = 10) -> list[dict]:
# Reddit upvotes, YouTube likes, and TikTok likes are not comparable units — """Return up to `limit` top comments with score >= min_score."""
# 10 upvotes on Reddit signals genuine community interest, 10 likes on a
# viral TikTok is noise. First-pass values; tune after live observation.
_TOP_COMMENT_MIN_SCORE: dict[str, int] = {
"reddit": 10,
"youtube": 50,
"tiktok": 500,
"hackernews": 5,
}
_TOP_COMMENT_VOTE_LABEL: dict[str, str] = {
"reddit": "upvotes",
"hackernews": "points",
"youtube": "likes",
"tiktok": "likes",
}
def _vote_label_for(source: str) -> str:
return _TOP_COMMENT_VOTE_LABEL.get(source, "votes")
def _top_comments_list(item: schema.SourceItem | None, limit: int = 3, min_score: int | None = None) -> list[dict]:
"""Return up to `limit` top comments with score at or above the source's minimum.
If `min_score` is passed explicitly it overrides the per-source default;
otherwise the source-keyed map is consulted, with an effective default of 0
(always show) for unknown sources so new sources don't get silently hidden.
"""
if not item: if not item:
return [] return []
comments = item.metadata.get("top_comments") or [] comments = item.metadata.get("top_comments") or []
if not comments or not isinstance(comments[0], dict): if not comments or not isinstance(comments[0], dict):
return [] return []
if min_score is None:
min_score = _TOP_COMMENT_MIN_SCORE.get(item.source, 0)
return [c for c in comments if (c.get("score") or 0) >= min_score][:limit] return [c for c in comments if (c.get("score") or 0) >= min_score][:limit]
+5 -30
View File
@@ -19,6 +19,7 @@ SOURCE_QUALITY = {
"polymarket": 0.5, "polymarket": 0.5,
"instagram": 0.58, "instagram": 0.58,
"tiktok": 0.58, "tiktok": 0.58,
"podcasts": 0.88,
} }
@@ -82,11 +83,12 @@ def _top_comment_score(item: schema.SourceItem) -> float:
# Per-source engagement weights: list of (field_name, weight) tuples. # Per-source engagement weights: list of (field_name, weight) tuples.
# Reddit, YouTube, and TikTok use custom functions because they include # Reddit uses a custom function because upvote_ratio and top_comment_score
# a dedicated 10% top-comment-score slot (see _reddit_engagement, # are not simple log1p fields.
# _youtube_engagement, _tiktok_engagement).
ENGAGEMENT_WEIGHTS: dict[str, list[tuple[str, float]]] = { ENGAGEMENT_WEIGHTS: dict[str, list[tuple[str, float]]] = {
"x": [("likes", 0.55), ("reposts", 0.25), ("replies", 0.15), ("quotes", 0.05)], "x": [("likes", 0.55), ("reposts", 0.25), ("replies", 0.15), ("quotes", 0.05)],
"youtube": [("views", 0.50), ("likes", 0.35), ("comments", 0.15)],
"tiktok": [("views", 0.50), ("likes", 0.30), ("comments", 0.20)],
"instagram": [("views", 0.50), ("likes", 0.30), ("comments", 0.20)], "instagram": [("views", 0.50), ("likes", 0.30), ("comments", 0.20)],
"hackernews": [("points", 0.55), ("comments", 0.45)], "hackernews": [("points", 0.55), ("comments", 0.45)],
"bluesky": [("likes", 0.40), ("reposts", 0.30), ("replies", 0.20), ("quotes", 0.10)], "bluesky": [("likes", 0.40), ("reposts", 0.30), ("replies", 0.20), ("quotes", 0.10)],
@@ -112,29 +114,6 @@ def _reddit_engagement(item: schema.SourceItem) -> float | None:
return (0.50 * score) + (0.35 * comments) + (0.05 * (ratio * 10.0)) + (0.10 * top_comment) return (0.50 * score) + (0.35 * comments) + (0.05 * (ratio * 10.0)) + (0.10 * top_comment)
def _youtube_engagement(item: schema.SourceItem) -> float | None:
views = log1p_safe(item.engagement.get("views"))
likes = log1p_safe(item.engagement.get("likes"))
comments = log1p_safe(item.engagement.get("comments"))
top_comment = _top_comment_score(item)
if not any([views, likes, comments, top_comment]):
return None
# Mirrors Reddit: carve out 10% for top-comment signal, keep view-weight
# dominant. Without comments, the pre-change weights (0.50/0.35/0.15)
# still govern relative ordering.
return (0.45 * views) + (0.32 * likes) + (0.13 * comments) + (0.10 * top_comment)
def _tiktok_engagement(item: schema.SourceItem) -> float | None:
views = log1p_safe(item.engagement.get("views"))
likes = log1p_safe(item.engagement.get("likes"))
comments = log1p_safe(item.engagement.get("comments"))
top_comment = _top_comment_score(item)
if not any([views, likes, comments, top_comment]):
return None
return (0.45 * views) + (0.27 * likes) + (0.18 * comments) + (0.10 * top_comment)
def _generic_engagement(item: schema.SourceItem) -> float | None: def _generic_engagement(item: schema.SourceItem) -> float | None:
if not item.engagement: if not item.engagement:
return None return None
@@ -147,10 +126,6 @@ def _generic_engagement(item: schema.SourceItem) -> float | None:
def engagement_raw(item: schema.SourceItem) -> float | None: def engagement_raw(item: schema.SourceItem) -> float | None:
if item.source == "reddit": if item.source == "reddit":
return _reddit_engagement(item) return _reddit_engagement(item)
if item.source == "youtube":
return _youtube_engagement(item)
if item.source == "tiktok":
return _tiktok_engagement(item)
weights = ENGAGEMENT_WEIGHTS.get(item.source) weights = ENGAGEMENT_WEIGHTS.get(item.source)
if weights: if weights:
return _weighted_engagement(item, weights) return _weighted_engagement(item, weights)
+33 -12
View File
@@ -9,9 +9,10 @@ API docs: https://scrapecreators.com/docs
import math import math
import re import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from . import dates, http, log from . import http, log
from .relevance import token_overlap_relevance as _compute_relevance from .relevance import token_overlap_relevance as _compute_relevance
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/threads" SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/threads"
@@ -28,6 +29,14 @@ def _log(msg: str):
log.source_log("Threads", msg) log.source_log("Threads", msg)
def _sc_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def _extract_core_subject(topic: str) -> str: def _extract_core_subject(topic: str) -> str:
"""Extract core subject from verbose query for Threads search.""" """Extract core subject from verbose query for Threads search."""
from .query import extract_core_subject from .query import extract_core_subject
@@ -43,17 +52,29 @@ def _extract_core_subject(topic: str) -> str:
def _parse_date(item: Dict[str, Any]) -> Optional[str]: def _parse_date(item: Dict[str, Any]) -> Optional[str]:
"""Parse date from Threads item to YYYY-MM-DD. """Parse date from Threads item to YYYY-MM-DD.
Tries common timestamp fields in order: taken_at and create_time Tries common timestamp fields: taken_at (unix), created_at (ISO),
(unix timestamps in Meta APIs), then created_at, published_at, and and falls back to any date-like string field.
date (ISO 8601 strings). dates.parse_date() handles both.
""" """
for key in ("taken_at", "create_time", "created_at", "published_at", "date"): # Unix timestamp (taken_at is common in Meta APIs)
for key in ("taken_at", "create_time"):
ts = item.get(key)
if ts:
try:
from . import dates
return dates.timestamp_to_date(int(ts))
except (ValueError, TypeError):
pass
# ISO 8601 string
for key in ("created_at", "published_at", "date"):
val = item.get(key) val = item.get(key)
if val is None: if val and isinstance(val, str):
continue try:
dt = dates.parse_date(str(val)) dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
if dt: return dt.strftime("%Y-%m-%d")
return dt.strftime("%Y-%m-%d") except (ValueError, TypeError):
pass
return None return None
@@ -162,7 +183,7 @@ def search_threads(
from urllib.parse import urlencode from urllib.parse import urlencode
params = urlencode({"keyword": core_topic}) params = urlencode({"keyword": core_topic})
url = f"{SCRAPECREATORS_BASE}/search?{params}" url = f"{SCRAPECREATORS_BASE}/search?{params}"
headers = http.scrapecreators_headers(token) headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2) data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e: except Exception as e:
@@ -173,7 +194,7 @@ def search_threads(
resp = _requests.get( resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search", f"{SCRAPECREATORS_BASE}/search",
params={"keyword": core_topic}, params={"keyword": core_topic},
headers=http.scrapecreators_headers(token), headers=_sc_headers(token),
timeout=30, timeout=30,
) )
resp.raise_for_status() resp.raise_for_status()
+15 -141
View File
@@ -109,6 +109,14 @@ def _log(msg: str):
log.source_log("TikTok", msg) log.source_log("TikTok", msg)
def _sc_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def _parse_date(item: Dict[str, Any]) -> Optional[str]: def _parse_date(item: Dict[str, Any]) -> Optional[str]:
"""Parse date from ScrapeCreators TikTok item to YYYY-MM-DD.""" """Parse date from ScrapeCreators TikTok item to YYYY-MM-DD."""
ts = item.get("create_time") ts = item.get("create_time")
@@ -219,7 +227,7 @@ def _hashtag_search(
from urllib.parse import urlencode from urllib.parse import urlencode
params = urlencode({"hashtag": hashtag}) params = urlencode({"hashtag": hashtag})
url = f"{SCRAPECREATORS_BASE}/search/hashtag?{params}" url = f"{SCRAPECREATORS_BASE}/search/hashtag?{params}"
headers = http.scrapecreators_headers(token) headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2) data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e: except Exception as e:
@@ -230,7 +238,7 @@ def _hashtag_search(
resp = _requests.get( resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search/hashtag", f"{SCRAPECREATORS_BASE}/search/hashtag",
params={"hashtag": hashtag}, params={"hashtag": hashtag},
headers=http.scrapecreators_headers(token), headers=_sc_headers(token),
timeout=30, timeout=30,
) )
resp.raise_for_status() resp.raise_for_status()
@@ -266,7 +274,7 @@ def _profile_videos(
from urllib.parse import urlencode from urllib.parse import urlencode
params = urlencode({"handle": handle, "sort_by": "latest"}) params = urlencode({"handle": handle, "sort_by": "latest"})
url = f"{profile_url}?{params}" url = f"{profile_url}?{params}"
headers = http.scrapecreators_headers(token) headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2) data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e: except Exception as e:
@@ -277,7 +285,7 @@ def _profile_videos(
resp = _requests.get( resp = _requests.get(
profile_url, profile_url,
params={"handle": handle, "sort_by": "latest"}, params={"handle": handle, "sort_by": "latest"},
headers=http.scrapecreators_headers(token), headers=_sc_headers(token),
timeout=30, timeout=30,
) )
resp.raise_for_status() resp.raise_for_status()
@@ -324,7 +332,7 @@ def search_tiktok(
from urllib.parse import urlencode from urllib.parse import urlencode
params = urlencode({"query": core_topic, "sort_by": "relevance"}) params = urlencode({"query": core_topic, "sort_by": "relevance"})
url = f"{SCRAPECREATORS_BASE}/search/keyword?{params}" url = f"{SCRAPECREATORS_BASE}/search/keyword?{params}"
headers = http.scrapecreators_headers(token) headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2) data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e: except Exception as e:
@@ -335,7 +343,7 @@ def search_tiktok(
resp = _requests.get( resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search/keyword", f"{SCRAPECREATORS_BASE}/search/keyword",
params={"query": core_topic, "sort_by": "relevance"}, params={"query": core_topic, "sort_by": "relevance"},
headers=http.scrapecreators_headers(token), headers=_sc_headers(token),
timeout=30, timeout=30,
) )
resp.raise_for_status() resp.raise_for_status()
@@ -425,7 +433,7 @@ def fetch_captions(
resp = _requests.get( resp = _requests.get(
f"{SCRAPECREATORS_BASE}/video/transcript", f"{SCRAPECREATORS_BASE}/video/transcript",
params={"url": url}, params={"url": url},
headers=http.scrapecreators_headers(token), headers=_sc_headers(token),
timeout=15, timeout=15,
) )
if resp.status_code == 200: if resp.status_code == 200:
@@ -539,137 +547,3 @@ def parse_tiktok_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
List of item dicts ready for normalization. List of item dicts ready for normalization.
""" """
return response.get("items", []) return response.get("items", [])
def _tiktok_total_engagement(item: Dict[str, Any]) -> int:
"""Total engagement for ranking which posts deserve comment enrichment."""
eng = item.get("engagement", {})
return (eng.get("views", 0) or 0) + (eng.get("likes", 0) or 0) + (eng.get("comments", 0) or 0)
def enrich_with_comments(
items: List[Dict[str, Any]],
token: str,
max_posts: int = 3,
max_comments: int = 5,
) -> List[Dict[str, Any]]:
"""Enrich top TikTok posts with comment data from ScrapeCreators.
For the top N posts by engagement, fetches comments via the SC API
and attaches them as a ``top_comments`` field on each item. Mirrors
youtube_yt.enrich_with_comments.
Args:
items: TikTok items from search_tiktok()
token: ScrapeCreators API key
max_posts: How many posts to enrich with comments
max_comments: Max comments to keep per post
Returns:
Items list (mutated in place) with top_comments added to enriched items.
"""
if not items or not token or max_posts <= 0:
return items
ranked = sorted(items, key=_tiktok_total_engagement, reverse=True)
top_items = ranked[:max_posts]
_log(f"Enriching comments for {len(top_items)} TikTok posts")
from concurrent.futures import ThreadPoolExecutor, as_completed
def _enrich_one(item: dict) -> bool:
post_url = item.get("url", "")
if not post_url:
return False
try:
comments = _fetch_post_comments(post_url, token, max_comments)
if comments:
item["top_comments"] = comments
return True
except Exception as exc:
_log(f"Comment enrichment failed for {post_url}: {exc}")
return False
enriched_count = 0
with ThreadPoolExecutor(max_workers=min(4, len(top_items))) as executor:
futures = {executor.submit(_enrich_one, item): item for item in top_items}
for future in as_completed(futures):
if future.result():
enriched_count += 1
_log(f"Enriched {enriched_count}/{len(top_items)} posts with comments")
return items
def _fetch_post_comments(
post_url: str,
token: str,
max_comments: int = 5,
) -> List[Dict[str, Any]]:
"""Fetch comments for a single TikTok post via ScrapeCreators.
SC endpoint: GET /v1/tiktok/video/comments?url=<video_url>
Response shape: { comments: [{text, user.nickname, digg_count, create_time, ...}], cursor, total }
Args:
post_url: Canonical TikTok post URL (share_url form works)
token: ScrapeCreators API key
max_comments: Maximum comments to return
Returns:
List of comment dicts with author, text, digg_count (likes), date.
Empty list on any error comment failures never crash the pipeline.
"""
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({"url": post_url, "trim": "true"})
url = f"{SCRAPECREATORS_BASE}/video/comments?{params}"
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as exc:
_log(f"Comment fetch error (urllib) for {post_url}: {exc}")
return []
else:
try:
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/video/comments",
params={"url": post_url, "trim": "true"},
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except Exception as exc:
_log(f"Comment fetch error for {post_url}: {exc}")
return []
raw_comments = data.get("comments") or data.get("data") or []
# Sort by digg_count desc so normalize sees the highest-signal first.
raw_comments = sorted(
raw_comments,
key=lambda c: c.get("digg_count", 0) or 0,
reverse=True,
)
out: List[Dict[str, Any]] = []
for c in raw_comments[:max_comments]:
text = c.get("text") or ""
if not text:
continue
user = c.get("user") if isinstance(c.get("user"), dict) else {}
author = user.get("nickname") or user.get("unique_id") or ""
create_time = c.get("create_time")
date_str = ""
if create_time:
try:
date_str = dates.timestamp_to_date(int(create_time)) or ""
except (ValueError, TypeError):
date_str = ""
out.append({
"author": author,
"text": text[:400],
"digg_count": c.get("digg_count", 0) or 0,
"date": date_str,
})
return out
+103 -116
View File
@@ -18,130 +18,117 @@ const SearchClient = withSearch(TwitterClientBase);
const args = process.argv.slice(2); const args = process.argv.slice(2);
function writeStdout(text) { // --check: verify that credentials can be resolved
if (text) process.stdout.write(text); if (args.includes('--check')) {
}
function writeStderr(text) {
if (text) process.stderr.write(text);
}
async function main() {
// --check: verify that credentials can be resolved
if (args.includes('--check')) {
try {
const { cookies, warnings } = await resolveCredentials({});
if (cookies.authToken && cookies.ct0) {
writeStdout(JSON.stringify({ authenticated: true, source: cookies.source }));
return 0;
}
writeStdout(JSON.stringify({ authenticated: false, warnings }));
return 1;
} catch (err) {
writeStdout(JSON.stringify({ authenticated: false, error: err.message }));
return 1;
}
}
// --whoami: check auth and output source
if (args.includes('--whoami')) {
try {
const { cookies } = await resolveCredentials({});
if (cookies.authToken && cookies.ct0) {
writeStdout(cookies.source || 'authenticated');
return 0;
}
writeStderr('Not authenticated\n');
return 1;
} catch (err) {
writeStderr(`Auth check failed: ${err.message}\n`);
return 1;
}
}
// Parse search args
let query = null;
let count = 20;
let jsonOutput = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--count' && args[i + 1]) {
count = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === '-n' && args[i + 1]) {
count = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === '--json') {
jsonOutput = true;
} else if (!args[i].startsWith('-')) {
query = args[i];
}
}
if (!query) {
writeStderr('Usage: node bird-search.mjs <query> [--count N] [--json]\n');
return 1;
}
try { try {
// Resolve credentials (env vars, then browser cookies)
const { cookies, warnings } = await resolveCredentials({}); const { cookies, warnings } = await resolveCredentials({});
if (cookies.authToken && cookies.ct0) {
if (!cookies.authToken || !cookies.ct0) { process.stdout.write(JSON.stringify({ authenticated: true, source: cookies.source }));
const msg = warnings.length > 0 ? warnings.join('; ') : 'No Twitter credentials found'; process.exit(0);
if (jsonOutput) {
writeStdout(JSON.stringify({ error: msg, items: [] }));
} else {
writeStderr(`Error: ${msg}\n`);
}
return 1;
}
const client = new SearchClient({
cookies: {
authToken: cookies.authToken,
ct0: cookies.ct0,
cookieHeader: cookies.cookieHeader,
},
timeoutMs: 30000,
});
const result = await client.search(query, count);
if (!result.success) {
if (jsonOutput) {
writeStdout(JSON.stringify({ error: result.error, items: [] }));
} else {
writeStderr(`Search failed: ${result.error}\n`);
}
return 1;
}
const tweets = result.tweets || [];
if (jsonOutput) {
writeStdout(JSON.stringify(tweets));
} else { } else {
for (const tweet of tweets) { process.stdout.write(JSON.stringify({ authenticated: false, warnings }));
const author = tweet.author?.username || 'unknown'; process.exit(1);
writeStdout(`@${author}: ${tweet.text?.slice(0, 200)}\n\n`);
}
} }
return 0;
} catch (err) { } catch (err) {
if (jsonOutput) { process.stdout.write(JSON.stringify({ authenticated: false, error: err.message }));
writeStdout(JSON.stringify({ error: err.message, items: [] })); process.exit(1);
} else {
writeStderr(`Error: ${err.message}\n`);
}
return 1;
} }
} }
// --whoami: check auth and output source
if (args.includes('--whoami')) {
try {
const { cookies } = await resolveCredentials({});
if (cookies.authToken && cookies.ct0) {
process.stdout.write(cookies.source || 'authenticated');
process.exit(0);
} else {
process.stderr.write('Not authenticated\n');
process.exit(1);
}
} catch (err) {
process.stderr.write(`Auth check failed: ${err.message}\n`);
process.exit(1);
}
}
// Parse search args
let query = null;
let count = 20;
let jsonOutput = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--count' && args[i + 1]) {
count = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === '-n' && args[i + 1]) {
count = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === '--json') {
jsonOutput = true;
} else if (!args[i].startsWith('-')) {
query = args[i];
}
}
if (!query) {
process.stderr.write('Usage: node bird-search.mjs <query> [--count N] [--json]\n');
process.exit(1);
}
try { try {
const code = await main(); // Resolve credentials (env vars, then browser cookies)
process.exitCode = Number.isInteger(code) ? code : 1; const { cookies, warnings } = await resolveCredentials({});
if (!cookies.authToken || !cookies.ct0) {
const msg = warnings.length > 0 ? warnings.join('; ') : 'No Twitter credentials found';
if (jsonOutput) {
process.stdout.write(JSON.stringify({ error: msg, items: [] }));
} else {
process.stderr.write(`Error: ${msg}\n`);
}
process.exit(1);
}
// Create search client
const client = new SearchClient({
cookies: {
authToken: cookies.authToken,
ct0: cookies.ct0,
cookieHeader: cookies.cookieHeader,
},
timeoutMs: 30000,
});
// Run search
const result = await client.search(query, count);
if (!result.success) {
if (jsonOutput) {
process.stdout.write(JSON.stringify({ error: result.error, items: [] }));
} else {
process.stderr.write(`Search failed: ${result.error}\n`);
}
process.exit(1);
}
// Output results
const tweets = result.tweets || [];
if (jsonOutput) {
process.stdout.write(JSON.stringify(tweets));
} else {
for (const tweet of tweets) {
const author = tweet.author?.username || 'unknown';
process.stdout.write(`@${author}: ${tweet.text?.slice(0, 200)}\n\n`);
}
}
process.exit(0);
} catch (err) { } catch (err) {
writeStderr(`Fatal error: ${err?.message || err}\n`); if (jsonOutput) {
process.exitCode = 1; process.stdout.write(JSON.stringify({ error: err.message, items: [] }));
} else {
process.stderr.write(`Error: ${err.message}\n`);
}
process.exit(1);
} }
+14 -6
View File
@@ -655,6 +655,14 @@ except ImportError:
_requests = None _requests = None
def _sc_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def _total_engagement(item: Dict[str, Any]) -> int: def _total_engagement(item: Dict[str, Any]) -> int:
"""Combined engagement score for ranking which videos to enrich.""" """Combined engagement score for ranking which videos to enrich."""
eng = item.get("engagement", {}) eng = item.get("engagement", {})
@@ -737,7 +745,7 @@ def _fetch_video_comments(
from urllib.parse import urlencode from urllib.parse import urlencode
params = urlencode({"id": video_id}) params = urlencode({"id": video_id})
url = f"{SCRAPECREATORS_YT_BASE}/video/comments?{params}" url = f"{SCRAPECREATORS_YT_BASE}/video/comments?{params}"
headers = http.scrapecreators_headers(token) headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2) data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as exc: except Exception as exc:
@@ -748,7 +756,7 @@ def _fetch_video_comments(
resp = _requests.get( resp = _requests.get(
f"{SCRAPECREATORS_YT_BASE}/video/comments", f"{SCRAPECREATORS_YT_BASE}/video/comments",
params={"id": video_id}, params={"id": video_id},
headers=http.scrapecreators_headers(token), headers=_sc_headers(token),
timeout=30, timeout=30,
) )
resp.raise_for_status() resp.raise_for_status()
@@ -898,7 +906,7 @@ def _sc_youtube_search(keyword: str, token: str) -> List[Dict[str, Any]]:
from urllib.parse import urlencode from urllib.parse import urlencode
params = urlencode({"keyword": keyword}) params = urlencode({"keyword": keyword})
url = f"{SCRAPECREATORS_YT_BASE}/search?{params}" url = f"{SCRAPECREATORS_YT_BASE}/search?{params}"
headers = http.scrapecreators_headers(token) headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2) data = http.get(url, headers=headers, timeout=30, retries=2)
return data.get("videos", data.get("data", data.get("items", []))) return data.get("videos", data.get("data", data.get("items", [])))
@@ -910,7 +918,7 @@ def _sc_youtube_search(keyword: str, token: str) -> List[Dict[str, Any]]:
resp = _requests.get( resp = _requests.get(
f"{SCRAPECREATORS_YT_BASE}/search", f"{SCRAPECREATORS_YT_BASE}/search",
params={"keyword": keyword}, params={"keyword": keyword},
headers=http.scrapecreators_headers(token), headers=_sc_headers(token),
timeout=30, timeout=30,
) )
resp.raise_for_status() resp.raise_for_status()
@@ -936,7 +944,7 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
from urllib.parse import urlencode from urllib.parse import urlencode
params = urlencode({"id": video_id}) params = urlencode({"id": video_id})
url = f"{SCRAPECREATORS_YT_BASE}/video/transcript?{params}" url = f"{SCRAPECREATORS_YT_BASE}/video/transcript?{params}"
headers = http.scrapecreators_headers(token) headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2) data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as exc: except Exception as exc:
@@ -947,7 +955,7 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
resp = _requests.get( resp = _requests.get(
f"{SCRAPECREATORS_YT_BASE}/video/transcript", f"{SCRAPECREATORS_YT_BASE}/video/transcript",
params={"id": video_id}, params={"id": video_id},
headers=http.scrapecreators_headers(token), headers=_sc_headers(token),
timeout=30, timeout=30,
) )
if resp.status_code != 200: if resp.status_code != 200:
+4 -63
View File
@@ -11,7 +11,7 @@ COMMON_TARGETS=(
# but local development needs the cache kept in sync with the repo. # but local development needs the cache kept in sync with the repo.
# Do NOT add ~/.claude/skills/last30days - it creates a duplicate # Do NOT add ~/.claude/skills/last30days - it creates a duplicate
# /last30days-3 in the slash command menu alongside the plugin version. # /last30days-3 in the slash command menu alongside the plugin version.
"$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3/3.0.1" "$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3/3.0.0-alpha"
"$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3-nogem/3.0.0-nogem" "$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3-nogem/3.0.0-nogem"
"$HOME/.agents/skills/last30days" "$HOME/.agents/skills/last30days"
"$HOME/.codex/skills/last30days" "$HOME/.codex/skills/last30days"
@@ -24,7 +24,7 @@ sync_target() {
echo "" echo ""
echo "--- Syncing to $target ---" echo "--- Syncing to $target ---"
mkdir -p "$target/scripts/lib" mkdir -p "$target/scripts/lib" "$target/variants/open/references"
cp "$skill_md" "$target/SKILL.md" cp "$skill_md" "$target/SKILL.md"
@@ -35,13 +35,7 @@ sync_target() {
"$SRC/scripts/store.py" \ "$SRC/scripts/store.py" \
"$target/scripts/" "$target/scripts/"
rsync -a "$SRC/scripts/lib/"*.py "$target/scripts/lib/" rsync -a "$SRC/scripts/lib/"*.py "$target/scripts/lib/"
rsync -a "$SRC/variants/open/" "$target/variants/open/"
# The OpenClaw variant lives in the private repo only. Skip cleanly when
# running this script from the public repo where variants/open does not exist.
if [ -d "$SRC/variants/open" ]; then
mkdir -p "$target/variants/open/references"
rsync -a "$SRC/variants/open/" "$target/variants/open/"
fi
if [ -d "$SRC/scripts/lib/vendor" ]; then if [ -d "$SRC/scripts/lib/vendor" ]; then
rsync -a "$SRC/scripts/lib/vendor" "$target/scripts/lib/" rsync -a "$SRC/scripts/lib/vendor" "$target/scripts/lib/"
@@ -69,60 +63,7 @@ for t in "${COMMON_TARGETS[@]}"; do
sync_target "$t" "$SRC/SKILL.md" sync_target "$t" "$SRC/SKILL.md"
done done
# Hermes sync: deploy to Hermes skills directory if it exists sync_target "$OPENCLAW_TARGET" "$SRC/variants/open/SKILL.md"
HERMES_TARGET="$HOME/.hermes/skills/research/last30days"
if [ -d "$HOME/.hermes/skills/research" ]; then
echo ""
echo "--- Syncing to Hermes ---"
mkdir -p "$HERMES_TARGET/scripts/lib"
# Use Hermes-specific SKILL.md if available, fallback to main
if [ -f "$SRC/.hermes-plugin/SKILL.md" ]; then
cp "$SRC/.hermes-plugin/SKILL.md" "$HERMES_TARGET/SKILL.md"
else
cp "$SRC/SKILL.md" "$HERMES_TARGET/SKILL.md"
fi
rsync -a \
"$SRC/scripts/last30days.py" \
"$SRC/scripts/watchlist.py" \
"$SRC/scripts/briefing.py" \
"$SRC/scripts/store.py" \
"$HERMES_TARGET/scripts/"
rsync -a "$SRC/scripts/lib/"*.py "$HERMES_TARGET/scripts/lib/"
if [ -d "$SRC/scripts/lib/vendor" ]; then
rsync -a "$SRC/scripts/lib/vendor" "$HERMES_TARGET/scripts/lib/"
fi
if [ -d "$SRC/fixtures" ]; then
mkdir -p "$HERMES_TARGET/fixtures"
rsync -a "$SRC/fixtures/" "$HERMES_TARGET/fixtures/"
fi
mod_count=$(ls "$HERMES_TARGET/scripts/lib/"*.py 2>/dev/null | wc -l | tr -d ' ')
echo " Copied $mod_count modules to Hermes"
if (
cd "$HERMES_TARGET/scripts" &&
python3 -c "import briefing, store, watchlist; from lib import youtube_yt, bird_x, render, ui; print(' Import check: OK')"
); then
true
else
echo " Import check FAILED"
fi
fi
# OpenClaw sync only runs when the private-repo OpenClaw variant is present
# in the source tree. The public repo does not ship variants/open (the variant
# is sanitized via strip_for_openclaw.py and published separately from
# last30days-skill-private).
if [ -d "$SRC/variants/open" ]; then
sync_target "$OPENCLAW_TARGET" "$SRC/variants/open/SKILL.md"
else
echo ""
echo "Skipping OpenClaw target (no variants/open in this repo)"
fi
echo "" echo ""
echo "Sync complete." echo "Sync complete."
+5 -4
View File
@@ -1,14 +1,14 @@
--- ---
name: last30days-v3-spec name: last30days
version: "3.0.1" version: "3.0.0"
description: "Internal architecture spec for the v3 last30days runtime pipeline. Not user-invocable." description: "Multi-query social search with intelligent planning. Agent plans queries when possible, falls back to Gemini/OpenAI when not. Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web."
argument-hint: "last30days codex vs claude code" argument-hint: "last30days codex vs claude code"
allowed-tools: Bash, Read, Write, WebSearch allowed-tools: Bash, Read, Write, WebSearch
homepage: https://github.com/mvanhorn/last30days-skill homepage: https://github.com/mvanhorn/last30days-skill
repository: https://github.com/mvanhorn/last30days-skill repository: https://github.com/mvanhorn/last30days-skill
author: mvanhorn author: mvanhorn
license: MIT license: MIT
user-invocable: false user-invocable: true
--- ---
# last30days v3.0.0 # last30days v3.0.0
@@ -86,6 +86,7 @@ fi
- `yt-dlp` enables YouTube. - `yt-dlp` enables YouTube.
- Planning and reranking fall back gracefully: Gemini -> OpenAI -> xAI -> deterministic/local. - Planning and reranking fall back gracefully: Gemini -> OpenAI -> xAI -> deterministic/local.
- Web retrieval stays within Brave/Serper dated results. Undated web hits are dropped. - Web retrieval stays within Brave/Serper dated results. Undated web hits are dropped.
- For OpenClaw-specific watchlist, briefing, and history workflows, use `variants/open/SKILL.md`.
## Output model ## Output model
+1 -27
View File
@@ -175,7 +175,7 @@ class TestVendoredBirdRuntime(unittest.TestCase):
} }
] ]
items = parse_bird_response(tweets, "test query") items = parse_bird_response(tweets, "test query")
self.assertIsNone(items[0]["engagement"]) self.assertIsNone(items[0]["engagement"]["likes"])
def test_fallback_to_second_key(self): def test_fallback_to_second_key(self):
tweets = [ tweets = [
@@ -203,32 +203,6 @@ class TestVendoredBirdRuntime(unittest.TestCase):
items = parse_bird_response(tweets, "test query") items = parse_bird_response(tweets, "test query")
self.assertEqual(0, items[0]["engagement"]["likes"]) self.assertEqual(0, items[0]["engagement"]["likes"])
def test_engagement_none_when_all_fields_missing(self):
"""All-None engagement dict should become None, not propagate."""
tweets = [
{
"id": "1",
"text": "test",
"permanent_url": "https://x.com/u/status/1",
}
]
items = parse_bird_response(tweets, "test query")
self.assertIsNone(items[0]["engagement"])
def test_engagement_preserved_when_any_field_present(self):
"""Engagement dict kept when at least one metric exists."""
tweets = [
{
"id": "1",
"text": "test",
"permanent_url": "https://x.com/u/status/1",
"likeCount": 5,
}
]
items = parse_bird_response(tweets, "test query")
self.assertIsNotNone(items[0]["engagement"])
self.assertEqual(5, items[0]["engagement"]["likes"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
-15
View File
@@ -77,13 +77,6 @@ class CliV3Tests(unittest.TestCase):
with self.assertRaises(SystemExit): with self.assertRaises(SystemExit):
cli.parse_search_flag(" , ") cli.parse_search_flag(" , ")
def test_build_parser_accepts_days_alias_and_preserves_topic_tokens(self):
parser = cli.build_parser()
args, extra = parser.parse_known_args(["--days", "7", "biosecurity", "ai", "agents"])
self.assertEqual(7, args.lookback_days)
self.assertEqual(["biosecurity", "ai", "agents"], args.topic)
self.assertEqual([], extra)
def test_ensure_supported_python_rejects_old_interpreter_with_actionable_error(self): def test_ensure_supported_python_rejects_old_interpreter_with_actionable_error(self):
stderr = io.StringIO() stderr = io.StringIO()
with redirect_stderr(stderr): with redirect_stderr(stderr):
@@ -135,14 +128,6 @@ class CliV3Tests(unittest.TestCase):
payload = json.loads(path.read_text()) payload = json.loads(path.read_text())
self.assertEqual("OpenClaw vs NanoClaw", payload["topic"]) self.assertEqual("OpenClaw vs NanoClaw", payload["topic"])
def test_save_output_writes_utf8_encoded_markdown(self):
report = self.make_report()
with tempfile.TemporaryDirectory() as tmp:
with mock.patch("pathlib.Path.write_text", autospec=True, return_value=1) as write_text:
cli.save_output(report, "md", tmp)
_, kwargs = write_text.call_args
self.assertEqual("utf-8", kwargs.get("encoding"))
def test_persist_report_updates_run_status_on_success_and_failure(self): def test_persist_report_updates_run_status_on_success_and_failure(self):
report = self.make_report() report = self.make_report()
-14
View File
@@ -1,14 +0,0 @@
from scripts.lib import env
def test_include_sources_defaults_to_empty_string(monkeypatch, tmp_path):
# Ensure the env var is not set
monkeypatch.delenv("INCLUDE_SOURCES", raising=False)
# Avoid reading any real user config file by patching the resolved module path directly
monkeypatch.setattr(env, "CONFIG_FILE", tmp_path / "does-not-exist.env")
cfg = env.get_config()
assert "INCLUDE_SOURCES" in cfg
assert cfg["INCLUDE_SOURCES"] == ""
-16
View File
@@ -56,22 +56,6 @@ class TestParseDate(unittest.TestCase):
def test_empty(self): def test_empty(self):
self.assertIsNone(github._parse_date("")) self.assertIsNone(github._parse_date(""))
def test_rejects_garbage(self):
"""The old naive slicing returned 'hello worl' for 'hello world'. Reject it."""
self.assertIsNone(github._parse_date("hello world"))
self.assertIsNone(github._parse_date("not-a-date"))
self.assertIsNone(github._parse_date("abcdefghij"))
def test_rejects_invalid_date_values(self):
"""An out-of-range date like 2026-99-99 is not a real date."""
self.assertIsNone(github._parse_date("2026-99-99"))
def test_iso_with_offset(self):
self.assertEqual(github._parse_date("2026-03-15T12:00:00+00:00"), "2026-03-15")
def test_iso_with_no_colon_offset(self):
self.assertEqual(github._parse_date("2026-03-15T12:00:00+0000"), "2026-03-15")
class TestSearchGithub(unittest.TestCase): class TestSearchGithub(unittest.TestCase):
@patch.dict("os.environ", {}, clear=True) @patch.dict("os.environ", {}, clear=True)
-63
View File
@@ -41,66 +41,3 @@ class Test429RetryLimit(unittest.TestCase):
http.request("GET", "http://example.com", retries=3) http.request("GET", "http://example.com", retries=3)
self.assertEqual(mock_urlopen.call_count, 3) self.assertEqual(mock_urlopen.call_count, 3)
def _mock_response(body: str = '{"ok": true}', status: int = 200):
resp = MagicMock()
resp.__enter__ = MagicMock(return_value=resp)
resp.__exit__ = MagicMock(return_value=False)
resp.read.return_value = body.encode("utf-8")
resp.status = status
return resp
class TestParamsEncoding(unittest.TestCase):
"""request() should urlencode the params dict into the URL."""
def _sent_url(self, mock_urlopen) -> str:
request_arg = mock_urlopen.call_args[0][0]
return request_arg.full_url
@patch("lib.http.urllib.request.urlopen")
def test_params_appended_to_url(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search", params={"q": "test", "limit": 10})
sent_url = self._sent_url(mock_urlopen)
self.assertIn("q=test", sent_url)
self.assertIn("limit=10", sent_url)
@patch("lib.http.urllib.request.urlopen")
def test_params_appended_with_existing_query_string(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search?api_key=secret", params={"q": "test"})
sent_url = self._sent_url(mock_urlopen)
self.assertTrue(sent_url.startswith("https://api.example.com/search?api_key=secret&"))
self.assertIn("q=test", sent_url)
@patch("lib.http.urllib.request.urlopen")
def test_none_values_dropped(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search", params={"q": "test", "filter": None})
sent_url = self._sent_url(mock_urlopen)
self.assertIn("q=test", sent_url)
self.assertNotIn("filter", sent_url)
@patch("lib.http.urllib.request.urlopen")
def test_empty_params_leaves_url_unchanged(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search", params={})
sent_url = self._sent_url(mock_urlopen)
self.assertEqual(sent_url, "https://api.example.com/search")
@patch("lib.http.urllib.request.urlopen")
def test_no_params_kwarg_leaves_url_unchanged(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search")
sent_url = self._sent_url(mock_urlopen)
self.assertEqual(sent_url, "https://api.example.com/search")
@patch("lib.http.urllib.request.urlopen")
def test_int_and_bool_params_stringified(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search", params={"count": 25, "raw": True})
sent_url = self._sent_url(mock_urlopen)
self.assertIn("count=25", sent_url)
self.assertIn("raw=True", sent_url)
-159
View File
@@ -49,165 +49,6 @@ class NormalizeV3Tests(unittest.TestCase):
) )
self.assertEqual([], normalized) self.assertEqual([], normalized)
def test_youtube_top_comments_passthrough_with_field_mapping(self):
"""YT comments from enrich_with_comments use likes/text; normalize must
carry them into metadata as the Reddit-compatible {score, excerpt} shape."""
items = [
{
"video_id": "vid-1",
"title": "How to deploy",
"url": "https://youtube.com/watch?v=vid-1",
"channel_name": "Example",
"date": "2026-03-01",
"engagement": {"views": 10000, "likes": 500, "comments": 30},
"top_comments": [
{"author": "Alice", "text": "Best tutorial ever", "likes": 120, "date": "2026-03-02"},
{"author": "Bob", "text": "Helped me ship", "likes": 45, "date": "2026-03-03"},
{"author": "Carol", "text": "Solid walkthrough", "likes": 7, "date": "2026-03-04"},
],
}
]
normalized = normalize.normalize_source_items(
"youtube", items, "2026-02-15", "2026-03-17",
)
self.assertEqual(1, len(normalized))
top = normalized[0].metadata.get("top_comments")
self.assertIsNotNone(top)
self.assertEqual(3, len(top))
# First comment: likes->score, text->excerpt
self.assertEqual(120, top[0]["score"])
self.assertEqual("Best tutorial ever", top[0]["excerpt"])
self.assertEqual("Alice", top[0]["author"])
self.assertEqual("2026-03-02", top[0]["date"])
# Preserves ordering from input (already sorted desc upstream)
self.assertEqual(45, top[1]["score"])
self.assertEqual(7, top[2]["score"])
def test_youtube_top_comments_empty_list_passes_through_cleanly(self):
items = [
{
"video_id": "vid-2",
"title": "Short clip",
"url": "https://youtube.com/watch?v=vid-2",
"channel_name": "Example",
"date": "2026-03-01",
"engagement": {"views": 50, "likes": 2},
"top_comments": [],
}
]
normalized = normalize.normalize_source_items(
"youtube", items, "2026-02-15", "2026-03-17",
)
self.assertEqual(1, len(normalized))
# Empty list is fine; metadata may have empty top_comments or omit it.
top = normalized[0].metadata.get("top_comments", [])
self.assertEqual([], top)
def test_youtube_without_top_comments_key_does_not_crash(self):
items = [
{
"video_id": "vid-3",
"title": "No comments fetched",
"url": "https://youtube.com/watch?v=vid-3",
"channel_name": "Example",
"date": "2026-03-01",
"engagement": {"views": 100, "likes": 5},
}
]
normalized = normalize.normalize_source_items(
"youtube", items, "2026-02-15", "2026-03-17",
)
self.assertEqual(1, len(normalized))
self.assertEqual([], normalized[0].metadata.get("top_comments", []))
def test_youtube_top_comments_feed_top_comment_score_signal(self):
"""Integration: after normalize, signals._top_comment_score should
return log1p(first comment score) for YT, proving the full chain."""
from lib import signals
import math
items = [
{
"video_id": "vid-4",
"title": "Viral comment thread",
"url": "https://youtube.com/watch?v=vid-4",
"channel_name": "Example",
"date": "2026-03-01",
"engagement": {"views": 1000, "likes": 50, "comments": 10},
"top_comments": [
{"author": "A", "text": "Legendary", "likes": 9999, "date": "2026-03-02"},
],
}
]
normalized = normalize.normalize_source_items(
"youtube", items, "2026-02-15", "2026-03-17",
)
self.assertAlmostEqual(math.log1p(9999), signals._top_comment_score(normalized[0]), places=4)
def test_tiktok_top_comments_passthrough_with_digg_count_mapping(self):
"""TikTok comments from enrich_with_comments use digg_count/text;
normalize must map to the shared {score, excerpt} shape."""
items = [
{
"id": "tt-1",
"text": "POV: shipping on Friday",
"url": "https://www.tiktok.com/@u/video/tt-1",
"author_name": "u",
"date": "2026-03-01",
"engagement": {"views": 50000, "likes": 2000, "comments": 300},
"top_comments": [
{"author": "Alice", "text": "dead", "digg_count": 1200, "date": "2026-03-02"},
{"author": "Bob", "text": "so real", "digg_count": 400, "date": "2026-03-03"},
],
}
]
normalized = normalize.normalize_source_items(
"tiktok", items, "2026-02-15", "2026-03-17",
)
self.assertEqual(1, len(normalized))
top = normalized[0].metadata.get("top_comments")
self.assertEqual(2, len(top))
self.assertEqual(1200, top[0]["score"])
self.assertEqual("dead", top[0]["excerpt"])
self.assertEqual("Alice", top[0]["author"])
self.assertEqual(400, top[1]["score"])
def test_tiktok_without_top_comments_does_not_crash(self):
items = [
{
"id": "tt-2",
"text": "plain clip",
"url": "https://www.tiktok.com/@u/video/tt-2",
"author_name": "u",
"date": "2026-03-01",
"engagement": {"views": 1000, "likes": 20},
}
]
normalized = normalize.normalize_source_items(
"tiktok", items, "2026-02-15", "2026-03-17",
)
self.assertEqual([], normalized[0].metadata.get("top_comments", []))
def test_tiktok_top_comments_feed_top_comment_score_signal(self):
from lib import signals
import math
items = [
{
"id": "tt-3",
"text": "viral",
"url": "https://www.tiktok.com/@u/video/tt-3",
"author_name": "u",
"date": "2026-03-01",
"engagement": {"views": 100000, "likes": 5000, "comments": 500},
"top_comments": [
{"author": "A", "text": "this aged well", "digg_count": 50000, "date": "2026-03-02"},
],
}
]
normalized = normalize.normalize_source_items(
"tiktok", items, "2026-02-15", "2026-03-17",
)
self.assertAlmostEqual(math.log1p(50000), signals._top_comment_score(normalized[0]), places=4)
def test_grounding_requires_a_usable_date(self): def test_grounding_requires_a_usable_date(self):
items = [ items = [
{ {
-28
View File
@@ -242,34 +242,6 @@ class RenderTopCommentsTests(unittest.TestCase):
self.assertNotIn("Comment (", text) self.assertNotIn("Comment (", text)
self.assertNotIn("upvotes)", text) self.assertNotIn("upvotes)", text)
def test_youtube_comments_use_likes_label_and_50_threshold(self):
comments = [
{"score": 120, "excerpt": "legit fire tutorial", "author": "alice"},
{"score": 60, "excerpt": "saved me hours", "author": "bob"},
{"score": 10, "excerpt": "below threshold", "author": "carol"},
]
report = self._make_report_with_comments(source="youtube", top_comments=comments)
text = render.render_compact(report)
self.assertIn("Comment (120 likes): legit fire tutorial", text)
self.assertIn("Comment (60 likes): saved me hours", text)
self.assertNotIn("Comment (10 likes)", text)
# Render must not silently label YT as upvotes.
self.assertNotIn("Comment (120 upvotes)", text)
def test_tiktok_comments_use_likes_label_and_500_threshold(self):
comments = [
{"score": 2000, "excerpt": "this aged well", "author": "a"},
{"score": 600, "excerpt": "so real", "author": "b"},
{"score": 400, "excerpt": "below tt threshold", "author": "c"},
{"score": 50, "excerpt": "way below", "author": "d"},
]
report = self._make_report_with_comments(source="tiktok", top_comments=comments)
text = render.render_compact(report)
self.assertIn("Comment (2000 likes): this aged well", text)
self.assertIn("Comment (600 likes): so real", text)
self.assertNotIn("Comment (400 likes)", text)
self.assertNotIn("Comment (50 likes)", text)
class RenderBestTakesCompactTests(unittest.TestCase): class RenderBestTakesCompactTests(unittest.TestCase):
"""Tests for Best Takes section in compact output and fun tags on candidates.""" """Tests for Best Takes section in compact output and fun tags on candidates."""
+9 -102
View File
@@ -28,98 +28,6 @@ class SignalsV3Tests(unittest.TestCase):
) )
self.assertAlmostEqual(expected, signals.engagement_raw(item)) self.assertAlmostEqual(expected, signals.engagement_raw(item))
def test_youtube_engagement_adds_top_comment_slot(self):
with_comment = schema.SourceItem(
item_id="yt1",
source="youtube",
title="Title",
body="Body",
url="https://youtube.com/watch?v=a",
engagement={"views": 10000, "likes": 500, "comments": 30},
metadata={"top_comments": [{"score": 500}]},
)
without = schema.SourceItem(
item_id="yt2",
source="youtube",
title="Title",
body="Body",
url="https://youtube.com/watch?v=b",
engagement={"views": 10000, "likes": 500, "comments": 30},
metadata={"top_comments": []},
)
with_score = signals.engagement_raw(with_comment)
without_score = signals.engagement_raw(without)
self.assertIsNotNone(with_score)
self.assertIsNotNone(without_score)
self.assertGreater(with_score, without_score)
expected = (
0.45 * math.log1p(10000)
+ 0.32 * math.log1p(500)
+ 0.13 * math.log1p(30)
+ 0.10 * math.log1p(500)
)
self.assertAlmostEqual(expected, with_score, places=6)
def test_youtube_engagement_empty_returns_none(self):
item = schema.SourceItem(
item_id="yt-empty",
source="youtube",
title="Title",
body="Body",
url="https://youtube.com/watch?v=e",
engagement={},
metadata={"top_comments": []},
)
self.assertIsNone(signals.engagement_raw(item))
def test_tiktok_engagement_adds_top_comment_slot(self):
item = schema.SourceItem(
item_id="tt1",
source="tiktok",
title="Title",
body="Body",
url="https://tiktok.com/@u/video/1",
engagement={"views": 100000, "likes": 5000, "comments": 500},
metadata={"top_comments": [{"score": 1200}]},
)
expected = (
0.45 * math.log1p(100000)
+ 0.27 * math.log1p(5000)
+ 0.18 * math.log1p(500)
+ 0.10 * math.log1p(1200)
)
self.assertAlmostEqual(expected, signals.engagement_raw(item), places=6)
def test_youtube_ranking_promotes_viral_comment_thread(self):
"""A moderately-viewed YouTube video with a 10k-like comment should
outrank a slightly-higher-viewed video with no high-signal comments."""
viral_comment = schema.SourceItem(
item_id="yt-with-viral-comment",
source="youtube",
title="Deploy to Fly.io",
body="Deploy to Fly.io walkthrough",
url="https://youtube.com/watch?v=x",
published_at="2026-03-15",
engagement={"views": 5000, "likes": 200, "comments": 50},
metadata={"top_comments": [{"score": 10000}]},
)
higher_views = schema.SourceItem(
item_id="yt-higher-views-no-comment",
source="youtube",
title="Deploy to Fly.io",
body="Deploy to Fly.io walkthrough",
url="https://youtube.com/watch?v=y",
published_at="2026-03-15",
engagement={"views": 8000, "likes": 300, "comments": 60},
metadata={"top_comments": []},
)
ranked = signals.annotate_stream(
[higher_views, viral_comment],
ranking_query="How do I deploy on Fly.io?",
freshness_mode="balanced_recent",
)
self.assertEqual("yt-with-viral-comment", ranked[0].item_id)
def test_polymarket_engagement_uses_market_fields(self): def test_polymarket_engagement_uses_market_fields(self):
item = schema.SourceItem( item = schema.SourceItem(
item_id="pm1", item_id="pm1",
@@ -313,8 +221,7 @@ class SignalsV3Tests(unittest.TestCase):
self.assertAlmostEqual(expected, result) self.assertAlmostEqual(expected, result)
def test_youtube_engagement_dominant_weight(self): def test_youtube_engagement_dominant_weight(self):
"""YouTube: views at 0.45 should dominate. With no top-comment data, """YouTube: views at 0.50 should dominate over comments at 0.15."""
the remaining 0.90 of weight is split views/likes/comments 0.45/0.32/0.13."""
item = schema.SourceItem( item = schema.SourceItem(
item_id="yt1", source="youtube", title="T", body="B", item_id="yt1", source="youtube", title="T", body="B",
url="https://example.com", url="https://example.com",
@@ -323,9 +230,9 @@ class SignalsV3Tests(unittest.TestCase):
result = signals.engagement_raw(item) result = signals.engagement_raw(item)
self.assertIsNotNone(result) self.assertIsNotNone(result)
expected = ( expected = (
0.45 * math.log1p(10000) 0.50 * math.log1p(10000)
+ 0.32 * math.log1p(500) + 0.35 * math.log1p(500)
+ 0.13 * math.log1p(80) + 0.15 * math.log1p(80)
) )
self.assertAlmostEqual(expected, result) self.assertAlmostEqual(expected, result)
@@ -345,7 +252,7 @@ class SignalsV3Tests(unittest.TestCase):
) )
result = signals.engagement_raw(item) result = signals.engagement_raw(item)
self.assertIsNotNone(result) self.assertIsNotNone(result)
expected = 0.45 * math.log1p(5000) expected = 0.50 * math.log1p(5000)
self.assertAlmostEqual(expected, result) self.assertAlmostEqual(expected, result)
def test_tiktok_engagement_dominant_weight(self): def test_tiktok_engagement_dominant_weight(self):
@@ -357,9 +264,9 @@ class SignalsV3Tests(unittest.TestCase):
result = signals.engagement_raw(item) result = signals.engagement_raw(item)
self.assertIsNotNone(result) self.assertIsNotNone(result)
expected = ( expected = (
0.45 * math.log1p(50000) 0.50 * math.log1p(50000)
+ 0.27 * math.log1p(3000) + 0.30 * math.log1p(3000)
+ 0.18 * math.log1p(200) + 0.20 * math.log1p(200)
) )
self.assertAlmostEqual(expected, result) self.assertAlmostEqual(expected, result)
@@ -379,7 +286,7 @@ class SignalsV3Tests(unittest.TestCase):
) )
result = signals.engagement_raw(item) result = signals.engagement_raw(item)
self.assertIsNotNone(result) self.assertIsNotNone(result)
expected = 0.27 * math.log1p(1000) expected = 0.30 * math.log1p(1000)
self.assertAlmostEqual(expected, result) self.assertAlmostEqual(expected, result)
def test_instagram_engagement_dominant_weight(self): def test_instagram_engagement_dominant_weight(self):
-107
View File
@@ -105,112 +105,5 @@ class TestExpandTikTokQueries(unittest.TestCase):
self.assertEqual(len(queries), 1) self.assertEqual(len(queries), 1)
class TestTikTokCommentsGate(unittest.TestCase):
def test_gate_requires_key_and_token(self):
from lib import env
self.assertFalse(env.is_tiktok_comments_available({}))
self.assertFalse(env.is_tiktok_comments_available(
{"SCRAPECREATORS_API_KEY": "k"}
))
self.assertFalse(env.is_tiktok_comments_available(
{"INCLUDE_SOURCES": "tiktok_comments"}
))
self.assertTrue(env.is_tiktok_comments_available(
{"SCRAPECREATORS_API_KEY": "k", "INCLUDE_SOURCES": "tiktok,tiktok_comments"}
))
def test_gate_case_matches_youtube_pattern(self):
from lib import env
# Matches the existing youtube_comments behaviour — plain substring match via _parse_include_sources.
self.assertTrue(env.is_tiktok_comments_available(
{"SCRAPECREATORS_API_KEY": "k", "INCLUDE_SOURCES": "TIKTOK,TIKTOK_COMMENTS"}
))
class TestTikTokEnrichWithComments(unittest.TestCase):
def test_empty_items_returns_empty(self):
from lib import tiktok
self.assertEqual([], tiktok.enrich_with_comments([], token="k"))
def test_missing_token_is_noop(self):
from lib import tiktok
items = [{"video_id": "1", "url": "https://www.tiktok.com/@u/video/1", "engagement": {"views": 100}}]
result = tiktok.enrich_with_comments(items, token="")
self.assertNotIn("top_comments", result[0])
def test_fetch_post_comments_parses_sc_response(self):
from unittest.mock import patch
from lib import tiktok
fake_sc_response = {
"comments": [
{"text": "loved it", "user": {"nickname": "Alice"},
"digg_count": 420, "create_time": 1709251200},
{"text": "meh", "user": {"nickname": "Bob"},
"digg_count": 3, "create_time": 1709251300},
{"text": "", "user": {"nickname": "Skip"},
"digg_count": 999, "create_time": 1709251400},
],
"total": 3,
}
class FakeResp:
def raise_for_status(self):
pass
def json(self):
return fake_sc_response
with patch.object(tiktok, "_requests") as mock_req:
mock_req.get.return_value = FakeResp()
out = tiktok._fetch_post_comments(
"https://www.tiktok.com/@u/video/1",
token="k",
max_comments=5,
)
# Empty-text comment dropped; rest sorted desc by digg_count.
self.assertEqual(2, len(out))
self.assertEqual("loved it", out[0]["text"])
self.assertEqual(420, out[0]["digg_count"])
self.assertEqual("Alice", out[0]["author"])
self.assertEqual("2024-03-01", out[0]["date"])
self.assertEqual(3, out[1]["digg_count"])
def test_fetch_post_comments_swallows_http_error(self):
from unittest.mock import patch
from lib import tiktok
with patch.object(tiktok, "_requests") as mock_req:
mock_req.get.side_effect = Exception("429 rate limit")
out = tiktok._fetch_post_comments(
"https://www.tiktok.com/@u/video/1",
token="k",
max_comments=5,
)
self.assertEqual([], out)
def test_enrich_attaches_top_comments_to_top_ranked_items(self):
from unittest.mock import patch
from lib import tiktok
items = [
{"video_id": "low", "url": "https://www.tiktok.com/@u/video/low",
"engagement": {"views": 10, "likes": 1, "comments": 0}},
{"video_id": "high", "url": "https://www.tiktok.com/@u/video/high",
"engagement": {"views": 10000, "likes": 500, "comments": 30}},
{"video_id": "mid", "url": "https://www.tiktok.com/@u/video/mid",
"engagement": {"views": 1000, "likes": 50, "comments": 5}},
]
with patch.object(tiktok, "_fetch_post_comments") as mock_fetch:
mock_fetch.return_value = [
{"author": "A", "text": "fire", "digg_count": 100, "date": "2024-03-01"}
]
tiktok.enrich_with_comments(items, token="k", max_posts=2)
# High and mid get comments; low does not.
by_id = {i["video_id"]: i for i in items}
self.assertIn("top_comments", by_id["high"])
self.assertIn("top_comments", by_id["mid"])
self.assertNotIn("top_comments", by_id["low"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
-30
View File
@@ -1,30 +0,0 @@
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _skill_version() -> str:
text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
match = re.search(r'^version:\s*"([^"]+)"\s*$', text, re.MULTILINE)
if not match:
raise AssertionError("SKILL.md version frontmatter not found")
return match.group(1)
class TestVersionConsistency(unittest.TestCase):
def test_root_skill_header_matches_frontmatter_version(self) -> None:
text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
version = _skill_version()
self.assertIn(f"# last30days v{version}:", text)
def test_sync_cache_path_uses_skill_version(self) -> None:
sync_text = (ROOT / "scripts" / "sync.sh").read_text(encoding="utf-8")
version = _skill_version()
self.assertIn(f'last30days-3/{version}"', sync_text)
if __name__ == "__main__":
unittest.main()
+176
View File
@@ -0,0 +1,176 @@
# Changelog
## 0.8.0 — 2026-01-19
### Added
- `bookmarks` thread expansion controls (`--expand-root-only`, `--author-chain`, `--author-only`, `--full-chain-only`, `--include-ancestor-branches`, `--include-parent`, `--thread-meta`, `--sort-chronological`) for richer context exports (#55) — thanks @kkretschmer2.
- `--chrome-profile-dir` to point at Chromium profile directories or cookie DB files (Arc/Brave/etc) for cookie extraction (#16) — thanks @tekumara.
- `about` command to report account origin/location metadata (#51) — thanks @pjtf93.
- `follow`/`unfollow` commands to manage follows (#54) — thanks @citizenlee.
- Twitter client now supports like/unlike/retweet/unretweet/bookmark via the engagement mixin (#53) — thanks @the-vampiire.
### Fixed
- `bookmarks` expanded JSON now preserves pagination `nextCursor`, and full-chain filtering only includes ancestor branches when requested.
- Follow/unfollow REST fallback now supports cursor pagination for followers/following (#54).
- About account live coverage now verifies data extraction paths (#51) — thanks @pjtf93.
### Tests
- Live tests now exercise engagement mutations (opt-in) (#53) — thanks @the-vampiire.
## 0.7.0 — 2026-01-12
### Added
- `home` command for the "For You" and "Following" home timelines (#31) — thanks @odysseus0.
- `news`/`trending` command for Explore tabs with AI-curated headlines (#39) — thanks @aavetis.
- `user-tweets` command to fetch a user's profile timeline (#34) — thanks @crcatala.
- `replies` and `thread` now support pagination (`--all`, `--max-pages`, `--cursor`, `--delay`) (#35) — thanks @crcatala.
- `search` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#42) — thanks @pjtf93.
- `likes` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#44) — thanks @jsholmes.
- `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli.
- Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0.
- Long-form article tweets now render rich Draft.js content blocks/entities (#36) — thanks @crcatala.
### Changed
- Library typing: `SearchResult` is now a discriminated union (so `error` only exists when `success: false`).
### Fixed
- Lists GraphQL feature flags updated to prevent 400s (#27) — thanks @zheli.
- Lists feature overrides now scope new GraphQL flags correctly (#50) — thanks @ryanh-ai.
- Tweet detail parsing now tolerates partial GraphQL errors when usable data exists (#48) — thanks @jsholmes.
- News output now respects `--tweets-per-item`, keeps unique IDs, and parses non-add entry instructions (#39) — thanks @aavetis.
- Following/followers pagination now guards repeat cursors and standardizes JSON output (#28) — thanks @malpern.
- Likes pagination now follows cursors and avoids stalling on duplicate pages (#12) — thanks @titouv.
- macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst.
- Terminal hyperlinks now sanitize control characters before emitting OSC 8 sequences (#29) — thanks @mafulafunk.
- `pnpm run build:dist` now succeeds after tightening JSON/pagination option typing in tweet output commands.
### Tests
- Following: split following/likes tests + cover cursor handling (#33) — thanks @VACInc.
## 0.6.0 — 2026-01-05
### Added
- Bookmark exports now support pagination (`--all`, `--max-pages`) with retries (#15) — thanks @Nano1337.
- `lists` + `list-timeline` commands for Twitter Lists (#21) — thanks @harperreed
- Tweet JSON output now includes media items (photos, videos, GIFs) (#14) — thanks @Hormold
- Bookmarks can resume pagination from a cursor (#26) — thanks @leonho
- `unbookmark` command to remove bookmarked tweets (#22) — thanks @mbelinky.
### Changed
- Feature flags can be overridden at runtime via `features.json` (refreshable via `query-ids`).
### Fixed
- GraphQL feature flags now include `post_ctas_fetch_enabled` to avoid 400s (#38) — thanks @philipp-spiess.
## 0.5.1 — 2026-01-01
### Changed
- `bird --help` now includes explicit “Shortcuts” and “JSON Output” sections (documents `bird <tweet-id-or-url>` shorthand + `--json`).
- Release docs now include explicit npm publish verification steps.
### Fixed
- `pnpm bird --help` now works (dev script runs the CLI entrypoint, not the library entrypoint).
- `following`/`followers` now fall back to internal v1.1 REST endpoints when GraphQL returns `404`.
### Tests
- Add root help output regression test.
- Add opt-in live CLI test suite (real GraphQL calls; skipped by default; gated via `BIRD_LIVE=1`).
## 0.5.0 — 2026-01-01
### Added
- `likes` command to list your liked tweets (thanks @swairshah).
- Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml).
- `following`/`followers` commands to list users (thanks @lockmeister).
### Changed
- Query ID updater now tracks the Likes GraphQL operation.
- Query ID updater now tracks Following/Followers GraphQL operations.
- Query ID updater now tracks BookmarkFolderTimeline and keeps bookmark query IDs seeded.
- `following`/`followers` JSON user fields are now camelCase (`followersCount`, `followingCount`, `isBlueVerified`, `profileImageUrl`, `createdAt`).
- Cookie extraction timeout is now configurable (default 30s on macOS) via `--cookie-timeout` / `BIRD_COOKIE_TIMEOUT_MS` (thanks @tylerseymour).
- Search now paginates beyond 20 results when using `-n` (thanks @ryanh-ai).
- Library exports are now separated from the CLI entrypoint for easier embedding.
## 0.4.1 — 2025-12-31
### Added
- `bookmarks` command to list your bookmarked tweets.
- `bookmarks --folder-id` to fetch bookmark folders (thanks @tylerseymour).
### Changed
- Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`).
- Query ID updater now tracks the Bookmarks GraphQL operation.
- Lint rules stricter (block statements, no-negation-else, useConst/useTemplate, top-level regex, import extension enforcement).
- `pnpm lint` now runs both Biome and oxlint (type-aware).
### Tests
- Coverage thresholds raised to 90% statements/lines/functions (80% branches).
- Added targeted Twitter client coverage suites.
## 0.4.0 — 2025-12-26
### Added
- Cookie source selection: `--cookie-source safari|chrome|firefox` (repeatable) + `cookieSource` config (string or array).
### Fixed
- `tweet`/`reply`: fallback to `statuses/update.json` when GraphQL `CreateTweet` returns error 226 (“automated request”).
### Breaking
- Remove `allowSafari`/`allowChrome`/`allowFirefox` config toggles in favor of `cookieSource` ordering.
## 0.3.0 — 2025-12-26
### Added
- Safari cookie extraction (`Cookies.binarycookies`) + `allowSafari` config toggle.
### Changed
- Removed the Sweetistics engine + fallback. `bird` is GraphQL-only.
- Browser cookie fallback order: Safari → Chrome → Firefox.
### Tests
- Enforce coverage thresholds (>= 70% statements/branches/functions/lines) + expand unit coverage for version/output/Twitter client branches.
## 0.2.0 — 2025-12-26
### Added
- Output controls: `--plain`, `--no-emoji`, `--no-color` (respects `NO_COLOR`).
- `help` command: `bird help <command>`.
- Runtime GraphQL query ID refresh: `bird query-ids --fresh` (cached on disk; auto-retry on 404; override cache via `BIRD_QUERY_IDS_CACHE`).
- GraphQL media uploads via `--media` (up to 4 images/GIFs, or 1 video).
### Fixed
- CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string) + append git sha when available.
### Changed
- `mentions`: no hardcoded user; defaults to authenticated user or accepts `--user @handle`.
- GraphQL query ID updater: correctly pairs `operationName``queryId` (CreateTweet/CreateRetweet/etc).
- `build:dist`: copies `src/lib/query-ids.json` into `dist/lib/query-ids.json` (keeps `dist/` in sync).
- `--engine graphql`: strict GraphQL-only (disables Sweetistics fallback).
## 0.1.1 — 2025-12-26
### Changed
- Engine default now `auto` (GraphQL primary; Sweetistics only on fallback when configured).
### Tests
- Add engine resolution tests for auto/default behavior.
### Fixed
- GraphQL read: rotate TweetDetail query IDs with fallback to avoid 404s.
## 0.1.0 — 2025-12-20
### Added
- CLI commands: `tweet`, `reply`, `read`, `replies`, `thread`, `search`, `mentions`, `whoami`, `check`.
- URL/ID shorthand for `read`, plus `--json` output where supported.
- GraphQL engine with cookie auth from Firefox/Chrome/env/flags (macOS browsers).
- Sweetistics engine (API key) with automatic fallback when configured.
- Media uploads via Sweetistics with per-item alt text (images or single video).
- Long-form Notes and Articles extraction for full text output.
- Thread + reply fetching with full conversation parsing.
- Search + mentions via GraphQL (latest timeline).
- JSON5 config files (`~/.config/bird/config.json5`, `./.birdrc.json5`) with engine defaults, profiles, allowChrome/allowFirefox, and timeoutMs.
- Request timeouts (`--timeout`, `timeoutMs`) for GraphQL and Sweetistics calls.
- Bun-compiled standalone binary via `pnpm run build`.
- Query ID refresh helper: `pnpm run graphql:update`.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+385
View File
@@ -0,0 +1,385 @@
# bird 🐦 — fast X CLI for tweeting, replying, and reading
`bird` is a fast X CLI for tweeting, replying, and reading via X/Twitter GraphQL (cookie auth).
## Disclaimer
This project uses X/Twitters **undocumented** web GraphQL API (and cookie auth). X can change endpoints, query IDs,
and anti-bot behavior at any time — **expect this to break without notice**.
## Install
```bash
npm install -g @steipete/bird
# or
pnpm add -g @steipete/bird
# or
bun add -g @steipete/bird
# one-shot (no install)
bunx @steipete/bird whoami
```
Homebrew (macOS, prebuilt Bun binary):
```bash
brew install steipete/tap/bird
```
## Quickstart
```bash
# Show the logged-in account
bird whoami
# Discover command help
bird help whoami
# Read a tweet (URL or ID)
bird read https://x.com/user/status/1234567890123456789
bird 1234567890123456789 --json
# Thread + replies
bird thread https://x.com/user/status/1234567890123456789
bird replies 1234567890123456789
bird replies 1234567890123456789 --max-pages 3 --json
bird thread 1234567890123456789 --max-pages 3 --json
# Search + mentions
bird search "from:steipete" -n 5
bird mentions -n 5
bird mentions --user @steipete -n 5
# User tweets (profile timeline)
bird user-tweets @steipete -n 20
bird user-tweets @steipete -n 50 --json
# Bookmarks
bird bookmarks -n 5
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
bird bookmarks --all --json
bird bookmarks --all --max-pages 2 --json
bird bookmarks --include-parent --json
bird unbookmark 1234567890123456789
bird unbookmark https://x.com/user/status/1234567890123456789
# Likes
bird likes -n 5
# News and trending topics (AI-curated from Explore tabs)
bird news --ai-only -n 10
bird news --sports -n 5
# Lists
bird list-timeline 1234567890 -n 20
bird list-timeline https://x.com/i/lists/1234567890 --all --json
bird list-timeline 1234567890 --max-pages 3 --json
# Following (who you follow)
bird following -n 20
bird following --user 12345678 -n 10 # by user ID
# Followers (who follows you)
bird followers -n 20
bird followers --user 12345678 -n 10 # by user ID
# Refresh GraphQL query IDs cache (no rebuild)
bird query-ids --fresh
```
## News & Trending
Fetch AI-curated news and trending topics from X's Explore page tabs:
```bash
# Fetch 10 news items from all tabs (default: For You, News, Sports, Entertainment)
bird news -n 10
# Fetch only AI-curated news (filters out regular trends)
bird news --ai-only -n 20
# Fetch from specific tabs
bird news --news-only --ai-only -n 10
bird news --sports -n 15
bird news --entertainment --ai-only -n 5
# Include related tweets for each news item
bird news --with-tweets --tweets-per-item 3 -n 10
# Combine multiple tab filters
bird news --sports --entertainment -n 20
# JSON output
bird news --json -n 5
bird news --json-full --ai-only -n 10 # includes raw API response
```
Tab options (can be combined):
- `--for-you` — Fetch from For You tab only
- `--news-only` — Fetch from News tab only
- `--sports` — Fetch from Sports tab only
- `--entertainment` — Fetch from Entertainment tab only
- `--trending-only` — Fetch from Trending tab only
By default, the command fetches from For You, News, Sports, and Entertainment tabs (Trending excluded to reduce noise). Headlines are automatically deduplicated across tabs.
## Library
`bird` can be used as a library (same GraphQL client as the CLI):
```ts
import { TwitterClient, resolveCredentials } from '@steipete/bird';
const { cookies } = await resolveCredentials({ cookieSource: 'safari' });
const client = new TwitterClient({ cookies });
// Search for tweets
const searchResult = await client.search('from:steipete', 50);
// Fetch news and trending topics from all tabs (default: For You, News, Sports, Entertainment)
const newsResult = await client.getNews(10, { aiOnly: true });
// Fetch from specific tabs with related tweets
const sportsNews = await client.getNews(10, {
aiOnly: true,
withTweets: true,
tabs: ['sports', 'entertainment']
});
```
Account details (About profile):
```ts
const aboutResult = await client.getUserAboutAccount('steipete');
if (aboutResult.success && aboutResult.aboutProfile) {
console.log(aboutResult.aboutProfile.accountBasedIn);
}
```
Fields:
- `accountBasedIn`
- `source`
- `createdCountryAccurate`
- `locationAccurate`
- `learnMoreUrl`
## Commands
- `bird tweet "<text>"` — post a new tweet.
- `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL.
- `bird help [command]` — show help (or help for a subcommand).
- `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs.
- `bird home [-n count] [--following] [--json] [--json-full]` — fetch your home timeline (For You) or Following feed.
- `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON.
- `bird <tweet-id-or-url> [--json]` — shorthand for `read` when only a URL or ID is provided.
- `bird replies <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — list replies to a tweet.
- `bird thread <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — show the full conversation thread.
- `bird search "<query>" [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — search for tweets matching a query; `--max-pages` requires `--all` or `--cursor`.
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
- `bird user-tweets <@handle> [-n count] [--cursor string] [--max-pages n] [--delay ms] [--json]` — get tweets from a user's profile timeline.
- `bird bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--cursor string] [--expand-root-only] [--author-chain] [--author-only] [--full-chain-only] [--include-ancestor-branches] [--include-parent] [--thread-meta] [--sort-chronological] [--json]` — list your bookmarked tweets (or a specific bookmark folder); expansion flags control thread context; `--max-pages` requires `--all` or `--cursor`.
- `bird unbookmark <tweet-id-or-url...>` — remove one or more bookmarks by tweet ID or URL.
- `bird likes [-n count] [--all] [--max-pages n] [--cursor string] [--json] [--json-full]` — list your liked tweets; `--max-pages` requires `--all` or `--cursor`.
- `bird news [-n count] [--ai-only] [--with-tweets] [--tweets-per-item n] [--for-you] [--news-only] [--sports] [--entertainment] [--trending-only] [--json]` — fetch news and trending topics from X's Explore tabs.
- `bird trending` — alias for `news` command.
- `bird lists [--member-of] [-n count] [--json]` — list your lists (owned or memberships).
- `bird list-timeline <list-id-or-url> [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — get tweets from a list timeline; `--max-pages` implies `--all`.
- `bird following [--user <userId>] [-n count] [--cursor string] [--all] [--max-pages n] [--json]` — list users that you (or another user) follow; `--max-pages` requires `--all`.
- `bird followers [--user <userId>] [-n count] [--cursor string] [--all] [--max-pages n] [--json]` — list users that follow you (or another user); `--max-pages` requires `--all`.
- `bird about <@handle> [--json]` — get account origin and location information for a user.
- `bird whoami` — print which Twitter account your cookies belong to.
- `bird check` — show which credentials are available and where they were sourced from.
Bookmarks flags:
- `--expand-root-only`: expand threads only when the bookmark is a root tweet.
- `--author-chain`: keep only the bookmarked author's connected self-reply chain.
- `--author-only`: include all tweets from the bookmarked author within the thread.
- `--full-chain-only`: keep the entire reply chain connected to the bookmarked tweet (all authors).
- `--include-ancestor-branches`: include sibling branches for ancestors when using `--full-chain-only`.
- `--include-parent`: include the direct parent tweet for non-root bookmarks.
- `--thread-meta`: add thread metadata fields to each tweet.
- `--sort-chronological`: sort output globally oldest to newest (default preserves bookmark order).
Global options:
- `--auth-token <token>`: set the `auth_token` cookie manually.
- `--ct0 <token>`: set the `ct0` cookie manually.
- `--cookie-source <safari|chrome|firefox>`: choose browser cookie source (repeatable; order matters).
- `--chrome-profile <name>`: Chrome profile name for cookie extraction (e.g., `Default`, `Profile 2`).
- `--chrome-profile-dir <path>`: Chrome/Chromium profile directory or cookie DB path for cookie extraction.
- `--firefox-profile <name>`: Firefox profile for cookie extraction.
- `--cookie-timeout <ms>`: cookie extraction timeout for keychain/OS helpers (milliseconds).
- `--timeout <ms>`: abort requests after the given timeout (milliseconds).
- `--quote-depth <n>`: max quoted tweet depth in JSON output (default: 1; 0 disables).
- `--plain`: stable output (no emoji, no color).
- `--no-emoji`: disable emoji output.
- `--no-color`: disable ANSI colors (or set `NO_COLOR=1`).
- `--media <path>`: attach media file (repeatable, up to 4 images or 1 video).
- `--alt <text>`: alt text for the corresponding `--media` (repeatable).
## Authentication (GraphQL)
GraphQL mode uses your existing X/Twitter web session (no password prompt). It sends requests to internal
X endpoints and authenticates via cookies (`auth_token`, `ct0`).
Write operations:
- `tweet`/`reply` primarily use GraphQL (`CreateTweet`).
- If GraphQL returns error `226` (“automated request”), `bird` falls back to the legacy `statuses/update.json` endpoint.
`bird` resolves credentials in this order:
1. CLI flags: `--auth-token`, `--ct0`
2. Environment variables: `AUTH_TOKEN`, `CT0` (fallback: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`)
3. Browser cookies via `@steipete/sweet-cookie` (override via `--cookie-source` order)
Browser cookie sources:
- Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`)
- Chrome: `~/Library/Application Support/Google/Chrome/<Profile>/Cookies`
- Firefox: `~/Library/Application Support/Firefox/Profiles/<profile>/cookies.sqlite`
- For Chromium variants (Arc/Brave/etc), pass a profile directory or cookie DB via `--chrome-profile-dir`.
## Config (JSON5)
Config precedence: CLI flags > env vars > project config > global config.
- Global: `~/.config/bird/config.json5`
- Project: `./.birdrc.json5`
Example `~/.config/bird/config.json5`:
```json5
{
// Cookie source order for browser extraction (string or array)
cookieSource: ["firefox", "safari"],
chromeProfileDir: "/path/to/Chromium/Profile",
firefoxProfile: "default-release",
cookieTimeoutMs: 30000,
timeoutMs: 20000,
quoteDepth: 1
}
```
Environment shortcuts:
- `BIRD_TIMEOUT_MS`
- `BIRD_COOKIE_TIMEOUT_MS`
- `BIRD_QUOTE_DEPTH`
## Output
- `--json` prints raw tweet objects for read/replies/thread/search/mentions/user-tweets/bookmarks/likes.
- When using `--json` with pagination (`--all`, `--cursor`, `--max-pages`, or for `user-tweets` when `-n > 20`), output is `{ tweets, nextCursor }`.
- `read` returns full text for Notes and Articles when present.
- Use `--plain` for stable, script-friendly output (no emoji, no color).
### JSON Schema
When using `--json`, tweet objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Tweet ID |
| `text` | string | Full tweet text (includes Note/Article content when present) |
| `author` | object | `{ username, name }` |
| `authorId` | string? | Author's user ID |
| `createdAt` | string | Timestamp |
| `replyCount` | number | Number of replies |
| `retweetCount` | number | Number of retweets |
| `likeCount` | number | Number of likes |
| `conversationId` | string | Thread conversation ID |
| `inReplyToStatusId` | string? | Parent tweet ID (present if this is a reply) |
| `quotedTweet` | object? | Embedded quote tweet (same schema; depth controlled by `--quote-depth`) |
When using `--json` with `following`/`followers`, user objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | User ID |
| `username` | string | Username/handle |
| `name` | string | Display name |
| `description` | string? | User bio |
| `followersCount` | number? | Followers count |
| `followingCount` | number? | Following count |
| `isBlueVerified` | boolean? | Blue verified flag |
| `profileImageUrl` | string? | Profile image URL |
| `createdAt` | string? | Account creation timestamp |
When using `--json` with `news`/`trending`, news objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Unique identifier for the news item |
| `headline` | string | News headline or trend title |
| `category` | string? | Category (e.g., "AI · Technology", "Trending", "News") |
| `timeAgo` | string? | Relative time (e.g., "2h ago") |
| `postCount` | number? | Number of posts |
| `description` | string? | Item description |
| `url` | string? | URL to the trend or news article |
| `tweets` | array? | Related tweets (only when `--with-tweets` is used) |
| `_raw` | object? | Raw API response (only when `--json-full` is used) |
## Query IDs (GraphQL)
X rotates GraphQL “query IDs” frequently. Each GraphQL operation is addressed as:
- `operationName` (e.g. `TweetDetail`, `CreateTweet`)
- `queryId` (rotating ID baked into Xs web client bundles)
`bird` ships with a baseline mapping in `src/lib/query-ids.json` (copied into `dist/` on build). At runtime,
it can refresh that mapping by scraping Xs public web client bundles and caching the result on disk.
Runtime cache:
- Default path: `~/.config/bird/query-ids-cache.json`
- Override path: `BIRD_QUERY_IDS_CACHE=/path/to/file.json`
- TTL: 24h (stale cache is still used, but marked “not fresh”)
Auto-recovery:
- On GraphQL `404` (query ID invalid), `bird` forces a refresh once and retries.
- For `TweetDetail`/`SearchTimeline`, `bird` also rotates through a small set of known fallback IDs to reduce
breakage while refreshing.
Refresh on demand:
```bash
bird query-ids --fresh
```
Exit codes:
- `0`: success
- `1`: runtime error (network/auth/etc)
- `2`: invalid usage/validation (e.g. bad `--user` handle)
## Version
`bird --version` prints `package.json` version plus current git sha when available, e.g. `0.3.0 (3df7969b)`.
## Media uploads
- Attach media with `--media` (repeatable) and optional `--alt` per item.
- Up to 4 images/GIFs, or 1 video (no mixing). Supported: jpg, jpeg, png, webp, gif, mp4, mov.
- Images/GIFs + 1 video supported (uploads via Twitter legacy upload endpoint + cookies; video may take longer to process).
Example:
```bash
bird tweet "hi" --media img.png --alt "desc"
```
## Development
```bash
cd ~/Projects/bird
pnpm install
pnpm run build # dist/ + bun binary
pnpm run build:dist # dist/ only
pnpm run build:binary
pnpm run dev tweet "Test"
pnpm run dev -- --plain check
pnpm test
pnpm run lint
```
## Notes
- GraphQL uses internal X endpoints and can be rate limited (429).
- Query IDs rotate; refresh at runtime with `bird query-ids --fresh` (or update the baked baseline via `pnpm run graphql:update`).
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env node
/**
* bird - CLI tool for posting tweets and replies
*
* Usage:
* bird tweet "Hello world!"
* bird reply <tweet-id> "This is a reply"
* bird reply <tweet-url> "This is a reply"
* bird read <tweet-id-or-url>
*/
export {};
//# sourceMappingURL=cli.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;;;GAQG"}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env node
/**
* bird - CLI tool for posting tweets and replies
*
* Usage:
* bird tweet "Hello world!"
* bird reply <tweet-id> "This is a reply"
* bird reply <tweet-url> "This is a reply"
* bird read <tweet-id-or-url>
*/
import { createProgram, KNOWN_COMMANDS } from './cli/program.js';
import { createCliContext } from './cli/shared.js';
import { resolveCliInvocation } from './lib/cli-args.js';
const rawArgs = process.argv.slice(2);
const normalizedArgs = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
const ctx = createCliContext(normalizedArgs);
const program = createProgram(ctx);
const { argv, showHelp } = resolveCliInvocation(normalizedArgs, KNOWN_COMMANDS);
if (showHelp) {
program.outputHelp();
process.exit(0);
}
if (argv) {
program.parse(argv);
}
else {
program.parse(['node', 'bird', ...normalizedArgs]);
}
//# sourceMappingURL=cli.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;;;GAQG;AAEH,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAEzD,MAAM,OAAO,GAAa,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AAChD,MAAM,cAAc,GAAa,OAAO,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;AAElF,MAAM,GAAG,GAAG,gBAAgB,CAAC,cAAc,CAAC,CAAC;AAE7C,MAAM,OAAO,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;AAEnC,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,oBAAoB,CAAC,cAAc,EAAE,cAAc,CAAC,CAAC;AAEhF,IAAI,QAAQ,EAAE,CAAC;IACb,OAAO,CAAC,UAAU,EAAE,CAAC;IACrB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,IAAI,IAAI,EAAE,CAAC;IACT,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AACtB,CAAC;KAAM,CAAC;IACN,OAAO,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,cAAc,CAAC,CAAC,CAAC;AACrD,CAAC"}
+35
View File
@@ -0,0 +1,35 @@
export type PaginationCmdOpts = {
all?: boolean;
maxPages?: string;
cursor?: string;
delay?: string;
};
export declare function parsePositiveIntFlag(raw: string | undefined, flagName: string): {
ok: true;
value: number | undefined;
} | {
ok: false;
error: string;
};
export declare function parseNonNegativeIntFlag(raw: string | undefined, flagName: string, defaultValue: number): {
ok: true;
value: number;
} | {
ok: false;
error: string;
};
export declare function parsePaginationFlags(cmdOpts: PaginationCmdOpts, opts?: {
maxPagesImpliesPagination?: boolean;
defaultDelayMs?: number;
includeDelay?: boolean;
}): {
ok: true;
usePagination: boolean;
maxPages?: number;
cursor?: string;
pageDelayMs?: number;
} | {
ok: false;
error: string;
};
//# sourceMappingURL=pagination.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"pagination.d.ts","sourceRoot":"","sources":["../../src/cli/pagination.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG;IAC9B,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,QAAQ,EAAE,MAAM,GACf;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CASxE;AAED,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,GACnB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAM5D;AAED,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,iBAAiB,EAC1B,IAAI,CAAC,EAAE;IACL,yBAAyB,CAAC,EAAE,OAAO,CAAC;IACpC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,GAEC;IACE,EAAE,EAAE,IAAI,CAAC;IACT,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GACD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CA8B/B"}
+43
View File
@@ -0,0 +1,43 @@
export function parsePositiveIntFlag(raw, flagName) {
if (raw === undefined) {
return { ok: true, value: undefined };
}
const value = Number.parseInt(raw, 10);
if (!Number.isFinite(value) || value <= 0) {
return { ok: false, error: `Invalid ${flagName}. Expected a positive integer.` };
}
return { ok: true, value };
}
export function parseNonNegativeIntFlag(raw, flagName, defaultValue) {
const value = Number.parseInt(raw ?? String(defaultValue), 10);
if (!Number.isFinite(value) || value < 0) {
return { ok: false, error: `Invalid ${flagName}. Expected a non-negative integer.` };
}
return { ok: true, value };
}
export function parsePaginationFlags(cmdOpts, opts) {
const maxPagesImpliesPagination = opts?.maxPagesImpliesPagination ?? false;
const includeDelay = opts?.includeDelay ?? false;
const defaultDelayMs = opts?.defaultDelayMs ?? 1000;
const maxPages = parsePositiveIntFlag(cmdOpts.maxPages, '--max-pages');
if (!maxPages.ok) {
return maxPages;
}
const usePagination = Boolean(cmdOpts.all || cmdOpts.cursor || (maxPagesImpliesPagination && maxPages.value !== undefined));
let pageDelayMs;
if (includeDelay) {
const delay = parseNonNegativeIntFlag(cmdOpts.delay, '--delay', defaultDelayMs);
if (!delay.ok) {
return delay;
}
pageDelayMs = delay.value;
}
return {
ok: true,
usePagination,
maxPages: maxPages.value,
cursor: cmdOpts.cursor,
pageDelayMs,
};
}
//# sourceMappingURL=pagination.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"pagination.js","sourceRoot":"","sources":["../../src/cli/pagination.ts"],"names":[],"mappings":"AAOA,MAAM,UAAU,oBAAoB,CAClC,GAAuB,EACvB,QAAgB;IAEhB,IAAI,GAAG,KAAK,SAAS,EAAE,CAAC;QACtB,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;IACxC,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;IACvC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,EAAE,CAAC;QAC1C,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,QAAQ,gCAAgC,EAAE,CAAC;IACnF,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,uBAAuB,CACrC,GAAuB,EACvB,QAAgB,EAChB,YAAoB;IAEpB,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,IAAI,MAAM,CAAC,YAAY,CAAC,EAAE,EAAE,CAAC,CAAC;IAC/D,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;QACzC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,QAAQ,oCAAoC,EAAE,CAAC;IACvF,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,oBAAoB,CAClC,OAA0B,EAC1B,IAIC;IAUD,MAAM,yBAAyB,GAAG,IAAI,EAAE,yBAAyB,IAAI,KAAK,CAAC;IAC3E,MAAM,YAAY,GAAG,IAAI,EAAE,YAAY,IAAI,KAAK,CAAC;IACjD,MAAM,cAAc,GAAG,IAAI,EAAE,cAAc,IAAI,IAAI,CAAC;IAEpD,MAAM,QAAQ,GAAG,oBAAoB,CAAC,OAAO,CAAC,QAAQ,EAAE,aAAa,CAAC,CAAC;IACvE,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,MAAM,aAAa,GAAG,OAAO,CAC3B,OAAO,CAAC,GAAG,IAAI,OAAO,CAAC,MAAM,IAAI,CAAC,yBAAyB,IAAI,QAAQ,CAAC,KAAK,KAAK,SAAS,CAAC,CAC7F,CAAC;IAEF,IAAI,WAA+B,CAAC;IACpC,IAAI,YAAY,EAAE,CAAC;QACjB,MAAM,KAAK,GAAG,uBAAuB,CAAC,OAAO,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,CAAC,CAAC;QAChF,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC;YACd,OAAO,KAAK,CAAC;QACf,CAAC;QACD,WAAW,GAAG,KAAK,CAAC,KAAK,CAAC;IAC5B,CAAC;IAED,OAAO;QACL,EAAE,EAAE,IAAI;QACR,aAAa;QACb,QAAQ,EAAE,QAAQ,CAAC,KAAK;QACxB,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,WAAW;KACZ,CAAC;AACJ,CAAC"}
+5
View File
@@ -0,0 +1,5 @@
import { Command } from 'commander';
import { type CliContext } from './shared.js';
export declare const KNOWN_COMMANDS: Set<string>;
export declare function createProgram(ctx: CliContext): Command;
//# sourceMappingURL=program.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"program.d.ts","sourceRoot":"","sources":["../../src/cli/program.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgBpC,OAAO,EAAE,KAAK,UAAU,EAAuB,MAAM,aAAa,CAAC;AAEnE,eAAO,MAAM,cAAc,aAyBzB,CAAC;AAEH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CA+GtD"}
+113
View File
@@ -0,0 +1,113 @@
import { Command } from 'commander';
import { registerBookmarksCommand } from '../commands/bookmarks.js';
import { registerCheckCommand } from '../commands/check.js';
import { registerFollowCommands } from '../commands/follow.js';
import { registerHelpCommand } from '../commands/help.js';
import { registerHomeCommand } from '../commands/home.js';
import { registerListsCommand } from '../commands/lists.js';
import { registerNewsCommand } from '../commands/news.js';
import { registerPostCommands } from '../commands/post.js';
import { registerQueryIdsCommand } from '../commands/query-ids.js';
import { registerReadCommands } from '../commands/read.js';
import { registerSearchCommands } from '../commands/search.js';
import { registerUnbookmarkCommand } from '../commands/unbookmark.js';
import { registerUserTweetsCommand } from '../commands/user-tweets.js';
import { registerUserCommands } from '../commands/users.js';
import { getCliVersion } from '../lib/version.js';
import { collectCookieSource } from './shared.js';
export const KNOWN_COMMANDS = new Set([
'tweet',
'reply',
'query-ids',
'read',
'replies',
'thread',
'search',
'mentions',
'bookmarks',
'unbookmark',
'follow',
'unfollow',
'following',
'followers',
'likes',
'lists',
'list-timeline',
'home',
'user-tweets',
'news',
'trending',
'help',
'whoami',
'check',
]);
export function createProgram(ctx) {
const program = new Command();
program.configureHelp({
showGlobalOptions: true,
styleTitle: (t) => ctx.colors.section(t),
styleUsage: (t) => ctx.colors.description(t),
styleCommandText: (t) => ctx.colors.command(t),
styleCommandDescription: (t) => ctx.colors.muted(t),
styleOptionTerm: (t) => ctx.colors.option(t),
styleOptionText: (t) => ctx.colors.option(t),
styleOptionDescription: (t) => ctx.colors.muted(t),
styleArgumentTerm: (t) => ctx.colors.argument(t),
styleArgumentText: (t) => ctx.colors.argument(t),
styleArgumentDescription: (t) => ctx.colors.muted(t),
styleSubcommandTerm: (t) => ctx.colors.command(t),
styleSubcommandText: (t) => ctx.colors.command(t),
styleSubcommandDescription: (t) => ctx.colors.muted(t),
styleDescriptionText: (t) => ctx.colors.muted(t),
});
const collect = (value, previous = []) => {
previous.push(value);
return previous;
};
program.addHelpText('beforeAll', () => `${ctx.colors.banner('bird')} ${ctx.colors.muted(getCliVersion())} ${ctx.colors.subtitle('— fast X CLI for tweeting, replying, and reading')}`);
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
const formatExample = (command, description) => `${ctx.colors.command(` ${command}`)}\n${ctx.colors.muted(` ${description}`)}`;
program.addHelpText('afterAll', () => `\n${ctx.colors.section('Examples')}\n${[
formatExample('bird whoami', 'Show the logged-in account via GraphQL cookies'),
formatExample('bird --firefox-profile default-release whoami', 'Use Firefox profile cookies'),
formatExample('bird tweet "hello from bird"', 'Send a tweet'),
formatExample('bird 1234567890123456789 --json', 'Read a tweet (ID or URL shorthand for `read`) and print JSON'),
].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[
formatExample('bird <tweet-id-or-url> [--json]', 'Shorthand for `bird read <tweet-id-or-url>`'),
].join('\n\n')}\n\n${ctx.colors.section('JSON Output')}\n${ctx.colors.muted(` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, about, lists, list-timeline, user-tweets, query-ids`)}\n${ctx.colors.muted(` Add ${ctx.colors.option('--json-full')} to include raw API response in ${ctx.colors.argument('_raw')} field (tweet commands only)`)}\n${ctx.colors.muted(` (Run ${ctx.colors.command('bird <command> --help')} to see per-command flags.)`)}`);
program.addHelpText('afterAll', () => `\n\n${ctx.colors.section('Config')}\n${ctx.colors.muted(` Reads ${ctx.colors.argument('~/.config/bird/config.json5')} and ${ctx.colors.argument('./.birdrc.json5')} (JSON5)`)}\n${ctx.colors.muted(` Supports: chromeProfile, chromeProfileDir, firefoxProfile, cookieSource, cookieTimeoutMs, timeoutMs, quoteDepth`)}\n\n${ctx.colors.section('Env')}\n${ctx.colors.muted(` ${ctx.colors.option('NO_COLOR')}, ${ctx.colors.option('BIRD_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_COOKIE_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_QUOTE_DEPTH')}`)}`);
program
.option('--auth-token <token>', 'Twitter auth_token cookie')
.option('--ct0 <token>', 'Twitter ct0 cookie')
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction', ctx.config.chromeProfile)
.option('--chrome-profile-dir <path>', 'Chrome/Chromium profile directory or cookie DB path for cookie extraction', ctx.config.chromeProfileDir)
.option('--firefox-profile <name>', 'Firefox profile name for cookie extraction', ctx.config.firefoxProfile)
.option('--cookie-timeout <ms>', 'Cookie extraction timeout in milliseconds (keychain/OS helpers)')
.option('--cookie-source <source>', 'Cookie source for browser cookie extraction (repeatable)', collectCookieSource)
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect)
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect)
.option('--timeout <ms>', 'Request timeout in milliseconds')
.option('--quote-depth <depth>', 'Max quoted tweet depth (default: 1; 0 disables)')
.option('--plain', 'Plain output (stable, no emoji, no color)')
.option('--no-emoji', 'Disable emoji output')
.option('--no-color', 'Disable ANSI colors (or set NO_COLOR)');
program.hook('preAction', (_thisCommand, actionCommand) => {
ctx.applyOutputFromCommand(actionCommand);
});
registerHelpCommand(program, ctx);
registerQueryIdsCommand(program, ctx);
registerPostCommands(program, ctx);
registerReadCommands(program, ctx);
registerSearchCommands(program, ctx);
registerBookmarksCommand(program, ctx);
registerUnbookmarkCommand(program, ctx);
registerFollowCommands(program, ctx);
registerListsCommand(program, ctx);
registerHomeCommand(program, ctx);
registerUserCommands(program, ctx);
registerUserTweetsCommand(program, ctx);
registerNewsCommand(program, ctx);
registerCheckCommand(program, ctx);
return program;
}
//# sourceMappingURL=program.js.map
File diff suppressed because one or more lines are too long
+77
View File
@@ -0,0 +1,77 @@
import type { Command } from 'commander';
import { type CookieSource, resolveCredentials } from '../lib/cookies.js';
import { labelPrefix, type OutputConfig, statusPrefix } from '../lib/output.js';
import type { TweetData } from '../lib/twitter-client.js';
export type BirdConfig = {
chromeProfile?: string;
chromeProfileDir?: string;
firefoxProfile?: string;
cookieSource?: CookieSource | CookieSource[];
cookieTimeoutMs?: number;
timeoutMs?: number;
quoteDepth?: number;
};
export type MediaSpec = {
path: string;
alt?: string;
mime: string;
buffer: Buffer;
};
export type CliContext = {
isTty: boolean;
getOutput: () => OutputConfig;
colors: {
banner: (t: string) => string;
subtitle: (t: string) => string;
section: (t: string) => string;
bullet: (t: string) => string;
command: (t: string) => string;
option: (t: string) => string;
argument: (t: string) => string;
description: (t: string) => string;
muted: (t: string) => string;
accent: (t: string) => string;
};
p: (kind: Parameters<typeof statusPrefix>[0]) => string;
l: (kind: Parameters<typeof labelPrefix>[0]) => string;
config: BirdConfig;
applyOutputFromCommand: (command: Command) => void;
resolveTimeoutFromOptions: (options: {
timeout?: string | number;
}) => number | undefined;
resolveQuoteDepthFromOptions: (options: {
quoteDepth?: string | number;
}) => number | undefined;
resolveCredentialsFromOptions: (opts: CredentialsOptions) => ReturnType<typeof resolveCredentials>;
loadMedia: (opts: {
media: string[];
alts: string[];
}) => MediaSpec[];
printTweets: (tweets: TweetData[], opts?: {
json?: boolean;
emptyMessage?: string;
showSeparator?: boolean;
}) => void;
printTweetsResult: (result: {
tweets?: TweetData[];
nextCursor?: string;
}, opts: {
json: boolean;
usePagination: boolean;
emptyMessage: string;
}) => void;
extractTweetId: (tweetIdOrUrl: string) => string;
};
export declare const collectCookieSource: (value: string, previous?: CookieSource[]) => CookieSource[];
type CredentialsOptions = {
authToken?: string;
ct0?: string;
chromeProfile?: string;
chromeProfileDir?: string;
firefoxProfile?: string;
cookieSource?: CookieSource[];
cookieTimeout?: string | number;
};
export declare function createCliContext(normalizedArgs: string[], env?: NodeJS.ProcessEnv): CliContext;
export {};
//# sourceMappingURL=shared.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/cli/shared.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAGzC,OAAO,EAAE,KAAK,YAAY,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAE1E,OAAO,EAEL,WAAW,EACX,KAAK,YAAY,EAGjB,YAAY,EACb,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAE1D,MAAM,MAAM,UAAU,GAAG;IACvB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,YAAY,GAAG,YAAY,EAAE,CAAC;IAC7C,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAErF,MAAM,MAAM,UAAU,GAAG;IACvB,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,MAAM,YAAY,CAAC;IAC9B,MAAM,EAAE;QACN,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC9B,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAChC,OAAO,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC/B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC9B,OAAO,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC/B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC9B,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAChC,WAAW,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QACnC,KAAK,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC7B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;KAC/B,CAAC;IACF,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC;IACxD,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC;IACvD,MAAM,EAAE,UAAU,CAAC;IACnB,sBAAsB,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IACnD,yBAAyB,EAAE,CAAC,OAAO,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,KAAK,MAAM,GAAG,SAAS,CAAC;IAC1F,4BAA4B,EAAE,CAAC,OAAO,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,KAAK,MAAM,GAAG,SAAS,CAAC;IAChG,6BAA6B,EAAE,CAAC,IAAI,EAAE,kBAAkB,KAAK,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;IACnG,SAAS,EAAE,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,IAAI,EAAE,MAAM,EAAE,CAAA;KAAE,KAAK,SAAS,EAAE,CAAC;IACtE,WAAW,EAAE,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,IAAI,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,OAAO,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IACtH,iBAAiB,EAAE,CACjB,MAAM,EAAE;QACN,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;QACrB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,EACD,IAAI,EAAE;QACJ,IAAI,EAAE,OAAO,CAAC;QACd,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,MAAM,CAAC;KACtB,KACE,IAAI,CAAC;IACV,cAAc,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC;CAClD,CAAC;AAYF,eAAO,MAAM,mBAAmB,GAAI,OAAO,MAAM,EAAE,WAAU,YAAY,EAAO,KAAG,YAAY,EAG9F,CAAC;AA4FF,KAAK,kBAAkB,GAAG;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,YAAY,EAAE,CAAC;IAC9B,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACjC,CAAC;AAEF,wBAAgB,gBAAgB,CAAC,cAAc,EAAE,MAAM,EAAE,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,UAAU,CA2P3G"}
+327
View File
@@ -0,0 +1,327 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import JSON5 from 'json5';
import kleur from 'kleur';
import { resolveCredentials } from '../lib/cookies.js';
import { extractTweetId } from '../lib/extract-tweet-id.js';
import { hyperlink, labelPrefix, resolveOutputConfigFromArgv, resolveOutputConfigFromCommander, statusPrefix, } from '../lib/output.js';
const COOKIE_SOURCES = ['safari', 'chrome', 'firefox'];
function parseCookieSource(value) {
const normalized = value.trim().toLowerCase();
if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') {
return normalized;
}
throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`);
}
export const collectCookieSource = (value, previous = []) => {
previous.push(parseCookieSource(value));
return previous;
};
function resolveCookieSourceOrder(input) {
if (typeof input === 'string') {
return [parseCookieSource(input)];
}
if (Array.isArray(input)) {
const result = [];
for (const entry of input) {
if (typeof entry !== 'string') {
continue;
}
result.push(parseCookieSource(entry));
}
return result.length > 0 ? result : undefined;
}
return undefined;
}
function resolveTimeoutMs(...values) {
for (const value of values) {
if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number(value);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return undefined;
}
function resolveQuoteDepth(...values) {
for (const value of values) {
if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
}
return undefined;
}
function detectMime(path) {
const ext = path.toLowerCase();
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
return 'image/jpeg';
}
if (ext.endsWith('.png')) {
return 'image/png';
}
if (ext.endsWith('.webp')) {
return 'image/webp';
}
if (ext.endsWith('.gif')) {
return 'image/gif';
}
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) {
return 'video/mp4';
}
if (ext.endsWith('.mov')) {
return 'video/quicktime';
}
return null;
}
function readConfigFile(path, warn) {
if (!existsSync(path)) {
return {};
}
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON5.parse(raw);
return parsed ?? {};
}
catch (error) {
warn(`Failed to parse config at ${path}: ${error instanceof Error ? error.message : String(error)}`);
return {};
}
}
function loadConfig(warn) {
const globalPath = join(homedir(), '.config', 'bird', 'config.json5');
const localPath = join(process.cwd(), '.birdrc.json5');
return {
...readConfigFile(globalPath, warn),
...readConfigFile(localPath, warn),
};
}
export function createCliContext(normalizedArgs, env = process.env) {
const isTty = process.stdout.isTTY;
let output = resolveOutputConfigFromArgv(normalizedArgs, env, isTty);
kleur.enabled = output.color;
const wrap = (styler) => (text) => isTty ? styler(text) : text;
const colors = {
banner: wrap((t) => kleur.bold().blue(t)),
subtitle: wrap((t) => kleur.dim(t)),
section: wrap((t) => kleur.bold().white(t)),
bullet: wrap((t) => kleur.blue(t)),
command: wrap((t) => kleur.bold().cyan(t)),
option: wrap((t) => kleur.cyan(t)),
argument: wrap((t) => kleur.magenta(t)),
description: wrap((t) => kleur.white(t)),
muted: wrap((t) => kleur.gray(t)),
accent: wrap((t) => kleur.green(t)),
};
const p = (kind) => {
const prefix = statusPrefix(kind, output);
if (output.plain || !output.color) {
return prefix;
}
if (kind === 'ok') {
return kleur.green(prefix);
}
if (kind === 'warn') {
return kleur.yellow(prefix);
}
if (kind === 'err') {
return kleur.red(prefix);
}
if (kind === 'info') {
return kleur.cyan(prefix);
}
return kleur.gray(prefix);
};
const l = (kind) => {
const prefix = labelPrefix(kind, output);
if (output.plain || !output.color) {
return prefix;
}
if (kind === 'url') {
return kleur.cyan(prefix);
}
if (kind === 'date') {
return kleur.magenta(prefix);
}
if (kind === 'source') {
return kleur.gray(prefix);
}
if (kind === 'engine') {
return kleur.blue(prefix);
}
if (kind === 'credentials') {
return kleur.yellow(prefix);
}
if (kind === 'user') {
return kleur.cyan(prefix);
}
if (kind === 'userId') {
return kleur.magenta(prefix);
}
if (kind === 'email') {
return kleur.green(prefix);
}
return kleur.gray(prefix);
};
const config = loadConfig((message) => {
console.error(colors.muted(`${p('warn')}${message}`));
});
function applyOutputFromCommand(command) {
const opts = command.optsWithGlobals();
output = resolveOutputConfigFromCommander(opts, env, isTty);
kleur.enabled = output.color;
}
function resolveTimeoutFromOptions(options) {
return resolveTimeoutMs(options.timeout, config.timeoutMs, env.BIRD_TIMEOUT_MS);
}
function resolveCookieTimeoutFromOptions(options) {
return resolveTimeoutMs(options.cookieTimeout, config.cookieTimeoutMs, env.BIRD_COOKIE_TIMEOUT_MS);
}
function resolveQuoteDepthFromOptions(options) {
return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, env.BIRD_QUOTE_DEPTH);
}
function resolveCredentialsFromOptions(opts) {
const cookieSource = opts.cookieSource?.length
? opts.cookieSource
: (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES);
const chromeProfile = opts.chromeProfileDir || opts.chromeProfile || config.chromeProfileDir || config.chromeProfile;
return resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
cookieSource,
chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
cookieTimeoutMs: resolveCookieTimeoutFromOptions(opts),
});
}
function loadMedia(opts) {
if (opts.media.length === 0) {
return [];
}
const specs = [];
for (const [index, path] of opts.media.entries()) {
const mime = detectMime(path);
if (!mime) {
throw new Error(`Unsupported media type for ${path}. Supported: jpg, jpeg, png, webp, gif, mp4, mov`);
}
const buffer = readFileSync(path);
specs.push({ path, mime, buffer, alt: opts.alts[index] });
}
const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length;
if (videoCount > 1) {
throw new Error('Only one video can be attached');
}
if (videoCount === 1 && specs.length > 1) {
throw new Error('Video cannot be combined with other media');
}
if (specs.length > 4) {
throw new Error('Maximum 4 media attachments');
}
return specs;
}
function printTweets(tweets, opts = {}) {
if (opts.json) {
console.log(JSON.stringify(tweets, null, 2));
return;
}
if (tweets.length === 0) {
console.log(opts.emptyMessage ?? 'No tweets found.');
return;
}
const useEmoji = output.emoji && !output.plain;
const articleLabel = useEmoji ? '📰' : 'Article:';
const mediaLabel = (type) => {
if (useEmoji) {
return type === 'video' ? '🎬' : type === 'animated_gif' ? '🔄' : '🖼️';
}
return type === 'video' ? 'VIDEO:' : type === 'animated_gif' ? 'GIF:' : 'PHOTO:';
};
const quotePrefix = useEmoji ? { top: '┌─', mid: '│ ', bot: '└─' } : { top: '> ', mid: '> ', bot: '> ' };
for (const tweet of tweets) {
console.log(`\n@${tweet.author.username} (${tweet.author.name}):`);
// Display tweet text, with article indicator if present
if (tweet.article) {
// Full body mode: text starts with article title (from extractArticleText)
// Preview mode: text is short tweet intro that doesn't start with title
const hasFullBody = tweet.text.startsWith(tweet.article.title);
if (hasFullBody) {
console.log(`${articleLabel} ${tweet.text}`);
}
else {
console.log(`${articleLabel} ${tweet.article.title}`);
if (tweet.article.previewText) {
console.log(` ${tweet.article.previewText}`);
}
}
}
else {
console.log(tweet.text);
}
// Display media attachments
if (tweet.media && tweet.media.length > 0) {
for (const m of tweet.media) {
console.log(`${mediaLabel(m.type)} ${m.url}`);
}
}
// Display quoted tweet
if (tweet.quotedTweet) {
console.log(`${quotePrefix.top} QT @${tweet.quotedTweet.author.username}:`);
const qtText = tweet.quotedTweet.article
? `${articleLabel} ${tweet.quotedTweet.article.title}`
: tweet.quotedTweet.text;
// Indent and truncate quoted tweet text
const maxLen = 280;
const truncated = qtText.length > maxLen ? `${qtText.slice(0, maxLen)}...` : qtText;
for (const line of truncated.split('\n').slice(0, 4)) {
console.log(`${quotePrefix.mid}${line}`);
}
// Display quoted tweet media
if (tweet.quotedTweet.media && tweet.quotedTweet.media.length > 0) {
for (const m of tweet.quotedTweet.media) {
console.log(`${quotePrefix.mid}${mediaLabel(m.type)} ${m.url}`);
}
}
console.log(`${quotePrefix.bot} https://x.com/${tweet.quotedTweet.author.username}/status/${tweet.quotedTweet.id}`);
}
if (tweet.createdAt) {
console.log(`${l('date')}${tweet.createdAt}`);
}
const tweetUrl = `https://x.com/${tweet.author.username}/status/${tweet.id}`;
console.log(`${l('url')}${hyperlink(tweetUrl, tweetUrl, output)}`);
if (opts.showSeparator ?? true) {
console.log('─'.repeat(50));
}
}
}
function printTweetsResult(result, opts) {
const tweets = result.tweets ?? [];
if (opts.json && opts.usePagination) {
console.log(JSON.stringify({ tweets, nextCursor: result.nextCursor ?? null }, null, 2));
return;
}
printTweets(tweets, { json: opts.json, emptyMessage: opts.emptyMessage });
}
return {
isTty,
getOutput: () => output,
colors,
p,
l,
config,
applyOutputFromCommand,
resolveTimeoutFromOptions,
resolveQuoteDepthFromOptions,
resolveCredentialsFromOptions,
loadMedia,
printTweets,
printTweetsResult,
extractTweetId,
};
}
//# sourceMappingURL=shared.js.map
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerBookmarksCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=bookmarks.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"bookmarks.d.ts","sourceRoot":"","sources":["../../src/commands/bookmarks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAMnD,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAsOhF"}
+189
View File
@@ -0,0 +1,189 @@
import { parsePaginationFlags } from '../cli/pagination.js';
import { extractBookmarkFolderId } from '../lib/extract-bookmark-folder-id.js';
import { addThreadMetadata, filterAuthorChain, filterAuthorOnly, filterFullChain } from '../lib/thread-filters.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerBookmarksCommand(program, ctx) {
program
.command('bookmarks')
.description('Get your bookmarked tweets')
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
.option('--folder-id <id>', 'Bookmark folder (collection) id')
.option('--all', 'Fetch all bookmarks (paged)')
.option('--max-pages <number>', 'Stop after N pages when using --all')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--expand-root-only', 'Only expand threads when bookmarked tweet is root')
.option('--author-chain', 'Only include author self-reply chains connected to the bookmark')
.option('--author-only', 'Include all tweets from bookmarked tweet author in thread')
.option('--full-chain-only', 'Save entire reply chain connected to the bookmarked tweet')
.option('--include-ancestor-branches', 'Include sibling branches for ancestors when using --full-chain-only')
.option('--include-parent', 'Include direct parent tweet for non-root bookmarks')
.option('--thread-meta', 'Add metadata fields (isThread, threadPosition, etc.)')
.option('--sort-chronological', 'Sort output globally oldest -> newest')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const pagination = parsePaginationFlags(cmdOpts);
if (!pagination.ok) {
console.error(`${ctx.p('err')}${pagination.error}`);
process.exit(1);
}
const maxPages = pagination.maxPages;
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const usePagination = pagination.usePagination;
if (maxPages !== undefined && !usePagination) {
console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`);
process.exit(1);
}
if (!usePagination && (!Number.isFinite(count) || count <= 0)) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
if (cmdOpts.folderId && !folderId) {
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
process.exit(1);
}
const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages, cursor: pagination.cursor };
const result = folderId
? usePagination
? await client.getAllBookmarkFolderTimeline(folderId, paginationOptions)
: await client.getBookmarkFolderTimeline(folderId, count, timelineOptions)
: usePagination
? await client.getAllBookmarks(paginationOptions)
: await client.getBookmarks(count, timelineOptions);
if (!result.success) {
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
process.exit(1);
}
if (cmdOpts.authorChain && (cmdOpts.authorOnly || cmdOpts.fullChainOnly)) {
console.error(`${ctx.p('warn')}--author-chain already limits to the connected self-reply chain; ` +
'other chain filters are redundant.');
}
if (cmdOpts.includeAncestorBranches && !cmdOpts.fullChainOnly) {
console.error(`${ctx.p('warn')}--include-ancestor-branches only applies with --full-chain-only.`);
}
const bookmarks = result.tweets;
if (!bookmarks || bookmarks.length === 0) {
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult(result, { json: isJson, usePagination, emptyMessage });
return;
}
const expandedResults = [];
const threadCache = new Map();
const includeMeta = Boolean(cmdOpts.threadMeta);
const includeParent = Boolean(cmdOpts.includeParent);
const expandRootOnly = Boolean(cmdOpts.expandRootOnly);
const filterAuthorChainFlag = Boolean(cmdOpts.authorChain);
const filterAuthorOnlyFlag = Boolean(cmdOpts.authorOnly);
const filterFullChainFlag = Boolean(cmdOpts.fullChainOnly);
const includeAncestorBranches = Boolean(cmdOpts.includeAncestorBranches) && filterFullChainFlag;
const useChronologicalSort = Boolean(cmdOpts.sortChronological);
const shouldAttemptExpand = expandRootOnly || filterAuthorChainFlag || filterAuthorOnlyFlag || filterFullChainFlag;
const shouldFetchThread = shouldAttemptExpand || includeMeta;
const fetchThread = async (tweet) => {
const cachedKey = tweet.conversationId ?? tweet.id;
const cached = threadCache.get(cachedKey);
if (cached) {
return cached;
}
const threadResult = await client.getThread(tweet.id, { includeRaw });
if (!threadResult.success) {
console.error(`${ctx.p('warn')}Failed to expand thread for ${tweet.id}: ${threadResult.error ?? 'Unknown error'}`);
return null;
}
if (!threadResult.tweets) {
console.error(`${ctx.p('warn')}No thread tweets returned for ${tweet.id}.`);
return null;
}
const rootKey = threadResult.tweets[0]?.conversationId ?? cachedKey;
threadCache.set(rootKey, threadResult.tweets);
return threadResult.tweets;
};
const delayBetweenExpansionsMs = 1000;
for (let index = 0; index < bookmarks.length; index += 1) {
const bookmark = bookmarks[index];
const isRoot = !bookmark.inReplyToStatusId;
let threadTweets = null;
if (shouldFetchThread) {
if (!expandRootOnly || isRoot || includeMeta) {
if (index > 0) {
await new Promise((resolve) => setTimeout(resolve, delayBetweenExpansionsMs));
}
threadTweets = await fetchThread(bookmark);
}
}
let outputTweets = [bookmark];
if (shouldAttemptExpand) {
if (expandRootOnly && !isRoot) {
outputTweets = [bookmark];
}
else if (threadTweets) {
if (filterAuthorChainFlag) {
outputTweets = filterAuthorChain(threadTweets, bookmark);
}
else {
outputTweets = filterFullChainFlag
? filterFullChain(threadTweets, bookmark, { includeAncestorBranches })
: threadTweets;
if (filterAuthorOnlyFlag) {
outputTweets = filterAuthorOnly(outputTweets, bookmark);
}
}
}
}
if (includeParent && bookmark.inReplyToStatusId) {
const alreadyIncluded = outputTweets.some((tweet) => tweet.id === bookmark.inReplyToStatusId);
if (!alreadyIncluded) {
const parentFromThread = threadTweets?.find((tweet) => tweet.id === bookmark.inReplyToStatusId);
if (parentFromThread) {
expandedResults.push(parentFromThread);
}
else {
const parentResult = await client.getTweet(bookmark.inReplyToStatusId, { includeRaw });
if (parentResult.success && parentResult.tweet) {
expandedResults.push(parentResult.tweet);
}
}
}
}
expandedResults.push(...outputTweets);
}
let finalResults = expandedResults;
if (includeMeta) {
finalResults = expandedResults.map((tweet) => {
const cacheKey = tweet.conversationId ?? tweet.id;
let conversationTweets = threadCache.get(cacheKey);
if (!conversationTweets) {
conversationTweets = [tweet];
}
return addThreadMetadata(tweet, conversationTweets);
});
}
const uniqueTweets = Array.from(new Map(finalResults.map((tweet) => [tweet.id, tweet])).values());
if (useChronologicalSort) {
uniqueTweets.sort((a, b) => {
const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
return aTime - bTime;
});
}
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult({ tweets: uniqueTweets, nextCursor: result.nextCursor }, { json: isJson, usePagination, emptyMessage });
});
}
//# sourceMappingURL=bookmarks.js.map
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerCheckCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=check.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../../src/commands/check.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CA4C5E"}
+43
View File
@@ -0,0 +1,43 @@
export function registerCheckCommand(program, ctx) {
program
.command('check')
.description('Check credential availability')
.action(async () => {
const opts = program.opts();
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
console.log(`${ctx.p('info')}Credential check`);
console.log('─'.repeat(40));
if (cookies.authToken) {
console.log(`${ctx.p('ok')}auth_token: ${cookies.authToken.slice(0, 10)}...`);
}
else {
console.log(`${ctx.p('err')}auth_token: not found`);
}
if (cookies.ct0) {
console.log(`${ctx.p('ok')}ct0: ${cookies.ct0.slice(0, 10)}...`);
}
else {
console.log(`${ctx.p('err')}ct0: not found`);
}
if (cookies.source) {
console.log(`${ctx.l('source')}${cookies.source}`);
}
if (warnings.length > 0) {
console.log(`\n${ctx.p('warn')}Warnings:`);
for (const warning of warnings) {
console.log(` - ${warning}`);
}
}
if (cookies.authToken && cookies.ct0) {
console.log(`\n${ctx.p('ok')}Ready to tweet!`);
}
else {
console.log(`\n${ctx.p('err')}Missing credentials. Options:`);
console.log(' 1. Login to x.com in Safari/Chrome/Firefox');
console.log(' 2. Set AUTH_TOKEN and CT0 environment variables');
console.log(' 3. Use --auth-token and --ct0 flags');
process.exit(1);
}
});
}
//# sourceMappingURL=check.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"check.js","sourceRoot":"","sources":["../../src/commands/check.ts"],"names":[],"mappings":"AAGA,MAAM,UAAU,oBAAoB,CAAC,OAAgB,EAAE,GAAe;IACpE,OAAO;SACJ,OAAO,CAAC,OAAO,CAAC;SAChB,WAAW,CAAC,+BAA+B,CAAC;SAC5C,MAAM,CAAC,KAAK,IAAI,EAAE;QACjB,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC;QAChD,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC;QAE5B,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;YACtB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,eAAe,OAAO,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC;QAChF,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,uBAAuB,CAAC,CAAC;QACtD,CAAC;QAED,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;YAChB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC;QACnE,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC;QAC/C,CAAC;QAED,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;YACnB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;QACrD,CAAC;QAED,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACxB,OAAO,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;YAC3C,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;gBAC/B,OAAO,CAAC,GAAG,CAAC,QAAQ,OAAO,EAAE,CAAC,CAAC;YACjC,CAAC;QACH,CAAC;QAED,IAAI,OAAO,CAAC,SAAS,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;YACrC,OAAO,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;QACjD,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,+BAA+B,CAAC,CAAC;YAC9D,OAAO,CAAC,GAAG,CAAC,+CAA+C,CAAC,CAAC;YAC7D,OAAO,CAAC,GAAG,CAAC,oDAAoD,CAAC,CAAC;YAClE,OAAO,CAAC,GAAG,CAAC,wCAAwC,CAAC,CAAC;YACtD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC"}
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerFollowCommands(program: Command, ctx: CliContext): void;
//# sourceMappingURL=follow.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"follow.d.ts","sourceRoot":"","sources":["../../src/commands/follow.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAmCnD,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CA8E9E"}
+91
View File
@@ -0,0 +1,91 @@
import { normalizeHandle } from '../lib/normalize-handle.js';
import { TwitterClient } from '../lib/twitter-client.js';
const ONLY_DIGITS_REGEX = /^\d+$/;
async function resolveUserId(client, usernameOrId, ctx) {
const raw = usernameOrId.trim();
const isNumeric = ONLY_DIGITS_REGEX.test(raw);
// Otherwise, treat as username and look up
const handle = normalizeHandle(raw);
if (handle) {
const lookup = await client.getUserIdByUsername(handle);
if (lookup.success && lookup.userId) {
return { userId: lookup.userId, username: lookup.username };
}
if (!isNumeric) {
console.error(`${ctx.p('err')}Failed to find user @${handle}: ${lookup.error ?? 'Unknown error'}`);
return null;
}
}
if (isNumeric) {
return { userId: raw };
}
console.error(`${ctx.p('err')}Invalid username: ${usernameOrId}`);
return null;
}
export function registerFollowCommands(program, ctx) {
program
.command('follow')
.description('Follow a user')
.argument('<username-or-id>', 'Username (with or without @) or user ID to follow')
.action(async (usernameOrId) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const resolved = await resolveUserId(client, usernameOrId, ctx);
if (!resolved) {
process.exit(1);
}
const { userId, username } = resolved;
const displayName = username ? `@${username}` : userId;
const result = await client.follow(userId);
if (result.success) {
const finalName = result.username ? `@${result.username}` : displayName;
console.log(`${ctx.p('ok')}Now following ${finalName}`);
}
else {
console.error(`${ctx.p('err')}Failed to follow ${displayName}: ${result.error}`);
process.exit(1);
}
});
program
.command('unfollow')
.description('Unfollow a user')
.argument('<username-or-id>', 'Username (with or without @) or user ID to unfollow')
.action(async (usernameOrId) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const resolved = await resolveUserId(client, usernameOrId, ctx);
if (!resolved) {
process.exit(1);
}
const { userId, username } = resolved;
const displayName = username ? `@${username}` : userId;
const result = await client.unfollow(userId);
if (result.success) {
const finalName = result.username ? `@${result.username}` : displayName;
console.log(`${ctx.p('ok')}Unfollowed ${finalName}`);
}
else {
console.error(`${ctx.p('err')}Failed to unfollow ${displayName}: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=follow.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"follow.js","sourceRoot":"","sources":["../../src/commands/follow.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEzD,MAAM,iBAAiB,GAAG,OAAO,CAAC;AAElC,KAAK,UAAU,aAAa,CAC1B,MAAqB,EACrB,YAAoB,EACpB,GAAe;IAEf,MAAM,GAAG,GAAG,YAAY,CAAC,IAAI,EAAE,CAAC;IAChC,MAAM,SAAS,GAAG,iBAAiB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAE9C,2CAA2C;IAC3C,MAAM,MAAM,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC;IACpC,IAAI,MAAM,EAAE,CAAC;QACX,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC;QACxD,IAAI,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;YACpC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC;QAC9D,CAAC;QACD,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,wBAAwB,MAAM,KAAK,MAAM,CAAC,KAAK,IAAI,eAAe,EAAE,CAAC,CAAC;YACnG,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IAED,IAAI,SAAS,EAAE,CAAC;QACd,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;IACzB,CAAC;IAED,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,qBAAqB,YAAY,EAAE,CAAC,CAAC;IAClE,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,OAAgB,EAAE,GAAe;IACtE,OAAO;SACJ,OAAO,CAAC,QAAQ,CAAC;SACjB,WAAW,CAAC,eAAe,CAAC;SAC5B,QAAQ,CAAC,kBAAkB,EAAE,mDAAmD,CAAC;SACjF,MAAM,CAAC,KAAK,EAAE,YAAoB,EAAE,EAAE;QACrC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC;QAEtD,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAC;YAC7D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;QAEzD,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,MAAM,EAAE,YAAY,EAAE,GAAG,CAAC,CAAC;QAChE,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,QAAQ,CAAC;QACtC,MAAM,WAAW,GAAG,QAAQ,CAAC,CAAC,CAAC,IAAI,QAAQ,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;QAEvD,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAC3C,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,MAAM,SAAS,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC;YACxE,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,iBAAiB,SAAS,EAAE,CAAC,CAAC;QAC1D,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,oBAAoB,WAAW,KAAK,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YACjF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;IAEL,OAAO;SACJ,OAAO,CAAC,UAAU,CAAC;SACnB,WAAW,CAAC,iBAAiB,CAAC;SAC9B,QAAQ,CAAC,kBAAkB,EAAE,qDAAqD,CAAC;SACnF,MAAM,CAAC,KAAK,EAAE,YAAoB,EAAE,EAAE;QACrC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC;QAEtD,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAC;YAC7D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;QAEzD,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,MAAM,EAAE,YAAY,EAAE,GAAG,CAAC,CAAC;QAChE,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,QAAQ,CAAC;QACtC,MAAM,WAAW,GAAG,QAAQ,CAAC,CAAC,CAAC,IAAI,QAAQ,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;QAEvD,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QAC7C,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,MAAM,SAAS,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC;YACxE,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,SAAS,EAAE,CAAC,CAAC;QACvD,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,sBAAsB,WAAW,KAAK,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YACnF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC"}
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerHelpCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=help.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"help.d.ts","sourceRoot":"","sources":["../../src/commands/help.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAmB3E"}
+19
View File
@@ -0,0 +1,19 @@
export function registerHelpCommand(program, ctx) {
program
.command('help [command]')
.description('Show help for a command')
.action((commandName) => {
if (!commandName) {
program.outputHelp();
return;
}
const cmd = program.commands.find((c) => c.name() === commandName);
if (!cmd) {
console.error(`${ctx.p('err')}Unknown command: ${commandName}`);
process.exitCode = 2;
return;
}
cmd.outputHelp();
});
}
//# sourceMappingURL=help.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"help.js","sourceRoot":"","sources":["../../src/commands/help.ts"],"names":[],"mappings":"AAGA,MAAM,UAAU,mBAAmB,CAAC,OAAgB,EAAE,GAAe;IACnE,OAAO;SACJ,OAAO,CAAC,gBAAgB,CAAC;SACzB,WAAW,CAAC,yBAAyB,CAAC;SACtC,MAAM,CAAC,CAAC,WAAoB,EAAE,EAAE;QAC/B,IAAI,CAAC,WAAW,EAAE,CAAC;YACjB,OAAO,CAAC,UAAU,EAAE,CAAC;YACrB,OAAO;QACT,CAAC;QAED,MAAM,GAAG,GAAG,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,WAAW,CAAC,CAAC;QACnE,IAAI,CAAC,GAAG,EAAE,CAAC;YACT,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,oBAAoB,WAAW,EAAE,CAAC,CAAC;YAChE,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;YACrB,OAAO;QACT,CAAC;QAED,GAAG,CAAC,UAAU,EAAE,CAAC;IACnB,CAAC,CAAC,CAAC;AACP,CAAC"}
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerHomeCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=home.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"home.d.ts","sourceRoot":"","sources":["../../src/commands/home.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAGnD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CA8C3E"}
+43
View File
@@ -0,0 +1,43 @@
import { TwitterClient } from '../lib/twitter-client.js';
export function registerHomeCommand(program, ctx) {
program
.command('home')
.description('Get your home timeline ("For You" feed)')
.option('-n, --count <number>', 'Number of tweets to fetch', '20')
.option('--following', 'Get "Following" feed (chronological) instead of "For You"')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
if (!Number.isFinite(count) || count <= 0) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = cmdOpts.following
? await client.getHomeLatestTimeline(count, { includeRaw })
: await client.getHomeTimeline(count, { includeRaw });
if (result.success) {
const feedType = cmdOpts.following ? 'Following' : 'For You';
const emptyMessage = `No tweets found in ${feedType} timeline.`;
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweets(result.tweets, { json: isJson, emptyMessage });
}
else {
console.error(`${ctx.p('err')}Failed to fetch home timeline: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=home.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"home.js","sourceRoot":"","sources":["../../src/commands/home.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEzD,MAAM,UAAU,mBAAmB,CAAC,OAAgB,EAAE,GAAe;IACnE,OAAO;SACJ,OAAO,CAAC,MAAM,CAAC;SACf,WAAW,CAAC,yCAAyC,CAAC;SACtD,MAAM,CAAC,sBAAsB,EAAE,2BAA2B,EAAE,IAAI,CAAC;SACjE,MAAM,CAAC,aAAa,EAAE,2DAA2D,CAAC;SAClF,MAAM,CAAC,QAAQ,EAAE,gBAAgB,CAAC;SAClC,MAAM,CAAC,aAAa,EAAE,yDAAyD,CAAC;SAChF,MAAM,CAAC,KAAK,EAAE,OAAoF,EAAE,EAAE;QACrG,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC;QACtD,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,KAAK,IAAI,IAAI,EAAE,EAAE,CAAC,CAAC;QAEzD,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAC;YAC7D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,EAAE,CAAC;YAC1C,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,+CAA+C,CAAC,CAAC;YAC9E,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;QACzD,MAAM,UAAU,GAAG,OAAO,CAAC,QAAQ,IAAI,KAAK,CAAC;QAE7C,MAAM,MAAM,GAAG,OAAO,CAAC,SAAS;YAC9B,CAAC,CAAC,MAAM,MAAM,CAAC,qBAAqB,CAAC,KAAK,EAAE,EAAE,UAAU,EAAE,CAAC;YAC3D,CAAC,CAAC,MAAM,MAAM,CAAC,eAAe,CAAC,KAAK,EAAE,EAAE,UAAU,EAAE,CAAC,CAAC;QAExD,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,MAAM,QAAQ,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;YAC7D,MAAM,YAAY,GAAG,sBAAsB,QAAQ,YAAY,CAAC;YAChE,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,IAAI,IAAI,OAAO,CAAC,QAAQ,CAAC,CAAC;YACzD,GAAG,CAAC,WAAW,CAAC,MAAM,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,YAAY,EAAE,CAAC,CAAC;QACjE,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,kCAAkC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YAC/E,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC"}
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerListsCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=lists.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"lists.d.ts","sourceRoot":"","sources":["../../src/commands/lists.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AA4BnD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAyH5E"}
+125
View File
@@ -0,0 +1,125 @@
// ABOUTME: CLI command for fetching Twitter Lists.
// ABOUTME: Supports listing owned lists, memberships, and list timelines.
import { parsePaginationFlags } from '../cli/pagination.js';
import { extractListId } from '../lib/extract-list-id.js';
import { hyperlink } from '../lib/output.js';
import { TwitterClient } from '../lib/twitter-client.js';
function printLists(lists, ctx) {
if (lists.length === 0) {
console.log('No lists found.');
return;
}
for (const list of lists) {
const visibility = list.isPrivate ? '[private]' : '[public]';
console.log(`${list.name} ${ctx.colors.muted(visibility)}`);
if (list.description) {
console.log(` ${list.description.slice(0, 100)}${list.description.length > 100 ? '...' : ''}`);
}
console.log(` ${ctx.p('info')}${list.memberCount?.toLocaleString() ?? 0} members`);
if (list.owner) {
console.log(` ${ctx.colors.muted(`Owner: @${list.owner.username}`)}`);
}
const listUrl = `https://x.com/i/lists/${list.id}`;
console.log(` ${ctx.colors.accent(hyperlink(listUrl, listUrl, ctx.getOutput()))}`);
console.log('──────────────────────────────────────────────────');
}
}
export function registerListsCommand(program, ctx) {
program
.command('lists')
.description('Get your Twitter lists')
.option('--member-of', 'Show lists you are a member of (instead of owned lists)')
.option('-n, --count <number>', 'Number of lists to fetch', '100')
.option('--json', 'Output as JSON')
.action(async (cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '100', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const result = cmdOpts.memberOf ? await client.getListMemberships(count) : await client.getOwnedLists(count);
if (result.success && result.lists) {
if (cmdOpts.json) {
console.log(JSON.stringify(result.lists, null, 2));
}
else {
const emptyMessage = cmdOpts.memberOf ? 'You are not a member of any lists.' : 'You do not own any lists.';
if (result.lists.length === 0) {
console.log(emptyMessage);
}
else {
printLists(result.lists, ctx);
}
}
}
else {
console.error(`${ctx.p('err')}Failed to fetch lists: ${result.error}`);
process.exit(1);
}
});
program
.command('list-timeline <list-id-or-url>')
.description('Get tweets from a list timeline')
.option('-n, --count <number>', 'Number of tweets to fetch', '20')
.option('--all', 'Fetch all tweets from list (paged). WARNING: your account might get banned using this flag')
.option('--max-pages <number>', 'Fetch N pages (implies --all)')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (listIdOrUrl, cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const pagination = parsePaginationFlags(cmdOpts, { maxPagesImpliesPagination: true });
if (!pagination.ok) {
console.error(`${ctx.p('err')}${pagination.error}`);
process.exit(1);
}
const listId = extractListId(listIdOrUrl);
if (!listId) {
console.error(`${ctx.p('err')}Invalid list ID or URL. Expected numeric ID or https://x.com/i/lists/<id>.`);
process.exit(2);
}
const usePagination = pagination.usePagination;
if (!usePagination && (!Number.isFinite(count) || count <= 0)) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages: pagination.maxPages, cursor: pagination.cursor };
const result = usePagination
? await client.getAllListTimeline(listId, paginationOptions)
: await client.getListTimeline(listId, count, timelineOptions);
if (result.success) {
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult(result, {
json: isJson,
usePagination,
emptyMessage: 'No tweets found in this list.',
});
}
else {
console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=lists.js.map
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerNewsCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=news.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"news.d.ts","sourceRoot":"","sources":["../../src/commands/news.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAmEnD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAuG3E"}
+131
View File
@@ -0,0 +1,131 @@
import { TwitterClient } from '../lib/twitter-client.js';
function formatPostCount(count) {
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`;
}
if (count >= 1_000) {
return `${(count / 1_000).toFixed(1)}K`;
}
return String(count);
}
function printNewsItems(items, ctx, opts = {}) {
if (opts.json) {
console.log(JSON.stringify(items, null, 2));
return;
}
if (items.length === 0) {
console.log(opts.emptyMessage ?? 'No news items found.');
return;
}
for (const item of items) {
const categoryLabel = item.category ? `[${item.category}]` : '';
console.log(`\n${ctx.colors.accent(categoryLabel)} ${ctx.colors.command(item.headline)}`);
if (item.description) {
console.log(` ${ctx.colors.muted(item.description)}`);
}
const meta = [];
if (item.timeAgo) {
meta.push(item.timeAgo);
}
if (item.postCount) {
meta.push(`${formatPostCount(item.postCount)} posts`);
}
if (meta.length > 0) {
console.log(` ${ctx.colors.muted(meta.join(' | '))}`);
}
if (item.url) {
console.log(` ${ctx.l('url')}${item.url}`);
}
// Print related tweets if available
if (item.tweets && item.tweets.length > 0) {
console.log(` ${ctx.colors.section('Related tweets:')}`);
const tweetLimit = opts.tweetLimit ?? item.tweets.length;
for (const tweet of item.tweets.slice(0, tweetLimit)) {
console.log(` @${tweet.author.username}: ${tweet.text.slice(0, 100)}${tweet.text.length > 100 ? '...' : ''}`);
}
}
console.log(ctx.colors.muted('─'.repeat(50)));
}
}
export function registerNewsCommand(program, ctx) {
program
.command('news')
.alias('trending')
.description('Fetch AI-curated news and trending topics from Explore tabs')
.option('-n, --count <number>', 'Number of items to fetch', '10')
.option('--ai-only', 'Show only AI-curated news items')
.option('--with-tweets', 'Also fetch related tweets for each news item')
.option('--tweets-per-item <number>', 'Number of tweets to fetch per news item (default: 5)', '5')
.option('--for-you', 'Fetch only from For You tab')
.option('--news-only', 'Fetch only from News tab')
.option('--sports', 'Fetch only from Sports tab')
.option('--entertainment', 'Fetch only from Entertainment tab')
.option('--trending-only', 'Fetch only from Trending tab')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10);
const tweetsPerItem = Number.parseInt(cmdOpts.tweetsPerItem || '5', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (Number.isNaN(count) || count < 1) {
console.error(`${ctx.p('err')}--count must be a positive number`);
process.exit(1);
}
if (Number.isNaN(tweetsPerItem) || tweetsPerItem < 1) {
console.error(`${ctx.p('err')}--tweets-per-item must be a positive number`);
process.exit(1);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
// Determine which tabs to fetch from
const tabs = [];
if (cmdOpts.forYou) {
tabs.push('forYou');
}
if (cmdOpts.newsOnly) {
tabs.push('news');
}
if (cmdOpts.sports) {
tabs.push('sports');
}
if (cmdOpts.entertainment) {
tabs.push('entertainment');
}
if (cmdOpts.trendingOnly) {
tabs.push('trending');
}
// If no specific tabs selected, use defaults (all tabs except trending)
const tabsToFetch = tabs.length > 0 ? tabs : undefined;
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const withTweets = cmdOpts.withTweets ?? false;
const aiOnly = cmdOpts.aiOnly ?? false;
const result = await client.getNews(count, {
includeRaw,
withTweets,
tweetsPerItem,
aiOnly,
tabs: tabsToFetch,
});
if (result.success) {
printNewsItems(result.items, ctx, {
json: cmdOpts.json || cmdOpts.jsonFull,
emptyMessage: 'No news items found.',
tweetLimit: withTweets ? tweetsPerItem : undefined,
});
}
else {
console.error(`${ctx.p('err')}Failed to fetch news: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=news.js.map
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerPostCommands(program: Command, ctx: CliContext): void;
//# sourceMappingURL=post.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"post.d.ts","sourceRoot":"","sources":["../../src/commands/post.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAa,MAAM,kBAAkB,CAAC;AAyB9D,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CA4F5E"}
+101
View File
@@ -0,0 +1,101 @@
import { formatTweetUrlLine } from '../lib/output.js';
import { TwitterClient } from '../lib/twitter-client.js';
async function uploadMediaOrExit(client, media, ctx) {
if (media.length === 0) {
return undefined;
}
const uploaded = [];
for (const item of media) {
const res = await client.uploadMedia({ data: item.buffer, mimeType: item.mime, alt: item.alt });
if (!res.success || !res.mediaId) {
console.error(`${ctx.p('err')}Media upload failed: ${res.error ?? 'Unknown error'}`);
process.exit(1);
}
uploaded.push(res.mediaId);
}
return uploaded;
}
export function registerPostCommands(program, ctx) {
program
.command('tweet')
.description('Post a new tweet')
.argument('<text>', 'Tweet text')
.action(async (text) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
let media = [];
try {
media = ctx.loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
}
catch (error) {
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
if (cookies.source) {
console.error(`${ctx.l('source')}${cookies.source}`);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.tweet(text, mediaIds);
if (result.success) {
console.log(`${ctx.p('ok')}Tweet posted successfully!`);
console.log(formatTweetUrlLine(result.tweetId, ctx.getOutput()));
}
else {
console.error(`${ctx.p('err')}Failed to post tweet: ${result.error}`);
process.exit(1);
}
});
program
.command('reply')
.description('Reply to an existing tweet')
.argument('<tweet-id-or-url>', 'Tweet ID or URL to reply to')
.argument('<text>', 'Reply text')
.action(async (tweetIdOrUrl, text) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
let media = [];
try {
media = ctx.loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
}
catch (error) {
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
if (cookies.source) {
console.error(`${ctx.l('source')}${cookies.source}`);
}
console.error(`${ctx.p('info')}Replying to tweet: ${tweetId}`);
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.reply(text, tweetId, mediaIds);
if (result.success) {
console.log(`${ctx.p('ok')}Reply posted successfully!`);
console.log(formatTweetUrlLine(result.tweetId, ctx.getOutput()));
}
else {
console.error(`${ctx.p('err')}Failed to post reply: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=post.js.map

Some files were not shown because too many files have changed in this diff Show More