diff --git a/tests/conftest.py b/tests/conftest.py index 445ca3d..645b981 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,6 +38,7 @@ def tweet_factory(): score=overrides.pop("score", 0.0), article_title=overrides.pop("article_title", None), article_text=overrides.pop("article_text", None), + is_subscriber_only=overrides.pop("is_subscriber_only", False), ) return _make_tweet diff --git a/tests/fixtures/list_timeline.json b/tests/fixtures/list_timeline.json index 9e9220a..0ab3eb9 100644 --- a/tests/fixtures/list_timeline.json +++ b/tests/fixtures/list_timeline.json @@ -15,6 +15,10 @@ "tweet_results": { "result": { "__typename": "TweetWithVisibilityResults", + "tweetInterstitial": { + "__typename": "TweetInterstitial", + "text": { "rtl": false, "text": "Subscribe to @lister to see this post" } + }, "tweet": { "__typename": "Tweet", "rest_id": "700", diff --git a/tests/test_client.py b/tests/test_client.py index 18005bd..163f8e2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -641,6 +641,7 @@ class TestParseTweetResult: tweet = parse_tweet_result(wrapped) assert tweet is not None assert tweet.id == "1234567890" + assert tweet.is_subscriber_only is False @patch("twitter_cli.client._get_cffi_session") @patch("twitter_cli.client._gen_ct_headers", return_value={}) diff --git a/tests/test_parser_fixtures.py b/tests/test_parser_fixtures.py index 8e08a7e..c6b752d 100644 --- a/tests/test_parser_fixtures.py +++ b/tests/test_parser_fixtures.py @@ -74,6 +74,7 @@ def test_parse_list_timeline_fixture_with_visibility_wrapper(fixture_loader) -> assert cursor == "list-cursor" assert tweets[0].author.verified is True assert tweets[0].lang == "zh" + assert tweets[0].is_subscriber_only is True def test_fetch_user_list_with_fixture(monkeypatch, fixture_loader) -> None: diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 1085fe0..22f2d83 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -77,3 +77,11 @@ def test_tweet_roundtrip_preserves_article_fields(tweet_factory) -> None: assert restored.article_title == "Long-form title" assert restored.article_text == "Intro\n\n## Details" + + +def test_tweet_roundtrip_preserves_subscriber_only(tweet_factory) -> None: + tweet = tweet_factory("99", is_subscriber_only=True) + payload = tweet_to_dict(tweet) + assert payload["isSubscriberOnly"] is True + restored = tweet_from_dict(payload) + assert restored.is_subscriber_only is True diff --git a/twitter_cli/models.py b/twitter_cli/models.py index 2017f26..02f10f8 100644 --- a/twitter_cli/models.py +++ b/twitter_cli/models.py @@ -52,6 +52,7 @@ class Tweet: score: Optional[float] = None article_title: Optional[str] = None article_text: Optional[str] = None + is_subscriber_only: bool = False @dataclass diff --git a/twitter_cli/parser.py b/twitter_cli/parser.py index 563c1b0..77ccaad 100644 --- a/twitter_cli/parser.py +++ b/twitter_cli/parser.py @@ -241,15 +241,21 @@ def parse_user_result(user_data): # ── Tweet parsing ──────────────────────────────────────────────────────── +def _unwrap_visibility(result): + # type: (Dict[str, Any]) -> Tuple[Dict[str, Any], bool] + """Unwrap TweetWithVisibilityResults, returning (inner_data, is_subscriber_only).""" + if result.get("__typename") == "TweetWithVisibilityResults" and result.get("tweet"): + return result["tweet"], bool(result.get("tweetInterstitial")) + return result, False + + def parse_tweet_result(result, depth=0): # type: (Dict[str, Any], int) -> Optional[Tweet] """Parse a single TweetResult into a Tweet dataclass.""" if depth > 2: return None - tweet_data = result - if result.get("__typename") == "TweetWithVisibilityResults" and result.get("tweet"): - tweet_data = result["tweet"] + tweet_data, is_subscriber_only = _unwrap_visibility(result) if tweet_data.get("__typename") == "TweetTombstone": return None @@ -270,8 +276,7 @@ def parse_tweet_result(result, depth=0): if is_retweet: retweet_result = _deep_get(legacy, "retweeted_status_result", "result") or {} - if retweet_result.get("__typename") == "TweetWithVisibilityResults" and retweet_result.get("tweet"): - retweet_result = retweet_result["tweet"] + retweet_result, retweet_subscriber_only = _unwrap_visibility(retweet_result) rt_legacy = retweet_result.get("legacy") rt_core = retweet_result.get("core") if isinstance(rt_legacy, dict) and isinstance(rt_core, dict): @@ -312,6 +317,7 @@ def parse_tweet_result(result, depth=0): retweeted_by=retweeted_by, quoted_tweet=quoted_tweet, lang=actual_legacy.get("lang", ""), + is_subscriber_only=retweet_subscriber_only if is_retweet else is_subscriber_only, **_parse_article(actual_data), ) diff --git a/twitter_cli/serialization.py b/twitter_cli/serialization.py index fb435cc..f20be6d 100644 --- a/twitter_cli/serialization.py +++ b/twitter_cli/serialization.py @@ -46,6 +46,7 @@ def tweet_to_dict(tweet: Tweet) -> Dict[str, Any]: "retweetedBy": tweet.retweeted_by, "lang": tweet.lang, "score": tweet.score, + "isSubscriberOnly": tweet.is_subscriber_only, } if tweet.article_title is not None: data["articleTitle"] = tweet.article_title @@ -122,6 +123,7 @@ def tweet_from_dict(data: Dict[str, Any]) -> Tweet: score=float(data["score"]) if data.get("score") is not None else None, article_title=_optional_str(data.get("articleTitle")), article_text=_optional_str(data.get("articleText")), + is_subscriber_only=bool(data.get("isSubscriberOnly", False)), )