Skip to content
Draft
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ twitter search "trending" --filter # Apply ranking filter
# Tweet detail (view tweet + replies)
twitter tweet 1234567890
twitter tweet 1234567890 --full-text
twitter tweet 1234567890 --markdown # Save tweet + replies as Markdown in the current folder
twitter tweet https://x.com/user/status/1234567890

# Open tweet by index from last list output
Expand Down Expand Up @@ -445,6 +446,7 @@ twitter search "trending" --filter # 启用排序筛选
# 推文详情
twitter tweet 1234567890
twitter tweet 1234567890 --full-text
twitter tweet 1234567890 --markdown # 将推文和回复保存为 Markdown 到当前文件夹

# 通过序号打开上次列表里的推文
twitter show 2 # 打开上次 feed/search 的第 2 条
Expand Down
146 changes: 142 additions & 4 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,68 @@

import json
import time
from pathlib import Path

from click.testing import CliRunner
import pytest
from rich.console import Console
import yaml
from click.testing import CliRunner
from rich.console import Console

from twitter_cli.cli import cli
from twitter_cli.formatter import article_to_markdown, print_tweet_table
from twitter_cli.models import Author, BookmarkFolder, Metrics, Tweet, UserProfile
from twitter_cli.formatter import article_to_markdown, print_tweet_table, tweet_thread_to_markdown
from twitter_cli.models import Author, BookmarkFolder, Metrics, Tweet, TweetMedia, UserProfile
from twitter_cli.serialization import tweets_to_json


def test_tweet_thread_to_markdown_contains_only_requested_fields() -> None:
quoted = Tweet(
id="900",
text="Quoted text must not appear",
author=Author(id="u9", name="Quoted", screen_name="quoted"),
metrics=Metrics(likes=99, views=999),
created_at="2026-08-01",
)
main = Tweet(
id="100",
text="Main tweet https://t.co/quote",
author=Author(
id="u1",
name="Alice",
screen_name="alice",
profile_image_url="https://images.example/alice-avatar.jpg",
),
metrics=Metrics(likes=10, views=100),
created_at="2026-08-02",
media=[TweetMedia(type="photo", url="https://images.example/main.jpg")],
quoted_tweet=quoted,
)
reply = Tweet(
id="101",
text="Reply text",
author=Author(
id="u2",
name="Bob",
screen_name="bob",
profile_image_url="https://images.example/bob-avatar.jpg",
),
metrics=Metrics(likes=20, views=200),
created_at="2026-08-03",
media=[TweetMedia(type="photo", url="https://images.example/reply.jpg")],
)

assert tweet_thread_to_markdown([main, reply]) == (
"# Tweet\n\n"
"## @alice\n\n"
"Main tweet\n\n"
"![](https://images.example/main.jpg)\n\n"
"https://x.com/quoted/status/900\n\n"
"## Replies\n\n"
"### @bob\n\n"
"Reply text\n\n"
"![](https://images.example/reply.jpg)\n"
)


def test_cli_user_command_works_with_client_factory(monkeypatch) -> None:
class FakeClient:
def fetch_user(self, screen_name: str) -> UserProfile:
Expand Down Expand Up @@ -232,6 +282,94 @@ def fetch_tweet_detail(self, tweet_id: str, max_count: int):
assert result.exit_code == 0


def test_cli_tweet_invalid_max_reports_friendly_error() -> None:
result = CliRunner().invoke(cli, ["tweet", "100", "--max", "0"])

assert result.exit_code == 1
assert "--max must be greater than 0" in result.output
assert type(result.exception).__name__ == "SystemExit"


def test_cli_tweet_markdown_saves_first_sentence_with_numbered_collision(
monkeypatch,
tweet_factory,
) -> None:
tweets = [
tweet_factory("100", text="First / sentence? Second sentence."),
tweet_factory(
"101",
text="A reply",
author=Author(id="u2", name="Bob", screen_name="bob"),
),
]

class FakeClient:
def fetch_tweet_detail(self, tweet_id: str, max_count: int):
assert tweet_id == "100"
assert max_count == 7
return tweets

monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
monkeypatch.setattr(
"twitter_cli.cli.load_config",
lambda: {
"fetch": {"count": 50},
"filter": {},
"rateLimit": {"maxCount": 200},
},
)
runner = CliRunner()

with runner.isolated_filesystem():
original = Path("First - sentence.md")
original.write_text("keep me", encoding="utf-8")

result = runner.invoke(cli, ["tweet", "100", "--markdown", "--max", "7"])

exported = Path("First - sentence (2).md")
assert result.exit_code == 0
assert original.read_text(encoding="utf-8") == "keep me"
assert exported.read_text(encoding="utf-8") == tweet_thread_to_markdown(tweets)
assert str(exported.resolve()) in result.output


def test_cli_tweet_markdown_uses_rate_limit_max_count_by_default(monkeypatch, tweet_factory) -> None:
received_counts = []

class FakeClient:
def fetch_tweet_detail(self, tweet_id: str, max_count: int):
assert tweet_id == "100"
received_counts.append(max_count)
return [tweet_factory("100", text="Default count")]

monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
monkeypatch.setattr(
"twitter_cli.cli.load_config",
lambda: {"fetch": {"count": 50}, "filter": {}, "rateLimit": {"maxCount": 200}},
)

with CliRunner().isolated_filesystem():
result = CliRunner().invoke(cli, ["tweet", "100", "--markdown"])

assert result.exit_code == 0
assert received_counts == [200]


