Skip to content

Commit 66115a4

Browse files
tao-baiclaude
andauthored
feat: detect subscriber-only tweets via tweetInterstitial (#33)
Extract visibility metadata from TweetWithVisibilityResults wrapper before unwrapping. Adds is_subscriber_only field to Tweet model, with full serialization roundtrip and test coverage. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 90f0635 commit 66115a4

8 files changed

Lines changed: 29 additions & 5 deletions

File tree

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def _make_tweet(tweet_id: str = "1", **overrides: Any) -> Tweet:
3838
score=overrides.pop("score", 0.0),
3939
article_title=overrides.pop("article_title", None),
4040
article_text=overrides.pop("article_text", None),
41+
is_subscriber_only=overrides.pop("is_subscriber_only", False),
4142
)
4243

4344
return _make_tweet

tests/fixtures/list_timeline.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
"tweet_results": {
1616
"result": {
1717
"__typename": "TweetWithVisibilityResults",
18+
"tweetInterstitial": {
19+
"__typename": "TweetInterstitial",
20+
"text": { "rtl": false, "text": "Subscribe to @lister to see this post" }
21+
},
1822
"tweet": {
1923
"__typename": "Tweet",
2024
"rest_id": "700",

tests/test_client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,7 @@ def test_parses_visibility_wrapper(self, mock_ct_headers, mock_session):
641641
tweet = parse_tweet_result(wrapped)
642642
assert tweet is not None
643643
assert tweet.id == "1234567890"
644+
assert tweet.is_subscriber_only is False
644645

645646
@patch("twitter_cli.client._get_cffi_session")
646647
@patch("twitter_cli.client._gen_ct_headers", return_value={})

tests/test_parser_fixtures.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def test_parse_list_timeline_fixture_with_visibility_wrapper(fixture_loader) ->
7474
assert cursor == "list-cursor"
7575
assert tweets[0].author.verified is True
7676
assert tweets[0].lang == "zh"
77+
assert tweets[0].is_subscriber_only is True
7778

7879

7980
def test_fetch_user_list_with_fixture(monkeypatch, fixture_loader) -> None:

tests/test_serialization.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,11 @@ def test_tweet_roundtrip_preserves_article_fields(tweet_factory) -> None:
7777

7878
assert restored.article_title == "Long-form title"
7979
assert restored.article_text == "Intro\n\n## Details"
80+
81+
82+
def test_tweet_roundtrip_preserves_subscriber_only(tweet_factory) -> None:
83+
tweet = tweet_factory("99", is_subscriber_only=True)
84+
payload = tweet_to_dict(tweet)
85+
assert payload["isSubscriberOnly"] is True
86+
restored = tweet_from_dict(payload)
87+
assert restored.is_subscriber_only is True

twitter_cli/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class Tweet:
5252
score: Optional[float] = None
5353
article_title: Optional[str] = None
5454
article_text: Optional[str] = None
55+
is_subscriber_only: bool = False
5556

5657

5758
@dataclass

twitter_cli/parser.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,15 +241,21 @@ def parse_user_result(user_data):
241241
# ── Tweet parsing ────────────────────────────────────────────────────────
242242

243243

244+
def _unwrap_visibility(result):
245+
# type: (Dict[str, Any]) -> Tuple[Dict[str, Any], bool]
246+
"""Unwrap TweetWithVisibilityResults, returning (inner_data, is_subscriber_only)."""
247+
if result.get("__typename") == "TweetWithVisibilityResults" and result.get("tweet"):
248+
return result["tweet"], bool(result.get("tweetInterstitial"))
249+
return result, False
250+
251+
244252
def parse_tweet_result(result, depth=0):
245253
# type: (Dict[str, Any], int) -> Optional[Tweet]
246254
"""Parse a single TweetResult into a Tweet dataclass."""
247255
if depth > 2:
248256
return None
249257

250-
tweet_data = result
251-
if result.get("__typename") == "TweetWithVisibilityResults" and result.get("tweet"):
252-
tweet_data = result["tweet"]
258+
tweet_data, is_subscriber_only = _unwrap_visibility(result)
253259
if tweet_data.get("__typename") == "TweetTombstone":
254260
return None
255261

@@ -270,8 +276,7 @@ def parse_tweet_result(result, depth=0):
270276

271277
if is_retweet:
272278
retweet_result = _deep_get(legacy, "retweeted_status_result", "result") or {}
273-
if retweet_result.get("__typename") == "TweetWithVisibilityResults" and retweet_result.get("tweet"):
274-
retweet_result = retweet_result["tweet"]
279+
retweet_result, retweet_subscriber_only = _unwrap_visibility(retweet_result)
275280
rt_legacy = retweet_result.get("legacy")
276281
rt_core = retweet_result.get("core")
277282
if isinstance(rt_legacy, dict) and isinstance(rt_core, dict):
@@ -312,6 +317,7 @@ def parse_tweet_result(result, depth=0):
312317
retweeted_by=retweeted_by,
313318
quoted_tweet=quoted_tweet,
314319
lang=actual_legacy.get("lang", ""),
320+
is_subscriber_only=retweet_subscriber_only if is_retweet else is_subscriber_only,
315321
**_parse_article(actual_data),
316322
)
317323

twitter_cli/serialization.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def tweet_to_dict(tweet: Tweet) -> Dict[str, Any]:
4646
"retweetedBy": tweet.retweeted_by,
4747
"lang": tweet.lang,
4848
"score": tweet.score,
49+
"isSubscriberOnly": tweet.is_subscriber_only,
4950
}
5051
if tweet.article_title is not None:
5152
data["articleTitle"] = tweet.article_title
@@ -122,6 +123,7 @@ def tweet_from_dict(data: Dict[str, Any]) -> Tweet:
122123
score=float(data["score"]) if data.get("score") is not None else None,
123124
article_title=_optional_str(data.get("articleTitle")),
124125
article_text=_optional_str(data.get("articleText")),
126+
is_subscriber_only=bool(data.get("isSubscriberOnly", False)),
125127
)
126128

127129

0 commit comments

Comments
 (0)