diff --git a/README.md b/README.md index 8980ee1..6b39d35 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,12 @@ Browser extraction is recommended — it forwards ALL Twitter cookies (not just TWITTER_CHROME_PROFILE="Profile 2" twitter feed ``` +**Browser priority:** If you have multiple browsers, set `TWITTER_BROWSER` to try a specific browser first: + +```bash +TWITTER_BROWSER=chrome twitter feed # Supported: arc, chrome, edge, firefox, brave +``` + After loading cookies, the CLI performs lightweight verification. Commands that require account access fail fast on clear auth errors (`401/403`). ### Proxy Support @@ -491,6 +497,12 @@ twitter follow elonmusk --json TWITTER_CHROME_PROFILE="Profile 2" twitter feed ``` +**浏览器优先级**:如果有多个浏览器,可通过 `TWITTER_BROWSER` 指定优先尝试的浏览器: + +```bash +TWITTER_BROWSER=chrome twitter feed # 支持: arc, chrome, edge, firefox, brave +``` + ### 代理支持 设置 `TWITTER_PROXY` 环境变量即可: diff --git a/pyproject.toml b/pyproject.toml index 67f08a2..4a9185b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "twitter-cli" -version = "0.8.3" +version = "0.8.4" description = "A CLI for Twitter/X — feed, bookmarks, and user timeline in terminal" readme = "README.md" license = "Apache-2.0" diff --git a/tests/test_cli.py b/tests/test_cli.py index 0d535ce..0cfddaa 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,22 +19,12 @@ def test_cli_user_command_works_with_client_factory(monkeypatch) -> None: def fetch_user(self, screen_name: str) -> UserProfile: return UserProfile(id="1", name="Alice", screen_name=screen_name) - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) runner = CliRunner() result = runner.invoke(cli, ["user", "alice"]) assert result.exit_code == 0 -def test_get_client_for_output_does_not_swallow_real_type_error(monkeypatch) -> None: - def _broken_get_client(config=None, quiet=False): - raise TypeError("real bug") - - monkeypatch.setattr("twitter_cli.cli._get_client", _broken_get_client) - - with pytest.raises(TypeError, match="real bug"): - from twitter_cli.cli import _get_client_for_output - - _get_client_for_output({}) def test_cli_feed_json_input_path(tmp_path, tweet_factory) -> None: @@ -102,7 +92,7 @@ def test_print_tweet_table_full_text_shows_untruncated_text(tweet_factory) -> No def test_cli_commands_wrap_client_creation_errors(monkeypatch, args) -> None: monkeypatch.setattr( "twitter_cli.cli._get_client", - lambda config=None: (_ for _ in ()).throw(RuntimeError("boom")), + lambda config=None, quiet=False: (_ for _ in ()).throw(RuntimeError("boom")), ) runner = CliRunner() @@ -117,7 +107,7 @@ def test_cli_user_error_yaml(monkeypatch) -> None: monkeypatch.setenv("OUTPUT", "auto") monkeypatch.setattr( "twitter_cli.cli._get_client", - lambda config=None: (_ for _ in ()).throw(RuntimeError("User not found")), + lambda config=None, quiet=False: (_ for _ in ()).throw(RuntimeError("User not found")), ) runner = CliRunner() @@ -136,7 +126,7 @@ def test_cli_tweet_accepts_shared_url_with_query(monkeypatch) -> None: assert max_count == 50 return [] - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + 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": {}}, @@ -162,7 +152,7 @@ def test_cli_article_accepts_article_url_and_json(monkeypatch) -> None: article_text="Hello\n\n## Section", ) - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + 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": {}}, @@ -195,7 +185,7 @@ def test_cli_article_markdown_output_and_save(monkeypatch, tmp_path) -> None: assert tweet_id == "12345" return article - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) monkeypatch.setattr("twitter_cli.cli.load_config", lambda: {}) output_path = tmp_path / "article.md" runner = CliRunner() @@ -227,7 +217,7 @@ def test_cli_article_markdown_overrides_auto_structured_output(monkeypatch) -> N return article monkeypatch.setenv("OUTPUT", "auto") - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) monkeypatch.setattr("twitter_cli.cli.load_config", lambda: {}) runner = CliRunner() @@ -254,7 +244,7 @@ def test_cli_bookmark_alias_works(monkeypatch) -> None: calls.append(tweet_id) return True - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) runner = CliRunner() result = runner.invoke(cli, ["bookmark", "123"]) @@ -270,7 +260,7 @@ def test_cli_whoami_command(monkeypatch) -> None: def fetch_me(self) -> UserProfile: return UserProfile(id="42", name="Test User", screen_name="testuser") - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) runner = CliRunner() result = runner.invoke(cli, ["whoami"]) @@ -289,7 +279,7 @@ def test_cli_whoami_auto_yaml(monkeypatch) -> None: return UserProfile(id="42", name="Test User", screen_name="testuser") monkeypatch.setenv("OUTPUT", "auto") - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) runner = CliRunner() result = runner.invoke(cli, ["whoami"]) @@ -307,7 +297,7 @@ def test_cli_status_auto_yaml(monkeypatch) -> None: return UserProfile(id="42", name="Test User", screen_name="testuser") monkeypatch.setenv("OUTPUT", "auto") - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) runner = CliRunner() result = runner.invoke(cli, ["status"]) @@ -328,7 +318,7 @@ def test_cli_reply_command(monkeypatch) -> None: calls.append({"text": text, "reply_to_id": reply_to_id}) return "999" - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) runner = CliRunner() result = runner.invoke(cli, ["reply", "12345", "Nice tweet!"]) @@ -345,7 +335,7 @@ def test_cli_quote_command(monkeypatch) -> None: calls.append({"tweet_id": tweet_id, "text": text}) return "888" - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) runner = CliRunner() result = runner.invoke(cli, ["quote", "12345", "Interesting!"]) @@ -361,7 +351,7 @@ def test_cli_post_json_output(monkeypatch) -> None: assert reply_to_id is None return "999" - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) runner = CliRunner() result = runner.invoke(cli, ["post", "hello", "--json"]) @@ -378,7 +368,7 @@ def test_cli_like_yaml_output(monkeypatch) -> None: assert tweet_id == "123" return True - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) runner = CliRunner() result = runner.invoke(cli, ["like", "123", "--yaml"]) @@ -399,7 +389,7 @@ def test_cli_follow_json_output(monkeypatch) -> None: assert user_id == "42" return True - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) runner = CliRunner() result = runner.invoke(cli, ["follow", "alice", "--json"]) @@ -421,7 +411,7 @@ def test_cli_follow_command(monkeypatch) -> None: actions.append(("follow", user_id)) return True - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) runner = CliRunner() result = runner.invoke(cli, ["follow", "alice"]) @@ -440,7 +430,7 @@ def test_cli_unfollow_command(monkeypatch) -> None: actions.append(("unfollow", user_id)) return True - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) runner = CliRunner() result = runner.invoke(cli, ["unfollow", "alice"]) @@ -457,7 +447,7 @@ def test_cli_search_advanced_options(monkeypatch) -> None: captured["product"] = product return [] - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + 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": {}}, @@ -492,7 +482,7 @@ def test_cli_search_operators_only_no_query(monkeypatch) -> None: captured["query"] = query return [] - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + 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": {}}, @@ -513,7 +503,7 @@ def test_cli_search_empty_query_no_options() -> None: def test_cli_search_invalid_date_rejected(monkeypatch) -> None: - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: None) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: None) runner = CliRunner() result = runner.invoke(cli, ["search", "python", "--since", "not-a-date"]) @@ -522,7 +512,7 @@ def test_cli_search_invalid_date_rejected(monkeypatch) -> None: def test_cli_search_rejects_reversed_date_range(monkeypatch) -> None: - monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: None) + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: None) runner = CliRunner() result = runner.invoke(cli, ["search", "python", "--since", "2026-03-02", "--until", "2026-03-01"]) diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index 7144ef1..ed33949 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -32,7 +32,6 @@ from __future__ import annotations import logging import re -import inspect import sys import time import urllib.parse @@ -153,17 +152,7 @@ def _get_client(config=None, quiet=False): ) -def _get_client_for_output(config=None, quiet=False): - # type: (Optional[Dict[str, Any]], bool) -> TwitterClient - """Call _get_client while staying compatible with monkeypatched legacy signatures.""" - try: - signature = inspect.signature(_get_client) - except (TypeError, ValueError): - signature = None - if signature and "quiet" in signature.parameters: - return _get_client(config, quiet=quiet) - return _get_client(config) def _exit_with_error(exc): @@ -417,7 +406,7 @@ def feed(ctx, feed_type, max_count, as_json, as_yaml, input_file, output_file, d console.print(" Loaded %d tweets" % len(tweets)) else: fetch_count = _resolve_configured_count(config, max_count) - client = _get_client_for_output(config, quiet=not rich_output) + client = _get_client(config, quiet=not rich_output) label = "following feed" if feed_type == "following" else "home timeline" if rich_output: console.print("📡 Fetching %s (%d tweets)...\n" % (label, fetch_count)) @@ -507,7 +496,7 @@ def user(screen_name, as_json, as_yaml): config = load_config() try: rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml) - client = _get_client_for_output(config, quiet=not rich_output) + client = _get_client(config, quiet=not rich_output) if rich_output: console.print("👤 Fetching user @%s..." % screen_name) profile = client.fetch_user(screen_name) @@ -534,7 +523,7 @@ def user_posts(ctx, screen_name, max_count, as_json, as_yaml, output_file, full_ config = load_config() def _run(): rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact) - client = _get_client_for_output(config, quiet=not rich_output) + client = _get_client(config, quiet=not rich_output) if rich_output: console.print("👤 Fetching @%s's profile..." % screen_name) profile = client.fetch_user(screen_name) @@ -619,7 +608,7 @@ def search(ctx, query, product, from_user, to_user, lang, since, until, has, exc config = load_config() def _run(): rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact) - client = _get_client_for_output(config, quiet=not rich_output) + client = _get_client(config, quiet=not rich_output) _fetch_and_display( lambda count: client.fetch_search(composed_query, count, product), "'%s' (%s)" % (composed_query, product), "🔍", max_count, as_json, as_yaml, output_file, do_filter, config, @@ -648,7 +637,7 @@ def likes(ctx, screen_name, max_count, as_json, as_yaml, output_file, do_filter, config = load_config() def _run(): rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact) - client = _get_client_for_output(config, quiet=not rich_output) + client = _get_client(config, quiet=not rich_output) if rich_output: console.print("👤 Fetching @%s's profile..." % screen_name) profile = client.fetch_user(screen_name) @@ -694,7 +683,7 @@ def tweet(ctx, tweet_id, max_count, full_text, as_json, as_yaml): config = load_config() rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact) try: - client = _get_client_for_output(config, quiet=not rich_output) + client = _get_client(config, quiet=not rich_output) if rich_output: console.print("🐦 Fetching tweet %s...\n" % tweet_id) start = time.time() @@ -758,7 +747,7 @@ def show(ctx, index, max_count, full_text, output_file, as_json, as_yaml): config = load_config() rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact) try: - client = _get_client_for_output(config, quiet=not rich_output) + client = _get_client(config, quiet=not rich_output) if rich_output: console.print("🐦 Fetching tweet #%d (id: %s)...\n" % (index, tweet_id)) start = time.time() @@ -796,7 +785,7 @@ def article(ctx, tweet_id, as_json, as_yaml, as_markdown, output_file): config = load_config() rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=False) and not as_markdown try: - client = _get_client_for_output(config, quiet=not rich_output) + client = _get_client(config, quiet=not rich_output) if rich_output: console.print("📰 Fetching article %s...\n" % tweet_id) start = time.time() @@ -856,7 +845,7 @@ def followers(screen_name, max_count, as_json, as_yaml): config = load_config() try: rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml) - client = _get_client_for_output(config, quiet=not rich_output) + client = _get_client(config, quiet=not rich_output) if rich_output: console.print("👤 Fetching @%s's profile..." % screen_name) profile = client.fetch_user(screen_name) @@ -889,7 +878,7 @@ def following(screen_name, max_count, as_json, as_yaml): config = load_config() try: rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml) - client = _get_client_for_output(config, quiet=not rich_output) + client = _get_client(config, quiet=not rich_output) if rich_output: console.print("👤 Fetching @%s's profile..." % screen_name) profile = client.fetch_user(screen_name) @@ -1062,7 +1051,7 @@ def status(as_json, as_yaml): config = load_config() try: rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml) - client = _get_client_for_output(config, quiet=not rich_output) + client = _get_client(config, quiet=not rich_output) profile = client.fetch_me() except RuntimeError as exc: payload = error_payload("not_authenticated", str(exc)) @@ -1087,7 +1076,7 @@ def whoami(as_json, as_yaml): config = load_config() try: rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml) - client = _get_client_for_output(config, quiet=not rich_output) + client = _get_client(config, quiet=not rich_output) if rich_output: console.print("👤 Fetching current user...") profile = client.fetch_me() diff --git a/twitter_cli/constants.py b/twitter_cli/constants.py index ea8b0de..b81e0ce 100644 --- a/twitter_cli/constants.py +++ b/twitter_cli/constants.py @@ -76,8 +76,6 @@ def get_accept_language(): # type: () -> str tag = _get_locale_tag() language = tag.split("-", 1)[0] or "en" - if tag == language: - return "%s,%s;q=0.9,en;q=0.8" % (tag, language) return "%s,%s;q=0.9,en;q=0.8" % (tag, language) diff --git a/uv.lock b/uv.lock index 2a56556..e2d6d70 100644 --- a/uv.lock +++ b/uv.lock @@ -609,7 +609,7 @@ wheels = [ [[package]] name = "twitter-cli" -version = "0.8.3" +version = "0.8.4" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },