From 5151bdece71832b3513dda758c5bc9dc07029d5a Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Thu, 11 Jun 2026 23:51:53 +0800 Subject: [PATCH] feat: publish articles from markdown --- README.md | 6 ++ tests/test_article.py | 53 ++++++++++++++ tests/test_cli.py | 77 +++++++++++++++++++++ tests/test_client.py | 95 +++++++++++++++++++++++++ twitter_cli/article.py | 154 +++++++++++++++++++++++++++++++++++++++++ twitter_cli/cli.py | 37 ++++++++++ twitter_cli/client.py | 81 ++++++++++++++++++++++ twitter_cli/graphql.py | 13 ++++ 8 files changed, 516 insertions(+) create mode 100644 tests/test_article.py create mode 100644 twitter_cli/article.py diff --git a/README.md b/README.md index e033cd2..87a83f6 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,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) - Quote: quote-tweet with optional images +- Articles: publish X Articles from Markdown files - Delete: remove your own tweets - Like / Unlike: manage tweet likes - Retweet / Unretweet: manage retweets @@ -133,6 +134,8 @@ twitter article 1234567890 twitter article https://x.com/user/article/1234567890 --json twitter article 1234567890 --markdown twitter article 1234567890 --output article.md +twitter article-publish draft.md --title "Article title" +twitter article-publish draft.md --title "Article title" --draft # List timeline twitter list 1539453138322673664 @@ -390,6 +393,7 @@ git clone git@github.com:jackwener/twitter-cli.git .agents/skills/twitter-cli **写入:** - 发推:发布新推文和回复,支持附带图片(最多 4 张,支持 JPEG/PNG/GIF/WebP) - 引用推文:带评论的转发,也支持附带图片 +- 文章:从 Markdown 文件发布 X Article - 删除:删除自己的推文 - 点赞 / 取消点赞 - 转推 / 取消转推 @@ -456,6 +460,8 @@ twitter article 1234567890 twitter article https://x.com/user/article/1234567890 --json twitter article 1234567890 --markdown twitter article 1234567890 --output article.md +twitter article-publish draft.md --title "文章标题" +twitter article-publish draft.md --title "文章标题" --draft # 列表时间线 twitter list 1539453138322673664 diff --git a/tests/test_article.py b/tests/test_article.py new file mode 100644 index 0000000..cd20088 --- /dev/null +++ b/tests/test_article.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from twitter_cli.article import article_markdown_to_content_state + + +def test_article_markdown_to_content_state_blocks_and_link_entity() -> None: + content_state = article_markdown_to_content_state( + "# Title\n\n" + "Paragraph with [docs](https://example.com) link.\n" + "- item\n" + "> quote\n" + "---\n" + "```python\n" + "print('hi')\n" + "```\n" + ) + + blocks = content_state["blocks"] + assert [block["type"] for block in blocks] == [ + "header-one", + "unstyled", + "unordered-list-item", + "blockquote", + "atomic", + "atomic", + ] + assert blocks[0]["text"] == "Title" + assert blocks[1]["text"] == "Paragraph with docs link." + + entities = content_state["entity_map"] + assert entities[0]["value"]["type"] == "LINK" + assert entities[0]["value"]["data"] == {"url": "https://example.com"} + assert blocks[1]["entity_ranges"] == [{"key": 0, "offset": 15, "length": 4}] + + assert entities[1]["value"]["type"] == "DIVIDER" + assert entities[2]["value"]["type"] == "MARKDOWN" + assert "print('hi')" in entities[2]["value"]["data"]["markdown"] + + +def test_article_markdown_to_content_state_empty_body_has_empty_block() -> None: + content_state = article_markdown_to_content_state("\n\n") + + assert content_state["entity_map"] == [] + assert content_state["blocks"] == [ + { + "data": {}, + "text": "", + "key": "48583", + "type": "unstyled", + "entity_ranges": [], + "inline_style_ranges": [], + } + ] diff --git a/tests/test_cli.py b/tests/test_cli.py index 5c6c58a..2f2c2b2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -364,6 +364,83 @@ def test_cli_article_rejects_compact_mode() -> None: assert "does not support --compact" in result.output +def test_cli_article_publish_json(monkeypatch, tmp_path) -> None: + markdown_path = tmp_path / "article.md" + markdown_path.write_text("# Heading\n\nBody with [link](https://example.com).", encoding="utf-8") + captured = {} + + class FakeClient: + def create_article(self, title, content_state, publish=True): + captured["title"] = title + captured["content_state"] = content_state + captured["publish"] = publish + return { + "id": "article-1", + "title": title, + "url": "https://x.com/i/article/article-1", + "published": publish, + } + + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "article-publish", + str(markdown_path), + "--title", + "Article title", + "--json", + ], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output[result.output.index("{"):]) + assert payload["data"]["id"] == "article-1" + assert payload["data"]["published"] is True + assert captured["title"] == "Article title" + assert captured["publish"] is True + assert captured["content_state"]["blocks"][0]["type"] == "header-one" + + +def test_cli_article_publish_draft_yaml(monkeypatch, tmp_path) -> None: + markdown_path = tmp_path / "article.md" + markdown_path.write_text("Draft body", encoding="utf-8") + captured = {} + + class FakeClient: + def create_article(self, title, content_state, publish=True): + captured["publish"] = publish + return { + "id": "draft-1", + "title": title, + "url": "https://x.com/compose/article/edit/draft-1", + "published": publish, + } + + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "article-publish", + str(markdown_path), + "--title", + "Draft title", + "--draft", + "--yaml", + ], + ) + + assert result.exit_code == 0 + payload = yaml.safe_load(result.output) + assert payload["data"]["id"] == "draft-1" + assert payload["data"]["published"] is False + assert captured["publish"] is False + + def test_cli_bookmark_alias_works(monkeypatch) -> None: calls = [] diff --git a/tests/test_client.py b/tests/test_client.py index c1393d3..afb9716 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1545,3 +1545,98 @@ def mock_post(operation_name, variables, features=None): assert captured.get("product") == "Latest" assert captured.get("querySource") == "typed_query" + + +class TestCreateArticle: + def _make_client(self): + client = TwitterClient.__new__(TwitterClient) + client._write_delay = lambda: None + return client + + def test_create_article_publishes_draft_with_content(self): + client = self._make_client() + calls = [] + + def mock_graphql_post(operation_name, variables, features=None): + calls.append((operation_name, variables)) + if operation_name == "ArticleEntityDraftCreate": + return { + "data": { + "articleentity_create_draft": { + "article_entity_results": { + "result": {"rest_id": "draft-1"} + } + } + } + } + if operation_name == "ArticleEntityPublish": + return { + "data": { + "articleentity_publish": { + "article_entity_results": { + "result": {"rest_id": "article-1"} + } + } + } + } + return {"data": {}} + + client._graphql_post = mock_graphql_post + + result = client.create_article( + "Title", + {"blocks": [{"text": "Body"}], "entity_map": []}, + publish=True, + ) + + assert result == { + "id": "article-1", + "title": "Title", + "url": "https://x.com/i/article/article-1", + "published": True, + } + assert [call[0] for call in calls] == [ + "ArticleEntityDraftCreate", + "ArticleEntityUpdateTitle", + "ArticleEntityUpdateContent", + "ArticleEntityPublish", + ] + assert calls[2][1] == { + "content_state": {"blocks": [{"text": "Body"}], "entity_map": []}, + "article_entity": "draft-1", + } + + def test_create_article_draft_skips_publish(self): + client = self._make_client() + calls = [] + + def mock_graphql_post(operation_name, variables, features=None): + calls.append(operation_name) + if operation_name == "ArticleEntityDraftCreate": + return { + "data": { + "articleentity_create_draft": { + "article_entity_results": { + "result": {"rest_id": "draft-2"} + } + } + } + } + return {"data": {}} + + client._graphql_post = mock_graphql_post + + result = client.create_article( + "Draft title", + {"blocks": [], "entity_map": []}, + publish=False, + ) + + assert result["id"] == "draft-2" + assert result["published"] is False + assert result["url"] == "https://x.com/compose/article/edit/draft-2" + assert calls == [ + "ArticleEntityDraftCreate", + "ArticleEntityUpdateTitle", + "ArticleEntityUpdateContent", + ] diff --git a/twitter_cli/article.py b/twitter_cli/article.py new file mode 100644 index 0000000..766a52b --- /dev/null +++ b/twitter_cli/article.py @@ -0,0 +1,154 @@ +"""Helpers for composing X Article content_state payloads.""" + +from __future__ import annotations + +import hashlib +import re +from typing import Any + + +DraftBlock = dict[str, Any] +DraftEntity = dict[str, Any] +DraftContentState = dict[str, Any] + + +def _block_key(seed: str, index: int) -> str: + return hashlib.sha1(("%s:%d" % (seed, index)).encode("utf-8")).hexdigest()[:5] + + +def _utf16_len(text: str) -> int: + return len(text.encode("utf-16-le")) // 2 + + +def _plain_block(block_type: str, text: str, key: str) -> DraftBlock: + return { + "data": {}, + "text": text, + "key": key, + "type": block_type, + "entity_ranges": [], + "inline_style_ranges": [], + } + + +def _atomic_block(entity_key: int, key: str) -> DraftBlock: + return { + "data": {}, + "text": " ", + "key": key, + "type": "atomic", + "entity_ranges": [{"key": entity_key, "offset": 0, "length": 1}], + "inline_style_ranges": [], + } + + +def _append_entity(content_state: DraftContentState, entity_type: str, mutability: str, data: dict[str, Any]) -> int: + entity_map = content_state["entity_map"] + key = len(entity_map) + entity_map.append( + { + "key": str(key), + "value": { + "data": data, + "type": entity_type, + "mutability": mutability, + }, + } + ) + return key + + +def _extract_links(text: str, content_state: DraftContentState) -> tuple[str, list[dict[str, int]]]: + ranges: list[dict[str, int]] = [] + output: list[str] = [] + cursor = 0 + for match in re.finditer(r"\[([^\]]+)\]\(([^)]+)\)", text): + output.append(text[cursor:match.start()]) + label = match.group(1) + url = match.group(2) + offset = _utf16_len("".join(output)) + output.append(label) + entity_key = _append_entity(content_state, "LINK", "Mutable", {"url": url}) + ranges.append({"key": entity_key, "offset": offset, "length": _utf16_len(label)}) + cursor = match.end() + output.append(text[cursor:]) + return "".join(output), ranges + + +def _strip_inline_markup(text: str) -> str: + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + text = re.sub(r"\*([^*]+)\*", r"\1", text) + text = re.sub(r"`([^`]+)`", r"\1", text) + return text + + +def article_markdown_to_content_state(markdown: str) -> DraftContentState: + """Convert a conservative Markdown subset into X Article content_state. + + Supported block forms: paragraphs, #/## headings, unordered and ordered + list items, blockquotes, horizontal rules, fenced code blocks, and inline + Markdown links. Unsupported Markdown is emitted as plain paragraph text. + """ + content_state: DraftContentState = {"blocks": [], "entity_map": []} + lines = markdown.splitlines() + i = 0 + block_index = 0 + + while i < len(lines): + raw = lines[i].rstrip() + stripped = raw.strip() + i += 1 + if not stripped: + continue + + if stripped.startswith("```"): + code_lines: list[str] = [] + opening = stripped + while i < len(lines): + code_line = lines[i].rstrip() + i += 1 + if code_line.strip().startswith("```"): + break + code_lines.append(code_line) + markdown_block = opening + "\n" + "\n".join(code_lines) + "\n```" + entity_key = _append_entity(content_state, "MARKDOWN", "Mutable", {"markdown": markdown_block}) + content_state["blocks"].append(_atomic_block(entity_key, _block_key(markdown_block, block_index))) + block_index += 1 + continue + + if stripped == "---": + entity_key = _append_entity(content_state, "DIVIDER", "Immutable", {}) + content_state["blocks"].append(_atomic_block(entity_key, _block_key(stripped, block_index))) + block_index += 1 + continue + + block_type = "unstyled" + text = stripped + if stripped.startswith("# "): + block_type = "header-one" + text = stripped[2:].strip() + elif stripped.startswith("## "): + block_type = "header-two" + text = stripped[3:].strip() + elif stripped.startswith("> "): + block_type = "blockquote" + text = stripped[2:].strip() + elif stripped.startswith(("- ", "* ")): + block_type = "unordered-list-item" + text = stripped[2:].strip() + else: + ordered = re.match(r"^\d+\.\s+(.+)$", stripped) + if ordered: + block_type = "ordered-list-item" + text = ordered.group(1).strip() + + text, entity_ranges = _extract_links(_strip_inline_markup(text), content_state) + block = _plain_block(block_type, text, _block_key(text, block_index)) + block["entity_ranges"] = entity_ranges + content_state["blocks"].append(block) + block_index += 1 + + if not content_state["blocks"]: + content_state["blocks"].append(_plain_block("unstyled", "", _block_key("", 0))) + + return content_state diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index d2dc523..6c7b65b 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -13,6 +13,7 @@ twitter likes elonmusk # user likes twitter tweet # tweet detail + replies twitter article # Twitter Article as Markdown + twitter article-publish file.md # publish an X Article from Markdown twitter list # list timeline twitter followers # followers list twitter following # following list @@ -46,6 +47,7 @@ import yaml from . import __version__ +from .article import article_markdown_to_content_state from .auth import get_cookies from .cache import resolve_cached_tweet, save_tweet_cache from .exceptions import TwitterError @@ -1005,6 +1007,41 @@ def article(ctx, tweet_id, as_json, as_yaml, as_markdown, output_file): console.print() +@cli.command(name="article-publish") +@click.argument("markdown_file", type=click.Path(exists=True, dir_okay=False)) +@click.option("--title", "-t", required=True, help="Article title.") +@click.option("--draft", is_flag=True, help="Create a draft without publishing.") +@structured_output_options +def article_publish(markdown_file, title, draft, as_json, as_yaml): + # type: (str, str, bool, bool, bool) -> None + """Publish an X Article from a Markdown file.""" + markdown = Path(markdown_file).read_text(encoding="utf-8") + content_state = article_markdown_to_content_state(markdown) + + def operation(client: TwitterClient) -> WritePayload: + result = client.create_article(title, content_state, publish=not draft) + return { + "success": True, + "action": "article_publish", + "id": result["id"], + "title": result["title"], + "url": result["url"], + "published": result["published"], + } + + action = "Creating article draft" if draft else "Publishing article" + payload = _run_write_command( + as_json=as_json, + as_yaml=as_yaml, + operation=operation, + progress_lines=["📝 %s..." % action], + success_lines=["[green]✅ Article %s![/green]" % ("draft created" if draft else "published")], + error_details={"action": "article_publish", "title": title, "draft": draft}, + ) + if payload and not _structured_mode(as_json=as_json, as_yaml=as_yaml): + console.print("🔗 %s" % payload["url"]) + + @cli.command(name="list") @click.argument("list_id") @click.option("--max", "-n", "max_count", type=int, default=None, help="Max tweets to fetch.") diff --git a/twitter_cli/client.py b/twitter_cli/client.py index 0436c8e..6927928 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -40,6 +40,7 @@ TwitterAPIError, ) from .graphql import ( + ARTICLE_FEATURES, FALLBACK_QUERY_IDS, FEATURES, _build_graphql_url, @@ -437,6 +438,86 @@ def fetch_article(self, tweet_id): logger.info("fetch_article: tweet_id=%s", tweet_id) return tweet + def create_article(self, title, content_state, publish=True): + # type: (str, Dict[str, Any], bool) -> Dict[str, Any] + """Create an X Article draft, optionally publish it, and return metadata.""" + if not title.strip(): + raise TwitterAPIError(0, "Article title is required") + + article_id = self._create_article_draft() + self._update_article_title(article_id, title) + self._update_article_content(article_id, content_state) + if publish: + published_id = self._publish_article(article_id) + if published_id: + article_id = published_id + url = "https://x.com/i/article/%s" % article_id + else: + url = "https://x.com/compose/article/edit/%s" % article_id + + self._write_delay() + return { + "id": article_id, + "title": title, + "url": url, + "published": bool(publish), + } + + def _create_article_draft(self): + # type: () -> str + variables = { + "content_state": {"blocks": [], "entity_map": []}, + "title": "", + } + data = self._graphql_post("ArticleEntityDraftCreate", variables, ARTICLE_FEATURES) + result = _deep_get( + data, + "data", + "articleentity_create_draft", + "article_entity_results", + "result", + ) + if result and result.get("rest_id"): + return result["rest_id"] + raise TwitterAPIError(0, "Failed to create article draft") + + def _update_article_title(self, article_id, title): + # type: (str, str) -> None + variables = { + "articleEntityId": article_id, + "title": title, + } + self._graphql_post("ArticleEntityUpdateTitle", variables, ARTICLE_FEATURES) + + def _update_article_content(self, article_id, content_state): + # type: (str, Dict[str, Any]) -> None + variables = { + "content_state": { + "blocks": content_state.get("blocks") or [], + "entity_map": content_state.get("entity_map") or [], + }, + "article_entity": article_id, + } + self._graphql_post("ArticleEntityUpdateContent", variables, ARTICLE_FEATURES) + + def _publish_article(self, article_id): + # type: (str) -> Optional[str] + variables = { + "articleEntityId": article_id, + "visibilitySetting": "Public", + } + data = self._graphql_post("ArticleEntityPublish", variables, ARTICLE_FEATURES) + result = _deep_get( + data, + "data", + "articleentity_publish", + "article_entity_results", + "result", + ) + if result: + return result.get("rest_id") + return None + def fetch_list_timeline(self, list_id, count=20, cursor=None, return_cursor=False): # type: (str, int, Optional[str], bool) -> Any """Fetch tweets from a Twitter List.""" diff --git a/twitter_cli/graphql.py b/twitter_cli/graphql.py index d34ea35..2c5284f 100644 --- a/twitter_cli/graphql.py +++ b/twitter_cli/graphql.py @@ -49,6 +49,10 @@ "TweetResultByRestId": "7xflPyRiUxGVbJd4uWmbfg", "BookmarkFoldersSlice": "i78YDd0Tza-dV4SYs58kRg", "BookmarkFolderTimeline": "hNY7X2xE2N7HVF6Qb_mu6w", + "ArticleEntityDraftCreate": "g1l5N8BxGewYuCy5USe_bQ", + "ArticleEntityUpdateTitle": "x75E2ABzm8_mGTg1bz8hcA", + "ArticleEntityUpdateContent": "M7N2FrPrlOmu-YrVIBxFnQ", + "ArticleEntityPublish": "m4SHicYMoWO_qkLvjhDk7Q", } # ── Default feature flags ──────────────────────────────────────────────── @@ -78,6 +82,15 @@ # Features dict that gets updated dynamically from x.com JS bundles FEATURES = dict(_DEFAULT_FEATURES) +ARTICLE_FEATURES = { + "profile_label_improvements_pcf_label_in_post_enabled": True, + "responsive_web_profile_redirect_enabled": False, + "rweb_tipjar_consumption_enabled": False, + "verified_phone_label_enabled": False, + "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False, + "responsive_web_graphql_timeline_navigation_enabled": True, +} + # Module-level caches (not thread-safe — CLI is single-threaded) _cached_query_ids: Dict[str, str] = {} _bundles_scanned = False