feat: add bookmark folders support (#30)

Add `twitter bookmarks folders` command to list bookmark folders
and `twitter bookmarks folders <id>` to fetch tweets from a specific
folder, with --since date filtering and pagination support.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
benny b
2026-03-17 05:59:08 -04:00
committed by GitHub
parent e496d8f870
commit ffd2a42f7c
5 changed files with 214 additions and 13 deletions

View File

@@ -4,6 +4,8 @@ Read commands:
twitter feed # home timeline (For You) twitter feed # home timeline (For You)
twitter feed -t following # following feed twitter feed -t following # following feed
twitter bookmarks # bookmarks twitter bookmarks # bookmarks
twitter bookmarks folders # list bookmark folders
twitter bookmarks folders <id> # tweets in a folder
twitter search "query" # search tweets twitter search "query" # search tweets
twitter search "query" --from user # advanced search twitter search "query" --from user # advanced search
twitter user elonmusk # user profile 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.") @click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.")
@structured_output_options @structured_output_options
@click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.") @click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.")
@@ -467,7 +469,8 @@ def favorites(ctx, max_count, as_json, as_yaml, output_file, do_filter, full_tex
@click.pass_context @click.pass_context
def bookmarks(ctx, max_count, as_json, as_yaml, output_file, do_filter, full_text): 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 # type: (Any, Optional[int], bool, bool, Optional[str], bool, bool) -> None
"""Fetch bookmarked tweets.""" """Fetch bookmarked tweets, or manage bookmark folders."""
if ctx.invoked_subcommand is None:
_run_bookmarks_command( _run_bookmarks_command(
max_count, max_count,
as_json, as_json,
@@ -479,6 +482,130 @@ def bookmarks(ctx, max_count, as_json, as_yaml, output_file, do_filter, full_tex
) )
@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 <id> # tweets in folder
twitter bookmarks folders <id> -n 50 # max 50 tweets
twitter bookmarks folders <id> --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() @cli.command()
@click.argument("screen_name") @click.argument("screen_name")
@structured_output_options @structured_output_options

View File

@@ -47,7 +47,7 @@ from .graphql import (
_resolve_query_id, _resolve_query_id,
_update_features_from_html, _update_features_from_html,
) )
from .models import UserProfile from .models import BookmarkFolder, UserProfile
from .parser import ( from .parser import (
_deep_get, _deep_get,
_parse_int, _parse_int,
@@ -183,6 +183,59 @@ class TwitterClient:
return self._fetch_timeline("Bookmarks", count, get_instructions) 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): def resolve_user_id(self, identifier):
# type: (str) -> str # type: (str) -> str
"""Resolve a user identifier (screen_name or numeric user_id) to numeric user_id. """Resolve a user identifier (screen_name or numeric user_id) to numeric user_id.

View File

@@ -47,6 +47,8 @@ FALLBACK_QUERY_IDS = {
"CreateBookmark": "aoDbu3RHznuiSkQ9aNM67Q", "CreateBookmark": "aoDbu3RHznuiSkQ9aNM67Q",
"DeleteBookmark": "Wlmlj2-xISYCixDmuS8KNg", "DeleteBookmark": "Wlmlj2-xISYCixDmuS8KNg",
"TweetResultByRestId": "7xflPyRiUxGVbJd4uWmbfg", "TweetResultByRestId": "7xflPyRiUxGVbJd4uWmbfg",
"BookmarkFoldersSlice": "i78YDd0Tza-dV4SYs58kRg",
"BookmarkFolderTimeline": "hNY7X2xE2N7HVF6Qb_mu6w",
} }
# ── Default feature flags ──────────────────────────────────────────────── # ── Default feature flags ────────────────────────────────────────────────

View File

@@ -54,6 +54,12 @@ class Tweet:
article_text: Optional[str] = None article_text: Optional[str] = None
@dataclass
class BookmarkFolder:
id: str
name: str
@dataclass @dataclass
class UserProfile: class UserProfile:
id: str id: str

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import json import json
from typing import Any, Dict, Iterable, List, Optional 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 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]: def user_profile_to_dict(user: UserProfile) -> Dict[str, Any]:
"""Convert a UserProfile dataclass into a JSON-safe dict.""" """Convert a UserProfile dataclass into a JSON-safe dict."""
return { return {