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
56 changes: 52 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,55 @@ rdt comment 3 "Great post!" # Comment on result #3

## Authentication

rdt-cli supports browser cookie extraction to authenticate with Reddit:
rdt-cli resolves credentials in priority order:

1. **Saved cookies** — loads from `~/.config/rdt-cli/credential.json`
2. **Browser cookies** — auto-detects installed browsers and extracts cookies (supports Chrome, Firefox, Edge, Brave)
1. **Environment variables** — `RDT_COOKIE` / `RDT_SESSION` (see below); never touches your browser
2. **Saved cookies** — loads from `~/.config/rdt-cli/credential.json`
3. **Browser cookies** — *last resort*: auto-detects installed browsers and extracts cookies (Chrome, Firefox, Edge, Brave)

`rdt login` automatically tries all installed browsers and uses the first one with valid cookies.
`rdt login` tries the saved credential, then falls back to browser extraction.

> **Public commands run anonymously.** Read-only commands (`popular`, `all`, `sub`, `sub-info`, `user`, `user-posts`, `user-comments`, `search`, `export`, `read`, `show`) never read your browser's cookie database. They use a saved credential if one already exists, otherwise they run unauthenticated. Run `rdt login` once to browse these authenticated (e.g., for NSFW visibility or personalized ranking). Only commands that require an account (`feed`, `saved`, `upvoted`, `upvote`, `save`, `subscribe`, `comment`) fall back to browser cookie extraction.

### Cookies via environment variables (no browser access)

If you'd rather rdt-cli **not read your browser's cookie database**, supply cookies yourself. These take priority over the saved file and browser extraction, and are read fresh each run (never written to disk):

| Variable | Description |
|---|---|
| `RDT_COOKIE` | A full raw Cookie header (`name=value; name2=value2; …`) — most robust, carries the whole session set |
| `RDT_SESSION` | Just the `reddit_session` cookie value (convenience) |
| `RDT_MODHASH` | Optional `modhash`; enables write actions (vote/save/comment) |

