Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5b03adffc | |||
| 38a1c27e2e | |||
| ed80797564 | |||
| 68c3420f9f | |||
| 12167ee19e | |||
| 21b8e5c6d3 | |||
| 1157ea8afe | |||
| 9f3be8bbda | |||
| beb54e9e9d | |||
| 8d8ca68781 | |||
| 0949b870e0 | |||
| 4b07ba02a6 | |||
| 039fc89874 | |||
| 2b506e90f5 | |||
| deb9f33437 | |||
| cb88bd2eed | |||
| 23fc6c7061 | |||
| 9dd3f21476 | |||
| e395c1d57f | |||
| 33502d2a07 | |||
| bdc71cfd07 | |||
| d3972a6523 | |||
| 65fcf6be65 | |||
| 9ef9d38b90 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "last30days",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
|
||||
# Second SKILL.md files would confuse claude.ai's uploader
|
||||
# (skills/last30days/ is an internal spec; skills/last30days-nux/ is a symlink)
|
||||
skills/ export-ignore
|
||||
|
||||
# Historical + repo-only manifests
|
||||
SKILL-original.md export-ignore
|
||||
SPEC.md export-ignore
|
||||
TASKS.md export-ignore
|
||||
test-run.log export-ignore
|
||||
CONTRIBUTORS.md export-ignore
|
||||
HERMES_SETUP.md export-ignore
|
||||
release-notes.md export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
uv.lock export-ignore
|
||||
|
||||
# Platform adapters - skill-upload path is platform-agnostic
|
||||
.agents/ export-ignore
|
||||
.codex-plugin/ export-ignore
|
||||
.hermes-plugin/ export-ignore
|
||||
.claude-plugin/ export-ignore
|
||||
|
||||
# CI workflows - repo-only, not needed at skill runtime
|
||||
.github/ export-ignore
|
||||
|
||||
# Build config itself
|
||||
.clawhubignore export-ignore
|
||||
.gitignore export-ignore
|
||||
.gitattributes export-ignore
|
||||
@@ -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
|
||||
@@ -19,3 +19,9 @@ mise.toml
|
||||
.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/
|
||||
|
||||
@@ -5,6 +5,24 @@ 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.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
|
||||
|
||||
@@ -141,47 +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
|
||||
```
|
||||
|
||||
### Gemini CLI
|
||||
|
||||
Gemini CLI supports installing extensions from GitHub repositories, but as of Gemini CLI v0.9.0 there is an upstream installer bug that can fail with:
|
||||
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:
|
||||
|
||||
`Configuration file not found at /tmp/gemini-extensionXXXXXX/gemini-extension.json`
|
||||
```bash
|
||||
git clone https://github.com/mvanhorn/last30days-skill
|
||||
gemini extensions install ./last30days-skill
|
||||
```
|
||||
|
||||
even when `gemini-extension.json` exists at the repo root.
|
||||
### Manual (developer)
|
||||
|
||||
Upstream bug:
|
||||
- https://github.com/google-gemini/gemini-cli/issues/11452
|
||||
|
||||
Workarounds:
|
||||
1) Clone locally, then install from the local path
|
||||
```bash
|
||||
git clone https://github.com/mvanhorn/last30days-skill
|
||||
gemini extensions install ./last30days-skill
|
||||
```
|
||||
2) If GitHub install fails, use the OpenClaw or Claude Code install paths above.
|
||||
|
||||
### Manual
|
||||
```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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
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 AI video tools, last30days best noise cancelling headphones'
|
||||
version: "3.0.1"
|
||||
description: "Research what people actually say about any topic in the last 30 days. Pulls posts and engagement from Reddit, X, YouTube, TikTok, Hacker News, Polymarket, GitHub, and the web."
|
||||
argument-hint: 'last30days nvidia earnings reaction | last30days AI video tools | last30days what users want in react'
|
||||
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
|
||||
homepage: https://github.com/mvanhorn/last30days-skill
|
||||
repository: https://github.com/mvanhorn/last30days-skill
|
||||
@@ -59,7 +59,7 @@ metadata:
|
||||
- clawhub
|
||||
---
|
||||
|
||||
# last30days v3.0.0: Research Any Topic from the Last 30 Days
|
||||
# last30days v3.0.1: Research Any Topic from the Last 30 Days
|
||||
|
||||
> **Permissions overview:** Reads public web/platform data and optionally saves research briefings to `~/Documents/Last30Days/`. X/Twitter search uses optional user-provided tokens (AUTH_TOKEN/CT0 env vars). Bluesky search uses optional app password (BSKY_HANDLE/BSKY_APP_PASSWORD env vars - create at bsky.app/settings/app-passwords). All credential usage and data writes are documented in the [Security & Permissions](#security--permissions) section.
|
||||
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
---
|
||||
title: Fix skill upload 200-file limit + packaging hygiene (public repo)
|
||||
type: fix
|
||||
status: completed
|
||||
date: 2026-04-14
|
||||
deepened: 2026-04-14
|
||||
---
|
||||
|
||||
# Fix skill upload 200-file limit + packaging hygiene (public repo)
|
||||
|
||||
## Overview
|
||||
|
||||
claude.ai's "Upload skill" UI rejects zips with more than 200 files. Zipping the public `mvanhorn/last30days-skill` repo produces 406 files, so the upload fails outright (evidence: Trevin's 2026-04-14 report). Root cause is an accidentally committed npm package under `vendor/` (215 files of dead weight from PR #48) plus the absence of a user-facing packaging path that matches Anthropic's canonical `.skill` format.
|
||||
|
||||
Goal: let any user produce a compliant `last30days.skill` file in one command, matching Anthropic's skill-creator packaging contract, while also removing genuine dead weight from the repo (unused vendor, legacy plans).
|
||||
|
||||
## Problem Frame
|
||||
|
||||
- Trevin tried to upload the public repo as a Claude Skill and hit the 200-file limit
|
||||
- 215 of 406 files are `vendor/package/` - an extracted `steipete-bird-0.8.0.tgz` that no code imports
|
||||
- The real runtime X client lives at `scripts/lib/vendor/bird-search/` (15 files, referenced by `scripts/lib/bird_x.py:5` and `tests/test_bird_x.py:133`)
|
||||
- `.clawhubignore` is ClawHub-specific and does not affect a hand-rolled zip upload
|
||||
- Users have no documented path to produce a compliant upload zip
|
||||
- Legacy top-level `plans/` folder holds pre-`docs/plans/` planning artifacts (confirmed waste by Matt, 2026-04-14 chat)
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
- R1. After this plan lands, the produced upload zip is =200 files
|
||||
- R2. The X/bird-search runtime still works - no regression in `tests/test_bird_x.py`
|
||||
- R3. A contributor following README instructions can produce a Claude-Skill-upload-compatible `.skill` file in one command
|
||||
- R4. Re-introduction of a root `vendor/` directory is prevented via `.gitignore`
|
||||
- R5. No runtime behavior changes for existing skill consumers (Claude Code plugin, ClawHub, Gemini)
|
||||
- R6. Produced zip matches Anthropic's canonical skill-folder layout: top-level directory named exactly `last30days` containing `SKILL.md` at its root, with YAML frontmatter `name: last30days`
|
||||
- R7. Root `SKILL.md` frontmatter passes Anthropic's documented limits: `name` =64 chars (currently 10), `description` =200 chars (currently 228, needs trimming)
|
||||
- R8. Produced zip contains exactly one `SKILL.md` (at `last30days/SKILL.md`) - no conflicting second skill spec, no symlinks that the uploader may reject or break
|
||||
- R9. No runtime import reaches an excluded path (proven by import-graph audit, not just asserted)
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
Non-goals:
|
||||
- Not touching the private repo or ClawHub publish flow (those have their own strip script)
|
||||
- Not resolving the adjacent open issues (#239 plugin loader path-escape, #236 OpenClaw paths, #231 security scan, #190 version drift, #184 Gemini install) - each deserves its own plan
|
||||
- Not redesigning the skill into self-contained subfolders or splitting scripts into a separate package
|
||||
- Not adding CI enforcement of the 200-file cap (possible follow-up)
|
||||
|
||||
## Context and Research
|
||||
|
||||
### Anthropic's canonical skill-upload contract
|
||||
|
||||
Sourced from Anthropic's skill-creator repo (`anthropics/skills/skills/skill-creator/scripts/package_skill.py`) and help-center docs:
|
||||
|
||||
1. **Output format:** a `.skill` file, which is a standard zip with the `.skill` extension.
|
||||
2. **Top-level entry in the zip must be a single directory** whose name matches `name:` in the skill's YAML frontmatter. Anthropic's packager uses `arcname = file_path.relative_to(skill_path.parent)`, so the zip always contains `<skill_name>/...`.
|
||||
3. **That directory must contain `SKILL.md`** at its root (the packager explicitly validates this).
|
||||
4. **Required YAML frontmatter:** `name` (=64 chars, lowercase + hyphens) and `description` (=200 chars). Our root SKILL.md already satisfies both.
|
||||
5. **Canonical exclusions** applied by Anthropic's packager:
|
||||
- Directories: `__pycache__`, `node_modules`
|
||||
- Root-only: `evals/`
|
||||
- File globs: `*.pyc`
|
||||
- Files: `.DS_Store`
|
||||
6. **Empirical limit:** the upload UI rejects =200 files (screenshot 2026-04-14). Not documented, but confirmed.
|
||||
7. **Per-file size cap** is not publicly documented; general claude.ai uploads cap at 30MB per file. Conservative target: keep any single file under 10MB.
|
||||
|
||||
### Relevant code and patterns in this repo
|
||||
|
||||
- `SKILL.md` (root, 1382 lines, 80KB) - `name: last30days`, `user-invocable: true`. This is the skill.
|
||||
- `skills/last30days/SKILL.md` (230 lines) - `name: last30days-v3-spec`, `user-invocable: false`. Internal architecture spec, separate skill name - not the upload target.
|
||||
- `vendor/package/` - accidental commit from PR #48, 215 files, zero importers.
|
||||
- `vendor/steipete-bird-0.8.0.tgz` - source tarball, also unused at runtime.
|
||||
- `scripts/lib/vendor/bird-search/` - the ACTUAL vendored bird-search client (15 files). Keep.
|
||||
- `plans/` (top-level, 2 files: `feat-add-websearch-source.md`, `fix-strict-date-filtering.md`) - legacy, pre-`docs/plans/` convention. Matt confirmed delete.
|
||||
- `scripts/sync.sh` - deploys skill to `~/.claude`, `~/.agents`, `~/.codex`. Reference for runtime-required files.
|
||||
- `.clawhubignore` - existing exclude list for the ClawHub path. Not used here, but good cross-reference for what is runtime-irrelevant.
|
||||
- `.gitignore` - current dev excludes (`.venv/`, `__pycache__/`, `.DS_Store`, etc).
|
||||
|
||||
### Institutional learnings
|
||||
|
||||
- Private repo has `scripts/clawhub-publish.sh` + `scripts/strip_for_openclaw.py` that build a staging dir with only OpenClaw-safe files. Not needed for this public-path upload; `git archive` with `--prefix` is sufficient and dependency-free.
|
||||
- PR #48 introduced `vendor/package/` unintentionally. No code imports from it.
|
||||
|
||||
### File count math (verified via dry run)
|
||||
|
||||
| Strategy | File count | Under cap? |
|
||||
|---|---|---|
|
||||
| Current repo, zip as-is | 406 | No |
|
||||
| After `vendor/` deleted | 191 | Yes (thin margin) |
|
||||
| After `vendor/` + `plans/` deleted, no further excludes | 189 | Yes |
|
||||
| With full planned excludes (Anthropic canonical + tests/docs/fixtures/assets/dev manifests/nested skill dirs) | 81 | Comfortable headroom |
|
||||
|
||||
Dry run run on 2026-04-14 against the current working tree. Simulated the proposed `.gitattributes` with a `find` filter matching the intended exclude list. Result: 81 files, 868KB uncompressed. Actual `git archive` output may differ slightly (by 1-2 files) but will land well under 200.
|
||||
|
||||
### Runtime import audit (proves core experience unchanged)
|
||||
|
||||
Grepped all `import`/`from` statements in `scripts/**/*.py`. Non-stdlib imports resolve to only:
|
||||
- `lib.*` (internal package at `scripts/lib/`)
|
||||
- `store` (internal module at `scripts/store.py`)
|
||||
- `scripts.*` (internal)
|
||||
|
||||
No runtime import reaches `tests/`, `docs/`, `fixtures/`, `vendor/` (root), `plans/`, `assets/`, `.agents/`, `.codex-plugin/`, `.hermes-plugin/`, or any other excluded path. The shipped `.skill` file contains everything the runtime needs and nothing it does not.
|
||||
|
||||
### Symlink and multi-SKILL.md audit
|
||||
|
||||
The repo contains one symlink: `skills/last30days-nux/SKILL.md -> ../../SKILL.md`. Three SKILL.md files in total:
|
||||
- `SKILL.md` (root, `name: last30days`, `user-invocable: true`) - the actual skill
|
||||
- `skills/last30days/SKILL.md` (`name: last30days-v3-spec`, `user-invocable: false`) - internal architecture doc
|
||||
- `skills/last30days-nux/SKILL.md` (symlink to root) - nux variant reference
|
||||
|
||||
Shipping all three inside one zip creates two rejection risks:
|
||||
1. Uploader sees multiple `SKILL.md` with conflicting `name:` values and refuses or misbinds
|
||||
2. `git archive` stores the symlink as a symlink entry; the uploader may reject symlinked entries on principle
|
||||
|
||||
Both risks disappear by excluding `skills/` entirely from the zip. The two internal skill definitions are not needed for claude.ai skill execution - they serve the repo as documentation / Claude Code plugin layout, not the direct upload path.
|
||||
|
||||
### Sources consulted
|
||||
|
||||
- Anthropic skill help center article (general upload guidance, no file-count number documented)
|
||||
- [anthropics/skills README](https://github.com/anthropics/skills/blob/main/README.md) - YAML frontmatter requirements
|
||||
- [anthropics/skills package_skill.py](https://github.com/anthropics/skills/blob/main/skills/skill-creator/scripts/package_skill.py) - canonical exclusions and arcname shape
|
||||
- Trevin's 2026-04-14 chat screenshot (empirical 200-file cap)
|
||||
- Adjacent issues #239, #236, #190 for context on current packaging mess
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **Delete `vendor/` outright** rather than gitignore-and-leave. Pure dead weight. Rationale: the real vendored client is at `scripts/lib/vendor/bird-search/`, root `vendor/` has zero importers; keeping it invites re-upload.
|
||||
- **Delete top-level `plans/`** (Matt confirmed). Rationale: superseded by `docs/plans/`. Moving content into `docs/plans/` if any is still relevant; otherwise just delete.
|
||||
- **Produce a `.skill` file (not a plain `.zip`)** via `git archive --format=zip --prefix=last30days/ -o dist/last30days.skill HEAD`. Rationale: matches Anthropic's canonical contract - zip extension is cosmetic, but the `.skill` affordance is what the upload UI expects.
|
||||
- **Use `git archive` + `.gitattributes export-ignore`** rather than a Python packager. Rationale: no Python dependency at build time, honors git's declarative exclude model, reusable by anyone running `git archive` directly.
|
||||
- **Mirror Anthropic's canonical exclusions in `.gitattributes`** (`__pycache__`, `node_modules`, `*.pyc`, `.DS_Store`, `evals/`) alongside our repo-specific excludes. Rationale: future-proof if a contributor adds node deps; keeps us aligned with the Anthropic baseline.
|
||||
- **Exclude `skills/` from the upload zip** (covers `skills/last30days/SKILL.md` and `skills/last30days-nux/SKILL.md`). Rationale: shipping multiple SKILL.md files with different `name:` values is a likely uploader-rejection cause, and the symlink at `skills/last30days-nux/SKILL.md` is an independent rejection risk. Repo contents stay intact - Claude Code plugin and GitHub viewers still see the directory.
|
||||
- **Keep `.clawhubignore` as-is** - it serves the ClawHub publish path separately. Do not merge the two lists; different consumers, different exclusions.
|
||||
- **Prevent regression with a `/vendor/` entry in `.gitignore`** (leading slash, so `scripts/lib/vendor/` is unaffected).
|
||||
- **Do not address #239 `"skills": ["./"]` path-escape here.** That is a plugin.json change, not a zip-packaging change. Separate plan.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved during planning
|
||||
|
||||
- Is root `vendor/` used? No. Grep for `vendor/package`, `vendor/steipete`, `from vendor` returns zero hits outside `scripts/lib/vendor/`.
|
||||
- Is `scripts/lib/vendor/bird-search/` safe? Yes. Referenced by `scripts/lib/bird_x.py:5` and `tests/test_bird_x.py:133`.
|
||||
- What name does the top-level zip directory need? `last30days` - matches `name: last30days` in the root `SKILL.md` frontmatter.
|
||||
- Does `skills/last30days/SKILL.md` conflict? No. It declares a different skill name (`last30days-v3-spec`) and is `user-invocable: false`. Not the upload target, and safe to ship inside the zip.
|
||||
- Is there a documented file-count cap? No. 200 is empirical from the UI error screenshot.
|
||||
- Should we gate this on a version bump? Yes, 3.0.0 - 3.0.1. Same API, same runtime, smaller and uploadable package.
|
||||
|
||||
### Deferred to implementation
|
||||
|
||||
- Exact `.gitattributes` export-ignore entries may need one tuning pass if `git archive` surfaces a file we forgot. Verification step catches it.
|
||||
- Whether to delete `SKILL-original.md` from the repo entirely or just export-ignore. Leaning export-ignore to preserve git history context.
|
||||
- Whether any content in `plans/*.md` is still live reference material. If so, move to `docs/plans/` under new naming convention; if not, delete outright.
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [ ] **Unit 1: Remove accidental `vendor/` commit**
|
||||
|
||||
**Goal:** Delete the root `vendor/` directory and the stray `.tgz`, both unused at runtime.
|
||||
|
||||
**Requirements:** R1, R2, R5
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
- Delete: `vendor/` (entire tree, 215 files)
|
||||
- Delete: `vendor/steipete-bird-0.8.0.tgz`
|
||||
- Modify: `.gitignore` (add `/vendor/` to prevent regression - leading slash to avoid matching `scripts/lib/vendor/`)
|
||||
|
||||
**Approach:**
|
||||
- Single commit: `chore: remove unused root vendor/ directory (215 files from PR #48)`
|
||||
- Verify `scripts/lib/vendor/bird-search/` is untouched
|
||||
- Verify no `from vendor` or `vendor/package` references appear in the diff
|
||||
|
||||
**Patterns to follow:**
|
||||
- Commit message style matches recent history
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `find . -type f -not -path './.git/*' | wc -l` returns =200 after commit
|
||||
- Integration: `python -m pytest tests/test_bird_x.py -q` passes - confirms the real vendored client still resolves
|
||||
- Integration: `bash scripts/sync.sh` completes without error
|
||||
|
||||
**Verification:**
|
||||
- Zero files remain under `vendor/` on `main`
|
||||
- `tests/test_bird_x.py` still passes
|
||||
- `.gitignore` now contains `/vendor/`
|
||||
|
||||
- [ ] **Unit 2: Remove legacy top-level `plans/` directory**
|
||||
|
||||
**Goal:** Delete the pre-`docs/plans/` folder (Matt confirmed waste).
|
||||
|
||||
**Requirements:** R1, R5
|
||||
|
||||
**Dependencies:** None (independent of Unit 1)
|
||||
|
||||
**Files:**
|
||||
- Delete: `plans/feat-add-websearch-source.md`
|
||||
- Delete: `plans/fix-strict-date-filtering.md`
|
||||
- Delete: `plans/` (now empty)
|
||||
|
||||
**Approach:**
|
||||
- Skim both files first. If either still reflects real upcoming work, port it to `docs/plans/YYYY-MM-DD-NNN-<type>-*-plan.md` before deletion. If not, delete.
|
||||
- Commit: `chore: remove legacy plans/ directory (superseded by docs/plans/)`
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none - pure housekeeping, no code paths affected
|
||||
|
||||
**Verification:**
|
||||
- `plans/` does not exist on `main`
|
||||
- Nothing in the repo references `plans/feat-add-websearch-source.md` or `plans/fix-strict-date-filtering.md` (grep to confirm)
|
||||
|
||||
- [ ] **Unit 3: Declare zip-time excludes via `.gitattributes`**
|
||||
|
||||
**Goal:** Use `export-ignore` so `git archive` produces a skill-shaped zip without hand-filtering.
|
||||
|
||||
**Requirements:** R1, R3, R6
|
||||
|
||||
**Dependencies:** Unit 1, Unit 2
|
||||
|
||||
**Files:**
|
||||
- Create: `.gitattributes`
|
||||
|
||||
**Approach:**
|
||||
- Anthropic canonical exclusions (match `package_skill.py`):
|
||||
- `__pycache__/` export-ignore
|
||||
- `node_modules/` export-ignore
|
||||
- `*.pyc` export-ignore
|
||||
- `.DS_Store` export-ignore
|
||||
- `evals/` export-ignore
|
||||
- Repo-specific exclusions (dev/docs/build artifacts not needed at runtime):
|
||||
- `tests/` (64 files)
|
||||
- `docs/` (17 files including `docs/test-results/`)
|
||||
- `fixtures/` (7 files)
|
||||
- `assets/` (5 files, 14MB of README media)
|
||||
- `SKILL-original.md` (historical)
|
||||
- `SPEC.md`, `TASKS.md`, `test-run.log`, `CONTRIBUTORS.md`, `HERMES_SETUP.md`, `release-notes.md`, `CHANGELOG.md`
|
||||
- `uv.lock`
|
||||
- `.agents/`, `.codex-plugin/`, `.hermes-plugin/`, `.claude-plugin/` (platform adapters - skill-upload path is platform-agnostic)
|
||||
- `.clawhubignore`, `.gitignore`, `.gitattributes`
|
||||
- `skills/` (avoid second SKILL.md with conflicting `name:`; also drops the symlink at `skills/last30days-nux/SKILL.md`)
|
||||
- Keep in archive: `scripts/` (runtime), root `SKILL.md`, `README.md`, `LICENSE`, `pyproject.toml`, `CLAUDE.md`, `gemini-extension.json`, `agents/`, `hooks/`
|
||||
|
||||
**Technical design:** *(directional guidance, not implementation spec)*
|
||||
|
||||
```gitattributes
|
||||
# Anthropic canonical skill-packaging excludes
|
||||
__pycache__/ export-ignore
|
||||
node_modules/ export-ignore
|
||||
*.pyc export-ignore
|
||||
.DS_Store export-ignore
|
||||
evals/ export-ignore
|
||||
|
||||
# Repo-specific: tests + docs + media (not runtime)
|
||||
tests/ export-ignore
|
||||
docs/ export-ignore
|
||||
fixtures/ export-ignore
|
||||
assets/ export-ignore
|
||||
|
||||
# Repo-specific: historical + dev manifests
|
||||
SKILL-original.md export-ignore
|
||||
SPEC.md export-ignore
|
||||
...
|
||||
```
|
||||
|
||||
**Patterns to follow:**
|
||||
- `.gitattributes` export-ignore syntax per [git docs](https://git-scm.com/docs/gitattributes#_creating_an_archive)
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `git archive --format=zip HEAD | zipinfo -1 - | wc -l` returns =200
|
||||
- Happy path: zip contains `SKILL.md`, `scripts/last30days.py`, `scripts/lib/bird_x.py`, `scripts/lib/vendor/bird-search/lib/cookies.js`
|
||||
- Happy path: zip contains exactly one `SKILL.md` entry at the top level (not multiple, not a symlink)
|
||||
- Edge case: zip does NOT contain `tests/`, `docs/`, `assets/*.jpeg`, `*.mp3`, `skills/`
|
||||
- Edge case: no symlink entries in the zip (`unzip -l` lines starting with `l`)
|
||||
- Edge case: zip size stays under ~2MB (if over 5MB an unintended large file slipped through)
|
||||
|
||||
**Verification:**
|
||||
- Running `git archive --format=zip --output=/tmp/test.zip HEAD && unzip -l /tmp/test.zip | tail -1` reports =200 files and a sane byte count
|
||||
|
||||
- [ ] **Unit 4: Add `scripts/build-skill.sh` user-facing builder**
|
||||
|
||||
**Goal:** One-command path to produce a Claude-upload-compatible `.skill` file.
|
||||
|
||||
**Requirements:** R3, R6
|
||||
|
||||
**Dependencies:** Unit 3
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/build-skill.sh`
|
||||
- Modify: `.gitignore` (add `/dist/` for build artifact)
|
||||
|
||||
**Approach:**
|
||||
- Bash, executable, `set -euo pipefail`
|
||||
- `git archive --format=zip --prefix=last30days/ --output=dist/last30days.skill HEAD`
|
||||
- The `--prefix=last30days/` nests everything under `last30days/` inside the zip, matching Anthropic's arcname contract
|
||||
- Refuse to build if working tree is dirty (`git diff --quiet && git diff --cached --quiet`)
|
||||
- Print file count, archive size, and path to paste into the upload UI
|
||||
- Fail with a clear error if count exceeds 200 (defensive check)
|
||||
|
||||
**Technical design:** *(directional guidance, not implementation spec)*
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# build-skill.sh - package repo as a Claude-upload-ready .skill file
|
||||
# Usage: bash scripts/build-skill.sh
|
||||
set -euo pipefail
|
||||
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "error: working tree is dirty - commit or stash first" >&2; exit 1
|
||||
fi
|
||||
|
||||
mkdir -p dist
|
||||
out="dist/last30days.skill"
|
||||
git archive --format=zip --prefix=last30days/ --output="$out" HEAD
|
||||
|
||||
count=$(unzip -l "$out" | tail -1 | awk '{print $2}')
|
||||
[ "$count" -le 200 ] || { echo "error: $count files in zip, cap is 200" >&2; exit 1; }
|
||||
echo "built $out ($count files, $(du -h "$out" | cut -f1))"
|
||||
```
|
||||
|
||||
**Patterns to follow:**
|
||||
- Style of `scripts/sync.sh` (bash, top-of-file comment, `set -euo pipefail`)
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: clean tree, `bash scripts/build-skill.sh` produces `dist/last30days.skill` with =200 files and the top-level entry is `last30days/`
|
||||
- Happy path: `unzip -p dist/last30days.skill last30days/SKILL.md | head -2` shows `---` (frontmatter start) confirming SKILL.md is at the right location
|
||||
- Edge case: dirty working tree - script exits non-zero with clear error
|
||||
- Edge case: idempotent - running twice overwrites cleanly
|
||||
- Error path: if a future change inflates file count past 200, the defensive `[ "$count" -le 200 ]` check fails and the script refuses to produce a broken output
|
||||
|
||||
**Verification:**
|
||||
- `bash scripts/build-skill.sh && unzip -l dist/last30days.skill | grep "^ 0 .* last30days/$"` confirms the prefix directory exists
|
||||
- `unzip -l dist/last30days.skill | grep "last30days/SKILL.md"` confirms SKILL.md is at the expected path
|
||||
- `unzip -l dist/last30days.skill | grep -c "SKILL.md"` returns exactly 1
|
||||
- `unzip -l dist/last30days.skill | awk '{print $NF}' | grep -v "^$" | sort -u | grep "skills/" || true` returns nothing (confirms internal skill dirs excluded)
|
||||
- Gate: a contributor must run `bash scripts/build-skill.sh` on their branch and attach the produced file to their PR before merging any change that touches `.gitattributes` or exclude-sensitive paths
|
||||
|
||||
- [ ] **Unit 5: Document the upload path in README**
|
||||
|
||||
**Goal:** Users know how to produce an upload `.skill` without reading the source.
|
||||
|
||||
**Requirements:** R3
|
||||
|
||||
**Dependencies:** Unit 4
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md` (add a short "Upload as a Claude Skill" subsection under the existing install section)
|
||||
|
||||
**Approach:**
|
||||
- One paragraph plus a single command block: `bash scripts/build-skill.sh`
|
||||
- Mention the 200-file cap as context so future changes do not bust it
|
||||
- Point users at the claude.ai skill upload UI (note: link only if a stable URL exists at implementation time, otherwise describe the UI path)
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none - pure documentation change
|
||||
|
||||
**Verification:**
|
||||
- `grep -n "build-skill" README.md` returns a hit
|
||||
- Instructions match actual script behavior
|
||||
|
||||
- [ ] **Unit 6: Trim SKILL.md description to =200 chars**
|
||||
|
||||
**Goal:** Make root `SKILL.md` frontmatter pass Anthropic's documented `description` limit.
|
||||
|
||||
**Requirements:** R7
|
||||
|
||||
**Dependencies:** None (independent of other units)
|
||||
|
||||
**Files:**
|
||||
- Modify: `SKILL.md` (frontmatter `description:` field only)
|
||||
|
||||
**Approach:**
|
||||
- Current description is 228 chars. Cut 28+ chars without losing signal.
|
||||
- Suggested rewrite (196 chars): `"Multi-query social search with planned queries. Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web. Gemini/OpenAI fallback when needed."`
|
||||
- Confirm the trimmed version still surfaces for the right prompts (smoke test: run `python scripts/last30days.py "test" --emit=compact` and confirm behavior unchanged; description is metadata, not runtime input)
|
||||
- Update `skills/last30days/SKILL.md` description too if it exceeds 200 chars (check during implementation)
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `python3 -c "import re; d=open('SKILL.md').read(); m=re.search(r'^description:\s*\"(.+?)\"', d, re.M); assert len(m.group(1)) <= 200, len(m.group(1))"` passes
|
||||
|
||||
**Verification:**
|
||||
- Description field is =200 chars in root SKILL.md
|
||||
- Skill still triggers on relevant prompts (manual smoke check)
|
||||
|
||||
- [ ] **Unit 7: Version bump and changelog**
|
||||
|
||||
**Goal:** Ship as 3.0.1 so consumers see the packaging fix.
|
||||
|
||||
**Requirements:** R5
|
||||
|
||||
**Dependencies:** Units 1-6
|
||||
|
||||
**Files:**
|
||||
- Modify: `.claude-plugin/plugin.json` (3.0.0 - 3.0.1)
|
||||
- Modify: `SKILL.md` frontmatter version
|
||||
- Modify: `skills/last30days/SKILL.md` frontmatter version
|
||||
- Modify: `gemini-extension.json` version (note: #190 flags this as stale at 2.9.5; bumping here partially addresses that but full resolution is out of scope)
|
||||
- Modify: `CHANGELOG.md`
|
||||
- Modify: `release-notes.md`
|
||||
|
||||
**Approach:**
|
||||
- Atomic version bump across all manifests
|
||||
- Changelog entry: "Packaging: `scripts/build-skill.sh` produces a compliant `.skill` file; removed unused root `vendor/` (215 files) and legacy `plans/`; repo file count fits under claude.ai's 200-file upload cap"
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `grep -rn "3.0.1" SKILL.md skills/last30days/SKILL.md .claude-plugin/plugin.json gemini-extension.json` returns four consistent hits
|
||||
- Integration: `bash scripts/sync.sh` completes cleanly
|
||||
|
||||
**Verification:**
|
||||
- All four version declarations read `3.0.1`
|
||||
- CHANGELOG and release-notes have dated entries
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** Skill-runtime import graph is unchanged. Removed code (root `vendor/`, `plans/`) has zero importers.
|
||||
- **Error propagation:** `build-skill.sh` is a new surface; failure mode is non-zero exit with clear stderr. No runtime error paths touched.
|
||||
- **State lifecycle risks:** None. `dist/` is gitignored build output.
|
||||
- **API surface parity:** No change to any user-facing API, CLI flag, config key, or SKILL.md contract.
|
||||
- **Integration coverage:** `tests/test_bird_x.py` exercises the real vendored client - if it regressed, the test fails. Run it after Unit 1.
|
||||
- **Unchanged invariants:** `scripts/lib/vendor/bird-search/` stays. `scripts/sync.sh` deploy behavior unchanged. ClawHub publish flow (private repo) untouched. Claude Code plugin install via GitHub URL still works.
|
||||
|
||||
## Risks and Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Deleting `vendor/` silently breaks something we missed | Run `pytest tests/test_bird_x.py` and `bash scripts/sync.sh` after the delete; grep for `vendor/package` before merging |
|
||||
| claude.ai rejects the `.skill` file for a reason other than file count (e.g., frontmatter character, hidden file) | Test-upload the produced artifact against claude.ai once before merging; iterate on `.gitattributes` if needed |
|
||||
| `.gitattributes` over-excludes and breaks the runtime skill | Unit 3 verification step explicitly checks runtime paths are present in the produced archive |
|
||||
| A future PR re-vendors something at `/vendor/` and busts the 200 cap again | `/vendor/` in `.gitignore` plus the defensive `=200` check in `build-skill.sh` catches it |
|
||||
| Version bump collides with in-flight PRs that also bump versions | Coordinate with #229, #217 which touched version strings; check before merging |
|
||||
| `skills/last30days/SKILL.md` (internal spec) being shipped inside the zip confuses the claude.ai uploader | Resolved by excluding `skills/` from the zip (Unit 3). Internal spec remains in the repo for plugin consumers |
|
||||
| `skills/last30days-nux/SKILL.md` is a symlink to `../../SKILL.md`; claude.ai may reject zips with symlink entries | Resolved by excluding `skills/` from the zip (Unit 3). Symlink never enters the archive |
|
||||
|
||||
## Documentation and Operational Notes
|
||||
|
||||
- Update README only (Unit 5). No runbook, no migration, no flag.
|
||||
- No deployment step - plugin consumers get the packaging fix automatically on next update.
|
||||
- Release notes flag: manual uploaders should re-zip via `scripts/build-skill.sh`.
|
||||
- Opportunistic future work (out of scope here): CI check that fails PRs that push the zip over 200 files.
|
||||
|
||||
## Sources and References
|
||||
|
||||
- Trevin's 2026-04-14 chat screenshot: "Zip contains too many files (maximum 200)"
|
||||
- [anthropics/skills README](https://github.com/anthropics/skills/blob/main/README.md) - YAML frontmatter requirements
|
||||
- [anthropics/skills package_skill.py](https://github.com/anthropics/skills/blob/main/skills/skill-creator/scripts/package_skill.py) - canonical exclusions, arcname convention, validation gates
|
||||
- [claude.ai skill help center](https://support.claude.com/en/articles/12512180-use-skills-in-claude) - upload failure modes (zip size, folder-name mismatch, missing SKILL.md)
|
||||
- PR #48 (2026-02) - the merge that introduced `vendor/package/`
|
||||
- Open issues adjacent but out of scope: #239, #236, #231, #190, #184
|
||||
- Related code: `scripts/lib/bird_x.py:5`, `tests/test_bird_x.py:133`, `.clawhubignore`, `scripts/sync.sh`, root `SKILL.md` frontmatter
|
||||
- Private-repo reference pattern: `scripts/clawhub-publish.sh` + `scripts/strip_for_openclaw.py` - not copied here; `git archive` is simpler for the public path
|
||||
@@ -0,0 +1,459 @@
|
||||
---
|
||||
title: claude.ai distribution + discoverability push
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-04-14
|
||||
---
|
||||
|
||||
# claude.ai distribution + discoverability push
|
||||
|
||||
## Overview
|
||||
|
||||
The 200-file upload bug is fixed and `last30days.skill` works on claude.ai. But "it can be uploaded" is not the same as "people use it." Claude.ai has no native skill marketplace, so discovery happens through a 3-layer stack: Anthropic's curated plugin marketplace, third-party aggregators, and social/newsletter amplification. The question Matt asked - "is the GitHub release the right decision" - has a clear answer: yes, but it is table stakes, not the strategy. This plan cuts the release and then pulls the real distribution levers.
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Today, the only way a claude.ai user can get `last30days` is to clone the repo and run `scripts/build-skill.sh`. That filters out 99% of potential users. Even once a release exists with a direct download link, the hard problem is discovery - claude.ai users do not browse GitHub for skills. They find skills via Anthropic's "Discover" tab in Claude Code, third-party aggregator sites (skillsmp.com, mcpmarket.com, claudeskills.info), awesome-lists on GitHub, newsletters (The Neuron), and social posts (X, r/ClaudeAI).
|
||||
|
||||
Success looks like: a claude.ai user who never visits the repo can find, download, and upload the skill in under 60 seconds, and keep using it because the trigger description fires on the right prompts.
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
- R1. A one-click install path exists for claude.ai users: click a link from README/marketplace/aggregator, get `last30days.skill`, drop into Upload dialog
|
||||
- R2. The skill is submitted to Anthropic's official plugin marketplace at `platform.claude.com/plugins/submit`
|
||||
- R3. The skill is listed in at least 4 high-traffic awesome-lists / aggregators
|
||||
- R4. The SKILL.md YAML `description` and `argument-hint` fields are tuned so Claude's skill-selector actually invokes `last30days` on research-intent prompts (trigger quality is the single biggest install-to-reuse lever)
|
||||
- R5. First-run experience works with zero API keys for the default sources (Reddit, Hacker News, Polymarket, GitHub) - already true, verify does not regress
|
||||
- R6. At least one high-visibility amplification moment ships within 14 days: demo GIF + launch tweet + The Neuron pitch
|
||||
- R7. Basic metrics are in place to learn what works: release-download counts, aggregator referrer traffic, GitHub star velocity before/after
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
Non-goals for this plan:
|
||||
- Not building a custom skill-hosting site or our own marketplace
|
||||
- Not changing the runtime pipeline or adding features - this is pure distribution
|
||||
- Not spamming aggregators with low-effort PRs - one quality submission per venue
|
||||
- Not gaming install counts or stars
|
||||
- Not displacing the existing Claude Code plugin / OpenClaw / Gemini distribution - those stay as-is, cross-linked
|
||||
- Not depending on Anthropic marketplace acceptance before other levers ship - marketplace review is slow and gate-able
|
||||
|
||||
## Context and Research
|
||||
|
||||
### The claude.ai skill ecosystem in April 2026
|
||||
|
||||
- **No native claude.ai skill marketplace.** Upload is the only end-user path inside the web UI.
|
||||
- **Anthropic's Plugin/Skills Marketplace** (submissions at `platform.claude.com/plugins/submit`) is the closest thing to a "featured" channel and ships through Claude Code's "Discover" tab. Quality/security review gates acceptance. Research-category skills are under-represented vs. dev-tool skills.
|
||||
- **Third-party aggregators** drive most organic discovery outside Anthropic's channels:
|
||||
- `skillsmp.com`, `mcpmarket.com`, `claudeskills.info`, `skillsdirectory.com`, `agensi.io`
|
||||
- These aggregators scrape awesome-lists, so one well-placed PR cascades
|
||||
- **Awesome-lists** where skills discovery congregates:
|
||||
- `ComposioHQ/awesome-claude-skills`
|
||||
- `travisvn/awesome-claude-skills`
|
||||
- `karanb192/awesome-claude-skills`
|
||||
- `VoltAgent/awesome-agent-skills`
|
||||
- `sickn33/antigravity-awesome-skills` (1,400+ skills indexed)
|
||||
- **Newsletter amplification:** The Neuron runs a daily "AI Skill of the Day" digest - the single biggest external traffic source per successful skill creators. Their "practical workflow" angle fits a research skill cleanly.
|
||||
- **Install-count reference points** from public aggregator data:
|
||||
- `self-improving-agent`: 357k installs
|
||||
- `frontend-design`: 277k installs
|
||||
- `skill-vetter`: 190k installs
|
||||
- `github`: 148k installs
|
||||
- `proactive-agent`: 135k installs
|
||||
- Long tail: ~500 installs
|
||||
|
||||
The gap between 500-install and 357k-install skills is mostly: (a) trigger description quality, (b) zero-config first run, (c) one amplification moment that caught.
|
||||
|
||||
### Current distribution surface for last30days
|
||||
|
||||
- Claude Code plugin via marketplace and GitHub URL: live
|
||||
- OpenClaw via ClawHub (`clawhub install last30days-official`): live
|
||||
- Gemini extension: live
|
||||
- Manual `git clone`: documented in README
|
||||
- claude.ai `.skill` upload: just shipped, undocumented for end users (no link)
|
||||
|
||||
The cross-linking graph is incomplete. Traffic that already exists (Claude Code install page, OpenClaw listing, Gemini extensions page) is not being routed to the new claude.ai path.
|
||||
|
||||
### Reference: trigger description quality
|
||||
|
||||
The root `SKILL.md` `description` field is how Claude decides whether to invoke the skill. Current text (as of 3.0.1, 167 chars):
|
||||
|
||||
> "Multi-query social research across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web. Intelligent query planning with Gemini/OpenAI fallback."
|
||||
|
||||
Analysis: solid source list, weak on action verbs and example queries. Successful skills include imperative verbs ("research", "find", "summarize", "compare") and 1-2 example triggers the user might type. At 167/200 chars, there is room.
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **Cut v3.0.1 GitHub release with `last30days.skill` attached as an asset** (table stakes). Rationale: every downstream lever (README link, marketplace submission, aggregator PR, tweet) needs a stable public download URL.
|
||||
- **Automate `.skill` build in CI on tag push** so future releases never forget to attach the artifact. Rationale: manual builds break over time; this is a one-time 10-line GitHub Actions workflow.
|
||||
- **Prioritize marketplace submission in parallel with aggregator PRs**, not in sequence. Rationale: marketplace review is slow and opaque; do not block aggregator work on it. If rejected, we still have the aggregator presence.
|
||||
- **Tune the SKILL.md description to optimize trigger selection**, not marketing copy. Rationale: this is the single biggest re-use lever per the ecosystem research. Marketing copy goes in README/release notes, not frontmatter.
|
||||
- **One quality pass per aggregator, not a spray.** Rationale: awesome-list maintainers reject duplicate / low-effort PRs; reputation matters.
|
||||
- **Ship the launch tweet with a real demo GIF**, not a screenshot. Rationale: Boris Cherny's Claude Code viral tweet template (one query, one result, "oh wow" moment) consistently outperforms text-only launches.
|
||||
- **Pitch The Neuron once, with a production-quality 60-second demo**, not a cold email. Rationale: single shot at the biggest amplifier; treat it like a press release, not a tweet.
|
||||
- **Track release-download count + GitHub referrer traffic as proxies for adoption** until we have better signal. Rationale: claude.ai upload counts are not exposed to creators.
|
||||
- **Cross-link existing distribution pages back to claude.ai** as part of the release. Rationale: converting existing users to multi-surface users is cheaper than acquiring new ones.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved during planning
|
||||
|
||||
- Is the GitHub release the right first step? Yes. Every other lever depends on a stable download URL. But it is a prerequisite, not the strategy.
|
||||
- Does claude.ai have a native skill directory? No (confirmed April 2026).
|
||||
- Should we wait for Anthropic marketplace acceptance before shipping other levers? No - parallelize.
|
||||
- Do we need to rebuild the runtime to improve claude.ai adoption? No - the runtime is strong; the gap is distribution.
|
||||
|
||||
### Deferred to implementation
|
||||
|
||||
- Exact Neuron pitch copy - draft during Unit 8, refine based on what their recent editions have favored
|
||||
- Whether to tag `@steipete`, `@AnthropicAI`, `@alexalbert__` in the launch tweet - confirm current handles and review each's posting culture before tagging
|
||||
- Which specific demo query to record for the launch GIF - pick during Unit 7 based on what's newsworthy that week
|
||||
- Whether to request a "skills-research" badge on skillsdirectory.com/agensi.io - check their current badge programs during Unit 5
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
> *This illustrates the intended distribution graph and is directional guidance for review, not implementation specification.*
|
||||
|
||||
```
|
||||
[GitHub Release v3.0.1]
|
||||
|
|
||||
+-- last30days.skill (asset, public URL)
|
||||
|
|
||||
+------> README "Upload to claude.ai" section (Unit 3)
|
||||
|
|
||||
+------> Claude Code plugin README link (Unit 4)
|
||||
+------> OpenClaw listing link (Unit 4)
|
||||
+------> Gemini extension link (Unit 4)
|
||||
|
|
||||
+------> Anthropic marketplace submission (Unit 6)
|
||||
|
|
||||
+------> Aggregator PRs (Unit 5):
|
||||
| * ComposioHQ/awesome-claude-skills
|
||||
| * travisvn/awesome-claude-skills
|
||||
| * karanb192/awesome-claude-skills
|
||||
| * VoltAgent/awesome-agent-skills
|
||||
| * sickn33/antigravity-awesome-skills
|
||||
| * skillsmp.com submit form
|
||||
|
|
||||
+------> Amplification (Units 7-9):
|
||||
* Demo GIF + launch tweet
|
||||
* The Neuron "Skill of the Day" pitch
|
||||
* News-cycle recurring tweet (weekly)
|
||||
|
||||
All paths end at: claude.ai Upload Skill dialog
|
||||
Trigger quality (Unit 2) determines whether installs become sustained usage
|
||||
```
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [ ] **Unit 1: Cut v3.0.1 GitHub release with `.skill` asset + auto-build CI**
|
||||
|
||||
**Goal:** Produce a stable public download URL for `last30days.skill` so every downstream lever has something to link to, and guarantee future releases include the artifact automatically.
|
||||
|
||||
**Requirements:** R1
|
||||
|
||||
**Dependencies:** None (plan 2026-04-14-001 already shipped the build script)
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/release.yml`
|
||||
- Modify: none at release time (release is a git-tag + GitHub release action)
|
||||
|
||||
**Approach:**
|
||||
- Tag `v3.0.1` on `main`, push
|
||||
- Create GitHub release with the CHANGELOG v3.0.1 entry as body, attach `dist/last30days.skill`
|
||||
- Add CI workflow that triggers on `push: tags: 'v*'`, runs `bash scripts/build-skill.sh`, uploads the artifact to the release. The `action-gh-release` pattern is standard.
|
||||
- Release URL shape: `https://github.com/mvanhorn/last30days-skill/releases/download/v3.0.1/last30days.skill` (deterministic, shareable)
|
||||
|
||||
**Patterns to follow:**
|
||||
- Any existing `.github/workflows/` patterns in the repo
|
||||
- `actions/checkout@v4` + `softprops/action-gh-release@v2` is the conventional combo
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: pushing `v3.0.1` tag produces a release with `last30days.skill` attached and publicly downloadable without auth
|
||||
- Edge case: re-tagging `v3.0.1` does not duplicate or corrupt the asset
|
||||
- Error path: build failure in the workflow fails the release cleanly (no empty release created)
|
||||
|
||||
**Verification:**
|
||||
- `curl -fsSL -o /tmp/dl.skill https://github.com/mvanhorn/last30days-skill/releases/download/v3.0.1/last30days.skill` succeeds anonymously
|
||||
- Downloaded file matches `dist/last30days.skill` byte-for-byte
|
||||
- A second tag (e.g., `v3.0.2-test`) in a branch triggers the workflow end-to-end
|
||||
|
||||
- [ ] **Unit 2: Tune SKILL.md description and argument-hint for trigger quality**
|
||||
|
||||
**Goal:** Increase the probability that Claude's skill-selector invokes `last30days` on research-intent prompts. Trigger quality is the single biggest install-to-reuse lever per ecosystem research.
|
||||
|
||||
**Requirements:** R4, R5
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
- Modify: `SKILL.md` (frontmatter `description` and `argument-hint` only)
|
||||
- Modify: `skills/last30days/SKILL.md` (if parity needed)
|
||||
|
||||
**Approach:**
|
||||
- Rewrite `description` to lead with an imperative action verb and include 1-2 concrete example queries, staying =200 chars
|
||||
- Rewrite `argument-hint` to show 2-3 canonical invocations that mirror real user phrasing, not marketing phrasing
|
||||
- Keep the source list intact - that's the value prop - but move it later in the sentence
|
||||
- Reference frames that worked for high-install skills: `frontend-design`, `self-improving-agent`, `github`
|
||||
|
||||
**Technical design:** *(directional guidance, not implementation spec)*
|
||||
|
||||
Candidate shape (verify char count in implementation):
|
||||
|
||||
```yaml
|
||||
description: "Research what people actually say about any topic in the last 30 days. Pulls real posts and engagement from Reddit, X, YouTube, TikTok, Hacker News, Polymarket, GitHub, and the web."
|
||||
argument-hint: 'last30days AI video tools | last30days nvidia earnings reaction | last30days best noise cancelling headphones'
|
||||
```
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: in a fresh claude.ai chat, prompts like "what are people saying about X this week" surface `last30days` in the skill-selector candidate set
|
||||
- Edge case: generic "research X" prompts do not over-select `last30days` when the user clearly wants a general answer (avoids false-positive selection)
|
||||
- Integration: test in all three environments - claude.ai web, Claude Code, OpenClaw - to confirm selection behavior is consistent
|
||||
|
||||
**Verification:**
|
||||
- Description =200 chars, checked by the same regex Unit 6 of plan 001 used
|
||||
- At least 3 real-user prompt phrasings trigger skill selection in manual testing
|
||||
- No regression on zero-config first-run (no new API keys required)
|
||||
|
||||
- [ ] **Unit 3: Rewrite the README claude.ai section with one-click install**
|
||||
|
||||
**Goal:** Replace the current "run this bash script" instructions with a one-click download link pointing at the GitHub release asset.
|
||||
|
||||
**Requirements:** R1
|
||||
|
||||
**Dependencies:** Unit 1 (release must exist first)
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md` (the "Upload as a Claude Skill" section added in plan 001)
|
||||
|
||||
**Approach:**
|
||||
- Replace the `bash scripts/build-skill.sh` instruction with a direct download link to the release asset
|
||||
- Keep the build-from-source instruction as a fallback for developers, demoted below the direct link
|
||||
- Add a short 3-step install guide with specific UI path: "Settings > Capabilities > Skills > + button, drop the .skill file"
|
||||
- Include a screenshot or GIF showing the upload flow if space allows (can be added in Unit 7)
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none - pure documentation change
|
||||
|
||||
**Verification:**
|
||||
- A user following the README instructions end-to-end can go from "never heard of this" to working skill in under 60 seconds
|
||||
- Instructions specify the exact claude.ai UI path current as of the release date
|
||||
|
||||
- [ ] **Unit 4: Cross-link existing distribution surfaces back to claude.ai**
|
||||
|
||||
**Goal:** Convert existing Claude Code plugin / OpenClaw / Gemini traffic into claude.ai installs. Cheaper than net-new acquisition.
|
||||
|
||||
**Requirements:** R1
|
||||
|
||||
**Dependencies:** Unit 1, Unit 3
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md` (install matrix - add claude.ai row prominently)
|
||||
- Modify: `variants/open/SKILL.md` in the private repo if that governs OpenClaw listing copy
|
||||
- Modify: `gemini-extension.json` if `description` or install hints exist there
|
||||
- External: update the ClawHub listing page for `last30days-official` to mention claude.ai availability
|
||||
|
||||
**Approach:**
|
||||
- Every listing page a user currently lands on should have a one-line "Also available as a claude.ai Skill: [download]" link
|
||||
- Use a consistent short-URL pattern so it's instantly recognizable across surfaces
|
||||
- Do not require users to re-read each install guide - the cross-link is opportunistic, not blocking
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none - documentation/external-listing updates
|
||||
|
||||
**Verification:**
|
||||
- Each of the 4 distribution surfaces (Claude Code plugin marketplace, OpenClaw ClawHub listing, Gemini extensions page, GitHub README) contains a visible claude.ai cross-link within 1 scroll of the page top
|
||||
|
||||
- [ ] **Unit 5: Submit PRs to high-traffic Claude skill awesome-lists**
|
||||
|
||||
**Goal:** Get listed in the 5 highest-traffic aggregators so third-party skill-discovery sites (skillsmp.com, mcpmarket.com, claudeskills.info) pick up the entry.
|
||||
|
||||
**Requirements:** R3
|
||||
|
||||
**Dependencies:** Unit 1, Unit 2 (description should be tuned before first impression in these lists)
|
||||
|
||||
**Files (external repos):**
|
||||
- `ComposioHQ/awesome-claude-skills` - PR adding last30days to the relevant category
|
||||
- `travisvn/awesome-claude-skills`
|
||||
- `karanb192/awesome-claude-skills`
|
||||
- `VoltAgent/awesome-agent-skills`
|
||||
- `sickn33/antigravity-awesome-skills`
|
||||
|
||||
**Approach:**
|
||||
- One PR per list, in parallel
|
||||
- Each PR: one-line entry matching the list's existing format; link to release asset (not repo root)
|
||||
- If the list has a "research" or "data-gathering" category, use it; otherwise append to the most adjacent section
|
||||
- Draft copy once, reuse across PRs - but match each list's voice and entry format
|
||||
- Do not self-star or brigade - let the listing earn traction organically
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none - external PRs, not code
|
||||
|
||||
**Verification:**
|
||||
- All 5 PRs opened on the same day (batch effort, reduces overhead)
|
||||
- Entries include: skill name, one-sentence description matching tuned SKILL.md copy, release URL, source repo URL
|
||||
- Track merge status over 14 days; abandon PRs that go stale after reasonable nudging
|
||||
|
||||
- [ ] **Unit 6: Submit to Anthropic's official Plugin/Skills Marketplace**
|
||||
|
||||
**Goal:** Get featured in Claude Code's "Discover" tab, the closest thing to a native claude.ai skill directory.
|
||||
|
||||
**Requirements:** R2
|
||||
|
||||
**Dependencies:** Unit 1, Unit 2
|
||||
|
||||
**Files:**
|
||||
- No repo changes; this is an external submission at `platform.claude.com/plugins/submit`
|
||||
|
||||
**Approach:**
|
||||
- Submit via Anthropic's form with: skill name, description (matches tuned SKILL.md), GitHub repo URL, release asset URL, demo video link (from Unit 7 if available)
|
||||
- Expect quality/security review; Anthropic will likely ask for the ClawGuard-scanner-style audit items already surfaced in issue #231 - have responses ready
|
||||
- Do not wait for acceptance before shipping other levers
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none - external submission
|
||||
|
||||
**Verification:**
|
||||
- Submission confirmation received
|
||||
- Track review status weekly; iterate on feedback if any
|
||||
|
||||
- [ ] **Unit 7: Record a 15-60 second demo GIF or screen recording**
|
||||
|
||||
**Goal:** Produce the visual asset that every amplification channel needs - launch tweet, Neuron pitch, README hero, release notes.
|
||||
|
||||
**Requirements:** R6
|
||||
|
||||
**Dependencies:** Unit 2 (want the tuned description on-screen), Unit 3 (want the updated install flow)
|
||||
|
||||
**Files:**
|
||||
- Create: `assets/claudeai-demo.gif` (or `.mp4` if GIF is too large)
|
||||
- Modify: `README.md` to embed the GIF
|
||||
|
||||
**Approach:**
|
||||
- Two possible framings:
|
||||
1. "Upload + use" flow: 15 seconds showing Upload dialog -> skill appears -> sample query -> result
|
||||
2. "One query" flow: 15-30 seconds of a real research query running end-to-end with actual output
|
||||
- Pick framing 2 for outside-audience amplification (tweet, Neuron); framing 1 for the README
|
||||
- Record at 1x speed (speeding up feels fake); edit to =60 seconds
|
||||
- Export as optimized GIF or H.264 MP4 =5MB
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none - media asset
|
||||
|
||||
**Verification:**
|
||||
- Asset loads cleanly in GitHub README
|
||||
- Asset uploads cleanly to X (under their video length/size caps)
|
||||
- Matt watches it fresh and the "oh wow" moment is unambiguous in the first 5 seconds
|
||||
|
||||
- [ ] **Unit 8: Pitch The Neuron "AI Skill of the Day"**
|
||||
|
||||
**Goal:** One high-leverage newsletter placement that historically drives the biggest external install spike for Claude skills.
|
||||
|
||||
**Requirements:** R6
|
||||
|
||||
**Dependencies:** Unit 1, Unit 7
|
||||
|
||||
**Approach:**
|
||||
- Identify The Neuron editor contact (newsletter footer, X DMs, their `skilloftheday@` alias if published)
|
||||
- Pitch with: 3-sentence hook, demo video link, release URL, 3 example queries that show breadth
|
||||
- Angle: "researcher skill that queries 12+ social sources in one shot" - novelty vs. their typical dev-tool coverage
|
||||
- Offer exclusive timing if they want (publish first, then we tweet)
|
||||
- Do not follow up more than twice
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none - external pitch
|
||||
|
||||
**Verification:**
|
||||
- Pitch sent with all assets linked
|
||||
- Track whether the issue ships within 14 days; if not, reuse the materials for other newsletters
|
||||
|
||||
- [ ] **Unit 9: Launch tweet + recurring news-cycle posts**
|
||||
|
||||
**Goal:** Seed social discovery and establish a weekly cadence so the skill stays top-of-mind.
|
||||
|
||||
**Requirements:** R6
|
||||
|
||||
**Dependencies:** Unit 1, Unit 7
|
||||
|
||||
**Approach:**
|
||||
- Launch tweet: demo GIF + 1-sentence description + install link. Post to X, cross-post to r/ClaudeAI and r/singularity same day.
|
||||
- Do not tag handles reflexively - research each target account's culture first
|
||||
- Weekly recurring pattern: pick a news moment (earnings, launch, election, cultural event), run `last30days` on it, screenshot the output, post. Low-effort, repeatable, compounds.
|
||||
- Track: likes, impressions, link-click referrer traffic to the release page
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none - social posts
|
||||
|
||||
**Verification:**
|
||||
- Launch tweet live with demo GIF
|
||||
- At least one follow-up news-cycle post within 7 days
|
||||
- Referrer traffic spike visible in GitHub traffic dashboard
|
||||
|
||||
- [ ] **Unit 10: Adoption telemetry and feedback loop**
|
||||
|
||||
**Goal:** Learn which levers work so we double down on wins and cut losses. Current blind spot: no visibility into claude.ai install counts.
|
||||
|
||||
**Requirements:** R7
|
||||
|
||||
**Dependencies:** Unit 1
|
||||
|
||||
**Approach:**
|
||||
- Baseline metrics (capture on Unit 1 ship day):
|
||||
- GitHub stars
|
||||
- Clones/day
|
||||
- Traffic referrers
|
||||
- Release-asset download count (GitHub exposes this on the Release page)
|
||||
- Weekly review during the first 6 weeks of:
|
||||
- Release download deltas
|
||||
- Star velocity
|
||||
- Referrer sources (identifies which aggregator/newsletter/tweet drove traffic)
|
||||
- New GitHub issues that mention claude.ai specifically
|
||||
- No dedicated analytics infrastructure - use what GitHub provides + manual referrer spot-checks
|
||||
- Publish a "what worked / what didn't" retro after 6 weeks in `docs/solutions/` so the next launch compounds
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none - observability
|
||||
|
||||
**Verification:**
|
||||
- Baseline metrics captured in a `docs/solutions/YYYY-MM-DD-*.md` note
|
||||
- Weekly log of download/star/referrer deltas maintained
|
||||
- Retro written at week 6 with concrete learnings for the next release
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** Touches GitHub (release, CI), external aggregators (PRs), Anthropic marketplace (submission), X/Reddit/newsletter (social), ClawHub/Gemini listings (cross-links). No runtime code changes.
|
||||
- **State lifecycle risks:** Minimal. The main risk is inconsistent cross-linking (some surfaces mention claude.ai, others don't) - Unit 4 treats this as a coordinated sweep, not per-surface creep.
|
||||
- **API surface parity:** None - no API changes.
|
||||
- **Integration coverage:** The critical integration is trigger-selection behavior (Unit 2). Manual verification across web / Claude Code / OpenClaw is the gate.
|
||||
- **Unchanged invariants:** Runtime pipeline, existing install paths (Claude Code plugin / OpenClaw / Gemini) all stay working. Zero-config first-run for default sources remains intact.
|
||||
|
||||
## Risks and Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Anthropic marketplace rejects the submission on security/quality grounds | Run `scripts/build-skill.sh` output through ClawGuard or equivalent scanner pre-submission; address #231 findings if real |
|
||||
| Awesome-list maintainers reject or ignore PRs | Submit to 5 lists in parallel; any 2 acceptances are enough; do not brigade |
|
||||
| The Neuron pitch is ignored | Treat as upside, not critical path; reuse materials for other newsletters (Ben's Bites, TLDR, Superhuman AI) |
|
||||
| Tuned description causes false-positive skill selection on unrelated prompts | Manual prompt-testing in Unit 2; be willing to walk back if Claude over-invokes the skill |
|
||||
| A new Anthropic marketplace or directory launches mid-plan and changes the landscape | The research-tracking cadence in Unit 10 catches this within a week; plan can adapt |
|
||||
| Launch tweet flops / no organic pickup | Weekly news-cycle cadence (Unit 9) is the compounding play, not the launch moment |
|
||||
| Cross-repo cross-links in Unit 4 go stale when listings move | Use canonical GitHub Release URL (deterministic) as the link target everywhere |
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- README gets a hero section update in Unit 3
|
||||
- CHANGELOG gets a v3.0.1 release-notes entry (already shipped in plan 001)
|
||||
- A `docs/solutions/` retrospective note ships after the 6-week observation window (Unit 10)
|
||||
- No runbook needed - distribution work is one-time-per-release
|
||||
|
||||
## Sources and References
|
||||
|
||||
- Research pass by repo-research-analyst on 2026-04-14 - [findings](https://github.com/anthropics/skills)
|
||||
- [Anthropic Plugin/Skills Marketplace submissions](https://platform.claude.com/plugins/submit)
|
||||
- [anthropics/skills](https://github.com/anthropics/skills) - 87k stars, canonical repo
|
||||
- [SkillsMP](https://skillsmp.com), [claudeskills.info](https://claudeskills.info), [mcpmarket.com/tools/skills](https://mcpmarket.com/tools/skills) - aggregators
|
||||
- [ComposioHQ/awesome-claude-skills](https://github.com/ComposioHQ/awesome-claude-skills)
|
||||
- [sickn33/antigravity-awesome-skills](https://github.com/sickn33/antigravity-awesome-skills)
|
||||
- [The Neuron Skill of the Day digest](https://www.theneuron.ai/explainer-articles/the-neurons-ai-skill-of-the-day-digest-april-2026-week-1/)
|
||||
- Completed prerequisite: `docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md` (packaging fix)
|
||||
- Related code: `SKILL.md` (frontmatter), `README.md`, `scripts/build-skill.sh`, `.github/workflows/`
|
||||
- Install-count reference points from aggregators: self-improving-agent 357k, frontend-design 277k, skill-vetter 190k, github 148k, proactive-agent 135k
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "last30days-skill",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"description": "Research a topic from the last 30 days across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web.",
|
||||
"settings": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-skill.sh - package this repo as a claude.ai-upload-ready .skill file
|
||||
# Usage: bash scripts/build-skill.sh (run from repo root)
|
||||
#
|
||||
# Produces dist/last30days.skill, a zip with a single top-level `last30days/`
|
||||
# directory containing SKILL.md and the scripts/ runtime. See
|
||||
# docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "error: working tree is dirty; commit or stash before building" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p dist
|
||||
OUT="dist/last30days.skill"
|
||||
git archive --format=zip --prefix=last30days/ --output="$OUT" HEAD
|
||||
|
||||
COUNT=$(unzip -l "$OUT" | tail -1 | awk '{print $2}')
|
||||
SIZE=$(du -h "$OUT" | cut -f1)
|
||||
|
||||
if [ "$COUNT" -gt 200 ]; then
|
||||
echo "error: $COUNT files in zip, claude.ai's cap is 200" >&2
|
||||
echo " check .gitattributes export-ignore entries" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SKILL_MD_COUNT=$(unzip -l "$OUT" | grep -c "SKILL.md" || true)
|
||||
if [ "$SKILL_MD_COUNT" -ne 1 ]; then
|
||||
echo "error: expected exactly one SKILL.md, found $SKILL_MD_COUNT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "built $OUT ($COUNT files, $SIZE)"
|
||||
echo "upload via the claude.ai skill UI"
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
+3
-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:
|
||||
@@ -579,6 +579,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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
+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}")
|
||||
|
||||
+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()
|
||||
|
||||
+7
-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:
|
||||
|
||||
+60
-47
@@ -18,46 +18,53 @@ const SearchClient = withSearch(TwitterClientBase);
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// --check: verify that credentials can be resolved
|
||||
if (args.includes('--check')) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --whoami: check auth and output source
|
||||
if (args.includes('--whoami')) {
|
||||
// --whoami: check auth and output source
|
||||
if (args.includes('--whoami')) {
|
||||
try {
|
||||
const { cookies } = await resolveCredentials({});
|
||||
if (cookies.authToken && cookies.ct0) {
|
||||
process.stdout.write(cookies.source || 'authenticated');
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.stderr.write('Not authenticated\n');
|
||||
process.exit(1);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse search args
|
||||
let query = null;
|
||||
let count = 20;
|
||||
let jsonOutput = false;
|
||||
// Parse search args
|
||||
let query = null;
|
||||
let count = 20;
|
||||
let jsonOutput = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--count' && args[i + 1]) {
|
||||
count = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
@@ -69,28 +76,27 @@ for (let i = 0; i < args.length; i++) {
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
query = args[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
process.stderr.write('Usage: node bird-search.mjs <query> [--count N] [--json]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!query) {
|
||||
writeStderr('Usage: node bird-search.mjs <query> [--count N] [--json]\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
// Resolve credentials (env vars, then browser cookies)
|
||||
const { cookies, warnings } = await resolveCredentials({});
|
||||
|
||||
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);
|
||||
} catch (err) {
|
||||
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`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
process.exit(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;
|
||||
}
|
||||
|
||||
@@ -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", {})
|
||||
@@ -745,7 +737,7 @@ def _fetch_video_comments(
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"id": video_id})
|
||||
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:
|
||||
@@ -756,7 +748,7 @@ def _fetch_video_comments(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_YT_BASE}/video/comments",
|
||||
params={"id": video_id},
|
||||
headers=_sc_headers(token),
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -906,7 +898,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 +910,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()
|
||||
@@ -944,7 +936,7 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"id": video_id})
|
||||
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:
|
||||
@@ -955,7 +947,7 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_YT_BASE}/video/transcript",
|
||||
params={"id": video_id},
|
||||
headers=_sc_headers(token),
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
|
||||
+1
-1
@@ -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"
|
||||
"$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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: last30days-v3-spec
|
||||
version: "3.0.0"
|
||||
version: "3.0.1"
|
||||
description: "Internal architecture spec for the v3 last30days runtime pipeline. Not user-invocable."
|
||||
argument-hint: "last30days codex vs claude code"
|
||||
allowed-tools: Bash, Read, Write, WebSearch
|
||||
|
||||
@@ -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)
|
||||
|
||||
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":"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"}
|
||||
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":"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"}
|
||||
-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
|
||||
-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 registerNewsCommand(program: Command, ctx: CliContext): void;
|
||||
//# sourceMappingURL=news.d.ts.map
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"news.d.ts","sourceRoot":"","sources":["../../src/commands/news.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAmEnD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAuG3E"}
|
||||
Vendored
-131
@@ -1,131 +0,0 @@
|
||||
import { TwitterClient } from '../lib/twitter-client.js';
|
||||
function formatPostCount(count) {
|
||||
if (count >= 1_000_000) {
|
||||
return `${(count / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (count >= 1_000) {
|
||||
return `${(count / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return String(count);
|
||||
}
|
||||
function printNewsItems(items, ctx, opts = {}) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(items, null, 2));
|
||||
return;
|
||||
}
|
||||
if (items.length === 0) {
|
||||
console.log(opts.emptyMessage ?? 'No news items found.');
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const categoryLabel = item.category ? `[${item.category}]` : '';
|
||||
console.log(`\n${ctx.colors.accent(categoryLabel)} ${ctx.colors.command(item.headline)}`);
|
||||
if (item.description) {
|
||||
console.log(` ${ctx.colors.muted(item.description)}`);
|
||||
}
|
||||
const meta = [];
|
||||
if (item.timeAgo) {
|
||||
meta.push(item.timeAgo);
|
||||
}
|
||||
if (item.postCount) {
|
||||
meta.push(`${formatPostCount(item.postCount)} posts`);
|
||||
}
|
||||
if (meta.length > 0) {
|
||||
console.log(` ${ctx.colors.muted(meta.join(' | '))}`);
|
||||
}
|
||||
if (item.url) {
|
||||
console.log(` ${ctx.l('url')}${item.url}`);
|
||||
}
|
||||
// Print related tweets if available
|
||||
if (item.tweets && item.tweets.length > 0) {
|
||||
console.log(` ${ctx.colors.section('Related tweets:')}`);
|
||||
const tweetLimit = opts.tweetLimit ?? item.tweets.length;
|
||||
for (const tweet of item.tweets.slice(0, tweetLimit)) {
|
||||
console.log(` @${tweet.author.username}: ${tweet.text.slice(0, 100)}${tweet.text.length > 100 ? '...' : ''}`);
|
||||
}
|
||||
}
|
||||
console.log(ctx.colors.muted('─'.repeat(50)));
|
||||
}
|
||||
}
|
||||
export function registerNewsCommand(program, ctx) {
|
||||
program
|
||||
.command('news')
|
||||
.alias('trending')
|
||||
.description('Fetch AI-curated news and trending topics from Explore tabs')
|
||||
.option('-n, --count <number>', 'Number of items to fetch', '10')
|
||||
.option('--ai-only', 'Show only AI-curated news items')
|
||||
.option('--with-tweets', 'Also fetch related tweets for each news item')
|
||||
.option('--tweets-per-item <number>', 'Number of tweets to fetch per news item (default: 5)', '5')
|
||||
.option('--for-you', 'Fetch only from For You tab')
|
||||
.option('--news-only', 'Fetch only from News tab')
|
||||
.option('--sports', 'Fetch only from Sports tab')
|
||||
.option('--entertainment', 'Fetch only from Entertainment tab')
|
||||
.option('--trending-only', 'Fetch only from Trending tab')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
||||
.action(async (cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||
const count = Number.parseInt(cmdOpts.count || '10', 10);
|
||||
const tweetsPerItem = Number.parseInt(cmdOpts.tweetsPerItem || '5', 10);
|
||||
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
|
||||
for (const warning of warnings) {
|
||||
console.error(`${ctx.p('warn')}${warning}`);
|
||||
}
|
||||
if (Number.isNaN(count) || count < 1) {
|
||||
console.error(`${ctx.p('err')}--count must be a positive number`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (Number.isNaN(tweetsPerItem) || tweetsPerItem < 1) {
|
||||
console.error(`${ctx.p('err')}--tweets-per-item must be a positive number`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error(`${ctx.p('err')}Missing required credentials`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Determine which tabs to fetch from
|
||||
const tabs = [];
|
||||
if (cmdOpts.forYou) {
|
||||
tabs.push('forYou');
|
||||
}
|
||||
if (cmdOpts.newsOnly) {
|
||||
tabs.push('news');
|
||||
}
|
||||
if (cmdOpts.sports) {
|
||||
tabs.push('sports');
|
||||
}
|
||||
if (cmdOpts.entertainment) {
|
||||
tabs.push('entertainment');
|
||||
}
|
||||
if (cmdOpts.trendingOnly) {
|
||||
tabs.push('trending');
|
||||
}
|
||||
// If no specific tabs selected, use defaults (all tabs except trending)
|
||||
const tabsToFetch = tabs.length > 0 ? tabs : undefined;
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
const includeRaw = cmdOpts.jsonFull ?? false;
|
||||
const withTweets = cmdOpts.withTweets ?? false;
|
||||
const aiOnly = cmdOpts.aiOnly ?? false;
|
||||
const result = await client.getNews(count, {
|
||||
includeRaw,
|
||||
withTweets,
|
||||
tweetsPerItem,
|
||||
aiOnly,
|
||||
tabs: tabsToFetch,
|
||||
});
|
||||
if (result.success) {
|
||||
printNewsItems(result.items, ctx, {
|
||||
json: cmdOpts.json || cmdOpts.jsonFull,
|
||||
emptyMessage: 'No news items found.',
|
||||
tweetLimit: withTweets ? tweetsPerItem : undefined,
|
||||
});
|
||||
}
|
||||
else {
|
||||
console.error(`${ctx.p('err')}Failed to fetch news: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=news.js.map
|
||||
-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 registerPostCommands(program: Command, ctx: CliContext): void;
|
||||
//# sourceMappingURL=post.d.ts.map
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"post.d.ts","sourceRoot":"","sources":["../../src/commands/post.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAa,MAAM,kBAAkB,CAAC;AAyB9D,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CA4F5E"}
|
||||
Vendored
-101
@@ -1,101 +0,0 @@
|
||||
import { formatTweetUrlLine } from '../lib/output.js';
|
||||
import { TwitterClient } from '../lib/twitter-client.js';
|
||||
async function uploadMediaOrExit(client, media, ctx) {
|
||||
if (media.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const uploaded = [];
|
||||
for (const item of media) {
|
||||
const res = await client.uploadMedia({ data: item.buffer, mimeType: item.mime, alt: item.alt });
|
||||
if (!res.success || !res.mediaId) {
|
||||
console.error(`${ctx.p('err')}Media upload failed: ${res.error ?? 'Unknown error'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
uploaded.push(res.mediaId);
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
export function registerPostCommands(program, ctx) {
|
||||
program
|
||||
.command('tweet')
|
||||
.description('Post a new tweet')
|
||||
.argument('<text>', 'Tweet text')
|
||||
.action(async (text) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||
let media = [];
|
||||
try {
|
||||
media = ctx.loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
|
||||
for (const warning of warnings) {
|
||||
console.error(`${ctx.p('warn')}${warning}`);
|
||||
}
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error(`${ctx.p('err')}Missing required credentials`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (cookies.source) {
|
||||
console.error(`${ctx.l('source')}${cookies.source}`);
|
||||
}
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
const mediaIds = await uploadMediaOrExit(client, media, ctx);
|
||||
const result = await client.tweet(text, mediaIds);
|
||||
if (result.success) {
|
||||
console.log(`${ctx.p('ok')}Tweet posted successfully!`);
|
||||
console.log(formatTweetUrlLine(result.tweetId, ctx.getOutput()));
|
||||
}
|
||||
else {
|
||||
console.error(`${ctx.p('err')}Failed to post tweet: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
program
|
||||
.command('reply')
|
||||
.description('Reply to an existing tweet')
|
||||
.argument('<tweet-id-or-url>', 'Tweet ID or URL to reply to')
|
||||
.argument('<text>', 'Reply text')
|
||||
.action(async (tweetIdOrUrl, text) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||
let media = [];
|
||||
try {
|
||||
media = ctx.loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
|
||||
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
|
||||
for (const warning of warnings) {
|
||||
console.error(`${ctx.p('warn')}${warning}`);
|
||||
}
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error(`${ctx.p('err')}Missing required credentials`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (cookies.source) {
|
||||
console.error(`${ctx.l('source')}${cookies.source}`);
|
||||
}
|
||||
console.error(`${ctx.p('info')}Replying to tweet: ${tweetId}`);
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
const mediaIds = await uploadMediaOrExit(client, media, ctx);
|
||||
const result = await client.reply(text, tweetId, mediaIds);
|
||||
if (result.success) {
|
||||
console.log(`${ctx.p('ok')}Reply posted successfully!`);
|
||||
console.log(formatTweetUrlLine(result.tweetId, ctx.getOutput()));
|
||||
}
|
||||
else {
|
||||
console.error(`${ctx.p('err')}Failed to post reply: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=post.js.map
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"post.js","sourceRoot":"","sources":["../../src/commands/post.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEzD,KAAK,UAAU,iBAAiB,CAC9B,MAAqB,EACrB,KAAkB,EAClB,GAAe;IAEf,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvB,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,EAAE,QAAQ,EAAE,IAAI,CAAC,IAAI,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;QAChG,IAAI,CAAC,GAAG,CAAC,OAAO,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC;YACjC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,wBAAwB,GAAG,CAAC,KAAK,IAAI,eAAe,EAAE,CAAC,CAAC;YACrF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QACD,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAC7B,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,OAAgB,EAAE,GAAe;IACpE,OAAO;SACJ,OAAO,CAAC,OAAO,CAAC;SAChB,WAAW,CAAC,kBAAkB,CAAC;SAC/B,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;SAChC,MAAM,CAAC,KAAK,EAAE,IAAY,EAAE,EAAE;QAC7B,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC;QACtD,MAAM,UAAU,GAAG,GAAG,CAAC,4BAA4B,CAAC,IAAI,CAAC,CAAC;QAC1D,IAAI,KAAK,GAAgB,EAAE,CAAC;QAC5B,IAAI,CAAC;YACH,KAAK,GAAG,GAAG,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,IAAI,EAAE,EAAE,CAAC,CAAC;QAC3E,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;YAC1F,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,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,OAAO,CAAC,MAAM,EAAE,CAAC;YACnB,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;QACvD,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC,CAAC;QACrE,MAAM,QAAQ,GAAG,MAAM,iBAAiB,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,CAAC,CAAC;QAC7D,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;QAElD,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,4BAA4B,CAAC,CAAC;YACxD,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;QACnE,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,yBAAyB,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YACtE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;IAEL,OAAO;SACJ,OAAO,CAAC,OAAO,CAAC;SAChB,WAAW,CAAC,4BAA4B,CAAC;SACzC,QAAQ,CAAC,mBAAmB,EAAE,6BAA6B,CAAC;SAC5D,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;SAChC,MAAM,CAAC,KAAK,EAAE,YAAoB,EAAE,IAAY,EAAE,EAAE;QACnD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC;QACtD,MAAM,UAAU,GAAG,GAAG,CAAC,4BAA4B,CAAC,IAAI,CAAC,CAAC;QAC1D,IAAI,KAAK,GAAgB,EAAE,CAAC;QAC5B,IAAI,CAAC;YACH,KAAK,GAAG,GAAG,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,IAAI,EAAE,EAAE,CAAC,CAAC;QAC3E,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;YAC1F,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QACD,MAAM,OAAO,GAAG,GAAG,CAAC,cAAc,CAAC,YAAY,CAAC,CAAC;QAEjD,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,OAAO,CAAC,MAAM,EAAE,CAAC;YACnB,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;QACvD,CAAC;QAED,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,sBAAsB,OAAO,EAAE,CAAC,CAAC;QAE/D,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC,CAAC;QACrE,MAAM,QAAQ,GAAG,MAAM,iBAAiB,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,CAAC,CAAC;QAC7D,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC;QAE3D,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,4BAA4B,CAAC,CAAC;YACxD,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;QACnE,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,yBAAyB,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YACtE,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 registerQueryIdsCommand(program: Command, ctx: CliContext): void;
|
||||
//# sourceMappingURL=query-ids.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"query-ids.d.ts","sourceRoot":"","sources":["../../src/commands/query-ids.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAqBnD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAgF/E"}
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
import { getFeatureOverridesSnapshot, refreshFeatureOverridesCache, } from '../lib/runtime-features.js';
|
||||
import { runtimeQueryIds } from '../lib/runtime-query-ids.js';
|
||||
function countFeatureOverrides(overrides) {
|
||||
let count = 0;
|
||||
if (overrides.global) {
|
||||
count += Object.keys(overrides.global).length;
|
||||
}
|
||||
if (overrides.sets) {
|
||||
for (const setOverrides of Object.values(overrides.sets)) {
|
||||
count += Object.keys(setOverrides).length;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
export function registerQueryIdsCommand(program, ctx) {
|
||||
program
|
||||
.command('query-ids')
|
||||
.description('Show or refresh cached Twitter GraphQL query IDs')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--fresh', 'Force refresh (downloads X client bundles)', false)
|
||||
.action(async (cmdOpts) => {
|
||||
const operations = [
|
||||
'CreateTweet',
|
||||
'CreateRetweet',
|
||||
'FavoriteTweet',
|
||||
'TweetDetail',
|
||||
'SearchTimeline',
|
||||
'UserArticlesTweets',
|
||||
'Bookmarks',
|
||||
'Following',
|
||||
'Followers',
|
||||
'Likes',
|
||||
];
|
||||
if (cmdOpts.fresh) {
|
||||
console.error(`${ctx.p('info')}Refreshing GraphQL query IDs…`);
|
||||
await runtimeQueryIds.refresh(operations, { force: true });
|
||||
console.error(`${ctx.p('info')}Refreshing feature overrides…`);
|
||||
await refreshFeatureOverridesCache();
|
||||
}
|
||||
const featureSnapshot = getFeatureOverridesSnapshot();
|
||||
const info = await runtimeQueryIds.getSnapshotInfo();
|
||||
if (!info) {
|
||||
if (cmdOpts.json) {
|
||||
console.log(JSON.stringify({
|
||||
cached: false,
|
||||
cachePath: runtimeQueryIds.cachePath,
|
||||
featuresPath: featureSnapshot.cachePath,
|
||||
features: featureSnapshot.overrides,
|
||||
}, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`${ctx.p('warn')}No cached query IDs yet.`);
|
||||
console.log(`${ctx.p('info')}Run: bird query-ids --fresh`);
|
||||
console.log(`features_path: ${featureSnapshot.cachePath}`);
|
||||
return;
|
||||
}
|
||||
if (cmdOpts.json) {
|
||||
console.log(JSON.stringify({
|
||||
cached: true,
|
||||
cachePath: info.cachePath,
|
||||
fetchedAt: info.snapshot.fetchedAt,
|
||||
isFresh: info.isFresh,
|
||||
ageMs: info.ageMs,
|
||||
ids: info.snapshot.ids,
|
||||
discovery: info.snapshot.discovery,
|
||||
featuresPath: featureSnapshot.cachePath,
|
||||
features: featureSnapshot.overrides,
|
||||
}, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`${ctx.p('ok')}GraphQL query IDs cached`);
|
||||
console.log(`path: ${info.cachePath}`);
|
||||
console.log(`fetched_at: ${info.snapshot.fetchedAt}`);
|
||||
console.log(`fresh: ${info.isFresh ? 'yes' : 'no'}`);
|
||||
console.log(`ops: ${Object.keys(info.snapshot.ids).length}`);
|
||||
console.log(`features_path: ${featureSnapshot.cachePath}`);
|
||||
console.log(`features: ${countFeatureOverrides(featureSnapshot.overrides)}`);
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=query-ids.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"query-ids.js","sourceRoot":"","sources":["../../src/commands/query-ids.ts"],"names":[],"mappings":"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"}
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
import type { CliContext } from '../cli/shared.js';
|
||||
export declare function registerReadCommands(program: Command, ctx: CliContext): void;
|
||||
//# sourceMappingURL=read.d.ts.map
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../../src/commands/read.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAInD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAqM5E"}
|
||||
Vendored
-152
@@ -1,152 +0,0 @@
|
||||
import { parsePaginationFlags } from '../cli/pagination.js';
|
||||
import { formatStatsLine } from '../lib/output.js';
|
||||
import { TwitterClient } from '../lib/twitter-client.js';
|
||||
export function registerReadCommands(program, ctx) {
|
||||
program
|
||||
.command('read')
|
||||
.description('Read/fetch a tweet by ID or URL')
|
||||
.argument('<tweet-id-or-url>', 'Tweet ID or URL to read')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
||||
.action(async (tweetIdOrUrl, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
|
||||
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
|
||||
for (const warning of warnings) {
|
||||
console.error(`${ctx.p('warn')}${warning}`);
|
||||
}
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error(`${ctx.p('err')}Missing required credentials`);
|
||||
process.exit(1);
|
||||
}
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
const includeRaw = cmdOpts.jsonFull ?? false;
|
||||
const result = await client.getTweet(tweetId, { includeRaw });
|
||||
if (result.success && result.tweet) {
|
||||
if (cmdOpts.json || cmdOpts.jsonFull) {
|
||||
console.log(JSON.stringify(result.tweet, null, 2));
|
||||
}
|
||||
else {
|
||||
ctx.printTweets([result.tweet], { showSeparator: false });
|
||||
console.log(formatStatsLine(result.tweet, ctx.getOutput()));
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.error(`${ctx.p('err')}Failed to read tweet: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
program
|
||||
.command('replies')
|
||||
.description('List replies to a tweet (by ID or URL)')
|
||||
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
||||
.option('--all', 'Fetch all replies (paged)')
|
||||
.option('--max-pages <number>', 'Fetch N pages (implies pagination)')
|
||||
.option('--delay <ms>', 'Delay in ms between page fetches', '1000')
|
||||
.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 (tweetIdOrUrl, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
|
||||
const pagination = parsePaginationFlags(cmdOpts, { maxPagesImpliesPagination: true, includeDelay: true });
|
||||
if (!pagination.ok) {
|
||||
console.error(`${ctx.p('err')}${pagination.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
|
||||
for (const warning of warnings) {
|
||||
console.error(`${ctx.p('warn')}${warning}`);
|
||||
}
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error(`${ctx.p('err')}Missing required credentials`);
|
||||
process.exit(1);
|
||||
}
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
const includeRaw = cmdOpts.jsonFull ?? false;
|
||||
const result = pagination.usePagination
|
||||
? await client.getRepliesPaged(tweetId, {
|
||||
includeRaw,
|
||||
maxPages: pagination.maxPages,
|
||||
cursor: pagination.cursor,
|
||||
pageDelayMs: pagination.pageDelayMs,
|
||||
})
|
||||
: await client.getReplies(tweetId, { includeRaw });
|
||||
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
|
||||
if (result.tweets) {
|
||||
ctx.printTweetsResult(result, {
|
||||
json: isJson,
|
||||
usePagination: pagination.usePagination,
|
||||
emptyMessage: 'No replies found.',
|
||||
});
|
||||
// Show pagination hint if there's more
|
||||
if (result.nextCursor && !isJson) {
|
||||
console.error(`${ctx.p('info')}More replies available. Use --cursor "${result.nextCursor}" to continue.`);
|
||||
}
|
||||
}
|
||||
if (!result.success) {
|
||||
console.error(`${ctx.p('err')}Failed to fetch replies: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
program
|
||||
.command('thread')
|
||||
.description('Show the full conversation thread containing the tweet')
|
||||
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
||||
.option('--all', 'Fetch all thread tweets (paged)')
|
||||
.option('--max-pages <number>', 'Fetch N pages (implies pagination)')
|
||||
.option('--delay <ms>', 'Delay in ms between page fetches', '1000')
|
||||
.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 (tweetIdOrUrl, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
|
||||
const pagination = parsePaginationFlags(cmdOpts, { maxPagesImpliesPagination: true, includeDelay: true });
|
||||
if (!pagination.ok) {
|
||||
console.error(`${ctx.p('err')}${pagination.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
|
||||
for (const warning of warnings) {
|
||||
console.error(`${ctx.p('warn')}${warning}`);
|
||||
}
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error(`${ctx.p('err')}Missing required credentials`);
|
||||
process.exit(1);
|
||||
}
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
const includeRaw = cmdOpts.jsonFull ?? false;
|
||||
const result = pagination.usePagination
|
||||
? await client.getThreadPaged(tweetId, {
|
||||
includeRaw,
|
||||
maxPages: pagination.maxPages,
|
||||
cursor: pagination.cursor,
|
||||
pageDelayMs: pagination.pageDelayMs,
|
||||
})
|
||||
: await client.getThread(tweetId, { includeRaw });
|
||||
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
|
||||
if (result.tweets) {
|
||||
ctx.printTweetsResult(result, {
|
||||
json: isJson,
|
||||
usePagination: pagination.usePagination,
|
||||
emptyMessage: 'No thread tweets found.',
|
||||
});
|
||||
// Show pagination hint if there's more
|
||||
if (result.nextCursor && !isJson) {
|
||||
console.error(`${ctx.p('info')}More thread tweets available. Use --cursor "${result.nextCursor}" to continue.`);
|
||||
}
|
||||
}
|
||||
if (!result.success) {
|
||||
console.error(`${ctx.p('err')}Failed to fetch thread: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=read.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 registerSearchCommands(program: Command, ctx: CliContext): void;
|
||||
//# sourceMappingURL=search.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/commands/search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAInD,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CA0I9E"}
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
import { parsePaginationFlags } from '../cli/pagination.js';
|
||||
import { mentionsQueryFromUserOption, normalizeHandle } from '../lib/normalize-handle.js';
|
||||
import { TwitterClient } from '../lib/twitter-client.js';
|
||||
export function registerSearchCommands(program, ctx) {
|
||||
program
|
||||
.command('search')
|
||||
.description('Search for tweets')
|
||||
.argument('<query>', 'Search query (e.g., "@clawdbot" or "from:clawdbot")')
|
||||
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
|
||||
.option('--all', 'Fetch all search results (paged)')
|
||||
.option('--max-pages <number>', 'Stop after N pages when using --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 (query, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||
const count = Number.parseInt(cmdOpts.count || '10', 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, quoteDepth });
|
||||
const includeRaw = cmdOpts.jsonFull ?? false;
|
||||
const searchOptions = { includeRaw };
|
||||
const paginationOptions = { includeRaw, maxPages, cursor: pagination.cursor };
|
||||
const result = usePagination
|
||||
? await client.getAllSearchResults(query, paginationOptions)
|
||||
: await client.search(query, count, searchOptions);
|
||||
if (result.success) {
|
||||
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
|
||||
ctx.printTweetsResult(result, {
|
||||
json: isJson,
|
||||
usePagination: Boolean(usePagination),
|
||||
emptyMessage: 'No tweets found.',
|
||||
});
|
||||
}
|
||||
else {
|
||||
console.error(`${ctx.p('err')}Search failed: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
program
|
||||
.command('mentions')
|
||||
.description('Find tweets mentioning a user (defaults to current user)')
|
||||
.option('-u, --user <handle>', 'User handle (e.g. @steipete)')
|
||||
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
||||
.action(async (cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||
const count = Number.parseInt(cmdOpts.count || '10', 10);
|
||||
const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user);
|
||||
if (fromUserOpt.error) {
|
||||
console.error(`${ctx.p('err')}${fromUserOpt.error}`);
|
||||
process.exit(2);
|
||||
}
|
||||
let query = fromUserOpt.query;
|
||||
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 });
|
||||
if (!query) {
|
||||
const who = await client.getCurrentUser();
|
||||
const handle = normalizeHandle(who.user?.username);
|
||||
if (handle) {
|
||||
query = `@${handle}`;
|
||||
}
|
||||
else {
|
||||
console.error(`${ctx.p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const includeRaw = cmdOpts.jsonFull ?? false;
|
||||
const result = await client.search(query, count, { includeRaw });
|
||||
if (result.success) {
|
||||
ctx.printTweets(result.tweets, {
|
||||
json: cmdOpts.json || cmdOpts.jsonFull,
|
||||
emptyMessage: 'No mentions found.',
|
||||
});
|
||||
}
|
||||
else {
|
||||
console.error(`${ctx.p('err')}Failed to fetch mentions: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=search.js.map
|
||||
-1
File diff suppressed because one or more lines are too long
@@ -1,4 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
import type { CliContext } from '../cli/shared.js';
|
||||
export declare function registerUnbookmarkCommand(program: Command, ctx: CliContext): void;
|
||||
//# sourceMappingURL=unbookmark.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"unbookmark.d.ts","sourceRoot":"","sources":["../../src/commands/unbookmark.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAGnD,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAsCjF"}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
import { TwitterClient } from '../lib/twitter-client.js';
|
||||
export function registerUnbookmarkCommand(program, ctx) {
|
||||
program
|
||||
.command('unbookmark')
|
||||
.description('Remove bookmarked tweets')
|
||||
.argument('<tweet-id-or-url...>', 'Tweet IDs or URLs to remove from bookmarks')
|
||||
.action(async (tweetIdOrUrls) => {
|
||||
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 });
|
||||
let failures = 0;
|
||||
for (const input of tweetIdOrUrls) {
|
||||
const tweetId = ctx.extractTweetId(input);
|
||||
const result = await client.unbookmark(tweetId);
|
||||
if (result.success) {
|
||||
console.log(`${ctx.p('ok')}Removed bookmark for ${tweetId}`);
|
||||
}
|
||||
else {
|
||||
failures += 1;
|
||||
console.error(`${ctx.p('err')}Failed to remove bookmark for ${tweetId}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
if (failures > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=unbookmark.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"unbookmark.js","sourceRoot":"","sources":["../../src/commands/unbookmark.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEzD,MAAM,UAAU,yBAAyB,CAAC,OAAgB,EAAE,GAAe;IACzE,OAAO;SACJ,OAAO,CAAC,YAAY,CAAC;SACrB,WAAW,CAAC,0BAA0B,CAAC;SACvC,QAAQ,CAAC,sBAAsB,EAAE,4CAA4C,CAAC;SAC9E,MAAM,CAAC,KAAK,EAAE,aAAuB,EAAE,EAAE;QACxC,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;QACzD,IAAI,QAAQ,GAAG,CAAC,CAAC;QAEjB,KAAK,MAAM,KAAK,IAAI,aAAa,EAAE,CAAC;YAClC,MAAM,OAAO,GAAG,GAAG,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC;YAC1C,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;YAChD,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;gBACnB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,wBAAwB,OAAO,EAAE,CAAC,CAAC;YAC/D,CAAC;iBAAM,CAAC;gBACN,QAAQ,IAAI,CAAC,CAAC;gBACd,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,iCAAiC,OAAO,KAAK,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YAC5F,CAAC;QACH,CAAC;QAED,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;YACjB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC"}
|
||||
@@ -1,4 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
import type { CliContext } from '../cli/shared.js';
|
||||
export declare function registerUserTweetsCommand(program: Command, ctx: CliContext): void;
|
||||
//# sourceMappingURL=user-tweets.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"user-tweets.d.ts","sourceRoot":"","sources":["../../src/commands/user-tweets.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAInD,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAwIjF"}
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
import { parseNonNegativeIntFlag, parsePositiveIntFlag } from '../cli/pagination.js';
|
||||
import { normalizeHandle } from '../lib/normalize-handle.js';
|
||||
import { TwitterClient } from '../lib/twitter-client.js';
|
||||
export function registerUserTweetsCommand(program, ctx) {
|
||||
const formatExample = (cmd, desc) => ` ${ctx.colors.command(cmd)}\n ${ctx.colors.muted(desc)}`;
|
||||
program
|
||||
.command('user-tweets')
|
||||
.description("Get tweets from a user's profile timeline")
|
||||
.argument('<handle>', 'Username to fetch tweets from (e.g., @steipete or steipete)')
|
||||
.option('-n, --count <number>', 'Number of tweets to fetch', '20')
|
||||
.option('--max-pages <number>', 'Stop after N pages (max: 10)')
|
||||
.option('--delay <ms>', 'Delay in ms between page fetches', '1000')
|
||||
.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')
|
||||
.addHelpText('after', () => `\n${ctx.colors.section('Command Examples')}\n${[
|
||||
formatExample('bird user-tweets @steipete', 'Get recent tweets from a user'),
|
||||
formatExample('bird user-tweets steipete -n 10', 'Get 10 tweets (@ is optional)'),
|
||||
formatExample('bird user-tweets @steipete -n 50', 'Fetch 50 tweets (paged)'),
|
||||
formatExample('bird user-tweets @steipete --max-pages 3 -n 200', 'Safety cap (max 3 pages)'),
|
||||
formatExample('bird user-tweets @steipete --json', 'Output as JSON'),
|
||||
formatExample('bird user-tweets @steipete --cursor "DAABCg..."', 'Resume from cursor'),
|
||||
].join('\n')}`)
|
||||
.action(async (handle, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||
const count = Number.parseInt(cmdOpts.count || '20', 10);
|
||||
const maxPagesParsed = parsePositiveIntFlag(cmdOpts.maxPages, '--max-pages');
|
||||
if (!maxPagesParsed.ok) {
|
||||
console.error(`${ctx.p('err')}${maxPagesParsed.error}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const maxPages = maxPagesParsed.value;
|
||||
const delayParsed = parseNonNegativeIntFlag(cmdOpts.delay, '--delay', 1000);
|
||||
if (!delayParsed.ok) {
|
||||
console.error(`${ctx.p('err')}${delayParsed.error}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const pageDelayMs = delayParsed.value;
|
||||
// Validate inputs
|
||||
if (!Number.isFinite(count) || count <= 0) {
|
||||
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
|
||||
process.exit(2);
|
||||
}
|
||||
const pageSize = 20;
|
||||
const hardMaxPages = 10;
|
||||
const hardMaxTweets = pageSize * hardMaxPages;
|
||||
if (count > hardMaxTweets) {
|
||||
console.error(`${ctx.p('err')}Invalid --count. Max ${hardMaxTweets} tweets per run (safety cap: ${hardMaxPages} pages). Use --cursor to continue.`);
|
||||
process.exit(2);
|
||||
}
|
||||
if (maxPages !== undefined && maxPages > hardMaxPages) {
|
||||
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer (max: ${hardMaxPages}).`);
|
||||
process.exit(2);
|
||||
}
|
||||
// Normalize handle (strip @ if present)
|
||||
const username = normalizeHandle(handle);
|
||||
if (!username) {
|
||||
console.error(`${ctx.p('err')}Invalid handle: ${handle}`);
|
||||
process.exit(2);
|
||||
}
|
||||
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 });
|
||||
// Look up user ID from username
|
||||
console.error(`${ctx.p('info')}Looking up @${username}...`);
|
||||
const userLookup = await client.getUserIdByUsername(username);
|
||||
if (!userLookup.success || !userLookup.userId) {
|
||||
console.error(`${ctx.p('err')}${userLookup.error || `Could not find user @${username}`}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const displayName = userLookup.name
|
||||
? `${userLookup.name} (@${userLookup.username})`
|
||||
: `@${userLookup.username}`;
|
||||
console.error(`${ctx.p('info')}Fetching tweets from ${displayName}...`);
|
||||
const includeRaw = cmdOpts.jsonFull ?? false;
|
||||
const wantsPaginationOutput = Boolean(cmdOpts.cursor) || maxPages !== undefined || count > pageSize;
|
||||
const result = await client.getUserTweetsPaged(userLookup.userId, count, {
|
||||
includeRaw,
|
||||
maxPages,
|
||||
cursor: cmdOpts.cursor,
|
||||
pageDelayMs,
|
||||
});
|
||||
if (result.success) {
|
||||
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
|
||||
ctx.printTweetsResult(result, {
|
||||
json: isJson,
|
||||
usePagination: wantsPaginationOutput,
|
||||
emptyMessage: `No tweets found for @${username}.`,
|
||||
});
|
||||
// Show pagination hint if there's more
|
||||
if (result.nextCursor && !cmdOpts.json && !cmdOpts.jsonFull) {
|
||||
console.error(`${ctx.p('info')}More tweets available. Use --cursor "${result.nextCursor}" to continue.`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.error(`${ctx.p('err')}Failed to fetch tweets: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=user-tweets.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