feat: improve cookie extraction diagnostics and add doctor command
- Add _diagnose_keychain_issues() for macOS Keychain/SSH detection - Extraction functions now return (cookies, diagnostics) tuples - Error messages include actionable Keychain hints (e.g. unlock-keychain) - Add 'twitter doctor' diagnostic command for troubleshooting - Enhance bug_report.yml with browser/access method/diagnostics fields - Expand README troubleshooting (EN+CN) with Keychain/SSH solutions - Add 5 new tests for Keychain diagnostics Closes #11
This commit is contained in:
31
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
31
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -21,6 +21,31 @@ body:
|
|||||||
- Other
|
- Other
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: browser
|
||||||
|
attributes:
|
||||||
|
label: Browser (for cookie extraction)
|
||||||
|
options:
|
||||||
|
- Arc
|
||||||
|
- Chrome
|
||||||
|
- Edge
|
||||||
|
- Firefox
|
||||||
|
- Brave
|
||||||
|
- Other / N/A (using env vars)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: access_method
|
||||||
|
attributes:
|
||||||
|
label: Access Method
|
||||||
|
description: How are you accessing the machine where twitter-cli runs?
|
||||||
|
options:
|
||||||
|
- Local terminal
|
||||||
|
- SSH
|
||||||
|
- Docker / container
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
@@ -41,6 +66,12 @@ body:
|
|||||||
label: Steps to reproduce
|
label: Steps to reproduce
|
||||||
description: "Commands or steps to reproduce the issue. Use `twitter -v <command>` for debug output."
|
description: "Commands or steps to reproduce the issue. Use `twitter -v <command>` for debug output."
|
||||||
render: bash
|
render: bash
|
||||||
|
- type: textarea
|
||||||
|
id: diagnostics
|
||||||
|
attributes:
|
||||||
|
label: Diagnostics
|
||||||
|
description: "Paste the output of `twitter doctor` here. This helps us diagnose cookie and auth issues quickly."
|
||||||
|
render: text
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: logs
|
id: logs
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -241,6 +241,14 @@ Mode behavior:
|
|||||||
- `Cookie expired or invalid (HTTP 401/403)`
|
- `Cookie expired or invalid (HTTP 401/403)`
|
||||||
- Re-login to `x.com` and retry.
|
- Re-login to `x.com` and retry.
|
||||||
|
|
||||||
|
- `Unable to get key for cookie decryption` (macOS Keychain)
|
||||||
|
- **SSH sessions**: Keychain is locked by default over SSH. Run:
|
||||||
|
```bash
|
||||||
|
security unlock-keychain ~/Library/Keychains/login.keychain-db
|
||||||
|
```
|
||||||
|
- **Local terminal**: Open **Keychain Access** → search for **"\<Browser\> Safe Storage"** → **Access Control** → add your Terminal app → **Save Changes**.
|
||||||
|
- Or click **"Always Allow"** when the Keychain authorization popup appears.
|
||||||
|
|
||||||
- `Twitter API error 404`
|
- `Twitter API error 404`
|
||||||
- This can happen when upstream GraphQL query IDs rotate.
|
- This can happen when upstream GraphQL query IDs rotate.
|
||||||
- Retry the command; the client attempts a live queryId fallback.
|
- Retry the command; the client attempts a live queryId fallback.
|
||||||
@@ -248,6 +256,8 @@ Mode behavior:
|
|||||||
- `Invalid tweet JSON file`
|
- `Invalid tweet JSON file`
|
||||||
- Regenerate input using `twitter feed --json > tweets.json`.
|
- Regenerate input using `twitter feed --json > tweets.json`.
|
||||||
|
|
||||||
|
**Diagnostics command**: Run `twitter doctor` to output a full diagnostic report (version, OS, browser detection, Keychain status, cookie extraction results). Paste this output into bug reports.
|
||||||
|
|
||||||
Structured error codes commonly include `not_authenticated`, `not_found`, `invalid_input`, `rate_limited`, and `api_error`.
|
Structured error codes commonly include `not_authenticated`, `not_found`, `invalid_input`, `rate_limited`, and `api_error`.
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
@@ -458,7 +468,17 @@ score = likes_w * likes
|
|||||||
- 报错 `No Twitter cookies found`:请先登录 `x.com`,并确认浏览器为 Arc/Chrome/Edge/Firefox/Brave 之一,或手动设置环境变量。
|
- 报错 `No Twitter cookies found`:请先登录 `x.com`,并确认浏览器为 Arc/Chrome/Edge/Firefox/Brave 之一,或手动设置环境变量。
|
||||||
- 如需查看浏览器提取细节,可加 `-v` 打开诊断日志。
|
- 如需查看浏览器提取细节,可加 `-v` 打开诊断日志。
|
||||||
- 报错 `Cookie expired or invalid`:Cookie 过期,重新登录后重试。
|
- 报错 `Cookie expired or invalid`:Cookie 过期,重新登录后重试。
|
||||||
|
- 报错 `Unable to get key for cookie decryption`(macOS Keychain 问题):
|
||||||
|
- **SSH 远程登录**:Keychain 默认锁定,需手动解锁:
|
||||||
|
```bash
|
||||||
|
security unlock-keychain ~/Library/Keychains/login.keychain-db
|
||||||
|
```
|
||||||
|
- **本地终端**:打开 **钥匙串访问** → 搜索 **"\<浏览器\> Safe Storage"** → **访问控制** → 添加你的终端 app → **保存更改**。
|
||||||
|
- 或在弹出 Keychain 授权时点击 **"始终允许"**。
|
||||||
- 报错 `Twitter API error 404`:通常是 queryId 轮换,重试即可。
|
- 报错 `Twitter API error 404`:通常是 queryId 轮换,重试即可。
|
||||||
|
|
||||||
|
**诊断命令**:运行 `twitter doctor` 可输出完整诊断报告(版本、OS、浏览器检测、Keychain 状态、cookie 提取结果),方便提交 bug report。
|
||||||
|
|
||||||
- 结构化错误码通常会区分 `not_authenticated`、`not_found`、`invalid_input`、`rate_limited`、`api_error`。
|
- 结构化错误码通常会区分 `not_authenticated`、`not_found`、`invalid_input`、`rate_limited`、`api_error`。
|
||||||
|
|
||||||
### 使用建议(防封号)
|
### 使用建议(防封号)
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ def test_get_cookies_reextracts_after_verify_failure(monkeypatch) -> None:
|
|||||||
monkeypatch.setattr(auth, "load_from_env", lambda: None)
|
monkeypatch.setattr(auth, "load_from_env", lambda: None)
|
||||||
extracted = iter(
|
extracted = iter(
|
||||||
[
|
[
|
||||||
{"auth_token": "stale-token", "ct0": "stale-csrf", "cookie_string": "stale=1"},
|
({"auth_token": "stale-token", "ct0": "stale-csrf", "cookie_string": "stale=1"}, []),
|
||||||
{"auth_token": "fresh-token", "ct0": "fresh-csrf", "cookie_string": "fresh=1"},
|
({"auth_token": "fresh-token", "ct0": "fresh-csrf", "cookie_string": "fresh=1"}, []),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(auth, "extract_from_browser", lambda: next(extracted))
|
monkeypatch.setattr(auth, "extract_from_browser", lambda: next(extracted))
|
||||||
@@ -84,11 +84,11 @@ def test_extract_cookies_from_jar_logs_missing_required_cookies(caplog) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_extract_from_browser_logs_warning_when_all_methods_fail(monkeypatch, caplog) -> None:
|
def test_extract_from_browser_logs_warning_when_all_methods_fail(monkeypatch, caplog) -> None:
|
||||||
monkeypatch.setattr(auth, "_extract_in_process", lambda: None)
|
monkeypatch.setattr(auth, "_extract_in_process", lambda: (None, []))
|
||||||
monkeypatch.setattr(auth, "_extract_via_subprocess", lambda: None)
|
monkeypatch.setattr(auth, "_extract_via_subprocess", lambda: (None, []))
|
||||||
|
|
||||||
with caplog.at_level("WARNING"):
|
with caplog.at_level("WARNING"):
|
||||||
cookies = auth.extract_from_browser()
|
cookies, diagnostics = auth.extract_from_browser()
|
||||||
|
|
||||||
assert cookies is None
|
assert cookies is None
|
||||||
assert "Twitter cookie extraction failed in both in-process and subprocess modes" in caplog.text
|
assert "Twitter cookie extraction failed in both in-process and subprocess modes" in caplog.text
|
||||||
@@ -110,7 +110,7 @@ def test_extract_in_process_supports_arc(monkeypatch) -> None:
|
|||||||
)
|
)
|
||||||
monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module)
|
monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module)
|
||||||
|
|
||||||
cookies = auth._extract_in_process()
|
cookies, diagnostics = auth._extract_in_process()
|
||||||
|
|
||||||
assert cookies is not None
|
assert cookies is not None
|
||||||
assert cookies["auth_token"] == "token"
|
assert cookies["auth_token"] == "token"
|
||||||
@@ -132,7 +132,7 @@ def test_extract_via_subprocess_script_includes_arc(monkeypatch) -> None:
|
|||||||
|
|
||||||
monkeypatch.setattr(auth.subprocess, "run", _run)
|
monkeypatch.setattr(auth.subprocess, "run", _run)
|
||||||
|
|
||||||
cookies = auth._extract_via_subprocess()
|
cookies, diagnostics = auth._extract_via_subprocess()
|
||||||
|
|
||||||
assert cookies is None
|
assert cookies is None
|
||||||
assert '("arc", browser_cookie3.arc)' in seen["script"]
|
assert '("arc", browser_cookie3.arc)' in seen["script"]
|
||||||
@@ -154,7 +154,7 @@ def test_extract_via_subprocess_retries_uv_when_current_env_has_no_output(monkey
|
|||||||
|
|
||||||
monkeypatch.setattr(auth.subprocess, "run", _run)
|
monkeypatch.setattr(auth.subprocess, "run", _run)
|
||||||
|
|
||||||
cookies = auth._extract_via_subprocess()
|
cookies, diagnostics = auth._extract_via_subprocess()
|
||||||
|
|
||||||
assert cookies == {"auth_token": "token", "ct0": "csrf"}
|
assert cookies == {"auth_token": "token", "ct0": "csrf"}
|
||||||
assert len(calls) == 2
|
assert len(calls) == 2
|
||||||
@@ -285,8 +285,85 @@ def test_extract_in_process_tries_multiple_profiles(monkeypatch, tmp_path) -> No
|
|||||||
)
|
)
|
||||||
monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module)
|
monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module)
|
||||||
|
|
||||||
cookies = auth._extract_in_process()
|
cookies, diagnostics = auth._extract_in_process()
|
||||||
|
|
||||||
assert cookies is not None
|
assert cookies is not None
|
||||||
assert cookies["auth_token"] == "tok123"
|
assert cookies["auth_token"] == "tok123"
|
||||||
assert cookies["ct0"] == "csrf456"
|
assert cookies["ct0"] == "csrf456"
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnose_keychain_issues_detects_decryption_error(monkeypatch) -> None:
|
||||||
|
"""_diagnose_keychain_issues should detect Keychain-related error strings."""
|
||||||
|
monkeypatch.setattr("sys.platform", "darwin")
|
||||||
|
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
||||||
|
monkeypatch.delenv("SSH_TTY", raising=False)
|
||||||
|
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||||
|
|
||||||
|
diagnostics = ["arc[Default]: Unable to get key for cookie decryption"]
|
||||||
|
hint = auth._diagnose_keychain_issues(diagnostics)
|
||||||
|
|
||||||
|
assert hint is not None
|
||||||
|
assert "Keychain" in hint
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnose_keychain_issues_ssh_hint(monkeypatch) -> None:
|
||||||
|
"""When SSH env vars are set, hint should suggest unlock-keychain."""
|
||||||
|
monkeypatch.setattr("sys.platform", "darwin")
|
||||||
|
monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 54321 22")
|
||||||
|
|
||||||
|
diagnostics = ["arc: Unable to get key for cookie decryption"]
|
||||||
|
hint = auth._diagnose_keychain_issues(diagnostics)
|
||||||
|
|
||||||
|
assert hint is not None
|
||||||
|
assert "SSH session detected" in hint
|
||||||
|
assert "security unlock-keychain" in hint
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnose_keychain_issues_returns_none_for_unrelated_errors() -> None:
|
||||||
|
"""Should return None when diagnostics don't mention Keychain."""
|
||||||
|
diagnostics = ["chrome[Default]=no-cookies", "firefox: profile not found"]
|
||||||
|
hint = auth._diagnose_keychain_issues(diagnostics)
|
||||||
|
|
||||||
|
assert hint is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_cookies_includes_keychain_hint_in_error(monkeypatch) -> None:
|
||||||
|
"""When extraction fails with Keychain errors, error msg should contain the hint."""
|
||||||
|
monkeypatch.setattr("sys.platform", "darwin")
|
||||||
|
monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 54321 22")
|
||||||
|
monkeypatch.setattr(auth, "load_from_env", lambda: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
auth,
|
||||||
|
"extract_from_browser",
|
||||||
|
lambda: (None, ["arc: Unable to get key for cookie decryption"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError) as exc_info:
|
||||||
|
auth.get_cookies()
|
||||||
|
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
assert "security unlock-keychain" in msg
|
||||||
|
assert "twitter doctor" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_in_process_returns_diagnostics_on_failure(monkeypatch) -> None:
|
||||||
|
"""_extract_in_process should return diagnostics containing error strings."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
class BrowserError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
fake_module = SimpleNamespace(
|
||||||
|
arc=lambda: (_ for _ in ()).throw(BrowserError("Unable to get key for cookie decryption")),
|
||||||
|
chrome=lambda: [],
|
||||||
|
edge=lambda: (_ for _ in ()).throw(BrowserError("Edge not found")),
|
||||||
|
firefox=lambda: (_ for _ in ()).throw(BrowserError("Firefox not found")),
|
||||||
|
brave=lambda: (_ for _ in ()).throw(BrowserError("Brave not found")),
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module)
|
||||||
|
|
||||||
|
cookies, diagnostics = auth._extract_in_process()
|
||||||
|
|
||||||
|
assert cookies is None
|
||||||
|
assert any("cookie decryption" in d for d in diagnostics)
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,48 @@ def _is_twitter_domain(domain: str) -> bool:
|
|||||||
return domain in _TWITTER_DOMAINS or domain.endswith(".x.com") or domain.endswith(".twitter.com")
|
return domain in _TWITTER_DOMAINS or domain.endswith(".x.com") or domain.endswith(".twitter.com")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Keychain / environment diagnostics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_KEYCHAIN_ERROR_KEYWORDS = (
|
||||||
|
"key for cookie decryption",
|
||||||
|
"safe storage",
|
||||||
|
"keychain",
|
||||||
|
"secretstorage",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _diagnose_keychain_issues(diagnostics: List[str]) -> Optional[str]:
|
||||||
|
"""Analyse extraction diagnostics for Keychain permission issues.
|
||||||
|
|
||||||
|
Returns a user-friendly hint string, or None.
|
||||||
|
"""
|
||||||
|
lowered = " ".join(diagnostics).lower()
|
||||||
|
if not any(kw in lowered for kw in _KEYCHAIN_ERROR_KEYWORDS):
|
||||||
|
return None
|
||||||
|
|
||||||
|
is_ssh = bool(os.environ.get("SSH_CLIENT") or os.environ.get("SSH_TTY") or os.environ.get("SSH_CONNECTION"))
|
||||||
|
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
if is_ssh:
|
||||||
|
return (
|
||||||
|
"macOS Keychain is locked (SSH session detected).\n"
|
||||||
|
" Fix: security unlock-keychain ~/Library/Keychains/login.keychain-db\n"
|
||||||
|
" Then retry the command."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"macOS Keychain permission denied — your terminal is not authorized to read browser cookie encryption keys.\n"
|
||||||
|
" Fix: Open Keychain Access → search for \"<Browser> Safe Storage\" → Access Control → add your Terminal app.\n"
|
||||||
|
" Or click \"Always Allow\" when the Keychain authorization popup appears."
|
||||||
|
)
|
||||||
|
# Linux: gnome-keyring / SecretStorage issues
|
||||||
|
return (
|
||||||
|
"System keyring access failed — the cookie encryption key could not be retrieved.\n"
|
||||||
|
" If running headless or via SSH, ensure your keyring daemon is unlocked."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_from_env() -> Optional[Dict[str, str]]:
|
def load_from_env() -> Optional[Dict[str, str]]:
|
||||||
"""Load cookies from environment variables."""
|
"""Load cookies from environment variables."""
|
||||||
auth_token = os.environ.get("TWITTER_AUTH_TOKEN", "")
|
auth_token = os.environ.get("TWITTER_AUTH_TOKEN", "")
|
||||||
@@ -197,7 +239,7 @@ def _iter_chrome_cookie_files(browser_name: str) -> List[str]:
|
|||||||
return paths
|
return paths
|
||||||
|
|
||||||
|
|
||||||
def _extract_in_process() -> Optional[Dict[str, str]]:
|
def _extract_in_process() -> Tuple[Optional[Dict[str, str]], List[str]]:
|
||||||
"""Extract cookies in the main process (required on macOS for Keychain access).
|
"""Extract cookies in the main process (required on macOS for Keychain access).
|
||||||
|
|
||||||
On macOS, Chrome encrypts cookies using a key stored in the system Keychain.
|
On macOS, Chrome encrypts cookies using a key stored in the system Keychain.
|
||||||
@@ -205,12 +247,14 @@ def _extract_in_process() -> Optional[Dict[str, str]]:
|
|||||||
browser_cookie3 must run in the main process to decrypt cookies.
|
browser_cookie3 must run in the main process to decrypt cookies.
|
||||||
|
|
||||||
For Chromium-based browsers, iterates all profiles to find Twitter cookies.
|
For Chromium-based browsers, iterates all profiles to find Twitter cookies.
|
||||||
|
|
||||||
|
Returns (cookies_dict | None, diagnostics_list).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import browser_cookie3
|
import browser_cookie3
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.debug("browser_cookie3 not installed, skipping in-process extraction")
|
logger.debug("browser_cookie3 not installed, skipping in-process extraction")
|
||||||
return None
|
return None, ["browser-cookie3 not installed"]
|
||||||
|
|
||||||
browsers = [
|
browsers = [
|
||||||
("arc", browser_cookie3.arc),
|
("arc", browser_cookie3.arc),
|
||||||
@@ -219,7 +263,8 @@ def _extract_in_process() -> Optional[Dict[str, str]]:
|
|||||||
("firefox", browser_cookie3.firefox),
|
("firefox", browser_cookie3.firefox),
|
||||||
("brave", browser_cookie3.brave),
|
("brave", browser_cookie3.brave),
|
||||||
]
|
]
|
||||||
attempts = []
|
attempts: List[str] = []
|
||||||
|
diagnostics: List[str] = []
|
||||||
|
|
||||||
for name, fn in browsers:
|
for name, fn in browsers:
|
||||||
if name in _CHROMIUM_BASE_DIRS:
|
if name in _CHROMIUM_BASE_DIRS:
|
||||||
@@ -232,11 +277,12 @@ def _extract_in_process() -> Optional[Dict[str, str]]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("%s in-process extraction failed: %s", name, e)
|
logger.debug("%s in-process extraction failed: %s", name, e)
|
||||||
attempts.append("%s=%s" % (name, type(e).__name__))
|
attempts.append("%s=%s" % (name, type(e).__name__))
|
||||||
|
diagnostics.append("%s: %s" % (name, e))
|
||||||
continue
|
continue
|
||||||
cookies = _extract_cookies_from_jar(jar, source="%s(in-process)" % name)
|
cookies = _extract_cookies_from_jar(jar, source="%s(in-process)" % name)
|
||||||
if cookies:
|
if cookies:
|
||||||
logger.info("Found cookies in %s (in-process, default)", name)
|
logger.info("Found cookies in %s (in-process, default)", name)
|
||||||
return cookies
|
return cookies, diagnostics
|
||||||
attempts.append("%s=no-cookies" % name)
|
attempts.append("%s=no-cookies" % name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -247,11 +293,12 @@ def _extract_in_process() -> Optional[Dict[str, str]]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("%s[%s] in-process extraction failed: %s", name, profile_name, e)
|
logger.debug("%s[%s] in-process extraction failed: %s", name, profile_name, e)
|
||||||
attempts.append("%s[%s]=%s" % (name, profile_name, type(e).__name__))
|
attempts.append("%s[%s]=%s" % (name, profile_name, type(e).__name__))
|
||||||
|
diagnostics.append("%s[%s]: %s" % (name, profile_name, e))
|
||||||
continue
|
continue
|
||||||
cookies = _extract_cookies_from_jar(jar, source="%s[%s](in-process)" % (name, profile_name))
|
cookies = _extract_cookies_from_jar(jar, source="%s[%s](in-process)" % (name, profile_name))
|
||||||
if cookies:
|
if cookies:
|
||||||
logger.info("Found cookies in %s profile '%s' (in-process)", name, profile_name)
|
logger.info("Found cookies in %s profile '%s' (in-process)", name, profile_name)
|
||||||
return cookies
|
return cookies, diagnostics
|
||||||
attempts.append("%s[%s]=no-cookies" % (name, profile_name))
|
attempts.append("%s[%s]=no-cookies" % (name, profile_name))
|
||||||
else:
|
else:
|
||||||
# Non-Chromium (Firefox): use default behavior
|
# Non-Chromium (Firefox): use default behavior
|
||||||
@@ -260,20 +307,24 @@ def _extract_in_process() -> Optional[Dict[str, str]]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("%s in-process extraction failed: %s", name, e)
|
logger.debug("%s in-process extraction failed: %s", name, e)
|
||||||
attempts.append("%s=%s" % (name, type(e).__name__))
|
attempts.append("%s=%s" % (name, type(e).__name__))
|
||||||
|
diagnostics.append("%s: %s" % (name, e))
|
||||||
continue
|
continue
|
||||||
cookies = _extract_cookies_from_jar(jar, source="%s(in-process)" % name)
|
cookies = _extract_cookies_from_jar(jar, source="%s(in-process)" % name)
|
||||||
if cookies:
|
if cookies:
|
||||||
logger.info("Found cookies in %s (in-process)", name)
|
logger.info("Found cookies in %s (in-process)", name)
|
||||||
return cookies
|
return cookies, diagnostics
|
||||||
attempts.append("%s=no-cookies" % name)
|
attempts.append("%s=no-cookies" % name)
|
||||||
|
|
||||||
if attempts:
|
if attempts:
|
||||||
logger.debug("In-process extraction attempts: %s", ", ".join(attempts))
|
logger.debug("In-process extraction attempts: %s", ", ".join(attempts))
|
||||||
return None
|
return None, diagnostics
|
||||||
|
|
||||||
|
|
||||||
def _extract_via_subprocess() -> Optional[Dict[str, str]]:
|
def _extract_via_subprocess() -> Tuple[Optional[Dict[str, str]], List[str]]:
|
||||||
"""Extract cookies via subprocess (fallback if in-process fails, e.g. SQLite lock)."""
|
"""Extract cookies via subprocess (fallback if in-process fails, e.g. SQLite lock).
|
||||||
|
|
||||||
|
Returns (cookies_dict | None, diagnostics_list).
|
||||||
|
"""
|
||||||
extract_script = '''
|
extract_script = '''
|
||||||
import glob, json, os, sys
|
import glob, json, os, sys
|
||||||
try:
|
try:
|
||||||
@@ -351,7 +402,7 @@ for name, fn in browsers:
|
|||||||
try:
|
try:
|
||||||
jar = fn()
|
jar = fn()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
attempts.append(f"{name}={type(exc).__name__}")
|
attempts.append(f"{name}={type(exc).__name__}: {exc}")
|
||||||
continue
|
continue
|
||||||
r = extract_from_jar(jar, name)
|
r = extract_from_jar(jar, name)
|
||||||
if r:
|
if r:
|
||||||
@@ -364,7 +415,7 @@ for name, fn in browsers:
|
|||||||
try:
|
try:
|
||||||
jar = fn(cookie_file=cf)
|
jar = fn(cookie_file=cf)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
attempts.append(f"{name}[{pname}]={type(exc).__name__}")
|
attempts.append(f"{name}[{pname}]={type(exc).__name__}: {exc}")
|
||||||
continue
|
continue
|
||||||
r = extract_from_jar(jar, name, pname)
|
r = extract_from_jar(jar, name, pname)
|
||||||
if r:
|
if r:
|
||||||
@@ -375,7 +426,7 @@ for name, fn in browsers:
|
|||||||
try:
|
try:
|
||||||
jar = fn()
|
jar = fn()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
attempts.append(f"{name}={type(exc).__name__}")
|
attempts.append(f"{name}={type(exc).__name__}: {exc}")
|
||||||
continue
|
continue
|
||||||
r = extract_from_jar(jar, name)
|
r = extract_from_jar(jar, name)
|
||||||
if r:
|
if r:
|
||||||
@@ -390,6 +441,8 @@ print(json.dumps({
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
diagnostics: List[str] = []
|
||||||
|
|
||||||
def _run_extract_command(
|
def _run_extract_command(
|
||||||
cmd: list[str],
|
cmd: list[str],
|
||||||
timeout: int,
|
timeout: int,
|
||||||
@@ -427,6 +480,7 @@ sys.exit(1)
|
|||||||
attempts = data.get("attempts") or []
|
attempts = data.get("attempts") or []
|
||||||
if attempts:
|
if attempts:
|
||||||
logger.debug("Subprocess extraction attempts (%s): %s", label, ", ".join(str(item) for item in attempts))
|
logger.debug("Subprocess extraction attempts (%s): %s", label, ", ".join(str(item) for item in attempts))
|
||||||
|
diagnostics.extend(str(item) for item in attempts)
|
||||||
retryable = data.get("error") == "browser-cookie3 not installed"
|
retryable = data.get("error") == "browser-cookie3 not installed"
|
||||||
return None, retryable
|
return None, retryable
|
||||||
|
|
||||||
@@ -446,7 +500,7 @@ sys.exit(1)
|
|||||||
)
|
)
|
||||||
|
|
||||||
if data is None:
|
if data is None:
|
||||||
return None
|
return None, diagnostics
|
||||||
logger.info("Found cookies in %s (subprocess)", data.get("browser", "unknown"))
|
logger.info("Found cookies in %s (subprocess)", data.get("browser", "unknown"))
|
||||||
|
|
||||||
# Build full cookie string from all extracted cookies
|
# Build full cookie string from all extracted cookies
|
||||||
@@ -456,30 +510,36 @@ sys.exit(1)
|
|||||||
cookie_str = "; ".join("%s=%s" % (k, v) for k, v in all_cookies.items())
|
cookie_str = "; ".join("%s=%s" % (k, v) for k, v in all_cookies.items())
|
||||||
cookies["cookie_string"] = cookie_str
|
cookies["cookie_string"] = cookie_str
|
||||||
logger.info("Extracted %d total cookies for full browser fingerprint", len(all_cookies))
|
logger.info("Extracted %d total cookies for full browser fingerprint", len(all_cookies))
|
||||||
return cookies
|
return cookies, diagnostics
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
logger.debug("Cookie extraction subprocess returned incomplete payload: %s", exc)
|
logger.debug("Cookie extraction subprocess returned incomplete payload: %s", exc)
|
||||||
return None
|
return None, diagnostics
|
||||||
|
|
||||||
|
|
||||||
def extract_from_browser() -> Optional[Dict[str, str]]:
|
def extract_from_browser() -> Tuple[Optional[Dict[str, str]], List[str]]:
|
||||||
"""Auto-extract ALL Twitter cookies from local browser using browser-cookie3.
|
"""Auto-extract ALL Twitter cookies from local browser using browser-cookie3.
|
||||||
|
|
||||||
Strategy:
|
Strategy:
|
||||||
1. Try in-process first (required on macOS for Keychain access)
|
1. Try in-process first (required on macOS for Keychain access)
|
||||||
2. Fall back to subprocess (handles SQLite lock when browser is running)
|
2. Fall back to subprocess (handles SQLite lock when browser is running)
|
||||||
|
|
||||||
|
Returns (cookies_dict | None, diagnostics_list).
|
||||||
"""
|
"""
|
||||||
|
all_diagnostics: List[str] = []
|
||||||
|
|
||||||
# 1. In-process (works on macOS, may fail with SQLite lock)
|
# 1. In-process (works on macOS, may fail with SQLite lock)
|
||||||
cookies = _extract_in_process()
|
cookies, diag = _extract_in_process()
|
||||||
|
all_diagnostics.extend(diag)
|
||||||
if cookies:
|
if cookies:
|
||||||
return cookies
|
return cookies, all_diagnostics
|
||||||
|
|
||||||
# 2. Subprocess fallback (handles SQLite lock, but fails on macOS Keychain)
|
# 2. Subprocess fallback (handles SQLite lock, but fails on macOS Keychain)
|
||||||
logger.debug("In-process extraction failed, trying subprocess fallback")
|
logger.debug("In-process extraction failed, trying subprocess fallback")
|
||||||
cookies = _extract_via_subprocess()
|
cookies, diag = _extract_via_subprocess()
|
||||||
|
all_diagnostics.extend(diag)
|
||||||
if not cookies:
|
if not cookies:
|
||||||
logger.warning("Twitter cookie extraction failed in both in-process and subprocess modes")
|
logger.warning("Twitter cookie extraction failed in both in-process and subprocess modes")
|
||||||
return cookies
|
return cookies, all_diagnostics
|
||||||
|
|
||||||
|
|
||||||
def get_cookies() -> Dict[str, str]:
|
def get_cookies() -> Dict[str, str]:
|
||||||
@@ -488,6 +548,7 @@ def get_cookies() -> Dict[str, str]:
|
|||||||
Raises RuntimeError if no cookies found.
|
Raises RuntimeError if no cookies found.
|
||||||
"""
|
"""
|
||||||
cookies: Optional[Dict[str, str]] = None
|
cookies: Optional[Dict[str, str]] = None
|
||||||
|
diagnostics: List[str] = []
|
||||||
|
|
||||||
# 1. Try environment variables
|
# 1. Try environment variables
|
||||||
cookies = load_from_env()
|
cookies = load_from_env()
|
||||||
@@ -497,14 +558,22 @@ def get_cookies() -> Dict[str, str]:
|
|||||||
# 2. Try browser extraction (auto-detect)
|
# 2. Try browser extraction (auto-detect)
|
||||||
if not cookies:
|
if not cookies:
|
||||||
logger.debug("Attempting browser cookie extraction")
|
logger.debug("Attempting browser cookie extraction")
|
||||||
cookies = extract_from_browser()
|
cookies, diagnostics = extract_from_browser()
|
||||||
|
|
||||||
if not cookies:
|
if not cookies:
|
||||||
raise RuntimeError(
|
lines = ["No Twitter cookies found."]
|
||||||
"No Twitter cookies found.\n"
|
# Add actionable Keychain hint when relevant
|
||||||
"Option 1: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables\n"
|
hint = _diagnose_keychain_issues(diagnostics)
|
||||||
"Option 2: Make sure you are logged into x.com in your browser (Arc/Chrome/Edge/Firefox/Brave)"
|
if hint:
|
||||||
)
|
lines.append("")
|
||||||
|
lines.append("Likely cause:")
|
||||||
|
lines.extend(" " + l for l in hint.splitlines())
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Option 1: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables")
|
||||||
|
lines.append("Option 2: Make sure you are logged into x.com in your browser (Arc/Chrome/Edge/Firefox/Brave)")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Run 'twitter doctor' for full diagnostics.")
|
||||||
|
raise RuntimeError("\n".join(lines))
|
||||||
|
|
||||||
# Verify only for explicit auth failures; transient endpoint issues are tolerated.
|
# Verify only for explicit auth failures; transient endpoint issues are tolerated.
|
||||||
try:
|
try:
|
||||||
@@ -512,7 +581,7 @@ def get_cookies() -> Dict[str, str]:
|
|||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# Auth failure — re-extract from browser and retry verification
|
# Auth failure — re-extract from browser and retry verification
|
||||||
logger.info("Cookie verification failed, re-extracting from browser")
|
logger.info("Cookie verification failed, re-extracting from browser")
|
||||||
fresh_cookies = extract_from_browser()
|
fresh_cookies, _ = extract_from_browser()
|
||||||
if fresh_cookies:
|
if fresh_cookies:
|
||||||
# Verify fresh cookies — if this also fails, let it raise
|
# Verify fresh cookies — if this also fails, let it raise
|
||||||
verify_cookies(fresh_cookies["auth_token"], fresh_cookies["ct0"], fresh_cookies.get("cookie_string"))
|
verify_cookies(fresh_cookies["auth_token"], fresh_cookies["ct0"], fresh_cookies.get("cookie_string"))
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Write commands:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -992,5 +993,137 @@ def unbookmark(tweet_id, as_json, as_yaml):
|
|||||||
_write_action("🔖", "Removing bookmark", "unbookmark_tweet", tweet_id, as_json=as_json, as_yaml=as_yaml)
|
_write_action("🔖", "Removing bookmark", "unbookmark_tweet", tweet_id, as_json=as_json, as_yaml=as_yaml)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command(name="doctor")
|
||||||
|
@structured_output_options
|
||||||
|
def doctor(as_json, as_yaml):
|
||||||
|
# type: (bool, bool) -> None
|
||||||
|
"""Run diagnostics for cookie extraction and authentication.
|
||||||
|
|
||||||
|
Useful for troubleshooting auth issues and for pasting into bug reports.
|
||||||
|
"""
|
||||||
|
import platform
|
||||||
|
|
||||||
|
from .auth import (
|
||||||
|
_diagnose_keychain_issues,
|
||||||
|
_extract_in_process,
|
||||||
|
_extract_via_subprocess,
|
||||||
|
extract_from_browser,
|
||||||
|
load_from_env,
|
||||||
|
verify_cookies,
|
||||||
|
)
|
||||||
|
|
||||||
|
info: Dict[str, Any] = {}
|
||||||
|
mode = _structured_mode(as_json=as_json, as_yaml=as_yaml)
|
||||||
|
|
||||||
|
# -- System info --
|
||||||
|
info["version"] = __version__
|
||||||
|
info["python"] = sys.version.split()[0]
|
||||||
|
info["platform"] = platform.platform()
|
||||||
|
info["os"] = sys.platform
|
||||||
|
|
||||||
|
# -- Environment --
|
||||||
|
is_ssh = bool(
|
||||||
|
os.environ.get("SSH_CLIENT")
|
||||||
|
or os.environ.get("SSH_TTY")
|
||||||
|
or os.environ.get("SSH_CONNECTION")
|
||||||
|
)
|
||||||
|
info["ssh_session"] = is_ssh
|
||||||
|
info["env_auth_token_set"] = bool(os.environ.get("TWITTER_AUTH_TOKEN"))
|
||||||
|
info["env_ct0_set"] = bool(os.environ.get("TWITTER_CT0"))
|
||||||
|
info["chrome_profile_override"] = os.environ.get("TWITTER_CHROME_PROFILE", "")
|
||||||
|
|
||||||
|
# -- Cookie extraction --
|
||||||
|
env_cookies = load_from_env()
|
||||||
|
info["env_cookies"] = "found" if env_cookies else "not set"
|
||||||
|
|
||||||
|
# In-process extraction
|
||||||
|
in_proc_cookies, in_proc_diag = _extract_in_process()
|
||||||
|
info["in_process"] = {
|
||||||
|
"status": "ok" if in_proc_cookies else "failed",
|
||||||
|
"diagnostics": in_proc_diag,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Subprocess extraction
|
||||||
|
sub_cookies, sub_diag = _extract_via_subprocess()
|
||||||
|
info["subprocess"] = {
|
||||||
|
"status": "ok" if sub_cookies else "failed",
|
||||||
|
"diagnostics": sub_diag,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Combined diagnostics
|
||||||
|
all_diag = in_proc_diag + sub_diag
|
||||||
|
cookies = in_proc_cookies or sub_cookies or env_cookies
|
||||||
|
|
||||||
|
# Keychain hint
|
||||||
|
hint = _diagnose_keychain_issues(all_diag)
|
||||||
|
if hint:
|
||||||
|
info["keychain_hint"] = hint
|
||||||
|
|
||||||
|
# Verification
|
||||||
|
if cookies:
|
||||||
|
try:
|
||||||
|
result = verify_cookies(
|
||||||
|
cookies["auth_token"],
|
||||||
|
cookies["ct0"],
|
||||||
|
cookies.get("cookie_string"),
|
||||||
|
)
|
||||||
|
info["verification"] = "ok"
|
||||||
|
info["screen_name"] = result.get("screen_name", "")
|
||||||
|
except RuntimeError as exc:
|
||||||
|
info["verification"] = "failed: %s" % exc
|
||||||
|
else:
|
||||||
|
info["verification"] = "skipped (no cookies)"
|
||||||
|
|
||||||
|
# -- Output --
|
||||||
|
if _emit_mode_payload(info, mode):
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print("\n🩺 [bold]twitter-cli doctor[/bold]\n")
|
||||||
|
console.print(" Version: %s" % info["version"])
|
||||||
|
console.print(" Python: %s" % info["python"])
|
||||||
|
console.print(" Platform: %s" % info["platform"])
|
||||||
|
console.print(" SSH session: %s" % ("yes ⚠️" if is_ssh else "no"))
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
console.print("[bold]Environment:[/bold]")
|
||||||
|
console.print(" TWITTER_AUTH_TOKEN: %s" % ("set ✅" if info["env_auth_token_set"] else "not set"))
|
||||||
|
console.print(" TWITTER_CT0: %s" % ("set ✅" if info["env_ct0_set"] else "not set"))
|
||||||
|
if info["chrome_profile_override"]:
|
||||||
|
console.print(" TWITTER_CHROME_PROFILE: %s" % info["chrome_profile_override"])
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
console.print("[bold]Cookie Extraction:[/bold]")
|
||||||
|
in_status = info["in_process"]["status"]
|
||||||
|
console.print(
|
||||||
|
" In-process: %s" % ("[green]ok ✅[/green]" if in_status == "ok" else "[red]failed ❌[/red]")
|
||||||
|
)
|
||||||
|
for d in info["in_process"]["diagnostics"]:
|
||||||
|
console.print(" [dim]• %s[/dim]" % d)
|
||||||
|
|
||||||
|
sub_status = info["subprocess"]["status"]
|
||||||
|
console.print(
|
||||||
|
" Subprocess: %s" % ("[green]ok ✅[/green]" if sub_status == "ok" else "[red]failed ❌[/red]")
|
||||||
|
)
|
||||||
|
for d in info["subprocess"]["diagnostics"]:
|
||||||
|
console.print(" [dim]• %s[/dim]" % d)
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
if hint:
|
||||||
|
console.print("[yellow]💡 Hint:[/yellow]")
|
||||||
|
for line in hint.splitlines():
|
||||||
|
console.print(" [yellow]%s[/yellow]" % line)
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
v = info["verification"]
|
||||||
|
if v == "ok":
|
||||||
|
screen = info.get("screen_name", "")
|
||||||
|
console.print("[green]✅ Authentication: OK[/green]%s" % (" (@%s)" % screen if screen else ""))
|
||||||
|
elif v.startswith("failed"):
|
||||||
|
console.print("[red]❌ Authentication: %s[/red]" % v)
|
||||||
|
else:
|
||||||
|
console.print("[yellow]⚠️ Authentication: %s[/yellow]" % v)
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
cli()
|
cli()
|
||||||
|
|||||||
Reference in New Issue
Block a user