From d9f8dfc54e10f752fc602c2ac8ac99c3532d03bd Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Thu, 11 Jun 2026 23:40:21 +0800 Subject: [PATCH 1/3] feat: support premium long-form posts --- README.md | 4 +++ tests/test_client.py | 81 ++++++++++++++++++++++++++++++++++++++++++ twitter_cli/client.py | 51 ++++++++++++++++++++++---- twitter_cli/graphql.py | 5 +++ 4 files changed, 135 insertions(+), 6 deletions(-) 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..1989d3b 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,24 @@ 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 + 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 +746,17 @@ 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 + 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, From e29f21fd80c609b730d961208abc1f24b7d05332 Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Fri, 12 Jun 2026 12:03:07 +0800 Subject: [PATCH 2/3] feat: log long-form routing decision (adopted from #60) --- twitter_cli/client.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/twitter_cli/client.py b/twitter_cli/client.py index 1989d3b..d72e8e6 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -617,6 +617,11 @@ def create_tweet(self, text, reply_to_id=None, media_ids=None): # 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 = _created_tweet_result(data) @@ -751,6 +756,11 @@ def quote_tweet(self, tweet_id, text, media_ids=None): 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 = _created_tweet_result(data) From e34333c0668ff5edadce06a09ecc6b27d6d38f91 Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Wed, 15 Jul 2026 13:46:38 +0800 Subject: [PATCH 3/3] fix: address long-form routing review feedback --- README.md | 13 ++++ tests/test_client.py | 24 +++--- tests/test_text.py | 40 ++++++++++ twitter_cli/client.py | 52 +++++-------- twitter_cli/graphql.py | 26 +++++-- twitter_cli/text.py | 165 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 268 insertions(+), 52 deletions(-) create mode 100644 tests/test_text.py create mode 100644 twitter_cli/text.py diff --git a/README.md b/README.md index 698a28f..b68838d 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,13 @@ A terminal-first CLI for Twitter/X: read timelines, bookmarks, and user profiles - Bookmark: bookmark/unbookmark (`favorite/unfavorite` kept as compatibility aliases) - Write commands also support explicit `--json` / `--yaml` output now +Long-form routing follows the twitter-text v3 NFC and character-weight ranges, +counts recognized URLs as 23 characters, and collapses common emoji sequences. +It is a routing estimate rather than a full composer validator: invalid control +characters remain server-validated, and malformed URLs or rare schemeless/IDN +domains can differ because the CLI uses a compact matcher instead of vendoring +twitter-text's generated URL/TLD grammar. + **Auth & Anti-Detection:** - Cookie auth: use browser cookies or environment variables - Full cookie forwarding: extracts ALL browser cookies for richer browser context @@ -399,6 +406,12 @@ git clone git@github.com:jackwener/twitter-cli.git .agents/skills/twitter-cli - 书签 / 取消书签:bookmark/unbookmark(保留 `favorite/unfavorite` 兼容别名) - 写操作现在也显式支持 `--json` / `--yaml` +长文路由遵循 twitter-text v3 的 NFC 规范化和字符权重区间,将识别到的 URL +按 23 个字符计数,并合并常见 emoji 序列。它只用于选择发布接口,不是完整的 +编辑器校验器:非法控制字符仍由服务端校验;由于 CLI 没有内置 twitter-text +生成的完整 URL/TLD 语法,格式错误的 URL 或少见的无协议/IDN 域名可能与网页 +编辑器计数不同。 + **认证与反风控:** - Cookie 认证:支持环境变量和浏览器自动提取 - 完整 Cookie 转发:提取浏览器中所有 Twitter Cookie,保留更多浏览器上下文 diff --git a/tests/test_client.py b/tests/test_client.py index 9e0ba5f..37e2909 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -14,14 +14,13 @@ from twitter_cli.client import ( _best_chrome_target, - _tweet_create_operation, - _tweet_weighted_length, TwitterClient, ) from twitter_cli.exceptions import TwitterAPIError from twitter_cli.graphql import ( FEATURES, FALLBACK_QUERY_IDS, + NOTE_TWEET_FEATURES, _build_graphql_url, _update_features_from_html, ) @@ -1497,32 +1496,29 @@ def _make_client(): 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)) + calls.append((operation_name, variables, features)) 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] + operation_name, variables, features = calls[0] assert operation_name == "CreateTweet" assert "disallowed_reply_options" not in variables + assert features is FEATURES + assert NOTE_TWEET_FEATURES.keys().isdisjoint(features) 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)) + calls.append((operation_name, variables, features)) return { "data": { "notetweet_create": { @@ -1534,18 +1530,19 @@ def mock_graphql_post(operation_name, variables, features=None): client._graphql_post = mock_graphql_post assert client.create_tweet("x" * 281, reply_to_id="123") == "99" - operation_name, variables = calls[0] + operation_name, variables, features = 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" + assert features == dict(FEATURES, **NOTE_TWEET_FEATURES) 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)) + calls.append((operation_name, variables, features)) return { "data": { "notetweet_create": { @@ -1557,10 +1554,11 @@ def mock_graphql_post(operation_name, variables, features=None): client._graphql_post = mock_graphql_post assert client.quote_tweet("456", "x" * 281) == "100" - operation_name, variables = calls[0] + operation_name, variables, features = calls[0] assert operation_name == "CreateNoteTweet" assert variables["disallowed_reply_options"] is None assert variables["attachment_url"] == "https://x.com/i/status/456" + assert features == dict(FEATURES, **NOTE_TWEET_FEATURES) # ── fetch_search uses POST ──────────────────────────────────────────────── diff --git a/tests/test_text.py b/tests/test_text.py new file mode 100644 index 0000000..8b37c83 --- /dev/null +++ b/tests/test_text.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from twitter_cli.text import ( + CREATE_NOTE_TWEET_OPERATION, + CREATE_TWEET_OPERATION, + tweet_create_route, + tweet_weighted_length, +) + + +def test_weighted_length_uses_twitter_text_v3_unicode_ranges() -> None: + assert tweet_weighted_length("\u00e9" * 280) == 280 + assert tweet_weighted_length("\u00df" * 280) == 280 + assert tweet_weighted_length("\u4f60" * 140) == 280 + + +def test_weighted_length_normalizes_nfc() -> None: + assert tweet_weighted_length("A\u0301B") == 2 + + +def test_weighted_length_normalizes_http_url_to_23_characters() -> None: + assert tweet_weighted_length("Hi http://test.co") == 26 + long_url = "https://example.com/" + ("path/" * 80) + assert tweet_weighted_length(long_url) == 23 + + +def test_weighted_length_normalizes_common_bare_domain() -> None: + assert tweet_weighted_length("See example.com/a/very/long/path") == 27 + + +def test_weighted_length_collapses_emoji_sequences() -> None: + family = "\U0001f468\u200d\U0001f469\u200d\U0001f467\u200d\U0001f466" + flag = "\U0001f1fa\U0001f1f8" + assert tweet_weighted_length(family * 140) == 280 + assert tweet_weighted_length(flag) == 2 + + +def test_route_uses_weighted_limit() -> None: + assert tweet_create_route("\u00e9" * 280) == (CREATE_TWEET_OPERATION, 280) + assert tweet_create_route("\u4f60" * 141) == (CREATE_NOTE_TWEET_OPERATION, 282) diff --git a/twitter_cli/client.py b/twitter_cli/client.py index d72e8e6..f6ed5e2 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -44,6 +44,7 @@ FEATURES, _build_graphql_url, _invalidate_query_id, + operation_features, _resolve_query_id, _update_features_from_html, ) @@ -55,6 +56,7 @@ parse_tweet_result, parse_user_result, ) +from .text import CREATE_NOTE_TWEET_OPERATION, tweet_create_route if TYPE_CHECKING: from typing import Dict, List, Optional, Set, Tuple # noqa: F401 @@ -70,7 +72,6 @@ # Hard ceiling to prevent accidental massive fetches _ABSOLUTE_MAX_COUNT = 500 -_STANDARD_TWEET_WEIGHT_LIMIT = 280 # ── Session management ─────────────────────────────────────────────────── @@ -131,18 +132,19 @@ 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 _prepare_tweet_create(text, variables): + # type: (str, Dict[str, Any]) -> Tuple[str, Dict[str, Any]] + """Apply operation-specific variables and return its feature set.""" + operation_name, weighted_length = tweet_create_route(text) + if operation_name == CREATE_NOTE_TWEET_OPERATION: + # 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, using CreateNoteTweet (long-form)", + weighted_length, + ) + return operation_name, operation_features(operation_name) def _created_tweet_result(data): @@ -612,17 +614,8 @@ def create_tweet(self, text, reply_to_id=None, media_ids=None): "in_reply_to_tweet_id": reply_to_id, "exclude_reply_user_ids": [], } - 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) + operation_name, features = _prepare_tweet_create(text, variables) + data = self._graphql_post(operation_name, variables, features) self._write_delay() result = _created_tweet_result(data) if result: @@ -753,15 +746,8 @@ def quote_tweet(self, tweet_id, text, media_ids=None): "dark_request": False, "includePromotedContent": False, } - 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) + operation_name, features = _prepare_tweet_create(text, variables) + data = self._graphql_post(operation_name, variables, features) self._write_delay() result = _created_tweet_result(data) if result: diff --git a/twitter_cli/graphql.py b/twitter_cli/graphql.py index dc20f7e..d209cef 100644 --- a/twitter_cli/graphql.py +++ b/twitter_cli/graphql.py @@ -16,6 +16,7 @@ from typing import Any, Dict, Optional # noqa: F401 from .exceptions import QueryIdError +from .text import CREATE_NOTE_TWEET_OPERATION, CREATE_TWEET_OPERATION logger = logging.getLogger(__name__) @@ -38,8 +39,8 @@ "ListLatestTweetsTimeline": "RlZzktZY_9wJynoepm8ZsA", "Followers": "IOh4aS6UdGWGJUYTqliQ7Q", "Following": "zx6e-TLzRkeDO_a7p4b3JQ", - "CreateTweet": "IID9x6WsdMnTlXnzXGq8ng", - "CreateNoteTweet": "dAlh5Gh9rR5pKk4HU4vW8g", + CREATE_TWEET_OPERATION: "IID9x6WsdMnTlXnzXGq8ng", + CREATE_NOTE_TWEET_OPERATION: "dAlh5Gh9rR5pKk4HU4vW8g", "DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "UnfavoriteTweet": "ZYKSe-w7KEslx3JhSIk5LA", @@ -65,12 +66,8 @@ "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, @@ -83,11 +80,28 @@ # Features dict that gets updated dynamically from x.com JS bundles FEATURES = dict(_DEFAULT_FEATURES) +# Extra composer flags belong only on CreateNoteTweet. Merging at call time +# preserves any live updates made to the shared base feature set. +NOTE_TWEET_FEATURES = { + "longform_notetweets_creation_enabled": True, + "longform_notetweets_richtext_consumption_enabled": True, + "articles_preview_enabled": True, + "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True, +} + # Module-level caches (not thread-safe — CLI is single-threaded) _cached_query_ids: Dict[str, str] = {} _bundles_scanned = False +def operation_features(operation_name): + # type: (str) -> Dict[str, Any] + """Return base features plus overrides scoped to one operation.""" + if operation_name == CREATE_NOTE_TWEET_OPERATION: + return dict(FEATURES, **NOTE_TWEET_FEATURES) + return FEATURES + + def _build_graphql_url(query_id, operation_name, variables, features, field_toggles=None): # type: (str, str, Dict[str, Any], Dict[str, Any], Optional[Dict[str, Any]]) -> str """Build GraphQL GET URL with encoded variables/features/fieldToggles. diff --git a/twitter_cli/text.py b/twitter_cli/text.py new file mode 100644 index 0000000..431d1e4 --- /dev/null +++ b/twitter_cli/text.py @@ -0,0 +1,165 @@ +"""X weighted text length and long-form routing helpers.""" + +from __future__ import annotations + +import re +import unicodedata +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Iterator, Optional, Tuple # noqa: F401 + + +CREATE_TWEET_OPERATION = "CreateTweet" +CREATE_NOTE_TWEET_OPERATION = "CreateNoteTweet" +STANDARD_TWEET_WEIGHT_LIMIT = 280 + +_TRANSFORMED_URL_LENGTH = 23 +_WEIGHT_ONE_RANGES = ( + (0x0000, 0x10FF), + (0x2000, 0x200D), + (0x2010, 0x201F), + (0x2032, 0x2037), +) + +# This intentionally recognizes protocol URLs broadly and common bare domains. +# The official parser's generated TLD expression is too large to vendor for a +# routing decision, so rare schemeless domains can differ; see the public +# function docstring below. +_PROTOCOL_URL = r"https?://[^\s<>{}\[\]\"']+" +_BARE_URL = ( + r"(?:[^\W_](?:[\w-]{0,61}[^\W_])?\.)+" + r"(?:[^\W\d_]{2,63}|xn--[\w-]{2,59})" + r"(?::\d{1,5})?(?:[/?#][^\s<>{}\[\]\"']*)?" +) +_URL_RE = re.compile(r"(? int + """Return the URL end before prose punctuation or unmatched closers.""" + end = len(url) + while end and url[end - 1] in _TRAILING_URL_PUNCTUATION: + end -= 1 + for opening, closing in (("(", ")"), ("[", "]"), ("{", "}")): + while end and url[end - 1] == closing and url[:end].count(closing) > url[:end].count(opening): + end -= 1 + return end + + +def _url_spans(text): + # type: (str) -> Iterator[Tuple[int, int]] + for match in _URL_RE.finditer(text): + end = match.start() + _trim_url_end(match.group(0)) + if end > match.start(): + yield match.start(), end + + +def _is_emoji_base(codepoint): + # type: (int) -> bool + return codepoint in _EMOJI_BASES or any(start <= codepoint <= end for start, end in _EMOJI_BASE_RANGES) + + +def _consume_emoji_suffix(text, index, limit): + # type: (str, int, int) -> int + while index < limit and ( + ord(text[index]) in (0xFE0E, 0xFE0F) + or 0x1F3FB <= ord(text[index]) <= 0x1F3FF + or 0xE0020 <= ord(text[index]) <= 0xE007F + ): + index += 1 + return index + + +def _emoji_sequence_end(text, index, limit): + # type: (str, int, int) -> Optional[int] + codepoint = ord(text[index]) + + if text[index] in "#*0123456789": + cursor = index + 1 + if cursor < limit and ord(text[cursor]) == 0xFE0F: + cursor += 1 + if cursor < limit and ord(text[cursor]) == 0x20E3: + return cursor + 1 + return None + + if 0x1F1E6 <= codepoint <= 0x1F1FF: + if index + 1 < limit and 0x1F1E6 <= ord(text[index + 1]) <= 0x1F1FF: + return index + 2 + return index + 1 + + if not _is_emoji_base(codepoint): + return None + + cursor = _consume_emoji_suffix(text, index + 1, limit) + while cursor + 1 < limit and ord(text[cursor]) == 0x200D: + next_codepoint = ord(text[cursor + 1]) + if not _is_emoji_base(next_codepoint): + break + cursor = _consume_emoji_suffix(text, cursor + 2, limit) + return cursor + + +def _character_weight(codepoint): + # type: (int) -> int + if any(start <= codepoint <= end for start, end in _WEIGHT_ONE_RANGES): + return 1 + return 2 + + +def _weighted_segment(text, start, end): + # type: (str, int, int) -> int + weight = 0 + index = start + while index < end: + emoji_end = _emoji_sequence_end(text, index, end) + if emoji_end is not None: + weight += 2 + index = emoji_end + continue + weight += _character_weight(ord(text[index])) + index += 1 + return weight + + +def tweet_weighted_length(text): + # type: (str) -> int + """Return the twitter-text v3 weighted length used for routing. + + This follows v3's NFC normalization, code-point ranges, emoji-sequence + collapsing, and 23-character URL transformation. It is intentionally not a + full validator: invalid control characters are left to X, and the compact + URL matcher does not vendor twitter-text's generated URL/TLD grammar, so + malformed URLs and rare schemeless or IDN edge cases can differ from the + web composer. + """ + normalized = unicodedata.normalize("NFC", text) + weight = 0 + cursor = 0 + for start, end in _url_spans(normalized): + weight += _weighted_segment(normalized, cursor, start) + weight += _TRANSFORMED_URL_LENGTH + cursor = end + return weight + _weighted_segment(normalized, cursor, len(normalized)) + + +def tweet_create_route(text): + # type: (str) -> Tuple[str, int] + """Return the create operation and weighted length for tweet text.""" + weighted_length = tweet_weighted_length(text) + operation = ( + CREATE_NOTE_TWEET_OPERATION + if weighted_length > STANDARD_TWEET_WEIGHT_LIMIT + else CREATE_TWEET_OPERATION + ) + return operation, weighted_length