From 16aa1d5aeca0a06e8cec5d8795c79f638038e00b Mon Sep 17 00:00:00 2001 From: rakei076 Date: Mon, 25 May 2026 23:07:50 +0900 Subject: [PATCH] feat: support long-form tweets via CreateNoteTweet (Closes #54) Adds automatic routing to the CreateNoteTweet GraphQL endpoint when the weighted character count of a post exceeds 280 (X Premium long-form post limit). Standard tweets continue to use CreateTweet. Changes ------- * graphql.py: register CreateNoteTweet queryId and add the four feature flags required by the long-form endpoint (longform_notetweets_creation_enabled, longform_notetweets_richtext_consumption_enabled, responsive_web_twitter_article_data_v2_enabled, articles_preview_enabled, tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled) * client.py: add static helper _weighted_length() that mirrors X's weighted-character rule (ASCII = 1, others = 2), and route create_tweet() to CreateNoteTweet when weighted > 280. Notes on the silent-failure bug ------------------------------- CreateNoteTweet requires variables.disallowed_reply_options to be present (null is the canonical value). Without this field X responds HTTP 200 with an empty tweet_results object, which makes the failure look like a successful call. Setting disallowed_reply_options=None fixes the silent failure. Tested on an X Premium account with Japanese long-form posts (~400 weighted characters): tweets are created and returned with a valid rest_id. Standard <=280 posts continue to use the existing CreateTweet path with no behavior change. --- twitter_cli/client.py | 44 +++++++++++++++++++++++++++++++++++++++--- twitter_cli/graphql.py | 6 ++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/twitter_cli/client.py b/twitter_cli/client.py index 0436c8e..d4e3a1a 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -559,10 +559,28 @@ def upload_media(self, file_path): return media_id + @staticmethod + def _weighted_length(text): + # type: (str) -> int + """Return X's weighted character count for a tweet. + + X counts basic ASCII characters as 1 and most other characters (CJK, + emoji, etc.) as 2. The standard tweet limit is 280 weighted units; + anything above must go through the CreateNoteTweet endpoint (X + Premium long-form post). + """ + n = 0 + for ch in text: + n += 1 if ord(ch) < 0x80 else 2 + return n + 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. + Automatically routes to the CreateNoteTweet GraphQL endpoint when the + weighted character count exceeds 280 (X Premium long-form post). + Args: text: Tweet text content. reply_to_id: Optional tweet ID to reply to. @@ -582,12 +600,32 @@ def create_tweet(self, text, reply_to_id=None, media_ids=None): "in_reply_to_tweet_id": reply_to_id, "exclude_reply_user_ids": [], } - data = self._graphql_post("CreateTweet", variables, FEATURES) + + # Route to long-form endpoint if the tweet exceeds the standard limit. + weighted = self._weighted_length(text) + if weighted > 280: + op_name = "CreateNoteTweet" + # CreateNoteTweet requires this field; without it X returns + # HTTP 200 with an empty tweet_results object (silent failure). + variables["disallowed_reply_options"] = None + logger.info( + "Tweet weighted=%d > 280, using CreateNoteTweet (long-form)", + weighted, + ) + else: + op_name = "CreateTweet" + + data = self._graphql_post(op_name, variables, FEATURES) self._write_delay() - result = _deep_get(data, "data", "create_tweet", "tweet_results", "result") + if op_name == "CreateNoteTweet": + result = _deep_get( + data, "data", "notetweet_create", "tweet_results", "result" + ) + else: + result = _deep_get(data, "data", "create_tweet", "tweet_results", "result") if result: return result.get("rest_id", "") - raise TwitterAPIError(0, "Failed to create tweet") + raise TwitterAPIError(0, "Failed to create tweet (op=%s)" % op_name) def delete_tweet(self, tweet_id): # type: (str) -> bool diff --git a/twitter_cli/graphql.py b/twitter_cli/graphql.py index d34ea35..becd0a5 100644 --- a/twitter_cli/graphql.py +++ b/twitter_cli/graphql.py @@ -39,6 +39,7 @@ "Followers": "IOh4aS6UdGWGJUYTqliQ7Q", "Following": "zx6e-TLzRkeDO_a7p4b3JQ", "CreateTweet": "IID9x6WsdMnTlXnzXGq8ng", + "CreateNoteTweet": "yeInFtqpUoABoBE_YWPYgA", "DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "UnfavoriteTweet": "ZYKSe-w7KEslx3JhSIk5LA", @@ -64,8 +65,13 @@ "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, + "responsive_web_twitter_article_data_v2_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,