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 -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,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 <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()