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 # 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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user