Add list cursor pagination (#56)
This commit is contained in:
@@ -136,6 +136,7 @@ twitter article 1234567890 --output article.md
|
|||||||
|
|
||||||
# List timeline
|
# List timeline
|
||||||
twitter list 1539453138322673664
|
twitter list 1539453138322673664
|
||||||
|
twitter list 1539453138322673664 --cursor "<next-cursor-from-previous-response>"
|
||||||
twitter list 1539453138322673664 --full-text
|
twitter list 1539453138322673664 --full-text
|
||||||
|
|
||||||
# User
|
# User
|
||||||
@@ -458,6 +459,7 @@ twitter article 1234567890 --output article.md
|
|||||||
|
|
||||||
# 列表时间线
|
# 列表时间线
|
||||||
twitter list 1539453138322673664
|
twitter list 1539453138322673664
|
||||||
|
twitter list 1539453138322673664 --cursor "<上一页返回的 nextCursor>"
|
||||||
twitter list 1539453138322673664 --full-text
|
twitter list 1539453138322673664 --full-text
|
||||||
|
|
||||||
# 用户
|
# 用户
|
||||||
|
|||||||
1
SKILL.md
1
SKILL.md
@@ -170,6 +170,7 @@ twitter show 2 # Open tweet #2 from last feed/search lis
|
|||||||
twitter show 2 --full-text # Full text in reply table
|
twitter show 2 --full-text # Full text in reply table
|
||||||
twitter show 2 --json # Structured output
|
twitter show 2 --json # Structured output
|
||||||
twitter list 1539453138322673664 # List timeline
|
twitter list 1539453138322673664 # List timeline
|
||||||
|
twitter list 1539453138322673664 --cursor "<next-cursor>"
|
||||||
twitter list 1539453138322673664 --full-text
|
twitter list 1539453138322673664 --full-text
|
||||||
twitter user-posts elonmusk --max 20 # User's tweets
|
twitter user-posts elonmusk --max 20 # User's tweets
|
||||||
twitter user-posts elonmusk --full-text
|
twitter user-posts elonmusk --full-text
|
||||||
|
|||||||
@@ -119,6 +119,37 @@ def test_cli_feed_accepts_cursor_and_emits_pagination(monkeypatch) -> None:
|
|||||||
assert payload["pagination"]["nextCursor"] == "cursor-next"
|
assert payload["pagination"]["nextCursor"] == "cursor-next"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_list_accepts_cursor_and_emits_pagination(monkeypatch, tweet_factory) -> None:
|
||||||
|
class FakeClient:
|
||||||
|
def fetch_list_timeline(
|
||||||
|
self,
|
||||||
|
list_id: str,
|
||||||
|
count: int,
|
||||||
|
cursor: str | None = None,
|
||||||
|
return_cursor: bool = False,
|
||||||
|
):
|
||||||
|
assert list_id == "123"
|
||||||
|
assert count == 20
|
||||||
|
assert cursor == "cursor-prev"
|
||||||
|
assert return_cursor is True
|
||||||
|
return [tweet_factory("1")], "cursor-next"
|
||||||
|
|
||||||
|
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"twitter_cli.cli.load_config",
|
||||||
|
lambda: {"fetch": {"count": 20}, "filter": {}, "rateLimit": {}},
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["list", "123", "--cursor", "cursor-prev", "--json"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
payload = json.loads(result.output)
|
||||||
|
assert payload["ok"] is True
|
||||||
|
assert payload["data"][0]["id"] == "1"
|
||||||
|
assert payload["pagination"]["nextCursor"] == "cursor-next"
|
||||||
|
|
||||||
|
|
||||||
def test_print_tweet_table_truncates_text_by_default(tweet_factory) -> None:
|
def test_print_tweet_table_truncates_text_by_default(tweet_factory) -> None:
|
||||||
long_text = "A" * 140
|
long_text = "A" * 140
|
||||||
console = Console(record=True, width=400)
|
console = Console(record=True, width=400)
|
||||||
|
|||||||
@@ -424,6 +424,34 @@ class TestPaginationBehavior:
|
|||||||
assert cursor == "cursor-next"
|
assert cursor == "cursor-next"
|
||||||
assert calls[0]["cursor"] == "cursor-prev"
|
assert calls[0]["cursor"] == "cursor-prev"
|
||||||
|
|
||||||
|
def test_fetch_list_timeline_accepts_cursor_and_returns_cursor(self):
|
||||||
|
client = TwitterClient.__new__(TwitterClient)
|
||||||
|
client._request_delay = 0.0
|
||||||
|
client._max_count = 200
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def _graphql_get(operation_name, variables, features, field_toggles=None):
|
||||||
|
calls.append((operation_name, variables.copy()))
|
||||||
|
return {"page": 1}
|
||||||
|
|
||||||
|
client._graphql_get = _graphql_get
|
||||||
|
|
||||||
|
tweet = MagicMock(id="tweet-1")
|
||||||
|
with patch('twitter_cli.client.parse_timeline_response', return_value=([tweet], "cursor-next")):
|
||||||
|
tweets, cursor = client.fetch_list_timeline(
|
||||||
|
"list-1",
|
||||||
|
1,
|
||||||
|
cursor="cursor-prev",
|
||||||
|
return_cursor=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [item.id for item in tweets] == ["tweet-1"]
|
||||||
|
assert cursor == "cursor-next"
|
||||||
|
assert calls[0][0] == "ListLatestTweetsTimeline"
|
||||||
|
assert calls[0][1]["listId"] == "list-1"
|
||||||
|
assert calls[0][1]["cursor"] == "cursor-prev"
|
||||||
|
|
||||||
def test_user_list_continues_when_cursor_advances_without_new_users(self):
|
def test_user_list_continues_when_cursor_advances_without_new_users(self):
|
||||||
client = TwitterClient.__new__(TwitterClient)
|
client = TwitterClient.__new__(TwitterClient)
|
||||||
client._request_delay = 0.0
|
client._request_delay = 0.0
|
||||||
|
|||||||
@@ -1008,22 +1008,57 @@ def article(ctx, tweet_id, as_json, as_yaml, as_markdown, output_file):
|
|||||||
@cli.command(name="list")
|
@cli.command(name="list")
|
||||||
@click.argument("list_id")
|
@click.argument("list_id")
|
||||||
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max tweets to fetch.")
|
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max tweets to fetch.")
|
||||||
|
@click.option("--cursor", type=str, default=None, help="Pagination cursor for continuing a previous list request.")
|
||||||
@structured_output_options
|
@structured_output_options
|
||||||
@click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
|
@click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
|
||||||
@click.option("--full-text", is_flag=True, help="Show full tweet text in table output.")
|
@click.option("--full-text", is_flag=True, help="Show full tweet text in table output.")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def list_timeline(ctx, list_id, max_count, as_json, as_yaml, do_filter, full_text):
|
def list_timeline(ctx, list_id, max_count, cursor, as_json, as_yaml, do_filter, full_text):
|
||||||
# type: (Any, str, int, bool, bool, bool, bool) -> None
|
# type: (Any, str, int, Optional[str], bool, bool, bool, bool) -> None
|
||||||
"""Fetch tweets from a Twitter List. LIST_ID is the numeric list ID."""
|
"""Fetch tweets from a Twitter List. LIST_ID is the numeric list ID."""
|
||||||
compact = ctx.obj.get("compact", False)
|
compact = ctx.obj.get("compact", False)
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact)
|
||||||
|
|
||||||
def _run():
|
def _run():
|
||||||
client = _get_client(config)
|
client = _get_client(config)
|
||||||
_fetch_and_display(
|
try:
|
||||||
lambda count: client.fetch_list_timeline(list_id, count),
|
fetch_count = _resolve_configured_count(config, max_count)
|
||||||
"list %s" % list_id, "📋", max_count, as_json, as_yaml, None, do_filter, config,
|
if rich_output:
|
||||||
compact=compact, full_text=full_text,
|
console.print("📋 Fetching list %s (%d tweets)...\n" % (list_id, fetch_count))
|
||||||
|
start = time.time()
|
||||||
|
tweets, next_cursor = client.fetch_list_timeline(
|
||||||
|
list_id,
|
||||||
|
fetch_count,
|
||||||
|
cursor=cursor,
|
||||||
|
return_cursor=True,
|
||||||
|
)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
if rich_output:
|
||||||
|
console.print("✅ Fetched %d list %s in %.1fs\n" % (len(tweets), list_id, elapsed))
|
||||||
|
except (TwitterError, RuntimeError) as exc:
|
||||||
|
_exit_with_error(exc)
|
||||||
|
|
||||||
|
filtered = _apply_filter(tweets, do_filter, config, rich_output=rich_output)
|
||||||
|
|
||||||
|
if compact:
|
||||||
|
click.echo(tweets_to_compact_json(filtered))
|
||||||
|
return
|
||||||
|
|
||||||
|
save_tweet_cache(filtered)
|
||||||
|
|
||||||
|
if _emit_timeline_structured(filtered, next_cursor, as_json=as_json, as_yaml=as_yaml):
|
||||||
|
return
|
||||||
|
|
||||||
|
print_tweet_table(
|
||||||
|
filtered,
|
||||||
|
console,
|
||||||
|
title="📋 list %s — %d tweets" % (list_id, len(filtered)),
|
||||||
|
full_text=full_text,
|
||||||
)
|
)
|
||||||
|
_print_show_hint()
|
||||||
|
console.print()
|
||||||
|
|
||||||
_run_guarded(_run)
|
_run_guarded(_run)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -437,8 +437,8 @@ class TwitterClient:
|
|||||||
logger.info("fetch_article: tweet_id=%s", tweet_id)
|
logger.info("fetch_article: tweet_id=%s", tweet_id)
|
||||||
return tweet
|
return tweet
|
||||||
|
|
||||||
def fetch_list_timeline(self, list_id, count=20):
|
def fetch_list_timeline(self, list_id, count=20, cursor=None, return_cursor=False):
|
||||||
# type: (str, int) -> List[Tweet]
|
# type: (str, int, Optional[str], bool) -> Any
|
||||||
"""Fetch tweets from a Twitter List."""
|
"""Fetch tweets from a Twitter List."""
|
||||||
return self._fetch_timeline(
|
return self._fetch_timeline(
|
||||||
"ListLatestTweetsTimeline",
|
"ListLatestTweetsTimeline",
|
||||||
@@ -446,6 +446,8 @@ class TwitterClient:
|
|||||||
lambda data: _deep_get(data, "data", "list", "tweets_timeline", "timeline", "instructions"),
|
lambda data: _deep_get(data, "data", "list", "tweets_timeline", "timeline", "instructions"),
|
||||||
extra_variables={"listId": list_id},
|
extra_variables={"listId": list_id},
|
||||||
override_base_variables=True,
|
override_base_variables=True,
|
||||||
|
start_cursor=cursor,
|
||||||
|
return_cursor=return_cursor,
|
||||||
)
|
)
|
||||||
|
|
||||||
def fetch_followers(self, user_id, count=20):
|
def fetch_followers(self, user_id, count=20):
|
||||||
|
|||||||
Reference in New Issue
Block a user