diff --git a/README.md b/README.md index 59beb17..e033cd2 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ twitter article 1234567890 --output article.md # List timeline twitter list 1539453138322673664 +twitter list 1539453138322673664 --cursor "" twitter list 1539453138322673664 --full-text # User @@ -458,6 +459,7 @@ twitter article 1234567890 --output article.md # 列表时间线 twitter list 1539453138322673664 +twitter list 1539453138322673664 --cursor "<上一页返回的 nextCursor>" twitter list 1539453138322673664 --full-text # 用户 diff --git a/SKILL.md b/SKILL.md index 512f7b9..82a3afe 100644 --- a/SKILL.md +++ b/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 --json # Structured output twitter list 1539453138322673664 # List timeline +twitter list 1539453138322673664 --cursor "" twitter list 1539453138322673664 --full-text twitter user-posts elonmusk --max 20 # User's tweets twitter user-posts elonmusk --full-text diff --git a/tests/test_cli.py b/tests/test_cli.py index 97944f4..5c6c58a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -119,6 +119,37 @@ def test_cli_feed_accepts_cursor_and_emits_pagination(monkeypatch) -> None: 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: long_text = "A" * 140 console = Console(record=True, width=400) diff --git a/tests/test_client.py b/tests/test_client.py index 931a6b9..c1393d3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -424,6 +424,34 @@ class TestPaginationBehavior: assert cursor == "cursor-next" 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): client = TwitterClient.__new__(TwitterClient) client._request_delay = 0.0 diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index 5393bbc..d2dc523 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -1008,22 +1008,57 @@ def article(ctx, tweet_id, as_json, as_yaml, as_markdown, output_file): @cli.command(name="list") @click.argument("list_id") @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 @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.pass_context -def list_timeline(ctx, list_id, max_count, as_json, as_yaml, do_filter, full_text): - # type: (Any, str, int, bool, bool, bool, bool) -> None +def list_timeline(ctx, list_id, max_count, cursor, as_json, as_yaml, do_filter, full_text): + # type: (Any, str, int, Optional[str], bool, bool, bool, bool) -> None """Fetch tweets from a Twitter List. LIST_ID is the numeric list ID.""" compact = ctx.obj.get("compact", False) config = load_config() + rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact) + def _run(): client = _get_client(config) - _fetch_and_display( - lambda count: client.fetch_list_timeline(list_id, count), - "list %s" % list_id, "📋", max_count, as_json, as_yaml, None, do_filter, config, - compact=compact, full_text=full_text, + try: + fetch_count = _resolve_configured_count(config, max_count) + if rich_output: + 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) diff --git a/twitter_cli/client.py b/twitter_cli/client.py index 8668a44..0436c8e 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -437,8 +437,8 @@ class TwitterClient: logger.info("fetch_article: tweet_id=%s", tweet_id) return tweet - def fetch_list_timeline(self, list_id, count=20): - # type: (str, int) -> List[Tweet] + def fetch_list_timeline(self, list_id, count=20, cursor=None, return_cursor=False): + # type: (str, int, Optional[str], bool) -> Any """Fetch tweets from a Twitter List.""" return self._fetch_timeline( "ListLatestTweetsTimeline", @@ -446,6 +446,8 @@ class TwitterClient: lambda data: _deep_get(data, "data", "list", "tweets_timeline", "timeline", "instructions"), extra_variables={"listId": list_id}, override_base_variables=True, + start_cursor=cursor, + return_cursor=return_cursor, ) def fetch_followers(self, user_id, count=20):