**Getting your cookie manually** — Reddit now requires a browser-established session even for "public" reads (a plain HTTP client is blocked by Reddit's anti-bot), so copy the cookie your browser already has:

1. Open **reddit.com** in your browser — **logged in** (recommended: `reddit_session` lasts weeks and enables write actions) or logged out (anonymous, read-only; the anonymous token expires in ~a day).
2. Open **DevTools** — `F12` or `⌥⌘I` (macOS) — and select the **Network** tab.
3. Load `https://www.reddit.com/r/popular.json` (or reload any reddit page).
4. Click the top request → **Headers** → **Request Headers** → select the whole **`cookie:`** value and copy it.
- *Alternative:* **Application/Storage → Cookies → `https://www.reddit.com`** lists cookies individually; the ones that matter are `reddit_session` (logged in) or `token_v2` / `loid` / `edgebucket` (anonymous).

**Setting the env var** — use **single quotes** (the value contains `;`, `=`, `%`):

```bash
# Recommended: the whole cookie header (zsh — macOS default)
export RDT_COOKIE='loid=...; edgebucket=...; token_v2=...; reddit_session=...'
echo "export RDT_COOKIE='...'" >> ~/.zshrc && source ~/.zshrc # persist
# bash: append to ~/.bashrc instead of ~/.zshrc

# Or, if you only have the session cookie:
export RDT_SESSION='<reddit_session value>'
export RDT_MODHASH='<modhash value>' # optional — enables voting/commenting
```

Verify:

```bash
rdt status # → ✅ Authenticated (source: env)
rdt popular -n 5 # → posts
```

> The logged-in cookie is an account credential — treat it like a password; don't commit or share it (`~/.zshrc` stores it in plaintext).

### Cookie TTL

Expand All @@ -149,6 +192,9 @@ After any listing command such as `feed`, `popular`, `all`, `sub`, or `search`,
| Variable | Default | Description |
|----------|---------|-------------|
| `OUTPUT` | `auto` | Output format: `json`, `yaml`, `rich`, or `auto` (→ YAML when non-TTY) |
| `RDT_COOKIE` | — | Raw Cookie header for auth (highest priority, never persisted) — see [Authentication](#authentication) |
| `RDT_SESSION` | — | `reddit_session` cookie value (convenience) |
| `RDT_MODHASH` | — | `modhash` value; enables write actions |

## Rate Limiting & Anti-Detection

Expand Down Expand Up @@ -368,6 +414,8 @@ rdt-cli 支持浏览器 Cookie 提取来认证 Reddit:

Cookie 保存后有效期 **7 天**,超时后自动尝试从浏览器刷新。

> **公开命令匿名运行。** 只读命令(`popular`、`all`、`sub`、`sub-info`、`user`、`user-posts`、`user-comments`、`search`、`export`、`read`、`show`)不会读取浏览器 Cookie 数据库:若已保存凭据则使用之,否则以匿名方式运行。如需登录态浏览(例如查看 NSFW 或个性化排序),先执行一次 `rdt login`。只有需要账号的命令(`feed`、`saved`、`upvoted`、`upvote`、`save`、`subscribe`、`comment`)才会回退到浏览器 Cookie 提取。

## 常见问题

- `No Reddit cookies found` — 请先在任意浏览器打开 https://www.reddit.com/ 并登录,然后执行 `rdt login`
Expand Down
81 changes: 76 additions & 5 deletions rdt_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,20 @@

import json
import logging
import os
import shutil
import subprocess
import time
from typing import Any

from .constants import CONFIG_DIR, CREDENTIAL_FILE, REQUIRED_COOKIES
from .constants import (
CONFIG_DIR,
CREDENTIAL_FILE,
ENV_COOKIE,
ENV_MODHASH,
ENV_SESSION,
REQUIRED_COOKIES,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -120,6 +128,56 @@ def clear_credential() -> None:
CREDENTIAL_FILE.unlink()


# ── Environment variable credential ─────────────────────────────────


def parse_cookie_header(header: str) -> dict[str, str]:
"""Parse a raw Cookie header ("a=1; b=2") into a name→value dict.

Splits each pair on the first '=' only (cookie values such as JWTs may
contain '='). Blank segments and nameless pairs are skipped.
"""
cookies: dict[str, str] = {}
for part in header.split(";"):
name, sep, value = part.strip().partition("=")
name = name.strip()
if not sep or not name:
continue
cookies[name] = value.strip()
return cookies


def extract_env_credential() -> Credential | None:
"""Build a credential from environment variables (no browser access).

- RDT_COOKIE : full raw Cookie header (most robust — carries the whole set)
- RDT_SESSION : reddit_session cookie value (convenience; overlays RDT_COOKIE)
- RDT_MODHASH : modhash (enables write actions)

Returned in-memory only; env credentials are never written to disk.
"""
raw = os.getenv(ENV_COOKIE)
session = os.getenv(ENV_SESSION)
modhash = os.getenv(ENV_MODHASH)

cookies: dict[str, str] = {}
if raw:
cookies.update(parse_cookie_header(raw))
if session:
cookies["reddit_session"] = session.strip()
if modhash:
cookies["modhash"] = modhash.strip()

if not cookies:
return None

return Credential(
cookies=cookies,
source="env",
modhash=modhash.strip() if modhash else None,
)


# ── Browser cookie extraction ───────────────────────────────────────


Expand Down Expand Up @@ -194,12 +252,25 @@ def _extract_direct() -> Credential | None:
# ── Credential chain ────────────────────────────────────────────────


def get_credential() -> Credential | None:
"""Try saved → browser → return None."""
cred = load_credential()
def get_credential(*, allow_browser: bool = True) -> Credential | None:
"""Resolve a credential: env → saved → browser → None.

Priority order:
1. Environment variables (RDT_COOKIE / RDT_SESSION) — non-invasive, highest
priority, read fresh each run.
2. Saved credential file.
3. Browser cookie extraction — invasive (reads the browser's cookie database)
and slow, so it is the last resort and is gated behind ``allow_browser``.
Public/optional endpoints pass ``allow_browser=False`` to avoid the browser.
"""
cred = extract_env_credential()
if cred:
return cred
cred = extract_browser_credential()
cred = load_credential()
if cred:
return cred
if allow_browser:
cred = extract_browser_credential()
if cred:
return cred
return None
17 changes: 14 additions & 3 deletions rdt_cli/commands/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,14 +183,22 @@ def require_auth() -> Credential:
"""Get credential or exit with error."""
cred = get_credential()
if not cred:
console.print("[yellow]⚠️ Not logged in[/yellow]. Use [bold]rdt login[/bold] to authenticate")
console.print(
"[yellow]⚠️ Not authenticated[/yellow]. Set [bold]RDT_COOKIE[/bold] "
"(see README) or run [bold]rdt login[/bold]"
)
sys.exit(1)
return cred


def optional_auth() -> Credential | None:
"""Get credential if available, or None (for public endpoints)."""
return get_credential()
"""Get a credential if one is already available, else None (for public endpoints).

Never triggers browser cookie extraction — public commands should not read
the browser's cookie database as a side effect. Run `rdt login` once to save
a credential if you want authenticated public browsing.
"""
return get_credential(allow_browser=False)


def get_client(credential: Credential | None = None) -> RedditClient:
Expand All @@ -204,6 +212,9 @@ def run_client_action(credential: Credential | None, action: Callable[[RedditCli
with get_client(credential) as client:
return action(client)
except SessionExpiredError:
# An anonymous run should never fall back to invasive browser extraction.
if credential is None:
raise
from ..auth import extract_browser_credential

fresh = extract_browser_credential()
Expand Down
5 changes: 3 additions & 2 deletions rdt_cli/commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ def login() -> None:
console.print(f"[green]✅ Login successful![/green] ({len(cred.cookies)} cookies extracted)")
else:
console.print("[red]❌ No Reddit cookies found.[/red]")
console.print(" [dim]Please login to reddit.com in your browser first, then retry.[/dim]")
console.print(" [dim]Set [bold]RDT_COOKIE[/bold] from your browser's cookie header (see README),[/dim]")
console.print(" [dim]or log into reddit.com in your browser, then retry.[/dim]")


@click.command()
Expand Down Expand Up @@ -92,7 +93,7 @@ def status(as_json: bool, as_yaml: bool) -> None:
console.print("[yellow]⚠️ Not authenticated[/yellow]")
if info["error"]:
console.print(f" [dim]{info['error']}[/dim]")
console.print(" [dim]Use 'rdt login' to extract cookies from your browser[/dim]")
console.print(" [dim]Set RDT_COOKIE (see README) or run 'rdt login' to use browser cookies[/dim]")


@click.command()
Expand Down
5 changes: 5 additions & 0 deletions rdt_cli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
CONFIG_DIR = Path.home() / ".config" / "rdt-cli"
CREDENTIAL_FILE = CONFIG_DIR / "credential.json"

# ── Environment variables (non-browser credential source) ───────────
ENV_COOKIE = "RDT_COOKIE" # raw Cookie header: "a=1; b=2; ..."
ENV_SESSION = "RDT_SESSION" # convenience: reddit_session cookie value
ENV_MODHASH = "RDT_MODHASH" # optional: modhash (enables write actions)

# ── Base URL ────────────────────────────────────────────────────────
BASE_URL = "https://www.reddit.com"
OAUTH_URL = "https://oauth.reddit.com"
Expand Down
3 changes: 2 additions & 1 deletion rdt_cli/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ def can_write(self) -> bool:

def refresh_capabilities(self) -> None:
capabilities: set[str] = set()
if self.cookies.get("reddit_session"):
# reddit_session → logged-in read; token_v2 → anonymous session read.
if self.cookies.get("reddit_session") or self.cookies.get("token_v2"):
capabilities.add("read")

inferred_modhash = self.modhash or _cookie_value(self.cookies, "modhash", "csrf_token")
Expand Down
16 changes: 16 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Shared test fixtures."""

from __future__ import annotations

import pytest

# Environment variables that influence credential resolution. Cleared for every
# test so a developer's own shell (e.g. an exported RDT_COOKIE) can't leak in and
# make credential tests flaky.
_RDT_ENV_VARS = ("RDT_COOKIE", "RDT_SESSION", "RDT_MODHASH")


@pytest.fixture(autouse=True)
def _clear_rdt_env(monkeypatch):
for var in _RDT_ENV_VARS:
monkeypatch.delenv(var, raising=False)
Loading