From ffd2a42f7c2637a08e0442c55a10c8d864af53d9 Mon Sep 17 00:00:00 2001 From: benny b Date: Tue, 17 Mar 2026 05:59:08 -0400 Subject: [PATCH] feat: add bookmark folders support (#30) Add `twitter bookmarks folders` command to list bookmark folders and `twitter bookmarks folders ` to fetch tweets from a specific folder, with --since date filtering and pagination support. Co-authored-by: Claude Opus 4.6 (1M context) --- twitter_cli/cli.py | 149 ++++++++++++++++++++++++++++++++--- twitter_cli/client.py | 55 ++++++++++++- twitter_cli/graphql.py | 2 + twitter_cli/models.py | 6 ++ twitter_cli/serialization.py | 15 +++- 5 files changed, 214 insertions(+), 13 deletions(-) diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index fac7a55..12b843b 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -4,6 +4,8 @@ Read commands: twitter feed # home timeline (For You) twitter feed -t following # following feed twitter bookmarks # bookmarks + twitter bookmarks folders # list bookmark folders + twitter bookmarks folders # tweets in a folder twitter search "query" # search tweets twitter search "query" --from user # advanced search twitter user elonmusk # user profile @@ -458,7 +460,7 @@ def favorites(ctx, max_count, as_json, as_yaml, output_file, do_filter, full_tex ) -@cli.command(name="bookmarks") +@cli.group(name="bookmarks", invoke_without_command=True) @click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.") @structured_output_options @click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.") @@ -467,16 +469,141 @@ def favorites(ctx, max_count, as_json, as_yaml, output_file, do_filter, full_tex @click.pass_context def bookmarks(ctx, max_count, as_json, as_yaml, output_file, do_filter, full_text): # type: (Any, Optional[int], bool, bool, Optional[str], bool, bool) -> None - """Fetch bookmarked tweets.""" - _run_bookmarks_command( - max_count, - as_json, - as_yaml, - output_file, - do_filter, - compact=ctx.obj.get("compact", False), - full_text=full_text, - ) + """Fetch bookmarked tweets, or manage bookmark folders.""" + if ctx.invoked_subcommand is None: + _run_bookmarks_command( + max_count, + as_json, + as_yaml, + output_file, + do_filter, + compact=ctx.obj.get("compact", False), + full_text=full_text, + ) + + +@bookmarks.command(name="folders") +@click.argument("folder_id", required=False, default=None) +@click.option("--max", "-n", "max_count", type=int, default=None, help="Max tweets to fetch from folder.") +@click.option("--since", type=str, default=None, help="Only show tweets after this date (YYYY-MM-DD).") +@structured_output_options +@click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.") +@click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.") +@click.pass_context +def bookmarks_folders(ctx, folder_id, max_count, since, as_json, as_yaml, output_file, do_filter): + # type: (Any, Optional[str], Optional[int], Optional[str], bool, bool, Optional[str], bool) -> None + """List bookmark folders, or fetch tweets from a folder. + + \b + Examples: + twitter bookmarks folders # list all folders + twitter bookmarks folders # tweets in folder + twitter bookmarks folders -n 50 # max 50 tweets + twitter bookmarks folders --since 2026-01-01 + """ + compact = ctx.obj.get("compact", False) + + if folder_id is None: + _run_list_bookmark_folders(as_json, as_yaml, compact) + else: + _run_bookmark_folder_timeline( + folder_id, max_count, since, as_json, as_yaml, output_file, do_filter, compact, + ) + + +def _run_list_bookmark_folders(as_json, as_yaml, compact): + # type: (bool, bool, bool) -> None + config = load_config() + rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact) + + def _run(): + client = _get_client(config) + if rich_output: + console.print("\U0001f4c2 Fetching bookmark folders...\n") + folders = client.fetch_bookmark_folders() + if rich_output: + console.print("\u2705 Found %d bookmark folders\n" % len(folders)) + + from .serialization import bookmark_folders_to_data + data = bookmark_folders_to_data(folders) + + if compact: + import json as _json + click.echo(_json.dumps(data, ensure_ascii=False, indent=2)) + return + + if emit_structured(data, as_json=as_json, as_yaml=as_yaml): + return + + # Rich table output + from rich.table import Table + table = Table(title="\U0001f4c2 Bookmark Folders \u2014 %d folders" % len(folders)) + table.add_column("ID", style="dim") + table.add_column("Name", style="bold") + for folder in folders: + table.add_row(folder.id, folder.name) + console.print(table) + console.print() + + _run_guarded(_run) + + +def _parse_since_date(since_str): + # type: (str) -> Any + """Parse a YYYY-MM-DD date string into a datetime for filtering.""" + from datetime import datetime, timezone + try: + return datetime.strptime(since_str, "%Y-%m-%d").replace(tzinfo=timezone.utc) + except ValueError: + raise RuntimeError("Invalid --since date format. Use YYYY-MM-DD (e.g. 2026-01-15).") + + +def _filter_tweets_since(tweets, since_str): + # type: (List[Tweet], str) -> List[Tweet] + """Filter tweets to only those created after the given date.""" + from datetime import datetime, timezone + from email.utils import parsedate_to_datetime + cutoff = _parse_since_date(since_str) + filtered = [] + for tweet in tweets: + if not tweet.created_at: + continue + try: + tweet_dt = parsedate_to_datetime(tweet.created_at) + if tweet_dt >= cutoff: + filtered.append(tweet) + except (ValueError, TypeError): + continue + return filtered + + +def _run_bookmark_folder_timeline(folder_id, max_count, since, as_json, as_yaml, output_file, do_filter, compact): + # type: (str, Optional[int], Optional[str], bool, bool, Optional[str], bool, bool) -> None + config = load_config() + + def _run(): + client = _get_client(config) + + def fetch_fn(count): + tweets = client.fetch_bookmark_folder_timeline(folder_id, count) + if since: + tweets = _filter_tweets_since(tweets, since) + return tweets + + _fetch_and_display( + fetch_fn, + "bookmark folder %s" % folder_id, + "\U0001f4c2", + max_count, + as_json, + as_yaml, + output_file, + do_filter, + config, + compact=compact, + ) + + _run_guarded(_run) @cli.command() diff --git a/twitter_cli/client.py b/twitter_cli/client.py index 329d046..6e7d707 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -47,7 +47,7 @@ from .graphql import ( _resolve_query_id, _update_features_from_html, ) -from .models import UserProfile +from .models import BookmarkFolder, UserProfile from .parser import ( _deep_get, _parse_int, @@ -183,6 +183,59 @@ class TwitterClient: return self._fetch_timeline("Bookmarks", count, get_instructions) + def fetch_bookmark_folders(self): + # type: () -> List[BookmarkFolder] + """Fetch all bookmark folders with pagination.""" + folders = [] # type: List[BookmarkFolder] + cursor = None # type: Optional[str] + max_pages = 10 + + for _ in range(max_pages): + variables = {} # type: Dict[str, Any] + if cursor: + variables["cursor"] = cursor + + data = self._graphql_get("BookmarkFoldersSlice", variables, FEATURES) + slice_data = _deep_get( + data, "data", "viewer", "user_results", "result", + "bookmark_collections_slice", + ) + if not isinstance(slice_data, dict): + break + + for item in slice_data.get("items", []): + folder_id = item.get("id") + folder_name = item.get("name", "") + if folder_id: + folders.append(BookmarkFolder(id=folder_id, name=folder_name)) + + next_cursor = _deep_get(slice_data, "slice_info", "next_cursor") + if not next_cursor or next_cursor == cursor: + break + cursor = next_cursor + + return folders + + def fetch_bookmark_folder_timeline(self, folder_id, count=50): + # type: (str, int) -> List[Tweet] + """Fetch tweets from a bookmark folder.""" + def get_instructions(data): + # type: (Any) -> Any + return _deep_get( + data, "data", "bookmark_collection_timeline", "timeline", "instructions", + ) + + return self._fetch_timeline( + "BookmarkFolderTimeline", + count, + get_instructions, + extra_variables={ + "bookmark_collection_id": folder_id, + "includePromotedContent": False, + }, + override_base_variables=True, + ) + def resolve_user_id(self, identifier): # type: (str) -> str """Resolve a user identifier (screen_name or numeric user_id) to numeric user_id. diff --git a/twitter_cli/graphql.py b/twitter_cli/graphql.py index 91d450a..40e96bb 100644 --- a/twitter_cli/graphql.py +++ b/twitter_cli/graphql.py @@ -47,6 +47,8 @@ FALLBACK_QUERY_IDS = { "CreateBookmark": "aoDbu3RHznuiSkQ9aNM67Q", "DeleteBookmark": "Wlmlj2-xISYCixDmuS8KNg", "TweetResultByRestId": "7xflPyRiUxGVbJd4uWmbfg", + "BookmarkFoldersSlice": "i78YDd0Tza-dV4SYs58kRg", + "BookmarkFolderTimeline": "hNY7X2xE2N7HVF6Qb_mu6w", } # ── Default feature flags ──────────────────────────────────────────────── diff --git a/twitter_cli/models.py b/twitter_cli/models.py index 6a4c660..2017f26 100644 --- a/twitter_cli/models.py +++ b/twitter_cli/models.py @@ -54,6 +54,12 @@ class Tweet: article_text: Optional[str] = None +@dataclass +class BookmarkFolder: + id: str + name: str + + @dataclass class UserProfile: id: str diff --git a/twitter_cli/serialization.py b/twitter_cli/serialization.py index fcfb709..fb435cc 100644 --- a/twitter_cli/serialization.py +++ b/twitter_cli/serialization.py @@ -5,7 +5,7 @@ from __future__ import annotations import json from typing import Any, Dict, Iterable, List, Optional -from .models import Author, Metrics, Tweet, TweetMedia, UserProfile +from .models import Author, BookmarkFolder, Metrics, Tweet, TweetMedia, UserProfile from .timeutil import format_iso8601, format_local_time @@ -175,6 +175,19 @@ def tweets_to_compact_json(tweets: Iterable[Tweet]) -> str: ) +def bookmark_folder_to_dict(folder: BookmarkFolder) -> Dict[str, Any]: + """Convert a BookmarkFolder dataclass into a JSON-safe dict.""" + return { + "id": folder.id, + "name": folder.name, + } + + +def bookmark_folders_to_data(folders: Iterable[BookmarkFolder]) -> List[Dict[str, Any]]: + """Serialize BookmarkFolder objects to Python dicts.""" + return [bookmark_folder_to_dict(f) for f in folders] + + def user_profile_to_dict(user: UserProfile) -> Dict[str, Any]: """Convert a UserProfile dataclass into a JSON-safe dict.""" return {