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:
@@ -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 <id> # 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,7 +469,8 @@ 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."""
|
||||
"""Fetch bookmarked tweets, or manage bookmark folders."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
_run_bookmarks_command(
|
||||
max_count,
|
||||
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()
|
||||
@click.argument("screen_name")
|
||||
@structured_output_options
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -47,6 +47,8 @@ FALLBACK_QUERY_IDS = {
|
||||
"CreateBookmark": "aoDbu3RHznuiSkQ9aNM67Q",
|
||||
"DeleteBookmark": "Wlmlj2-xISYCixDmuS8KNg",
|
||||
"TweetResultByRestId": "7xflPyRiUxGVbJd4uWmbfg",
|
||||
"BookmarkFoldersSlice": "i78YDd0Tza-dV4SYs58kRg",
|
||||
"BookmarkFolderTimeline": "hNY7X2xE2N7HVF6Qb_mu6w",
|
||||
}
|
||||
|
||||
# ── Default feature flags ────────────────────────────────────────────────
|
||||
|
||||
@@ -54,6 +54,12 @@ class Tweet:
|
||||
article_text: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookmarkFolder:
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserProfile:
|
||||
id: str
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user