Feed cursor pagination (#49)

* Expose promoted tweets in feed output

* Add cursor-based feed pagination output
This commit is contained in:
Lucius
2026-04-10 01:20:18 +08:00
committed by GitHub
parent e3545ab069
commit 7816f8d813
12 changed files with 199 additions and 13 deletions

View File

@@ -39,6 +39,7 @@ def tweet_factory():
article_title=overrides.pop("article_title", None),
article_text=overrides.pop("article_text", None),
is_subscriber_only=overrides.pop("is_subscriber_only", False),
is_promoted=overrides.pop("is_promoted", False),
)
return _make_tweet

View File

@@ -57,6 +57,68 @@ def test_cli_feed_input_accepts_structured_json_envelope(tmp_path, tweet_factory
assert '"id": "1"' in result.output
def test_cli_feed_passes_include_promoted(monkeypatch, tweet_factory) -> None:
class FakeClient:
def fetch_home_timeline(
self,
count: int,
include_promoted: bool = False,
cursor: str | None = None,
return_cursor: bool = False,
):
assert count == 20
assert include_promoted is True
assert cursor is None
assert return_cursor is True
return [tweet_factory("1", is_promoted=True)], "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, ["feed", "--json", "--include-promoted"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["ok"] is True
assert payload["data"][0]["isPromoted"] is True
assert payload["pagination"]["nextCursor"] == "cursor-next"
def test_cli_feed_accepts_cursor_and_emits_pagination(monkeypatch) -> None:
class FakeClient:
def fetch_following_feed(
self,
count: int,
include_promoted: bool = False,
cursor: str | None = None,
return_cursor: bool = False,
):
assert count == 20
assert include_promoted is False
assert cursor == "cursor-prev"
assert return_cursor is True
return [], "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, ["feed", "-t", "following", "--cursor", "cursor-prev", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["ok"] is True
assert payload["data"] == []
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)

View File

@@ -333,6 +333,24 @@ class TestBuildHeaders:
class TestPaginationBehavior:
def test_fetch_timeline_can_include_promoted_content(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(variables.copy())
return {"page": 1}
client._graphql_get = _graphql_get
with patch('twitter_cli.client.parse_timeline_response', return_value=([], None)):
client._fetch_timeline("HomeTimeline", 1, lambda data: data, include_promoted=True)
assert calls[0]["includePromotedContent"] is True
def test_continues_when_cursor_advances_without_new_tweets(self):
client = TwitterClient.__new__(TwitterClient)
client._request_delay = 0.0
@@ -379,6 +397,33 @@ class TestPaginationBehavior:
assert tweets == []
assert calls == [None, "cursor-same"]
def test_fetch_timeline_returns_continuation_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(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_timeline(
"HomeTimeline",
1,
lambda data: data,
start_cursor="cursor-prev",
return_cursor=True,
)
assert [item.id for item in tweets] == ["tweet-1"]
assert cursor == "cursor-next"
assert calls[0]["cursor"] == "cursor-prev"
def test_user_list_continues_when_cursor_advances_without_new_users(self):
client = TwitterClient.__new__(TwitterClient)
client._request_delay = 0.0

View File

@@ -36,6 +36,21 @@ def test_parse_home_timeline_fixture(fixture_loader) -> None:
assert tweets[1].quoted_tweet.id == "30"
def test_parse_home_timeline_fixture_marks_promoted_entries(fixture_loader) -> None:
payload = fixture_loader("home_timeline.json")
entry = payload["data"]["home"]["home_timeline_urt"]["instructions"][0]["entries"][0]
entry["entryId"] = "promoted-tweet-1-demo"
entry["content"]["itemContent"]["promotedMetadata"] = {"impressionId": "demo"}
tweets, _ = parse_timeline_response(
payload,
lambda data: _deep_get(data, "data", "home", "home_timeline_urt", "instructions"),
)
assert tweets[0].is_promoted is True
assert tweets[1].is_promoted is False
def test_parse_tweet_detail_fixture_with_nested_items(fixture_loader) -> None:
payload = fixture_loader("tweet_detail.json")

View File

@@ -85,3 +85,11 @@ def test_tweet_roundtrip_preserves_subscriber_only(tweet_factory) -> None:
assert payload["isSubscriberOnly"] is True
restored = tweet_from_dict(payload)
assert restored.is_subscriber_only is True
def test_tweet_roundtrip_preserves_promoted_flag(tweet_factory) -> None:
tweet = tweet_factory("100", is_promoted=True)
payload = tweet_to_dict(tweet)
assert payload["isPromoted"] is True
restored = tweet_from_dict(payload)
assert restored.is_promoted is True