diff --git a/README.md b/README.md index e033cd2..698a28f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ A terminal-first CLI for Twitter/X: read timelines, bookmarks, and user profiles **Write:** - Post: create new tweets and replies, with optional image attachments (up to 4) +- Long-form posts: Premium accounts automatically route text above the standard 280 weighted-character limit through X long-form posting - Quote: quote-tweet with optional images - Delete: remove your own tweets - Like / Unlike: manage tweet likes @@ -152,6 +153,7 @@ twitter following elonmusk --max 50 # Write operations twitter post "Hello from twitter-cli!" +twitter post "Long-form text..." # Premium long-form posts route automatically twitter post "Hello!" --image photo.jpg # Post with image twitter post "Gallery" -i a.png -i b.jpg -i c.webp # Up to 4 images twitter post "reply text" --reply-to 1234567890 @@ -389,6 +391,7 @@ git clone git@github.com:jackwener/twitter-cli.git .agents/skills/twitter-cli **写入:** - 发推:发布新推文和回复,支持附带图片(最多 4 张,支持 JPEG/PNG/GIF/WebP) +- 长文:Premium 账号在文本超过标准 280 加权字符限制时自动使用 X 长文发布 - 引用推文:带评论的转发,也支持附带图片 - 删除:删除自己的推文 - 点赞 / 取消点赞 @@ -475,6 +478,7 @@ twitter following elonmusk # 写操作 twitter post "你好,世界!" +twitter post "长文内容..." # Premium 长文自动路由 twitter post "发图" --image photo.jpg # 带图发推 twitter post "多图" -i a.png -i b.jpg -i c.webp # 最多 4 张图片 twitter post "回复内容" --reply-to 1234567890 diff --git a/tests/test_client.py b/tests/test_client.py index c1393d3..9e0ba5f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -14,6 +14,8 @@ from twitter_cli.client import ( _best_chrome_target, + _tweet_create_operation, + _tweet_weighted_length, TwitterClient, ) from twitter_cli.exceptions import TwitterAPIError @@ -1440,6 +1442,7 @@ def test_create_tweet_with_media_ids(self, mock_session): captured_body = {} def mock_graphql_post(operation_name, variables, features=None): + captured_body["operation_name"] = operation_name captured_body.update(variables) return {"data": {"create_tweet": {"tweet_results": {"result": {"rest_id": "99"}}}}} @@ -1447,6 +1450,7 @@ def mock_graphql_post(operation_name, variables, features=None): result = client.create_tweet("test", media_ids=["111", "222"]) assert result == "99" + assert captured_body["operation_name"] == "CreateTweet" entities = captured_body["media"]["media_entities"] assert len(entities) == 2 @@ -1472,6 +1476,7 @@ def test_create_tweet_without_media_ids(self, mock_session): captured_body = {} def mock_graphql_post(operation_name, variables, features=None): + captured_body["operation_name"] = operation_name captured_body.update(variables) return {"data": {"create_tweet": {"tweet_results": {"result": {"rest_id": "88"}}}}} @@ -1479,9 +1484,85 @@ def mock_graphql_post(operation_name, variables, features=None): result = client.create_tweet("no media") assert result == "88" + assert captured_body["operation_name"] == "CreateTweet" assert captured_body["media"]["media_entities"] == [] +class TestCreateLongFormTweet: + """Tests routing oversized posts through CreateNoteTweet.""" + + @staticmethod + def _make_client(): + client = TwitterClient.__new__(TwitterClient) + client._write_delay = lambda: None + return client + + def test_weighted_length_routes_non_ascii_text_to_note_tweet(self): + text = "你" * 141 + assert _tweet_weighted_length(text) == 282 + assert _tweet_create_operation(text) == "CreateNoteTweet" + + def test_standard_tweet_uses_create_tweet(self): + client = self._make_client() + calls = [] + + def mock_graphql_post(operation_name, variables, features=None): + calls.append((operation_name, variables)) + return {"data": {"create_tweet": {"tweet_results": {"result": {"rest_id": "88"}}}}} + + client._graphql_post = mock_graphql_post + + assert client.create_tweet("x" * 280) == "88" + operation_name, variables = calls[0] + assert operation_name == "CreateTweet" + assert "disallowed_reply_options" not in variables + + def test_long_tweet_uses_create_note_tweet(self): + client = self._make_client() + calls = [] + + def mock_graphql_post(operation_name, variables, features=None): + calls.append((operation_name, variables)) + return { + "data": { + "notetweet_create": { + "tweet_results": {"result": {"rest_id": "99"}} + } + } + } + + client._graphql_post = mock_graphql_post + + assert client.create_tweet("x" * 281, reply_to_id="123") == "99" + operation_name, variables = calls[0] + assert operation_name == "CreateNoteTweet" + assert variables["disallowed_reply_options"] is None + assert variables["includePromotedContent"] is False + assert variables["reply"]["in_reply_to_tweet_id"] == "123" + + def test_long_quote_uses_create_note_tweet(self): + client = self._make_client() + calls = [] + + def mock_graphql_post(operation_name, variables, features=None): + calls.append((operation_name, variables)) + return { + "data": { + "notetweet_create": { + "tweet_results": {"result": {"rest_id": "100"}} + } + } + } + + client._graphql_post = mock_graphql_post + + assert client.quote_tweet("456", "x" * 281) == "100" + operation_name, variables = calls[0] + assert operation_name == "CreateNoteTweet" + assert variables["disallowed_reply_options"] is None + assert variables["attachment_url"] == "https://x.com/i/status/456" + + # ── fetch_search uses POST ──────────────────────────────────────────────── class TestFetchSearchUsesPost: diff --git a/twitter_cli/client.py b/twitter_cli/client.py index 0436c8e..d72e8e6 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -70,6 +70,7 @@ # Hard ceiling to prevent accidental massive fetches _ABSOLUTE_MAX_COUNT = 500 +_STANDARD_TWEET_WEIGHT_LIMIT = 280 # ── Session management ─────────────────────────────────────────────────── @@ -130,6 +131,30 @@ def _url_fetch(url, headers=None): return resp.text +def _tweet_weighted_length(text): + # type: (str) -> int + """Return X's approximate weighted character count for composer routing.""" + return sum(1 if ord(ch) < 0x80 else 2 for ch in text) + + +def _tweet_create_operation(text): + # type: (str) -> str + """Return the GraphQL mutation for a standard or long-form post.""" + if _tweet_weighted_length(text) > _STANDARD_TWEET_WEIGHT_LIMIT: + return "CreateNoteTweet" + return "CreateTweet" + + +def _created_tweet_result(data): + # type: (Dict[str, Any]) -> Optional[Dict[str, Any]] + """Extract tweet result from CreateTweet or CreateNoteTweet responses.""" + return ( + _deep_get(data, "data", "create_tweet", "tweet_results", "result") + or _deep_get(data, "data", "notetweet_create", "tweet_results", "result") + or _deep_get(data, "data", "create_note_tweet", "tweet_results", "result") + ) + + # ── TwitterClient ──────────────────────────────────────────────────────── @@ -563,6 +588,10 @@ def create_tweet(self, text, reply_to_id=None, media_ids=None): # type: (str, Optional[str], Optional[List[str]]) -> str """Post a new tweet. Returns the new tweet ID. + Posts over the standard weighted length limit are routed through + CreateNoteTweet, which is the GraphQL mutation X uses for Premium + long-form posts. + Args: text: Tweet text content. reply_to_id: Optional tweet ID to reply to. @@ -576,18 +605,29 @@ def create_tweet(self, text, reply_to_id=None, media_ids=None): "media": {"media_entities": media_entities, "possibly_sensitive": False}, "semantic_annotation_ids": [], "dark_request": False, + "includePromotedContent": False, } # type: Dict[str, Any] if reply_to_id: variables["reply"] = { "in_reply_to_tweet_id": reply_to_id, "exclude_reply_user_ids": [], } - data = self._graphql_post("CreateTweet", variables, FEATURES) + operation_name = _tweet_create_operation(text) + if operation_name == "CreateNoteTweet": + # Required by the long-form mutation. Without it X can return HTTP + # 200 with an empty tweet_results object instead of creating a post. + variables["disallowed_reply_options"] = None + logger.info( + "Tweet weighted=%d > %d, using CreateNoteTweet (long-form)", + _tweet_weighted_length(text), + _STANDARD_TWEET_WEIGHT_LIMIT, + ) + data = self._graphql_post(operation_name, variables, FEATURES) self._write_delay() - result = _deep_get(data, "data", "create_tweet", "tweet_results", "result") + result = _created_tweet_result(data) if result: return result.get("rest_id", "") - raise TwitterAPIError(0, "Failed to create tweet") + raise TwitterAPIError(0, "Failed to create tweet (op=%s)" % operation_name) def delete_tweet(self, tweet_id): # type: (str) -> bool @@ -711,13 +751,22 @@ def quote_tweet(self, tweet_id, text, media_ids=None): "media": {"media_entities": media_entities, "possibly_sensitive": False}, "semantic_annotation_ids": [], "dark_request": False, + "includePromotedContent": False, } - data = self._graphql_post("CreateTweet", variables, FEATURES) + operation_name = _tweet_create_operation(text) + if operation_name == "CreateNoteTweet": + variables["disallowed_reply_options"] = None + logger.info( + "Quote weighted=%d > %d, using CreateNoteTweet (long-form)", + _tweet_weighted_length(text), + _STANDARD_TWEET_WEIGHT_LIMIT, + ) + data = self._graphql_post(operation_name, variables, FEATURES) self._write_delay() - result = _deep_get(data, "data", "create_tweet", "tweet_results", "result") + result = _created_tweet_result(data) if result: return result.get("rest_id", "") - raise TwitterAPIError(0, "Failed to create quote tweet") + raise TwitterAPIError(0, "Failed to create quote tweet (op=%s)" % operation_name) def follow_user(self, user_id): # type: (str) -> bool diff --git a/twitter_cli/graphql.py b/twitter_cli/graphql.py index d34ea35..dc20f7e 100644 --- a/twitter_cli/graphql.py +++ b/twitter_cli/graphql.py @@ -39,6 +39,7 @@ "Followers": "IOh4aS6UdGWGJUYTqliQ7Q", "Following": "zx6e-TLzRkeDO_a7p4b3JQ", "CreateTweet": "IID9x6WsdMnTlXnzXGq8ng", + "CreateNoteTweet": "dAlh5Gh9rR5pKk4HU4vW8g", "DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "UnfavoriteTweet": "ZYKSe-w7KEslx3JhSIk5LA", @@ -64,8 +65,12 @@ "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True, "view_counts_everywhere_api_enabled": True, "longform_notetweets_consumption_enabled": True, + "longform_notetweets_creation_enabled": True, + "longform_notetweets_richtext_consumption_enabled": True, "responsive_web_twitter_article_tweet_consumption_enabled": True, + "articles_preview_enabled": True, "tweet_awards_web_tipping_enabled": False, + "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True, "longform_notetweets_rich_text_read_enabled": True, "longform_notetweets_inline_media_enabled": True, "rweb_video_timestamps_enabled": True,