Author SHA1 Message Date
Peter Steinberger b771e827aa Merge pull request #48 from jsholmes/fix/tweet-detail-partial-errors
fix: handle partial errors in TweetDetail API responses
2026-01-12 07:33:40 +00:00
Peter Steinberger afdcad4f25 fix: allow partial tweet detail errors (#48) (thanks @jsholmes) 2026-01-12 07:33:25 +00:00
John HolmesandClaude Opus 4.5 35b8774b1c fix: handle partial errors in TweetDetail API responses
Twitter API sometimes returns partial errors (e.g., is_translatable field
failures) alongside valid thread data. Previously, any error caused the
entire request to fail even when usable data was present.

Now we check if threaded_conversation_with_injections_v2.instructions
exists before failing on errors, allowing successful thread fetching
despite non-fatal API errors.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-12 07:32:42 +00:00
Peter Steinberger 57c8e8e793 Merge pull request #44 from jsholmes/feat/likes-pagination
feat: add full pagination support to likes (parity with bookmarks)
2026-01-12 07:09:02 +00:00
Peter Steinberger afa7063a72 fix: guard pagination limits (#44) (thanks @jsholmes) 2026-01-12 07:07:47 +00:00
Peter Steinberger bc744a3bf9 feat: add full pagination support to likes (parity with bookmarks)
Library changes:
- Add getAllLikes(options) method for fetching all likes with cursor support
- Refactor getLikes to use private getLikesPaged helper
- Return nextCursor for resumable pagination
- Support maxPages option to limit pages fetched

CLI changes:
- Add --all flag to fetch all likes
- Add --max-pages to limit pagination
- Add --cursor to resume from previous fetch
- Output nextCursor in JSON mode for resumable pagination

This brings likes to feature parity with bookmarks.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

# Conflicts:
#	src/commands/users.ts
2026-01-12 07:06:18 +00:00
Peter Steinberger 9cfeda13b2 test: add search CLI pagination checks 2026-01-12 06:44:42 +00:00
Peter Steinberger 35f8fa1a38 Merge pull request #42 from pjtf93/feat/search-pagination
feat: add pagination support to search command
2026-01-12 06:31:15 +00:00
Peter Steinberger a782ca5d3a fix: search pagination output (#42) (thanks @pjtf93) 2026-01-12 06:30:20 +00:00
Pablo Tovar e23c20a6a2 feat: add pagination support to search command
Adds --all, --max-pages, and --cursor options to search command, enabling users to fetch all search results through automatic pagination. Follows the same pattern as bookmarks pagination for consistency.
2026-01-12 06:29:27 +00:00
Peter Steinberger f351414046 fix: harden home timeline live checks 2026-01-12 06:10:57 +00:00
Peter Steinberger 731c149c85 Merge pull request #39 from aavetis/news-feature
feat: Add Grok generated News headlines
2026-01-12 05:39:05 +00:00
Peter Steinberger c96ce5238f fix: tighten news output and parsing (#39) (thanks @aavetis) 2026-01-12 05:37:44 +00:00
aavetisandClaude Sonnet 4.5 fb092f5cec feat: add multi-tab fetching to news command for 5x more headlines
Completely rewrites the news command to fetch from multiple Explore tabs
(For You, News, Sports, Entertainment) using GenericTimelineById GraphQL
API instead of just the ExplorePage initialTimeline. This delivers 15+
AI-curated headlines instead of 3, a 5x improvement in content discovery.

Key Changes:
- Add GenericTimelineById query ID support with auto-refresh capability
- Implement multi-tab fetching with cross-tab headline deduplication
- Add CLI flags for granular tab filtering (--for-you, --news-only,
  --sports, --entertainment, --trending-only)
- Fetch from 4 tabs by default (excludes trending to reduce noise)
- Add early stopping optimization when count is reached
- Handle tab-level errors gracefully without failing entire request

Implementation:
- Added TIMELINE_IDS constant with base64 timeline identifiers
- Created fetchTimelineTab() method for GenericTimelineById requests
- Created parseTimelineTabItems() for new response structure
- Removed old parseNewsItems() and extractNewsItemsFromInstructions()
- Updated all 7 tests to mock GenericTimelineById responses

API Changes:
- NewsFetchOptions: Added `tabs?: ExploreTab[]` option
- ExploreTab type exported for library consumers
- Backward compatible - existing code continues to work

Documentation:
- Added comprehensive "News & Trending" section with examples
- Updated command reference with all new flags
- Added library usage examples with tab filtering
- Updated JSON schema documentation

Testing:
- All 268 tests passing
- Real-world verified: fetches 15+ AI headlines across tabs
- Tab filtering verified: --sports, --entertainment, etc all work

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-01-12 05:33:19 +00:00
aavetisandClaude Sonnet 4.5 57d1498f1d feat: add news command to fetch AI-curated headlines
Add comprehensive news/trending feature that fetches AI-generated news
headlines from Twitter's "For You" page. This feature specifically
targets the AI-curated news items that Twitter displays under "Today's
News" section, not generic trending hashtags.

- Uses Twitter's ExplorePage GraphQL API endpoint to access the Explore
  page timeline data where AI headlines are located
- AI headlines are identified by the explicit `is_ai_trend: true` flag
  in the API response
- Fallback heuristic detection for full-sentence headlines (5+ words)
  with "News" or time indicators in social context
- Headlines are found in "stories-*" modules within the initialTimeline
  structure under a "Today's News" header
- Follows the existing mixin pattern (withNews) for composability
- Supports both AI-only filtering and mixed trending/AI results

- `bird news` - Fetch news and trending topics (mixed results)
- `bird news --ai-only` - Fetch ONLY AI-curated headlines
- `bird news -n <count>` - Limit number of results
- `bird news --json` - Output as JSON
- `bird news --json-full` - Include raw API response
- `bird news --with-tweets` - Enrich with related tweets
- `bird trending` - Alias for news command
- AI headlines are clearly marked with "AI · " category prefix
- Automatic deduplication of duplicate headlines
- Rich formatting with category, time, post count, and URLs

- src/commands/news.ts - CLI command implementation
- src/lib/twitter-client-news.ts - Core news fetching functionality
- tests/commands.news.test.ts - Command validation tests (7 tests)
- tests/twitter-client-coverage.news.test.ts - API coverage tests (7 tests)

- README.md - Added documentation for news command
- src/cli/program.ts - Registered news command
- src/lib/index.ts - Exported news-related types
- src/lib/twitter-client.ts - Integrated withNews mixin
- src/lib/query-ids.json - Added ExplorePage query ID
- src/lib/twitter-client-constants.ts - Added ExplorePage constant
- src/lib/twitter-client-features.ts - Added buildExploreFeatures()
- scripts/update-query-ids.ts - Added ExplorePage to update script

- All 268 existing tests continue to pass
- Added 14 new tests covering command validation and API functionality
- Tested with real Twitter data confirming AI headline detection
- All TypeScript compilation and linting checks pass

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-01-12 05:32:06 +00:00
Peter Steinberger abc6db411d docs: add live longform test note 2026-01-12 05:22:00 +00:00
Peter Steinberger cbf99a12ab Merge pull request #36 from crcatala/feature/longform-tweet-rich-content
feat: support rich content in long-form tweets (X Articles)
2026-01-12 05:14:53 +00:00
Peter Steinberger 28695d2ef0 fix: harden article rich content rendering (#36) (thanks @crcatala) 2026-01-12 05:14:39 +00:00
cc-vpsandChristian Catalan 316cdf77be feat: support rich content in long-form tweets (X Articles)
Add support for extracting rich content from X's long-form tweets,
including embedded code snippets, markdown blocks, quoted tweets,
and other structured content that was previously lost.

Changes:
- Add fieldToggles with withArticleRichContentState to TweetDetail API request
- Implement Draft.js content_state parser (renderContentState) that converts
  blocks and entities to readable markdown format
- Add content_state type definition to GraphqlTweetResult

Supported content:
- Block types: paragraphs, headers, ordered/unordered lists, blockquotes
- Entity types: MARKDOWN (code blocks), DIVIDER, TWEET, LINK, IMAGE

No impact on regular tweets - rich content only adds payload when present.

Includes 20 unit tests for the parser and an opt-in live smoke test.

Co-authored-by: Christian Catalan <[email protected]>
2026-01-12 05:12:33 +00:00
Peter Steinberger e0b960db1a fix: repair search and user-tweets requests 2026-01-12 05:09:28 +00:00
Peter Steinberger b51258c4e8 Merge pull request #49 from steipete/refactor/pagination-cleanup
refactor: unify pagination helpers
2026-01-12 02:59:07 +00:00
Peter Steinberger d5a527d5fb refactor: unify pagination helpers 2026-01-12 02:58:00 +00:00
Peter Steinberger e4d2773d46 Merge pull request #35 from crcatala/feat/thread-replies-pagination
feat: add pagination support for thread and replies commands
2026-01-12 02:42:17 +00:00
Peter Steinberger 16f21800b5 refactor: align replies/thread pagination (#35) (thanks @crcatala) 2026-01-12 02:41:08 +00:00
cc-vpsandChristian Catalan 90037ce880 feat: add pagination support for thread and replies commands
Add --all, --max-pages, --delay, and --cursor options to both thread
and replies commands, enabling fetching of all conversation content
with rate-limit-friendly pagination.

Changes:
- Modified fetchTweetDetail() to accept optional cursor parameter
- Added getRepliesPaged() and getThreadPaged() methods with pagination loop
- Updated CLI commands with new options (--all, --max-pages, --delay, --cursor)
- Added TweetDetailPaginationOptions interface
- JSON output includes nextCursor for scripting/resumption

Options:
- --all: Fetch all pages (no hard limit)
- --max-pages <n>: Limit number of pages when using --all
- --delay <ms>: Delay between page fetches (default: 1000ms)
- --cursor <string>: Resume from a previous cursor

Follows patterns established by bookmarks command and PR #34 (user-tweets).

Co-authored-by: Christian Catalan <[email protected]>
2026-01-12 02:35:36 +00:00
Peter Steinberger c7e2ec0495 Merge pull request #47 from steipete/chore/bump-0.7.0-unreleased
chore: start 0.7.0 (unreleased)
2026-01-12 02:20:50 +00:00
Peter Steinberger 762738484a chore: start 0.7.0 (unreleased) 2026-01-12 02:19:54 +00:00
Peter Steinberger 2d1f784415 Merge pull request #46 from steipete/fix/ci-build-dist
fix: restore build:dist and gate in CI
2026-01-12 02:17:20 +00:00
Peter Steinberger c7acdbd3fd fix: restore build:dist and gate in CI 2026-01-12 02:16:37 +00:00
Peter Steinberger da5d81d11c Merge pull request #45 from steipete/refactor/user-tweets-cleanup
refactor: user lookup + paging output cleanup
2026-01-12 02:10:53 +00:00
Peter Steinberger 9dd9efa4e3 test: split user lookup coverage 2026-01-12 02:09:52 +00:00
Peter Steinberger 436d4dfb89 refactor: centralize paged tweet printing 2026-01-12 02:09:47 +00:00
Peter Steinberger 5c0d5d1068 refactor: dedupe REST user parsing 2026-01-12 02:09:42 +00:00
Peter Steinberger 0536006012 refactor: extract user lookup mixin 2026-01-12 02:09:37 +00:00
Peter Steinberger 2f0d239b62 Merge pull request #43 from steipete/test/user-tweets-more
test: expand user-tweets coverage
2026-01-12 01:48:44 +00:00
Peter Steinberger 11cc9fbd27 test: expand user-tweets coverage 2026-01-12 01:45:41 +00:00
Peter Steinberger 64b0460c4d Merge pull request #34 from crcatala/feat/user-tweets-command
feat: add user-tweets command to fetch tweets from a user's profile
2026-01-12 01:42:24 +00:00
Peter Steinberger 21701bad5a fix: repair pnpm patchedDependencies 2026-01-12 01:41:10 +00:00
Peter Steinberger 7b5f4b3f2d docs: document user-tweets 2026-01-12 01:35:04 +00:00
Peter Steinberger 4eac0927c1 fix: paginate user-tweets by --count 2026-01-12 01:35:00 +00:00
cc-vpsandChristian Catalan df98e827f6 feat: add user-tweets command to fetch tweets from a user's profile
Adds new CLI command to fetch tweets from any user's profile timeline.

Usage:
  bird user-tweets @username [-n count] [--pages N] [--json]

Features:
- GraphQL UserByScreenName for username → userId resolution
- Cursor-based pagination with configurable delay (rate-limit safe)
- Hard cap of 10 pages to prevent abuse
- Input validation via normalizeHandle()

Includes 17 unit tests and live smoke test.

Co-authored-by: Christian Catalan <[email protected]>
2026-01-12 01:28:04 +00:00
Peter Steinberger 46255e8a38 test: add followers cursor coverage 2026-01-12 00:57:24 +00:00
Peter Steinberger c115232135 Merge pull request #33 from VACInc/feat/following-pagination
feat(following): add pagination support for following list
2026-01-12 00:47:07 +00:00
Peter Steinberger dec78547fe test: split following/likes tests (#33) (thanks @VACInc) 2026-01-12 00:46:32 +00:00
Peter Steinberger baede65f77 fix: following pagination returns cursor and errors (#33) (thanks @VACInc) 2026-01-12 00:41:34 +00:00
Peter Steinberger 138b643d8a test: add print output coverage (#32) (thanks @odysseus0) 2026-01-11 23:38:44 +00:00
Peter Steinberger 5fc6f53871 Merge pull request #32 from odysseus0/feat/text-output
feat: improve text output with article preview, quote tweets, and media
2026-01-11 23:35:48 +00:00
Peter Steinberger ba22d86f70 docs: changelog for text output (#32) (thanks @odysseus0) 2026-01-11 23:35:35 +00:00
Peter Steinberger 15aa24d2b1 fix: honor output flags in text rendering 2026-01-11 23:35:03 +00:00
George ZhangandClaude Opus 4.5 a48aa6db88 fix: remove duplicate title field in GraphqlTweetResult article type
The article type had title defined twice (at lines 83 and 135), causing
TypeScript compilation to fail with TS2300: Duplicate identifier 'title'.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-11 23:35:03 +00:00
George ZhangandClaude Opus 4.5 d40c048333 fix: improve article detection and quote tweet display
- Use startsWith check instead of fragile length heuristic for
  detecting full article body vs preview mode
- Add media display for quote tweets (🖼️/🎬/🔄 indicators)
- Add preview_text to GraphqlTweetResult type definition to avoid
  type assertions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-11 23:35:03 +00:00
George Zhang 0475a2e1b6 test: add tests for article metadata extraction
- extractArticleMetadata: 6 tests for title/preview extraction
- mapTweetResult: 3 tests for article field inclusion
2026-01-11 23:35:03 +00:00
George Zhang cf78fce646 feat: improve text output with article preview, quote tweets, and media
Enhance the text output format to show more useful information:

- Article tweets: Show 📰 indicator with title + preview text in feeds,
  full body when reading single tweets
- Quote tweets: Display quoted content with ┌─ QT @user formatting
- Media: Show 🖼️/🎬/🔄 indicators with URLs for photos/videos/GIFs

Also refactors single tweet read to use shared printTweets function
for consistent formatting.
2026-01-11 23:35:03 +00:00
Peter Steinberger de49a66601 docs: add home live test steps 2026-01-11 11:39:35 +00:00
Peter Steinberger b29b446d18 Merge pull request #31 from odysseus0/feat/home-timeline
feat: add home timeline command
2026-01-11 11:37:57 +00:00
Peter Steinberger 94773c66dc fix: harden home timeline pagination (#31) (thanks @odysseus0) 2026-01-11 11:36:59 +00:00
George ZhangandClaude Opus 4.5 92742cac38 refactor: rename --latest to --following for clarity
Matches Twitter's UI terminology ("For You" vs "Following" tabs),
making the flag immediately understandable to users.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-11 11:35:57 +00:00
George ZhangandClaude Opus 4.5 5bb9e48252 refactor: move home query ID methods to mixin as private
Follow the pattern from twitter-client-timelines.ts where query ID
methods are private to the mixin that uses them, rather than protected
in the base class.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-11 11:35:57 +00:00
George Zhang 73b8cff1b2 test: add home timeline API tests
- getHomeTimeline: success, error, API error handling
- getHomeLatestTimeline: success, error handling
- pagination: deduplication across pages

Uses realistic fixture structure based on actual API responses.
2026-01-11 11:35:57 +00:00
George Zhang ba7b00eb29 feat: add home timeline command
Add `bird home` command to fetch the authenticated user's home timeline.

- Supports "For You" feed (default) and "Following" feed (--latest)
- Uses HomeTimeline and HomeLatestTimeline GraphQL operations
- Includes count option (-n), JSON output, and pagination

Usage:
  bird home           # Get "For You" feed
  bird home --latest  # Get "Following" (chronological) feed
  bird home -n 50     # Fetch 50 tweets
2026-01-11 11:35:57 +00:00
Peter Steinberger 05e54350c7 Merge pull request #30 from zheli/feature/list-timeline-pagination
Add pagination support for list timeline
2026-01-11 11:28:01 +00:00
Peter Steinberger 6cf177f92f fix: list timeline pagination (#30) (thanks @zheli) 2026-01-11 11:27:46 +00:00
Zhe LiandClaude Sonnet 4.5 ebcd1618f9 feat: make --max-pages imply --all for list-timeline
- Remove requirement for --max-pages to be used with --all
- Update help text to clarify --max-pages implies --all
- Add list-timeline documentation to README with examples

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-01-11 11:17:27 +00:00
Zhe Li 8fc208ca31 Apply suggestions from code review 2026-01-11 11:17:27 +00:00
Zhe LiandClaude Sonnet 4.5 803bd25b0c feat: add pagination support to list-timeline command
Adds --all, --max-pages, and --cursor options to list-timeline command, enabling users to fetch all tweets from a list through automatic pagination. Follows the same pattern as bookmarks pagination for consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-01-11 11:17:27 +00:00
Peter Steinberger bda980fa4d Merge pull request #29 from mafulafunk/feature/clickable-terminal-urls
feat: add clickable hyperlinks for URLs in terminal output
2026-01-11 11:11:54 +00:00
Peter Steinberger 0a4699711f fix: sanitize hyperlink OSC output (#29) (thanks @mafulafunk) 2026-01-11 11:11:21 +00:00
Martin FunkandClaude Opus 4.5 837b09fcad feat: add clickable hyperlinks for URLs in terminal output
Use OSC 8 escape sequences to make tweet and list URLs clickable
in supported terminals (iTerm2, Ghostty, Kitty, WezTerm, VS Code, etc.).

- Add hyperlink() helper function in output.ts
- Apply hyperlinks in printTweets() for tweet URLs
- Apply hyperlinks in printLists() for list URLs
- Add hyperlinks flag to OutputConfig (auto-disabled for non-TTY)
- Automatically falls back to plain text in --plain mode or when piped

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-11 11:10:34 +00:00
Peter Steinberger bbc90dbe08 docs: add manual live follow test 2026-01-11 11:09:48 +00:00
Peter Steinberger e39b430beb test: add users command pagination cases 2026-01-11 11:09:44 +00:00
Peter Steinberger f4e7b9c51c Merge pull request #28 from malpern/feat/following-followers-pagination
feat: add cursor-based pagination for following/followers commands
2026-01-11 11:02:40 +00:00
Peter Steinberger 092be1a782 fix: harden follow pagination output (#28) (thanks @malpern) 2026-01-11 11:02:09 +00:00
Micah AlpernandClaude Opus 4.5 39a477265c feat: add cursor-based pagination for following/followers commands
Add pagination support to `bird following` and `bird followers` commands,
similar to existing pagination in search/bookmarks/likes.

Changes:
- Add `cursor` parameter to `getFollowing()` and `getFollowers()` client methods
- Return `nextCursor` in `FollowingResult` for pagination continuation
- Add `--cursor <cursor>` CLI option for manual pagination
- Add `--all` CLI flag to automatically fetch all pages
- Add `--max-pages <number>` option to limit pages when using --all
- Add input validation for --max-pages (requires --all or --cursor)
- Add deduplication using Set to prevent duplicate users
- Add 1-second delay between pages to avoid overwhelming the API
- Update `-n/--count` description to clarify it's per-page
- Add unit tests for cursor parameter and nextCursor response

Usage examples:
  # Fetch first page (default 20 users)
  bird following

  # Fetch with specific page size
  bird following -n 50

  # Use cursor for next page
  bird following --cursor "CURSOR_FROM_PREVIOUS"

  # Fetch ALL following users automatically (with rate limiting)
  bird following --all --json

  # Limit to first 5 pages
  bird following --all --max-pages 5

  # Same options work for followers
  bird followers --all

Note: REST API fallback does not support cursor pagination.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-11 10:59:35 +00:00
Peter Steinberger ad125348d6 test: expand lists feature coverage 2026-01-11 10:58:47 +00:00
Peter Steinberger e782b08a5e Merge pull request #27 from zheli/fix/lists-api-features
Add required lists GraphQL feature flags
2026-01-11 10:43:56 +00:00
Peter Steinberger ad1d5f24cd fix: add lists feature flags (#27) (thanks @zheli) 2026-01-11 10:43:33 +00:00
Zhe Li 892fe25970 Update src/lib/twitter-client-features.ts 2026-01-11 10:42:17 +00:00
Zhe Li d1de42e91b fix: add required lists API features 2026-01-11 10:42:17 +00:00
Zhe LiandClaude Sonnet 4.5 ec09010122 fix: add required API features to buildListsFeatures
X/Twitter now requires vibe_api_enabled, responsive_web_text_conversations_enabled, tweetypie_unmention_optimization_enabled, and interactive_text_enabled to be explicitly set in lists API requests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-01-11 10:42:17 +00:00
Peter Steinberger 3c2107d5d1 Merge pull request #12 from titouv/paginate-fix
Paginate for bookmarks and likes
2026-01-11 10:32:51 +00:00
Peter Steinberger 2e03bd8c07 fix: paginate likes + avoid duplicate loops (#12) (thanks @titouv) 2026-01-11 10:31:47 +00:00
Titouan V 7548fd5ed1 paginate bookmarks and folders 2026-01-11 10:29:58 +00:00
Peter Steinberger f048c3d968 Merge pull request #40 from gakonst/fix/brave-cookies
fix: support Brave cookies on macOS
2026-01-11 02:50:37 +00:00
Peter Steinberger 7c74b42bef fix: support Brave cookies on macOS (#40) (thanks @gakonst) 2026-01-11 03:49:49 +01:00
Georgios Konstantopoulos 1284c3ae02 fix: support Brave cookies on macOS 2026-01-11 03:48:54 +01:00
Peter Steinberger 3dce3dd247 Merge pull request #38 from philipp-spiess/main
fix: add post_ctas_fetch_enabled feature flag
2026-01-11 02:21:24 +00:00
Peter Steinberger b8009939a2 fix: add post_ctas_fetch_enabled flag (#38) (thanks @philipp-spiess) 2026-01-11 03:20:53 +01:00
Philipp Spiess f08c94e2a8 fix: add post_ctas_fetch_enabled feature flag
Twitter API now requires this flag, otherwise returns HTTP 400.
2026-01-08 16:14:51 +01:00
69 changed files with 9681 additions and 1334 deletions
+3
View File
@@ -34,5 +34,8 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build dist
run: pnpm run build:dist
- name: Run tests
run: pnpm test
+32
View File
@@ -1,5 +1,34 @@
# Changelog
## 0.7.0 — Unreleased
### Added
- `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli.
- `home` command for the "For You" and "Following" home timelines (#31) — thanks @odysseus0.
- Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0.
- `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.
- Long-form article tweets now render rich Draft.js content blocks/entities (#36) — thanks @crcatala.
- `news`/`trending` command for Explore tabs with AI-curated headlines (#39) — thanks @aavetis.
- `search` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#42) — thanks @pjtf93.
- `likes` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#44) — thanks @jsholmes.
### Changed
- Library typing: `SearchResult` is now a discriminated union (so `error` only exists when `success: false`).
### Fixed
- 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.
- Following/followers pagination now guards repeat cursors and standardizes JSON output (#28) — thanks @malpern.
- Lists GraphQL feature flags updated to prevent 400s (#27) — thanks @zheli.
- Likes pagination now follows cursors and avoids stalling on duplicate pages (#12) — thanks @titouv.
- Tweet detail parsing now tolerates partial GraphQL errors when usable data exists (#48) — thanks @jsholmes.
- `pnpm run build:dist` now succeeds after tightening JSON/pagination option typing in tweet output commands.
- News output now respects `--tweets-per-item`, keeps unique IDs, and parses non-add entry instructions (#39) — thanks @aavetis.
### Tests
- Following: split following/likes tests + cover cursor handling (#33) — thanks @VACInc.
## 0.6.0 — 2026-01-05
### Added
@@ -12,6 +41,9 @@
### 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
+98 -7
View File
@@ -42,12 +42,18 @@ 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>
@@ -59,6 +65,15 @@ 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
@@ -71,6 +86,42 @@ bird followers --user 12345678 -n 10 # by user ID
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):
@@ -80,7 +131,19 @@ import { TwitterClient, resolveCredentials } from '@steipete/bird';
const { cookies } = await resolveCredentials({ cookieSource: 'safari' });
const client = new TwitterClient({ cookies });
const result = await client.search('from:steipete', 50);
// 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']
});
```
## Commands
@@ -91,17 +154,29 @@ const result = await client.search('from:steipete', 50);
- `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs.
- `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> [--json]` — list replies to a tweet.
- `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread.
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
- `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] [--json]` — list your bookmarked tweets (or a specific bookmark folder); `--max-pages` requires `--all`.
- `bird unbookmark <tweet-id-or-url...>` — remove one or more bookmarks by tweet ID or URL.
- `bird likes [-n count] [--json]` — list your liked tweets.
- `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 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] [--json]` — list users that you (or another user) follow.
- `bird followers [--user <userId>] [-n count] [--json]` — list users that follow you (or another user).
- `bird whoami` — print which Twitter account your cookies belong to.
- `bird check` — show which credentials are available and where they were sourced from.
- `bird likes [-n count] [--json]` — list your liked tweets.
- `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 (fetches from For You, News, Sports, and Entertainment tabs by default).
- `bird trending` — alias for `news` command.
- `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] [--json]` — list users that you (or another user) follow.
- `bird followers [--user <userId>] [-n count] [--json]` — list users that follow you (or another user).
- `bird whoami` — print which Twitter account your cookies belong to.
- `bird check` — show which credentials are available and where they were sourced from.
Global options:
- `--auth-token <token>`: set the `auth_token` cookie manually.
@@ -165,7 +240,8 @@ Environment shortcuts:
## Output
- `--json` prints raw tweet objects for read/replies/thread/search/mentions/bookmarks/likes.
- `--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).
@@ -198,8 +274,23 @@ When using `--json` with `following`/`followers`, user objects include:
| `followersCount` | number? | Followers count |
| `followingCount` | number? | Following count |
| `isBlueVerified` | boolean? | Blue verified flag |
| `profileImageUrl` | string? | Profile image URL |
| `createdAt` | string? | Account creation timestamp |
| `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)
+6
View File
@@ -15,6 +15,11 @@ Requirements:
Run:
- `pnpm test:live`
- `pnpm bird following --all --max-pages 2 --json --cookie-source chrome --chrome-profile Default`
- `pnpm bird list-timeline <list-id> --all --max-pages 2 --json --cookie-source chrome --chrome-profile Default`
- `pnpm bird search "from:steipete" --all --max-pages 2 --json --cookie-source chrome --chrome-profile Default`
- `pnpm bird home --count 5 --json --cookie-source chrome --chrome-profile Default`
- `pnpm bird home --count 5 --following --json --cookie-source chrome --chrome-profile Default`
Notes:
- Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`).
@@ -23,6 +28,7 @@ Notes:
- Cookie extraction timeout is configurable via `BIRD_LIVE_COOKIE_TIMEOUT_MS` (ms).
- Spawned CLI `NODE_ENV` defaults to `production` (override with `BIRD_LIVE_NODE_ENV`).
- If you don't tweet, set `BIRD_LIVE_TWEET_ID` to a known tweet ID to use for `read/replies/thread`.
- Long-form article coverage: set `BIRD_LIVE_LONGFORM_TWEET_ID` to a known article tweet ID (example: `2007184284944322584` from @X; refresh by finding a tweet with `article` via `bird user-tweets X -n 20 --json`).
- Optional: set `BIRD_LIVE_BOOKMARK_FOLDER_ID` to exercise `bookmarks --folder-id`.
- `bird query-ids --fresh` live coverage: set `BIRD_LIVE_QUERY_IDS_FRESH=1`.
- The live suite may hit internal X endpoints (v1.1 REST) as fallback; it still uses cookie auth (no developer API key).
+7 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@steipete/bird",
"version": "0.6.0",
"version": "0.7.0",
"description": "CLI tool for tweeting and replying via Twitter/X GraphQL API",
"type": "module",
"main": "dist/index.js",
@@ -23,6 +23,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:live": "pnpm run build:dist && BIRD_LIVE=1 vitest run --no-file-parallelism tests/live/live.test.ts",
"test:live:all": "pnpm run build:dist && BIRD_LIVE=1 vitest run --no-file-parallelism tests/live/live-all.test.ts",
"lint": "pnpm run lint:biome && pnpm run lint:oxlint",
"lint:biome": "biome check .",
"lint:oxlint": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings src tests scripts",
@@ -39,6 +40,11 @@
"json5": "^2.2.3",
"kleur": "^4.1.5"
},
"pnpm": {
"patchedDependencies": {
"@steipete/sweet-cookie": "patches/@steipete__sweet-cookie.patch"
}
},
"devDependencies": {
"@biomejs/biome": "^2.3.10",
"@types/node": "^25.0.3",
+68 -5
View File
@@ -1,13 +1,76 @@
diff --git a/dist/providers/chromeSqliteMac.js b/dist/providers/chromeSqliteMac.js
index 71c815f77378d03e07061976ac372b3a55a61ac4..3a85a2fce102128d7e7b69145d97bc353f663008 100644
index 71c815f77378d03e07061976ac372b3a55a61ac4..b21c15640955a8e7faa8f1d6ea6fed7ca8f04ddb 100644
--- a/dist/providers/chromeSqliteMac.js
+++ b/dist/providers/chromeSqliteMac.js
@@ -15,7 +15,7 @@ export async function getCookiesFromChromeSqliteMac(options, origins, allowlistN
@@ -4,19 +4,37 @@ import { decryptChromiumAes128CbcCookieValue, deriveAes128CbcKeyFromPassword, }
import { getCookiesFromChromeSqliteDb } from './chromeSqlite/shared.js';
import { readKeychainGenericPasswordFirst } from './chromium/macosKeychain.js';
import { resolveCookiesDbFromProfileOrRoots } from './chromium/paths.js';
+
+function resolveKeychainForDb(dbPath) {
+ const lower = dbPath.toLowerCase();
+ if (lower.includes('bravesoftware') || lower.includes('brave-browser') || lower.includes('brave browser')) {
+ return {
+ account: 'Brave',
+ services: ['Brave Safe Storage'],
+ label: 'Brave Safe Storage',
+ };
+ }
+ return {
+ account: 'Chrome',
+ services: ['Chrome Safe Storage'],
+ label: 'Chrome Safe Storage',
+ };
+}
+
export async function getCookiesFromChromeSqliteMac(options, origins, allowlistNames) {
const dbPath = resolveChromeCookiesDb(options.profile);
if (!dbPath) {
return { cookies: [], warnings: ['Chrome cookies database not found.'] };
}
const warnings = [];
- // On macOS, Chrome stores its "Safe Storage" secret in Keychain.
+ // On macOS, Chromium stores its "Safe Storage" secret in Keychain.
// `security find-generic-password` is stable and avoids any native Node keychain modules.
+ const keychain = resolveKeychainForDb(dbPath);
const passwordResult = await readKeychainGenericPasswordFirst({
account: 'Chrome',
services: ['Chrome Safe Storage'],
- account: 'Chrome',
- services: ['Chrome Safe Storage'],
- timeoutMs: 3_000,
- label: 'Chrome Safe Storage',
+ account: keychain.account,
+ services: keychain.services,
+ timeoutMs: options.timeoutMs ?? 3_000,
label: 'Chrome Safe Storage',
+ label: keychain.label,
});
if (!passwordResult.ok) {
warnings.push(passwordResult.error);
@@ -24,7 +42,7 @@ export async function getCookiesFromChromeSqliteMac(options, origins, allowlistN
}
const chromePassword = passwordResult.password.trim();
if (!chromePassword) {
- warnings.push('macOS Keychain returned an empty Chrome Safe Storage password.');
+ warnings.push(`macOS Keychain returned an empty ${keychain.label} password.`);
return { cookies: [], warnings };
}
// Chromium uses PBKDF2(password, "saltysalt", 1003, 16, sha1) for AES-128-CBC cookie values on macOS.
@@ -46,14 +64,18 @@ export async function getCookiesFromChromeSqliteMac(options, origins, allowlistN
result.warnings.unshift(...warnings);
return result;
}
+
function resolveChromeCookiesDb(profile) {
const home = homedir();
/* c8 ignore next */
const roots = process.platform === 'darwin'
- ? [path.join(home, 'Library', 'Application Support', 'Google', 'Chrome')]
+ ? [
+ path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
+ path.join(home, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'),
+ ]
: [];
const args = { roots };
if (profile !== undefined)
args.profile = profile;
return resolveCookiesDbFromProfileOrRoots(args);
}
+3 -3
View File
@@ -6,7 +6,7 @@ settings:
patchedDependencies:
'@steipete/sweet-cookie':
hash: 05180a0fa74ba5074775f965db7d4561e59ba51978bd5e955c0e4f058a4ed340
hash: 4ff44e9d3b5770a7b751c7a3470572bdf485763c28ead8055468dd0ae9a6e986
path: patches/@steipete__sweet-cookie.patch
importers:
@@ -15,7 +15,7 @@ importers:
dependencies:
'@steipete/sweet-cookie':
specifier: 0.1.0
version: 0.1.0(patch_hash=05180a0fa74ba5074775f965db7d4561e59ba51978bd5e955c0e4f058a4ed340)
version: 0.1.0(patch_hash=4ff44e9d3b5770a7b751c7a3470572bdf485763c28ead8055468dd0ae9a6e986)
commander:
specifier: ^14.0.2
version: 14.0.2
@@ -1056,7 +1056,7 @@ snapshots:
'@standard-schema/[email protected]': {}
'@steipete/[email protected](patch_hash=05180a0fa74ba5074775f965db7d4561e59ba51978bd5e955c0e4f058a4ed340)': {}
'@steipete/[email protected](patch_hash=4ff44e9d3b5770a7b751c7a3470572bdf485763c28ead8055468dd0ae9a6e986)': {}
'@types/[email protected]':
dependencies:
-3
View File
@@ -1,5 +1,2 @@
onlyBuiltDependencies:
- esbuild
patchedDependencies:
'@steipete/sweet-cookie': patches/@steipete__sweet-cookie.patch
+4
View File
@@ -19,6 +19,10 @@ const TARGET_OPERATIONS = [
'Following',
'Followers',
'Likes',
'ExploreSidebar',
'ExplorePage',
'GenericTimelineById',
'TrendHistory',
] as const;
type OperationName = (typeof TARGET_OPERATIONS)[number];
+79
View File
@@ -0,0 +1,79 @@
export type PaginationCmdOpts = {
all?: boolean;
maxPages?: string;
cursor?: string;
delay?: string;
};
export function parsePositiveIntFlag(
raw: string | undefined,
flagName: string,
): { ok: true; value: number | undefined } | { ok: false; error: string } {
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: string | undefined,
flagName: string,
defaultValue: number,
): { ok: true; value: number } | { ok: false; error: string } {
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: PaginationCmdOpts,
opts?: {
maxPagesImpliesPagination?: boolean;
defaultDelayMs?: number;
includeDelay?: boolean;
},
):
| {
ok: true;
usePagination: boolean;
maxPages?: number;
cursor?: string;
pageDelayMs?: number;
}
| { ok: false; error: string } {
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: number | undefined;
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,
};
}
+11 -1
View File
@@ -2,12 +2,15 @@ import { Command } from 'commander';
import { registerBookmarksCommand } from '../commands/bookmarks.js';
import { registerCheckCommand } from '../commands/check.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 { type CliContext, collectCookieSource } from './shared.js';
@@ -28,6 +31,10 @@ export const KNOWN_COMMANDS = new Set([
'likes',
'lists',
'list-timeline',
'home',
'user-tweets',
'news',
'trending',
'help',
'whoami',
'check',
@@ -86,7 +93,7 @@ export function createProgram(ctx: CliContext): Command {
].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, lists, list-timeline, query-ids`,
` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, 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.)`)}`,
@@ -131,7 +138,10 @@ export function createProgram(ctx: CliContext): Command {
registerBookmarksCommand(program, ctx);
registerUnbookmarkCommand(program, ctx);
registerListsCommand(program, ctx);
registerHomeCommand(program, ctx);
registerUserCommands(program, ctx);
registerUserTweetsCommand(program, ctx);
registerNewsCommand(program, ctx);
registerCheckCommand(program, ctx);
return program;
+84 -1
View File
@@ -7,6 +7,7 @@ import kleur from 'kleur';
import { type CookieSource, resolveCredentials } from '../lib/cookies.js';
import { extractTweetId } from '../lib/extract-tweet-id.js';
import {
hyperlink,
labelPrefix,
type OutputConfig,
resolveOutputConfigFromArgv,
@@ -50,6 +51,17 @@ export type CliContext = {
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;
};
@@ -317,19 +329,89 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn
console.log(opts.emptyMessage ?? 'No tweets found.');
return;
}
const useEmoji = output.emoji && !output.plain;
const articleLabel = useEmoji ? '📰' : 'Article:';
const mediaLabel = (type: 'photo' | 'video' | 'animated_gif'): string => {
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}`);
}
console.log(`${l('url')}https://x.com/${tweet.author.username}/status/${tweet.id}`);
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: { tweets?: TweetData[]; nextCursor?: string },
opts: { json: boolean; usePagination: boolean; emptyMessage: string },
) {
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,
@@ -343,6 +425,7 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn
resolveCredentialsFromOptions,
loadMedia,
printTweets,
printTweetsResult,
extractTweetId,
};
}
+11 -13
View File
@@ -1,4 +1,5 @@
import type { Command } from 'commander';
import { parsePositiveIntFlag } from '../cli/pagination.js';
import type { CliContext } from '../cli/shared.js';
import { extractBookmarkFolderId } from '../lib/extract-bookmark-folder-id.js';
import { TwitterClient } from '../lib/twitter-client.js';
@@ -27,7 +28,12 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
const maxPagesParsed = parsePositiveIntFlag(cmdOpts.maxPages, '--max-pages');
if (!maxPagesParsed.ok) {
console.error(`${ctx.p('err')}${maxPagesParsed.error}`);
process.exit(1);
}
const maxPages = maxPagesParsed.value;
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
@@ -40,7 +46,7 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
process.exit(1);
}
const usePagination = cmdOpts.all || cmdOpts.cursor;
const usePagination = Boolean(cmdOpts.all || cmdOpts.cursor);
if (maxPages !== undefined && !usePagination) {
console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`);
process.exit(1);
@@ -49,10 +55,6 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
@@ -71,14 +73,10 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
? await client.getAllBookmarks(paginationOptions)
: await client.getBookmarks(count, timelineOptions);
if (result.success && result.tweets) {
if (result.success) {
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
const isJson = cmdOpts.json || cmdOpts.jsonFull;
if (isJson && usePagination) {
console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2));
} else {
ctx.printTweets(result.tweets, { json: isJson, emptyMessage });
}
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult(result, { json: isJson, usePagination, emptyMessage });
} else {
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
process.exit(1);
+51
View File
@@ -0,0 +1,51 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerHomeCommand(program: Command, ctx: CliContext): void {
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: { count?: string; following?: boolean; json?: boolean; jsonFull?: boolean }) => {
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);
}
});
}
+45 -7
View File
@@ -2,8 +2,10 @@
// ABOUTME: Supports listing owned lists, memberships, and list timelines.
import type { Command } from 'commander';
import { parsePaginationFlags } from '../cli/pagination.js';
import type { CliContext } from '../cli/shared.js';
import { extractListId } from '../lib/extract-list-id.js';
import { hyperlink } from '../lib/output.js';
import type { TwitterList } from '../lib/twitter-client.js';
import { TwitterClient } from '../lib/twitter-client.js';
@@ -23,7 +25,8 @@ function printLists(lists: TwitterList[], ctx: CliContext): void {
if (list.owner) {
console.log(` ${ctx.colors.muted(`Owner: @${list.owner.username}`)}`);
}
console.log(` ${ctx.colors.accent(`https://x.com/i/lists/${list.id}`)}`);
const listUrl = `https://x.com/i/lists/${list.id}`;
console.log(` ${ctx.colors.accent(hyperlink(listUrl, listUrl, ctx.getOutput()))}`);
console.log('──────────────────────────────────────────────────');
}
}
@@ -76,20 +79,46 @@ export function registerListsCommand(program: Command, ctx: CliContext): void {
.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: string, cmdOpts: { count?: string; json?: boolean; jsonFull?: boolean }) => {
.action(
async (
listIdOrUrl: string,
cmdOpts: {
count?: string;
json?: boolean;
jsonFull?: boolean;
all?: boolean;
maxPages?: string;
cursor?: string;
},
) => {
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) {
@@ -102,16 +131,25 @@ export function registerListsCommand(program: Command, ctx: CliContext): void {
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getListTimeline(listId, count, { includeRaw: cmdOpts.jsonFull });
const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages: pagination.maxPages, cursor: pagination.cursor };
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, {
json: cmdOpts.json || cmdOpts.jsonFull,
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);
}
});
},
);
}
+172
View File
@@ -0,0 +1,172 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { TwitterClient } from '../lib/twitter-client.js';
import type { ExploreTab, NewsItem } from '../lib/twitter-client-news.js';
function formatPostCount(count: number): string {
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: NewsItem[],
ctx: CliContext,
opts: { json?: boolean; emptyMessage?: string; tweetLimit?: number } = {},
): void {
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: string[] = [];
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: Command, ctx: CliContext): void {
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: {
count?: string;
aiOnly?: boolean;
withTweets?: boolean;
tweetsPerItem?: string;
forYou?: boolean;
newsOnly?: boolean;
sports?: boolean;
entertainment?: boolean;
trendingOnly?: boolean;
json?: boolean;
jsonFull?: boolean;
}) => {
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: ExploreTab[] = [];
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);
}
},
);
}
+98 -18
View File
@@ -1,4 +1,5 @@
import type { Command } from 'commander';
import { parsePaginationFlags } from '../cli/pagination.js';
import type { CliContext } from '../cli/shared.js';
import { formatStatsLine } from '../lib/output.js';
import { TwitterClient } from '../lib/twitter-client.js';
@@ -36,11 +37,7 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
if (cmdOpts.json || cmdOpts.jsonFull) {
console.log(JSON.stringify(result.tweet, null, 2));
} else {
console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`);
console.log(result.tweet.text);
if (result.tweet.createdAt) {
console.log(`\n${ctx.l('date')}${result.tweet.createdAt}`);
}
ctx.printTweets([result.tweet], { showSeparator: false });
console.log(formatStatsLine(result.tweet, ctx.getOutput()));
}
} else {
@@ -53,14 +50,35 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
.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: string, cmdOpts: { json?: boolean; jsonFull?: boolean }) => {
.action(
async (
tweetIdOrUrl: string,
cmdOpts: {
all?: boolean;
maxPages?: string;
delay?: string;
cursor?: string;
json?: boolean;
jsonFull?: boolean;
},
) => {
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) {
@@ -74,28 +92,70 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.getReplies(tweetId, { includeRaw });
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json || cmdOpts.jsonFull, emptyMessage: 'No replies found.' });
} else {
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: string, cmdOpts: { json?: boolean; jsonFull?: boolean }) => {
.action(
async (
tweetIdOrUrl: string,
cmdOpts: {
all?: boolean;
maxPages?: string;
delay?: string;
cursor?: string;
json?: boolean;
jsonFull?: boolean;
},
) => {
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) {
@@ -109,16 +169,36 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.getThread(tweetId, { includeRaw });
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, {
json: cmdOpts.json || cmdOpts.jsonFull,
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.',
});
} else {
// 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);
}
});
},
);
}
+49 -7
View File
@@ -9,13 +9,28 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
.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: string, cmdOpts: { count?: string; json?: boolean; jsonFull?: boolean }) => {
.action(
async (
query: string,
cmdOpts: {
count?: string;
all?: boolean;
maxPages?: string;
cursor?: string;
json?: boolean;
jsonFull?: boolean;
},
) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
@@ -28,17 +43,41 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
process.exit(1);
}
const usePagination = cmdOpts.all || cmdOpts.cursor;
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);
}
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.search(query, count, { includeRaw });
const searchOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor };
const result = usePagination
? await client.getAllSearchResults(query, paginationOptions)
: await client.search(query, count, searchOptions);
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json || cmdOpts.jsonFull, emptyMessage: 'No tweets found.' });
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')
@@ -90,8 +129,11 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.search(query, count, { includeRaw });
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json || cmdOpts.jsonFull, emptyMessage: 'No mentions found.' });
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);
+143
View File
@@ -0,0 +1,143 @@
import type { Command } from 'commander';
import { parseNonNegativeIntFlag, parsePositiveIntFlag } from '../cli/pagination.js';
import type { CliContext } from '../cli/shared.js';
import { normalizeHandle } from '../lib/normalize-handle.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerUserTweetsCommand(program: Command, ctx: CliContext): void {
const formatExample = (cmd: string, desc: string): string =>
` ${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: string,
cmdOpts: {
count?: string;
maxPages?: string;
delay?: string;
cursor?: string;
json?: boolean;
jsonFull?: boolean;
},
) => {
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);
}
},
);
}
+246 -14
View File
@@ -1,18 +1,41 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { TwitterClient } from '../lib/twitter-client.js';
import type { TwitterUser } from '../lib/twitter-client-types.js';
export function registerUserCommands(program: Command, ctx: CliContext): void {
program
.command('following')
.description('Get users that you (or another user) follow')
.option('--user <userId>', 'User ID to get following for (defaults to current user)')
.option('-n, --count <number>', 'Number of users to fetch', '20')
.option('-n, --count <number>', 'Number of users to fetch per page', '20')
.option('--cursor <cursor>', 'Cursor for pagination (from previous response)')
.option('--all', 'Fetch all users (paginate automatically)')
.option('--max-pages <number>', 'Stop after N pages when using --all')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
.action(
async (cmdOpts: {
user?: string;
count?: string;
cursor?: string;
all?: boolean;
maxPages?: string;
json?: boolean;
}) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
const usePagination = cmdOpts.all || cmdOpts.cursor;
if (maxPages !== undefined && !cmdOpts.all) {
console.error(`${ctx.p('err')}--max-pages requires --all.`);
process.exit(1);
}
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
@@ -37,11 +60,83 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
userId = currentUser.user.id;
}
const result = await client.getFollowing(userId, count);
if (cmdOpts.all) {
// Fetch all pages
const allUsers: TwitterUser[] = [];
const seen = new Set<string>();
let cursor: string | undefined = cmdOpts.cursor;
let pageNum = 0;
let nextCursor: string | undefined;
while (true) {
pageNum++;
if (!cmdOpts.json) {
console.error(`${ctx.p('info')}Fetching page ${pageNum}...`);
}
const result = await client.getFollowing(userId, count, cursor);
if (!result.success || !result.users) {
console.error(`${ctx.p('err')}Failed to fetch following: ${result.error}`);
process.exit(1);
}
let added = 0;
for (const user of result.users) {
if (!seen.has(user.id)) {
seen.add(user.id);
allUsers.push(user);
added += 1;
}
}
const pageCursor = result.nextCursor;
if (!pageCursor || result.users.length === 0 || added === 0 || pageCursor === cursor) {
nextCursor = undefined;
break;
}
if (maxPages && pageNum >= maxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
// Rate limit: wait between pages to avoid overwhelming the API
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (cmdOpts.json) {
console.log(JSON.stringify({ users: allUsers, nextCursor: nextCursor ?? null }, null, 2));
} else {
console.error(`${ctx.p('info')}Total: ${allUsers.length} users`);
if (nextCursor) {
console.error(`${ctx.p('info')}Stopped at --max-pages. Use --cursor to continue.`);
console.error(`${ctx.p('info')}Next cursor: ${nextCursor}`);
}
for (const user of allUsers) {
console.log(`@${user.username} (${user.name})`);
if (user.description) {
console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`);
}
if (user.followersCount !== undefined) {
console.log(` ${ctx.p('info')}${user.followersCount.toLocaleString()} followers`);
}
console.log('──────────────────────────────────────────────────');
}
}
} else {
// Single page fetch
const result = await client.getFollowing(userId, count, cmdOpts.cursor);
if (result.success && result.users) {
if (cmdOpts.json) {
if (usePagination) {
console.log(JSON.stringify({ users: result.users, nextCursor: result.nextCursor ?? null }, null, 2));
} else {
console.log(JSON.stringify(result.users, null, 2));
}
} else {
if (result.users.length === 0) {
console.log('No users found.');
@@ -56,24 +151,51 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
}
console.log('──────────────────────────────────────────────────');
}
if (result.nextCursor) {
console.error(`${ctx.p('info')}Next cursor: ${result.nextCursor}`);
}
}
}
} else {
console.error(`${ctx.p('err')}Failed to fetch following: ${result.error}`);
process.exit(1);
}
});
}
},
);
program
.command('followers')
.description('Get users that follow you (or another user)')
.option('--user <userId>', 'User ID to get followers for (defaults to current user)')
.option('-n, --count <number>', 'Number of users to fetch', '20')
.option('-n, --count <number>', 'Number of users to fetch per page', '20')
.option('--cursor <cursor>', 'Cursor for pagination (from previous response)')
.option('--all', 'Fetch all users (paginate automatically)')
.option('--max-pages <number>', 'Stop after N pages when using --all')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
.action(
async (cmdOpts: {
user?: string;
count?: string;
cursor?: string;
all?: boolean;
maxPages?: string;
json?: boolean;
}) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
const usePagination = cmdOpts.all || cmdOpts.cursor;
if (maxPages !== undefined && !cmdOpts.all) {
console.error(`${ctx.p('err')}--max-pages requires --all.`);
process.exit(1);
}
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
@@ -98,11 +220,83 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
userId = currentUser.user.id;
}
const result = await client.getFollowers(userId, count);
if (cmdOpts.all) {
// Fetch all pages
const allUsers: TwitterUser[] = [];
const seen = new Set<string>();
let cursor: string | undefined = cmdOpts.cursor;
let pageNum = 0;
let nextCursor: string | undefined;
while (true) {
pageNum++;
if (!cmdOpts.json) {
console.error(`${ctx.p('info')}Fetching page ${pageNum}...`);
}
const result = await client.getFollowers(userId, count, cursor);
if (!result.success || !result.users) {
console.error(`${ctx.p('err')}Failed to fetch followers: ${result.error}`);
process.exit(1);
}
let added = 0;
for (const user of result.users) {
if (!seen.has(user.id)) {
seen.add(user.id);
allUsers.push(user);
added += 1;
}
}
const pageCursor = result.nextCursor;
if (!pageCursor || result.users.length === 0 || added === 0 || pageCursor === cursor) {
nextCursor = undefined;
break;
}
if (maxPages && pageNum >= maxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
// Rate limit: wait between pages to avoid overwhelming the API
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (cmdOpts.json) {
console.log(JSON.stringify({ users: allUsers, nextCursor: nextCursor ?? null }, null, 2));
} else {
console.error(`${ctx.p('info')}Total: ${allUsers.length} users`);
if (nextCursor) {
console.error(`${ctx.p('info')}Stopped at --max-pages. Use --cursor to continue.`);
console.error(`${ctx.p('info')}Next cursor: ${nextCursor}`);
}
for (const user of allUsers) {
console.log(`@${user.username} (${user.name})`);
if (user.description) {
console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`);
}
if (user.followersCount !== undefined) {
console.log(` ${ctx.p('info')}${user.followersCount.toLocaleString()} followers`);
}
console.log('──────────────────────────────────────────────────');
}
}
} else {
// Single page fetch
const result = await client.getFollowers(userId, count, cmdOpts.cursor);
if (result.success && result.users) {
if (cmdOpts.json) {
if (usePagination) {
console.log(JSON.stringify({ users: result.users, nextCursor: result.nextCursor ?? null }, null, 2));
} else {
console.log(JSON.stringify(result.users, null, 2));
}
} else {
if (result.users.length === 0) {
console.log('No users found.');
@@ -117,25 +311,42 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
}
console.log('──────────────────────────────────────────────────');
}
if (result.nextCursor) {
console.error(`${ctx.p('info')}Next cursor: ${result.nextCursor}`);
}
}
}
} else {
console.error(`${ctx.p('err')}Failed to fetch followers: ${result.error}`);
process.exit(1);
}
});
}
},
);
program
.command('likes')
.description('Get your liked tweets')
.option('-n, --count <number>', 'Number of likes to fetch', '20')
.option('--all', 'Fetch all likes (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 (cmdOpts: { count?: string; json?: boolean; jsonFull?: boolean }) => {
.action(
async (cmdOpts: {
count?: string;
json?: boolean;
jsonFull?: boolean;
all?: boolean;
maxPages?: string;
cursor?: string;
}) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
@@ -148,20 +359,41 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
process.exit(1);
}
const usePagination = cmdOpts.all || cmdOpts.cursor;
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);
}
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.getLikes(count, { includeRaw });
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor };
const result = usePagination
? await client.getAllLikes(paginationOptions)
: await client.getLikes(count, timelineOptions);
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, {
json: cmdOpts.json || cmdOpts.jsonFull,
if (result.success) {
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult(result, {
json: isJson,
usePagination: Boolean(usePagination),
emptyMessage: 'No liked tweets found.',
});
} else {
console.error(`${ctx.p('err')}Failed to fetch likes: ${result.error}`);
process.exit(1);
}
});
},
);
program
.command('whoami')
+2
View File
@@ -18,6 +18,8 @@ export {
type TwitterClientOptions,
type TwitterUser,
} from './twitter-client.js';
export type { HomeTimelineFetchOptions } from './twitter-client-home.js';
export type { ExploreTab, NewsFetchOptions, NewsItem, NewsResult } from './twitter-client-news.js';
export type { SearchFetchOptions } from './twitter-client-search.js';
export type { TimelineFetchOptions } from './twitter-client-timelines.js';
export type { TweetFetchOptions } from './twitter-client-tweet-detail.js';
+23 -3
View File
@@ -2,6 +2,7 @@ export type OutputConfig = {
plain: boolean;
emoji: boolean;
color: boolean;
hyperlinks: boolean;
};
export type StatusKind = 'ok' | 'warn' | 'err' | 'info' | 'hint';
@@ -33,8 +34,9 @@ export function resolveOutputConfigFromArgv(argv: string[], env: NodeJS.ProcessE
const plain = argv.includes('--plain');
const emoji = !plain && !argv.includes('--no-emoji');
const color = !plain && !argv.includes('--no-color') && defaultColor;
const hyperlinks = !plain && isTty;
return { plain, emoji, color };
return { plain, emoji, color, hyperlinks };
}
export function resolveOutputConfigFromCommander(
@@ -48,8 +50,9 @@ export function resolveOutputConfigFromCommander(
const plain = Boolean(opts.plain);
const emoji = !plain && (opts.emoji ?? true);
const color = !plain && (opts.color ?? true) && defaultColor;
const hyperlinks = !plain && isTty;
return { plain, emoji, color };
return { plain, emoji, color, hyperlinks };
}
export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string {
@@ -93,6 +96,23 @@ export function formatTweetUrl(tweetId: string): string {
return `https://x.com/i/status/${tweetId}`;
}
/**
* Wraps a URL in OSC 8 escape sequences to make it clickable in supported terminals.
* Falls back to plain text when not in a TTY or when hyperlinks are disabled.
*/
export function hyperlink(url: string, text?: string, cfg?: OutputConfig): string {
const displayText = text ?? url;
// Only use hyperlinks when explicitly enabled (requires TTY and not plain mode)
if (!cfg?.hyperlinks) {
return displayText;
}
const safeUrl = url.replaceAll('\x1b', '').replaceAll('\x07', '');
const safeText = displayText.replaceAll('\x1b', '').replaceAll('\x07', '');
// OSC 8 hyperlink: \x1b]8;;URL\x07TEXT\x1b]8;;\x07
return `\x1b]8;;${safeUrl}\x07${safeText}\x1b]8;;\x07`;
}
export function formatTweetUrlLine(tweetId: string, cfg: OutputConfig): string {
return `${labelPrefix('url', cfg)}${formatTweetUrl(tweetId)}`;
const url = formatTweetUrl(tweetId);
return `${labelPrefix('url', cfg)}${hyperlink(url, url, cfg)}`;
}
+73
View File
@@ -0,0 +1,73 @@
export type CursorPage<T> =
| {
success: true;
items: T[];
cursor?: string;
}
| {
success: false;
error: string;
};
export type CursorPaginationResult<T> =
| {
success: true;
items: T[];
nextCursor?: string;
}
| {
success: false;
error: string;
items?: T[];
nextCursor?: string;
};
export async function paginateCursor<T>(opts: {
cursor?: string;
maxPages?: number;
pageDelayMs?: number;
sleep: (ms: number) => Promise<void>;
getKey: (item: T) => string;
fetchPage: (cursor?: string) => Promise<CursorPage<T>>;
}): Promise<CursorPaginationResult<T>> {
const { maxPages, pageDelayMs = 1000 } = opts;
const seen = new Set<string>();
const items: T[] = [];
let cursor: string | undefined = opts.cursor;
let pagesFetched = 0;
while (true) {
if (pagesFetched > 0 && pageDelayMs > 0) {
await opts.sleep(pageDelayMs);
}
const page = await opts.fetchPage(cursor);
if (!page.success) {
if (items.length > 0) {
return { success: false, error: page.error, items, nextCursor: cursor };
}
return page;
}
pagesFetched += 1;
for (const item of page.items) {
const key = opts.getKey(item);
if (seen.has(key)) {
continue;
}
seen.add(key);
items.push(item);
}
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor) {
return { success: true, items, nextCursor: undefined };
}
if (maxPages !== undefined && pagesFetched >= maxPages) {
return { success: true, items, nextCursor: pageCursor };
}
cursor = pageCursor;
}
}
+10 -10
View File
@@ -1,17 +1,17 @@
{
"CreateTweet": "Uf3io9zVp1DsYxrmL5FJ7g",
"CreateTweet": "nmdAQXJDxw6-0KKF2on7eA",
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ",
"TweetDetail": "97JF30KziU00483E_8elBA",
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
"TweetDetail": "_NvJCnIjOW__EP5-RF197A",
"SearchTimeline": "6AAys3t42mosm_yTI_QENg",
"Bookmarks": "RV1g3b8n_SGOHwkqKYSCFw",
"BookmarkFolderTimeline": "KJIQpsvxrTfRIlbaRIySHQ",
"Following": "BEkNpEt5pNETESoqMsTEGA",
"Followers": "kuFUYP9eV1FPoEy4N-pi7w",
"Likes": "JR2gceKucIKcVNB_9JkhsA",
"ListOwnerships": "wQcOSjSQ8NtgxIwvYl1lMg",
"ListMemberships": "BlEXXdARdSeL_0KyKHHvvg",
"ListLatestTweetsTimeline": "2TemLyqrMpTeAmysdbnVqw",
"ListByRestId": "wXzyA5vM_aVkBL9G8Vp3kw"
"Following": "mWYeougg_ocJS2Vr1Vt28w",
"Followers": "SFYY3WsgwjlXSLlfnEUE4A",
"Likes": "ETJflBunfqNa1uE1mBPCaw",
"ExploreSidebar": "lpSN4M6qpimkF4nRFPE3nQ",
"ExplorePage": "kheAINB_4pzRDqkzG3K-ng",
"GenericTimelineById": "uGSr7alSjR9v6QJAIaqSKQ",
"TrendHistory": "Sj4T-jSB9pr0Mxtsc1UKZQ"
}
+4
View File
@@ -41,6 +41,10 @@ export abstract class TwitterClientBase {
protected abstract getCurrentUser(): Promise<CurrentUserResult>;
protected async sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
protected async getQueryId(operationName: OperationName): Promise<string> {
const cached = await runtimeQueryIds.getQueryId(operationName);
return cached ?? QUERY_IDS[operationName];
+7
View File
@@ -21,6 +21,7 @@ export const FALLBACK_QUERY_IDS = {
TweetDetail: '97JF30KziU00483E_8elBA',
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
UserTweets: 'Wms1GvIiHXAPBaCr9KblaA',
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
Following: 'BEkNpEt5pNETESoqMsTEGA',
Followers: 'kuFUYP9eV1FPoEy4N-pi7w',
@@ -30,6 +31,12 @@ export const FALLBACK_QUERY_IDS = {
ListMemberships: 'BlEXXdARdSeL_0KyKHHvvg',
ListLatestTweetsTimeline: '2TemLyqrMpTeAmysdbnVqw',
ListByRestId: 'wXzyA5vM_aVkBL9G8Vp3kw',
HomeTimeline: 'edseUwk9sP5Phz__9TIRnA',
HomeLatestTimeline: 'iOEZpOdfekFsxSlPQCQtPg',
ExploreSidebar: 'lpSN4M6qpimkF4nRFPE3nQ',
ExplorePage: 'kheAINB_4pzRDqkzG3K-ng',
GenericTimelineById: 'uGSr7alSjR9v6QJAIaqSKQ',
TrendHistory: 'Sj4T-jSB9pr0Mxtsc1UKZQ',
} as const;
export type OperationName = keyof typeof FALLBACK_QUERY_IDS;
+103
View File
@@ -9,6 +9,7 @@ export function buildArticleFeatures(): Record<string, boolean> {
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
@@ -17,6 +18,7 @@ export function buildArticleFeatures(): Record<string, boolean> {
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_grok_annotations_enabled: false,
responsive_web_jetfuel_frame: true,
post_ctas_fetch_enabled: true,
responsive_web_grok_share_attachment_enabled: true,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
@@ -86,6 +88,7 @@ export function buildSearchFeatures(): Record<string, boolean> {
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_grok_annotations_enabled: false,
responsive_web_jetfuel_frame: true,
post_ctas_fetch_enabled: true,
responsive_web_grok_share_attachment_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
@@ -121,6 +124,7 @@ export function buildTweetCreateFeatures(): Record<string, boolean> {
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_grok_annotations_enabled: false,
responsive_web_jetfuel_frame: true,
post_ctas_fetch_enabled: true,
responsive_web_grok_share_attachment_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
@@ -184,6 +188,7 @@ export function buildListsFeatures(): Record<string, boolean> {
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
@@ -192,6 +197,7 @@ export function buildListsFeatures(): Record<string, boolean> {
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_grok_annotations_enabled: false,
responsive_web_jetfuel_frame: true,
post_ctas_fetch_enabled: true,
responsive_web_grok_share_attachment_enabled: true,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
@@ -212,6 +218,58 @@ export function buildListsFeatures(): Record<string, boolean> {
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_enhance_cards_enabled: false,
blue_business_profile_image_shape_enabled: false,
responsive_web_text_conversations_enabled: false,
tweetypie_unmention_optimization_enabled: true,
vibe_api_enabled: false,
interactive_text_enabled: false,
});
}
export function buildHomeTimelineFeatures(): Record<string, boolean> {
return applyFeatureOverrides('homeTimeline', {
...buildTimelineFeatures(),
});
}
export function buildUserTweetsFeatures(): Record<string, boolean> {
return applyFeatureOverrides('userTweets', {
rweb_video_screen_enabled: false,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: false,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: true,
responsive_web_jetfuel_frame: true,
post_ctas_fetch_enabled: true,
responsive_web_grok_share_attachment_enabled: true,
responsive_web_grok_annotations_enabled: false,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: true,
responsive_web_grok_analysis_button_from_backend: true,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_enhance_cards_enabled: false,
});
}
@@ -232,6 +290,7 @@ export function buildFollowingFeatures(): Record<string, boolean> {
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_grok_annotations_enabled: false,
responsive_web_jetfuel_frame: false,
post_ctas_fetch_enabled: true,
responsive_web_grok_share_attachment_enabled: false,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
@@ -254,3 +313,47 @@ export function buildFollowingFeatures(): Record<string, boolean> {
responsive_web_enhance_cards_enabled: false,
});
}
export function buildExploreFeatures(): Record<string, boolean> {
return applyFeatureOverrides('explore', {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: true,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: true,
responsive_web_grok_analyze_post_followups_enabled: true,
responsive_web_grok_annotations_enabled: true,
responsive_web_jetfuel_frame: true,
responsive_web_grok_share_attachment_enabled: true,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: true,
responsive_web_grok_analysis_button_from_backend: true,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: true,
responsive_web_enhance_cards_enabled: false,
// Additional features required for ExploreSidebar
post_ctas_fetch_enabled: true,
rweb_video_timestamps_enabled: true,
});
}
+201
View File
@@ -0,0 +1,201 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildHomeTimelineFeatures } from './twitter-client-features.js';
import type { GraphqlTweetResult, SearchResult, TweetData } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
const QUERY_UNSPECIFIED_REGEX = /query:\s*unspecified/i;
function isQueryIdMismatch(errors: Array<{ message?: string }>): boolean {
return errors.some((error) => QUERY_UNSPECIFIED_REGEX.test(error.message ?? ''));
}
/** Options for home timeline fetch methods */
export interface HomeTimelineFetchOptions {
/** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean;
}
export interface TwitterClientHomeMethods {
getHomeTimeline(count?: number, options?: HomeTimelineFetchOptions): Promise<SearchResult>;
getHomeLatestTimeline(count?: number, options?: HomeTimelineFetchOptions): Promise<SearchResult>;
}
export function withHome<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientHomeMethods> {
abstract class TwitterClientHome extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private async getHomeTimelineQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('HomeTimeline');
return Array.from(new Set([primary, 'edseUwk9sP5Phz__9TIRnA']));
}
private async getHomeLatestTimelineQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('HomeLatestTimeline');
return Array.from(new Set([primary, 'iOEZpOdfekFsxSlPQCQtPg']));
}
/**
* Get the authenticated user's "For You" home timeline
*/
async getHomeTimeline(count = 20, options: HomeTimelineFetchOptions = {}): Promise<SearchResult> {
return this.fetchHomeTimeline('HomeTimeline', count, options);
}
/**
* Get the authenticated user's "Following" (latest/chronological) home timeline
*/
async getHomeLatestTimeline(count = 20, options: HomeTimelineFetchOptions = {}): Promise<SearchResult> {
return this.fetchHomeTimeline('HomeLatestTimeline', count, options);
}
private async fetchHomeTimeline(
operation: 'HomeTimeline' | 'HomeLatestTimeline',
count: number,
options: HomeTimelineFetchOptions,
): Promise<SearchResult> {
const { includeRaw = false } = options;
const features = buildHomeTimelineFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined;
const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined;
let had404 = false;
const queryIds =
operation === 'HomeTimeline'
? await this.getHomeTimelineQueryIds()
: await this.getHomeLatestTimelineQueryIds();
for (const queryId of queryIds) {
const variables = {
count: pageCount,
includePromotedContent: true,
latestControlAvailable: true,
requestContext: 'launch',
withCommunity: true,
...(pageCursor ? { cursor: pageCursor } : {}),
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const url = `${TWITTER_API_BASE}/${queryId}/${operation}?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
home?: {
home_timeline_urt?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
const errorMessage = data.errors.map((e) => e.message).join(', ');
return {
success: false as const,
error: errorMessage,
had404: had404 || isQueryIdMismatch(data.errors),
};
}
const instructions = data.data?.home?.home_timeline_urt?.instructions;
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const nextCursor = extractCursorFromInstructions(instructions);
return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching home timeline', had404 };
};
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
const firstAttempt = await fetchPage(pageCount, pageCursor);
if (firstAttempt.success) {
return firstAttempt;
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await fetchPage(pageCount, pageCursor);
if (secondAttempt.success) {
return secondAttempt;
}
return { success: false as const, error: secondAttempt.error };
}
return { success: false as const, error: firstAttempt.error };
};
while (tweets.length < count) {
const pageCount = Math.min(pageSize, count - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) {
return { success: false, error: page.error };
}
let added = 0;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
added += 1;
if (tweets.length >= count) {
break;
}
}
if (!page.cursor || page.cursor === cursor || page.tweets.length === 0 || added === 0) {
break;
}
cursor = page.cursor;
}
return { success: true, tweets };
}
}
return TwitterClientHome;
}
+84 -21
View File
@@ -4,14 +4,15 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildListsFeatures } from './twitter-client-features.js';
import type { TimelineFetchOptions } from './twitter-client-timelines.js';
import type { GraphqlTweetResult, ListsResult, SearchResult, TwitterList } from './twitter-client-types.js';
import { parseTweetsFromInstructions } from './twitter-client-utils.js';
import type { TimelineFetchOptions, TimelinePaginationOptions } from './twitter-client-timelines.js';
import type { GraphqlTweetResult, ListsResult, SearchResult, TweetData, TwitterList } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
export interface TwitterClientListMethods {
getOwnedLists(count?: number): Promise<ListsResult>;
getListMemberships(count?: number): Promise<ListsResult>;
getListTimeline(listId: string, count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
getAllListTimeline(listId: string, options?: TimelinePaginationOptions): Promise<SearchResult>;
}
interface GraphqlListResult {
@@ -329,25 +330,49 @@ export function withLists<TBase extends AbstractConstructor<TwitterClientBase>>(
* Get tweets from a list timeline
*/
async getListTimeline(listId: string, count = 20, options: TimelineFetchOptions = {}): Promise<SearchResult> {
const { includeRaw = false } = options;
return this.getListTimelinePaged(listId, count, options);
}
/**
* Get all tweets from a list timeline (paginated)
*/
async getAllListTimeline(listId: string, options?: TimelinePaginationOptions): Promise<SearchResult> {
return this.getListTimelinePaged(listId, Number.POSITIVE_INFINITY, options);
}
/**
* Internal paginated list timeline fetcher
*/
private async getListTimelinePaged(
listId: string,
limit: number,
options: TimelinePaginationOptions = {},
): Promise<SearchResult> {
const features = buildListsFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined;
let pagesFetched = 0;
const { includeRaw = false, maxPages } = options;
const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getListTimelineQueryIds();
const variables = {
listId,
count,
count: pageCount,
...(pageCursor ? { cursor: pageCursor } : {}),
};
const features = buildListsFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getListTimelineQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/ListLatestTweetsTimeline?${params.toString()}`;
@@ -396,9 +421,10 @@ export function withLists<TBase extends AbstractConstructor<TwitterClientBase>>(
}
const instructions = data.data?.list?.tweets_timeline?.timeline?.instructions;
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const nextCursor = extractCursorFromInstructions(instructions);
return { success: true as const, tweets, had404 };
return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
@@ -407,21 +433,58 @@ export function withLists<TBase extends AbstractConstructor<TwitterClientBase>>(
return { success: false as const, error: lastError ?? 'Unknown error fetching list timeline', had404 };
};
const firstAttempt = await tryOnce();
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
const firstAttempt = await fetchPage(pageCount, pageCursor);
if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
return firstAttempt;
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
const secondAttempt = await fetchPage(pageCount, pageCursor);
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
return secondAttempt;
}
return { success: false as const, error: secondAttempt.error };
}
return { success: false as const, error: firstAttempt.error };
};
const unlimited = limit === Number.POSITIVE_INFINITY;
while (unlimited || tweets.length < limit) {
const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) {
return { success: false, error: page.error };
}
pagesFetched += 1;
let added = 0;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
added += 1;
if (!unlimited && tweets.length >= limit) {
break;
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined;
break;
}
if (maxPages && pagesFetched >= maxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
nextCursor = pageCursor;
}
return { success: true, tweets, nextCursor };
}
}
-4
View File
@@ -28,10 +28,6 @@ export function withMedia<TBase extends AbstractConstructor<TwitterClientBase>>(
return null;
}
private async sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult> {
const category = this.mediaCategoryForMime(input.mimeType);
if (!category) {
+430
View File
@@ -0,0 +1,430 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildExploreFeatures } from './twitter-client-features.js';
import type { SearchResult, TweetData } from './twitter-client-types.js';
const POST_COUNT_REGEX = /[\d.]+[KMB]?\s*posts?/i;
const POST_COUNT_MATCH_REGEX = /([\d.]+)([KMB]?)\s*posts?/i;
// Timeline IDs for different Explore tabs
const TIMELINE_IDS = {
forYou: 'VGltZWxpbmU6DAC2CwABAAAAB2Zvcl95b3UAAA==',
trending: 'VGltZWxpbmU6DAC2CwABAAAACHRyZW5kaW5nAAA=',
news: 'VGltZWxpbmU6DAC2CwABAAAABG5ld3MAAA==',
sports: 'VGltZWxpbmU6DAC2CwABAAAABnNwb3J0cwAA',
entertainment: 'VGltZWxpbmU6DAC2CwABAAAADWVudGVydGFpbm1lbnQAAA==',
} as const;
export type ExploreTab = keyof typeof TIMELINE_IDS;
/** Options for news fetch methods */
export interface NewsFetchOptions {
/** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean;
/** Also fetch related tweets for each news item */
withTweets?: boolean;
/** Number of tweets to fetch per news item (default: 5) */
tweetsPerItem?: number;
/** Filter to show only AI-curated news items */
aiOnly?: boolean;
/** Fetch from specific tabs only (default: all tabs) */
tabs?: ExploreTab[];
}
export interface NewsItem {
id: string;
headline: string;
category?: string;
timeAgo?: string;
postCount?: number;
description?: string;
url?: string;
tweets?: TweetData[];
// biome-ignore lint/suspicious/noExplicitAny: Raw API response can have any structure
_raw?: any;
}
export type NewsResult =
| {
success: true;
items: NewsItem[];
}
| {
success: false;
error: string;
};
export interface TwitterClientNewsMethods {
getNews(count?: number, options?: NewsFetchOptions): Promise<NewsResult>;
}
export function withNews<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientNewsMethods> {
abstract class TwitterClientNews extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
/**
* Fetch news and trending topics from Twitter's Explore page tabs
*/
async getNews(count = 10, options: NewsFetchOptions = {}): Promise<NewsResult> {
const {
includeRaw = false,
withTweets = false,
tweetsPerItem = 5,
aiOnly = false,
tabs = ['forYou', 'news', 'sports', 'entertainment'],
} = options;
const debug = process.env.BIRD_DEBUG === '1';
if (debug) {
console.error(`[getNews] Fetching from tabs: ${tabs.join(', ')}`);
}
const allItems: NewsItem[] = [];
const seenHeadlines = new Set<string>();
// Fetch from each tab
for (const tab of tabs) {
const timelineId = TIMELINE_IDS[tab];
if (!timelineId) {
continue;
}
try {
const tabItems = await this.fetchTimelineTab(tab, timelineId, count, aiOnly, includeRaw);
// Deduplicate across tabs
for (const item of tabItems) {
if (!seenHeadlines.has(item.headline)) {
seenHeadlines.add(item.headline);
allItems.push(item);
}
}
if (debug) {
console.error(`[getNews] Tab ${tab}: found ${tabItems.length} items, total unique: ${allItems.length}`);
}
// Stop early if we have enough
if (allItems.length >= count) {
break;
}
} catch (error) {
if (debug) {
console.error(`[getNews] Error fetching tab ${tab}:`, error);
}
// Continue with other tabs
}
}
if (allItems.length === 0) {
return { success: false, error: 'No news items found' };
}
// Limit to requested count
const items = allItems.slice(0, count);
if (withTweets) {
await this.enrichWithTweets(items, tweetsPerItem, includeRaw);
}
return { success: true, items };
}
/**
* Fetch a specific timeline tab using GenericTimelineById
*/
private async fetchTimelineTab(
tabName: string,
timelineId: string,
maxCount: number,
aiOnly: boolean,
includeRaw: boolean,
): Promise<NewsItem[]> {
const queryId = await this.getQueryId('GenericTimelineById');
const features = buildExploreFeatures();
const variables = {
timelineId: timelineId,
count: maxCount * 2, // Fetch more to account for filtering
includePromotedContent: false,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const url = `${TWITTER_API_BASE}/${queryId}/GenericTimelineById?${params.toString()}`;
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`);
}
const data = (await response.json()) as {
// biome-ignore lint/suspicious/noExplicitAny: API response structure is complex
data?: any;
// biome-ignore lint/suspicious/noExplicitAny: API errors can have any structure
errors?: Array<{ message: string; code?: number; [key: string]: any }>;
};
// Debug: save response if BIRD_DEBUG_JSON is set
if (process.env.BIRD_DEBUG_JSON) {
const fs = await import('node:fs/promises');
const debugPath = process.env.BIRD_DEBUG_JSON.replace('.json', `-${tabName}.json`);
await fs.writeFile(debugPath, JSON.stringify(data, null, 2)).catch(() => {});
}
if (data.errors && data.errors.length > 0) {
throw new Error(data.errors.map((e) => e.message).join('; '));
}
// Parse timeline response
return this.parseTimelineTabItems(data, tabName, maxCount, aiOnly, includeRaw);
}
/**
* Parse items from a GenericTimelineById response
*/
private parseTimelineTabItems(
// biome-ignore lint/suspicious/noExplicitAny: API response structure is complex
data: any,
source: string,
maxCount: number,
aiOnly: boolean,
includeRaw: boolean,
): NewsItem[] {
const items: NewsItem[] = [];
const seenHeadlines = new Set<string>();
// Navigate to timeline instructions
const timeline = data?.data?.timeline?.timeline;
if (!timeline) {
return [];
}
const instructions = timeline.instructions || [];
for (const instruction of instructions) {
const entries = instruction.entries ?? (instruction.entry ? [instruction.entry] : []);
if (!entries || entries.length === 0) {
continue;
}
for (const entry of entries) {
if (items.length >= maxCount) {
break;
}
const content = entry.content;
if (!content) {
continue;
}
// Handle TimelineTimelineItem (single trend item)
if (content.itemContent) {
const newsItem = this.parseNewsItemFromContent(
content.itemContent,
entry.entryId,
source,
seenHeadlines,
aiOnly,
includeRaw,
);
if (newsItem) {
items.push(newsItem);
}
}
// Handle TimelineTimelineModule (multiple items)
const itemsArray = content?.items || [];
for (const data of itemsArray) {
if (items.length >= maxCount) {
break;
}
// Structure can be data.itemContent OR data.item.itemContent
const itemContent = data?.itemContent || data?.item?.itemContent;
if (!itemContent) {
continue;
}
const newsItem = this.parseNewsItemFromContent(
itemContent,
entry.entryId,
source,
seenHeadlines,
aiOnly,
includeRaw,
);
if (newsItem) {
items.push(newsItem);
}
}
}
}
return items;
}
private parseNewsItemFromContent(
// biome-ignore lint/suspicious/noExplicitAny: API response structure is complex
itemContent: any,
entryId: string,
source: string,
seenHeadlines: Set<string>,
aiOnly: boolean,
includeRaw: boolean,
): NewsItem | null {
const headline = itemContent.name || itemContent.title;
if (!headline) {
return null;
}
const trendMetadata = itemContent?.trend_metadata;
const trendUrl = itemContent.trend_url?.url || trendMetadata?.url?.url;
// Detect AI news by characteristics:
// 1. Full sentence headlines (contains spaces and is longer)
// 2. Has social_context with "News" category
// 3. Or explicitly marked as is_ai_trend
const socialContext = itemContent?.social_context?.text || '';
const hasNewsCategory = socialContext.includes('News') || socialContext.includes('hours ago');
const isFullSentence = headline.split(' ').length >= 5; // AI news are full sentences
const isExplicitlyAiTrend = itemContent.is_ai_trend === true;
const isAiNews = isExplicitlyAiTrend || (isFullSentence && hasNewsCategory);
// Filter AI trends if aiOnly is enabled
if (aiOnly && !isAiNews) {
return null;
}
if (seenHeadlines.has(headline)) {
return null;
}
seenHeadlines.add(headline);
let postCount: number | undefined;
let timeAgo: string | undefined;
let category = 'Trending';
// Parse social context for metadata
const socialCtx = itemContent?.social_context;
if (socialCtx?.text) {
const socialContextText = socialCtx.text;
const parts = socialContextText.split('·').map((s: string) => s.trim());
for (const part of parts) {
if (part.includes('ago')) {
timeAgo = part;
} else if (part.match(POST_COUNT_REGEX)) {
const match = part.match(POST_COUNT_MATCH_REGEX);
if (match) {
let num = Number.parseFloat(match[1]);
const suffix = match[2]?.toUpperCase();
if (suffix === 'K') {
num *= 1000;
} else if (suffix === 'M') {
num *= 1_000_000;
} else if (suffix === 'B') {
num *= 1_000_000_000;
}
postCount = Math.round(num);
}
} else {
category = part;
}
}
}
// Parse trend metadata
if (trendMetadata?.meta_description) {
const metaDesc = trendMetadata.meta_description;
const postMatch = metaDesc.match(POST_COUNT_MATCH_REGEX);
if (postMatch) {
let num = Number.parseFloat(postMatch[1]);
const suffix = postMatch[2]?.toUpperCase();
if (suffix === 'K') {
num *= 1000;
} else if (suffix === 'M') {
num *= 1_000_000;
} else if (suffix === 'B') {
num *= 1_000_000_000;
}
postCount = Math.round(num);
}
}
if (trendMetadata?.domain_context && (category === 'Trending' || category === 'News')) {
category = trendMetadata.domain_context;
}
const item: NewsItem = {
id: trendUrl ?? (entryId ? `${entryId}-${headline}` : `${source}-${headline}`),
headline,
category: isAiNews ? `AI · ${category}` : category,
timeAgo,
postCount,
description: itemContent.description,
url: trendUrl,
};
if (includeRaw) {
item._raw = itemContent;
}
return item;
}
private async enrichWithTweets(items: NewsItem[], tweetsPerItem: number, includeRaw: boolean): Promise<void> {
const debug = process.env.BIRD_DEBUG === '1';
for (const item of items) {
try {
const searchQuery = item.headline;
if (!searchQuery) {
continue;
}
// Use the search method if available (requires search mixin)
if ('search' in this && typeof (this as { search?: unknown }).search === 'function') {
const result = (await (
this as { search: (q: string, c: number, o: { includeRaw: boolean }) => Promise<SearchResult> }
).search(searchQuery, tweetsPerItem, { includeRaw })) as SearchResult;
if (result.success && result.tweets) {
item.tweets = result.tweets;
}
}
} catch {
if (debug) {
console.error('[getNews] Failed to enrich item with tweets:', item.headline);
}
}
}
}
}
return TwitterClientNews;
}
+83 -11
View File
@@ -4,14 +4,45 @@ import { buildSearchFeatures } from './twitter-client-features.js';
import type { SearchResult, TweetData } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
const RAW_QUERY_MISSING_REGEX = /must be defined/i;
/** Options for search methods */
export interface SearchFetchOptions {
/** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean;
}
/** Options for paged search methods */
export interface SearchPaginationOptions extends SearchFetchOptions {
maxPages?: number;
/** Starting cursor for pagination (resume from previous fetch) */
cursor?: string;
}
export interface TwitterClientSearchMethods {
search(query: string, count?: number, options?: SearchFetchOptions): Promise<SearchResult>;
getAllSearchResults(query: string, options?: SearchPaginationOptions): Promise<SearchResult>;
}
function isQueryIdMismatch(payload: string): boolean {
try {
const parsed = JSON.parse(payload) as {
errors?: Array<{ message?: string; path?: string[]; extensions?: { code?: string } }>;
};
return (
parsed.errors?.some((error) => {
if (error?.extensions?.code === 'GRAPHQL_VALIDATION_FAILED') {
return true;
}
if (error?.path?.includes('rawQuery') && RAW_QUERY_MISSING_REGEX.test(error.message ?? '')) {
return true;
}
return false;
}) ?? false
);
} catch {
return false;
}
}
export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>(
@@ -27,12 +58,29 @@ export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>
* Search for tweets matching a query
*/
async search(query: string, count = 20, options: SearchFetchOptions = {}): Promise<SearchResult> {
const { includeRaw = false } = options;
return this.searchPaged(query, count, options);
}
/**
* Get all search results (paged)
*/
async getAllSearchResults(query: string, options?: SearchPaginationOptions): Promise<SearchResult> {
return this.searchPaged(query, Number.POSITIVE_INFINITY, options);
}
private async searchPaged(
query: string,
limit: number,
options: SearchPaginationOptions = {},
): Promise<SearchResult> {
const features = buildSearchFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined;
let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined;
let pagesFetched = 0;
const { includeRaw = false, maxPages } = options;
const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined;
@@ -69,7 +117,13 @@ export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
const shouldRefreshQueryIds =
(response.status === 400 || response.status === 422) && isQueryIdMismatch(text);
return {
success: false as const,
error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
had404: had404 || shouldRefreshQueryIds,
};
}
const data = (await response.json()) as {
@@ -112,11 +166,18 @@ export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>
};
};
};
errors?: Array<{ message: string }>;
errors?: Array<{ message: string; extensions?: { code?: string } }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
const shouldRefreshQueryIds = data.errors.some(
(error) => error?.extensions?.code === 'GRAPHQL_VALIDATION_FAILED',
);
return {
success: false as const,
error: data.errors.map((e) => e.message).join(', '),
had404: had404 || shouldRefreshQueryIds,
};
}
const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions;
@@ -148,31 +209,42 @@ export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>
return { success: false as const, error: firstAttempt.error };
};
while (tweets.length < count) {
const pageCount = Math.min(pageSize, count - tweets.length);
const unlimited = limit === Number.POSITIVE_INFINITY;
while (unlimited || tweets.length < limit) {
const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) {
return { success: false, error: page.error };
}
pagesFetched += 1;
let added = 0;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
if (tweets.length >= count) {
added += 1;
if (!unlimited && tweets.length >= limit) {
break;
}
}
if (!page.cursor || page.cursor === cursor || page.tweets.length === 0) {
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined;
break;
}
cursor = page.cursor;
if (maxPages && pagesFetched >= maxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
nextCursor = pageCursor;
}
return { success: true, tweets };
return { success: true, tweets, nextCursor };
}
}
+101 -35
View File
@@ -21,6 +21,7 @@ export interface TwitterClientTimelineMethods {
getBookmarks(count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
getAllBookmarks(options?: TimelinePaginationOptions): Promise<SearchResult>;
getLikes(count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
getAllLikes(options?: TimelinePaginationOptions): Promise<SearchResult>;
getBookmarkFolderTimeline(folderId: string, count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
getAllBookmarkFolderTimeline(folderId: string, options?: TimelinePaginationOptions): Promise<SearchResult>;
}
@@ -75,34 +76,48 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
* Get the authenticated user's liked tweets
*/
async getLikes(count = 20, options: TimelineFetchOptions = {}): Promise<SearchResult> {
const { includeRaw = false } = options;
return this.getLikesPaged(count, options);
}
async getAllLikes(options?: TimelinePaginationOptions): Promise<SearchResult> {
return this.getLikesPaged(Number.POSITIVE_INFINITY, options);
}
private async getLikesPaged(limit: number, options: TimelinePaginationOptions = {}): Promise<SearchResult> {
const userResult = await this.getCurrentUser();
if (!userResult.success || !userResult.user) {
return { success: false, error: userResult.error ?? 'Could not determine current user' };
}
const variables = {
userId: userResult.user.id,
count,
includePromotedContent: false,
withClientEventToken: false,
withBirdwatchNotes: false,
withVoice: true,
};
const userId = userResult.user.id;
const features = buildLikesFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined;
let pagesFetched = 0;
const { includeRaw = false, maxPages } = options;
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const tryOnce = async () => {
const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getLikesQueryIds();
for (const queryId of queryIds) {
const variables = {
userId,
count: pageCount,
includePromotedContent: false,
withClientEventToken: false,
withBirdwatchNotes: false,
withVoice: true,
...(pageCursor ? { cursor: pageCursor } : {}),
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const url = `${TWITTER_API_BASE}/${queryId}/Likes?${params.toString()}`;
try {
@@ -147,14 +162,21 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
if (data.errors && data.errors.length > 0) {
const message = data.errors.map((e) => e.message).join(', ');
if (!instructions) {
if (message.includes('Query: Unspecified')) {
lastError = message;
continue;
}
return { success: false as const, error: message, had404 };
}
}
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const extractedCursor = extractCursorFromInstructions(instructions);
return { success: true as const, tweets, had404 };
return { success: true as const, tweets: pageTweets, cursor: extractedCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
@@ -163,21 +185,61 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
return { success: false as const, error: lastError ?? 'Unknown error fetching likes', had404 };
};
const firstAttempt = await tryOnce();
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
const firstAttempt = await fetchPage(pageCount, pageCursor);
if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
return firstAttempt;
}
if (firstAttempt.had404) {
const shouldRefresh =
firstAttempt.had404 ||
(typeof firstAttempt.error === 'string' && firstAttempt.error.includes('Query: Unspecified'));
if (shouldRefresh) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
const secondAttempt = await fetchPage(pageCount, pageCursor);
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
return secondAttempt;
}
return { success: false as const, error: secondAttempt.error };
}
return { success: false as const, error: firstAttempt.error };
};
const unlimited = limit === Number.POSITIVE_INFINITY;
while (unlimited || tweets.length < limit) {
const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) {
return { success: false, error: page.error };
}
pagesFetched += 1;
let added = 0;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
added += 1;
if (!unlimited && tweets.length >= limit) {
break;
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined;
break;
}
if (maxPages && pagesFetched >= maxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
nextCursor = pageCursor;
}
return { success: true, tweets, nextCursor };
}
/**
@@ -320,7 +382,7 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
return { success: false as const, error: firstAttempt.error };
};
const unlimited = !Number.isFinite(limit);
const unlimited = limit === Number.POSITIVE_INFINITY;
while (unlimited || tweets.length < limit) {
const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor);
@@ -329,19 +391,21 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
}
pagesFetched += 1;
let added = 0;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
added += 1;
if (!unlimited && tweets.length >= limit) {
break;
}
}
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0) {
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined;
break;
}
@@ -501,7 +565,7 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
return { success: false as const, error: firstAttempt.error };
};
const unlimited = !Number.isFinite(limit);
const unlimited = limit === Number.POSITIVE_INFINITY;
while (unlimited || tweets.length < limit) {
const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor);
@@ -510,19 +574,21 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
}
pagesFetched += 1;
let added = 0;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
added += 1;
if (!unlimited && tweets.length >= limit) {
break;
}
}
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0) {
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined;
break;
}
+123 -2
View File
@@ -1,9 +1,11 @@
import { paginateCursor } from './paginate-cursor.js';
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildArticleFeatures, buildArticleFieldToggles, buildTweetDetailFeatures } from './twitter-client-features.js';
import type { GetTweetResult, GraphqlTweetResult, SearchResult } from './twitter-client-types.js';
import type { GetTweetResult, GraphqlTweetResult, SearchResult, TweetData } from './twitter-client-types.js';
import {
extractArticleText,
extractCursorFromInstructions,
findTweetInInstructions,
firstText,
mapTweetResult,
@@ -16,10 +18,22 @@ export interface TweetFetchOptions {
includeRaw?: boolean;
}
/** Options for paginated tweet detail fetch */
export interface TweetDetailPaginationOptions extends TweetFetchOptions {
/** Maximum number of pages to fetch (default: unlimited when using pagination) */
maxPages?: number;
/** Starting cursor for pagination (resume from previous fetch) */
cursor?: string;
/** Delay in milliseconds between page fetches (default: 1000) */
pageDelayMs?: number;
}
export interface TwitterClientTweetDetailMethods {
getTweet(tweetId: string, options?: TweetFetchOptions): Promise<GetTweetResult>;
getReplies(tweetId: string, options?: TweetFetchOptions): Promise<SearchResult>;
getThread(tweetId: string, options?: TweetFetchOptions): Promise<SearchResult>;
getRepliesPaged(tweetId: string, options?: TweetDetailPaginationOptions): Promise<SearchResult>;
getThreadPaged(tweetId: string, options?: TweetDetailPaginationOptions): Promise<SearchResult>;
}
export function withTweetDetails<TBase extends AbstractConstructor<TwitterClientBase>>(
@@ -110,7 +124,10 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
return {};
}
private async fetchTweetDetail(tweetId: string): Promise<
private async fetchTweetDetail(
tweetId: string,
cursor?: string,
): Promise<
| {
success: true;
data: {
@@ -141,6 +158,7 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
withQuickPromoteEligibilityTweetFields: true,
withBirdwatchNotes: true,
withVoice: true,
...(cursor ? { cursor } : {}),
};
const features = {
@@ -159,9 +177,15 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
rweb_video_timestamps_enabled: true,
};
const fieldToggles = {
...buildArticleFieldToggles(),
withArticleRichContentState: true,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
fieldToggles: JSON.stringify(fieldToggles),
});
try {
@@ -192,8 +216,16 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
};
if (data.errors && data.errors.length > 0) {
// Twitter API sometimes returns partial errors (e.g., is_translatable failures)
// alongside valid tweet/thread data. Only fail if nothing useful is present.
const hasUsableData = Boolean(
data.data?.tweetResult?.result ||
data.data?.threaded_conversation_with_injections_v2?.instructions?.length,
);
if (!hasUsableData) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', ') };
}
}
return { success: true as const, data: data.data ?? {} };
};
@@ -340,6 +372,95 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
return { success: true, tweets: thread };
}
/**
* Get replies to a tweet with pagination support
*/
async getRepliesPaged(tweetId: string, options: TweetDetailPaginationOptions = {}): Promise<SearchResult> {
const { includeRaw = false, maxPages, pageDelayMs = 1000 } = options;
const result = await paginateCursor<TweetData>({
cursor: options.cursor,
maxPages,
pageDelayMs,
sleep: async (ms) => this.sleep(ms),
getKey: (tweet) => tweet.id,
fetchPage: async (cursor) => {
const response = await this.fetchTweetDetail(tweetId, cursor);
if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const replies = tweets.filter((tweet) => tweet.inReplyToStatusId === tweetId);
const pageCursor = extractCursorFromInstructions(instructions);
return { success: true as const, items: replies, cursor: pageCursor };
},
});
if (result.success) {
return { success: true, tweets: result.items, nextCursor: result.nextCursor };
}
if (result.items) {
return { success: false, tweets: result.items, nextCursor: result.nextCursor, error: result.error };
}
return { success: false, error: result.error };
}
/**
* Get full conversation thread with pagination support
*/
async getThreadPaged(tweetId: string, options: TweetDetailPaginationOptions = {}): Promise<SearchResult> {
const { includeRaw = false, maxPages, pageDelayMs = 1000 } = options;
let rootId: string | undefined;
const result = await paginateCursor<TweetData>({
cursor: options.cursor,
maxPages,
pageDelayMs,
sleep: async (ms) => this.sleep(ms),
getKey: (tweet) => tweet.id,
fetchPage: async (cursor) => {
const response = await this.fetchTweetDetail(tweetId, cursor);
if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
if (!rootId) {
const target = tweets.find((t) => t.id === tweetId);
rootId = target?.conversationId || tweetId;
}
const threadTweets = tweets.filter((tweet) => tweet.conversationId === rootId);
const pageCursor = extractCursorFromInstructions(instructions);
return { success: true as const, items: threadTweets, cursor: pageCursor };
},
});
const sortedTweets = (result.items ?? []).slice().sort((a, b) => {
const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
return aTime - bTime;
});
if (result.success) {
return { success: true, tweets: sortedTweets, nextCursor: result.nextCursor };
}
if (result.items) {
return { success: false, tweets: sortedTweets, nextCursor: result.nextCursor, error: result.error };
}
return { success: false, error: result.error };
}
}
return TwitterClientTweetDetails;
+50 -6
View File
@@ -80,9 +80,12 @@ export type GraphqlTweetResult = {
};
};
article?: {
title?: string;
preview_text?: string;
article_results?: {
result?: {
title?: string;
preview_text?: string;
plain_text?: string;
text?: string;
richtext?: {
@@ -127,9 +130,36 @@ export type GraphqlTweetResult = {
};
}>;
}>;
/** Draft.js content state for rich article content */
content_state?: {
blocks: Array<{
key: string;
type: string;
text: string;
data?: Record<string, unknown>;
entityRanges?: Array<{ key: number; offset: number; length: number }>;
inlineStyleRanges?: Array<{ offset: number; length: number; style: string }>;
}>;
entityMap?:
| Array<{
key: string;
value: {
type: string;
mutability: string;
data: Record<string, unknown>;
};
}>
| Record<
string,
{
type: string;
mutability: string;
data: Record<string, unknown>;
}
>;
};
};
};
title?: string;
plain_text?: string;
text?: string;
richtext?: {
@@ -236,6 +266,11 @@ export interface TweetData {
quotedTweet?: TweetData;
// Media attachments (photos, videos, GIFs)
media?: TweetMedia[];
// Article metadata (for Twitter Articles / long-form posts)
article?: {
title: string;
previewText?: string;
};
// Raw GraphQL tweet result (only when includeRaw is enabled)
_raw?: GraphqlTweetResult;
}
@@ -246,13 +281,20 @@ export interface GetTweetResult {
error?: string;
}
export interface SearchResult {
success: boolean;
tweets?: TweetData[];
error?: string;
export type SearchResult =
| {
success: true;
tweets: TweetData[];
/** Cursor for fetching the next page of results */
nextCursor?: string;
}
}
| {
success: false;
error: string;
tweets?: TweetData[];
/** Cursor for fetching the next page of results */
nextCursor?: string;
};
export interface CurrentUserResult {
success: boolean;
@@ -280,6 +322,8 @@ export interface FollowingResult {
success: boolean;
users?: TwitterUser[];
error?: string;
/** Cursor for fetching the next page of results */
nextCursor?: string;
}
export interface TwitterClientOptions {
+211
View File
@@ -0,0 +1,211 @@
import { normalizeHandle } from './normalize-handle.js';
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
/** Result of username to userId lookup */
export interface UserLookupResult {
success: boolean;
userId?: string;
username?: string;
name?: string;
error?: string;
}
export interface TwitterClientUserLookupMethods {
getUserIdByUsername(username: string): Promise<UserLookupResult>;
}
export function withUserLookup<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientUserLookupMethods> {
abstract class TwitterClientUserLookup extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private async getUserByScreenNameGraphQL(screenName: string): Promise<UserLookupResult> {
// UserByScreenName query IDs observed from web client
const queryIds = ['xc8f1g7BYqr6VTzTbvNlGw', 'qW5u-DAuXpMEG0zA1F7UGQ', 'sLVLhk0bGj3MVFEKTdax1w'];
const variables = {
screen_name: screenName,
withSafetyModeUserFields: true,
};
const features = {
hidden_profile_subscriptions_enabled: true,
hidden_profile_likes_enabled: true,
rweb_tipjar_consumption_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
verified_phone_label_enabled: false,
subscriptions_verification_info_is_identity_verified_enabled: true,
subscriptions_verification_info_verified_since_enabled: true,
highlights_tweets_tab_ui_enabled: true,
responsive_web_twitter_article_notes_tab_enabled: true,
subscriptions_feature_can_gift_premium: true,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
responsive_web_graphql_timeline_navigation_enabled: true,
blue_business_profile_image_shape_enabled: true,
};
const fieldToggles = {
withAuxiliaryUserLabels: false,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
fieldToggles: JSON.stringify(fieldToggles),
});
let lastError: string | undefined;
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/UserByScreenName?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
const text = await response.text();
if (response.status === 404) {
// Try next query ID
lastError = `HTTP ${response.status}`;
continue;
}
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
continue;
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
__typename?: string;
rest_id?: string;
legacy?: {
screen_name?: string;
name?: string;
};
core?: {
screen_name?: string;
name?: string;
};
};
};
};
errors?: Array<{ message: string }>;
};
// Check for user not found
if (data.data?.user?.result?.__typename === 'UserUnavailable') {
return { success: false, error: `User @${screenName} not found or unavailable` };
}
const userResult = data.data?.user?.result;
const userId = userResult?.rest_id;
const username = userResult?.legacy?.screen_name ?? userResult?.core?.screen_name;
const name = userResult?.legacy?.name ?? userResult?.core?.name;
if (userId && username) {
return {
success: true,
userId,
username,
name,
};
}
if (data.errors && data.errors.length > 0) {
lastError = data.errors.map((e) => e.message).join(', ');
continue;
}
lastError = 'Could not parse user data from response';
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false, error: lastError ?? 'Unknown error looking up user' };
}
/**
* Look up a user's ID by their username/handle.
* Uses GraphQL UserByScreenName first, then falls back to REST on transient failures.
*/
async getUserIdByUsername(username: string): Promise<UserLookupResult> {
const cleanUsername = normalizeHandle(username);
if (!cleanUsername) {
return { success: false, error: `Invalid username: ${username}` };
}
const graphqlResult = await this.getUserByScreenNameGraphQL(cleanUsername);
if (graphqlResult.success) {
return graphqlResult;
}
// If GraphQL definitively says user is unavailable, don't retry with REST
if (graphqlResult.error?.includes('not found or unavailable')) {
return graphqlResult;
}
// Fallback to REST API for transient GraphQL errors
const urls = [
`https://x.com/i/api/1.1/users/show.json?screen_name=${encodeURIComponent(cleanUsername)}`,
`https://api.twitter.com/1.1/users/show.json?screen_name=${encodeURIComponent(cleanUsername)}`,
];
let lastError: string | undefined = graphqlResult.error;
for (const url of urls) {
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
const text = await response.text();
if (response.status === 404) {
return { success: false, error: `User @${cleanUsername} not found` };
}
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
continue;
}
const data = (await response.json()) as {
id_str?: string;
id?: number;
screen_name?: string;
name?: string;
};
const userId = data.id_str ?? (data.id ? String(data.id) : null);
if (!userId) {
lastError = 'Could not parse user ID from response';
continue;
}
return {
success: true,
userId,
username: data.screen_name ?? cleanUsername,
name: data.name,
};
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false, error: lastError ?? 'Unknown error looking up user' };
}
}
return TwitterClientUserLookup;
}
+262
View File
@@ -0,0 +1,262 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildUserTweetsFeatures } from './twitter-client-features.js';
import type { GraphqlTweetResult, SearchResult, TweetData } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
/** Options for user tweets fetch methods */
export interface UserTweetsFetchOptions {
/** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean;
}
/** Options for paginated user tweets fetch */
export interface UserTweetsPaginationOptions extends UserTweetsFetchOptions {
/** Maximum number of pages to fetch (default: 1) */
maxPages?: number;
/** Starting cursor for pagination (resume from previous fetch) */
cursor?: string;
/** Delay in milliseconds between page fetches (default: 1000) */
pageDelayMs?: number;
}
export interface TwitterClientUserTweetsMethods {
getUserTweets(userId: string, count?: number, options?: UserTweetsFetchOptions): Promise<SearchResult>;
getUserTweetsPaged(userId: string, limit: number, options?: UserTweetsPaginationOptions): Promise<SearchResult>;
}
export function withUserTweets<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientUserTweetsMethods> {
abstract class TwitterClientUserTweets extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private async getUserTweetsQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('UserTweets');
// Fallback query ID observed from web client
return Array.from(new Set([primary, 'Wms1GvIiHXAPBaCr9KblaA']));
}
/**
* Get tweets from a user's profile timeline (single page).
*/
async getUserTweets(userId: string, count = 20, options: UserTweetsFetchOptions = {}): Promise<SearchResult> {
return this.getUserTweetsPaged(userId, count, options);
}
/**
* Get tweets from a user's profile timeline with pagination support.
*/
async getUserTweetsPaged(
userId: string,
limit: number,
options: UserTweetsPaginationOptions = {},
): Promise<SearchResult> {
if (!Number.isFinite(limit) || limit <= 0) {
return { success: false, error: `Invalid limit: ${limit}` };
}
const { includeRaw = false, maxPages, pageDelayMs = 1000 } = options;
const features = buildUserTweetsFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined;
let pagesFetched = 0;
const hardMaxPages = 10;
const computedMaxPages = Math.max(1, Math.ceil(limit / pageSize));
const effectiveMaxPages = Math.min(hardMaxPages, maxPages ?? computedMaxPages);
const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getUserTweetsQueryIds();
const variables = {
userId,
count: pageCount,
includePromotedContent: false, // Filter out ads
withQuickPromoteEligibilityTweetFields: true,
withVoice: true,
...(pageCursor ? { cursor: pageCursor } : {}),
};
const fieldToggles = {
withArticlePlainText: false,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
fieldToggles: JSON.stringify(fieldToggles),
});
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/UserTweets?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{
type?: string;
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
items?: Array<{
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
cursorType?: string;
value?: string;
};
}>;
}>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
// Check for common errors
const errorMsg = data.errors.map((e) => e.message).join(', ');
if (errorMsg.includes('User has been suspended') || errorMsg.includes('User not found')) {
return { success: false as const, error: errorMsg, had404 };
}
// Some errors are non-fatal if we got data
if (!data.data?.user?.result?.timeline?.timeline?.instructions) {
return { success: false as const, error: errorMsg, had404 };
}
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const pageCursorValue = extractCursorFromInstructions(instructions);
return { success: true as const, tweets: pageTweets, cursor: pageCursorValue, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching user tweets', had404 };
};
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
const firstAttempt = await fetchPage(pageCount, pageCursor);
if (firstAttempt.success) {
return firstAttempt;
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await fetchPage(pageCount, pageCursor);
if (secondAttempt.success) {
return secondAttempt;
}
return { success: false as const, error: secondAttempt.error };
}
return { success: false as const, error: firstAttempt.error };
};
while (tweets.length < limit) {
// Add delay between pages (but not before the first page)
if (pagesFetched > 0 && pageDelayMs > 0) {
await this.sleep(pageDelayMs);
}
const remaining = limit - tweets.length;
const pageCount = Math.min(pageSize, remaining);
const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) {
return { success: false, error: page.error };
}
pagesFetched += 1;
let added = 0;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
added += 1;
if (tweets.length >= limit) {
break;
}
}
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined;
break;
}
if (pagesFetched >= effectiveMaxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
nextCursor = pageCursor;
}
return { success: true, tweets, nextCursor };
}
}
return TwitterClientUserTweets;
}
+70 -78
View File
@@ -6,13 +6,26 @@ import {
TWITTER_API_BASE,
} from './twitter-client-constants.js';
import { buildFollowingFeatures } from './twitter-client-features.js';
import type { CurrentUserResult, FollowingResult } from './twitter-client-types.js';
import { parseUsersFromInstructions } from './twitter-client-utils.js';
import type { CurrentUserResult, FollowingResult, TwitterUser } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseUsersFromInstructions } from './twitter-client-utils.js';
type RestUser = {
id_str?: string;
id?: string | number;
screen_name?: string;
name?: string;
description?: string;
followers_count?: number;
friends_count?: number;
verified?: boolean;
profile_image_url_https?: string;
created_at?: string;
};
export interface TwitterClientUserMethods {
getCurrentUser(): Promise<CurrentUserResult>;
getFollowing(userId: string, count?: number): Promise<FollowingResult>;
getFollowers(userId: string, count?: number): Promise<FollowingResult>;
getFollowing(userId: string, count?: number, cursor?: string): Promise<FollowingResult>;
getFollowers(userId: string, count?: number, cursor?: string): Promise<FollowingResult>;
}
export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
@@ -34,6 +47,29 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
return Array.from(new Set([primary, 'kuFUYP9eV1FPoEy4N-pi7w']));
}
private parseUsersFromRestResponse(users: RestUser[] | undefined): TwitterUser[] {
return (users ?? [])
.map((u) => {
const id = typeof u.id_str === 'string' ? u.id_str : typeof u.id === 'number' ? String(u.id) : null;
const username = typeof u.screen_name === 'string' ? u.screen_name : null;
if (!id || !username) {
return null;
}
return {
id,
username,
name: typeof u.name === 'string' && u.name.length > 0 ? u.name : username,
description: typeof u.description === 'string' ? u.description : undefined,
followersCount: typeof u.followers_count === 'number' ? u.followers_count : undefined,
followingCount: typeof u.friends_count === 'number' ? u.friends_count : undefined,
isBlueVerified: typeof u.verified === 'boolean' ? u.verified : undefined,
profileImageUrl: typeof u.profile_image_url_https === 'string' ? u.profile_image_url_https : undefined,
createdAt: typeof u.created_at === 'string' ? u.created_at : undefined,
};
})
.filter((u) => u !== null);
}
private async getFollowersViaRest(userId: string, count: number): Promise<FollowingResult> {
const params = new URLSearchParams({
user_id: userId,
@@ -63,40 +99,10 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
}
const data = (await response.json()) as {
users?: Array<{
id_str?: string;
id?: string | number;
screen_name?: string;
name?: string;
description?: string;
followers_count?: number;
friends_count?: number;
verified?: boolean;
profile_image_url_https?: string;
created_at?: string;
}>;
users?: RestUser[];
};
const users = (data.users ?? [])
.map((u) => {
const id = typeof u.id_str === 'string' ? u.id_str : typeof u.id === 'number' ? String(u.id) : null;
const username = typeof u.screen_name === 'string' ? u.screen_name : null;
if (!id || !username) {
return null;
}
return {
id,
username,
name: typeof u.name === 'string' && u.name.length > 0 ? u.name : username,
description: typeof u.description === 'string' ? u.description : undefined,
followersCount: typeof u.followers_count === 'number' ? u.followers_count : undefined,
followingCount: typeof u.friends_count === 'number' ? u.friends_count : undefined,
isBlueVerified: typeof u.verified === 'boolean' ? u.verified : undefined,
profileImageUrl: typeof u.profile_image_url_https === 'string' ? u.profile_image_url_https : undefined,
createdAt: typeof u.created_at === 'string' ? u.created_at : undefined,
};
})
.filter((u) => u !== null);
const users = this.parseUsersFromRestResponse(data.users);
return { success: true, users };
} catch (error) {
@@ -136,40 +142,10 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
}
const data = (await response.json()) as {
users?: Array<{
id_str?: string;
id?: string | number;
screen_name?: string;
name?: string;
description?: string;
followers_count?: number;
friends_count?: number;
verified?: boolean;
profile_image_url_https?: string;
created_at?: string;
}>;
users?: RestUser[];
};
const users = (data.users ?? [])
.map((u) => {
const id = typeof u.id_str === 'string' ? u.id_str : typeof u.id === 'number' ? String(u.id) : null;
const username = typeof u.screen_name === 'string' ? u.screen_name : null;
if (!id || !username) {
return null;
}
return {
id,
username,
name: typeof u.name === 'string' && u.name.length > 0 ? u.name : username,
description: typeof u.description === 'string' ? u.description : undefined,
followersCount: typeof u.followers_count === 'number' ? u.followers_count : undefined,
followingCount: typeof u.friends_count === 'number' ? u.friends_count : undefined,
isBlueVerified: typeof u.verified === 'boolean' ? u.verified : undefined,
profileImageUrl: typeof u.profile_image_url_https === 'string' ? u.profile_image_url_https : undefined,
createdAt: typeof u.created_at === 'string' ? u.created_at : undefined,
};
})
.filter((u) => u !== null);
const users = this.parseUsersFromRestResponse(data.users);
return { success: true, users };
} catch (error) {
@@ -309,13 +285,17 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
/**
* Get users that a user is following
*/
async getFollowing(userId: string, count = 20): Promise<FollowingResult> {
const variables = {
async getFollowing(userId: string, count = 20, cursor?: string): Promise<FollowingResult> {
const variables: Record<string, unknown> = {
userId,
count,
includePromotedContent: false,
};
if (cursor) {
variables.cursor = cursor;
}
const features = buildFollowingFeatures();
const params = new URLSearchParams({
@@ -369,8 +349,11 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const users = parseUsersFromInstructions(instructions);
const nextCursor = extractCursorFromInstructions(
instructions as Array<{ entries?: Array<{ content?: unknown }> }> | undefined,
);
return { success: true as const, users, had404 };
return { success: true as const, users, nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
@@ -381,18 +364,19 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, users: firstAttempt.users };
return { success: true, users: firstAttempt.users, nextCursor: firstAttempt.nextCursor };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, users: secondAttempt.users };
return { success: true, users: secondAttempt.users, nextCursor: secondAttempt.nextCursor };
}
// GraphQL Following can also return 404 (queryId churn / endpoint flakiness).
// Fallback to the internal v1.1 REST endpoint used by the web client (cookie-auth; no dev API key).
// Note: REST fallback does not support cursor pagination.
const restAttempt = await this.getFollowingViaRest(userId, count);
if (restAttempt.success) {
return restAttempt;
@@ -407,13 +391,17 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
/**
* Get users that follow a user
*/
async getFollowers(userId: string, count = 20): Promise<FollowingResult> {
const variables = {
async getFollowers(userId: string, count = 20, cursor?: string): Promise<FollowingResult> {
const variables: Record<string, unknown> = {
userId,
count,
includePromotedContent: false,
};
if (cursor) {
variables.cursor = cursor;
}
const features = buildFollowingFeatures();
const params = new URLSearchParams({
@@ -467,8 +455,11 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const users = parseUsersFromInstructions(instructions);
const nextCursor = extractCursorFromInstructions(
instructions as Array<{ entries?: Array<{ content?: unknown }> }> | undefined,
);
return { success: true as const, users, had404 };
return { success: true as const, users, nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
@@ -479,18 +470,19 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, users: firstAttempt.users };
return { success: true, users: firstAttempt.users, nextCursor: firstAttempt.nextCursor };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, users: secondAttempt.users };
return { success: true, users: secondAttempt.users, nextCursor: secondAttempt.nextCursor };
}
// GraphQL Followers regularly returns 404 (queryId churn / endpoint flakiness).
// Fallback to the internal v1.1 REST endpoint used by the web client (cookie-auth; no dev API key).
// Note: REST fallback does not support cursor pagination.
const restAttempt = await this.getFollowersViaRest(userId, count);
if (restAttempt.success) {
return restAttempt;
+293
View File
@@ -66,6 +66,253 @@ export function uniqueOrdered(values: string[]): string[] {
return result;
}
// ============================================================================
// Draft.js Content State Types and Parser for Long-form Tweets (X Articles)
// ============================================================================
/** Inline style range for text formatting (Bold, Italic, etc.) */
interface InlineStyleRange {
offset: number;
length: number;
style: string;
}
/** Entity range linking a portion of text to an entity in entityMap */
interface EntityRange {
key: number;
offset: number;
length: number;
}
/** A content block in Draft.js format */
interface ContentBlock {
key: string;
type: string;
text: string;
data?: {
mentions?: Array<{ fromIndex: number; toIndex: number; text: string }>;
};
entityRanges?: EntityRange[];
inlineStyleRanges?: InlineStyleRange[];
}
/** Entity data for different entity types */
interface EntityValue {
type: string;
mutability: string;
data: {
markdown?: string;
url?: string;
tweetId?: string;
};
}
/** Entity map entry */
interface EntityMapEntry {
key: string;
value: EntityValue;
}
/** Draft.js content state structure */
interface ContentState {
blocks: ContentBlock[];
entityMap?: Array<EntityMapEntry> | Record<string, EntityValue>;
}
/**
* Renders a Draft.js content_state into readable markdown/text format.
* Handles blocks (paragraphs, headers, lists) and entities (code blocks, links, tweets, dividers).
*/
export function renderContentState(contentState: ContentState | undefined): string | undefined {
if (!contentState?.blocks || contentState.blocks.length === 0) {
return undefined;
}
// Build entity lookup map from array/object formats
const entityMap = new Map<number, EntityValue>();
const rawEntityMap = contentState.entityMap ?? [];
if (Array.isArray(rawEntityMap)) {
for (const entry of rawEntityMap) {
const key = Number.parseInt(entry.key, 10);
if (!Number.isNaN(key)) {
entityMap.set(key, entry.value);
}
}
} else {
for (const [key, value] of Object.entries(rawEntityMap)) {
const keyNumber = Number.parseInt(key, 10);
if (!Number.isNaN(keyNumber)) {
entityMap.set(keyNumber, value);
}
}
}
const outputLines: string[] = [];
let orderedListCounter = 0;
let previousBlockType: string | undefined;
for (const block of contentState.blocks) {
// Reset ordered list counter when leaving ordered list context
if (block.type !== 'ordered-list-item' && previousBlockType === 'ordered-list-item') {
orderedListCounter = 0;
}
switch (block.type) {
case 'unstyled': {
// Plain paragraph - just output text with any inline formatting
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(text);
}
break;
}
case 'header-one': {
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(`# ${text}`);
}
break;
}
case 'header-two': {
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(`## ${text}`);
}
break;
}
case 'header-three': {
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(`### ${text}`);
}
break;
}
case 'unordered-list-item': {
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(`- ${text}`);
}
break;
}
case 'ordered-list-item': {
orderedListCounter++;
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(`${orderedListCounter}. ${text}`);
}
break;
}
case 'blockquote': {
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(`> ${text}`);
}
break;
}
case 'atomic': {
// Atomic blocks are placeholders for embedded entities
const entityContent = renderAtomicBlock(block, entityMap);
if (entityContent) {
outputLines.push(entityContent);
}
break;
}
default: {
// Fallback: just output the text
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(text);
}
}
}
previousBlockType = block.type;
}
const result = outputLines.join('\n\n');
return result.trim() || undefined;
}
/**
* Renders text content of a block, applying inline link entities.
*/
function renderBlockText(block: ContentBlock, entityMap: Map<number, EntityValue>): string {
let text = block.text;
// Handle LINK entities by appending URL in markdown format
// Process in reverse order to not mess up offsets
const linkRanges = (block.entityRanges ?? [])
.filter((range) => {
const entity = entityMap.get(range.key);
return entity?.type === 'LINK' && entity.data.url;
})
.sort((a, b) => b.offset - a.offset);
for (const range of linkRanges) {
const entity = entityMap.get(range.key);
if (entity?.data.url) {
const linkText = text.slice(range.offset, range.offset + range.length);
const markdownLink = `[${linkText}](${entity.data.url})`;
text = text.slice(0, range.offset) + markdownLink + text.slice(range.offset + range.length);
}
}
return text.trim();
}
/**
* Renders an atomic block by looking up its entity and returning appropriate content.
*/
function renderAtomicBlock(block: ContentBlock, entityMap: Map<number, EntityValue>): string | undefined {
const entityRanges = block.entityRanges ?? [];
if (entityRanges.length === 0) {
return undefined;
}
const entityKey = entityRanges[0].key;
const entity = entityMap.get(entityKey);
if (!entity) {
return undefined;
}
switch (entity.type) {
case 'MARKDOWN':
// Code blocks and other markdown content - output as-is
return entity.data.markdown?.trim();
case 'DIVIDER':
return '---';
case 'TWEET':
if (entity.data.tweetId) {
return `[Embedded Tweet: https://x.com/i/status/${entity.data.tweetId}]`;
}
return undefined;
case 'LINK':
if (entity.data.url) {
return `[Link: ${entity.data.url}]`;
}
return undefined;
case 'IMAGE':
// Images in atomic blocks - could extract URL if available
return '[Image]';
default:
return undefined;
}
}
export function extractArticleText(result: GraphqlTweetResult | undefined): string | undefined {
const article = result?.article;
if (!article) {
@@ -87,7 +334,31 @@ export function extractArticleText(result: GraphqlTweetResult | undefined): stri
),
);
}
const title = firstText(articleResult.title, article.title);
// Try to render from rich content_state first (Draft.js format with blocks + entityMap)
// This preserves code blocks, embedded tweets, markdown, etc.
const contentState = article.article_results?.result?.content_state;
const richBody = renderContentState(contentState);
if (richBody) {
// Rich content found - prepend title if not already included
if (title) {
const normalizedTitle = title.trim();
const trimmedBody = richBody.trimStart();
const headingMatches = [`# ${normalizedTitle}`, `## ${normalizedTitle}`, `### ${normalizedTitle}`];
const hasTitle =
trimmedBody === normalizedTitle ||
trimmedBody.startsWith(`${normalizedTitle}\n`) ||
headingMatches.some((heading) => trimmedBody.startsWith(heading));
if (!hasTitle) {
return `${title}\n\n${richBody}`;
}
}
return richBody;
}
// Fallback to plain text extraction for articles without rich content_state
let body = firstText(
articleResult.plain_text,
article.plain_text,
@@ -153,6 +424,26 @@ export function extractTweetText(result: GraphqlTweetResult | undefined): string
return extractArticleText(result) ?? extractNoteTweetText(result) ?? firstText(result?.legacy?.full_text);
}
export function extractArticleMetadata(
result: GraphqlTweetResult | undefined,
): { title: string; previewText?: string } | undefined {
const article = result?.article;
if (!article) {
return undefined;
}
const articleResult = article.article_results?.result ?? article;
const title = firstText(articleResult.title, article.title);
if (!title) {
return undefined;
}
// preview_text is available in home timeline responses
const previewText = firstText(articleResult.preview_text, article.preview_text);
return { title, previewText };
}
export function extractMedia(result: GraphqlTweetResult | undefined): TweetMedia[] | undefined {
// Prefer extended_entities (has video info), fall back to entities
const rawMedia = result?.legacy?.extended_entities?.media ?? result?.legacy?.entities?.media;
@@ -261,6 +552,7 @@ export function mapTweetResult(
}
const media = extractMedia(result);
const article = extractArticleMetadata(result);
const tweetData: TweetData = {
id: result.rest_id,
@@ -278,6 +570,7 @@ export function mapTweetResult(
authorId: userId,
quotedTweet,
media,
article,
};
if (includeRaw) {
+23 -3
View File
@@ -1,30 +1,50 @@
import type { AbstractConstructor } from './twitter-client-base.js';
import { TwitterClientBase } from './twitter-client-base.js';
import { type TwitterClientBookmarkMethods, withBookmarks } from './twitter-client-bookmarks.js';
import { type TwitterClientHomeMethods, withHome } from './twitter-client-home.js';
import { type TwitterClientListMethods, withLists } from './twitter-client-lists.js';
import { type TwitterClientMediaMethods, withMedia } from './twitter-client-media.js';
import { type TwitterClientNewsMethods, withNews } from './twitter-client-news.js';
import { type TwitterClientPostingMethods, withPosting } from './twitter-client-posting.js';
import { type TwitterClientSearchMethods, withSearch } from './twitter-client-search.js';
import { type TwitterClientTimelineMethods, withTimelines } from './twitter-client-timelines.js';
import { type TwitterClientTweetDetailMethods, withTweetDetails } from './twitter-client-tweet-detail.js';
import { type TwitterClientUserLookupMethods, withUserLookup } from './twitter-client-user-lookup.js';
import { type TwitterClientUserTweetsMethods, withUserTweets } from './twitter-client-user-tweets.js';
import { type TwitterClientUserMethods, withUsers } from './twitter-client-users.js';
type TwitterClientInstance = TwitterClientBase &
TwitterClientBookmarkMethods &
TwitterClientHomeMethods &
TwitterClientListMethods &
TwitterClientMediaMethods &
TwitterClientNewsMethods &
TwitterClientPostingMethods &
TwitterClientSearchMethods &
TwitterClientTimelineMethods &
TwitterClientTweetDetailMethods &
TwitterClientUserMethods;
TwitterClientUserMethods &
TwitterClientUserLookupMethods &
TwitterClientUserTweetsMethods;
const MixedTwitterClient = withUsers(
withLists(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))),
// News mixin wraps search because it depends on the search() method
const MixedTwitterClient = withNews(
withUserTweets(
withUserLookup(
withUsers(
withLists(
withHome(
withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase)))))),
),
),
),
),
),
) as AbstractConstructor<TwitterClientInstance>;
export class TwitterClient extends MixedTwitterClient {}
export type { NewsFetchOptions, NewsItem, NewsResult } from './twitter-client-news.js';
export type {
BookmarkMutationResult,
CurrentUserResult,
+187
View File
@@ -0,0 +1,187 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CliContext } from '../src/cli/shared.js';
import { registerNewsCommand } from '../src/commands/news.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
describe('news command', () => {
let program: Command;
let mockContext: Partial<CliContext>;
beforeEach(() => {
program = new Command();
mockContext = {
resolveTimeoutFromOptions: () => 30000,
resolveQuoteDepthFromOptions: () => undefined,
resolveCredentialsFromOptions: async () => ({
cookies: {
authToken: 'auth',
ct0: 'ct0',
cookieHeader: 'auth=auth; ct0=ct0',
},
warnings: [],
}),
p: (type: string) => `[${type}] `,
colors: {
accent: (text: string) => text,
command: (text: string) => text,
muted: (text: string) => text,
section: (text: string) => text,
},
l: (key: string) => key,
};
});
it('requires positive count value', async () => {
registerNewsCommand(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'news', '--count', '0'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--count must be a positive number'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('rejects negative count value', async () => {
registerNewsCommand(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'news', '--count', '-5'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--count must be a positive number'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('rejects non-numeric count value', async () => {
registerNewsCommand(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'news', '--count', 'abc'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--count must be a positive number'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('requires positive tweets-per-item value', async () => {
registerNewsCommand(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'news', '--tweets-per-item', '0'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--tweets-per-item must be a positive number'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('rejects negative tweets-per-item value', async () => {
registerNewsCommand(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'news', '--tweets-per-item', '-3'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--tweets-per-item must be a positive number'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('rejects non-numeric tweets-per-item value', async () => {
registerNewsCommand(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'news', '--tweets-per-item', 'xyz'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--tweets-per-item must be a positive number'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('requires both authToken and ct0 credentials', async () => {
mockContext.resolveCredentialsFromOptions = async () => ({
cookies: {
authToken: '',
ct0: '',
cookieHeader: '',
},
warnings: [],
});
registerNewsCommand(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'news'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Missing required credentials'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('limits related tweets to tweets-per-item in CLI output', async () => {
registerNewsCommand(program, mockContext as CliContext);
const getNewsSpy = vi.spyOn(TwitterClient.prototype, 'getNews').mockResolvedValue({
success: true,
items: [
{
id: 'item-1',
headline: 'News headline',
category: 'News',
tweets: [
{ id: 't1', text: 'first tweet', author: { username: 'a', name: 'A' } },
{ id: 't2', text: 'second tweet', author: { username: 'b', name: 'B' } },
{ id: 't3', text: 'third tweet', author: { username: 'c', name: 'C' } },
],
},
],
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
try {
await program.parseAsync(['node', 'bird', 'news', '--with-tweets', '--tweets-per-item', '2', '--count', '1']);
expect(getNewsSpy).toHaveBeenCalledWith(1, expect.objectContaining({ tweetsPerItem: 2, withTweets: true }));
const tweetLines = logSpy.mock.calls
.map((call) => String(call[0]))
.filter((line) => line.trimStart().startsWith('@'));
expect(tweetLines).toHaveLength(2);
} finally {
getNewsSpy.mockRestore();
logSpy.mockRestore();
}
});
});
+155
View File
@@ -0,0 +1,155 @@
import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';
import type { CliContext } from '../src/cli/shared.js';
import { registerReadCommands } from '../src/commands/read.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
describe('replies command', () => {
const createMockContext = () =>
({
resolveTimeoutFromOptions: () => undefined,
resolveQuoteDepthFromOptions: () => 1,
extractTweetId: (input: string) => input,
resolveCredentialsFromOptions: async () => ({
cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' },
warnings: [],
}),
p: () => '',
printTweets: () => undefined,
printTweetsResult: () => undefined,
}) as unknown as CliContext;
it('uses pagination when --max-pages is provided', async () => {
const program = new Command();
registerReadCommands(program, createMockContext());
const pagedSpy = vi
.spyOn(TwitterClient.prototype, 'getRepliesPaged')
.mockResolvedValue({ success: true, tweets: [], nextCursor: undefined });
const unpagedSpy = vi.spyOn(TwitterClient.prototype, 'getReplies').mockResolvedValue({ success: true, tweets: [] });
try {
await program.parseAsync(['node', 'bird', 'replies', '123', '--max-pages', '2', '--json']);
expect(pagedSpy).toHaveBeenCalledTimes(1);
expect(unpagedSpy).toHaveBeenCalledTimes(0);
} finally {
pagedSpy.mockRestore();
unpagedSpy.mockRestore();
}
});
it('validates --max-pages is a positive integer', async () => {
const program = new Command();
registerReadCommands(program, createMockContext());
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(
program.parseAsync(['node', 'bird', 'replies', '123', '--all', '--max-pages', '-1']),
).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid --max-pages'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('validates --delay is a non-negative integer', async () => {
const program = new Command();
registerReadCommands(program, createMockContext());
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'replies', '123', '--all', '--delay', '-100'])).rejects.toThrow(
'exit 1',
);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid --delay'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
});
describe('thread command', () => {
const createMockContext = () =>
({
resolveTimeoutFromOptions: () => undefined,
resolveQuoteDepthFromOptions: () => 1,
extractTweetId: (input: string) => input,
resolveCredentialsFromOptions: async () => ({
cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' },
warnings: [],
}),
p: () => '',
printTweets: () => undefined,
printTweetsResult: () => undefined,
}) as unknown as CliContext;
it('uses pagination when --max-pages is provided', async () => {
const program = new Command();
registerReadCommands(program, createMockContext());
const pagedSpy = vi
.spyOn(TwitterClient.prototype, 'getThreadPaged')
.mockResolvedValue({ success: true, tweets: [], nextCursor: undefined });
const unpagedSpy = vi.spyOn(TwitterClient.prototype, 'getThread').mockResolvedValue({ success: true, tweets: [] });
try {
await program.parseAsync(['node', 'bird', 'thread', '123', '--max-pages', '2', '--json']);
expect(pagedSpy).toHaveBeenCalledTimes(1);
expect(unpagedSpy).toHaveBeenCalledTimes(0);
} finally {
pagedSpy.mockRestore();
unpagedSpy.mockRestore();
}
});
it('validates --max-pages is a positive integer', async () => {
const program = new Command();
registerReadCommands(program, createMockContext());
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'thread', '123', '--all', '--max-pages', '0'])).rejects.toThrow(
'exit 1',
);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid --max-pages'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('validates --delay is a non-negative integer', async () => {
const program = new Command();
registerReadCommands(program, createMockContext());
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'thread', '123', '--all', '--delay', 'abc'])).rejects.toThrow(
'exit 1',
);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid --delay'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
});
+119
View File
@@ -0,0 +1,119 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CliContext } from '../src/cli/shared.js';
import { registerSearchCommands } from '../src/commands/search.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
describe('search command', () => {
let program: Command;
let mockContext: Partial<CliContext>;
beforeEach(() => {
program = new Command();
mockContext = {
resolveTimeoutFromOptions: () => undefined,
resolveQuoteDepthFromOptions: () => undefined,
resolveCredentialsFromOptions: async () => ({
cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' },
warnings: [],
}),
p: () => '',
printTweetsResult: vi.fn(),
};
});
it('requires --all or --cursor when --max-pages is provided', async () => {
registerSearchCommands(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'search', 'cats', '--max-pages', '2'])).rejects.toThrow(
'exit 1',
);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all or --cursor'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('requires positive --count when not paging', async () => {
registerSearchCommands(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'search', 'cats', '--count', '0'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid --count. Expected a positive integer.'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('requires positive --max-pages when paging', async () => {
registerSearchCommands(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'search', 'cats', '--all', '--max-pages', '0'])).rejects.toThrow(
'exit 1',
);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid --max-pages. Expected a positive integer.'),
);
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('uses paged search when --all is set', async () => {
registerSearchCommands(program, mockContext as CliContext);
const getAllSpy = vi
.spyOn(TwitterClient.prototype, 'getAllSearchResults')
.mockResolvedValue({ success: true, tweets: [] });
const searchSpy = vi.spyOn(TwitterClient.prototype, 'search').mockResolvedValue({ success: true, tweets: [] });
try {
await program.parseAsync(['node', 'bird', 'search', 'cats', '--all', '--json']);
expect(getAllSpy).toHaveBeenCalledWith('cats', expect.objectContaining({ includeRaw: false }));
expect(searchSpy).not.toHaveBeenCalled();
expect(mockContext.printTweetsResult).toHaveBeenCalledWith(expect.objectContaining({ tweets: [] }), {
json: true,
usePagination: true,
emptyMessage: 'No tweets found.',
});
} finally {
getAllSpy.mockRestore();
searchSpy.mockRestore();
}
});
it('uses cursor pagination when --cursor is set', async () => {
registerSearchCommands(program, mockContext as CliContext);
const getAllSpy = vi
.spyOn(TwitterClient.prototype, 'getAllSearchResults')
.mockResolvedValue({ success: true, tweets: [] });
try {
await program.parseAsync(['node', 'bird', 'search', 'cats', '--cursor', 'cursor-1']);
expect(getAllSpy).toHaveBeenCalledWith('cats', expect.objectContaining({ cursor: 'cursor-1' }));
expect(mockContext.printTweetsResult).toHaveBeenCalledWith(expect.objectContaining({ tweets: [] }), {
json: false,
usePagination: true,
emptyMessage: 'No tweets found.',
});
} finally {
getAllSpy.mockRestore();
}
});
});
+214
View File
@@ -0,0 +1,214 @@
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { CliContext } from '../src/cli/shared.js';
import { registerUserCommands } from '../src/commands/users.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
const baseCtx = {
resolveTimeoutFromOptions: () => undefined,
resolveCredentialsFromOptions: async () => ({
cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' },
warnings: [],
}),
p: () => '',
printTweets: () => undefined,
} as unknown as CliContext;
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('users commands', () => {
it('requires --all when --max-pages is provided', async () => {
const program = new Command();
registerUserCommands(program, baseCtx);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'following', '--max-pages', '2'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all.'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('rejects --max-pages when only --cursor is provided', async () => {
const program = new Command();
registerUserCommands(program, baseCtx);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(
program.parseAsync(['node', 'bird', 'following', '--cursor', 'prev', '--max-pages', '2']),
).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all.'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('emits pagination JSON when --cursor is provided', async () => {
const program = new Command();
registerUserCommands(program, baseCtx);
vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
vi.spyOn(TwitterClient.prototype, 'getFollowing').mockResolvedValue({
success: true,
users: [{ id: '1', username: 'alpha', name: 'Alpha' }],
nextCursor: 'next-1',
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
await program.parseAsync(['node', 'bird', 'following', '--cursor', 'prev', '--json']);
const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
expect(payload.users).toHaveLength(1);
expect(payload.nextCursor).toBe('next-1');
});
it('prints next cursor in non-JSON single-page mode', async () => {
const program = new Command();
registerUserCommands(program, baseCtx);
vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
vi.spyOn(TwitterClient.prototype, 'getFollowing').mockResolvedValue({
success: true,
users: [{ id: '1', username: 'alpha', name: 'Alpha' }],
nextCursor: 'next-1',
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
await program.parseAsync(['node', 'bird', 'following', '--cursor', 'prev']);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Next cursor: next-1'));
expect(logSpy).toHaveBeenCalled();
});
it('emits pagination JSON for --all results', async () => {
const program = new Command();
registerUserCommands(program, baseCtx);
vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
vi.spyOn(TwitterClient.prototype, 'getFollowing').mockResolvedValue({
success: true,
users: [{ id: '1', username: 'alpha', name: 'Alpha' }],
nextCursor: 'next-1',
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
await program.parseAsync(['node', 'bird', 'following', '--all', '--max-pages', '1', '--json']);
const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
expect(payload.users).toHaveLength(1);
expect(payload.nextCursor).toBe('next-1');
});
it('prints next cursor when stopping at max pages in non-JSON mode', async () => {
vi.useFakeTimers();
const program = new Command();
registerUserCommands(program, baseCtx);
vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
vi.spyOn(TwitterClient.prototype, 'getFollowing').mockResolvedValue({
success: true,
users: [{ id: '1', username: 'alpha', name: 'Alpha' }],
nextCursor: 'next-1',
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const run = program.parseAsync(['node', 'bird', 'following', '--all', '--max-pages', '1']);
await vi.advanceTimersByTimeAsync(1000);
await run;
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Next cursor: next-1'));
expect(logSpy).toHaveBeenCalled();
});
it('deduplicates users across pages in --all mode', async () => {
vi.useFakeTimers();
const program = new Command();
registerUserCommands(program, baseCtx);
vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
const followingSpy = vi.spyOn(TwitterClient.prototype, 'getFollowing');
followingSpy
.mockResolvedValueOnce({
success: true,
users: [
{ id: '1', username: 'alpha', name: 'Alpha' },
{ id: '2', username: 'beta', name: 'Beta' },
],
nextCursor: 'cursor-1',
})
.mockResolvedValueOnce({
success: true,
users: [
{ id: '2', username: 'beta', name: 'Beta' },
{ id: '3', username: 'gamma', name: 'Gamma' },
],
nextCursor: undefined,
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const run = program.parseAsync(['node', 'bird', 'following', '--all', '--json']);
await vi.advanceTimersByTimeAsync(1000);
await run;
const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
expect(payload.users).toHaveLength(3);
expect(payload.users.map((user: { id: string }) => user.id)).toEqual(['1', '2', '3']);
});
it('stops pagination when cursor repeats or no new users are added', async () => {
vi.useFakeTimers();
const program = new Command();
registerUserCommands(program, baseCtx);
vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
const followingSpy = vi.spyOn(TwitterClient.prototype, 'getFollowing');
followingSpy
.mockResolvedValueOnce({
success: true,
users: [{ id: '1', username: 'alpha', name: 'Alpha' }],
nextCursor: 'cursor-1',
})
.mockResolvedValueOnce({
success: true,
users: [{ id: '1', username: 'alpha', name: 'Alpha' }],
nextCursor: 'cursor-1',
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const run = program.parseAsync(['node', 'bird', 'following', '--all', '--json']);
await vi.advanceTimersByTimeAsync(1000);
await run;
const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
expect(payload.users).toHaveLength(1);
expect(payload.nextCursor).toBeNull();
expect(followingSpy).toHaveBeenCalledTimes(2);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import type { GraphqlTweetResult } from '../src/lib/twitter-client-types.js';
import { extractArticleMetadata } from '../src/lib/twitter-client-utils.js';
describe('extractArticleMetadata', () => {
it('returns undefined when no result', () => {
expect(extractArticleMetadata(undefined)).toBeUndefined();
});
it('returns undefined when no article field', () => {
const result: GraphqlTweetResult = {
rest_id: '123',
legacy: { full_text: 'Hello world' },
};
expect(extractArticleMetadata(result)).toBeUndefined();
});
it('returns undefined when article has no title', () => {
const result: GraphqlTweetResult = {
rest_id: '123',
article: {
article_results: {
result: {},
},
},
};
expect(extractArticleMetadata(result)).toBeUndefined();
});
it('extracts title from article_results.result', () => {
const result: GraphqlTweetResult = {
rest_id: '123',
article: {
article_results: {
result: {
title: 'Building Context Graphs for GTM',
},
},
},
};
const metadata = extractArticleMetadata(result);
expect(metadata).toEqual({
title: 'Building Context Graphs for GTM',
previewText: undefined,
});
});
it('extracts title and preview_text from article_results.result', () => {
const result: GraphqlTweetResult = {
rest_id: '123',
article: {
article_results: {
result: {
title: 'Building Context Graphs for GTM',
preview_text:
'Foundation Capital recently argued that one of the next trillion-dollar opportunities in AI will come from context graphs.',
},
},
},
} as GraphqlTweetResult;
const metadata = extractArticleMetadata(result);
expect(metadata).toEqual({
title: 'Building Context Graphs for GTM',
previewText:
'Foundation Capital recently argued that one of the next trillion-dollar opportunities in AI will come from context graphs.',
});
});
it('falls back to article.title when article_results.result.title missing', () => {
const result: GraphqlTweetResult = {
rest_id: '123',
article: {
title: 'Fallback Title',
article_results: {
result: {},
},
},
};
const metadata = extractArticleMetadata(result);
expect(metadata).toEqual({
title: 'Fallback Title',
previewText: undefined,
});
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import type { GraphqlTweetResult } from '../src/lib/twitter-client-types.js';
import { extractArticleText } from '../src/lib/twitter-client-utils.js';
describe('extractArticleText', () => {
it('does not duplicate title when rich content starts with a heading', () => {
const result = {
rest_id: '1',
article: {
title: 'Hello World',
article_results: {
result: {
title: 'Hello World',
content_state: {
blocks: [
{
key: '1',
type: 'header-one',
text: 'Hello World',
entityRanges: [],
inlineStyleRanges: [],
},
],
entityMap: [],
},
},
},
},
} as GraphqlTweetResult;
expect(extractArticleText(result)).toBe('# Hello World');
});
});
+426
View File
@@ -0,0 +1,426 @@
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { beforeAll, describe, expect, it } from 'vitest';
type RunResult = { exitCode: number; stdout: string; stderr: string; signal: NodeJS.Signals | null };
const LIVE = process.env.BIRD_LIVE === '1';
const authToken = (process.env.AUTH_TOKEN ?? process.env.TWITTER_AUTH_TOKEN ?? '').trim();
const ct0 = (process.env.CT0 ?? process.env.TWITTER_CT0 ?? '').trim();
const CLI_PATH = path.resolve(process.cwd(), 'dist', 'cli.js');
const WHOAMI_HANDLE_REGEX = /^user:\s*@([A-Za-z0-9_]+)/m;
const WHOAMI_USER_ID_REGEX = /^user_id:\s*([0-9]+)/m;
const TWEET_ID_REGEX = /^\d+$/;
const LIVE_NODE_ENV = (process.env.BIRD_LIVE_NODE_ENV ?? 'production').trim() || 'production';
function runBird(args: string[], options: { timeoutMs?: number } = {}): Promise<RunResult> {
if (!LIVE) {
throw new Error('runBird() called without BIRD_LIVE=1');
}
return new Promise((resolve) => {
const child = spawn(process.execPath, [CLI_PATH, ...args], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
AUTH_TOKEN: authToken,
CT0: ct0,
NODE_ENV: LIVE_NODE_ENV,
},
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
const killTimer =
typeof options.timeoutMs === 'number' && options.timeoutMs > 0
? setTimeout(() => {
child.kill('SIGKILL');
}, options.timeoutMs)
: null;
child.on('close', (code, signal) => {
if (killTimer) {
clearTimeout(killTimer);
}
resolve({
exitCode: code ?? (signal ? 1 : 0),
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
stderr: Buffer.concat(stderrChunks).toString('utf8'),
signal: signal ?? null,
});
});
});
}
function parseJson<T>(stdout: string): T {
try {
return JSON.parse(stdout) as T;
} catch (error) {
throw new Error(`Invalid JSON output: ${error instanceof Error ? error.message : String(error)}\n${stdout}`);
}
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
const d = LIVE ? describe : describe.skip;
d('live CLI (Twitter/X) all commands', () => {
const timeoutArg = (process.env.BIRD_LIVE_TIMEOUT_MS ?? '20000').trim();
const cookieTimeoutArg = (process.env.BIRD_LIVE_COOKIE_TIMEOUT_MS ?? '30000').trim();
const baseArgs = ['--plain', '--timeout', timeoutArg, '--quote-depth', '0'];
let whoamiStdout = '';
let handle = '';
let userId = '';
let tweetId = '';
let searchStdout = '';
let listId = '';
let listTimelineAvailable = false;
beforeAll(async () => {
if (!existsSync(CLI_PATH)) {
throw new Error(`Missing ${CLI_PATH}. Run: pnpm run build:dist`);
}
if (!authToken || !ct0) {
const check = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'check'], { timeoutMs: 45_000 });
if (check.exitCode !== 0) {
throw new Error(
'Missing live credentials.\n' +
'- Option A: set AUTH_TOKEN + CT0 (or TWITTER_AUTH_TOKEN/TWITTER_CT0)\n' +
'- Option B: login to x.com in Safari/Chrome/Firefox for cookie extraction\n\n' +
`bird check output:\n${check.stdout}\n${check.stderr}`,
);
}
}
const who = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'whoami'], { timeoutMs: 45_000 });
if (who.exitCode !== 0) {
throw new Error(`whoami failed (exit ${who.exitCode}, signal ${who.signal ?? 'none'}):\n${who.stderr}`);
}
whoamiStdout = who.stdout;
const handleMatch = WHOAMI_HANDLE_REGEX.exec(who.stdout);
if (!handleMatch?.[1]) {
throw new Error(`Could not parse whoami handle:\n${who.stdout}`);
}
handle = handleMatch[1];
const userIdMatch = WHOAMI_USER_ID_REGEX.exec(who.stdout);
if (!userIdMatch?.[1]) {
throw new Error(`Could not parse whoami user_id:\n${who.stdout}`);
}
userId = userIdMatch[1];
const forcedTweetId = (process.env.BIRD_LIVE_TWEET_ID ?? '').trim();
if (forcedTweetId) {
if (!TWEET_ID_REGEX.test(forcedTweetId)) {
throw new Error(`Invalid BIRD_LIVE_TWEET_ID (expected digits): "${forcedTweetId}"`);
}
tweetId = forcedTweetId;
} else {
const searchQuery = (
process.env.BIRD_LIVE_SEARCH_QUERY ?? `from:${handle} -filter:replies -filter:retweets`
).trim();
const search = await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'search', searchQuery, '-n', '25', '--json'],
{
timeoutMs: 45_000,
},
);
if (search.exitCode !== 0) {
throw new Error(
`search failed (exit ${search.exitCode}, signal ${search.signal ?? 'none'}):\n${search.stderr}`,
);
}
searchStdout = search.stdout;
const tweets = parseJson<Array<{ id?: string }>>(search.stdout);
const first = String(tweets[0]?.id ?? '');
if (!TWEET_ID_REGEX.test(first)) {
throw new Error(
`Search returned no usable tweets. Query: "${searchQuery}". ` +
`Override with BIRD_LIVE_SEARCH_QUERY.\n${search.stdout}`,
);
}
tweetId = first;
}
listId = (process.env.BIRD_LIVE_LIST_ID ?? '').trim();
if (!listId) {
const lists = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'lists', '-n', '1', '--json'], {
timeoutMs: 45_000,
});
if (lists.exitCode === 0) {
const parsed = parseJson<Array<{ id?: string }>>(lists.stdout);
listId = String(parsed[0]?.id ?? '').trim();
}
}
if (!listId) {
const lists = await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'lists', '--member-of', '-n', '1', '--json'],
{
timeoutMs: 45_000,
},
);
if (lists.exitCode === 0) {
const parsed = parseJson<Array<{ id?: string }>>(lists.stdout);
listId = String(parsed[0]?.id ?? '').trim();
}
}
listTimelineAvailable = Boolean(listId);
});
it('requires env credentials + built dist CLI', async () => {
expect(existsSync(CLI_PATH)).toBe(true);
expect(whoamiStdout.length).toBeGreaterThan(0);
});
it('check works', async () => {
const check = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'check'], { timeoutMs: 45_000 });
expect(check.exitCode).toBe(0);
expect(check.stdout.length).toBeGreaterThan(0);
});
it('whoami works', async () => {
expect(whoamiStdout).toContain('user: @');
expect(whoamiStdout).toContain('user_id:');
});
it('search returns JSON array', async () => {
const search = searchStdout
? { exitCode: 0, stdout: searchStdout }
: await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'search', `from:${handle}`, '-n', '10', '--json'],
{ timeoutMs: 45_000 },
);
expect(search.exitCode).toBe(0);
const tweets = parseJson<Array<{ id?: string }>>(search.stdout);
expect(Array.isArray(tweets)).toBe(true);
});
it('read returns tweet JSON', async () => {
const read = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'read', tweetId, '--json'], {
timeoutMs: 45_000,
});
expect(read.exitCode).toBe(0);
const readTweet = parseJson<{ id?: string; author?: { username?: string } }>(read.stdout);
expect(readTweet.id).toBe(tweetId);
expect(readTweet.author?.username?.length).toBeGreaterThan(0);
});
it('tweet-id shorthand returns tweet JSON', async () => {
const shorthand = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, tweetId, '--json'], {
timeoutMs: 45_000,
});
expect(shorthand.exitCode).toBe(0);
const shorthandTweet = parseJson<{ id?: string }>(shorthand.stdout);
expect(shorthandTweet.id).toBe(tweetId);
});
it('replies returns JSON array', async () => {
const replies = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'replies', tweetId, '--json'], {
timeoutMs: 45_000,
});
expect(replies.exitCode).toBe(0);
const replyTweets = parseJson<Array<{ id?: string }>>(replies.stdout);
expect(Array.isArray(replyTweets)).toBe(true);
});
it('thread returns JSON array', async () => {
const thread = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'thread', tweetId, '--json'], {
timeoutMs: 45_000,
});
expect(thread.exitCode).toBe(0);
const threadTweets = parseJson<Array<{ id?: string }>>(thread.stdout);
expect(Array.isArray(threadTweets)).toBe(true);
});
it('mentions returns JSON array', async () => {
const mentions = await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'mentions', '-n', '10', '--json'],
{
timeoutMs: 45_000,
},
);
expect(mentions.exitCode).toBe(0);
const mentionTweets = parseJson<Array<{ id?: string }>>(mentions.stdout);
expect(Array.isArray(mentionTweets)).toBe(true);
});
it('bookmarks returns JSON array', async () => {
const bookmarks = await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'bookmarks', '-n', '10', '--json'],
{
timeoutMs: 45_000,
},
);
expect(bookmarks.exitCode).toBe(0);
const bookmarkTweets = parseJson<Array<{ id?: string }>>(bookmarks.stdout);
expect(Array.isArray(bookmarkTweets)).toBe(true);
});
it('likes returns JSON array', async () => {
const likes = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'likes', '-n', '10', '--json'], {
timeoutMs: 45_000,
});
expect(likes.exitCode).toBe(0);
const likeTweets = parseJson<Array<{ id?: string }>>(likes.stdout);
expect(Array.isArray(likeTweets)).toBe(true);
});
it('following returns JSON array', async () => {
const following = await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'following', '--user', userId, '-n', '10', '--json'],
{
timeoutMs: 45_000,
},
);
expect(following.exitCode).toBe(0);
const followingUsers = parseJson<Array<{ id?: string; username?: string }>>(following.stdout);
expect(Array.isArray(followingUsers)).toBe(true);
});
it('followers returns JSON array', async () => {
const followers = await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'followers', '--user', userId, '-n', '10', '--json'],
{
timeoutMs: 45_000,
},
);
expect(followers.exitCode).toBe(0);
const followerUsers = parseJson<Array<{ id?: string; username?: string }>>(followers.stdout);
expect(Array.isArray(followerUsers)).toBe(true);
});
it('user-tweets returns JSON array', async () => {
const testHandle = process.env.BIRD_LIVE_USER_TWEETS_HANDLE || 'X';
const userTweets = await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'user-tweets', testHandle, '-n', '5', '--json'],
{
timeoutMs: 45_000,
},
);
expect(userTweets.exitCode).toBe(0);
const tweets = parseJson<Array<{ id?: string; author?: { username?: string } }>>(userTweets.stdout);
expect(Array.isArray(tweets)).toBe(true);
expect(tweets.length).toBeGreaterThan(0);
});
it('user-tweets paged JSON returns { tweets, nextCursor }', async () => {
const testHandle =
process.env.BIRD_LIVE_USER_TWEETS_PAGED_HANDLE ?? process.env.BIRD_LIVE_USER_TWEETS_HANDLE ?? 'X';
const userTweets = await runBird(
[
...baseArgs,
'--cookie-timeout',
cookieTimeoutArg,
'user-tweets',
testHandle,
'-n',
'50',
'--max-pages',
'3',
'--delay',
'0',
'--json',
],
{
timeoutMs: 45_000,
},
);
expect(userTweets.exitCode).toBe(0);
const payload = parseJson<{ tweets?: Array<{ id?: string }>; nextCursor?: string | null }>(userTweets.stdout);
expect(Array.isArray(payload.tweets)).toBe(true);
});
it('lists returns JSON array', async () => {
const lists = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'lists', '-n', '10', '--json'], {
timeoutMs: 45_000,
});
expect(lists.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(lists.stdout);
expect(Array.isArray(payload)).toBe(true);
});
it('list-timeline returns JSON array', async () => {
if (!listTimelineAvailable) {
return;
}
const timeline = await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'list-timeline', listId, '-n', '5', '--json'],
{ timeoutMs: 45_000 },
);
expect(timeline.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(timeline.stdout);
expect(Array.isArray(payload)).toBe(true);
});
it('home for-you returns JSON array', async () => {
const home = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'home', '-n', '5', '--json'], {
timeoutMs: 45_000,
});
expect(home.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(home.stdout);
expect(Array.isArray(payload)).toBe(true);
}, 60_000);
it('home following returns JSON array', async () => {
let home = await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'home', '--following', '-n', '5', '--json'],
{
timeoutMs: 45_000,
},
);
if (home.exitCode !== 0 && home.stderr.includes('OverCapacity')) {
await sleep(1500);
home = await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'home', '--following', '-n', '5', '--json'],
{
timeoutMs: 45_000,
},
);
}
expect(home.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(home.stdout);
expect(Array.isArray(payload)).toBe(true);
}, 60_000);
it('news returns JSON array', async () => {
const news = await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'news', '-n', '5', '--ai-only', '--json'],
{ timeoutMs: 45_000 },
);
expect(news.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(news.stdout);
expect(Array.isArray(payload)).toBe(true);
});
it('trending returns JSON array', async () => {
const trending = await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'trending', '-n', '5', '--json'],
{ timeoutMs: 45_000 },
);
expect(trending.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(trending.stdout);
expect(Array.isArray(payload)).toBe(true);
});
it('query-ids returns JSON', async () => {
const queryIds = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'query-ids', '--json'], {
timeoutMs: 60_000,
});
expect(queryIds.exitCode).toBe(0);
const snapshot = parseJson<{ cached?: boolean; ids?: Record<string, string> }>(queryIds.stdout);
expect(typeof snapshot.cached).toBe('boolean');
});
});
+63
View File
@@ -268,6 +268,52 @@ d('live CLI (Twitter/X)', () => {
expect(Array.isArray(followerUsers)).toBe(true);
});
it('user-tweets returns JSON array', async () => {
// Use a known active account for reliable testing (authenticated user may have no tweets)
const testHandle = process.env.BIRD_LIVE_USER_TWEETS_HANDLE || 'X';
const userTweets = await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'user-tweets', testHandle, '-n', '5', '--json'],
{
timeoutMs: 45_000,
},
);
expect(userTweets.exitCode).toBe(0);
const tweets = parseJson<Array<{ id?: string; author?: { username?: string } }>>(userTweets.stdout);
expect(Array.isArray(tweets)).toBe(true);
expect(tweets.length).toBeGreaterThan(0);
const handleLower = testHandle.toLowerCase();
expect(tweets.some((t) => t.author?.username?.toLowerCase() === handleLower)).toBe(true);
});
it('user-tweets paged JSON returns { tweets, nextCursor }', async () => {
const testHandle =
process.env.BIRD_LIVE_USER_TWEETS_PAGED_HANDLE ?? process.env.BIRD_LIVE_USER_TWEETS_HANDLE ?? 'X';
const userTweets = await runBird(
[
...baseArgs,
'--cookie-timeout',
cookieTimeoutArg,
'user-tweets',
testHandle,
'-n',
'50',
'--max-pages',
'3',
'--delay',
'0',
'--json',
],
{
timeoutMs: 45_000,
},
);
expect(userTweets.exitCode).toBe(0);
const payload = parseJson<{ tweets?: Array<{ id?: string }>; nextCursor?: string | null }>(userTweets.stdout);
expect(Array.isArray(payload.tweets)).toBe(true);
expect(payload.tweets?.length).toBeGreaterThan(0);
expect(payload.tweets?.[0]?.id?.length).toBeGreaterThan(0);
});
it('query-ids returns JSON', async () => {
const queryIds = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'query-ids', '--json'], {
timeoutMs: 60_000,
@@ -287,4 +333,21 @@ d('live CLI (Twitter/X)', () => {
expect(snapshot.cached).toBe(true);
expect(snapshot.ids && Object.keys(snapshot.ids).length).toBeGreaterThan(0);
});
it('long-form tweet (article) extracts rich content (opt-in)', async () => {
const longformTweetId = (process.env.BIRD_LIVE_LONGFORM_TWEET_ID ?? '').trim();
if (!longformTweetId) {
// Skip unless explicitly provided - long-form tweets may be deleted/unavailable
return;
}
const read = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'read', longformTweetId, '--json'], {
timeoutMs: 45_000,
});
expect(read.exitCode).toBe(0);
const tweet = parseJson<{ id?: string; text?: string }>(read.stdout);
expect(tweet.id).toBe(longformTweetId);
// Long-form tweets (articles) typically have substantial content (>500 chars)
// This verifies the article content is being extracted, not just a stub
expect(tweet.text?.length).toBeGreaterThan(500);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import type { GraphqlTweetResult } from '../src/lib/twitter-client-types.js';
import { mapTweetResult } from '../src/lib/twitter-client-utils.js';
describe('mapTweetResult article', () => {
const makeUserResult = () => ({
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'alice', name: 'Alice' },
},
},
},
});
it('includes extracted article metadata on mapped tweet', () => {
const result: GraphqlTweetResult = {
rest_id: '1',
legacy: {
full_text: 'Check out my article',
},
article: {
article_results: {
result: {
title: 'Building Context Graphs for GTM',
preview_text: 'Foundation Capital recently argued...',
},
},
},
...makeUserResult(),
} as GraphqlTweetResult;
const mapped = mapTweetResult(result, 0);
expect(mapped).toBeDefined();
expect(mapped?.article).toEqual({
title: 'Building Context Graphs for GTM',
previewText: 'Foundation Capital recently argued...',
});
});
it('omits article when none present', () => {
const result: GraphqlTweetResult = {
rest_id: '2',
legacy: { full_text: 'no article' },
...makeUserResult(),
};
const mapped = mapTweetResult(result, 0);
expect(mapped?.article).toBeUndefined();
});
it('includes article with only title when no preview_text', () => {
const result: GraphqlTweetResult = {
rest_id: '3',
legacy: { full_text: 'article link' },
article: {
article_results: {
result: {
title: 'My Article Title',
},
},
},
...makeUserResult(),
};
const mapped = mapTweetResult(result, 0);
expect(mapped?.article).toEqual({
title: 'My Article Title',
previewText: undefined,
});
});
});
+101 -13
View File
@@ -1,22 +1,30 @@
import { describe, expect, it } from 'vitest';
import { createCliContext } from '../src/cli/shared.js';
import {
formatStatsLine,
formatTweetUrlLine,
hyperlink,
labelPrefix,
resolveOutputConfigFromArgv,
resolveOutputConfigFromCommander,
statusPrefix,
} from '../src/lib/output.js';
import type { TweetData } from '../src/lib/twitter-client-types.js';
describe('output', () => {
it('defaults to emoji + color on TTY', () => {
it('defaults to emoji + color + hyperlinks on TTY', () => {
const cfg = resolveOutputConfigFromArgv([], {}, true);
expect(cfg).toEqual({ plain: false, emoji: true, color: true });
expect(cfg).toEqual({ plain: false, emoji: true, color: true, hyperlinks: true });
});
it('plain disables emoji + color', () => {
it('disables hyperlinks on non-TTY', () => {
const cfg = resolveOutputConfigFromArgv([], {}, false);
expect(cfg.hyperlinks).toBe(false);
});
it('plain disables emoji + color + hyperlinks', () => {
const cfg = resolveOutputConfigFromArgv(['--plain'], {}, true);
expect(cfg).toEqual({ plain: true, emoji: false, color: false });
expect(cfg).toEqual({ plain: true, emoji: false, color: false, hyperlinks: false });
expect(statusPrefix('ok', cfg)).toBe('[ok] ');
expect(labelPrefix('url', cfg)).toBe('url: ');
});
@@ -33,7 +41,7 @@ describe('output', () => {
it('--no-color disables colors', () => {
const cfg = resolveOutputConfigFromArgv(['--no-color'], {}, true);
expect(cfg).toEqual({ plain: false, emoji: true, color: false });
expect(cfg).toEqual({ plain: false, emoji: true, color: false, hyperlinks: true });
});
it('--no-emoji switches to text prefixes', () => {
@@ -44,32 +52,112 @@ describe('output', () => {
it('commander opts override defaults', () => {
const cfg = resolveOutputConfigFromCommander({ emoji: false, color: false }, {}, true);
expect(cfg).toEqual({ plain: false, emoji: false, color: false });
expect(cfg).toEqual({ plain: false, emoji: false, color: false, hyperlinks: true });
expect(statusPrefix('info', cfg)).toBe('Info: ');
expect(labelPrefix('date', cfg)).toBe('Date: ');
});
it('commander plain wins over emoji/color', () => {
const cfg = resolveOutputConfigFromCommander({ plain: true, emoji: true, color: true }, {}, true);
expect(cfg).toEqual({ plain: true, emoji: false, color: false });
expect(cfg).toEqual({ plain: true, emoji: false, color: false, hyperlinks: false });
});
it('commander disables hyperlinks on non-TTY', () => {
const cfg = resolveOutputConfigFromCommander({}, {}, false);
expect(cfg.hyperlinks).toBe(false);
});
it('formats stats line for all modes', () => {
const stats = { likeCount: null, retweetCount: undefined, replyCount: 2 };
expect(formatStatsLine(stats, { plain: true, emoji: false, color: false })).toBe(
expect(formatStatsLine(stats, { plain: true, emoji: false, color: false, hyperlinks: false })).toBe(
'likes: 0 retweets: 0 replies: 2',
);
expect(formatStatsLine(stats, { plain: false, emoji: false, color: false })).toBe('Likes 0 Retweets 0 Replies 2');
expect(formatStatsLine(stats, { plain: false, emoji: true, color: false })).toBe('❤️ 0 🔁 0 💬 2');
expect(formatStatsLine(stats, { plain: false, emoji: false, color: false, hyperlinks: false })).toBe(
'Likes 0 Retweets 0 Replies 2',
);
expect(formatStatsLine(stats, { plain: false, emoji: true, color: false, hyperlinks: false })).toBe(
'❤️ 0 🔁 0 💬 2',
);
});
it('always includes tweet URL in all modes', () => {
const id = '1234567890';
const url = `https://x.com/i/status/${id}`;
expect(formatTweetUrlLine(id, { plain: true, emoji: false, color: false })).toContain(url);
expect(formatTweetUrlLine(id, { plain: false, emoji: false, color: false })).toContain(url);
expect(formatTweetUrlLine(id, { plain: false, emoji: true, color: false })).toContain(url);
expect(formatTweetUrlLine(id, { plain: true, emoji: false, color: false, hyperlinks: false })).toContain(url);
expect(formatTweetUrlLine(id, { plain: false, emoji: false, color: false, hyperlinks: false })).toContain(url);
expect(formatTweetUrlLine(id, { plain: false, emoji: true, color: false, hyperlinks: true })).toContain(url);
});
it('hyperlink returns plain text when hyperlinks disabled', () => {
const cfg = { plain: true, emoji: false, color: false, hyperlinks: false };
expect(hyperlink('https://x.com/test', undefined, cfg)).toBe('https://x.com/test');
});
it('hyperlink returns plain text on non-TTY (hyperlinks: false)', () => {
const cfg = { plain: false, emoji: true, color: false, hyperlinks: false };
expect(hyperlink('https://x.com/test', undefined, cfg)).toBe('https://x.com/test');
});
it('hyperlink wraps URL with OSC 8 escapes when hyperlinks enabled', () => {
const cfg = { plain: false, emoji: true, color: true, hyperlinks: true };
const result = hyperlink('https://x.com/test', undefined, cfg);
expect(result).toContain('\x1b]8;;');
expect(result).toContain('\x07');
});
it('hyperlink uses custom display text', () => {
const cfg = { plain: false, emoji: true, color: true, hyperlinks: true };
const result = hyperlink('https://x.com/test', 'Click here', cfg);
expect(result).toContain('Click here');
expect(result).toContain('\x1b]8;;https://x.com/test\x07');
});
it('hyperlink strips OSC control characters from url and text', () => {
const cfg = { plain: false, emoji: true, color: true, hyperlinks: true };
const result = hyperlink('https://x.com/\u001btest\u0007', 'Hi\u001b\u0007', cfg);
expect(result).not.toContain('\u001btest\u0007');
expect(result).not.toContain('Hi\u001b\u0007');
});
it('printTweets respects emoji toggle for media/article/quotes', () => {
const tweet: TweetData = {
id: '1',
text: 'hello world',
author: { username: 'alice', name: 'Alice' },
media: [{ type: 'photo', url: 'https://img/test.jpg' }],
article: { title: 'Article title', previewText: 'Preview' },
quotedTweet: {
id: '2',
text: 'quoted text',
author: { username: 'bob', name: 'Bob' },
media: [{ type: 'video', url: 'https://vid/test.mp4' }],
},
};
const ctxEmoji = createCliContext([], {}, true);
const logEmoji = vi.spyOn(console, 'log').mockImplementation(() => {});
ctxEmoji.printTweets([tweet]);
const emojiOutput = logEmoji.mock.calls.flat().join('\n');
logEmoji.mockRestore();
expect(emojiOutput).toContain('📰 Article title');
expect(emojiOutput).toContain('🖼️ https://img/test.jpg');
expect(emojiOutput).toContain('┌─ QT @bob:');
expect(emojiOutput).toContain('🎬 https://vid/test.mp4');
const ctxPlain = createCliContext(['--plain'], {}, true);
const logPlain = vi.spyOn(console, 'log').mockImplementation(() => {});
ctxPlain.printTweets([tweet]);
const plainOutput = logPlain.mock.calls.flat().join('\n');
logPlain.mockRestore();
expect(plainOutput).toContain('Article: Article title');
expect(plainOutput).toContain('PHOTO: https://img/test.jpg');
expect(plainOutput).toContain('QT @bob:');
expect(plainOutput).toContain('VIDEO: https://vid/test.mp4');
expect(plainOutput).not.toContain('📰');
expect(plainOutput).not.toContain('🖼️');
});
});
+351
View File
@@ -0,0 +1,351 @@
import { describe, expect, it } from 'vitest';
import { renderContentState } from '../src/lib/twitter-client-utils.js';
describe('renderContentState', () => {
it('returns undefined for undefined input', () => {
expect(renderContentState(undefined)).toBeUndefined();
});
it('returns undefined for empty blocks', () => {
expect(renderContentState({ blocks: [], entityMap: [] })).toBeUndefined();
});
it('renders unstyled blocks as plain paragraphs', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'unstyled', text: 'First paragraph', entityRanges: [], inlineStyleRanges: [] },
{ key: '2', type: 'unstyled', text: 'Second paragraph', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [],
});
expect(result).toBe('First paragraph\n\nSecond paragraph');
});
it('renders header-one as # heading', () => {
const result = renderContentState({
blocks: [{ key: '1', type: 'header-one', text: 'Main Title', entityRanges: [], inlineStyleRanges: [] }],
entityMap: [],
});
expect(result).toBe('# Main Title');
});
it('renders header-two as ## heading', () => {
const result = renderContentState({
blocks: [{ key: '1', type: 'header-two', text: 'Section Title', entityRanges: [], inlineStyleRanges: [] }],
entityMap: [],
});
expect(result).toBe('## Section Title');
});
it('renders header-three as ### heading', () => {
const result = renderContentState({
blocks: [{ key: '1', type: 'header-three', text: 'Subsection', entityRanges: [], inlineStyleRanges: [] }],
entityMap: [],
});
expect(result).toBe('### Subsection');
});
it('renders unordered-list-item as bullet points', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'unordered-list-item', text: 'Item one', entityRanges: [], inlineStyleRanges: [] },
{ key: '2', type: 'unordered-list-item', text: 'Item two', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [],
});
expect(result).toBe('- Item one\n\n- Item two');
});
it('renders ordered-list-item with incrementing numbers', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'ordered-list-item', text: 'First step', entityRanges: [], inlineStyleRanges: [] },
{ key: '2', type: 'ordered-list-item', text: 'Second step', entityRanges: [], inlineStyleRanges: [] },
{ key: '3', type: 'ordered-list-item', text: 'Third step', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [],
});
expect(result).toBe('1. First step\n\n2. Second step\n\n3. Third step');
});
it('resets ordered list counter after non-list block', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'ordered-list-item', text: 'First', entityRanges: [], inlineStyleRanges: [] },
{ key: '2', type: 'ordered-list-item', text: 'Second', entityRanges: [], inlineStyleRanges: [] },
{ key: '3', type: 'unstyled', text: 'Paragraph break', entityRanges: [], inlineStyleRanges: [] },
{ key: '4', type: 'ordered-list-item', text: 'New first', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [],
});
expect(result).toBe('1. First\n\n2. Second\n\nParagraph break\n\n1. New first');
});
it('renders blockquote with > prefix', () => {
const result = renderContentState({
blocks: [{ key: '1', type: 'blockquote', text: 'A wise quote', entityRanges: [], inlineStyleRanges: [] }],
entityMap: [],
});
expect(result).toBe('> A wise quote');
});
it('renders MARKDOWN entity as code block', () => {
const result = renderContentState({
blocks: [
{
key: '1',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 0, offset: 0, length: 1 }],
inlineStyleRanges: [],
},
],
entityMap: [
{
key: '0',
value: {
type: 'MARKDOWN',
mutability: 'Mutable',
data: { markdown: '```bash\necho "hello"\n```' },
},
},
],
});
expect(result).toBe('```bash\necho "hello"\n```');
});
it('handles entityMap object form', () => {
const result = renderContentState({
blocks: [
{
key: '1',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 0, offset: 0, length: 1 }],
},
],
entityMap: {
0: {
type: 'MARKDOWN',
mutability: 'Mutable',
data: { markdown: '```js\nconsole.log("ok")\n```' },
},
},
});
expect(result).toBe('```js\nconsole.log("ok")\n```');
});
it('renders DIVIDER entity as horizontal rule', () => {
const result = renderContentState({
blocks: [
{
key: '1',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 0, offset: 0, length: 1 }],
inlineStyleRanges: [],
},
],
entityMap: [
{
key: '0',
value: {
type: 'DIVIDER',
mutability: 'Immutable',
data: {},
},
},
],
});
expect(result).toBe('---');
});
it('renders TWEET entity with URL', () => {
const result = renderContentState({
blocks: [
{
key: '1',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 0, offset: 0, length: 1 }],
inlineStyleRanges: [],
},
],
entityMap: [
{
key: '0',
value: {
type: 'TWEET',
mutability: 'Immutable',
data: { tweetId: '1234567890' },
},
},
],
});
expect(result).toBe('[Embedded Tweet: https://x.com/i/status/1234567890]');
});
it('renders LINK entity in atomic block', () => {
const result = renderContentState({
blocks: [
{
key: '1',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 0, offset: 0, length: 1 }],
inlineStyleRanges: [],
},
],
entityMap: [
{
key: '0',
value: {
type: 'LINK',
mutability: 'Mutable',
data: { url: 'https://example.com' },
},
},
],
});
expect(result).toBe('[Link: https://example.com]');
});
it('renders inline LINK entity as markdown link', () => {
const result = renderContentState({
blocks: [
{
key: '1',
type: 'unstyled',
text: 'Check out this link for more info',
entityRanges: [{ key: 0, offset: 15, length: 4 }],
inlineStyleRanges: [],
},
],
entityMap: [
{
key: '0',
value: {
type: 'LINK',
mutability: 'Mutable',
data: { url: 'https://example.com' },
},
},
],
});
expect(result).toBe('Check out this [link](https://example.com) for more info');
});
it('renders IMAGE entity as placeholder', () => {
const result = renderContentState({
blocks: [
{
key: '1',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 0, offset: 0, length: 1 }],
inlineStyleRanges: [],
},
],
entityMap: [
{
key: '0',
value: {
type: 'IMAGE',
mutability: 'Immutable',
data: {},
},
},
],
});
expect(result).toBe('[Image]');
});
it('handles complex document with mixed content', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'unstyled', text: 'Introduction paragraph.', entityRanges: [], inlineStyleRanges: [] },
{ key: '2', type: 'header-two', text: 'Getting Started', entityRanges: [], inlineStyleRanges: [] },
{ key: '3', type: 'ordered-list-item', text: 'Install dependencies', entityRanges: [], inlineStyleRanges: [] },
{ key: '4', type: 'ordered-list-item', text: 'Run the script', entityRanges: [], inlineStyleRanges: [] },
{
key: '5',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 0, offset: 0, length: 1 }],
inlineStyleRanges: [],
},
{ key: '6', type: 'unstyled', text: 'Conclusion.', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [
{
key: '0',
value: {
type: 'MARKDOWN',
mutability: 'Mutable',
data: { markdown: '```bash\nnpm install\n```' },
},
},
],
});
expect(result).toBe(
'Introduction paragraph.\n\n' +
'## Getting Started\n\n' +
'1. Install dependencies\n\n' +
'2. Run the script\n\n' +
'```bash\nnpm install\n```\n\n' +
'Conclusion.',
);
});
it('skips empty text blocks', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'unstyled', text: 'Content', entityRanges: [], inlineStyleRanges: [] },
{ key: '2', type: 'unstyled', text: ' ', entityRanges: [], inlineStyleRanges: [] },
{ key: '3', type: 'unstyled', text: '', entityRanges: [], inlineStyleRanges: [] },
{ key: '4', type: 'unstyled', text: 'More content', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [],
});
expect(result).toBe('Content\n\nMore content');
});
it('handles missing entityRanges on text blocks', () => {
const result = renderContentState({
blocks: [{ key: '1', type: 'unstyled', text: 'Content' }],
entityMap: [],
});
expect(result).toBe('Content');
});
it('handles atomic block with missing entity gracefully', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'unstyled', text: 'Before', entityRanges: [], inlineStyleRanges: [] },
{
key: '2',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 99, offset: 0, length: 1 }],
inlineStyleRanges: [],
},
{ key: '3', type: 'unstyled', text: 'After', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [],
});
expect(result).toBe('Before\n\nAfter');
});
it('handles atomic block with no entityRanges gracefully', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'unstyled', text: 'Before', entityRanges: [], inlineStyleRanges: [] },
{ key: '2', type: 'atomic', text: ' ', entityRanges: [], inlineStyleRanges: [] },
{ key: '3', type: 'unstyled', text: 'After', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [],
});
expect(result).toBe('Before\n\nAfter');
});
});
+17 -1
View File
@@ -4,7 +4,7 @@ import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { clearFeatureOverridesCache, refreshFeatureOverridesCache } from '../src/lib/runtime-features.js';
import { buildLikesFeatures, buildSearchFeatures } from '../src/lib/twitter-client-features.js';
import { buildLikesFeatures, buildListsFeatures, buildSearchFeatures } from '../src/lib/twitter-client-features.js';
describe('runtime-features', () => {
afterEach(() => {
@@ -29,6 +29,9 @@ describe('runtime-features', () => {
likes: {
likes_only: true,
},
lists: {
responsive_web_text_conversations_enabled: true,
},
},
});
@@ -41,6 +44,9 @@ describe('runtime-features', () => {
const likes = buildLikesFeatures();
expect(likes.global_flag).toBe(true);
expect(likes.likes_only).toBe(true);
const lists = buildListsFeatures();
expect(lists.responsive_web_text_conversations_enabled).toBe(true);
});
it('refresh merges defaults with existing cache', async () => {
@@ -110,4 +116,14 @@ describe('runtime-features', () => {
expect(search.file_flag).toBe(true);
expect(search.file_search).toBe(true);
});
it('includes required lists API feature flags', () => {
const features = buildListsFeatures();
expect(features.responsive_web_graphql_exclude_directive_enabled).toBe(true);
expect(features.blue_business_profile_image_shape_enabled).toBe(false);
expect(features.responsive_web_text_conversations_enabled).toBe(false);
expect(features.tweetypie_unmention_optimization_enabled).toBe(true);
expect(features.vibe_api_enabled).toBe(false);
expect(features.interactive_text_enabled).toBe(false);
});
});
@@ -185,6 +185,89 @@ describe('TwitterClient core coverage', () => {
expect(result.error).toBe('bad news');
});
it('allows partial errors when tweetResult is present', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({
data: {
tweetResult: {
result: {
rest_id: '1',
legacy: {
full_text: 'hi',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
},
core: { user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } } },
},
},
},
errors: [{ message: 'field error' }],
}),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1');
expect(result.success).toBe(true);
expect(result.data?.tweetResult).toBeDefined();
});
it('allows partial errors when instructions are present', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({
data: {
threaded_conversation_with_injections_v2: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '2',
legacy: {
full_text: 'thread tweet',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '2',
},
core: {
user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } },
},
},
},
},
},
},
],
},
],
},
},
errors: [{ message: 'is_translatable error' }],
}),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('2');
expect(result.success).toBe(true);
expect(result.data?.threaded_conversation_with_injections_v2?.instructions?.length).toBe(1);
});
it('parses POST responses when GET returns 404', async () => {
const mockFetch = vi
.fn()
+397
View File
@@ -0,0 +1,397 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
const validCookies = {
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
};
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
ok: true,
status: 200,
json: async (): Promise<unknown> => ({}),
text: async (): Promise<string> => '',
...overrides,
});
// Helper to create GenericTimelineById response structure
const makeTimelineResponse = (items: unknown[]) => ({
data: {
timeline: {
timeline: {
instructions: [
{
type: 'TimelineAddEntries',
entries: items.map((item, index) => ({
entryId: `test-entry-${index}`,
content: {
items: [
{
item: {
itemContent: item,
},
},
],
},
})),
},
],
},
},
},
});
const makeTimelineResponseWithInstruction = (type: string, entries: unknown[]) => ({
data: {
timeline: {
timeline: {
instructions: [
{
type,
entries,
},
],
},
},
},
});
describe('TwitterClient news API coverage', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('getNews', () => {
it('returns news items from timeline tabs', async () => {
// Mock multiple tab requests (forYou, news, sports, entertainment)
const mockFetch = vi
.fn()
.mockResolvedValueOnce(
makeResponse({
json: async () =>
makeTimelineResponse([
{
is_ai_trend: true,
name: 'AI Breakthrough in Machine Learning',
social_context: {
text: 'AI · 2h ago · 15.5K posts',
},
trend_url: {
url: 'https://x.com/hashtag/AI',
},
},
]),
}),
)
.mockResolvedValue(makeResponse({ json: async () => makeTimelineResponse([]) }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getNews(1);
expect(result.success).toBe(true);
expect(result.items).toBeDefined();
expect(result.items?.length).toBe(1);
expect(result.items?.[0].headline).toBe('AI Breakthrough in Machine Learning');
expect(result.items?.[0].category).toBe('AI · AI');
expect(result.items?.[0].timeAgo).toBe('2h ago');
expect(result.items?.[0].postCount).toBe(15500);
expect(result.items?.[0].url).toBe('https://x.com/hashtag/AI');
});
it('includes raw item content when includeRaw is true', async () => {
const itemContent = {
is_ai_trend: true,
name: 'AI News',
social_context: { text: 'AI · 1h ago · 1.2K posts' },
};
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => makeTimelineResponse([itemContent]) }))
.mockResolvedValue(makeResponse({ json: async () => makeTimelineResponse([]) }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getNews(1, { includeRaw: true });
expect(result.success).toBe(true);
expect(result.items?.[0]._raw).toEqual(itemContent);
});
it('filters to AI-only items when aiOnly is true', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(
makeResponse({
json: async () =>
makeTimelineResponse([
{
is_ai_trend: true,
name: 'AI News',
},
{
is_ai_trend: false,
name: 'Regular News',
},
]),
}),
)
.mockResolvedValue(makeResponse({ json: async () => makeTimelineResponse([]) }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getNews(10, { aiOnly: true });
expect(result.success).toBe(true);
expect(result.items?.length).toBe(1);
expect(result.items?.[0].headline).toBe('AI News');
});
it('fetches only selected tabs', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(
makeResponse({
json: async () =>
makeTimelineResponse([
{
is_ai_trend: true,
name: 'News 1',
},
]),
}),
)
.mockResolvedValue(makeResponse({ json: async () => makeTimelineResponse([]) }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getNews(1, { tabs: ['news'] });
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('returns error for non-ok responses', async () => {
// Mock all 4 default tabs (forYou, news, sports, entertainment) to return HTTP 500
const mockFetch = vi
.fn()
.mockResolvedValue(makeResponse({ ok: false, status: 500, text: async () => 'Server error' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getNews(10);
expect(result.success).toBe(false);
expect(result.error).toContain('No news items found');
});
it('returns error when API returns errors', async () => {
// Mock all 4 default tabs to return API errors
const mockFetch = vi.fn().mockResolvedValue(
makeResponse({
json: async () => ({
errors: [{ message: 'Rate limited' }, { message: 'Too many requests' }],
}),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getNews(10);
expect(result.success).toBe(false);
expect(result.error).toContain('No news items found');
});
it('returns error when no news items found', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ json: async () => makeTimelineResponse([]) }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getNews(10);
expect(result.success).toBe(false);
expect(result.error).toContain('No news items found');
});
it('deduplicates headlines across tabs', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(
makeResponse({
json: async () =>
makeTimelineResponse([
{
is_ai_trend: true,
name: 'Duplicate Headline',
},
]),
}),
)
.mockResolvedValueOnce(
makeResponse({
json: async () =>
makeTimelineResponse([
{
is_ai_trend: true,
name: 'Duplicate Headline',
},
]),
}),
)
.mockResolvedValue(makeResponse({ json: async () => makeTimelineResponse([]) }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getNews(10);
expect(result.success).toBe(true);
expect(result.items?.length).toBe(1);
expect(result.items?.[0].headline).toBe('Duplicate Headline');
});
it('respects count parameter', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(
makeResponse({
json: async () =>
makeTimelineResponse([
{
is_ai_trend: true,
name: 'News 1',
},
{
is_ai_trend: true,
name: 'News 2',
},
{
is_ai_trend: true,
name: 'News 3',
},
]),
}),
)
.mockResolvedValue(makeResponse({ json: async () => makeTimelineResponse([]) }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getNews(2);
expect(result.success).toBe(true);
expect(result.items?.length).toBe(2);
});
it('parses entries from non-TimelineAddEntries instructions', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(
makeResponse({
json: async () =>
makeTimelineResponseWithInstruction('TimelineReplaceEntry', [
{
entryId: 'replace-entry-1',
content: {
itemContent: {
is_ai_trend: true,
name: 'Replacement headline',
},
},
},
]),
}),
)
.mockResolvedValue(makeResponse({ json: async () => makeTimelineResponse([]) }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getNews(1);
expect(result.success).toBe(true);
expect(result.items?.[0].headline).toBe('Replacement headline');
});
it('assigns unique ids for module entries', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(
makeResponse({
json: async () =>
makeTimelineResponseWithInstruction('TimelineAddEntries', [
{
entryId: 'module-entry',
content: {
items: [
{
item: { itemContent: { is_ai_trend: true, name: 'Headline A' } },
},
{
item: { itemContent: { is_ai_trend: true, name: 'Headline B' } },
},
],
},
},
]),
}),
)
.mockResolvedValue(makeResponse({ json: async () => makeTimelineResponse([]) }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getNews(2);
expect(result.success).toBe(true);
const ids = result.items?.map((item) => item.id) ?? [];
expect(new Set(ids).size).toBe(ids.length);
});
it('uses trend urls as ids when available', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(
makeResponse({
json: async () =>
makeTimelineResponse([
{
is_ai_trend: true,
name: 'Headline A',
trend_url: { url: 'https://x.com/hashtag/A' },
},
]),
}),
)
.mockResolvedValue(makeResponse({ json: async () => makeTimelineResponse([]) }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getNews(1);
expect(result.success).toBe(true);
expect(result.items?.[0].id).toBe('https://x.com/hashtag/A');
});
});
});
@@ -0,0 +1,180 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import type { TwitterClientPrivate } from './twitter-client-fixtures.js';
import { validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
const makeUserEntry = (id: string, username: string, name: string) => ({
content: {
itemContent: {
user_results: {
result: {
__typename: 'User',
rest_id: id,
legacy: {
screen_name: username,
name,
followers_count: 10,
friends_count: 5,
profile_image_url_https: 'https://img/test_normal.jpg',
},
is_blue_verified: false,
core: {
screen_name: username,
name,
},
},
},
},
},
});
describe('TwitterClient following pagination', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it('returns nextCursor from the response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
makeUserEntry('1', 'alice', 'Alice'),
{ content: { cursorType: 'Bottom', value: 'cursor-2' } },
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getFollowingQueryIds = async () => ['q1'];
clientPrivate.getFollowingViaRest = vi.fn();
const result = await client.getFollowing('user-id', 3);
expect(result.success).toBe(true);
expect(result.users).toHaveLength(1);
expect(result.users?.[0].username).toBe('alice');
expect(result.nextCursor).toBe('cursor-2');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('passes cursor parameter to following API and returns nextCursor', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
makeUserEntry('1', 'alice', 'Alice'),
{ content: { cursorType: 'Bottom', value: 'cursor-2' } },
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getFollowingQueryIds = async () => ['q1'];
clientPrivate.getFollowingViaRest = vi.fn();
const result = await client.getFollowing('user-id', 20, 'prev-cursor-xyz');
expect(result.success).toBe(true);
expect(result.nextCursor).toBe('cursor-2');
const [url] = mockFetch.mock.calls[0];
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.cursor).toBe('prev-cursor-xyz');
});
it('returns undefined nextCursor when no cursor in response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [makeUserEntry('1', 'alice', 'Alice')],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getFollowingQueryIds = async () => ['q1'];
clientPrivate.getFollowingViaRest = vi.fn();
const result = await client.getFollowing('user-id', 20);
expect(result.success).toBe(true);
expect(result.nextCursor).toBeUndefined();
});
it('returns an error for non-ok responses', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'boom',
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getFollowingQueryIds = async () => ['q1'];
clientPrivate.getFollowingViaRest = vi.fn().mockResolvedValue({ success: false, error: 'rest fail' });
const result = await client.getFollowing('user-id', 3);
expect(result.success).toBe(false);
expect(result.error).toContain('500');
expect(result.users).toBeUndefined();
expect(result.nextCursor).toBeUndefined();
});
});
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
import { validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
@@ -280,17 +280,8 @@ describe('TwitterClient following/followers', () => {
expect(urls[1]).toContain('/Followers?');
expect(urls[2]).toContain('/followers/list.json?');
});
});
describe('TwitterClient likes', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches likes and parses tweet results', async () => {
it('passes cursor parameter to followers API and returns nextCursor', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
@@ -306,26 +297,62 @@ describe('TwitterClient likes', () => {
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '2',
legacy: {
full_text: 'liked',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '2',
},
core: {
user_results: {
result: makeUserResult('9', 'beta', 'Beta'),
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'followers-next-cursor',
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> };
clientPrivate.getFollowersQueryIds = async () => ['test'];
const result = await client.getFollowers('456', 50, 'my-cursor');
expect(result.success).toBe(true);
expect(result.users?.[0].username).toBe('beta');
expect(result.nextCursor).toBe('followers-next-cursor');
const [url] = mockFetch.mock.calls[0];
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.cursor).toBe('my-cursor');
});
it('returns undefined nextCursor when no cursor in followers response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
user_results: {
result: makeUserResult('1', 'only', 'Only'),
},
},
},
@@ -342,38 +369,12 @@ describe('TwitterClient likes', () => {
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '42', username: 'tester', name: 'Tester' },
});
clientPrivate.getLikesQueryIds = async () => ['test'];
const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> };
clientPrivate.getFollowersQueryIds = async () => ['test'];
const result = await client.getLikes(2);
const result = await client.getFollowers('123', 20);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('2');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(String(url)).toContain('/Likes?');
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.userId).toBe('42');
expect(parsedVars.count).toBe(2);
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBeUndefined();
});
it('returns an error when current user is unavailable', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ success: false, error: 'no user' });
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('no user');
expect(mockFetch).not.toHaveBeenCalled();
expect(result.nextCursor).toBeUndefined();
});
});
+344
View File
@@ -0,0 +1,344 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
// Realistic fixture based on actual Twitter API response structure
const makeHomeTimelineResponse = (tweetId: string, text: string, username: string) => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
__typename: 'Tweet',
rest_id: tweetId,
legacy: {
full_text: text,
created_at: 'Mon Jan 06 00:00:00 +0000 2025',
reply_count: 5,
retweet_count: 10,
favorite_count: 25,
conversation_id_str: tweetId,
},
core: {
user_results: {
result: {
__typename: 'User',
rest_id: 'u123',
legacy: {
screen_name: username,
name: `${username} Name`,
},
},
},
},
},
},
},
},
},
],
},
],
},
},
},
});
describe('TwitterClient home timeline', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
describe('getHomeTimeline', () => {
it('should return tweets from For You feed', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => makeHomeTimelineResponse('123456', 'Hello from For You feed!', 'testuser'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getHomeTimeline(1);
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.tweets?.[0].id).toBe('123456');
expect(result.tweets?.[0].text).toBe('Hello from For You feed!');
expect(result.tweets?.[0].author.username).toBe('testuser');
});
it('should return error on non-ok response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
text: async () => 'Unauthorized',
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getHomeTimeline(1);
expect(result.success).toBe(false);
expect(result.error).toContain('401');
});
it('should return error when API returns errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [{ message: 'Rate limit exceeded' }],
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getHomeTimeline(1);
expect(result.success).toBe(false);
expect(result.error).toContain('Rate limit exceeded');
});
});
describe('getHomeLatestTimeline', () => {
it('should return tweets from Following feed', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => makeHomeTimelineResponse('789012', 'Hello from Following feed!', 'chronouser'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getHomeLatestTimeline(1);
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.tweets?.[0].id).toBe('789012');
expect(result.tweets?.[0].text).toBe('Hello from Following feed!');
expect(result.tweets?.[0].author.username).toBe('chronouser');
});
it('should return error on non-ok response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
text: async () => 'Forbidden',
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getHomeLatestTimeline(1);
expect(result.success).toBe(false);
expect(result.error).toContain('403');
});
});
describe('pagination', () => {
it('should deduplicate tweets across pages', async () => {
// First page
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: { full_text: 'First tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } },
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor123',
},
},
],
},
],
},
},
},
}),
});
// Second page with duplicate
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1', // duplicate
legacy: { full_text: 'First tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } },
},
},
},
},
},
},
},
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet2',
legacy: { full_text: 'Second tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u2', legacy: { screen_name: 'user2', name: 'User 2' } },
},
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getHomeTimeline(2);
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(2);
expect(result.tweets?.[0].id).toBe('tweet1');
expect(result.tweets?.[1].id).toBe('tweet2');
});
it('stops when a page only returns duplicates', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: { full_text: 'First tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } },
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor123',
},
},
],
},
],
},
},
},
}),
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: { full_text: 'First tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } },
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor456',
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getHomeTimeline(5);
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
});
+305
View File
@@ -0,0 +1,305 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient likes', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches likes and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '2',
legacy: {
full_text: 'liked',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '42', username: 'tester', name: 'Tester' },
});
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(2);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('2');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(String(url)).toContain('/Likes?');
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.userId).toBe('42');
expect(parsedVars.count).toBe(2);
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBeUndefined();
});
it('paginates likes when count exceeds the first page', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'liked page 1',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-1',
},
},
],
},
],
},
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '2',
legacy: {
full_text: 'liked page 2',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '42', username: 'tester', name: 'Tester' },
});
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(3);
expect(result.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1', '2']);
expect(mockFetch).toHaveBeenCalledTimes(2);
const secondVars = JSON.parse(
new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string,
);
expect(secondVars.cursor).toBe('cursor-1');
});
it('respects maxPages when fetching all likes', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'liked page 1',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-1',
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '42', username: 'tester', name: 'Tester' },
});
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getAllLikes({ maxPages: 1 });
expect(result.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1']);
expect(result.nextCursor).toBe('cursor-1');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('returns an error when current user is unavailable', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ success: false, error: 'no user' });
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('no user');
expect(mockFetch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,137 @@
// ABOUTME: Tests for TwitterClient list methods.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient lists memberships', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
describe('getListMemberships', () => {
it('fetches list memberships and parses list results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '5555',
name: 'Member List',
member_count: 100,
user_results: {
result: {
rest_id: '99999',
legacy: { screen_name: 'otheruser', name: 'Other User' },
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListMembershipsQueryIds = async () => ['test'];
const result = await client.getListMemberships(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].id).toBe('5555');
expect(result.lists?.[0].name).toBe('Member List');
expect(result.lists?.[0].owner?.username).toBe('otheruser');
});
it('retries on 404 error after refreshing query IDs', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not Found',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '6666',
name: 'Retry Membership List',
mode: 'Public',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListMembershipsQueryIds = async () => ['test'];
clientPrivate.refreshQueryIds = async () => {};
const result = await client.getListMemberships(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].id).toBe('6666');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
});
@@ -0,0 +1,292 @@
// ABOUTME: Tests for TwitterClient list methods.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient lists owned errors', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
describe('getOwnedLists error paths', () => {
it('returns error on API failure', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Internal Server Error',
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns error when getCurrentUser fails', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: false,
error: 'Unauthorized',
});
const result = await client.getOwnedLists(100);
expect(result.success).toBe(false);
// When getCurrentUser fails with an error, that error is used; otherwise fallback message is used
expect(result.error).toContain('Unauthorized');
});
it('handles API errors in response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'Rate limit exceeded' }],
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(false);
expect(result.error).toContain('Rate limit exceeded');
});
it('returns missing feature flag errors from the API', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'missing required feature flag: responsive_web_graphql_exclude_directive_enabled' }],
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(1);
expect(result.success).toBe(false);
expect(result.error).toContain('missing required feature flag');
});
it('retries on 404 error after refreshing query IDs', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not Found',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '333',
name: 'Retry List',
mode: 'Public',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
clientPrivate.refreshQueryIds = async () => {};
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].id).toBe('333');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('skips list entries with missing id_str or name', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '111',
name: 'Valid List',
},
},
},
},
{
content: {
itemContent: {
list: {
// Missing id_str
name: 'Invalid List 1',
},
},
},
},
{
content: {
itemContent: {
list: {
id_str: '222',
// Missing name
},
},
},
},
{
content: {
itemContent: {
list: {
id_str: '333',
name: 'Another Valid List',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toHaveLength(2);
expect(result.lists?.[0].id).toBe('111');
expect(result.lists?.[1].id).toBe('333');
});
it('handles list with missing owner gracefully', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '444',
name: 'List Without Owner',
// No user_results
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toHaveLength(1);
expect(result.lists?.[0].owner).toBeUndefined();
});
});
});
+308
View File
@@ -0,0 +1,308 @@
// ABOUTME: Tests for TwitterClient list methods.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient lists owned', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
describe('getOwnedLists', () => {
it('fetches owned lists and parses list results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '1234567890',
name: 'My Test List',
description: 'A test list for testing',
member_count: 10,
subscriber_count: 5,
mode: 'Public',
created_at: '2024-01-01T00:00:00Z',
user_results: {
result: {
rest_id: '12345',
legacy: { screen_name: 'testuser', name: 'Test User' },
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toHaveLength(1);
expect(result.lists?.[0].id).toBe('1234567890');
expect(result.lists?.[0].name).toBe('My Test List');
expect(result.lists?.[0].description).toBe('A test list for testing');
expect(result.lists?.[0].memberCount).toBe(10);
expect(result.lists?.[0].subscriberCount).toBe(5);
expect(result.lists?.[0].isPrivate).toBe(false);
expect(result.lists?.[0].owner?.username).toBe('testuser');
});
it('includes required feature flags in list requests', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
await client.getOwnedLists(1);
const [url] = mockFetch.mock.calls[0];
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.responsive_web_graphql_exclude_directive_enabled).toBe(true);
expect(parsedFeatures.blue_business_profile_image_shape_enabled).toBe(false);
expect(parsedFeatures.responsive_web_text_conversations_enabled).toBe(false);
expect(parsedFeatures.tweetypie_unmention_optimization_enabled).toBe(true);
expect(parsedFeatures.vibe_api_enabled).toBe(false);
expect(parsedFeatures.interactive_text_enabled).toBe(false);
});
it('handles private lists correctly', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '9999',
name: 'Secret List',
mode: 'Private',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].isPrivate).toBe(true);
});
it('handles lowercase private mode', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '8888',
name: 'Lowercase Private',
mode: 'private',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].isPrivate).toBe(true);
});
it('handles missing mode', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '9999',
name: 'No Mode',
mode: null,
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].isPrivate).toBe(false);
});
it('returns empty array when no lists exist', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toEqual([]);
});
});
});
@@ -0,0 +1,463 @@
// ABOUTME: Tests for paginated list timeline fetches.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient lists pagination (continued)', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('resumes from cursor', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet-resumed',
legacy: {
full_text: 'Resumed tweet',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet-resumed',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890', { cursor: 'resume-cursor' });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.tweets?.[0].id).toBe('tweet-resumed');
// Verify cursor was passed in the request
const callUrl = (mockFetch.mock.calls[0] as [string])[0];
expect(callUrl).toContain('resume-cursor');
});
it('deduplicates tweets across pages', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'duplicate-tweet',
legacy: {
full_text: 'Duplicate',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'duplicate-tweet',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'duplicate-tweet',
legacy: {
full_text: 'Duplicate',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'duplicate-tweet',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'unique-tweet',
legacy: {
full_text: 'Unique',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'unique-tweet',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User 2' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890');
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(2);
expect(result.tweets?.[0].id).toBe('duplicate-tweet');
expect(result.tweets?.[1].id).toBe('unique-tweet');
});
it('stops when a page only returns duplicates', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'dup',
legacy: {
full_text: 'Duplicate',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'dup',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'dup',
legacy: {
full_text: 'Duplicate',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'dup',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-3',
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890');
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('stops when no cursor is returned', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: {
full_text: 'Page 1',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet2',
legacy: {
full_text: 'Last page',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User 2' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890');
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(2);
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,265 @@
// ABOUTME: Tests for paginated list timeline fetches.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient lists pagination', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
describe('getAllListTimeline', () => {
it('fetches multiple pages and returns all tweets', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: {
full_text: 'First tweet',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet2',
legacy: {
full_text: 'Second tweet',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User 2' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890');
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(2);
expect(result.tweets?.[0].id).toBe('tweet1');
expect(result.tweets?.[1].id).toBe('tweet2');
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(2);
// Verify cursor was passed in second request
const secondCallUrl = (mockFetch.mock.calls[1] as [string])[0];
expect(secondCallUrl).toContain('cursor-page-2');
});
it('stops at maxPages limit and returns nextCursor', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: {
full_text: 'Page 1',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet2',
legacy: {
full_text: 'Page 2',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User 2' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-3',
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890', { maxPages: 2 });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(2);
expect(result.nextCursor).toBe('cursor-page-3');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
});
-825
View File
@@ -1,825 +0,0 @@
// ABOUTME: Tests for TwitterClient list methods.
// ABOUTME: Tests getOwnedLists, getListMemberships, and getListTimeline.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient lists', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
describe('getOwnedLists', () => {
it('fetches owned lists and parses list results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '1234567890',
name: 'My Test List',
description: 'A test list for testing',
member_count: 10,
subscriber_count: 5,
mode: 'Public',
created_at: '2024-01-01T00:00:00Z',
user_results: {
result: {
rest_id: '12345',
legacy: { screen_name: 'testuser', name: 'Test User' },
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toHaveLength(1);
expect(result.lists?.[0].id).toBe('1234567890');
expect(result.lists?.[0].name).toBe('My Test List');
expect(result.lists?.[0].description).toBe('A test list for testing');
expect(result.lists?.[0].memberCount).toBe(10);
expect(result.lists?.[0].subscriberCount).toBe(5);
expect(result.lists?.[0].isPrivate).toBe(false);
expect(result.lists?.[0].owner?.username).toBe('testuser');
});
it('handles private lists correctly', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '9999',
name: 'Secret List',
mode: 'Private',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].isPrivate).toBe(true);
});
it('handles lowercase private mode', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '8888',
name: 'Lowercase Private',
mode: 'private',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].isPrivate).toBe(true);
});
it('handles missing mode', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '9999',
name: 'No Mode',
mode: null,
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].isPrivate).toBe(false);
});
it('returns empty array when no lists exist', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toEqual([]);
});
it('returns error on API failure', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Internal Server Error',
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns error when getCurrentUser fails', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: false,
error: 'Unauthorized',
});
const result = await client.getOwnedLists(100);
expect(result.success).toBe(false);
// When getCurrentUser fails with an error, that error is used; otherwise fallback message is used
expect(result.error).toContain('Unauthorized');
});
it('handles API errors in response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'Rate limit exceeded' }],
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(false);
expect(result.error).toContain('Rate limit exceeded');
});
it('retries on 404 error after refreshing query IDs', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not Found',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '333',
name: 'Retry List',
mode: 'Public',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
clientPrivate.refreshQueryIds = async () => {};
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].id).toBe('333');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('skips list entries with missing id_str or name', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '111',
name: 'Valid List',
},
},
},
},
{
content: {
itemContent: {
list: {
// Missing id_str
name: 'Invalid List 1',
},
},
},
},
{
content: {
itemContent: {
list: {
id_str: '222',
// Missing name
},
},
},
},
{
content: {
itemContent: {
list: {
id_str: '333',
name: 'Another Valid List',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toHaveLength(2);
expect(result.lists?.[0].id).toBe('111');
expect(result.lists?.[1].id).toBe('333');
});
it('handles list with missing owner gracefully', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '444',
name: 'List Without Owner',
// No user_results
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toHaveLength(1);
expect(result.lists?.[0].owner).toBeUndefined();
});
});
describe('getListMemberships', () => {
it('fetches list memberships and parses list results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '5555',
name: 'Member List',
member_count: 100,
user_results: {
result: {
rest_id: '99999',
legacy: { screen_name: 'otheruser', name: 'Other User' },
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListMembershipsQueryIds = async () => ['test'];
const result = await client.getListMemberships(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].id).toBe('5555');
expect(result.lists?.[0].name).toBe('Member List');
expect(result.lists?.[0].owner?.username).toBe('otheruser');
});
it('retries on 404 error after refreshing query IDs', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not Found',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '6666',
name: 'Retry Membership List',
mode: 'Public',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListMembershipsQueryIds = async () => ['test'];
clientPrivate.refreshQueryIds = async () => {};
const result = await client.getListMemberships(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].id).toBe('6666');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
describe('getListTimeline', () => {
it('fetches list timeline and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '111',
legacy: {
full_text: 'Tweet from list',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '111',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'listmember', name: 'List Member' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('111');
expect(result.tweets?.[0].text).toBe('Tweet from list');
expect(result.tweets?.[0].author.username).toBe('listmember');
});
it('returns empty array when list has no tweets', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(true);
expect(result.tweets).toEqual([]);
});
it('returns error on API failure', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
text: async () => 'Forbidden',
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 403');
});
it('handles API errors in response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'List not found' }],
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('nonexistent', 20);
expect(result.success).toBe(false);
expect(result.error).toContain('List not found');
});
it('retries on 404 error after refreshing query IDs', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not Found',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '222',
legacy: {
full_text: 'Retry success',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '222',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User Two' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
clientPrivate.refreshQueryIds = async () => {};
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('222');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
});
+214
View File
@@ -0,0 +1,214 @@
// ABOUTME: Tests for TwitterClient list methods.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient lists timeline', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
describe('getListTimeline', () => {
it('fetches list timeline and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '111',
legacy: {
full_text: 'Tweet from list',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '111',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'listmember', name: 'List Member' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('111');
expect(result.tweets?.[0].text).toBe('Tweet from list');
expect(result.tweets?.[0].author.username).toBe('listmember');
});
it('returns empty array when list has no tweets', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(true);
expect(result.tweets).toEqual([]);
});
it('returns error on API failure', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
text: async () => 'Forbidden',
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 403');
});
it('handles API errors in response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'List not found' }],
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('nonexistent', 20);
expect(result.success).toBe(false);
expect(result.error).toContain('List not found');
});
it('retries on 404 error after refreshing query IDs', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not Found',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '222',
legacy: {
full_text: 'Retry success',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '222',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User Two' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
clientPrivate.refreshQueryIds = async () => {};
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('222');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
});
+424
View File
@@ -0,0 +1,424 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient pagination for thread and replies', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
const makeConversationPayload = (tweetIds: string[], cursor?: string, inReplyTo?: string, conversationId = '1') => ({
data: {
threaded_conversation_with_injections_v2: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
...tweetIds.map((id, i) => ({
entryId: `tweet-${id}`,
content: {
itemContent: {
tweet_results: {
result: {
rest_id: id,
legacy: {
full_text: `tweet ${id}`,
created_at: `2024-01-0${i + 1}T00:00:00Z`,
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: conversationId,
...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}),
},
core: {
user_results: { result: { legacy: { screen_name: `user${id}`, name: `User ${id}` } } },
},
},
},
},
},
})),
// Add cursor entry if provided
...(cursor
? [
{
entryId: `cursor-bottom-${Date.now()}`,
content: {
cursorType: 'Bottom',
value: cursor,
},
},
]
: []),
],
},
],
},
},
});
describe('getRepliesPaged', () => {
it('returns replies from single page when no cursor in response', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => makeConversationPayload(['2', '3'], undefined, '1'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getRepliesPaged('1');
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('fetches multiple pages when cursor is present and --all is used', async () => {
// First page returns cursor, second page does not
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeConversationPayload(['2', '3'], 'cursor-page-2', '1'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeConversationPayload(['4', '5'], undefined, '1'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getRepliesPaged('1', { pageDelayMs: 0 });
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(4);
expect(result.tweets?.map((t) => t.id)).toEqual(['2', '3', '4', '5']);
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('respects maxPages option', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeConversationPayload(['2', '3'], 'cursor-page-2', '1'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeConversationPayload(['4', '5'], 'cursor-page-3', '1'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getRepliesPaged('1', { maxPages: 2, pageDelayMs: 0 });
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(4);
expect(result.nextCursor).toBe('cursor-page-3');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('resumes from provided cursor', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => makeConversationPayload(['4', '5'], undefined, '1'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getRepliesPaged('1', { cursor: 'resume-cursor', pageDelayMs: 0 });
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
// Verify cursor was passed in the request
const url = mockFetch.mock.calls[0][0] as string;
expect(url).toContain('resume-cursor');
});
it('deduplicates tweets across pages', async () => {
// Second page returns a duplicate tweet
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeConversationPayload(['2', '3'], 'cursor-page-2', '1'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeConversationPayload(['3', '4'], undefined, '1'), // '3' is duplicate
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getRepliesPaged('1', { pageDelayMs: 0 });
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(3); // 2, 3, 4 (no duplicate)
expect(result.tweets?.map((t) => t.id)).toEqual(['2', '3', '4']);
});
it('returns partial results on error after first page', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeConversationPayload(['2', '3'], 'cursor-page-2', '1'),
})
.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Internal Server Error',
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getRepliesPaged('1', { pageDelayMs: 0 });
expect(result.success).toBe(false);
expect(result.tweets?.length).toBe(2);
expect(result.error).toContain('500');
expect(result.nextCursor).toBe('cursor-page-2');
});
it('continues pagination even when a page contains no replies', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeConversationPayload(['2'], 'cursor-page-2', '1'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeConversationPayload(['10'], 'cursor-page-3', undefined),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeConversationPayload(['3'], undefined, '1'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getRepliesPaged('1', { pageDelayMs: 0 });
expect(result.success).toBe(true);
expect(result.tweets?.map((t) => t.id)).toEqual(['2', '3']);
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(3);
});
});
describe('getThreadPaged', () => {
it('returns thread tweets from single page', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => makeConversationPayload(['1', '2', '3'], undefined, undefined, '1'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getThreadPaged('1');
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(3);
expect(result.nextCursor).toBeUndefined();
});
it('fetches multiple pages for thread', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeConversationPayload(['1', '2'], 'cursor-page-2', undefined, '1'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeConversationPayload(['3', '4'], undefined, undefined, '1'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getThreadPaged('1', { pageDelayMs: 0 });
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(4);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('sorts thread tweets by creation time', async () => {
// Return tweets in non-chronological order
const payload = {
data: {
threaded_conversation_with_injections_v2: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
{
entryId: 'tweet-3',
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '3',
legacy: {
full_text: 'tweet 3',
created_at: '2024-01-03T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: { user_results: { result: { legacy: { screen_name: 'user3', name: 'User 3' } } } },
},
},
},
},
},
{
entryId: 'tweet-1',
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'tweet 1',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: { user_results: { result: { legacy: { screen_name: 'user1', name: 'User 1' } } } },
},
},
},
},
},
],
},
],
},
},
};
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => payload,
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getThreadPaged('1');
expect(result.success).toBe(true);
expect(result.tweets?.map((t) => t.id)).toEqual(['1', '3']); // Sorted by date
});
it('filters by conversation ID', async () => {
// Include a tweet from a different conversation
const payload = {
data: {
threaded_conversation_with_injections_v2: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
{
entryId: 'tweet-1',
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'tweet 1',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: { user_results: { result: { legacy: { screen_name: 'user1', name: 'User 1' } } } },
},
},
},
},
},
{
entryId: 'tweet-99',
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '99',
legacy: {
full_text: 'different conversation',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '99', // Different conversation
},
core: { user_results: { result: { legacy: { screen_name: 'user99', name: 'User 99' } } } },
},
},
},
},
},
],
},
],
},
},
};
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => payload,
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getThreadPaged('1');
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(1);
expect(result.tweets?.[0].id).toBe('1');
});
it('respects maxPages for thread', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeConversationPayload(['1', '2'], 'cursor-page-2', undefined, '1'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeConversationPayload(['3', '4'], 'cursor-page-3', undefined, '1'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getThreadPaged('1', { maxPages: 2, pageDelayMs: 0 });
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(4);
expect(result.nextCursor).toBe('cursor-page-3');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
});
@@ -341,6 +341,141 @@ describe('TwitterClient search', () => {
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1']);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('respects maxPages when fetching all search results', async () => {
const makeSearchEntry = (id: string, text: string) => ({
content: {
itemContent: {
tweet_results: {
result: {
rest_id: id,
legacy: {
full_text: text,
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: id,
},
core: {
user_results: {
result: { legacy: { screen_name: 'root', name: 'Root' } },
},
},
},
},
},
},
});
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [
{
entries: [
makeSearchEntry('1', 'page 1'),
{ content: { cursorType: 'Bottom', value: 'cursor-1' } },
],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [
{
entries: [makeSearchEntry('2', 'page 2')],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getAllSearchResults('query', { maxPages: 1 });
expect(result.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1']);
expect(result.nextCursor).toBe('cursor-1');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('does not return a stale cursor when search pagination ends', async () => {
const makeSearchEntry = (id: string) => ({
content: {
itemContent: {
tweet_results: {
result: {
rest_id: id,
legacy: {
full_text: `tweet-${id}`,
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: id,
},
core: {
user_results: {
result: { legacy: { screen_name: 'root', name: 'Root' } },
},
},
},
},
},
},
});
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [
{
entries: [makeSearchEntry('1')],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getAllSearchResults('query', { cursor: 'old-cursor' });
expect(result.success).toBe(true);
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(1);
const vars = JSON.parse(new URL(mockFetch.mock.calls[0][0] as string).searchParams.get('variables') as string);
expect(vars.cursor).toBe('old-cursor');
});
});
describe('TwitterClient bookmarks', () => {
@@ -691,6 +826,120 @@ describe('TwitterClient bookmarks', () => {
expect(vars.cursor).toBe('cursor-1');
});
it('stops paginating when a page only returns duplicates', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_timeline_v2: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'saved page 1',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-1',
},
},
],
},
],
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_timeline_v2: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'saved page 2 duplicate',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-2',
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getAllBookmarks();
expect(result.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1']);
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('treats graphql errors as non-fatal when instructions are present', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
+166
View File
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient getUserIdByUsername', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('resolves username to userId via GraphQL', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
__typename: 'User',
rest_id: '12345',
legacy: {
screen_name: 'testuser',
name: 'Test User',
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserIdByUsername('@testuser');
expect(result.success).toBe(true);
expect(result.userId).toBe('12345');
expect(result.username).toBe('testuser');
expect(result.name).toBe('Test User');
});
it('handles username without @ prefix', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
__typename: 'User',
rest_id: '67890',
legacy: {
screen_name: 'anotheruser',
name: 'Another User',
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserIdByUsername('anotheruser');
expect(result.success).toBe(true);
expect(result.userId).toBe('67890');
});
it('returns error for invalid username format', async () => {
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserIdByUsername('invalid handle with spaces');
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid username');
expect(mockFetch).not.toHaveBeenCalled();
});
it('returns error for empty username', async () => {
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserIdByUsername('');
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid username');
expect(mockFetch).not.toHaveBeenCalled();
});
it('returns error for username exceeding 15 characters', async () => {
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserIdByUsername('thisusernameiswaytoolong');
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid username');
expect(mockFetch).not.toHaveBeenCalled();
});
it('returns error when user is unavailable', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
__typename: 'UserUnavailable',
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserIdByUsername('suspended');
expect(result.success).toBe(false);
expect(result.error).toContain('not found or unavailable');
});
it('retries with fallback query IDs on 404', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not found',
})
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not found',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
__typename: 'User',
rest_id: '99999',
legacy: {
screen_name: 'founduser',
name: 'Found User',
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserIdByUsername('founduser');
expect(result.success).toBe(true);
expect(result.userId).toBe('99999');
expect(mockFetch).toHaveBeenCalledTimes(3);
});
});
+421
View File
@@ -0,0 +1,421 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient getUserTweets', () => {
let mockFetch: ReturnType<typeof vi.fn>;
const makeTweetResult = (id: string, text: string, username = 'testuser') => ({
rest_id: id,
legacy: {
full_text: text,
created_at: '2024-01-01T00:00:00Z',
reply_count: 1,
retweet_count: 2,
favorite_count: 3,
conversation_id_str: id,
},
core: {
user_results: {
result: {
rest_id: '12345',
legacy: {
screen_name: username,
name: 'Test User',
},
},
},
},
});
const makeTimelineResponse = (tweets: ReturnType<typeof makeTweetResult>[], cursor?: string) => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
...tweets.map((t) => ({
entryId: `tweet-${t.rest_id}`,
content: {
entryType: 'TimelineTimelineItem',
itemContent: {
tweet_results: {
result: t,
},
},
},
})),
...(cursor
? [
{
entryId: 'cursor-bottom-123',
content: {
entryType: 'TimelineTimelineCursor',
cursorType: 'Bottom',
value: cursor,
},
},
]
: []),
],
},
],
},
},
},
},
},
});
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches and parses user tweets', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () =>
makeTimelineResponse([makeTweetResult('1', 'First tweet'), makeTweetResult('2', 'Second tweet')]),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweets('12345', 10);
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
expect(result.tweets?.[0].id).toBe('1');
expect(result.tweets?.[0].text).toBe('First tweet');
expect(result.tweets?.[1].id).toBe('2');
expect(result.tweets?.[1].text).toBe('Second tweet');
});
it('respects count parameter', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () =>
makeTimelineResponse([
makeTweetResult('1', 'First'),
makeTweetResult('2', 'Second'),
makeTweetResult('3', 'Third'),
]),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweets('12345', 2);
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
});
it('returns empty array for user with no tweets', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([]),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweets('12345', 10);
expect(result.success).toBe(true);
expect(result.tweets).toEqual([]);
});
it('extracts cursor for pagination', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('1', 'Tweet')], 'next-cursor-abc'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweetsPaged('12345', 20, { maxPages: 1 });
expect(result.success).toBe(true);
expect(result.nextCursor).toBe('next-cursor-abc');
});
it('handles API errors gracefully', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Internal Server Error',
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweets('12345', 10);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
});
describe('TwitterClient getUserTweetsPaged', () => {
let mockFetch: ReturnType<typeof vi.fn>;
const makeTweetResult = (id: string, text: string) => ({
rest_id: id,
legacy: {
full_text: text,
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: id,
},
core: {
user_results: {
result: {
rest_id: '12345',
legacy: {
screen_name: 'testuser',
name: 'Test User',
},
},
},
},
});
const makeTimelineResponse = (tweets: ReturnType<typeof makeTweetResult>[], cursor?: string) => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
...tweets.map((t) => ({
entryId: `tweet-${t.rest_id}`,
content: {
entryType: 'TimelineTimelineItem',
itemContent: {
tweet_results: {
result: t,
},
},
},
})),
...(cursor
? [
{
entryId: 'cursor-bottom-123',
content: {
entryType: 'TimelineTimelineCursor',
cursorType: 'Bottom',
value: cursor,
},
},
]
: []),
],
},
],
},
},
},
},
},
});
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('requests only the remaining count', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () =>
makeTimelineResponse(
[makeTweetResult('1', 'Tweet 1'), makeTweetResult('2', 'Tweet 2'), makeTweetResult('3', 'Tweet 3')],
'cursor-2',
),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweetsPaged('12345', 5, { pageDelayMs: 0 });
expect(result.success).toBe(true);
const [url] = mockFetch.mock.calls[0];
const variablesRaw = new URL(url).searchParams.get('variables');
expect(variablesRaw).not.toBeNull();
const variables = JSON.parse(String(variablesRaw)) as { count?: number };
expect(variables.count).toBe(5);
});
it('enforces a hard 10 page cap by default', async () => {
for (let page = 1; page <= 10; page += 1) {
const startId = (page - 1) * 20 + 1;
const tweets = Array.from({ length: 20 }, (_v, index) => {
const id = String(startId + index);
return makeTweetResult(id, `Tweet ${id}`);
});
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse(tweets, `cursor-${page + 1}`),
});
}
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweetsPaged('12345', 9999, { pageDelayMs: 0 });
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(10);
expect(result.tweets?.length).toBe(200);
expect(result.nextCursor).toBe('cursor-11');
});
it('returns error for invalid limit', async () => {
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweetsPaged('12345', 0);
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid limit');
expect(mockFetch).not.toHaveBeenCalled();
});
it('fetches multiple pages with cursor', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('1', 'Page 1 Tweet')], 'cursor-page-2'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('2', 'Page 2 Tweet')], 'cursor-page-3'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweetsPaged('12345', 40, {
maxPages: 2,
pageDelayMs: 0, // No delay for tests
});
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
expect(result.tweets?.[0].id).toBe('1');
expect(result.tweets?.[1].id).toBe('2');
expect(result.nextCursor).toBe('cursor-page-3');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('respects maxPages limit', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('1', 'Tweet 1')], 'cursor-2'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('2', 'Tweet 2')], 'cursor-3'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweetsPaged('12345', 40, {
maxPages: 2,
pageDelayMs: 0,
});
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(result.nextCursor).toBe('cursor-3'); // Has more but stopped at maxPages
});
it('stops when no more cursor is returned', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('1', 'Tweet 1')], 'cursor-2'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('2', 'Tweet 2')]), // No cursor = end
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweetsPaged('12345', 100, {
maxPages: 5,
pageDelayMs: 0,
});
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('deduplicates tweets across pages', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('1', 'Tweet 1')], 'cursor-2'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () =>
makeTimelineResponse([
makeTweetResult('1', 'Tweet 1'), // Duplicate
makeTweetResult('2', 'Tweet 2'),
]),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweetsPaged('12345', 40, {
maxPages: 2,
pageDelayMs: 0,
});
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2); // Not 3
expect(result.tweets?.map((t) => t.id)).toEqual(['1', '2']);
});
it('resumes from provided cursor', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('5', 'Resumed Tweet')]),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweetsPaged('12345', 20, {
maxPages: 1,
cursor: 'resume-cursor-xyz',
pageDelayMs: 0,
});
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('5');
// Verify cursor was passed in the request
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('resume-cursor-xyz');
});
});