@pytest.mark.parametrize(
"args",
[
["tweet", "100", "--markdown", "--json"],
["tweet", "100", "--markdown", "--yaml"],
["-c", "tweet", "100", "--markdown"],
],
)
def test_cli_tweet_markdown_rejects_other_output_modes(args) -> None:
result = CliRunner().invoke(cli, args)

assert result.exit_code == 2
assert "does not combine" in result.output


def test_cli_article_accepts_article_url_and_json(monkeypatch) -> None:
class FakeClient:
def fetch_article(self, tweet_id: str) -> Tweet:
Expand Down
57 changes: 53 additions & 4 deletions twitter_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
print_tweet_table,
print_user_profile,
print_user_table,
tweet_thread_to_markdown,
)
from .models import Tweet, UserProfile
from .output import (
Expand Down Expand Up @@ -849,31 +850,79 @@ def _run():
_run_guarded(_run)


def _tweet_markdown_path(tweet: Tweet) -> Path:
"""Build a safe, non-overwriting Markdown path from the first sentence."""
text = tweet.text.strip()
end = re.search(r"[.!?。!?]|\n", text)
sentence = text[: end.start() + (0 if end.group() == "\n" else 1)] if end else text
sentence = sentence.rstrip(".!?。!?")
stem = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "-", sentence)
stem = re.sub(r"\s+", " ", stem).strip(" .")
stem = stem.encode("utf-8")[:200].decode("utf-8", "ignore").rstrip(" .")
stem = stem or f"Tweet {tweet.id}"

path = Path.cwd() / f"{stem}.md"
number = 2
while path.exists():
path = Path.cwd() / f"{stem} ({number}).md"
number += 1
return path


@cli.command()
@click.argument("tweet_id")
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max replies to fetch.")
@click.option("--full-text", is_flag=True, help="Show full reply text in table output.")
@click.option(
"--markdown",
"as_markdown",
is_flag=True,
help="Save tweet and replies as Markdown in the current directory.",
)
@structured_output_options
@click.pass_context
def tweet(ctx, tweet_id, max_count, full_text, as_json, as_yaml):
# type: (Any, str, int, bool, bool, bool) -> None
def tweet(ctx, tweet_id, max_count, full_text, as_markdown, as_json, as_yaml):
# type: (Any, str, int, bool, bool, bool, bool) -> None
"""View a tweet and its replies. TWEET_ID is the numeric tweet ID or full URL."""
compact = ctx.obj.get("compact", False)
if as_markdown and (as_json or as_yaml or compact):
raise click.UsageError("--markdown does not combine with --json, --yaml, or --compact.")
tweet_id = _normalize_tweet_id(tweet_id)
config = load_config()
rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact)
rich_output = (
use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact)
and not as_markdown
)
default_count = (
config.get("rateLimit", {}).get("maxCount", 200)
if as_markdown
else config.get("fetch", {}).get("count", 50)
)
try:
fetch_count = _resolve_fetch_count(max_count, default_count)
client = _get_client(config, quiet=not rich_output)
if rich_output:
console.print("🐦 Fetching tweet %s...\n" % tweet_id)
start = time.time()
tweets = client.fetch_tweet_detail(tweet_id, _resolve_configured_count(config, max_count))
tweets = client.fetch_tweet_detail(tweet_id, fetch_count)
elapsed = time.time() - start
if rich_output:
console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed))
except (TwitterError, RuntimeError) as exc:
_exit_with_error(exc)

if as_markdown:
if not tweets:
raise click.ClickException("Tweet not found.")
output_path = _tweet_markdown_path(tweets[0])
try:
with output_path.open("x", encoding="utf-8") as output:
output.write(tweet_thread_to_markdown(tweets))
except OSError as exc:
raise click.ClickException(f"Could not save Markdown: {exc}")
click.echo(f"Saved Markdown to {output_path}")
return

_emit_tweet_detail(tweets, compact=compact, as_json=as_json, as_yaml=as_yaml, full_text=full_text)


Expand Down
37 changes: 37 additions & 0 deletions twitter_cli/formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import re
import sys
from typing import List, Optional

Expand Down Expand Up @@ -169,6 +170,42 @@ def print_tweet_detail(tweet: Tweet, console: Optional[Console] = None) -> None:
))


def _tweet_markdown_lines(tweet: Tweet, heading: str) -> list[str]:
"""Render one tweet without metadata or downloaded media."""
text = tweet.text.strip()
if tweet.quoted_tweet:
text = re.sub(r"\s*https://t\.co/[A-Za-z0-9_]+\s*$", "", text).rstrip()

lines = [f"{heading} @{tweet.author.screen_name}"]
if text:
lines.extend(["", text])
for media in tweet.media:
if media.type == "photo" and media.url:
lines.extend(["", f"![]({media.url})"])
if tweet.quoted_tweet:
quoted = tweet.quoted_tweet
lines.extend([
"",
f"https://x.com/{quoted.author.screen_name}/status/{quoted.id}",
])
return lines


def tweet_thread_to_markdown(tweets: list[Tweet]) -> str:
"""Convert a tweet and its replies into a Markdown document."""
if not tweets:
return ""

lines = ["# Tweet", ""]
lines.extend(_tweet_markdown_lines(tweets[0], "##"))
if len(tweets) > 1:
lines.extend(["", "## Replies"])
for reply in tweets[1:]:
lines.append("")
lines.extend(_tweet_markdown_lines(reply, "###"))
return "\n".join(lines).strip() + "\n"


def article_to_markdown(tweet: Tweet) -> str:
"""Convert a Twitter Article tweet into a Markdown document."""
title = tweet.article_title or "Twitter Article"
Expand Down