Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5864c687a3 | |||
| 790e5bc26a | |||
| 1da9c601c3 | |||
| 4388fed46a | |||
| a7d6ef051a | |||
| b7df5ecd2d | |||
| a0d61b0dc6 | |||
| 5f218aaac5 | |||
| a709d66e2a | |||
| 4d9f29d2ed | |||
| 52fb0e50cb | |||
| f635f78e4a | |||
| a070a584a4 | |||
| 8e18d0142c | |||
| e8105df4fd | |||
| 361e9d6c13 | |||
| 58845df312 | |||
| d14814a9b0 | |||
| e9911ae2ae | |||
| 7cee41509f | |||
| 371f62a403 | |||
| bad1d312ef | |||
| 0103324701 | |||
| f09c6850bc | |||
| 3499c246b8 | |||
| 53b8e33d13 | |||
| 73b4bd6ac6 | |||
| a2850e3d19 | |||
| 9c1e253dcc | |||
| f4a3cc104b | |||
| a220632186 | |||
| 082efe03e3 | |||
| 242e38ef56 | |||
| c12dd3adbf | |||
| c5b03adffc | |||
| 38a1c27e2e | |||
| ed80797564 | |||
| 68c3420f9f | |||
| 12167ee19e | |||
| 21b8e5c6d3 | |||
| 1157ea8afe | |||
| 9f3be8bbda | |||
| beb54e9e9d | |||
| 8d8ca68781 | |||
| 0949b870e0 | |||
| 4b07ba02a6 | |||
| 039fc89874 | |||
| 2b506e90f5 | |||
| deb9f33437 | |||
| cb88bd2eed | |||
| 23fc6c7061 | |||
| 9dd3f21476 | |||
| e395c1d57f | |||
| 33502d2a07 | |||
| bdc71cfd07 | |||
| 65be6196c1 | |||
| 7dc530b4c9 | |||
| b159f8b1ff | |||
| cff005b038 | |||
| 460565c107 | |||
| e6493033b0 | |||
| ca00cacf83 | |||
| b982ed5b30 | |||
| a9d13d695a | |||
| 877706da4d | |||
| 1a6d8d07d0 | |||
| 3bc12cdc57 | |||
| ad59e60269 | |||
| 9d037786f2 | |||
| 8b67378964 | |||
| 2b015b64ab | |||
| d3972a6523 | |||
| 56cabf33c6 | |||
| 13dcea781d | |||
| 01812ec185 | |||
| 86b2b9dd69 | |||
| 6e7c0ba7aa | |||
| 71e0492840 | |||
| 8b2cf41f13 | |||
| 3d57db9644 | |||
| 99b167d03a | |||
| 65fcf6be65 | |||
| 9ef9d38b90 |
@@ -10,7 +10,7 @@
|
||||
{
|
||||
"name": "last30days",
|
||||
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, HN, Polymarket, GitHub, and 5+ more sources.",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.9",
|
||||
"author": {
|
||||
"name": "Matt Van Horn",
|
||||
"url": "https://github.com/mvanhorn"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "last30days",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.9",
|
||||
"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": {
|
||||
"name": "Matt Van Horn",
|
||||
@@ -11,6 +11,5 @@
|
||||
"repository": "https://github.com/mvanhorn/last30days-skill",
|
||||
"license": "MIT",
|
||||
"keywords": ["research", "reddit", "twitter", "youtube", "tiktok", "instagram", "trends", "prompts", "polymarket", "github", "perplexity", "threads", "pinterest", "eli5", "hacker-news"],
|
||||
"skills": ["./"],
|
||||
"hooks": {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"name": "last30days"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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
|
||||
|
||||
# NOTE: skills/ and .claude-plugin/ are NOT export-ignored here because
|
||||
# Claude Code's /plugin install fetches this same git archive tarball.
|
||||
# Removing those from the archive (as v3.0.1 did) silently breaks installs.
|
||||
# claude.ai-bundle-specific exclusions live in scripts/build-skill.sh.
|
||||
|
||||
# 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
|
||||
|
||||
# CI workflows - repo-only, not needed at skill runtime
|
||||
.github/ export-ignore
|
||||
|
||||
# Build config itself
|
||||
.clawhubignore export-ignore
|
||||
.gitignore export-ignore
|
||||
.gitattributes export-ignore
|
||||
@@ -0,0 +1,31 @@
|
||||
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
@@ -15,3 +15,16 @@ variants/open/references/research.md
|
||||
__pycache__/
|
||||
*.pyc
|
||||
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/
|
||||
|
||||
+138
-2
@@ -5,7 +5,135 @@ 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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.0.0] - 2026-04
|
||||
## [3.0.9] - 2026-04-18 - The Self-Debug Release
|
||||
|
||||
### Highlights
|
||||
|
||||
v3.0.9 adds the engine-side Class 1 keyword-trap refuse-gate ("birthday gift for 40 year old" now gets a clarifying question, not 5 minutes of junk), promotes TikTok and YouTube top comments to the same first-class rendering Reddit's got, lands Hermes AI Agent as a first-class deploy target, and moves the SKILL.md formatting contract from line 1094 to the top of the file.
|
||||
|
||||
"The Self-Debug Release" refers to how the fixes in 3.0.6-3.0.9 were written: 5 separate Opus 4.7 instances each debugged their own failed outputs. Three converged on "SKILL.md is too big and the LAWs are too deep." Two converged on "the engine should refuse demographic-shopping queries." I shipped exactly what they said. Validation: 5/5 canonical compliance.
|
||||
|
||||
### Added
|
||||
|
||||
- **Engine Class 1 keyword-trap refuse-gate** (`scripts/lib/preflight.py`, new). Pattern-matches demographic-shopping queries at main() front-door. Exit code 2 with structured REFUSE message. Escape hatch: `LAST30DAYS_SKIP_PREFLIGHT=1`. 29 tests in `tests/test_preflight.py`.
|
||||
- **TikTok + YouTube top comments** rendered with same `💬 Top comment` prominence as Reddit's. Shipped in [#260](https://github.com/mvanhorn/last30days-skill/pull/260); enrichment fixed in [#265](https://github.com/mvanhorn/last30days-skill/pull/265).
|
||||
- **Hermes AI Agent as a deploy target** - thanks @stephenmcconnachie ([#228](https://github.com/mvanhorn/last30days-skill/pull/228)). `scripts/sync.sh` detects `~/.hermes/skills/research` and deploys automatically.
|
||||
- **Multi-key SCRAPECREATORS_API_KEY rotation** - thanks @zaydiscold ([#268](https://github.com/mvanhorn/last30days-skill/pull/268)). Set `SCRAPECREATORS_API_KEY_1`, `_2`, etc. Engine rotates on rate-limit.
|
||||
- **Offline quality evaluation fixture** - thanks @j-sperling ([#233](https://github.com/mvanhorn/last30days-skill/pull/233)). `eval_topics.json` lets contributors run quality regressions without burning live API credits.
|
||||
- **END-OF-CANONICAL-OUTPUT boundary** in `render_compact()`. Engine now emits an explicit pass-through instruction so re-synthesis requires actively ignoring a visible boundary.
|
||||
- **LAW 1 verbatim-pattern override.** LAW 1 now quotes the exact WebSearch tool-result reminder ("CRITICAL REQUIREMENT: MUST include Sources: section") and declares it OVERRIDDEN inside last30days output.
|
||||
|
||||
### Changed
|
||||
|
||||
- **SKILL.md restructure.** VOICE CONTRACT LAWs and BADGE MANDATORY block moved from line 1094 to lines 75-150. Grounded in 3 separate Opus 4.7 self-debugs.
|
||||
- **Engine emits the badge as stdout.** `🌐 last30days v3.0.9 · synced YYYY-MM-DD` is the first line of every compact emit. Pass-through is now the default-correct behavior.
|
||||
- **Reddit client HTTP consolidation** - thanks @iliaal ([#207](https://github.com/mvanhorn/last30days-skill/pull/207)). Migrated to `http.get(params=...)` helper.
|
||||
- **ScrapeCreators header consolidation** - thanks @iliaal ([#209](https://github.com/mvanhorn/last30days-skill/pull/209)). `_sc_headers` refactored into `http.scrapecreators_headers`.
|
||||
- **Simpler Hermes sync.** `scripts/sync.sh` Hermes branch now always uses main SKILL.md (previously had a `.hermes-plugin/SKILL.md` fallback that created a wrong-file-capture hazard).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Peter Steinberger trailing Sources leak.** 2026-04-18 validation failure where the model appended a TechCrunch / TED / Fortune / Wikipedia Sources list after the invitation. Now structurally prevented at three layers: engine emits the canonical body, LAW 1 quotes the exact WebSearch reminder, closing boundary names the anti-pattern.
|
||||
- **Wrong-file SKILL.md capture.** Deleted `.agents/skills/last30days/SKILL.md` (1382 lines, April 13 snapshot) and `.hermes-plugin/SKILL.md` (269 lines). One SKILL.md per plugin now, at the plugin root.
|
||||
- **GitHub date parsing garbage** - thanks @iliaal ([#208](https://github.com/mvanhorn/last30days-skill/pull/208)). `_parse_date` now rejects invalid input cleanly.
|
||||
- **Windows Bird X stability** - thanks @Chelebii ([#227](https://github.com/mvanhorn/last30days-skill/pull/227)).
|
||||
- **Linux `check_perms` false-warn** - thanks @george231224 ([#216](https://github.com/mvanhorn/last30days-skill/pull/216)). Uses GNU stat first.
|
||||
- **UTF-8 saved output** - thanks @Gujiassh ([#225](https://github.com/mvanhorn/last30days-skill/pull/225)).
|
||||
- **Version metadata alignment** - thanks @Gujiassh ([#217](https://github.com/mvanhorn/last30days-skill/pull/217)) and @shalomma ([#229](https://github.com/mvanhorn/last30days-skill/pull/229)).
|
||||
- **`--days` alias backcompat** - thanks @BryanTegomoh ([#230](https://github.com/mvanhorn/last30days-skill/pull/230)).
|
||||
- **`INCLUDE_SOURCES` env default** - thanks @hnshah ([#223](https://github.com/mvanhorn/last30days-skill/pull/223)).
|
||||
- **Bird X all-None engagement** - thanks @j-sperling ([#234](https://github.com/mvanhorn/last30days-skill/pull/234)).
|
||||
|
||||
### Contributors
|
||||
|
||||
@j-sperling, @stephenmcconnachie, @zaydiscold, @iliaal, @Chelebii, @Gujiassh, @hnshah, @george231224, @shalomma, @BryanTegomoh for PRs since v3.0.0. @uppinote20, @zerone0x, @thinkun, @thomasmktong, @fanispoulinakisai-boop, @pejmanjohn, @zl190, @Jah-yee, @dannyshmueli, @Cody-Coyote for issues and PRs that shaped the v3 roadmap.
|
||||
|
||||
### Recovery
|
||||
|
||||
```
|
||||
/plugin update last30days
|
||||
/reload-plugins
|
||||
```
|
||||
|
||||
Verify: `cat ~/.claude/plugins/cache/last30days-skill/last30days/*/.claude-plugin/plugin.json | grep version` returns `"version": "3.0.9"`.
|
||||
|
||||
Smoke test: `/last30days birthday gift for 40 year old` should ask a clarifying question before running.
|
||||
|
||||
## [3.0.5] - 2026-04-15
|
||||
|
||||
### Added
|
||||
|
||||
- **`/last30days` slash command for plugin users.** New `commands/last30days.md` registers a Claude Code slash command. Users type `/last30days <topic>` and Claude Code's autocomplete prefix-matches it to the canonical `/last30days:last30days` form (the same way `/ce:plan` resolves to `/compound-engineering:ce-plan`). The command delegates to the existing `last30days` skill body — no skill behavior changes.
|
||||
|
||||
### Removed
|
||||
|
||||
- **`skills/last30days-nux/`** — byte-identical duplicate of root `SKILL.md` that created confusing `/last30days:last30days-nux` autocomplete entries via Claude Code's plugin namespacing. The root `SKILL.md` remains the canonical skill source.
|
||||
|
||||
### Recovery
|
||||
|
||||
```
|
||||
/plugin update last30days
|
||||
/reload-plugins
|
||||
```
|
||||
|
||||
Then type `/last30days <topic>` to invoke the skill via slash command. Natural-language invocation ("search the last 30 days for X") continues to work unchanged.
|
||||
|
||||
## [3.0.4] - 2026-04-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cleared `/doctor` path-escape error on Claude Code v2.1.109+.** `.claude-plugin/plugin.json` previously declared `"skills": ["./"]`. That value shipped unchanged from v2.1.0 through v3.0.3 and worked on older Claude Code, but current versions reject `./` with `Path escapes plugin directory: ./ (skills)`. The `"skills"` key is now omitted entirely, matching the pattern used by every other plugin in the Claude Code marketplace ecosystem. Claude Code auto-discovers `skills/*/SKILL.md` when the key is absent.
|
||||
|
||||
### Recovery
|
||||
|
||||
If `/doctor` reports a path-escape error for last30days, run `/plugin update last30days` then `/reload-plugins`. If errors persist, uninstall and reinstall the plugin.
|
||||
|
||||
## [3.0.3] - 2026-04-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Restored `skills/` and `.claude-plugin/` to the plugin install tarball.** v3.0.1 added `.gitattributes` rules that excluded both directories from `git archive` output to shrink the claude.ai `.skill` bundle. Claude Code's `/plugin install` fetches the same archive, so users installing v3.0.1 or v3.0.2 received a tarball with no plugin manifest and no skill files. `git archive v3.0.0` contained 8 files under those paths; `v3.0.1` and `v3.0.2` contained 0. This release reverts those `.gitattributes` lines.
|
||||
- **Reverted `plugin.json` `"skills"` field to `["./"]`.** v3.0.2 changed this to `["skills"]` based on a misdiagnosis — the manifest change had no effect because the manifest wasn't in the tarball at all. The historical `["./"]` value shipped in every release from v2.1.0 through v3.0.0 without issues and is restored here.
|
||||
|
||||
### Recovery
|
||||
|
||||
Users on v3.0.1 or v3.0.2: run `/plugin update last30days` then `/reload-plugins`. If autoUpdate is enabled, the next session start will pull v3.0.3 automatically. Users on cached v3.0.0 or earlier installs were unaffected.
|
||||
|
||||
### Notes
|
||||
|
||||
- The claude.ai `.skill` bundle built by `scripts/build-skill.sh` still works — the archive grew from 89 to 97 files, well under the 200-file cap.
|
||||
- claude.ai-specific exclusions (avoiding duplicate `SKILL.md` files in the bundle) should move into `scripts/build-skill.sh` rather than `.gitattributes` in a future release, since `.gitattributes` cannot distinguish between the two distribution channels.
|
||||
|
||||
## [3.0.2] - 2026-04-15
|
||||
|
||||
### 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
|
||||
|
||||
@@ -34,10 +162,18 @@ Intelligent search, fun judge, cross-source cluster merging, single-pass compari
|
||||
- Polymarket display shows % odds only; dollar volumes removed
|
||||
- 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
|
||||
|
||||
- @j-sperling -- v3 engine architecture, Python pre-research brain
|
||||
- @hnshah -- Watchlist features
|
||||
- @Cody-Coyote -- Marketplace validation bug report (#204)
|
||||
- @Jah-yee -- Codex CLI integration inspiration (#153)
|
||||
|
||||
## [2.9.4] - 2026-03-06
|
||||
|
||||
@@ -181,7 +317,6 @@ Three headline features: watchlists for always-on bots, YouTube transcripts as a
|
||||
|
||||
### Credits
|
||||
|
||||
- @steipete -- Bird CLI (vendored X search) and yt-dlp/summarize inspiration for YouTube transcripts
|
||||
- @galligan -- Marketplace plugin inspiration
|
||||
- @hutchins -- Pushed for YouTube feature
|
||||
|
||||
@@ -189,6 +324,7 @@ Three headline features: watchlists for always-on bots, YouTube transcripts as a
|
||||
|
||||
Initial public release. Reddit + X search via OpenAI Responses API and xAI API.
|
||||
|
||||
[3.0.9]: https://github.com/mvanhorn/last30days-skill/compare/v3.0.5...v3.0.9
|
||||
[2.9.1]: https://github.com/mvanhorn/last30days-skill/compare/v2.9.0...v2.9.1
|
||||
[2.9.0]: https://github.com/mvanhorn/last30days-skill/compare/v2.8.0...v2.9.0
|
||||
[2.8.0]: https://github.com/mvanhorn/last30days-skill/compare/v2.6.0...v2.8.0
|
||||
|
||||
@@ -18,4 +18,8 @@ bash scripts/sync.sh # Deploy to ~/.claud
|
||||
## Rules
|
||||
- `lib/__init__.py` must be bare package marker (comment only, NO eager imports)
|
||||
- After edits: run `bash scripts/sync.sh` to deploy
|
||||
- Git remotes: origin=private, upstream=public
|
||||
- Git remote: origin = public (`mvanhorn/last30days-skill`)
|
||||
|
||||
## Beta channel
|
||||
|
||||
Experimental changes get tested on `mvanhorn/last30days-skill-private`, which installs as a parallel `/last30days-beta` slash command. Beta-only changes never ship to public without a review PR here. Workflow guide lives at `BETA.md` in the private repo. Plan that established this setup: `docs/plans/2026-04-17-005-feat-beta-skill-from-private-repo-plan.md`.
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
# 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
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
**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-v3/SKILL.md](skills/last30days-v3/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/SKILL.md](skills/last30days/SKILL.md), which is the source of truth for the latest command and setup behavior.
|
||||
|
||||
Claude Code:
|
||||
```
|
||||
@@ -24,6 +24,12 @@ OpenClaw:
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -122,7 +128,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.
|
||||
- **YouTube transcripts that actually work.** Widened candidate pool 3x past music videos to reach talk/review content with captions.
|
||||
- **Threads, Pinterest, YouTube comments.** Opt-in sources via ScrapeCreators. Set `INCLUDE_SOURCES=tiktok,instagram` and add threads, pinterest, youtube_comments for more.
|
||||
- **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.
|
||||
- **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?"
|
||||
- **Resilient Reddit.** Timeout budgets and runtime fallback. One slow thread doesn't kill the whole run.
|
||||
@@ -135,28 +141,52 @@ Say "eli5 on" after any research run. The synthesis rewrites in plain language.
|
||||
|
||||
## 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
|
||||
|
||||
#### Install
|
||||
```
|
||||
/plugin marketplace add mvanhorn/last30days-skill
|
||||
```
|
||||
|
||||
#### Update
|
||||
```
|
||||
claude plugin update last30days@last30days-skill
|
||||
```
|
||||
Update later with `claude plugin update last30days@last30days-skill`.
|
||||
|
||||
### OpenClaw
|
||||
|
||||
```bash
|
||||
clawhub install last30days-official
|
||||
```
|
||||
|
||||
### Manual
|
||||
### Gemini CLI
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
## Bring your own keys
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
description: Research what people actually say about any topic in the last 30 days across Reddit, X, YouTube, TikTok, Hacker News, Polymarket, GitHub, and the web.
|
||||
argument-hint: <topic> — e.g. "nvidia earnings reaction" or "best noise cancelling headphones"
|
||||
allowed-tools: [Bash, Read, Write, AskUserQuestion, WebSearch]
|
||||
---
|
||||
|
||||
Invoke the `last30days` skill with the user's arguments: $ARGUMENTS
|
||||
|
||||
Use the skill's canonical pipeline (plan → retrieve → normalize → fuse → rerank → cluster → render). If the user provided no arguments, ask them for a topic before proceeding.
|
||||
@@ -0,0 +1,112 @@
|
||||
# v3.0.9 - The Self-Debug Release
|
||||
|
||||
## Highlights
|
||||
|
||||
**v3.0.9 is live.** New user-facing capabilities, broader cross-platform support, and a skill that now runs reliably on Claude Code, Codex, Hermes, Gemini, claude.ai, and OpenClaw. The headline fix: the engine refuses "birthday gift for 40 year old" style queries with a clarifying question instead of 5 minutes of junk output. The headline feature: TikTok and YouTube top comments now render alongside Reddit's, so the most-engaged voice from every source makes it into the synthesis.
|
||||
|
||||
**The label - "The Self-Debug Release":** I handed 5 separate Opus 4.7 instances their own failed outputs and asked them to debug themselves. Three converged on "SKILL.md is too big and the LAWs are too deep." Two converged on "the engine should refuse demographic-shopping queries outright" and "the WebSearch Sources reminder is overriding LAW 1." I copy-pasted their diagnoses into code. Validation: 5/5 canonical compliance on the topics that had failed.
|
||||
|
||||
## New capabilities
|
||||
|
||||
- **TikTok and YouTube top comments render alongside Reddit's.** PR [#260](https://github.com/mvanhorn/last30days-skill/pull/260) made the top-engagement comment from each TikTok video and YouTube video first-class in the output - same prominent `💬 Top comment` treatment Reddit's top comment already got. This is the biggest user-facing output change since 3.0.0 and it was never announced. The community inspiration trace: @uppinote20's original push for richer Reddit comments ([PR #143](https://github.com/mvanhorn/last30days-skill/pull/143)) seeded the pattern; this PR generalized it across TikTok and YouTube. PR [#265](https://github.com/mvanhorn/last30days-skill/pull/265) followed up by fixing the ScrapeCreators `url=` param + new response shape for YouTube comments/transcripts so the enrichment actually works.
|
||||
|
||||
- **last30days runs on Hermes AI Agent now.** @stephenmcconnachie's PR ([#228](https://github.com/mvanhorn/last30days-skill/pull/228)) added Hermes as a first-class deploy target. `scripts/sync.sh` detects `~/.hermes/skills/research` and deploys the full skill (SKILL.md, scripts, lib modules, fixtures) to Hermes's skills directory alongside Claude Code and Codex. This is one of the biggest surface-area expansions in v3 - last30days is now usable inside the Hermes agent's research workflows without any manual wiring.
|
||||
|
||||
- **Multi-key SCRAPECREATORS_API_KEY rotation.** @zaydiscold's PR ([#268](https://github.com/mvanhorn/last30days-skill/pull/268)) added automatic key rotation. Set `SCRAPECREATORS_API_KEY_1`, `SCRAPECREATORS_API_KEY_2`, etc. and the engine rotates when a key hits rate limits instead of failing the whole run. For power users running daily queries, this is the difference between rate-limit 429s and zero-touch reliability.
|
||||
|
||||
- **The skill works on Windows now.** @Chelebii's PR ([#227](https://github.com/mvanhorn/last30days-skill/pull/227)) stabilized the vendored Bird X search client on Windows. Previously the bundled X backend had subtle runtime issues on Windows terminals; now it runs clean. Pair this with @Gujiassh's UTF-8 encoding fix ([#225](https://github.com/mvanhorn/last30days-skill/pull/225)) for saved output and Windows users get the full v3 experience without workarounds.
|
||||
|
||||
- **Linux permission checks stopped false-warning.** @george231224's PR ([#216](https://github.com/mvanhorn/last30days-skill/pull/216)) fixed `check_perms` on Linux by preferring GNU stat's syntax over the BSD stat that the skill was calling. Linux users were getting spurious permission warnings on `.env` files that were already correctly 600-chmod'd. Now the check matches reality.
|
||||
|
||||
- **Gemini CLI got a first-class install path.** @hnshah's docs PR ([#224](https://github.com/mvanhorn/last30days-skill/pull/224)) added the Gemini CLI install note and workaround for a rough edge in the Gemini skill loader. Gemini users now have a one-paragraph install flow in the README instead of having to reverse-engineer the plugin layout.
|
||||
|
||||
- **Offline quality evaluation.** @j-sperling's PR ([#233](https://github.com/mvanhorn/last30days-skill/pull/233)) added `eval_topics.json` as a fixture. Contributors and I can now run quality-regression checks on synthesis output without burning live API credits. This is the scaffolding that made the plan 015 validation gate affordable - without eval fixtures, testing 5/5 canonical compliance on every release would cost real money every time. Ships as contributor infrastructure but shows up as stability for end users.
|
||||
|
||||
- **Reddit client got a cleaner HTTP layer.** @iliaal shipped three architecture PRs back-to-back ([#207](https://github.com/mvanhorn/last30days-skill/pull/207), [#208](https://github.com/mvanhorn/last30days-skill/pull/208), [#209](https://github.com/mvanhorn/last30days-skill/pull/209)) that consolidated Reddit's HTTP handling into `http.get(params=...)`, rejected garbage input in `_parse_date`, and unified `_sc_headers` into `http.scrapecreators_headers`. End-user benefit: fewer flaky timeouts, fewer "weird parse error" crashes, a codebase that's easier for future contributors to touch without breaking Reddit. These aren't sexy PRs; they're the kind of refactor that prevents six future bug reports.
|
||||
|
||||
- **The `--days=N` flag keeps working.** @BryanTegomoh's PR ([#230](https://github.com/mvanhorn/last30days-skill/pull/230)) restored backcompat for the legacy `--days` alias so anyone who'd scripted against it in 2.x doesn't break on v3. Small PR, meaningful reliability gain for existing users.
|
||||
|
||||
- **INCLUDE_SOURCES has a sane default.** @hnshah's PR ([#223](https://github.com/mvanhorn/last30days-skill/pull/223)) defaulted the env var to empty string instead of unset. Missing env no longer breaks source inclusion on fresh installs.
|
||||
|
||||
- **Version metadata stays in sync.** @Gujiassh's PR ([#217](https://github.com/mvanhorn/last30days-skill/pull/217)) aligned the SKILL.md version header with the sync target version, and @shalomma's PR ([#229](https://github.com/mvanhorn/last30days-skill/pull/229)) closed the remaining drift between the SKILL.md header and plugin.json. "Which version am I actually on" is no longer an adventure.
|
||||
|
||||
- **Bird X engagement handling got hardened.** @j-sperling's PR ([#234](https://github.com/mvanhorn/last30days-skill/pull/234)) made `bird_x` skip all-None engagement dicts instead of crashing on them. Rare condition, but the kind of thing that silently kills a run on a specific topic.
|
||||
|
||||
- **Dev workflow hygiene.** @j-sperling's gitignore PR ([#232](https://github.com/mvanhorn/last30days-skill/pull/232)) dropped `.venv`, `.coverage`, `htmlcov`, and `.memsearch` from the tracked tree. Contributor quality-of-life; keeps PR diffs clean.
|
||||
|
||||
- **The skill installs to claude.ai.** PRs [#242](https://github.com/mvanhorn/last30days-skill/pull/242) and [#244](https://github.com/mvanhorn/last30days-skill/pull/244) shipped `scripts/build-skill.sh` plus the `.gitattributes` + `export-ignore` plumbing that packages last30days into a claude.ai-upload-ready `.skill` file under the 200-file cap. The skill is no longer Claude-Code-only - it installs directly on claude.ai, too. README has the upload workflow.
|
||||
|
||||
- **OpenAI Codex CLI discovers the skill natively.** PR [#219](https://github.com/mvanhorn/last30days-skill/pull/219) added `.agents/skills/last30days/SKILL.md` as a real file (not symlinked - Codex's loader skips symlinks) plus `.codex-plugin/plugin.json` as the namespace marker. The skill now shows up as `last30days:last30days` when Codex runs in a checkout. Inspired by @Jah-yee ([#153](https://github.com/mvanhorn/last30days-skill/pull/153)) and @dannyshmueli on X.
|
||||
|
||||
- **`/last30days` as a slash command.** PR [#267](https://github.com/mvanhorn/last30days-skill/pull/267) added `commands/last30days.md` so plugin users can type `/last30days <topic>` and Claude Code autocomplete prefix-matches it to the canonical `/last30days:last30days` form. No more typing the double-namespace.
|
||||
|
||||
## The self-debug technique, for anyone rebuilding this elsewhere
|
||||
|
||||
The breakthrough wasn't the individual fixes. It was the realization that instead of guessing why the model was ignoring the rules, I should ask the model. Five separate Opus 4.7 sessions debugged their own outputs:
|
||||
|
||||
- "Did you read SKILL.md?" → "I tried Read, hit the 25K token cap, and bailed instead of chunked-reading."
|
||||
- "Why the trailing Sources block?" → "The WebSearch tool's own reminder said MANDATORY. Precedence was unclear."
|
||||
- "Why the section headers?" → "I had strong priors on Peter Steinberger and wrote my thesis instead of passing through."
|
||||
- "Why the wrong file?" → "I read `.agents/skills/last30days/SKILL.md` first because it appeared in the path glob."
|
||||
|
||||
Three of the five said "move the LAWs to the top." Two said "make the engine enforce it so the model can't not comply." I shipped both. That's the whole technique: when the LLM-under-orchestration keeps breaking the contract, don't argue with it - ask it to debug itself, and build structural enforcement around whatever it names as the root cause.
|
||||
|
||||
## Thank you
|
||||
|
||||
**Community PR authors since v3.0.0:**
|
||||
- @j-sperling - v3 engine architecture, eval fixtures, gitignore hygiene, Bird X hardening ([#232](https://github.com/mvanhorn/last30days-skill/pull/232), [#233](https://github.com/mvanhorn/last30days-skill/pull/233), [#234](https://github.com/mvanhorn/last30days-skill/pull/234))
|
||||
- @stephenmcconnachie - Hermes AI Agent support ([#228](https://github.com/mvanhorn/last30days-skill/pull/228))
|
||||
- @zaydiscold - Multi-key SCRAPECREATORS rotation ([#268](https://github.com/mvanhorn/last30days-skill/pull/268))
|
||||
- @iliaal - Reddit HTTP helper + GitHub date parsing + ScrapeCreators header consolidation ([#207](https://github.com/mvanhorn/last30days-skill/pull/207), [#208](https://github.com/mvanhorn/last30days-skill/pull/208), [#209](https://github.com/mvanhorn/last30days-skill/pull/209))
|
||||
- @Chelebii - Windows Bird X stability ([#227](https://github.com/mvanhorn/last30days-skill/pull/227))
|
||||
- @george231224 - Linux check_perms stat ([#216](https://github.com/mvanhorn/last30days-skill/pull/216))
|
||||
- @Gujiassh - UTF-8 saved output + version metadata alignment ([#217](https://github.com/mvanhorn/last30days-skill/pull/217), [#225](https://github.com/mvanhorn/last30days-skill/pull/225))
|
||||
- @hnshah - INCLUDE_SOURCES default + Gemini install docs ([#223](https://github.com/mvanhorn/last30days-skill/pull/223), [#224](https://github.com/mvanhorn/last30days-skill/pull/224))
|
||||
- @shalomma - SKILL.md v3.0.0 version header ([#229](https://github.com/mvanhorn/last30days-skill/pull/229))
|
||||
- @BryanTegomoh - --days alias backcompat ([#230](https://github.com/mvanhorn/last30days-skill/pull/230))
|
||||
|
||||
**v3 roadmap contributors (issues and PRs that shaped the v3 feature set):**
|
||||
- @uppinote20 - rich Reddit comments ([#143](https://github.com/mvanhorn/last30days-skill/pull/143))
|
||||
- @zerone0x - GitHub as a first-class source ([#134](https://github.com/mvanhorn/last30days-skill/issues/134), [#136](https://github.com/mvanhorn/last30days-skill/pull/136))
|
||||
- @thinkun - Reddit enrichment timeout handling ([#116](https://github.com/mvanhorn/last30days-skill/pull/116))
|
||||
- @thomasmktong - pure-Python Reddit fallback ([#124](https://github.com/mvanhorn/last30days-skill/pull/124))
|
||||
- @fanispoulinakisai-boop - Reddit timeout report ([#100](https://github.com/mvanhorn/last30days-skill/issues/100))
|
||||
- @pejmanjohn - plugin directory naming ([#99](https://github.com/mvanhorn/last30days-skill/issues/99), [#78](https://github.com/mvanhorn/last30days-skill/issues/78))
|
||||
- @zl190 - HN trending merge ([#115](https://github.com/mvanhorn/last30days-skill/pull/115))
|
||||
- @hnshah - Watchlist features ([#84](https://github.com/mvanhorn/last30days-skill/pull/84), [#85](https://github.com/mvanhorn/last30days-skill/pull/85), [#86](https://github.com/mvanhorn/last30days-skill/pull/86))
|
||||
- @Jah-yee, @dannyshmueli - Codex CLI discovery
|
||||
- @Cody-Coyote - marketplace validation bug report ([#204](https://github.com/mvanhorn/last30days-skill/issues/204))
|
||||
|
||||
**The five Opus 4.7 instances that debugged their own failures on v3.0.7 and v3.0.8 and converged on the fixes.** The convergence was the breakthrough; this release is their diagnosis in code.
|
||||
|
||||
## Install / Update
|
||||
|
||||
```
|
||||
/plugin marketplace add mvanhorn/last30days-skill
|
||||
/plugin install last30days@last30days-skill
|
||||
```
|
||||
|
||||
Or if already installed:
|
||||
|
||||
```
|
||||
/plugin update last30days
|
||||
/reload-plugins
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```
|
||||
cat ~/.claude/plugins/cache/last30days-skill/last30days/*/.claude-plugin/plugin.json | grep version
|
||||
```
|
||||
|
||||
Should print `"version": "3.0.9"`.
|
||||
|
||||
## Smoke test
|
||||
|
||||
```
|
||||
/last30days birthday gift for 40 year old
|
||||
```
|
||||
|
||||
Should ask a clarifying question before running. If it runs the engine anyway, the cache is stale - repeat the plugin update.
|
||||
|
||||
**Full Changelog:** https://github.com/mvanhorn/last30days-skill/compare/v3.0.5...v3.0.9
|
||||
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "last30days-skill",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.5",
|
||||
"description": "Research a topic from the last 30 days across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web.",
|
||||
"settings": [
|
||||
{
|
||||
|
||||
@@ -12,7 +12,11 @@ check_perms() {
|
||||
local file="$1"
|
||||
if [[ ! -f "$file" ]]; then return; fi
|
||||
local perms
|
||||
perms=$(stat -f '%Lp' "$file" 2>/dev/null || stat -c '%a' "$file" 2>/dev/null || echo "")
|
||||
# Try GNU stat first (Linux), fall back to BSD stat (macOS).
|
||||
# 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
|
||||
echo "/last30days: WARNING — $file has permissions $perms (should be 600)."
|
||||
echo " Fix: chmod 600 $file"
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
# 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
|
||||
@@ -1,328 +0,0 @@
|
||||
# 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
|
||||
+58
-47
@@ -1,75 +1,86 @@
|
||||
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, 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.
|
||||
`/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.
|
||||
|
||||
## v3 Community
|
||||
## v3 is the intelligent search release
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Thanks to @uppinote20, @zerone0x, @thinkun, @thomasmktong, @fanispoulinakisai-boop, @pejmanjohn, @zl190, and @hnshah.
|
||||
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.
|
||||
|
||||
## What's New in v2.9.1
|
||||
## Headline features
|
||||
|
||||
**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.
|
||||
### Intelligent pre-research
|
||||
|
||||
## Three Headline Features in v2.9
|
||||
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.
|
||||
|
||||
**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.
|
||||
### Best Takes
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
**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.
|
||||
### Cross-source cluster merging
|
||||
|
||||
Plus: **Instagram Reels** (v2.8), **Polymarket prediction markets** (v2.5), **YouTube transcripts** (v2.1), **bundled X search** - no external CLI needed.
|
||||
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.
|
||||
|
||||
## Beta Test Results (v2.9)
|
||||
### Single-pass comparisons
|
||||
|
||||
| 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 |
|
||||
"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.
|
||||
|
||||
## What's New
|
||||
### GitHub person-mode and project-mode
|
||||
|
||||
### 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
|
||||
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.
|
||||
|
||||
### 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
|
||||
When the topic is a project, it pulls live star counts, READMEs, releases, and top issues from the GitHub API. No stale blog posts.
|
||||
|
||||
### Fixed
|
||||
- Utility sub noise in subreddit discovery
|
||||
- Reddit no longer requires `OPENAI_API_KEY`
|
||||
### ELI5 mode
|
||||
|
||||
## New Contributors
|
||||
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.
|
||||
|
||||
- @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))
|
||||
### 13+ sources
|
||||
|
||||
## Credits
|
||||
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.
|
||||
|
||||
- [@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
|
||||
### Per-author cap and entity disambiguation
|
||||
|
||||
Max 3 items per author prevents single-voice dominance. Synthesis trusts resolved handles over fuzzy keyword matches.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
|
||||
Claude Code:
|
||||
|
||||
# Codex CLI
|
||||
git clone https://github.com/mvanhorn/last30days-skill.git ~/.agents/skills/last30days
|
||||
```
|
||||
/plugin marketplace add mvanhorn/last30days-skill
|
||||
```
|
||||
|
||||
30 days of research. 30 seconds of work. Eight sources. Zero stale prompts.
|
||||
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 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.
|
||||
|
||||
Thanks to @uppinote20, @zerone0x, @thinkun, @thomasmktong, @fanispoulinakisai-boop, @pejmanjohn, @zl190, and @hnshah. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list.
|
||||
|
||||
Contributors who shaped the release itself:
|
||||
|
||||
- @Jah-yee (#153) surfaced the need for a real Codex CLI integration, which shipped in #219
|
||||
- @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]`.
|
||||
|
||||
## Earlier contributors
|
||||
|
||||
From the v1 and v2 lineage:
|
||||
|
||||
- [@galligan](https://github.com/galligan) for marketplace plugin inspiration
|
||||
- [@hutchins](https://x.com/hutchins) for pushing the YouTube feature
|
||||
|
||||
30 days of research. 30 seconds of work. Thirteen sources. Zero stale prompts.
|
||||
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/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
|
||||
|
||||
# claude.ai's .skill bundle only needs the root SKILL.md + scripts/ runtime.
|
||||
# Claude Code needs skills/ and .claude-plugin/ in the git archive
|
||||
# (that's why they're NOT in .gitattributes export-ignore), but the .skill
|
||||
# bundle must strip them to keep a single canonical SKILL.md and stay under
|
||||
# the 200-file cap.
|
||||
zip -d "$OUT" "last30days/skills/*" "last30days/.claude-plugin/*" > /dev/null 2>&1 || true
|
||||
|
||||
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 and this script's zip -d excludes" >&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"
|
||||
+21
-20
@@ -1,14 +1,13 @@
|
||||
#!/bin/bash
|
||||
# A/B/C test runner for last30days skill variants
|
||||
# A/B test runner: public release vs private beta
|
||||
# Usage: bash scripts/compare.sh "Kanye West"
|
||||
#
|
||||
# Runs all 3 skills sequentially (30s gap for rate limits),
|
||||
# saves raw results with unique suffixes, then prints file paths
|
||||
# for comparison.
|
||||
# Runs /last30days (public release) and /last30days-beta (private beta)
|
||||
# sequentially with a 30s gap, saves raw results with distinct suffixes,
|
||||
# prints file paths for comparison.
|
||||
|
||||
set -e
|
||||
|
||||
# Join all args as the topic (so "bash compare.sh Kevin Rose" works without quotes)
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: bash scripts/compare.sh <topic>"
|
||||
echo " Example: bash scripts/compare.sh Kevin Rose"
|
||||
@@ -20,40 +19,42 @@ DIR="$HOME/Documents/Last30Days"
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
echo "=============================================="
|
||||
echo " A/B/C Test: $TOPIC"
|
||||
echo " A/B Test: $TOPIC"
|
||||
echo " Date: $DATE"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
|
||||
# Run 1: v2.9 production
|
||||
echo "[1/3] Running v2.9 (production /last30days)..."
|
||||
# Run 1: public release
|
||||
echo "[1/2] Running /last30days (public release)..."
|
||||
echo " This takes 2-4 minutes..."
|
||||
claude -p --dangerously-skip-permissions "/last30days $TOPIC" > /dev/null 2>&1 || true
|
||||
V2_FILE="$DIR/${SLUG}-raw.md"
|
||||
[ -f "$V2_FILE" ] && echo " ✓ Done → $V2_FILE" || echo " ✗ FAILED — no output file"
|
||||
RELEASE_FILE="$DIR/${SLUG}-raw.md"
|
||||
[ -f "$RELEASE_FILE" ] && echo " Done: $RELEASE_FILE" || echo " FAILED: no output file"
|
||||
echo ""
|
||||
|
||||
echo " Waiting 30s for API rate limits..."
|
||||
sleep 30
|
||||
|
||||
# Run 2: v3 Gemini
|
||||
echo "[2/3] Running v3 (/last30days-3)..."
|
||||
# Run 2: private beta
|
||||
echo "[2/2] Running /last30days-beta (private beta)..."
|
||||
echo " This takes 2-4 minutes..."
|
||||
claude -p --dangerously-skip-permissions "/last30days-3:last30days-skill-private $TOPIC" > /dev/null 2>&1 || true
|
||||
V3GEM_FILE="$DIR/${SLUG}-raw-v3.md"
|
||||
[ -f "$V3GEM_FILE" ] && echo " ✓ Done → $V3GEM_FILE" || echo " ✗ FAILED — no output file"
|
||||
echo ""
|
||||
|
||||
claude -p --dangerously-skip-permissions "/last30days-beta $TOPIC" > /dev/null 2>&1 || true
|
||||
BETA_FILE="$DIR/${SLUG}-raw-beta.md"
|
||||
[ -f "$BETA_FILE" ] && echo " Done: $BETA_FILE" || echo " FAILED: no output file"
|
||||
echo ""
|
||||
|
||||
echo "=============================================="
|
||||
echo " Both complete. Raw files:"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
ls -la "$DIR/${SLUG}-raw"*.md 2>/dev/null || echo " (no files found — check if skills saved correctly)"
|
||||
ls -la "$DIR/${SLUG}-raw"*.md 2>/dev/null || echo " (no files found - check if skills saved correctly)"
|
||||
echo ""
|
||||
echo "To compare, run in Claude Code:"
|
||||
echo " Read and compare these raw research files, produce a detailed report:"
|
||||
echo " $DIR/${SLUG}-raw.md"
|
||||
echo " $DIR/${SLUG}-raw-v3.md"
|
||||
echo " $RELEASE_FILE"
|
||||
echo " $BETA_FILE"
|
||||
echo ""
|
||||
echo "Beta output should start with a line like:"
|
||||
echo " 🧪 last30days-beta · branch <name> · synced $DATE"
|
||||
echo "If that line is missing, the beta badge regressed. See docs/plans/2026-04-17-005-*-plan.md."
|
||||
echo ""
|
||||
|
||||
+64
-5
@@ -33,6 +33,11 @@ def ensure_supported_python(version_info: tuple[int, int, int] | object | None =
|
||||
|
||||
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()
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
@@ -103,20 +108,40 @@ def save_output(report: schema.Report, emit: str, save_dir: str, suffix: str = "
|
||||
content = emit_output(report, emit)
|
||||
else:
|
||||
content = render.render_full(report)
|
||||
out_path.write_text(content)
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
return out_path
|
||||
|
||||
|
||||
def emit_output(report: schema.Report, emit: str, fun_level: str = "medium") -> str:
|
||||
def emit_output(report: schema.Report, emit: str, fun_level: str = "medium", save_path: str | None = None) -> str:
|
||||
if emit == "json":
|
||||
return json.dumps(schema.to_dict(report), indent=2, sort_keys=True)
|
||||
if emit in {"compact", "md"}:
|
||||
return render.render_compact(report, fun_level=fun_level)
|
||||
return render.render_compact(report, fun_level=fun_level, save_path=save_path)
|
||||
if emit == "context":
|
||||
return render.render_context(report)
|
||||
raise SystemExit(f"Unsupported emit mode: {emit}")
|
||||
|
||||
|
||||
def compute_save_path_display(save_dir: str, topic: str, suffix: str, emit: str) -> str:
|
||||
"""Compute the user-friendly save path string that will be shown in the footer.
|
||||
|
||||
Uses ~ for the home directory so the footer reads "~/Documents/Last30Days/slug-raw.md"
|
||||
instead of an absolute machine-local path.
|
||||
"""
|
||||
from pathlib import Path as _Path
|
||||
path = _Path(save_dir).expanduser().resolve()
|
||||
slug = slugify(topic)
|
||||
extension = "json" if emit == "json" else "md"
|
||||
suffix_part = f"-{suffix}" if suffix else ""
|
||||
raw = path / f"{slug}-raw{suffix_part}.{extension}"
|
||||
try:
|
||||
home = _Path.home().resolve()
|
||||
relative = raw.relative_to(home)
|
||||
return f"~/{relative}"
|
||||
except ValueError:
|
||||
return str(raw)
|
||||
|
||||
|
||||
def persist_report(report: schema.Report) -> dict[str, int]:
|
||||
import store
|
||||
|
||||
@@ -165,7 +190,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
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("--ig-creators", help="Comma-separated Instagram creator handles (e.g., tella.tv,laborstories)")
|
||||
parser.add_argument("--lookback-days", type=int, default=30, help="Number of days to look back for research (default: 30, watchlist uses 90)")
|
||||
parser.add_argument(
|
||||
"--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",
|
||||
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)")
|
||||
@@ -258,6 +290,13 @@ def main() -> int:
|
||||
parser.print_usage(sys.stderr)
|
||||
return 2
|
||||
|
||||
if not os.environ.get("LAST30DAYS_SKIP_PREFLIGHT"):
|
||||
from lib import preflight
|
||||
refuse_msg = preflight.check_class_1_trap(topic)
|
||||
if refuse_msg:
|
||||
sys.stderr.write(refuse_msg)
|
||||
return 2
|
||||
|
||||
progress = ui.ProgressDisplay(topic, show_banner=True)
|
||||
progress.start_processing()
|
||||
|
||||
@@ -361,7 +400,27 @@ def main() -> int:
|
||||
pass
|
||||
|
||||
fun_level = config.get("FUN_LEVEL", "medium").lower()
|
||||
rendered = emit_output(report, args.emit, fun_level=fun_level)
|
||||
footer_save_path = None
|
||||
if args.save_dir:
|
||||
footer_save_path = compute_save_path_display(
|
||||
args.save_dir, report.topic, args.save_suffix or "", args.emit
|
||||
)
|
||||
|
||||
# Signal to render_compact whether pre-research flags were supplied.
|
||||
# Used to emit a Pre-Research Status warning when the model skipped
|
||||
# Step 0.5 / 0.55 and invoked the engine bare on an eligible topic.
|
||||
pre_research_flags_present = bool(
|
||||
args.x_handle
|
||||
or args.github_user
|
||||
or args.subreddits
|
||||
or args.plan
|
||||
or args.auto_resolve
|
||||
or args.tiktok_creators
|
||||
or args.ig_creators
|
||||
)
|
||||
report.artifacts["pre_research_flags_present"] = pre_research_flags_present
|
||||
|
||||
rendered = emit_output(report, args.emit, fun_level=fun_level, save_path=footer_save_path)
|
||||
if args.save_dir:
|
||||
save_path = save_output(report, args.emit, args.save_dir, suffix=args.save_suffix or "")
|
||||
sys.stderr.write(f"[last30days] Saved output to {save_path}\n")
|
||||
|
||||
@@ -177,6 +177,8 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
preexec_fn=preexec,
|
||||
env=_subprocess_env(),
|
||||
)
|
||||
@@ -336,6 +338,8 @@ def search_handles(
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
preexec_fn=preexec,
|
||||
env=_subprocess_env(),
|
||||
)
|
||||
@@ -460,7 +464,7 @@ def parse_bird_response(response: Dict[str, Any], query: str = "") -> List[Dict[
|
||||
"url": url,
|
||||
"author_handle": author_handle.lstrip("@"),
|
||||
"date": date,
|
||||
"engagement": engagement,
|
||||
"engagement": engagement if any(v is not None for v in engagement.values()) else None,
|
||||
"why_relevant": "", # Bird doesn't provide relevance explanations
|
||||
"relevance": _compute_relevance(query, str(tweet.get("text", ""))) if query else 0.7,
|
||||
}
|
||||
|
||||
+15
-1
@@ -264,7 +264,7 @@ def get_config() -> dict[str, Any]:
|
||||
('XQUIK_API_KEY', None),
|
||||
('FROM_BROWSER', None),
|
||||
('SETUP_COMPLETE', None),
|
||||
('INCLUDE_SOURCES', None),
|
||||
('INCLUDE_SOURCES', ''),
|
||||
]
|
||||
|
||||
for key, default in keys:
|
||||
@@ -441,6 +441,18 @@ def is_youtube_comments_available(config: dict[str, Any]) -> bool:
|
||||
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:
|
||||
"""Check if ScrapeCreators YouTube search fallback is available.
|
||||
|
||||
@@ -579,6 +591,8 @@ def get_x_source_status(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
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()
|
||||
xai_available = bool(config.get('XAI_API_KEY'))
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from . import log
|
||||
from . import dates, log
|
||||
from .query import extract_core_subject
|
||||
from .relevance import token_overlap_relevance
|
||||
|
||||
@@ -106,13 +106,14 @@ def _parse_repo_from_url(html_url: str) -> str:
|
||||
|
||||
|
||||
def _parse_date(iso_str: Optional[str]) -> Optional[str]:
|
||||
"""Extract YYYY-MM-DD from ISO 8601 datetime string."""
|
||||
if not iso_str:
|
||||
return None
|
||||
try:
|
||||
return iso_str[:10]
|
||||
except (IndexError, TypeError):
|
||||
return None
|
||||
"""Parse a GitHub ISO 8601 datetime string and return YYYY-MM-DD.
|
||||
|
||||
Returns None for non-date input. GitHub's API always emits ISO 8601
|
||||
(e.g. "2026-02-26T16:00:00Z"), but we defer to dates.parse_date() so
|
||||
garbage input gets rejected instead of silently sliced.
|
||||
"""
|
||||
dt = dates.parse_date(iso_str)
|
||||
return dt.strftime("%Y-%m-%d") if dt else None
|
||||
|
||||
|
||||
def _compute_relevance(
|
||||
|
||||
@@ -38,6 +38,7 @@ def request(
|
||||
url: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
retries: int = MAX_RETRIES,
|
||||
max_429_retries: int = MAX_429_RETRIES,
|
||||
@@ -50,6 +51,8 @@ def request(
|
||||
url: Request URL
|
||||
headers: Optional headers dict
|
||||
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
|
||||
retries: Number of retries on failure
|
||||
max_429_retries: Maximum 429 retries before giving up (separate cap)
|
||||
@@ -64,6 +67,12 @@ def request(
|
||||
headers = headers or {}
|
||||
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
|
||||
if json_data is not None:
|
||||
data = json.dumps(json_data).encode('utf-8')
|
||||
@@ -157,6 +166,14 @@ 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)
|
||||
|
||||
|
||||
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]:
|
||||
"""Fetch Reddit thread JSON.
|
||||
|
||||
|
||||
@@ -112,14 +112,6 @@ def _log(msg: str):
|
||||
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]:
|
||||
"""Parse date from ScrapeCreators Instagram item to YYYY-MM-DD.
|
||||
|
||||
@@ -249,7 +241,7 @@ def _user_reels(
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"handle": handle})
|
||||
url = f"{reels_url}?{params}"
|
||||
headers = _sc_headers(token)
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
@@ -260,7 +252,7 @@ def _user_reels(
|
||||
resp = _requests.get(
|
||||
reels_url,
|
||||
params={"handle": handle},
|
||||
headers=_sc_headers(token),
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -307,7 +299,7 @@ def search_instagram(
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"query": core_topic})
|
||||
url = f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search?{params}"
|
||||
headers = _sc_headers(token)
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
@@ -318,7 +310,7 @@ def search_instagram(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search",
|
||||
params={"query": core_topic},
|
||||
headers=_sc_headers(token),
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -403,7 +395,7 @@ def fetch_captions(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/v2/instagram/media/transcript",
|
||||
params={"url": url},
|
||||
headers=_sc_headers(token),
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
|
||||
@@ -69,6 +69,47 @@ def normalize_source_items(
|
||||
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:
|
||||
if not url:
|
||||
return None
|
||||
@@ -200,6 +241,11 @@ def _normalize_youtube(
|
||||
metadata: dict[str, Any] = {}
|
||||
if 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(
|
||||
item_id=str(item.get("video_id") or item.get("id") or f"YT{index + 1}"),
|
||||
source=source,
|
||||
@@ -242,7 +288,16 @@ def _normalize_shortform_video(
|
||||
relevance_hint=item.get("relevance", 0.5),
|
||||
why_relevant=str(item.get("why_relevant") or ""),
|
||||
snippet=caption,
|
||||
metadata={"hashtags": item.get("hashtags") or []},
|
||||
metadata={
|
||||
"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"),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -49,14 +49,6 @@ def _log(msg: str):
|
||||
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]]:
|
||||
"""Parse raw Pinterest items into normalized dicts.
|
||||
|
||||
@@ -154,7 +146,7 @@ def search_pinterest(
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"keyword": core_topic})
|
||||
url = f"{SCRAPECREATORS_BASE}/search?{params}"
|
||||
headers = _sc_headers(token)
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
@@ -165,7 +157,7 @@ def search_pinterest(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/search",
|
||||
params={"keyword": core_topic},
|
||||
headers=_sc_headers(token),
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
+53
-5
@@ -204,7 +204,7 @@ def run(
|
||||
plan = planner._sanitize_plan(
|
||||
external_plan, topic, available, requested_sources, depth,
|
||||
)
|
||||
print(f"[Planner] Using external plan ({len(plan.subqueries)} subqueries)", file=sys.stderr)
|
||||
plan_source = "external"
|
||||
else:
|
||||
plan = planner.plan_query(
|
||||
topic=topic,
|
||||
@@ -215,6 +215,14 @@ def run(
|
||||
model=None if mock else runtime.planner_model,
|
||||
context=config.get("_auto_resolve_context", ""),
|
||||
)
|
||||
# Source labelling: the fallback path annotates notes with "fallback-plan"
|
||||
# or "deterministic-comparison-plan"; anything else came from the LLM.
|
||||
if any("fallback" in note or "deterministic" in note for note in (plan.notes or [])):
|
||||
plan_source = "deterministic"
|
||||
elif not mock and reasoning_provider and runtime.planner_model:
|
||||
plan_source = "llm"
|
||||
else:
|
||||
plan_source = "deterministic"
|
||||
|
||||
# Safety net: ensure grounding appears in all subqueries even if the planner
|
||||
# omits it. This is redundant when the planner includes grounding via
|
||||
@@ -224,7 +232,32 @@ def run(
|
||||
if "grounding" not in sq.sources:
|
||||
sq.sources.append("grounding")
|
||||
|
||||
# Always-on planner trace. Emits one summary line plus one per subquery
|
||||
# so retrieval-breadth failures like the 2026-04-19 Hermes Agent Use Cases
|
||||
# disaster are visible without --debug. Stderr only; does not leak into
|
||||
# the user-facing stdout synthesis.
|
||||
print(
|
||||
f"[Planner] Plan: intent={plan.intent}, freshness={plan.freshness_mode}, "
|
||||
f"cluster_mode={plan.cluster_mode}, subqueries={len(plan.subqueries)}, "
|
||||
f"source={plan_source}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if plan.subqueries:
|
||||
for index, sq in enumerate(plan.subqueries, start=1):
|
||||
sources_str = ",".join(sq.sources) if sq.sources else "(none)"
|
||||
print(
|
||||
f"[Planner] sq{index} label={sq.label} "
|
||||
f'search="{sq.search_query}" sources=[{sources_str}]',
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print("[Planner] (no subqueries in plan)", file=sys.stderr)
|
||||
|
||||
bundle = schema.RetrievalBundle(artifacts={"grounding": []})
|
||||
# Expose plan_source to the renderer so render_compact can emit the
|
||||
# DEGRADED RUN banner when a named-entity topic was invoked bare
|
||||
# (source=deterministic AND no pre-research flags). LAW 7 backstop.
|
||||
bundle.artifacts["plan_source"] = plan_source
|
||||
|
||||
# Project-mode or person-mode GitHub: run once before the main subquery loop
|
||||
_github_custom_done = False
|
||||
@@ -407,7 +440,7 @@ def run(
|
||||
if bundle.items_by_source.get(source):
|
||||
del bundle.errors_by_source[source]
|
||||
|
||||
items_by_source = _finalize_items_by_source(bundle.items_by_source)
|
||||
items_by_source = _finalize_items_by_source(bundle.items_by_source, topic=topic)
|
||||
candidates = weighted_rrf(bundle.items_by_source_and_query, plan, pool_limit=settings["pool_limit"])
|
||||
ranked_candidates = rerank.rerank_candidates(
|
||||
topic=topic,
|
||||
@@ -472,11 +505,22 @@ def _normalize_score_dedupe(
|
||||
return normalized
|
||||
|
||||
|
||||
def _finalize_items_by_source(items_by_source_raw: dict[str, list[schema.SourceItem]]) -> dict[str, list[schema.SourceItem]]:
|
||||
def _finalize_items_by_source(
|
||||
items_by_source_raw: dict[str, list[schema.SourceItem]],
|
||||
topic: str = "",
|
||||
) -> dict[str, list[schema.SourceItem]]:
|
||||
finalized = {}
|
||||
for source, items in items_by_source_raw.items():
|
||||
items = sorted(items, key=lambda item: item.local_rank_score or 0.0, reverse=True)
|
||||
finalized[source] = dedupe.dedupe_items(items)
|
||||
items = dedupe.dedupe_items(items)
|
||||
# Post-merge topic-relevance filter for Polymarket: comparison queries
|
||||
# fan out into per-entity subqueries ("Hermes", "OpenClaw") whose topic
|
||||
# is too narrow for Gamma API to filter meaningfully. Re-validating the
|
||||
# merged list against the full original topic drops off-topic markets
|
||||
# (e.g., WTI crude oil, Elon tweet counts) before footer emission.
|
||||
if source == "polymarket" and topic:
|
||||
items = polymarket.filter_items_against_topic(topic, items)
|
||||
finalized[source] = items
|
||||
return finalized
|
||||
|
||||
|
||||
@@ -887,7 +931,11 @@ def _retrieve_stream(
|
||||
hashtags=tiktok_hashtags,
|
||||
creators=tiktok_creators,
|
||||
)
|
||||
return tiktok.parse_tiktok_response(result), {}
|
||||
items = 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":
|
||||
# Use raw_topic so expand_instagram_queries() generates diverse variants
|
||||
# from the original user topic, not the planner's narrowed search_query.
|
||||
|
||||
+134
-6
@@ -113,6 +113,25 @@ def plan_query(
|
||||
topic, available_sources, requested_sources, depth,
|
||||
note=f"fallback-plan (LLM error: {type(exc).__name__})",
|
||||
)
|
||||
# No --plan was passed and no engine-internal provider is configured.
|
||||
# The deterministic fallback path produces weaker plans than either the
|
||||
# --plan path or the engine-internal LLM planner path. Surface this so
|
||||
# the HOSTING reasoning model (Claude Code, Codex, Hermes, Gemini, etc.)
|
||||
# realizes it is supposed to generate the plan itself and pass --plan.
|
||||
# The word "provider" in engine messages refers to the engine's INTERNAL
|
||||
# planner credentials - NOT a prerequisite the caller needs. If you are
|
||||
# the hosting reasoning model, YOU are the provider. LAW 7 / 2026-04-19
|
||||
# Hermes Agent Use Cases failure mode.
|
||||
import sys
|
||||
print(
|
||||
"[Planner] No --plan passed. If you are the reasoning model hosting "
|
||||
"this skill (Claude Code, Codex, Hermes, Gemini, or any agent runtime), "
|
||||
"YOU ARE the planner: generate a JSON query plan yourself and pass it "
|
||||
"via --plan. You do not need an API key or credentials; you ARE the "
|
||||
"LLM. The deterministic fallback below is the headless/cron path only. "
|
||||
"See LAW 7 in SKILL.md and Step 0.75 for the plan schema.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return _fallback_plan(topic, available_sources, requested_sources, depth)
|
||||
|
||||
|
||||
@@ -151,7 +170,7 @@ Return JSON only with this shape:
|
||||
}}
|
||||
|
||||
Rules:
|
||||
- emit 1 to 4 subqueries
|
||||
- emit 1 to 5 subqueries (how_to/opinion/product/breaking_news intents benefit from 4-5; factual/concept from 2)
|
||||
- every subquery must include both search_query and ranking_query
|
||||
- sources must be drawn from Available sources only
|
||||
- use cluster_mode=none for factual or many how-to queries
|
||||
@@ -162,6 +181,8 @@ Rules:
|
||||
- preserve exact proper nouns and entity strings from the topic
|
||||
- NEVER include temporal phrases in search_query: no 'last 30 days', 'recent', month names, year numbers
|
||||
- NEVER include meta-research phrases: no 'news', 'updates', 'public appearances', 'latest developments'
|
||||
- INTENT-MODIFIER HANDLING: when the topic contains one of {{use cases, use case, workflows, workflow, examples, tutorial, tutorials, review, reviews, comparison, applications, in practice, production, production use, how i use}}, STRIP that phrase from every search_query (keep its meaning in ranking_query). Emit 4-5 paraphrased subqueries that each express the intent differently (e.g., 'production', 'workflow OR pipeline', 'review OR experience', 'vs COMPETITOR', 'community discussion'). Broad retrieval, narrow ranking. This was the 2026-04-19 Hermes Agent Use Cases failure mode: the planner echoed "hermes agent use cases" as a literal search string and returned near-zero results because nobody posts that exact phrase.
|
||||
- DO NOT quote the user's full topic verbatim in search_query. Quote only multi-word proper nouns like "Hermes Agent", "Claude Code", "Nous Research". Bare keywords OR'd together retrieve more than exact-phrase searches.
|
||||
- search_query should match how content is TITLED on platforms
|
||||
- GitHub (Issues/PRs) is best for engineering, developer tools, and open source topics: 'kanye west bully' not 'kanye west album news March 2026'
|
||||
""".strip()
|
||||
@@ -204,7 +225,7 @@ def _sanitize_plan(
|
||||
source_weights = _normalize_weights(source_weights)
|
||||
|
||||
subqueries: list[schema.SubQuery] = []
|
||||
for index, subquery in enumerate((raw.get("subqueries") or [])[:_max_subqueries(intent_hint)], start=1):
|
||||
for index, subquery in enumerate((raw.get("subqueries") or [])[:_max_subqueries(intent_hint, topic)], start=1):
|
||||
if not isinstance(subquery, dict):
|
||||
continue
|
||||
sources = [source for source in subquery.get("sources") or [] if source in source_weights]
|
||||
@@ -382,13 +403,22 @@ def _fallback_plan(
|
||||
)
|
||||
)
|
||||
|
||||
# Intent-modifier fanout: when topic contains a phrase like "use cases",
|
||||
# "workflows", "examples", "review" (see _INTENT_MODIFIER_PATTERNS),
|
||||
# paraphrase the intent across 3 extra subqueries rather than echoing
|
||||
# the literal phrase. Fixes 2026-04-19 Hermes Agent Use Cases failure.
|
||||
# Excluded for comparison/prediction since those already have dedicated
|
||||
# fanout (entity-per-subquery / odds).
|
||||
if depth != "quick" and intent not in {"comparison", "prediction"} and _has_intent_modifier(topic):
|
||||
subqueries.extend(_intent_modifier_subqueries(topic, core, base_search, source_weights))
|
||||
|
||||
return schema.QueryPlan(
|
||||
intent=intent,
|
||||
freshness_mode=_default_freshness(intent),
|
||||
cluster_mode=_default_cluster_mode(intent),
|
||||
raw_topic=topic,
|
||||
subqueries=_normalize_subquery_weights(
|
||||
_trim_subqueries_for_depth(subqueries[:_max_subqueries(intent)], intent, depth, list(source_weights))
|
||||
_trim_subqueries_for_depth(subqueries[:_max_subqueries(intent, topic)], intent, depth, list(source_weights))
|
||||
),
|
||||
source_weights=_normalize_weights(source_weights),
|
||||
notes=[note],
|
||||
@@ -418,7 +448,15 @@ def _infer_intent(topic: str) -> str:
|
||||
return "concept"
|
||||
if re.search(r"\b(tournament|championship|playoffs|march madness|world cup|olympics|super bowl|final four|ceremony|awards|keynote)\b", text):
|
||||
return "breaking_news"
|
||||
# Recency signals take priority when nothing more specific matched.
|
||||
if re.search(r"\b(trending|this week|right now|today|this month)\b", text):
|
||||
return "breaking_news"
|
||||
# Default changed from "breaking_news" to "concept" on 2026-04-19 after
|
||||
# the Hermes Agent Use Cases failure: unclassified topics were getting
|
||||
# strict_recent freshness, which over-weighted the last 7 days and
|
||||
# under-weighted older relevant material. "concept" defaults to
|
||||
# evergreen_ok freshness, a safer posture for unknown topics.
|
||||
return "concept"
|
||||
|
||||
|
||||
def _default_freshness(intent: str) -> str:
|
||||
@@ -464,8 +502,26 @@ def _default_source_weights(intent: str, sources: list[str]) -> dict[str, float]
|
||||
|
||||
|
||||
def _keyword_query(topic: str, core: str) -> str:
|
||||
"""Build a search_query string for the deterministic fallback.
|
||||
|
||||
Quote ONLY title-cased multi-word proper nouns ("Hermes Agent",
|
||||
"Claude Code", "Nous Research") so platform search engines preserve the
|
||||
name as a phrase. Hyphenated compounds and lowercase terms are left as
|
||||
bare keywords, which broadens retrieval instead of narrowing it.
|
||||
|
||||
Prior behavior quoted the entire compound including the user's typed
|
||||
topic, producing searches like `"Hermes Agent Actual Use Cases" hermes agent actual`
|
||||
that returned near-zero matches on X and Reddit because nobody posts
|
||||
that exact phrase. See 2026-04-19 Hermes Agent Use Cases failure.
|
||||
"""
|
||||
compounds = query.extract_compound_terms(topic)
|
||||
quoted = " ".join(f"\"{term}\"" for term in compounds[:2])
|
||||
# Only quote title-cased proper nouns (multi-word names). Hyphenated
|
||||
# compounds go unquoted so platform tokenizers can split and match.
|
||||
title_cased = [
|
||||
term for term in compounds
|
||||
if re.match(r"^(?:[A-Z][a-z]+\s+){1,}[A-Z][a-z]+$", term)
|
||||
]
|
||||
quoted = " ".join(f'"{term}"' for term in title_cased[:2])
|
||||
keywords = [quoted.strip(), core.strip() or topic.strip()]
|
||||
return " ".join(part for part in keywords if part).strip()
|
||||
|
||||
@@ -513,12 +569,84 @@ def _should_force_deterministic_plan(topic: str) -> bool:
|
||||
return _infer_intent(topic) == "comparison" and len(_comparison_entities(topic)) >= 2
|
||||
|
||||
|
||||
def _max_subqueries(intent: str) -> int:
|
||||
_INTENT_MODIFIER_PATTERNS = (
|
||||
"use cases", "use case", "workflows", "workflow",
|
||||
"examples", "example", "tutorial", "tutorials",
|
||||
"review", "reviews", "comparison", "applications",
|
||||
"in practice", "production use", "production",
|
||||
"how i use",
|
||||
)
|
||||
|
||||
|
||||
def _has_intent_modifier(topic: str) -> bool:
|
||||
"""Return True if the topic contains an intent modifier phrase.
|
||||
|
||||
See 2026-04-19 Hermes Agent Use Cases failure: a literal "Hermes Agent
|
||||
use cases" search returns near-zero matches because nobody posts that
|
||||
exact phrase. Intent modifiers should be stripped from search_query
|
||||
and paraphrased across multiple subqueries.
|
||||
"""
|
||||
text = topic.lower()
|
||||
return any(pattern in text for pattern in _INTENT_MODIFIER_PATTERNS)
|
||||
|
||||
|
||||
def _intent_modifier_subqueries(
|
||||
topic: str,
|
||||
core: str,
|
||||
base_search: str,
|
||||
source_weights: dict[str, float],
|
||||
) -> list[schema.SubQuery]:
|
||||
"""Produce paraphrased subqueries for intent-modifier topics.
|
||||
|
||||
The deterministic fallback used to echo the user's literal phrase
|
||||
(e.g., "hermes agent use cases") into every search_query. This helper
|
||||
fans out 3 extra subqueries that each express the intent differently
|
||||
so retrieval pulls a broader corpus for reranking.
|
||||
"""
|
||||
entity = core or topic.strip()
|
||||
sources = list(source_weights)
|
||||
return [
|
||||
schema.SubQuery(
|
||||
label="workflows",
|
||||
search_query=f"{entity} workflow pipeline",
|
||||
ranking_query=f"What real-world workflows or pipelines are people running with {entity}?",
|
||||
sources=sources,
|
||||
weight=0.6,
|
||||
),
|
||||
schema.SubQuery(
|
||||
label="production",
|
||||
search_query=f"{entity} production real-world",
|
||||
ranking_query=f"What production deployments or real-world use cases of {entity} are people describing?",
|
||||
sources=sources,
|
||||
weight=0.55,
|
||||
),
|
||||
schema.SubQuery(
|
||||
label="experience",
|
||||
search_query=f"{entity} experience review",
|
||||
ranking_query=f"What hands-on experience reports or reviews of {entity} exist in the last 30 days?",
|
||||
sources=sources,
|
||||
weight=0.5,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _max_subqueries(intent: str, topic: str | None = None) -> int:
|
||||
# how_to/opinion/product/breaking_news/prediction benefit from 4-5
|
||||
# paraphrased subqueries when the topic carries an intent modifier
|
||||
# (use cases, workflows, examples, review, etc.). See 2026-04-19
|
||||
# Hermes Agent Use Cases failure: prior cap of 3 produced near-literal
|
||||
# echoes of the topic instead of a paraphrase fanout.
|
||||
if intent == "comparison":
|
||||
return 4
|
||||
# Intent-modifier topics get headroom for paraphrase fanout even when
|
||||
# the intent itself is factual/concept. Without this, a "Hermes Agent
|
||||
# use cases" query (classified "concept" after the 2026-04-19 default
|
||||
# change) would be capped at 2 and drop the fanout.
|
||||
if topic and _has_intent_modifier(topic):
|
||||
return 5
|
||||
if intent in {"factual", "concept"}:
|
||||
return 2
|
||||
return 3
|
||||
return 5
|
||||
|
||||
|
||||
def _default_sources_for_intent(intent: str, available_sources: list[str]) -> list[str]:
|
||||
|
||||
@@ -117,6 +117,9 @@ _NOISE_WORDS = frozenset({
|
||||
"software", "plugin", "skill", "agent", "bot", "search", "research",
|
||||
# Generic prediction market terms
|
||||
"market", "odds", "prediction", "forecast", "chance", "probability",
|
||||
# Comparison-query conjunctions — should not count as informative filter tokens
|
||||
# when the topic is "X vs Y vs Z"
|
||||
"vs", "versus",
|
||||
})
|
||||
|
||||
|
||||
@@ -165,6 +168,70 @@ def _passes_topic_filter(topic: str, event_title: str) -> bool:
|
||||
return match_count >= min_matches
|
||||
|
||||
|
||||
def _passes_any_informative_word(topic: str, event_title: str) -> bool:
|
||||
"""Looser variant of _passes_topic_filter that keeps an item if ANY
|
||||
informative word from the topic appears in the title.
|
||||
|
||||
Designed for post-merge validation of comparison topics (e.g., "OpenClaw vs
|
||||
Hermes vs Paperclip"), where a market mentioning just one of the entities
|
||||
is still on-topic. The stricter _passes_topic_filter (min_matches=2 for
|
||||
3+ informative words) is correct for single-entity topics like "Mill.com
|
||||
food recycler" but drops legitimate single-entity comparison results.
|
||||
"""
|
||||
core = _extract_core_subject(topic).lower()
|
||||
core_words = [w for w in re.sub(r"[^\w\s]", " ", core).split() if len(w) > 1]
|
||||
if not core_words:
|
||||
return True
|
||||
informative = [w for w in core_words if w not in _NOISE_WORDS]
|
||||
if not informative:
|
||||
return True
|
||||
|
||||
title_lower = " ".join(re.sub(r"[^\w\s]", " ", event_title.lower()).split())
|
||||
title_words = set(title_lower.split())
|
||||
|
||||
for word in informative:
|
||||
if word in title_words:
|
||||
return True
|
||||
if len(word) >= 4 and word in title_lower:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def filter_items_against_topic(topic: str, items: List[Any]) -> List[Any]:
|
||||
"""Drop items whose title shares no informative word with the original topic.
|
||||
|
||||
Called post-merge from pipeline.py so per-entity subquery results for
|
||||
comparison topics get re-validated against the ORIGINAL full topic before
|
||||
landing in the footer. Prevents noise like WTI crude oil or Elon tweet
|
||||
markets from surviving a loose "Hermes" single-entity subquery match.
|
||||
|
||||
Uses the looser _passes_any_informative_word rule (ANY entity name match
|
||||
is sufficient) so a market mentioning just one of several compared entities
|
||||
still counts as on-topic.
|
||||
|
||||
Accepts a list of either raw dicts (with 'title') or SourceItem-like objects
|
||||
(with .title attribute). Returns the filtered list in the same order.
|
||||
"""
|
||||
if not topic:
|
||||
return items
|
||||
|
||||
filtered = []
|
||||
for item in items:
|
||||
title = getattr(item, "title", None)
|
||||
if title is None and isinstance(item, dict):
|
||||
title = item.get("title", "")
|
||||
title = title or ""
|
||||
|
||||
if _passes_any_informative_word(topic, title):
|
||||
filtered.append(item)
|
||||
|
||||
dropped = len(items) - len(filtered)
|
||||
if dropped:
|
||||
_log(f"Post-merge topic filter dropped {dropped} Polymarket items against full topic '{topic}'")
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def _extract_domain_queries(topic: str, events: List[Dict]) -> List[str]:
|
||||
"""Extract domain-indicator search terms from first-pass event tags.
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Engine-side query-quality pre-flight.
|
||||
|
||||
Detects Class 1 (demographic shopping) keyword-trap queries and returns a
|
||||
structured REFUSE message. The caller (scripts/last30days.py main()) writes
|
||||
the message to stderr and exits code 2. No pipeline work runs on a doomed
|
||||
query; the model sees the REFUSE on stderr and asks the user for the
|
||||
hobbies/relationship/budget context it needs.
|
||||
|
||||
Patterns ported from SKILL.md Step 0.45 prose. Only Class 1 is implemented
|
||||
here because it has a verified failure mode on v3.0.8 (2026-04-18 'birthday
|
||||
gift for 40 year old' run returned r/todayilearned and unrelated drama
|
||||
posts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_CLASS_1_PATTERNS = [
|
||||
re.compile(
|
||||
r"^\s*(birthday\s+)?(gift|gifts|present|presents)\s+"
|
||||
r"(for|ideas\s+for)\s+(a\s+|my\s+)?\d+[\s-]?year[\s-]?old\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(
|
||||
r"^\s*(best|top)\s+[\w\s-]+?\s+for\s+"
|
||||
r"(men|women|kids|guys|girls|teens|dads|moms|husbands|wives|brothers|sisters|friends)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(
|
||||
r"^\s*what\s+to\s+(buy|get|gift)\s+(for\s+)?(a\s+|my\s+)?"
|
||||
r"(\d+[\s-]?year[\s-]?old|husband|wife|dad|mom|brother|sister|friend|boss|coworker)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(
|
||||
r"^\s*(present|presents|gift|gifts)\s+for\s+(a\s+|my\s+)?"
|
||||
r"(husband|wife|dad|mom|brother|sister|friend|boss|coworker)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
]
|
||||
|
||||
_QUALIFIER_PATTERNS = [
|
||||
re.compile(r"\$\d+"),
|
||||
re.compile(r"\bbudget\b", re.IGNORECASE),
|
||||
re.compile(r"\bwho\s+(loves|likes|is\s+into|enjoys)\b", re.IGNORECASE),
|
||||
re.compile(r"\bhobbies?\b", re.IGNORECASE),
|
||||
re.compile(r"\b(cooking|running|reading|gaming|golf|woodworking|coding|hiking|cycling|fishing|music)[\s-]?(obsessed|enthusiast|fan|lover)\b", re.IGNORECASE),
|
||||
]
|
||||
|
||||
_RELATIONSHIP_WORDS = {
|
||||
"husband", "wife", "dad", "mom", "father", "mother", "brother", "sister",
|
||||
"friend", "boss", "coworker", "son", "daughter", "grandma", "grandpa",
|
||||
"aunt", "uncle", "nephew", "niece", "partner", "boyfriend", "girlfriend",
|
||||
}
|
||||
|
||||
_YEAR_OLD_NOUN = re.compile(r"\byear[\s-]?old\s+(\w+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _has_qualifier(topic: str) -> bool:
|
||||
"""Return True if the topic contains hobbies/relationship/budget context.
|
||||
|
||||
A Class 1 base pattern plus a qualifier means the user already filled in
|
||||
the specificity Step 0.45 would ask for. Skip the refuse-gate and let
|
||||
the engine run.
|
||||
|
||||
Also skips when `{n} year old <activity-noun>` is present, but only when
|
||||
the noun is NOT a relationship word. 'year old runner' qualifies as an
|
||||
interest and skips; 'year old husband' is just another relationship
|
||||
reframing of the demographic query and does not skip.
|
||||
"""
|
||||
if any(pattern.search(topic) for pattern in _QUALIFIER_PATTERNS):
|
||||
return True
|
||||
|
||||
match = _YEAR_OLD_NOUN.search(topic)
|
||||
if match and match.group(1).lower() not in _RELATIONSHIP_WORDS:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def check_class_1_trap(topic: str) -> str | None:
|
||||
"""Return a REFUSE message string if the topic matches Class 1, else None.
|
||||
|
||||
Class 1 is the demographic-shopping keyword trap. The literal phrase
|
||||
'birthday gift for 40 year old' is not the vocabulary of actual gift
|
||||
discussions on Reddit, X, or TikTok, so running the engine returns
|
||||
low-signal generic posts. Refuse up-front and ask for context.
|
||||
"""
|
||||
if not topic:
|
||||
return None
|
||||
|
||||
matched = any(pattern.search(topic) for pattern in _CLASS_1_PATTERNS)
|
||||
if not matched:
|
||||
return None
|
||||
|
||||
if _has_qualifier(topic):
|
||||
return None
|
||||
|
||||
return _refuse_message(topic.strip())
|
||||
|
||||
|
||||
def _refuse_message(topic: str) -> str:
|
||||
return (
|
||||
f'[last30days] REFUSE: topic "{topic}" matches Class 1 keyword-trap '
|
||||
"pattern (demographic shopping).\n"
|
||||
"\n"
|
||||
"The literal phrase is not the vocabulary of actual gift discussions "
|
||||
"on Reddit, X, or TikTok. Running the engine will return low-signal "
|
||||
"generic posts (the 2026-04-18 validation run returned "
|
||||
"r/todayilearned and unrelated drama).\n"
|
||||
"\n"
|
||||
"Ask the user for at least one of:\n"
|
||||
" - hobbies (cooks / runs / reads / gaming / outdoors / golf / music)\n"
|
||||
" - relationship (husband / dad / friend / boss / brother)\n"
|
||||
" - budget range\n"
|
||||
"\n"
|
||||
"Then re-run with the enriched query. If the user insists 'just run it',\n"
|
||||
"re-invoke with LAST30DAYS_SKIP_PREFLIGHT=1 to bypass this gate.\n"
|
||||
)
|
||||
+22
-100
@@ -12,15 +12,8 @@ import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed, wait as futures_wait
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
try:
|
||||
import requests as _requests
|
||||
except ImportError:
|
||||
_requests = None
|
||||
|
||||
|
||||
def _first_of(*values, default=None):
|
||||
"""Return first value that is not None."""
|
||||
for v in values:
|
||||
@@ -28,7 +21,7 @@ def _first_of(*values, default=None):
|
||||
return v
|
||||
return default
|
||||
|
||||
from . import http, log
|
||||
from . import dates, http, log
|
||||
|
||||
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/reddit"
|
||||
|
||||
@@ -76,14 +69,6 @@ def _log(msg: str):
|
||||
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:
|
||||
"""Extract core subject from verbose query.
|
||||
|
||||
@@ -212,27 +197,16 @@ def _parse_date(value) -> Optional[str]:
|
||||
|
||||
Global search returns ``created_at`` as an ISO string
|
||||
(e.g. "2018-05-03T01:09:17.620000+0000"); subreddit search returns
|
||||
``created_utc`` as a Unix timestamp. Handle both.
|
||||
``created_utc`` as a Unix timestamp. dates.parse_date() handles 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:
|
||||
return None
|
||||
# ISO-8601 string (contains 'T' or '-')
|
||||
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
|
||||
dt = dates.parse_date(str(value))
|
||||
return dt.strftime("%Y-%m-%d") if dt else None
|
||||
|
||||
|
||||
def _extract_subreddit_name(value: Any) -> str:
|
||||
@@ -350,39 +324,18 @@ def _global_search(
|
||||
Returns:
|
||||
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)
|
||||
data = http.get(
|
||||
f"{SCRAPECREATORS_BASE}/search",
|
||||
headers=http.scrapecreators_headers(token),
|
||||
params={"query": query, "sort": sort, "timeframe": timeframe},
|
||||
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):
|
||||
if 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:
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/search",
|
||||
params={"query": query, "sort": sort, "timeframe": timeframe},
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("posts", data.get("data", []))
|
||||
except _requests.exceptions.HTTPError as e:
|
||||
if e.response is not None and e.response.status_code in (401, 403):
|
||||
raise http.HTTPError(f"Auth error: {e}", e.response.status_code)
|
||||
_log(f"Global search error: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
@@ -409,36 +362,19 @@ def _subreddit_search(
|
||||
Returns:
|
||||
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:
|
||||
resp = _requests.get(
|
||||
data = http.get(
|
||||
f"{SCRAPECREATORS_BASE}/subreddit/search",
|
||||
headers=http.scrapecreators_headers(token),
|
||||
params={
|
||||
"subreddit": subreddit,
|
||||
"query": query,
|
||||
"sort": sort,
|
||||
"timeframe": timeframe,
|
||||
},
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
retries=2,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("posts", data.get("data", []))
|
||||
except Exception as e:
|
||||
_log(f"Subreddit search error for r/{subreddit}: {e}")
|
||||
@@ -458,28 +394,14 @@ def fetch_post_comments(
|
||||
Returns:
|
||||
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:
|
||||
resp = _requests.get(
|
||||
data = http.get(
|
||||
f"{SCRAPECREATORS_BASE}/post/comments",
|
||||
headers=http.scrapecreators_headers(token),
|
||||
params={"url": url},
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
retries=2,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("comments", data.get("data", []))
|
||||
except Exception as e:
|
||||
_log(f"Comment fetch error: {e}")
|
||||
|
||||
+676
-19
@@ -2,10 +2,49 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
from collections import Counter
|
||||
from datetime import date
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import dates, schema
|
||||
|
||||
|
||||
def _skill_version() -> str:
|
||||
"""Read plugin version from .claude-plugin/plugin.json if available.
|
||||
|
||||
Tries nearest plugin.json by walking up from render.py's own location.
|
||||
Falls back to "?" if not found. This keeps the badge emission from
|
||||
crashing on non-plugin-cache installs (repo checkout, Gemini, Codex).
|
||||
"""
|
||||
here = pathlib.Path(__file__).resolve()
|
||||
for parent in [here.parent, *here.parents]:
|
||||
candidate = parent / ".claude-plugin" / "plugin.json"
|
||||
if candidate.is_file():
|
||||
try:
|
||||
return json.loads(candidate.read_text()).get("version", "?")
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return "?"
|
||||
return "?"
|
||||
|
||||
|
||||
def _render_badge() -> list[str]:
|
||||
"""Emit the MANDATORY first-line badge per SKILL.md OUTPUT CONTRACT.
|
||||
|
||||
Added in v3.0.8 after three Opus 4.7 self-debugs (2026-04-18) confirmed
|
||||
the model was failing to emit the badge manually because SKILL.md was
|
||||
too big to reach the BADGE MANDATORY block before synthesis. Engine
|
||||
emission makes passing-through-the-script-output the default-correct
|
||||
behavior; emitting the badge no longer depends on model compliance.
|
||||
"""
|
||||
version = _skill_version()
|
||||
today = date.today().strftime("%Y-%m-%d")
|
||||
return [
|
||||
f"🌐 last30days v{version} · synced {today}",
|
||||
"",
|
||||
]
|
||||
|
||||
SOURCE_LABELS = {
|
||||
"grounding": "Web",
|
||||
"hackernews": "Hacker News",
|
||||
@@ -36,9 +75,10 @@ def _assistant_safety_lines() -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str = "medium") -> str:
|
||||
def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str = "medium", save_path: str | None = None) -> str:
|
||||
non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
|
||||
lines = [
|
||||
*_render_badge(),
|
||||
f"# last30days v3.0.0: {report.topic}",
|
||||
"",
|
||||
*_assistant_safety_lines(),
|
||||
@@ -60,6 +100,24 @@ def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str
|
||||
lines.extend(f"- {warning}" for warning in report.warnings)
|
||||
lines.append("")
|
||||
|
||||
# LAW 7 backstop: emit the DEGRADED RUN WARNING block BEFORE the evidence
|
||||
# envelope so the model's pass-through contract forces it into the user's
|
||||
# response on bare named-entity calls. The stderr [Planner] warning is
|
||||
# invisible to the user; this block is not.
|
||||
degraded_warning = _render_degraded_run_warning(report)
|
||||
if degraded_warning:
|
||||
lines.extend(degraded_warning)
|
||||
lines.append("")
|
||||
|
||||
# Open EVIDENCE FOR SYNTHESIS envelope. The ## Ranked Evidence Clusters,
|
||||
# ## Stats, and ## Source Coverage blocks inside this envelope are raw
|
||||
# evidence for the model to READ, not output to emit. LAW 6 in SKILL.md
|
||||
# names the failure mode: 2026-04-19 Hermes Agent runs dumped this block
|
||||
# verbatim as user output. The envelope comments give the model an
|
||||
# unambiguous scope for "pass through verbatim" (the PASS-THROUGH FOOTER
|
||||
# block below) vs "synthesize from" (this block).
|
||||
lines.append("<!-- EVIDENCE FOR SYNTHESIS: read this, do not emit verbatim. Transform into `What I learned:` prose per LAW 2. -->")
|
||||
lines.append("")
|
||||
lines.append("## Ranked Evidence Clusters")
|
||||
lines.append("")
|
||||
candidate_by_id = {candidate.candidate_id: candidate for candidate in report.ranked_candidates}
|
||||
@@ -86,9 +144,254 @@ def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str
|
||||
lines.extend([""] + best_takes)
|
||||
|
||||
lines.extend(_render_source_coverage(report))
|
||||
# Close EVIDENCE FOR SYNTHESIS envelope before anything that passes through verbatim.
|
||||
lines.append("")
|
||||
lines.append("<!-- END EVIDENCE FOR SYNTHESIS -->")
|
||||
|
||||
pre_research_warning = _render_pre_research_warning(report)
|
||||
if pre_research_warning:
|
||||
lines.append("")
|
||||
lines.extend(pre_research_warning)
|
||||
|
||||
comparison_scaffold = _render_comparison_scaffold(report.topic)
|
||||
if comparison_scaffold:
|
||||
lines.append("")
|
||||
lines.extend(comparison_scaffold)
|
||||
|
||||
footer = _render_emoji_footer(report, save_path)
|
||||
if footer:
|
||||
lines.append("")
|
||||
lines.append("<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->")
|
||||
lines.extend(footer)
|
||||
lines.append("<!-- END PASS-THROUGH FOOTER -->")
|
||||
|
||||
lines.extend(_render_canonical_boundary())
|
||||
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
|
||||
def _render_canonical_boundary() -> list[str]:
|
||||
"""Emit the explicit END-OF-CANONICAL-OUTPUT boundary.
|
||||
|
||||
Added in v3.0.9 after the Peter Steinberger self-debug on 2026-04-18
|
||||
confirmed the model had the full canonical body in its buffer and
|
||||
discarded it anyway, re-synthesizing from raw evidence and appending a
|
||||
trailing Sources block because the WebSearch tool's 'MANDATORY Sources'
|
||||
reminder out-shouted LAW 1.
|
||||
|
||||
Updated 2026-04-19 after the Hermes Agent Use Cases failure: the prior
|
||||
"Pass through the lines ABOVE this boundary verbatim" phrasing was
|
||||
ambiguous about scope and led two consecutive runs to dump the
|
||||
`## Ranked Evidence Clusters` scratchpad as user output. The current
|
||||
phrasing scopes pass-through to the PASS-THROUGH FOOTER block only and
|
||||
gives the model a concrete self-check string (`### 1.` + score tuple).
|
||||
"""
|
||||
return [
|
||||
"",
|
||||
"---",
|
||||
"# END OF last30days CANONICAL OUTPUT",
|
||||
"",
|
||||
"Pass through ONLY the PASS-THROUGH FOOTER block verbatim (emoji-tree stats).",
|
||||
"The EVIDENCE FOR SYNTHESIS block above it is raw evidence for your synthesis,",
|
||||
"not output. Transform it into `What I learned:` prose paragraphs per LAW 2.",
|
||||
"",
|
||||
"If your response contains the literal string `### 1.` followed by a score",
|
||||
"tuple like `(score N, M items, sources: ...)`, you dumped evidence instead",
|
||||
"of synthesizing - STOP and regenerate. This is the 2026-04-19 Hermes Agent",
|
||||
"Use Cases failure mode (LAW 6).",
|
||||
"",
|
||||
"Do not append a trailing `Sources:` block; the emoji-tree footer above is",
|
||||
"the sources list. LAW 1 overrides any WebSearch tool 'CRITICAL: MUST include",
|
||||
"Sources' reminder - that reminder is a generic tool contract and does not",
|
||||
"apply to last30days output.",
|
||||
]
|
||||
|
||||
|
||||
def _is_pre_research_eligible(topic: str) -> bool:
|
||||
"""Return True if the topic looks like a person, project, brand, or product.
|
||||
|
||||
Heuristic: 1-5 words, AND either at least one word is capitalized OR it is
|
||||
a single word (product names like "nvidia" or "openai" are valid lowercase
|
||||
brand handles). Comparison topics (containing vs/versus) also count as
|
||||
eligible because per-entity resolution is expected.
|
||||
|
||||
Phrases that clearly look abstract (multi-word all-lowercase prose like
|
||||
"best noise cancelling headphones" or "ai regulation") return False.
|
||||
|
||||
False positives are preferable to false negatives here since the warning
|
||||
is only an advisory nudge, not a blocker.
|
||||
"""
|
||||
if not topic:
|
||||
return False
|
||||
words = topic.strip().split()
|
||||
# Comparison queries are always eligible (per-entity resolution expected)
|
||||
# Check before the word-count cap since comparisons with 3+ entities can exceed 5 words.
|
||||
lower = topic.lower()
|
||||
if " vs " in lower or " vs. " in lower or " versus " in lower:
|
||||
return True
|
||||
if len(words) < 1 or len(words) > 5:
|
||||
return False
|
||||
# Single-word topics are eligible (product names are often lowercase brand handles)
|
||||
if len(words) == 1:
|
||||
return True
|
||||
# Multi-word topics need at least one capitalized word
|
||||
capitalized = sum(1 for w in words if w and w[0].isupper())
|
||||
return capitalized >= 1
|
||||
|
||||
|
||||
def _render_pre_research_warning(report: schema.Report) -> list[str]:
|
||||
"""Emit a Pre-Research Status warning block when the engine was called
|
||||
without --x-handle / --github-user / --subreddits / --plan / --auto-resolve
|
||||
on a topic that would benefit from pre-research resolution.
|
||||
|
||||
Returns empty list when flags are present or topic is not eligible.
|
||||
"""
|
||||
flags_present = bool(report.artifacts.get("pre_research_flags_present", False))
|
||||
if flags_present:
|
||||
return []
|
||||
if not _is_pre_research_eligible(report.topic):
|
||||
return []
|
||||
|
||||
return [
|
||||
"## Pre-Research Status",
|
||||
"",
|
||||
"⚠️ Step 0.55 pre-research was skipped. The engine ran with keyword search only.",
|
||||
"",
|
||||
"For people, projects, brands, and products this usually misses:",
|
||||
"- Founder and team X timelines (what they post about their own work)",
|
||||
"- GitHub repo activity (issues, PRs, release notes, commit velocity)",
|
||||
"- Subreddit-specific threads on dedicated communities",
|
||||
"- Topic-specific TikTok and Instagram creators",
|
||||
"",
|
||||
"To fix: in a fresh Claude Code window, run `ToolSearch select:WebSearch` first,",
|
||||
f"then rerun `/last30days {report.topic}`. The skill will resolve handles",
|
||||
"and communities before calling the engine this time, producing richer results.",
|
||||
"",
|
||||
"If this topic really is abstract (e.g. \"AI regulation\") and doesn't need",
|
||||
"handle resolution, add `--auto-resolve` to the engine command or ignore this",
|
||||
"warning - the current results are the keyword-search fallback.",
|
||||
]
|
||||
|
||||
|
||||
def _render_degraded_run_warning(report: schema.Report) -> list[str]:
|
||||
"""Emit a user-visible DEGRADED RUN WARNING block when:
|
||||
- The engine ran the deterministic fallback planner (source=deterministic), AND
|
||||
- No pre-research flags were passed (bare invocation), AND
|
||||
- The topic is pre-research-eligible (a named entity).
|
||||
|
||||
Runtime-agnostic: this skill ships to Claude Code, Codex, Hermes, Gemini,
|
||||
and ~/.agents. The banner enumerates them explicitly so the hosting
|
||||
reasoning model recognizes itself regardless of runtime.
|
||||
|
||||
Positioned BEFORE the EVIDENCE FOR SYNTHESIS envelope in render_compact
|
||||
so the model's pass-through contract forces it into the user's view per
|
||||
LAW 7. The 2026-04-19 Hermes Agent Use Cases Run 1 failure mode: the
|
||||
engine's stderr warning about "no LLM provider" was invisible to the
|
||||
user because Claude hid stderr. User-visible stdout block is the
|
||||
backstop that makes silent degradation impossible.
|
||||
"""
|
||||
plan_source = report.artifacts.get("plan_source", "unknown")
|
||||
flags_present = bool(report.artifacts.get("pre_research_flags_present", False))
|
||||
if plan_source != "deterministic":
|
||||
return []
|
||||
if flags_present:
|
||||
return []
|
||||
if not _is_pre_research_eligible(report.topic):
|
||||
return []
|
||||
|
||||
return [
|
||||
"<!-- USER-VISIBLE BANNER: emit verbatim before synthesis per LAW 5 / LAW 7. -->",
|
||||
"## DEGRADED RUN WARNING",
|
||||
"",
|
||||
f"⚠️ This run was called BARE on a named-entity topic (\"{report.topic}\"):",
|
||||
"no `--plan` JSON, no pre-flight handle resolution. The engine ran",
|
||||
"deterministic fallback retrieval, which is the cron/headless path -",
|
||||
"weaker than the path where the hosting reasoning model authors its own",
|
||||
"plan upstream.",
|
||||
"",
|
||||
"**If you are the reasoning model hosting this skill** (Claude Code, Codex,",
|
||||
"Hermes, Gemini, or any agent runtime that invoked `/last30days`): YOU ARE",
|
||||
"THE LLM. You do not need an API key, provider credentials, or an external",
|
||||
"planning service. The `--plan` flag exists precisely so a reasoning model",
|
||||
"generates its own JSON plan upstream and passes it in. That is you.",
|
||||
"Generate a plan with `intent` / `freshness_mode` / 3-5 paraphrased",
|
||||
"subqueries and re-run with `--plan '$JSON'`. This is not a",
|
||||
"missing-credentials problem; this is a skipped-LAW-7 problem.",
|
||||
"",
|
||||
"What went wrong: on a named-entity topic, the full contract is",
|
||||
"(a) resolve X handles / GitHub repos / subreddits via your runtime's",
|
||||
"web-search tool (Step 0.55) and (b) generate a JSON `--plan` yourself",
|
||||
"and pass it via `--plan '$JSON'` (Step 0.75 / LAW 7). Both were skipped.",
|
||||
"",
|
||||
"**If you are a user reading this:** the assistant skipped its own",
|
||||
"planning step. Ask it to regenerate following Step 0.55 and Step 0.75",
|
||||
"of SKILL.md.",
|
||||
"<!-- END USER-VISIBLE BANNER -->",
|
||||
]
|
||||
|
||||
|
||||
def _parse_comparison_entities(topic: str) -> list[str] | None:
|
||||
"""Return list of entity names if topic is a comparison query, else None.
|
||||
|
||||
Splits on ` vs ` or ` versus ` (case-insensitive). Caps at 4 entities
|
||||
for table readability. Returns None if only one entity or empty input.
|
||||
"""
|
||||
if not topic:
|
||||
return None
|
||||
import re
|
||||
parts = re.split(r"\s+(?:vs\.?|versus)\s+", topic.strip(), flags=re.IGNORECASE)
|
||||
parts = [p.strip() for p in parts if p.strip()]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
return parts[:4]
|
||||
|
||||
|
||||
def _render_comparison_scaffold(topic: str) -> list[str]:
|
||||
"""Emit a markdown comparison table scaffold for synthesizer to fill.
|
||||
|
||||
Returns empty list if topic is not a comparison query. When present,
|
||||
the block is bracketed so the synthesizer can detect it and pass through.
|
||||
|
||||
Axes match the April 9 launch-video exemplar (9 axes suited to AI-tool
|
||||
comparisons). For non-AI-tool comparisons, the synthesizer writes N/A
|
||||
or topic-appropriate substitutes in irrelevant rows.
|
||||
"""
|
||||
entities = _parse_comparison_entities(topic)
|
||||
if not entities:
|
||||
return []
|
||||
|
||||
# Header row - uses "Dimension" per the April 9 exemplar (not "Feature")
|
||||
header = "| Dimension | " + " | ".join(entities) + " |"
|
||||
# Separator row matching column count
|
||||
separator = "|" + "|".join(["---"] * (len(entities) + 1)) + "|"
|
||||
# 9 axes from the April 9 exemplar. Model fills with topic-appropriate
|
||||
# content; irrelevant axes get "N/A" rather than invented data.
|
||||
axes = [
|
||||
"What it is",
|
||||
"GitHub stars",
|
||||
"Philosophy",
|
||||
"Skills",
|
||||
"Memory",
|
||||
"Models",
|
||||
"Security",
|
||||
"Best for",
|
||||
"Install",
|
||||
]
|
||||
body = [f"| {axis} | " + " | ".join([" "] * len(entities)) + " |" for axis in axes]
|
||||
|
||||
return [
|
||||
"## Head-to-Head",
|
||||
"",
|
||||
"Fill each cell based on the research above. Keep cells short (5-15 words). Use ' - ' (hyphen with spaces) not em-dashes. Write N/A for axes that do not apply to this topic class. This scaffold matches the April 9 launch-video exemplar shape.",
|
||||
"",
|
||||
header,
|
||||
separator,
|
||||
*body,
|
||||
"",
|
||||
"After the table, write the Bottom Line section with one Choose-X-if paragraph per entity, then the emerging stack paragraph. See the comparison template in SKILL.md for the full structure.",
|
||||
]
|
||||
|
||||
|
||||
def render_full(report: schema.Report) -> str:
|
||||
"""Full data dump: ALL clusters + ALL items by source. For saved files and debugging."""
|
||||
# Start with the same header as compact
|
||||
@@ -152,13 +455,14 @@ def render_full(report: schema.Report) -> str:
|
||||
lines.append(f" *{item.container}*")
|
||||
if item.snippet:
|
||||
lines.append(f" {item.snippet[:500]}")
|
||||
# Top comments for Reddit
|
||||
# Top comments for Reddit, YouTube, TikTok, HackerNews.
|
||||
top_comments = item.metadata.get("top_comments", [])
|
||||
if top_comments and isinstance(top_comments[0], dict):
|
||||
vote_label = _vote_label_for(item.source)
|
||||
for tc in top_comments[:3]:
|
||||
excerpt = tc.get("excerpt", tc.get("text", ""))[:200]
|
||||
tc_score = tc.get("score", "")
|
||||
lines.append(f" Top comment ({tc_score} upvotes): {excerpt}")
|
||||
lines.append(f" Top comment ({tc_score} {vote_label}): {excerpt}")
|
||||
# Comment insights for Reddit
|
||||
insights = item.metadata.get("comment_insights", [])
|
||||
if insights:
|
||||
@@ -276,7 +580,8 @@ def _render_candidate(candidate: schema.Candidate, prefix: str) -> list[str]:
|
||||
for tc in _top_comments_list(primary):
|
||||
excerpt = tc.get("excerpt") or tc.get("text") or ""
|
||||
score = tc.get("score", "")
|
||||
lines.append(f" - Comment ({score} upvotes): {_truncate(excerpt.strip(), 240)}")
|
||||
vote_label = _vote_label_for(primary.source) if primary else "upvotes"
|
||||
lines.append(f" - Comment ({score} {vote_label}): {_truncate(excerpt.strip(), 240)}")
|
||||
insight = _comment_insight(primary)
|
||||
if insight:
|
||||
lines.append(f" - Insight: {_truncate(insight, 220)}")
|
||||
@@ -299,10 +604,54 @@ def _format_volume_short(volume: float) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _shorten_polymarket_title(title: str) -> str:
|
||||
"""Strip boilerplate from a Polymarket question to produce a compact descriptor.
|
||||
|
||||
Examples:
|
||||
- "Will Kanye West visit the UK by June 30?" -> "UK visit"
|
||||
- "Kanye West blocked from entering another country by June 30?" -> "blocked from entering another country"
|
||||
- "Will Bianca and Kanye West separate in 2026?" -> "Bianca and Kanye West separate"
|
||||
|
||||
Falls back to first 3-4 significant words if stripping does not reduce below 40 chars.
|
||||
Never truncates mid-word.
|
||||
"""
|
||||
import re
|
||||
|
||||
t = (title or "").strip().rstrip("?").strip()
|
||||
|
||||
# Drop leading "Will "
|
||||
if t.lower().startswith("will "):
|
||||
t = t[5:].strip()
|
||||
|
||||
# Drop "by <Month> <Day>" or "by <Month> <Day>, <Year>" tail
|
||||
t = re.sub(r"\s+by\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d+(?:,\s*\d{4})?$", "", t, flags=re.IGNORECASE)
|
||||
# Drop "in <Year>" tail (e.g. "separate in 2026")
|
||||
t = re.sub(r"\s+in\s+\d{4}$", "", t, flags=re.IGNORECASE)
|
||||
# Drop "by <Year>" tail
|
||||
t = re.sub(r"\s+by\s+\d{4}$", "", t, flags=re.IGNORECASE)
|
||||
# Drop "before <Month> <Day>" tail
|
||||
t = re.sub(r"\s+before\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d+$", "", t, flags=re.IGNORECASE)
|
||||
|
||||
# Pattern: "<Subject> visit <Place>" -> "<Place> visit"
|
||||
m = re.match(r"^(.+?)\s+visit\s+(?:the\s+)?(.+)$", t, flags=re.IGNORECASE)
|
||||
if m:
|
||||
subject, place = m.group(1), m.group(2)
|
||||
t = f"{place} visit"
|
||||
|
||||
t = t.strip()
|
||||
|
||||
# If still too long, fall back to first 6 significant words
|
||||
if len(t) > 40:
|
||||
words = t.split()
|
||||
t = " ".join(words[:6])
|
||||
|
||||
return t
|
||||
|
||||
|
||||
def _polymarket_top_markets(items: list[schema.SourceItem], limit: int = 3) -> list[str]:
|
||||
"""Build short summary strings for the top Polymarket markets by volume.
|
||||
|
||||
Returns list like: ['"BULLY <300k": 96% ($66K)', '"Top Spotify": Kanye 6.5% ($21K)']
|
||||
Returns list like: ['UK visit 5.5%', 'Israel visit 8%', 'blocked from entering 36%']
|
||||
"""
|
||||
# Sort by volume descending
|
||||
sorted_items = sorted(
|
||||
@@ -311,27 +660,28 @@ def _polymarket_top_markets(items: list[schema.SourceItem], limit: int = 3) -> l
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
summaries = []
|
||||
summaries: list[str] = []
|
||||
for item in sorted_items[:limit]:
|
||||
outcome_prices = item.metadata.get("outcome_prices") or []
|
||||
if not outcome_prices:
|
||||
continue
|
||||
|
||||
# Pick the leading outcome (first one, already sorted by relevance in polymarket.py)
|
||||
lead_name, lead_price = outcome_prices[0]
|
||||
# For binary Yes/No markets, show "Yes: 96%" format
|
||||
# For multi-outcome, show "OutcomeName: X%"
|
||||
if isinstance(lead_price, (int, float)):
|
||||
pct = f"{lead_price * 100:.0f}%" if lead_price >= 0.1 else f"{lead_price * 100:.1f}%"
|
||||
else:
|
||||
if not isinstance(lead_price, (int, float)):
|
||||
continue
|
||||
|
||||
# Short title
|
||||
title = item.metadata.get("question") or item.title
|
||||
if len(title) > 30:
|
||||
title = title[:27] + "..."
|
||||
pct = f"{lead_price * 100:.0f}%" if lead_price >= 0.1 else f"{lead_price * 100:.1f}%"
|
||||
|
||||
summaries.append(f'"{title}": {lead_name} {pct}')
|
||||
descriptor = _shorten_polymarket_title(item.metadata.get("question") or item.title or "")
|
||||
if not descriptor:
|
||||
continue
|
||||
|
||||
# For binary Yes/No markets (lead_name == "Yes"), the "Yes" is implicit - omit it.
|
||||
# For named outcomes (e.g. "Kanye" in a multi-way market), keep the outcome name.
|
||||
if lead_name.lower() == "yes":
|
||||
summaries.append(f"{descriptor} {pct}")
|
||||
else:
|
||||
summaries.append(f"{descriptor}: {lead_name} {pct}")
|
||||
|
||||
return summaries
|
||||
|
||||
@@ -352,6 +702,284 @@ def _render_source_coverage(report: schema.Report) -> list[str]:
|
||||
return lines
|
||||
|
||||
|
||||
# Known publications for the Web line of the emoji-tree footer.
|
||||
# Maps apex domain to a clean display name. Unknown domains fall back to
|
||||
# the bare domain string (protocol stripped, www. removed).
|
||||
_SITE_NAMES: dict[str, str] = {
|
||||
"later.com": "Later",
|
||||
"buffer.com": "Buffer",
|
||||
"socialbee.com": "SocialBee",
|
||||
"cnn.com": "CNN",
|
||||
"bbc.com": "BBC",
|
||||
"bbc.co.uk": "BBC",
|
||||
"nytimes.com": "NYT",
|
||||
"nypost.com": "NY Post",
|
||||
"wsj.com": "WSJ",
|
||||
"bloomberg.com": "Bloomberg",
|
||||
"reuters.com": "Reuters",
|
||||
"theverge.com": "The Verge",
|
||||
"techcrunch.com": "TechCrunch",
|
||||
"wired.com": "Wired",
|
||||
"arstechnica.com": "Ars Technica",
|
||||
"theguardian.com": "The Guardian",
|
||||
"independent.co.uk": "The Independent",
|
||||
"theatlantic.com": "The Atlantic",
|
||||
"newyorker.com": "The New Yorker",
|
||||
"washingtonpost.com": "Washington Post",
|
||||
"politico.com": "Politico",
|
||||
"axios.com": "Axios",
|
||||
"semafor.com": "Semafor",
|
||||
"theinformation.com": "The Information",
|
||||
"medium.com": "Medium",
|
||||
"substack.com": "Substack",
|
||||
"dev.to": "dev.to",
|
||||
"github.com": "GitHub",
|
||||
"stackoverflow.com": "Stack Overflow",
|
||||
"producthunt.com": "Product Hunt",
|
||||
"variety.com": "Variety",
|
||||
"deadline.com": "Deadline",
|
||||
"rollingstone.com": "Rolling Stone",
|
||||
"complex.com": "Complex",
|
||||
"pbs.org": "PBS",
|
||||
"npr.org": "NPR",
|
||||
"forbes.com": "Forbes",
|
||||
"cnbc.com": "CNBC",
|
||||
"businessinsider.com": "Business Insider",
|
||||
"fortune.com": "Fortune",
|
||||
"vox.com": "Vox",
|
||||
"slate.com": "Slate",
|
||||
"theregister.com": "The Register",
|
||||
"venturebeat.com": "VentureBeat",
|
||||
"hackernoon.com": "HackerNoon",
|
||||
"anthropic.com": "Anthropic",
|
||||
"openai.com": "OpenAI",
|
||||
"aws.amazon.com": "AWS",
|
||||
"9to5mac.com": "9to5Mac",
|
||||
"9to5google.com": "9to5Google",
|
||||
"decrypt.co": "Decrypt",
|
||||
"xda-developers.com": "XDA",
|
||||
"tomshardware.com": "Tom's Hardware",
|
||||
"engadget.com": "Engadget",
|
||||
"mashable.com": "Mashable",
|
||||
"vellum.ai": "Vellum",
|
||||
"helpnetsecurity.com": "Help Net Security",
|
||||
"gizmodo.com": "Gizmodo",
|
||||
}
|
||||
|
||||
|
||||
def _site_name_for_url(url: str) -> str:
|
||||
"""Return a clean publication name for a URL, or a bare domain fallback.
|
||||
|
||||
Strips protocol and ``www.`` from unknowns; checks known publications
|
||||
before falling back. Returns a short readable string, never a raw URL.
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
u = url.strip()
|
||||
if not u:
|
||||
return ""
|
||||
# urlparse needs a scheme to resolve the netloc; prepend http:// if missing.
|
||||
parsed = urlparse(u if "://" in u else f"http://{u}")
|
||||
host = (parsed.netloc or parsed.path.split("/", 1)[0]).lower()
|
||||
if host.startswith("www."):
|
||||
host = host[4:]
|
||||
if not host:
|
||||
return u[:40]
|
||||
if host in _SITE_NAMES:
|
||||
return _SITE_NAMES[host]
|
||||
# Try stripping one subdomain level (eu.example.com -> example.com)
|
||||
parts = host.split(".")
|
||||
if len(parts) >= 3:
|
||||
apex = ".".join(parts[-2:])
|
||||
if apex in _SITE_NAMES:
|
||||
return _SITE_NAMES[apex]
|
||||
return host
|
||||
|
||||
|
||||
def _format_web_line_sources(items: list[schema.SourceItem], limit: int = 8) -> str:
|
||||
"""Return comma-separated clean publication names for the Web line.
|
||||
|
||||
Deduplicates by display name while preserving first-seen order.
|
||||
"""
|
||||
seen: list[str] = []
|
||||
for item in items:
|
||||
if not item.url:
|
||||
continue
|
||||
name = _site_name_for_url(item.url)
|
||||
if not name:
|
||||
continue
|
||||
if name not in seen:
|
||||
seen.append(name)
|
||||
if len(seen) >= limit:
|
||||
break
|
||||
return ", ".join(seen)
|
||||
|
||||
|
||||
# Per-source line format for the emoji-tree footer.
|
||||
# Label in the template, emoji prefix, word for the item count, and which
|
||||
# engagement dimensions to show. Keys are the source names as used in
|
||||
# Report.items_by_source. Order here is the render order.
|
||||
_FOOTER_SOURCES: list[tuple[str, str, str, str, list[tuple[str, str]]]] = [
|
||||
# (source_key, emoji, display_name, item_word_singular, [(engagement_key, word)])
|
||||
("reddit", "🟠", "Reddit", "thread", [("score", "upvotes"), ("num_comments", "comments")]),
|
||||
("x", "🔵", "X", "post", [("likes", "likes"), ("reposts", "reposts")]),
|
||||
("youtube", "🔴", "YouTube", "video", [("views", "views")]), # transcripts appended below in _build_source_footer_lines
|
||||
("tiktok", "🎵", "TikTok", "video", [("views", "views"), ("likes", "likes")]),
|
||||
("instagram", "📸", "Instagram", "reel", [("views", "views"), ("likes", "likes")]),
|
||||
("threads", "🧵", "Threads", "post", [("likes", "likes"), ("replies", "replies")]),
|
||||
("pinterest", "📌", "Pinterest", "pin", [("saves", "saves"), ("comments", "comments")]),
|
||||
("hackernews", "🟡", "HN", "story", [("points", "points"), ("comments", "comments")]),
|
||||
("bluesky", "🦋", "Bluesky", "post", [("likes", "likes"), ("reposts", "reposts")]),
|
||||
("truthsocial", "🇺🇸", "Truth Social", "post", [("likes", "likes"), ("reposts", "reposts")]),
|
||||
("github", "🐙", "GitHub", "item", [("reactions", "reactions"), ("comments", "comments")]),
|
||||
]
|
||||
|
||||
|
||||
def _sum_engagement(items: list[schema.SourceItem], key: str) -> int:
|
||||
total = 0
|
||||
for item in items:
|
||||
value = item.engagement.get(key) if item.engagement else None
|
||||
if value in (None, ""):
|
||||
continue
|
||||
try:
|
||||
total += int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
def _footer_line_for_source(emoji: str, label: str, count: int, item_word: str, stats: str) -> str:
|
||||
count_str = f"{count:,}" if count >= 1000 else str(count)
|
||||
plural = f"{item_word}s" if count != 1 else item_word
|
||||
if stats:
|
||||
return f"{emoji} {label}: {count_str} {plural} │ {stats}"
|
||||
return f"{emoji} {label}: {count_str} {plural}"
|
||||
|
||||
|
||||
def _build_source_footer_lines(report: schema.Report) -> list[str]:
|
||||
"""Return emoji-tree body lines (without tree characters) for each populated source.
|
||||
|
||||
The caller adds the tree characters (├─ / └─) after assembling all lines.
|
||||
"""
|
||||
out: list[str] = []
|
||||
for source_key, emoji, label, item_word, engagement_fields in _FOOTER_SOURCES:
|
||||
items = report.items_by_source.get(source_key) or []
|
||||
if not items:
|
||||
continue
|
||||
parts: list[str] = []
|
||||
for eng_key, word in engagement_fields:
|
||||
total = _sum_engagement(items, eng_key)
|
||||
if total > 0:
|
||||
total_str = f"{total:,}" if total >= 1000 else str(total)
|
||||
parts.append(f"{total_str} {word}")
|
||||
# YouTube: append "N with transcripts" instead of a third likes-based column.
|
||||
# Transcripts are a more meaningful research-depth signal than likes.
|
||||
if source_key == "youtube":
|
||||
with_transcripts = sum(
|
||||
1 for it in items
|
||||
if (it.metadata.get("transcript_highlights") or it.metadata.get("transcript_snippet"))
|
||||
)
|
||||
if with_transcripts > 0:
|
||||
parts.append(f"{with_transcripts} with transcripts")
|
||||
stats = " │ ".join(parts)
|
||||
out.append(_footer_line_for_source(emoji, label, len(items), item_word, stats))
|
||||
|
||||
# Polymarket (special: count + odds string from existing helper)
|
||||
polymarket_items = report.items_by_source.get("polymarket") or []
|
||||
if polymarket_items:
|
||||
odds = _polymarket_top_markets(polymarket_items, limit=3)
|
||||
odds_str = ", ".join(odds) if odds else ""
|
||||
count = len(polymarket_items)
|
||||
count_str = f"{count:,}" if count >= 1000 else str(count)
|
||||
plural = "markets" if count != 1 else "market"
|
||||
if odds_str:
|
||||
out.append(f"📊 Polymarket: {count_str} {plural} │ {odds_str}")
|
||||
else:
|
||||
out.append(f"📊 Polymarket: {count_str} {plural}")
|
||||
|
||||
# Web (sources from grounding)
|
||||
web_items = report.items_by_source.get("grounding") or []
|
||||
if web_items:
|
||||
names = _format_web_line_sources(web_items)
|
||||
count = len(web_items)
|
||||
count_str = f"{count:,}" if count >= 1000 else str(count)
|
||||
plural = "pages" if count != 1 else "page"
|
||||
if names:
|
||||
out.append(f"🌐 Web: {count_str} {plural} - {names}")
|
||||
else:
|
||||
out.append(f"🌐 Web: {count_str} {plural}")
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _top_voices_footer_line(report: schema.Report) -> str | None:
|
||||
"""Return the 🗣️ Top voices line or None if no meaningful voices exist.
|
||||
|
||||
Combines top handles (X, Bluesky, Truth Social, YouTube, TikTok, Instagram)
|
||||
and top subreddits, separated by │.
|
||||
"""
|
||||
handle_items = {
|
||||
source: report.items_by_source.get(source) or []
|
||||
for source in ("x", "bluesky", "truthsocial", "youtube", "tiktok", "instagram", "threads")
|
||||
}
|
||||
handle_counts: Counter[str] = Counter()
|
||||
for items in handle_items.values():
|
||||
for item in items:
|
||||
actor = _stats_actor(item)
|
||||
if actor and actor.startswith("@"):
|
||||
handle_counts[actor] += 1
|
||||
|
||||
subreddit_counts: Counter[str] = Counter()
|
||||
for item in report.items_by_source.get("reddit") or []:
|
||||
if item.container:
|
||||
subreddit_counts[f"r/{item.container}"] += 1
|
||||
|
||||
top_handles = [h for h, _ in handle_counts.most_common(3)]
|
||||
top_subs = [s for s, _ in subreddit_counts.most_common(3)]
|
||||
if not top_handles and not top_subs:
|
||||
return None
|
||||
parts: list[str] = []
|
||||
if top_handles:
|
||||
parts.append(", ".join(top_handles))
|
||||
if top_subs:
|
||||
parts.append(", ".join(top_subs))
|
||||
return f"🗣️ Top voices: {' │ '.join(parts)}"
|
||||
|
||||
|
||||
def _render_emoji_footer(report: schema.Report, save_path: str | None) -> list[str]:
|
||||
"""Produce the deterministic magic footer block.
|
||||
|
||||
Returns a list of markdown lines, including enclosing ``---`` separators.
|
||||
Returns an empty list if no sources are populated.
|
||||
"""
|
||||
source_lines = _build_source_footer_lines(report)
|
||||
if not source_lines:
|
||||
return []
|
||||
|
||||
voices_line = _top_voices_footer_line(report)
|
||||
raw_line = f"📎 Raw results saved to {save_path}" if save_path else None
|
||||
|
||||
body: list[str] = []
|
||||
body.extend(source_lines)
|
||||
if voices_line:
|
||||
body.append(voices_line)
|
||||
if raw_line:
|
||||
body.append(raw_line)
|
||||
|
||||
# Apply tree characters: ├─ for all but the last body line, └─ for the last.
|
||||
tree_lines: list[str] = []
|
||||
for i, line in enumerate(body):
|
||||
prefix = "└─" if i == len(body) - 1 else "├─"
|
||||
tree_lines.append(f"{prefix} {line}")
|
||||
|
||||
return [
|
||||
"---",
|
||||
"✅ All agents reported back!",
|
||||
*tree_lines,
|
||||
"---",
|
||||
]
|
||||
|
||||
|
||||
def _render_stats(report: schema.Report) -> list[str]:
|
||||
lines = [
|
||||
"## Stats",
|
||||
@@ -582,13 +1210,42 @@ def _format_explanation(candidate: schema.Candidate) -> str | None:
|
||||
return candidate.explanation
|
||||
|
||||
|
||||
def _top_comments_list(item: schema.SourceItem | None, limit: int = 3, min_score: int = 10) -> list[dict]:
|
||||
"""Return up to `limit` top comments with score >= min_score."""
|
||||
# Per-source minimum vote counts for showing a top comment in compact emit.
|
||||
# Reddit upvotes, YouTube likes, and TikTok likes are not comparable units —
|
||||
# 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:
|
||||
return []
|
||||
comments = item.metadata.get("top_comments") or []
|
||||
if not comments or not isinstance(comments[0], dict):
|
||||
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]
|
||||
|
||||
|
||||
|
||||
+125
-11
@@ -3,8 +3,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from . import http, providers, schema
|
||||
from . import http, providers, query, schema
|
||||
|
||||
|
||||
# Penalty applied when a candidate does not mention the primary entity
|
||||
# from the topic in its title or snippet. Picked empirically: a typical
|
||||
# score spread in the shortlist is 30-70, so 25 points reliably pushes
|
||||
# an off-topic candidate below on-topic ones without fully zeroing out
|
||||
# marginal matches. See 2026-04-19 Hermes Agent Use Cases failure: a
|
||||
# Nate Herk "Managed Agents" video scored 51 / ranked #2 with zero
|
||||
# Hermes content.
|
||||
ENTITY_MISS_PENALTY = 25.0
|
||||
|
||||
# Intent modifiers to strip before extracting the primary entity so that,
|
||||
# for example, "Hermes Agent use cases" yields primary_entity="hermes agent"
|
||||
# rather than "hermes agent use cases". Kept in sync with
|
||||
# planner._INTENT_MODIFIER_PATTERNS.
|
||||
_INTENT_MODIFIER_RE = re.compile(
|
||||
r"\b("
|
||||
r"use cases|use case|workflows|workflow|"
|
||||
r"examples|example|tutorial|tutorials|"
|
||||
r"review|reviews|comparison|applications|"
|
||||
r"in practice|production use|production|"
|
||||
r"how i use"
|
||||
r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
INTENT_SCORING_HINTS: dict[str, str] = {
|
||||
"comparison": (
|
||||
@@ -60,20 +86,21 @@ def rerank_candidates(
|
||||
) -> list[schema.Candidate]:
|
||||
"""Rerank the fused shortlist, demoting candidates the reranker scored as irrelevant."""
|
||||
shortlisted = candidates[:shortlist_size]
|
||||
primary_entity = _primary_entity(topic)
|
||||
if provider and model and shortlisted:
|
||||
try:
|
||||
response = provider.generate_json(model, _build_prompt(topic, plan, shortlisted))
|
||||
response = provider.generate_json(model, _build_prompt(topic, plan, shortlisted, primary_entity))
|
||||
_apply_llm_scores(shortlisted, response)
|
||||
except (ValueError, KeyError, json.JSONDecodeError, OSError, http.HTTPError) as exc:
|
||||
import sys
|
||||
print(f"[Rerank] LLM reranking failed, using local fallback: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
_apply_fallback_scores(shortlisted)
|
||||
_apply_fallback_scores(shortlisted, primary_entity=primary_entity)
|
||||
else:
|
||||
_apply_fallback_scores(shortlisted)
|
||||
_apply_fallback_scores(shortlisted, primary_entity=primary_entity)
|
||||
|
||||
if len(candidates) > shortlist_size:
|
||||
tail = candidates[shortlist_size:]
|
||||
_apply_fallback_scores(tail)
|
||||
_apply_fallback_scores(tail, primary_entity=primary_entity)
|
||||
|
||||
return sorted(
|
||||
candidates,
|
||||
@@ -103,7 +130,7 @@ def _fenced_untrusted_content(candidate_block: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _build_prompt(topic: str, plan: schema.QueryPlan, candidates: list[schema.Candidate]) -> str:
|
||||
def _build_prompt(topic: str, plan: schema.QueryPlan, candidates: list[schema.Candidate], primary_entity: str = "") -> str:
|
||||
ranking_queries = "\n".join(
|
||||
f"- {subquery.label}: {subquery.ranking_query}"
|
||||
for subquery in plan.subqueries
|
||||
@@ -121,6 +148,16 @@ def _build_prompt(topic: str, plan: schema.QueryPlan, candidates: list[schema.Ca
|
||||
)
|
||||
for candidate in candidates
|
||||
)
|
||||
grounding_hint = ""
|
||||
if primary_entity:
|
||||
grounding_hint = (
|
||||
f"\nPrimary entity grounding: the user's primary entity is \"{primary_entity}\". "
|
||||
"A candidate that does NOT mention this entity (or a clear synonym/abbreviation) "
|
||||
"in its title or snippet should score no higher than 30, regardless of other "
|
||||
"signals. Do not let a candidate match the topic vicinity without matching the "
|
||||
"entity itself. 2026-04-19 Hermes Agent Use Cases failure: a Nate Herk video "
|
||||
"about Claude's Managed Agents scored 51 with zero Hermes content.\n"
|
||||
)
|
||||
return f"""
|
||||
Judge search-result relevance for a last-30-days research pipeline.
|
||||
|
||||
@@ -145,7 +182,7 @@ Scoring guidance:
|
||||
- 70 to 89: clearly relevant and useful
|
||||
- 40 to 69: somewhat relevant but weaker
|
||||
- 0 to 39: weak, redundant, or off-target
|
||||
{_intent_hint_block(plan)}
|
||||
{grounding_hint}{_intent_hint_block(plan)}
|
||||
{_fenced_untrusted_content(candidate_block)}
|
||||
""".strip()
|
||||
|
||||
@@ -169,21 +206,93 @@ def _apply_llm_scores(candidates: list[schema.Candidate], payload: dict) -> None
|
||||
candidate.final_score = _final_score(candidate)
|
||||
|
||||
|
||||
def _apply_fallback_scores(candidates: list[schema.Candidate]) -> None:
|
||||
def _apply_fallback_scores(candidates: list[schema.Candidate], *, primary_entity: str = "") -> None:
|
||||
for candidate in candidates:
|
||||
rerank_score, reason = _fallback_tuple(candidate)
|
||||
rerank_score, reason = _fallback_tuple(candidate, primary_entity=primary_entity)
|
||||
candidate.rerank_score = rerank_score
|
||||
candidate.explanation = reason
|
||||
candidate.final_score = _final_score(candidate)
|
||||
|
||||
|
||||
def _fallback_tuple(candidate: schema.Candidate) -> tuple[float, str]:
|
||||
def _candidate_haystack(candidate: schema.Candidate) -> str:
|
||||
"""Build the lowercase text blob against which entity-grounding is checked.
|
||||
|
||||
Expanded 2026-04-19 to include transcript snippets, transcript highlights,
|
||||
and top-comment text. The prior `title + snippet` check missed YouTube
|
||||
videos whose entity mentions live in transcript content and Reddit posts
|
||||
whose mentions are in top comments. Now checks all text surfaces a human
|
||||
would see.
|
||||
"""
|
||||
parts: list[str] = [candidate.title or "", candidate.snippet or ""]
|
||||
metadata = candidate.metadata or {}
|
||||
|
||||
transcript_snippet = metadata.get("transcript_snippet") or ""
|
||||
if isinstance(transcript_snippet, str):
|
||||
parts.append(transcript_snippet)
|
||||
|
||||
for hl in metadata.get("transcript_highlights") or []:
|
||||
if isinstance(hl, str):
|
||||
parts.append(hl)
|
||||
|
||||
for tc in metadata.get("top_comments") or []:
|
||||
if isinstance(tc, dict):
|
||||
parts.append(str(tc.get("excerpt", "") or tc.get("text", "") or ""))
|
||||
elif isinstance(tc, str):
|
||||
parts.append(tc)
|
||||
|
||||
for insight in metadata.get("comment_insights") or []:
|
||||
if isinstance(insight, str):
|
||||
parts.append(insight)
|
||||
|
||||
return " ".join(parts).lower()
|
||||
|
||||
|
||||
def _fallback_tuple(candidate: schema.Candidate, *, primary_entity: str = "") -> tuple[float, str]:
|
||||
score = (
|
||||
(candidate.local_relevance * 100.0 * 0.7)
|
||||
+ (candidate.freshness * 0.2)
|
||||
+ (candidate.source_quality * 100.0 * 0.1)
|
||||
)
|
||||
return max(0.0, min(100.0, score)), "fallback-local-score"
|
||||
reason = "fallback-local-score"
|
||||
# Entity-grounding demotion: if the primary entity (topic minus intent
|
||||
# modifier) is not present anywhere in the candidate's text surfaces
|
||||
# (title, snippet, transcript, transcript highlights, top comments,
|
||||
# insights), subtract ENTITY_MISS_PENALTY. Skip for candidates with
|
||||
# NO text anywhere (e.g., image-only TikToks) to avoid penalizing
|
||||
# thin-text sources unfairly. 2026-04-19 Nate Herk "Managed Agents"
|
||||
# video ranked #2 on a Hermes query despite zero Hermes mentions
|
||||
# because the old haystack only checked title + snippet.
|
||||
if primary_entity:
|
||||
haystack = _candidate_haystack(candidate)
|
||||
if haystack.strip() and primary_entity.lower() not in haystack:
|
||||
score -= ENTITY_MISS_PENALTY
|
||||
reason = "fallback-local-score (entity-miss demotion)"
|
||||
return max(0.0, min(100.0, score)), reason
|
||||
|
||||
|
||||
def _primary_entity(topic: str) -> str:
|
||||
"""Extract the primary entity from the topic for grounding checks.
|
||||
|
||||
Strips intent-modifier suffixes (see planner._INTENT_MODIFIER_PATTERNS),
|
||||
trims trailing punctuation, collapses whitespace. Returns the empty
|
||||
string for topics that are all intent modifier with no entity, so
|
||||
callers can skip the grounding check.
|
||||
"""
|
||||
stripped = _INTENT_MODIFIER_RE.sub(" ", topic)
|
||||
# Also collapse multiple spaces and strip punctuation.
|
||||
stripped = re.sub(r"\s+", " ", stripped).strip(" \t\r\n?.,:;!")
|
||||
return stripped
|
||||
|
||||
|
||||
#: Secondary entity-miss penalty applied directly to final_score (not just
|
||||
#: rerank_score). The -25 on rerank_score composes to only -15 on final_score
|
||||
#: via the 0.60 weight, which engagement bonus partially offsets on
|
||||
#: high-view YouTube items. This secondary penalty lands the full weight on
|
||||
#: the composite signal the cluster-scoring layer consumes. 2026-04-19
|
||||
#: Nate Herk "Managed Agents" video ranked at cluster #2 with score 51
|
||||
#: despite the rerank_score demotion because engagement + freshness drowned
|
||||
#: the dilute penalty. This backstop makes the demotion actually decisive.
|
||||
ENTITY_MISS_FINAL_PENALTY = 20.0
|
||||
|
||||
|
||||
def _final_score(candidate: schema.Candidate) -> float:
|
||||
@@ -204,6 +313,11 @@ def _final_score(candidate: schema.Candidate) -> float:
|
||||
)
|
||||
if candidate.rerank_score is not None and candidate.rerank_score < 20.0:
|
||||
base *= 0.3
|
||||
# Secondary entity-grounding penalty: when the fallback path flagged
|
||||
# entity-miss via candidate.explanation, apply an additional penalty
|
||||
# at final_score level so engagement signal can't mask the demotion.
|
||||
if candidate.explanation and "entity-miss" in candidate.explanation:
|
||||
base = max(0.0, base - ENTITY_MISS_FINAL_PENALTY)
|
||||
return base
|
||||
|
||||
|
||||
|
||||
+30
-4
@@ -82,12 +82,11 @@ def _top_comment_score(item: schema.SourceItem) -> float:
|
||||
|
||||
|
||||
# Per-source engagement weights: list of (field_name, weight) tuples.
|
||||
# Reddit uses a custom function because upvote_ratio and top_comment_score
|
||||
# are not simple log1p fields.
|
||||
# Reddit, YouTube, and TikTok use custom functions because they include
|
||||
# a dedicated 10% top-comment-score slot (see _reddit_engagement,
|
||||
# _youtube_engagement, _tiktok_engagement).
|
||||
ENGAGEMENT_WEIGHTS: dict[str, list[tuple[str, float]]] = {
|
||||
"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)],
|
||||
"hackernews": [("points", 0.55), ("comments", 0.45)],
|
||||
"bluesky": [("likes", 0.40), ("reposts", 0.30), ("replies", 0.20), ("quotes", 0.10)],
|
||||
@@ -113,6 +112,29 @@ def _reddit_engagement(item: schema.SourceItem) -> float | None:
|
||||
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:
|
||||
if not item.engagement:
|
||||
return None
|
||||
@@ -125,6 +147,10 @@ def _generic_engagement(item: schema.SourceItem) -> float | None:
|
||||
def engagement_raw(item: schema.SourceItem) -> float | None:
|
||||
if item.source == "reddit":
|
||||
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)
|
||||
if weights:
|
||||
return _weighted_engagement(item, weights)
|
||||
|
||||
+11
-32
@@ -9,10 +9,9 @@ API docs: https://scrapecreators.com/docs
|
||||
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from . import http, log
|
||||
from . import dates, http, log
|
||||
from .relevance import token_overlap_relevance as _compute_relevance
|
||||
|
||||
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/threads"
|
||||
@@ -29,14 +28,6 @@ def _log(msg: str):
|
||||
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:
|
||||
"""Extract core subject from verbose query for Threads search."""
|
||||
from .query import extract_core_subject
|
||||
@@ -52,29 +43,17 @@ def _extract_core_subject(topic: str) -> str:
|
||||
def _parse_date(item: Dict[str, Any]) -> Optional[str]:
|
||||
"""Parse date from Threads item to YYYY-MM-DD.
|
||||
|
||||
Tries common timestamp fields: taken_at (unix), created_at (ISO),
|
||||
and falls back to any date-like string field.
|
||||
Tries common timestamp fields in order: taken_at and create_time
|
||||
(unix timestamps in Meta APIs), then created_at, published_at, and
|
||||
date (ISO 8601 strings). dates.parse_date() handles both.
|
||||
"""
|
||||
# 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"):
|
||||
for key in ("taken_at", "create_time", "created_at", "published_at", "date"):
|
||||
val = item.get(key)
|
||||
if val and isinstance(val, str):
|
||||
try:
|
||||
dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
|
||||
if val is None:
|
||||
continue
|
||||
dt = dates.parse_date(str(val))
|
||||
if dt:
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -183,7 +162,7 @@ def search_threads(
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"keyword": core_topic})
|
||||
url = f"{SCRAPECREATORS_BASE}/search?{params}"
|
||||
headers = _sc_headers(token)
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
@@ -194,7 +173,7 @@ def search_threads(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/search",
|
||||
params={"keyword": core_topic},
|
||||
headers=_sc_headers(token),
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
+141
-15
@@ -109,14 +109,6 @@ def _log(msg: str):
|
||||
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]:
|
||||
"""Parse date from ScrapeCreators TikTok item to YYYY-MM-DD."""
|
||||
ts = item.get("create_time")
|
||||
@@ -227,7 +219,7 @@ def _hashtag_search(
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"hashtag": hashtag})
|
||||
url = f"{SCRAPECREATORS_BASE}/search/hashtag?{params}"
|
||||
headers = _sc_headers(token)
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
@@ -238,7 +230,7 @@ def _hashtag_search(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/search/hashtag",
|
||||
params={"hashtag": hashtag},
|
||||
headers=_sc_headers(token),
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -274,7 +266,7 @@ def _profile_videos(
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"handle": handle, "sort_by": "latest"})
|
||||
url = f"{profile_url}?{params}"
|
||||
headers = _sc_headers(token)
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
@@ -285,7 +277,7 @@ def _profile_videos(
|
||||
resp = _requests.get(
|
||||
profile_url,
|
||||
params={"handle": handle, "sort_by": "latest"},
|
||||
headers=_sc_headers(token),
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -332,7 +324,7 @@ def search_tiktok(
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"query": core_topic, "sort_by": "relevance"})
|
||||
url = f"{SCRAPECREATORS_BASE}/search/keyword?{params}"
|
||||
headers = _sc_headers(token)
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
@@ -343,7 +335,7 @@ def search_tiktok(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/search/keyword",
|
||||
params={"query": core_topic, "sort_by": "relevance"},
|
||||
headers=_sc_headers(token),
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -433,7 +425,7 @@ def fetch_captions(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/video/transcript",
|
||||
params={"url": url},
|
||||
headers=_sc_headers(token),
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
@@ -547,3 +539,137 @@ def parse_tiktok_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
List of item dicts ready for normalization.
|
||||
"""
|
||||
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
|
||||
|
||||
+44
-31
@@ -18,20 +18,28 @@ const SearchClient = withSearch(TwitterClientBase);
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function writeStdout(text) {
|
||||
if (text) process.stdout.write(text);
|
||||
}
|
||||
|
||||
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) {
|
||||
process.stdout.write(JSON.stringify({ authenticated: true, source: cookies.source }));
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify({ authenticated: false, warnings }));
|
||||
process.exit(1);
|
||||
writeStdout(JSON.stringify({ authenticated: true, source: cookies.source }));
|
||||
return 0;
|
||||
}
|
||||
writeStdout(JSON.stringify({ authenticated: false, warnings }));
|
||||
return 1;
|
||||
} catch (err) {
|
||||
process.stdout.write(JSON.stringify({ authenticated: false, error: err.message }));
|
||||
process.exit(1);
|
||||
writeStdout(JSON.stringify({ authenticated: false, error: err.message }));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,15 +48,14 @@ 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);
|
||||
writeStdout(cookies.source || 'authenticated');
|
||||
return 0;
|
||||
}
|
||||
writeStderr('Not authenticated\n');
|
||||
return 1;
|
||||
} catch (err) {
|
||||
process.stderr.write(`Auth check failed: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
writeStderr(`Auth check failed: ${err.message}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,8 +79,8 @@ for (let i = 0; i < args.length; i++) {
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
process.stderr.write('Usage: node bird-search.mjs <query> [--count N] [--json]\n');
|
||||
process.exit(1);
|
||||
writeStderr('Usage: node bird-search.mjs <query> [--count N] [--json]\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -83,14 +90,13 @@ try {
|
||||
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: [] }));
|
||||
writeStdout(JSON.stringify({ error: msg, items: [] }));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${msg}\n`);
|
||||
writeStderr(`Error: ${msg}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Create search client
|
||||
const client = new SearchClient({
|
||||
cookies: {
|
||||
authToken: cookies.authToken,
|
||||
@@ -100,35 +106,42 @@ try {
|
||||
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: [] }));
|
||||
writeStdout(JSON.stringify({ error: result.error, items: [] }));
|
||||
} else {
|
||||
process.stderr.write(`Search failed: ${result.error}\n`);
|
||||
writeStderr(`Search failed: ${result.error}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Output results
|
||||
const tweets = result.tweets || [];
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(JSON.stringify(tweets));
|
||||
writeStdout(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`);
|
||||
writeStdout(`@${author}: ${tweet.text?.slice(0, 200)}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(JSON.stringify({ error: err.message, items: [] }));
|
||||
writeStdout(JSON.stringify({ error: err.message, items: [] }));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${err.message}\n`);
|
||||
writeStderr(`Error: ${err.message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const code = await main();
|
||||
process.exitCode = Number.isInteger(code) ? code : 1;
|
||||
} catch (err) {
|
||||
writeStderr(`Fatal error: ${err?.message || err}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
+36
-21
@@ -655,14 +655,6 @@ except ImportError:
|
||||
_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:
|
||||
"""Combined engagement score for ranking which videos to enrich."""
|
||||
eng = item.get("engagement", {})
|
||||
@@ -740,12 +732,13 @@ def _fetch_video_comments(
|
||||
Returns:
|
||||
List of comment dicts with author, text, likes, date.
|
||||
"""
|
||||
video_url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
if not _requests:
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"id": video_id})
|
||||
params = urlencode({"url": video_url})
|
||||
url = f"{SCRAPECREATORS_YT_BASE}/video/comments?{params}"
|
||||
headers = _sc_headers(token)
|
||||
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:
|
||||
@@ -755,8 +748,8 @@ def _fetch_video_comments(
|
||||
try:
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_YT_BASE}/video/comments",
|
||||
params={"id": video_id},
|
||||
headers=_sc_headers(token),
|
||||
params={"url": video_url},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -771,11 +764,32 @@ def _fetch_video_comments(
|
||||
text = c.get("text") or c.get("body") or c.get("content", "")
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# SC returns author as {"name": "@handle", ...}; legacy mocks may pass a string.
|
||||
author = c.get("author") or c.get("author_name", "")
|
||||
if isinstance(author, dict):
|
||||
author = author.get("name") or author.get("handle") or ""
|
||||
|
||||
# SC nests likes under engagement.likes; legacy shapes used top-level keys.
|
||||
engagement = c.get("engagement") or {}
|
||||
likes = c.get("likes")
|
||||
if likes is None:
|
||||
likes = engagement.get("likes", 0) if isinstance(engagement, dict) else 0
|
||||
if not likes:
|
||||
likes = c.get("vote_count", 0)
|
||||
|
||||
date = (
|
||||
c.get("date")
|
||||
or c.get("published_at")
|
||||
or c.get("publishedTime")
|
||||
or c.get("publishedTimeText", "")
|
||||
)
|
||||
|
||||
comments.append({
|
||||
"author": c.get("author") or c.get("author_name", ""),
|
||||
"author": author,
|
||||
"text": text[:400],
|
||||
"likes": c.get("likes") or c.get("vote_count", 0),
|
||||
"date": c.get("date") or c.get("published_at", ""),
|
||||
"likes": likes,
|
||||
"date": date,
|
||||
})
|
||||
|
||||
return comments
|
||||
@@ -906,7 +920,7 @@ def _sc_youtube_search(keyword: str, token: str) -> List[Dict[str, Any]]:
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"keyword": keyword})
|
||||
url = f"{SCRAPECREATORS_YT_BASE}/search?{params}"
|
||||
headers = _sc_headers(token)
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
return data.get("videos", data.get("data", data.get("items", [])))
|
||||
@@ -918,7 +932,7 @@ def _sc_youtube_search(keyword: str, token: str) -> List[Dict[str, Any]]:
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_YT_BASE}/search",
|
||||
params={"keyword": keyword},
|
||||
headers=_sc_headers(token),
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -939,12 +953,13 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
|
||||
Returns:
|
||||
Plaintext transcript string, or None if unavailable.
|
||||
"""
|
||||
video_url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
if not _requests:
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"id": video_id})
|
||||
params = urlencode({"url": video_url})
|
||||
url = f"{SCRAPECREATORS_YT_BASE}/video/transcript?{params}"
|
||||
headers = _sc_headers(token)
|
||||
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:
|
||||
@@ -954,8 +969,8 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
|
||||
try:
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_YT_BASE}/video/transcript",
|
||||
params={"id": video_id},
|
||||
headers=_sc_headers(token),
|
||||
params={"url": video_url},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
|
||||
+56
-2
@@ -11,7 +11,7 @@ COMMON_TARGETS=(
|
||||
# but local development needs the cache kept in sync with the repo.
|
||||
# Do NOT add ~/.claude/skills/last30days - it creates a duplicate
|
||||
# /last30days-3 in the slash command menu alongside the plugin version.
|
||||
"$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3/3.0.0-alpha"
|
||||
"$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3/3.0.1"
|
||||
"$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3-nogem/3.0.0-nogem"
|
||||
"$HOME/.agents/skills/last30days"
|
||||
"$HOME/.codex/skills/last30days"
|
||||
@@ -24,7 +24,7 @@ sync_target() {
|
||||
|
||||
echo ""
|
||||
echo "--- Syncing to $target ---"
|
||||
mkdir -p "$target/scripts/lib" "$target/variants/open/references"
|
||||
mkdir -p "$target/scripts/lib"
|
||||
|
||||
cp "$skill_md" "$target/SKILL.md"
|
||||
|
||||
@@ -35,7 +35,13 @@ sync_target() {
|
||||
"$SRC/scripts/store.py" \
|
||||
"$target/scripts/"
|
||||
rsync -a "$SRC/scripts/lib/"*.py "$target/scripts/lib/"
|
||||
|
||||
# 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
|
||||
rsync -a "$SRC/scripts/lib/vendor" "$target/scripts/lib/"
|
||||
@@ -63,7 +69,55 @@ for t in "${COMMON_TARGETS[@]}"; do
|
||||
sync_target "$t" "$SRC/SKILL.md"
|
||||
done
|
||||
|
||||
# Hermes sync: deploy to Hermes skills directory if it exists
|
||||
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"
|
||||
|
||||
cp "$SRC/SKILL.md" "$HERMES_TARGET/SKILL.md"
|
||||
|
||||
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 "Sync complete."
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../SKILL.md
|
||||
@@ -1,231 +0,0 @@
|
||||
---
|
||||
name: last30days
|
||||
version: "3.0.0"
|
||||
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"
|
||||
allowed-tools: Bash, Read, Write, WebSearch
|
||||
homepage: https://github.com/mvanhorn/last30days-skill
|
||||
repository: https://github.com/mvanhorn/last30days-skill
|
||||
author: mvanhorn
|
||||
license: MIT
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# last30days v3.0.0
|
||||
|
||||
Use `last30days` when the user wants recent, cross-source evidence from the last 30 days.
|
||||
|
||||
The runtime is a single v3 pipeline:
|
||||
|
||||
1. plan the query
|
||||
2. retrieve per `(subquery, source)`
|
||||
3. normalize and dedupe
|
||||
4. extract best snippets
|
||||
5. fuse with weighted RRF
|
||||
6. rerank with one relevance score
|
||||
7. cluster evidence
|
||||
8. render ranked clusters
|
||||
|
||||
## Setup: resolve the skill root
|
||||
|
||||
```bash
|
||||
for dir in \
|
||||
"." \
|
||||
"${CLAUDE_PLUGIN_ROOT:-}" \
|
||||
"${GEMINI_EXTENSION_DIR:-}" \
|
||||
"$HOME/.openclaw/workspace/skills/last30days" \
|
||||
"$HOME/.openclaw/skills/last30days" \
|
||||
"$HOME/.claude/skills/last30days" \
|
||||
"$HOME/.agents/skills/last30days" \
|
||||
"$HOME/.codex/skills/last30days"; do
|
||||
[ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break
|
||||
done
|
||||
|
||||
if [ -z "${SKILL_ROOT:-}" ]; then
|
||||
echo "ERROR: Could not find scripts/last30days.py" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## Default command
|
||||
|
||||
```bash
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=compact
|
||||
```
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=json
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --quick
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --deep
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --search=reddit,x,grounding
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --store
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" --diagnose
|
||||
```
|
||||
|
||||
## Runtime expectations
|
||||
|
||||
- One reasoning provider is required: `GOOGLE_API_KEY` for Gemini, `OPENAI_API_KEY` for OpenAI, or `XAI_API_KEY` for xAI.
|
||||
- `BRAVE_API_KEY` enables Brave web search (recommended). `SERPER_API_KEY` is the web fallback.
|
||||
- `SCRAPECREATORS_API_KEY` enables Reddit, TikTok, and Instagram.
|
||||
- `XAI_API_KEY` enables xAI reasoning and X search.
|
||||
- `AUTH_TOKEN` plus `CT0` enables Bird-backed X search.
|
||||
- `yt-dlp` enables YouTube.
|
||||
- Planning and reranking fall back gracefully: Gemini -> OpenAI -> xAI -> deterministic/local.
|
||||
- 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
|
||||
|
||||
- `compact` and `md`: cluster-first markdown
|
||||
- `json`: full v3 report
|
||||
- `context`: short synthesis-oriented context
|
||||
|
||||
Important report fields:
|
||||
|
||||
- `provider_runtime`
|
||||
- `query_plan`
|
||||
- `ranked_candidates`
|
||||
- `clusters`
|
||||
- `items_by_source`
|
||||
- `errors_by_source`
|
||||
|
||||
## Usage guidance for agents
|
||||
|
||||
- Prefer `--quick` for fast iteration.
|
||||
- Prefer default mode when the user wants a balanced answer.
|
||||
- Prefer `--deep` only when the user explicitly wants maximum recall or the topic is complex enough to justify extra latency.
|
||||
- Prefer `--emit=json` when downstream code or evaluation will consume the result.
|
||||
- Use `--search=` only when the user explicitly wants source restrictions.
|
||||
|
||||
## X handle resolution
|
||||
|
||||
If the topic could have its own X/Twitter account (people, brands, products, companies), do a quick WebSearch for their handle:
|
||||
```
|
||||
WebSearch("{TOPIC} X twitter handle site:x.com")
|
||||
```
|
||||
If you find a verified handle, pass `--x-handle={handle}` (without @). This searches their posts directly, finding content they posted that doesn't mention their own name. Skip this for generic concepts ("best headphones 2026", "how to use Docker").
|
||||
|
||||
## Synthesis guidance
|
||||
|
||||
### First: synthesize, don't summarize
|
||||
|
||||
Extract key facts from the output first, then synthesize across sources. Lead with patterns that appear across multiple clusters. Present a unified narrative, not a source-by-source summary.
|
||||
|
||||
### Ground in actual research, not pre-existing knowledge
|
||||
|
||||
Use exact product/tool names, specific quotes, and what sources actually say. If research mentions "ClawdBot" and "@clawdbot", that is a different product than "Claude Code" -- read what the research actually says.
|
||||
|
||||
**Anti-pattern to avoid:**
|
||||
- BAD: User asks "best Claude Code skills" and you respond with generic advice: "Skills are powerful. Keep them under 500 lines."
|
||||
- GOOD: You respond with specifics from the research: "Most mentioned: /commit (5 mentions), remotion skill (4x), git-worktree (3x). The Remotion announcement got 16K likes on X per @thedorbrothers."
|
||||
|
||||
### Source weighting (highest to lowest signal)
|
||||
|
||||
1. **Cross-cluster corroboration** -- same evidence across multiple sources is the strongest signal. Lead with it.
|
||||
2. **Reddit top comments** -- often the wittiest, most insightful take. Quote directly when upvotes are high.
|
||||
3. **YouTube transcript highlights** -- pre-extracted key moments. Quote and attribute to channel name.
|
||||
4. **X/Twitter @handles** -- real-time community signal. Quote with engagement context.
|
||||
5. **Polymarket odds** -- real money on outcomes cuts through opinion. Include specific odds AND movement.
|
||||
6. **TikTok/Instagram** -- viral/creator signal. Cite @creators with views/likes.
|
||||
7. **Hacker News** -- technical community perspective. Cite as "per HN."
|
||||
8. **Web (Brave/Serper)** -- cite only when social sources don't cover a fact.
|
||||
|
||||
### Polymarket interpretation
|
||||
|
||||
When Polymarket returns relevant markets:
|
||||
1. Prefer structural/long-term markets over near-term deadlines (championship odds > regular season, IPO > incremental update)
|
||||
2. Call out the specific outcome's odds and movement, not just that a market exists
|
||||
3. Weave odds into the narrative as supporting evidence, don't isolate them
|
||||
4. When multiple relevant markets exist, highlight 3-5 ordered by importance
|
||||
|
||||
Domain importance ranking:
|
||||
- **Sports:** Championship/tournament > conference title > regular season > weekly matchup
|
||||
- **Geopolitics:** Regime change/structural > near-term strike deadlines > sanctions
|
||||
- **Tech/Business:** IPO, major product launch > incremental updates
|
||||
- **Elections:** Presidency > primary > individual state
|
||||
|
||||
### Citation rules
|
||||
|
||||
Cite the single strongest source per point in short format: "per @handle" or "per r/subreddit". Save engagement metrics for the stats section. Use the priority order from source weighting above. The tool's value is surfacing what PEOPLE are saying, not what journalists wrote.
|
||||
|
||||
### Comparison queries
|
||||
|
||||
For "X vs Y" queries, structure output as:
|
||||
|
||||
```
|
||||
## Quick Verdict
|
||||
[1-2 sentences: which one the community prefers and why, with source counts]
|
||||
|
||||
## [Entity A]
|
||||
**Community Sentiment:** [Positive/Mixed/Negative] (N mentions across sources)
|
||||
**Strengths:** [with source attribution]
|
||||
**Weaknesses:** [with source attribution]
|
||||
|
||||
## [Entity B]
|
||||
[Same structure]
|
||||
|
||||
## Head-to-Head
|
||||
| Dimension | Entity A | Entity B |
|
||||
|-----------|----------|----------|
|
||||
| [Key dim] | [position] | [position] |
|
||||
|
||||
## Bottom Line
|
||||
Choose A if... Choose B if... (based on community data)
|
||||
```
|
||||
|
||||
### Recommendation queries
|
||||
|
||||
When users ask "best X" or "top X", extract SPECIFIC NAMES:
|
||||
|
||||
```
|
||||
Most mentioned:
|
||||
[Name] -- Nx mentions
|
||||
Sources: @handle1, r/subreddit, [YouTube channel]
|
||||
|
||||
[Name] -- Nx mentions
|
||||
Sources: @handle2, r/subreddit2
|
||||
|
||||
Notable mentions: [others with 1-2 mentions]
|
||||
```
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **Empty results from a source:** State what is missing. ("No Reddit discussion found for this topic.") Do not fill the gap with training data.
|
||||
- **Sources contradict each other:** Present both sides with attribution. ("Reddit r/fitness is bullish on X, while @DrExpert on X warns about Y.")
|
||||
- **All results are low-engagement or off-topic:** Acknowledge uncertainty. ("Limited recent discussion found -- these findings should be treated as preliminary.")
|
||||
|
||||
### Follow-up conversations
|
||||
|
||||
After research completes, treat yourself as an expert on this topic. Answer follow-ups from the research findings. Cite the specific threads, posts, and channels you found. Only run new research if the user asks about a DIFFERENT topic.
|
||||
|
||||
## Security and permissions
|
||||
|
||||
**What this skill does:**
|
||||
- Sends search queries to ScrapeCreators API for Reddit, TikTok, Instagram search
|
||||
- Sends search queries via xAI API or Bird client for X search
|
||||
- Sends search queries to Algolia HN Search API (free, no auth)
|
||||
- Sends search queries to Polymarket Gamma API (free, no auth)
|
||||
- Runs yt-dlp locally for YouTube search and transcript extraction (no API key)
|
||||
- Sends search queries to Brave Search API or Serper for web search (optional)
|
||||
- Uses Gemini, OpenAI, or xAI for LLM planning and reranking
|
||||
- Stores findings in local SQLite database (--store mode only)
|
||||
|
||||
**What this skill does NOT do:**
|
||||
- Does not post, like, or modify content on any platform
|
||||
- Does not access your personal accounts on any platform
|
||||
- Does not share API keys between providers
|
||||
- Does not log or cache API keys in output files
|
||||
+27
-1
@@ -175,7 +175,7 @@ class TestVendoredBirdRuntime(unittest.TestCase):
|
||||
}
|
||||
]
|
||||
items = parse_bird_response(tweets, "test query")
|
||||
self.assertIsNone(items[0]["engagement"]["likes"])
|
||||
self.assertIsNone(items[0]["engagement"])
|
||||
|
||||
def test_fallback_to_second_key(self):
|
||||
tweets = [
|
||||
@@ -203,6 +203,32 @@ class TestVendoredBirdRuntime(unittest.TestCase):
|
||||
items = parse_bird_response(tweets, "test query")
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -77,6 +77,13 @@ class CliV3Tests(unittest.TestCase):
|
||||
with self.assertRaises(SystemExit):
|
||||
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):
|
||||
stderr = io.StringIO()
|
||||
with redirect_stderr(stderr):
|
||||
@@ -128,6 +135,14 @@ class CliV3Tests(unittest.TestCase):
|
||||
payload = json.loads(path.read_text())
|
||||
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):
|
||||
report = self.make_report()
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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"] == ""
|
||||
@@ -56,6 +56,22 @@ class TestParseDate(unittest.TestCase):
|
||||
def test_empty(self):
|
||||
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):
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
|
||||
@@ -41,3 +41,66 @@ class Test429RetryLimit(unittest.TestCase):
|
||||
http.request("GET", "http://example.com", retries=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)
|
||||
|
||||
@@ -49,6 +49,165 @@ class NormalizeV3Tests(unittest.TestCase):
|
||||
)
|
||||
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):
|
||||
items = [
|
||||
{
|
||||
|
||||
@@ -28,6 +28,30 @@ class PipelineV3Tests(unittest.TestCase):
|
||||
self.assertIn("grounding", report.items_by_source)
|
||||
self.assertEqual("gemini", report.provider_runtime.reasoning_provider)
|
||||
|
||||
def test_planner_trace_always_fires_on_mock_run(self):
|
||||
"""Unit 5: The unified planner trace emits one summary line plus one
|
||||
line per subquery on every run, regardless of --debug. 2026-04-19
|
||||
Hermes Agent Use Cases failure: retrieval-breadth issues were invisible
|
||||
because the internal planner path logged nothing.
|
||||
"""
|
||||
import io
|
||||
import contextlib
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stderr(buf):
|
||||
pipeline.run(
|
||||
topic="test topic",
|
||||
config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
|
||||
depth="quick",
|
||||
requested_sources=["reddit", "x", "grounding"],
|
||||
mock=True,
|
||||
)
|
||||
output = buf.getvalue()
|
||||
self.assertIn("[Planner] Plan: intent=", output)
|
||||
self.assertIn("subqueries=", output)
|
||||
self.assertIn("source=", output)
|
||||
# At least one per-subquery line.
|
||||
self.assertIn("[Planner] sq1 label=", output)
|
||||
|
||||
|
||||
class TestSourceFetchCap(unittest.TestCase):
|
||||
"""X source fetch count must be capped by MAX_SOURCE_FETCHES."""
|
||||
|
||||
@@ -281,5 +281,177 @@ class PlannerV3Tests(unittest.TestCase):
|
||||
self.assertIn("instagram", all_sources)
|
||||
|
||||
|
||||
class IntentModifierBreadthTests(unittest.TestCase):
|
||||
"""Unit 2: Topics with intent modifiers (use cases, workflows, examples,
|
||||
review, comparison) must fan out across paraphrased subqueries rather
|
||||
than echo the literal phrase. 2026-04-19 Hermes Agent Use Cases failure.
|
||||
"""
|
||||
|
||||
def test_max_subqueries_raised_to_5_for_how_to(self):
|
||||
self.assertEqual(5, planner._max_subqueries("how_to"))
|
||||
|
||||
def test_max_subqueries_raised_to_5_for_opinion(self):
|
||||
self.assertEqual(5, planner._max_subqueries("opinion"))
|
||||
|
||||
def test_max_subqueries_raised_to_5_for_product(self):
|
||||
self.assertEqual(5, planner._max_subqueries("product"))
|
||||
|
||||
def test_max_subqueries_unchanged_for_comparison(self):
|
||||
self.assertEqual(4, planner._max_subqueries("comparison"))
|
||||
|
||||
def test_max_subqueries_unchanged_for_factual_and_concept(self):
|
||||
self.assertEqual(2, planner._max_subqueries("factual"))
|
||||
self.assertEqual(2, planner._max_subqueries("concept"))
|
||||
|
||||
def test_has_intent_modifier_detects_use_cases(self):
|
||||
self.assertTrue(planner._has_intent_modifier("Hermes Agent use cases"))
|
||||
self.assertTrue(planner._has_intent_modifier("Hermes Agent Actual Use Cases"))
|
||||
|
||||
def test_has_intent_modifier_detects_workflows(self):
|
||||
self.assertTrue(planner._has_intent_modifier("Claude Code workflows"))
|
||||
|
||||
def test_has_intent_modifier_detects_review_and_tutorial(self):
|
||||
self.assertTrue(planner._has_intent_modifier("Ollama review"))
|
||||
self.assertTrue(planner._has_intent_modifier("DSPy tutorial"))
|
||||
|
||||
def test_has_intent_modifier_false_for_bare_entity(self):
|
||||
self.assertFalse(planner._has_intent_modifier("Kanye West"))
|
||||
self.assertFalse(planner._has_intent_modifier("hermes agent"))
|
||||
|
||||
def test_fallback_fans_out_when_intent_modifier_present(self):
|
||||
plan = planner.plan_query(
|
||||
topic="Hermes Agent use cases",
|
||||
available_sources=["reddit", "x", "youtube", "hackernews"],
|
||||
requested_sources=None,
|
||||
depth="default",
|
||||
provider=None,
|
||||
model=None,
|
||||
)
|
||||
# Expect at least 3 subqueries total (primary + fanout); cap is 5 for
|
||||
# how_to/opinion/product/breaking_news. Label set should include at
|
||||
# least one of the paraphrase labels.
|
||||
labels = {sq.label for sq in plan.subqueries}
|
||||
self.assertGreaterEqual(len(plan.subqueries), 3)
|
||||
self.assertTrue(
|
||||
labels & {"workflows", "production", "experience"},
|
||||
f"Expected paraphrase labels in {labels}",
|
||||
)
|
||||
|
||||
def test_fallback_does_not_fan_out_for_bare_entity(self):
|
||||
plan = planner.plan_query(
|
||||
topic="Kanye West",
|
||||
available_sources=["reddit", "x", "grounding"],
|
||||
requested_sources=None,
|
||||
depth="default",
|
||||
provider=None,
|
||||
model=None,
|
||||
)
|
||||
# Bare entity without intent modifier should not trigger the paraphrase
|
||||
# fanout (those labels are not in the plan).
|
||||
labels = {sq.label for sq in plan.subqueries}
|
||||
self.assertFalse(labels & {"workflows", "production", "experience"})
|
||||
|
||||
def test_prompt_includes_intent_modifier_rule(self):
|
||||
prompt = planner._build_prompt(
|
||||
topic="Hermes Agent use cases",
|
||||
available_sources=["reddit", "x", "youtube"],
|
||||
requested_sources=None,
|
||||
depth="default",
|
||||
)
|
||||
self.assertIn("INTENT-MODIFIER HANDLING", prompt)
|
||||
self.assertIn("use cases", prompt)
|
||||
self.assertIn("STRIP that phrase", prompt)
|
||||
|
||||
|
||||
class FallbackDefaultsTests(unittest.TestCase):
|
||||
"""Unit 3: Deterministic fallback defaults and keyword_query quoting.
|
||||
2026-04-19 Hermes Agent Use Cases failure.
|
||||
"""
|
||||
|
||||
def test_unclassified_topic_defaults_to_concept_not_breaking_news(self):
|
||||
# Prior default was "breaking_news" with strict_recent freshness,
|
||||
# which biased against older relevant material on unfamiliar topics.
|
||||
self.assertEqual("concept", planner._infer_intent("some unfamiliar topic"))
|
||||
self.assertEqual("concept", planner._infer_intent("Hermes Agent"))
|
||||
|
||||
def test_recency_signals_still_break_out_to_breaking_news(self):
|
||||
self.assertEqual("breaking_news", planner._infer_intent("trending AI tools"))
|
||||
self.assertEqual("breaking_news", planner._infer_intent("what's happening today"))
|
||||
self.assertEqual("breaking_news", planner._infer_intent("this week in AI"))
|
||||
|
||||
def test_specific_intents_still_classify_correctly(self):
|
||||
# Regression: other regex branches still fire as before.
|
||||
self.assertEqual("how_to", planner._infer_intent("how to deploy Docker"))
|
||||
self.assertEqual("factual", planner._infer_intent("who acquired Wiz"))
|
||||
self.assertEqual("opinion", planner._infer_intent("thoughts on OpenAI Codex"))
|
||||
self.assertEqual("comparison", planner._infer_intent("Codex vs Claude Code"))
|
||||
|
||||
def test_keyword_query_quotes_only_title_cased_proper_nouns(self):
|
||||
# "Hermes Agent" is a multi-word title-cased proper noun — keep quoted.
|
||||
# "Use Cases" is also title-cased BUT we only quote the first 2
|
||||
# title-cased compounds; the first extracted is "Hermes Agent".
|
||||
search = planner._keyword_query("Hermes Agent use cases", "hermes agent")
|
||||
self.assertIn('"Hermes Agent"', search)
|
||||
# The old behavior quoted the entire typed topic; confirm it does not.
|
||||
self.assertNotIn('"Hermes Agent Actual Use Cases"', search)
|
||||
|
||||
def test_keyword_query_does_not_quote_bare_lowercase_topic(self):
|
||||
search = planner._keyword_query("kanye west bully", "kanye west bully")
|
||||
# Lowercase topics have no title-cased compound to quote.
|
||||
self.assertNotIn('"', search)
|
||||
|
||||
def test_fallback_logs_warning_when_no_provider(self):
|
||||
import io
|
||||
import contextlib
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stderr(buf):
|
||||
planner.plan_query(
|
||||
topic="Hermes Agent use cases",
|
||||
available_sources=["reddit", "x"],
|
||||
requested_sources=None,
|
||||
depth="default",
|
||||
provider=None,
|
||||
model=None,
|
||||
)
|
||||
output = buf.getvalue()
|
||||
# New language: "No --plan passed" + "YOU ARE the planner" +
|
||||
# runtime enumeration. Unit 4 (2026-04-19) rewrite to stop the
|
||||
# "no provider = no LLM = I need a key" misread.
|
||||
self.assertIn("No --plan passed", output)
|
||||
self.assertIn("YOU ARE the planner", output)
|
||||
self.assertIn("you ARE the LLM", output)
|
||||
# Runtime-agnostic: each supported runtime name should appear.
|
||||
for runtime_name in ("Claude Code", "Codex", "Hermes", "Gemini"):
|
||||
self.assertIn(runtime_name, output)
|
||||
# The old misleading phrasing must NOT appear.
|
||||
self.assertNotIn("No --plan and no LLM provider configured", output)
|
||||
|
||||
def test_fallback_does_not_log_new_warning_when_provider_present(self):
|
||||
# When a provider is configured, the provider path runs; if it
|
||||
# errors, we get the "LLM planning failed" message, NOT the
|
||||
# "No --plan passed" guidance (which is specifically for the
|
||||
# no-provider-no-plan caller path).
|
||||
import io
|
||||
import contextlib
|
||||
buf = io.StringIO()
|
||||
|
||||
class _NoopProvider:
|
||||
def generate_json(self, model, prompt):
|
||||
raise ValueError("force fallback for test")
|
||||
|
||||
with contextlib.redirect_stderr(buf):
|
||||
planner.plan_query(
|
||||
topic="Kanye West",
|
||||
available_sources=["reddit", "x"],
|
||||
requested_sources=None,
|
||||
depth="default",
|
||||
provider=_NoopProvider(),
|
||||
model="some-model",
|
||||
)
|
||||
output = buf.getvalue()
|
||||
self.assertIn("LLM planning failed", output)
|
||||
self.assertNotIn("No --plan passed", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Tests for scripts/lib/preflight.py Class 1 keyword-trap refuse-gate.
|
||||
|
||||
Class 1 (demographic shopping) is the one failure class that shipped to
|
||||
public v3.0.8 and still returned junk for queries like 'birthday gift for
|
||||
40 year old'. This module is the engine's structural refusal, so the model
|
||||
cannot bypass by skipping SKILL.md.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
|
||||
from lib import preflight
|
||||
|
||||
|
||||
class TestClass1Match(unittest.TestCase):
|
||||
"""Queries that MUST trigger the refuse-gate."""
|
||||
|
||||
def test_birthday_gift_for_age(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("birthday gift for 40 year old"))
|
||||
|
||||
def test_gift_for_age(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("gift for 42 year old"))
|
||||
|
||||
def test_gift_for_age_relationship(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("gift for my 42 year old husband"))
|
||||
|
||||
def test_gift_ideas_for_age(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("gift ideas for 30 year old"))
|
||||
|
||||
def test_present_for_age(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("present for a 50 year old"))
|
||||
|
||||
def test_hyphenated_year_old(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("gift for 40-year-old"))
|
||||
|
||||
def test_best_for_men(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("best running shoes for men"))
|
||||
|
||||
def test_best_for_women(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("best gifts for women"))
|
||||
|
||||
def test_best_for_kids(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("best toys for kids"))
|
||||
|
||||
def test_what_to_buy_husband(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("what to buy my husband"))
|
||||
|
||||
def test_what_to_get_boss(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("what to get my boss"))
|
||||
|
||||
def test_what_to_gift_age(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("what to gift a 35 year old"))
|
||||
|
||||
def test_gifts_for_husband(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("gifts for my husband"))
|
||||
|
||||
def test_case_insensitive(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("Birthday Gift For 40 Year Old"))
|
||||
|
||||
def test_leading_whitespace(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap(" gift for 40 year old "))
|
||||
|
||||
|
||||
class TestClass1Skip(unittest.TestCase):
|
||||
"""Queries that MUST NOT trigger the refuse-gate (qualifier present or not shopping)."""
|
||||
|
||||
def test_named_person(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("Peter Steinberger"))
|
||||
|
||||
def test_comparison(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("OpenClaw vs Paperclip"))
|
||||
|
||||
def test_entity_query(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("Kanye West"))
|
||||
|
||||
def test_general_concept(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("vibe coding"))
|
||||
|
||||
def test_budget_qualifier(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("gift for my husband, $200 budget"))
|
||||
|
||||
def test_hobby_qualifier(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("gift for my cooking-obsessed husband"))
|
||||
|
||||
def test_loves_qualifier(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("gift for my dad who loves golf"))
|
||||
|
||||
def test_is_into_qualifier(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("gift for my brother who is into woodworking"))
|
||||
|
||||
def test_specific_interest_in_query(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("birthday gift for 40 year old runner"))
|
||||
|
||||
|
||||
class TestRefuseMessage(unittest.TestCase):
|
||||
"""The REFUSE message must contain the diagnostic content the model needs."""
|
||||
|
||||
def test_refuse_mentions_class_1(self):
|
||||
msg = preflight.check_class_1_trap("birthday gift for 40 year old")
|
||||
assert msg is not None
|
||||
self.assertIn("Class 1", msg)
|
||||
|
||||
def test_refuse_asks_for_hobbies(self):
|
||||
msg = preflight.check_class_1_trap("gift for 40 year old")
|
||||
assert msg is not None
|
||||
self.assertIn("hobbies", msg.lower())
|
||||
|
||||
def test_refuse_asks_for_relationship(self):
|
||||
msg = preflight.check_class_1_trap("gift for 40 year old")
|
||||
assert msg is not None
|
||||
self.assertIn("relationship", msg.lower())
|
||||
|
||||
def test_refuse_asks_for_budget(self):
|
||||
msg = preflight.check_class_1_trap("gift for 40 year old")
|
||||
assert msg is not None
|
||||
self.assertIn("budget", msg.lower())
|
||||
|
||||
def test_refuse_echoes_topic(self):
|
||||
msg = preflight.check_class_1_trap("birthday gift for 40 year old")
|
||||
assert msg is not None
|
||||
self.assertIn("birthday gift for 40 year old", msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+155
-2
@@ -117,8 +117,72 @@ class RenderV3Tests(unittest.TestCase):
|
||||
report.errors_by_source = {"x": "HTTP 400: Bad Request"}
|
||||
text = render.render_compact(report)
|
||||
self.assertIn("## Source Errors", text)
|
||||
self.assertIn("HTTP 400: Bad Request", text)
|
||||
self.assertIn("X:", text)
|
||||
|
||||
|
||||
class OutputEnvelopeTests(unittest.TestCase):
|
||||
"""LAW 6 envelope comments: scope "pass through verbatim" unambiguously.
|
||||
|
||||
Added 2026-04-19 after the Hermes Agent Use Cases failure where two
|
||||
consecutive runs dumped `## Ranked Evidence Clusters` as user output.
|
||||
"""
|
||||
|
||||
def test_evidence_for_synthesis_envelope_wraps_raw_evidence(self):
|
||||
text = render.render_compact(sample_report())
|
||||
self.assertIn("<!-- EVIDENCE FOR SYNTHESIS:", text)
|
||||
self.assertIn("<!-- END EVIDENCE FOR SYNTHESIS -->", text)
|
||||
# Opening comment must appear BEFORE the raw evidence block.
|
||||
self.assertLess(
|
||||
text.index("<!-- EVIDENCE FOR SYNTHESIS:"),
|
||||
text.index("## Ranked Evidence Clusters"),
|
||||
)
|
||||
# Closing comment must appear AFTER Source Coverage.
|
||||
self.assertGreater(
|
||||
text.index("<!-- END EVIDENCE FOR SYNTHESIS -->"),
|
||||
text.index("## Source Coverage"),
|
||||
)
|
||||
|
||||
def test_pass_through_footer_envelope_wraps_emoji_tree(self):
|
||||
text = render.render_compact(sample_report())
|
||||
self.assertIn("<!-- PASS-THROUGH FOOTER:", text)
|
||||
self.assertIn("<!-- END PASS-THROUGH FOOTER -->", text)
|
||||
# Emoji footer sits between the two markers.
|
||||
open_idx = text.index("<!-- PASS-THROUGH FOOTER:")
|
||||
close_idx = text.index("<!-- END PASS-THROUGH FOOTER -->")
|
||||
self.assertIn("All agents reported back!", text[open_idx:close_idx])
|
||||
|
||||
def test_canonical_boundary_scopes_pass_through_to_footer(self):
|
||||
text = render.render_compact(sample_report())
|
||||
# New boundary text scopes verbatim to the PASS-THROUGH FOOTER block,
|
||||
# not everything above.
|
||||
self.assertIn("Pass through ONLY the PASS-THROUGH FOOTER block verbatim", text)
|
||||
# Self-check string is present so the model has a concrete failure signal.
|
||||
self.assertIn("### 1.", text)
|
||||
self.assertIn("LAW 6", text)
|
||||
# The prior ambiguous phrasing is gone.
|
||||
self.assertNotIn("Pass through the lines ABOVE this boundary verbatim", text)
|
||||
|
||||
def test_envelopes_appear_in_md_emit_mode(self):
|
||||
# --emit md and --emit compact both route to render_compact, so the
|
||||
# same envelopes apply. Guard against future divergence.
|
||||
text = render.render_compact(sample_report())
|
||||
self.assertEqual(text.count("<!-- EVIDENCE FOR SYNTHESIS:"), 1)
|
||||
self.assertEqual(text.count("<!-- END EVIDENCE FOR SYNTHESIS -->"), 1)
|
||||
self.assertEqual(text.count("<!-- PASS-THROUGH FOOTER:"), 1)
|
||||
self.assertEqual(text.count("<!-- END PASS-THROUGH FOOTER -->"), 1)
|
||||
|
||||
def test_no_dangling_envelope_open_without_close(self):
|
||||
# Open/close counts must always match, even for empty clusters.
|
||||
report = sample_report()
|
||||
report.clusters = []
|
||||
text = render.render_compact(report)
|
||||
self.assertEqual(
|
||||
text.count("<!-- EVIDENCE FOR SYNTHESIS:"),
|
||||
text.count("<!-- END EVIDENCE FOR SYNTHESIS -->"),
|
||||
)
|
||||
self.assertEqual(
|
||||
text.count("<!-- PASS-THROUGH FOOTER:"),
|
||||
text.count("<!-- END PASS-THROUGH FOOTER -->"),
|
||||
)
|
||||
|
||||
|
||||
class RenderTopCommentsTests(unittest.TestCase):
|
||||
@@ -242,6 +306,34 @@ class RenderTopCommentsTests(unittest.TestCase):
|
||||
self.assertNotIn("Comment (", 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):
|
||||
"""Tests for Best Takes section in compact output and fun tags on candidates."""
|
||||
@@ -370,5 +462,66 @@ class RenderBestTakesCompactTests(unittest.TestCase):
|
||||
self.assertNotIn("## Best Takes", text)
|
||||
|
||||
|
||||
class DegradedRunBannerTests(unittest.TestCase):
|
||||
"""Unit 1: DEGRADED RUN WARNING surfaces bare named-entity invocations
|
||||
in user-visible stdout. LAW 7 backstop. 2026-04-19 Hermes Agent Use
|
||||
Cases Run 1 failure mode.
|
||||
"""
|
||||
|
||||
def _bare_named_entity_report(self) -> schema.Report:
|
||||
report = sample_report()
|
||||
report.topic = "Hermes Agent"
|
||||
report.artifacts["plan_source"] = "deterministic"
|
||||
report.artifacts["pre_research_flags_present"] = False
|
||||
return report
|
||||
|
||||
def test_banner_appears_on_bare_named_entity_deterministic_run(self):
|
||||
text = render.render_compact(self._bare_named_entity_report())
|
||||
self.assertIn("## DEGRADED RUN WARNING", text)
|
||||
self.assertIn("<!-- USER-VISIBLE BANNER:", text)
|
||||
self.assertIn("<!-- END USER-VISIBLE BANNER -->", text)
|
||||
self.assertIn("YOU ARE", text)
|
||||
# Runtime-agnostic enumeration: all host runtimes appear.
|
||||
for runtime_name in ("Claude Code", "Codex", "Hermes", "Gemini"):
|
||||
self.assertIn(runtime_name, text)
|
||||
|
||||
def test_banner_positioned_before_evidence_envelope(self):
|
||||
text = render.render_compact(self._bare_named_entity_report())
|
||||
banner_idx = text.index("## DEGRADED RUN WARNING")
|
||||
envelope_idx = text.index("<!-- EVIDENCE FOR SYNTHESIS:")
|
||||
self.assertLess(banner_idx, envelope_idx,
|
||||
"DEGRADED RUN banner must appear BEFORE evidence envelope so pass-through catches it.")
|
||||
|
||||
def test_banner_suppressed_when_plan_source_external(self):
|
||||
report = self._bare_named_entity_report()
|
||||
report.artifacts["plan_source"] = "external"
|
||||
text = render.render_compact(report)
|
||||
self.assertNotIn("## DEGRADED RUN WARNING", text)
|
||||
|
||||
def test_banner_suppressed_when_plan_source_llm(self):
|
||||
report = self._bare_named_entity_report()
|
||||
report.artifacts["plan_source"] = "llm"
|
||||
text = render.render_compact(report)
|
||||
self.assertNotIn("## DEGRADED RUN WARNING", text)
|
||||
|
||||
def test_banner_suppressed_when_pre_research_flags_present(self):
|
||||
report = self._bare_named_entity_report()
|
||||
report.artifacts["pre_research_flags_present"] = True
|
||||
text = render.render_compact(report)
|
||||
self.assertNotIn("## DEGRADED RUN WARNING", text)
|
||||
|
||||
def test_banner_suppressed_on_non_eligible_abstract_topic(self):
|
||||
report = self._bare_named_entity_report()
|
||||
# Multi-word lowercase abstract phrase is NOT pre-research-eligible.
|
||||
report.topic = "how to deploy containers in the cloud"
|
||||
text = render.render_compact(report)
|
||||
self.assertNotIn("## DEGRADED RUN WARNING", text)
|
||||
|
||||
def test_banner_mentions_law_7_and_plan_flag(self):
|
||||
text = render.render_compact(self._bare_named_entity_report())
|
||||
self.assertIn("LAW 7", text)
|
||||
self.assertIn("--plan", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+203
-1
@@ -178,9 +178,211 @@ class RerankV3Tests(unittest.TestCase):
|
||||
self.assertEqual("gemini-3.1-flash-lite-preview", provider.model)
|
||||
self.assertEqual(95.0, first.rerank_score)
|
||||
self.assertEqual("high fit", first.explanation)
|
||||
self.assertEqual("fallback-local-score", second.explanation)
|
||||
# Tail is scored via the fallback (may or may not carry the entity-miss
|
||||
# suffix depending on topic-title overlap; assert the base tag is present).
|
||||
self.assertIn("fallback-local-score", second.explanation or "")
|
||||
self.assertEqual(first.candidate_id, ranked[0].candidate_id)
|
||||
|
||||
|
||||
class EntityGroundingTests(unittest.TestCase):
|
||||
"""Unit 4: Reranker entity-grounding demotion. 2026-04-19 Hermes Agent
|
||||
Use Cases failure: an off-topic video about Claude Managed Agents
|
||||
scored 51 and ranked #2 with zero Hermes content.
|
||||
"""
|
||||
|
||||
def _candidate(self, title: str, snippet: str = "") -> schema.Candidate:
|
||||
return schema.Candidate(
|
||||
candidate_id=f"c-{title[:10]}",
|
||||
item_id="i1",
|
||||
source="youtube",
|
||||
title=title,
|
||||
url="https://example.com",
|
||||
snippet=snippet,
|
||||
subquery_labels=["primary"],
|
||||
native_ranks={"primary:youtube": 1},
|
||||
local_relevance=0.8,
|
||||
freshness=80,
|
||||
engagement=50,
|
||||
source_quality=0.7,
|
||||
rrf_score=0.02,
|
||||
)
|
||||
|
||||
def test_primary_entity_strips_intent_modifier(self):
|
||||
self.assertEqual("Hermes Agent", rerank._primary_entity("Hermes Agent use cases"))
|
||||
self.assertEqual("Hermes Agent Actual", rerank._primary_entity("Hermes Agent Actual Use Cases"))
|
||||
self.assertEqual("Claude Code", rerank._primary_entity("Claude Code workflows"))
|
||||
self.assertEqual("DSPy", rerank._primary_entity("DSPy tutorial"))
|
||||
|
||||
def test_primary_entity_leaves_bare_entity_unchanged(self):
|
||||
self.assertEqual("Kanye West", rerank._primary_entity("Kanye West"))
|
||||
self.assertEqual("Nous Research", rerank._primary_entity("Nous Research"))
|
||||
|
||||
def test_fallback_demotes_candidate_without_primary_entity(self):
|
||||
on_topic = self._candidate("Hermes Agent: Self-Improving AI", "Nous Research Hermes walkthrough")
|
||||
off_topic = self._candidate("I Tested Claude's Managed Agents", "What you need to know about Anthropic's new managed agents")
|
||||
rerank._apply_fallback_scores([on_topic, off_topic], primary_entity="Hermes Agent")
|
||||
self.assertGreater(on_topic.final_score, off_topic.final_score)
|
||||
self.assertIn("entity-miss", off_topic.explanation or "")
|
||||
self.assertEqual(on_topic.explanation, "fallback-local-score")
|
||||
|
||||
def test_fallback_match_is_case_insensitive(self):
|
||||
on_topic = self._candidate("HERMES agent rocks", "some text")
|
||||
rerank._apply_fallback_scores([on_topic], primary_entity="Hermes Agent")
|
||||
self.assertEqual("fallback-local-score", on_topic.explanation)
|
||||
|
||||
def test_fallback_skips_demotion_for_empty_text_candidates(self):
|
||||
empty = self._candidate("", "")
|
||||
rerank._apply_fallback_scores([empty], primary_entity="Hermes Agent")
|
||||
self.assertEqual("fallback-local-score", empty.explanation)
|
||||
|
||||
def test_fallback_skips_demotion_when_no_primary_entity(self):
|
||||
off = self._candidate("Completely unrelated", "snippet")
|
||||
rerank._apply_fallback_scores([off], primary_entity="")
|
||||
self.assertEqual("fallback-local-score", off.explanation)
|
||||
|
||||
def test_llm_prompt_includes_primary_entity_grounding_hint(self):
|
||||
candidate = self._candidate("Something", "snippet text")
|
||||
plan = make_plan()
|
||||
prompt = rerank._build_prompt(
|
||||
"Hermes Agent use cases", plan, [candidate], primary_entity="Hermes Agent"
|
||||
)
|
||||
self.assertIn("Primary entity grounding", prompt)
|
||||
self.assertIn("Hermes Agent", prompt)
|
||||
|
||||
def test_llm_prompt_omits_grounding_hint_when_no_primary_entity(self):
|
||||
candidate = self._candidate("Something", "snippet text")
|
||||
plan = make_plan()
|
||||
prompt = rerank._build_prompt("", plan, [candidate], primary_entity="")
|
||||
self.assertNotIn("Primary entity grounding", prompt)
|
||||
|
||||
|
||||
class ExpandedHaystackTests(unittest.TestCase):
|
||||
"""Unit 3: Entity-grounding haystack covers transcript snippets,
|
||||
transcript highlights, top comments, and comment insights - not
|
||||
just title + snippet.
|
||||
"""
|
||||
|
||||
def _youtube_candidate(self, title: str, transcript_snippet: str = "",
|
||||
transcript_highlights: list[str] | None = None) -> schema.Candidate:
|
||||
c = schema.Candidate(
|
||||
candidate_id=f"c-{title[:10]}",
|
||||
item_id="i1",
|
||||
source="youtube",
|
||||
title=title,
|
||||
url="https://youtube.com/watch?v=x",
|
||||
snippet="",
|
||||
subquery_labels=["primary"],
|
||||
native_ranks={"primary:youtube": 1},
|
||||
local_relevance=0.8,
|
||||
freshness=80,
|
||||
engagement=50,
|
||||
source_quality=0.7,
|
||||
rrf_score=0.02,
|
||||
)
|
||||
c.metadata = {}
|
||||
if transcript_snippet:
|
||||
c.metadata["transcript_snippet"] = transcript_snippet
|
||||
if transcript_highlights:
|
||||
c.metadata["transcript_highlights"] = transcript_highlights
|
||||
return c
|
||||
|
||||
def test_entity_found_in_transcript_snippet_avoids_demotion(self):
|
||||
# Title + snippet miss the entity, but the transcript contains it.
|
||||
c = self._youtube_candidate(
|
||||
"Weekly roundup",
|
||||
transcript_snippet="In this video I walk through using Hermes Agent in production.",
|
||||
)
|
||||
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
|
||||
self.assertEqual("fallback-local-score", c.explanation)
|
||||
|
||||
def test_entity_found_in_transcript_highlights_avoids_demotion(self):
|
||||
c = self._youtube_candidate(
|
||||
"Some review",
|
||||
transcript_highlights=[
|
||||
"Today we're talking about Hermes Agent",
|
||||
"Let's compare it to the alternatives",
|
||||
],
|
||||
)
|
||||
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
|
||||
self.assertEqual("fallback-local-score", c.explanation)
|
||||
|
||||
def test_entity_missing_everywhere_still_demoted_for_video(self):
|
||||
# Nate Herk "Managed Agents" case: no Hermes in title, snippet,
|
||||
# or transcript - demotion fires.
|
||||
c = self._youtube_candidate(
|
||||
"I Tested Claude's New Managed Agents",
|
||||
transcript_snippet="Managed agents are Anthropic's new product with ClickUp and cron...",
|
||||
)
|
||||
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
|
||||
self.assertIn("entity-miss", c.explanation)
|
||||
|
||||
def test_entity_found_in_reddit_top_comments_avoids_demotion(self):
|
||||
c = schema.Candidate(
|
||||
candidate_id="r1",
|
||||
item_id="i1",
|
||||
source="reddit",
|
||||
title="Best agent framework?",
|
||||
url="https://reddit.com/r/x",
|
||||
snippet="",
|
||||
subquery_labels=["primary"],
|
||||
native_ranks={"primary:reddit": 1},
|
||||
local_relevance=0.8, freshness=80, engagement=50,
|
||||
source_quality=0.7, rrf_score=0.02,
|
||||
)
|
||||
c.metadata = {
|
||||
"top_comments": [
|
||||
{"excerpt": "I've been using Hermes Agent for a month and it's great"},
|
||||
{"text": "another comment"},
|
||||
],
|
||||
}
|
||||
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
|
||||
self.assertEqual("fallback-local-score", c.explanation)
|
||||
|
||||
def test_entity_found_in_comment_insights_avoids_demotion(self):
|
||||
c = schema.Candidate(
|
||||
candidate_id="r2", item_id="i1", source="reddit",
|
||||
title="AI tools", url="https://reddit.com/r/x", snippet="",
|
||||
subquery_labels=["primary"],
|
||||
native_ranks={"primary:reddit": 1},
|
||||
local_relevance=0.8, freshness=80, engagement=50,
|
||||
source_quality=0.7, rrf_score=0.02,
|
||||
)
|
||||
c.metadata = {
|
||||
"comment_insights": ["Consensus: Hermes Agent handles long sessions best"],
|
||||
}
|
||||
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
|
||||
self.assertEqual("fallback-local-score", c.explanation)
|
||||
|
||||
def test_truly_empty_candidate_still_skipped(self):
|
||||
# Image-only TikTok with no text anywhere - do not penalize.
|
||||
c = self._youtube_candidate("") # empty title
|
||||
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
|
||||
self.assertEqual("fallback-local-score", c.explanation)
|
||||
|
||||
def test_final_score_secondary_penalty_applied_on_entity_miss(self):
|
||||
# When fallback flags entity-miss, final_score gets an ADDITIONAL
|
||||
# -20 penalty beyond the rerank_score reduction. Verify by
|
||||
# comparing final_score for a demoted candidate vs an identical
|
||||
# candidate that matched the entity.
|
||||
off_topic = self._youtube_candidate("Managed Agents from Anthropic")
|
||||
on_topic = self._youtube_candidate(
|
||||
"Hermes Agent walkthrough",
|
||||
transcript_snippet="Hermes Agent review",
|
||||
)
|
||||
rerank._apply_fallback_scores([off_topic, on_topic], primary_entity="Hermes Agent")
|
||||
# Gap should be well above the rerank_score-only path's 0.60 * 25 = 15;
|
||||
# with the secondary penalty it's 15 + 20 = 35 points.
|
||||
gap = on_topic.final_score - off_topic.final_score
|
||||
self.assertGreater(gap, 25.0,
|
||||
f"entity-miss demotion gap only {gap:.1f}; secondary penalty may not be firing")
|
||||
|
||||
def test_secondary_penalty_not_applied_when_entity_match(self):
|
||||
on_topic = self._youtube_candidate("Hermes Agent: use cases")
|
||||
rerank._apply_fallback_scores([on_topic], primary_entity="Hermes Agent")
|
||||
# Explanation does NOT contain entity-miss, so secondary penalty
|
||||
# should not fire; final_score reflects only base signal.
|
||||
self.assertNotIn("entity-miss", on_topic.explanation or "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+102
-9
@@ -28,6 +28,98 @@ class SignalsV3Tests(unittest.TestCase):
|
||||
)
|
||||
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):
|
||||
item = schema.SourceItem(
|
||||
item_id="pm1",
|
||||
@@ -221,7 +313,8 @@ class SignalsV3Tests(unittest.TestCase):
|
||||
self.assertAlmostEqual(expected, result)
|
||||
|
||||
def test_youtube_engagement_dominant_weight(self):
|
||||
"""YouTube: views at 0.50 should dominate over comments at 0.15."""
|
||||
"""YouTube: views at 0.45 should dominate. With no top-comment data,
|
||||
the remaining 0.90 of weight is split views/likes/comments 0.45/0.32/0.13."""
|
||||
item = schema.SourceItem(
|
||||
item_id="yt1", source="youtube", title="T", body="B",
|
||||
url="https://example.com",
|
||||
@@ -230,9 +323,9 @@ class SignalsV3Tests(unittest.TestCase):
|
||||
result = signals.engagement_raw(item)
|
||||
self.assertIsNotNone(result)
|
||||
expected = (
|
||||
0.50 * math.log1p(10000)
|
||||
+ 0.35 * math.log1p(500)
|
||||
+ 0.15 * math.log1p(80)
|
||||
0.45 * math.log1p(10000)
|
||||
+ 0.32 * math.log1p(500)
|
||||
+ 0.13 * math.log1p(80)
|
||||
)
|
||||
self.assertAlmostEqual(expected, result)
|
||||
|
||||
@@ -252,7 +345,7 @@ class SignalsV3Tests(unittest.TestCase):
|
||||
)
|
||||
result = signals.engagement_raw(item)
|
||||
self.assertIsNotNone(result)
|
||||
expected = 0.50 * math.log1p(5000)
|
||||
expected = 0.45 * math.log1p(5000)
|
||||
self.assertAlmostEqual(expected, result)
|
||||
|
||||
def test_tiktok_engagement_dominant_weight(self):
|
||||
@@ -264,9 +357,9 @@ class SignalsV3Tests(unittest.TestCase):
|
||||
result = signals.engagement_raw(item)
|
||||
self.assertIsNotNone(result)
|
||||
expected = (
|
||||
0.50 * math.log1p(50000)
|
||||
+ 0.30 * math.log1p(3000)
|
||||
+ 0.20 * math.log1p(200)
|
||||
0.45 * math.log1p(50000)
|
||||
+ 0.27 * math.log1p(3000)
|
||||
+ 0.18 * math.log1p(200)
|
||||
)
|
||||
self.assertAlmostEqual(expected, result)
|
||||
|
||||
@@ -286,7 +379,7 @@ class SignalsV3Tests(unittest.TestCase):
|
||||
)
|
||||
result = signals.engagement_raw(item)
|
||||
self.assertIsNotNone(result)
|
||||
expected = 0.30 * math.log1p(1000)
|
||||
expected = 0.27 * math.log1p(1000)
|
||||
self.assertAlmostEqual(expected, result)
|
||||
|
||||
def test_instagram_engagement_dominant_weight(self):
|
||||
|
||||
@@ -105,5 +105,112 @@ class TestExpandTikTokQueries(unittest.TestCase):
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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()
|
||||
Vendored
-176
@@ -1,176 +0,0 @@
|
||||
# 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`.
|
||||
Vendored
-21
@@ -1,21 +0,0 @@
|
||||
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.
|
||||
Vendored
-385
@@ -1,385 +0,0 @@
|
||||
# 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/Twitter’s **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 X’s 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 X’s 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`).
|
||||
Vendored
-12
@@ -1,12 +0,0 @@
|
||||
#!/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
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;;;GAQG"}
|
||||
Vendored
-29
@@ -1,29 +0,0 @@
|
||||
#!/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
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
{"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
@@ -1,35 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
{"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
@@ -1,43 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
Vendored
-113
@@ -1,113 +0,0 @@
|
||||
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
|
||||
-1
File diff suppressed because one or more lines are too long
Vendored
-77
@@ -1,77 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
Vendored
-327
@@ -1,327 +0,0 @@
|
||||
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
|
||||
-1
File diff suppressed because one or more lines are too long
-4
@@ -1,4 +0,0 @@
|
||||
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 +0,0 @@
|
||||
{"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
@@ -1,189 +0,0 @@
|
||||
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
@@ -1,4 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
{"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
@@ -1,43 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
{"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
@@ -1,4 +0,0 @@
|
||||
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 +0,0 @@
|
||||
{"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
@@ -1,91 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
{"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
@@ -1,4 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
Vendored
-19
@@ -1,19 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
{"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
@@ -1,4 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
Vendored
-43
@@ -1,43 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
{"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
@@ -1,4 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
{"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
@@ -1,125 +0,0 @@
|
||||
// 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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user