Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
- 删除:删除自己的推文
- 点赞 / 取消点赞
- 转推 / 取消转推
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions tests/test_article.py
Original file line number Diff line number Diff line change
@@ -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": [],
}
]
Comment on lines +44 to +53
77 changes: 77 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand Down
95 changes: 95 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Loading