Skip to content
82 changes: 80 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ A CLI for Reddit — browse feeds, read posts, search, and interact via reverse-
- 📤 **Export** — export search results to CSV or JSON; `-o file.json` on any listing
- 👤 **Users** — view user profiles, post history, comment history, saved and upvoted items
- ⬆️ **Interactions** — upvote/downvote, save/unsave, subscribe/unsubscribe, comment (with 1.5-4s rate-limit delay)
- ✍️ **Create posts** — save text/link **drafts** headlessly (`rdt post --draft`); publish text/link/image posts with a browser reCAPTCHA token (`--recaptcha-token`) or automatic solving via solvecaptcha.com
- 🛡️ **Anti-detection** — consistent Chrome 133 fingerprint, `sec-ch-ua` alignment, Gaussian jitter, exponential backoff
- 📊 **Structured output** — `--yaml`, `--json`, `--output FILE`, `--compact`, `--full-text`
- 📦 **Stable envelope** — see [SCHEMA.md](./SCHEMA.md) for `ok/schema_version/data/error`
Expand Down Expand Up @@ -80,6 +81,7 @@ rdt all # /r/all
rdt sub python # Browse subreddit
rdt sub programming -s top -t week # Sort + time filter
rdt sub-info python # Subreddit info (subscribers, etc.)
rdt rules python # Subreddit rules
rdt user spez # User profile
rdt user-posts spez # User's submitted posts
rdt user-comments spez # User's comments
Expand Down Expand Up @@ -120,8 +122,64 @@ rdt save 3 --undo # Unsave
rdt subscribe python # Subscribe to r/python
rdt subscribe python --undo # Unsubscribe
rdt comment 3 "Great post!" # Comment on result #3
rdt delete 1abc123 -y # Delete your own post (t1_… for comments)

# ─── Create posts (require login) ─────────────────
# Drafts save headlessly. Publishing is gated behind reCAPTCHA Enterprise:
# either pass --recaptcha-token from a browser (single-use, ~2 min), or set
# RDT_SOLVECAPTCHA_API_KEY (or APIKEY_SOLVECAPTCHA) and a token is bought from
# solvecaptcha.com automatically right before publishing (paid, ~10-60s).
rdt post python "WIP title" --text "Hello **world**" --draft # Save a text draft
rdt post news "Cool read" --url https://example.com --draft # Save a link draft
rdt post python "Title" --text "body" --recaptcha-token <tok> # Publish text post
rdt post pics "My cat" --image cat.jpg # Publish image (captcha auto-solved)
rdt post u_yourname "On my profile" --text "hi" # Publish to profile
rdt post python "Guide" --text "step 1 ![img] done" --embed step1.jpg # Inline image in a text post
rdt post python "Report" --text "summary **md**" --image chart.png # Text post + attached image
rdt flairs askscience # List post flair templates (id + text)
rdt post askscience "Q" --text "…" --flair-id <template-id> # Publish with flair (some subs require it)
```

> **Post flair (live-verified):** some communities require flair. `rdt flairs
> <subreddit>` lists the templates; pass the id via `--flair-id` when
> publishing (plus `--flair-text` to override text-editable templates).
> Publish-only, community posts only. Careful: a template with empty default
> text still applies without `--flair-text`, but renders as a blank
> (invisible) label — the listing flags such templates.

> **Attached image in a text post:** `--text` + `--image` publishes a markdown
> text post with the image attached (`image.url` in the mutation input): it
> renders after the body, uncaptioned. The body stays markdown, so formatting
> works — markdown just can't *inline* images; for that use `--embed`.
>
> **Feed visibility trade-off (live-verified):** an attached image is the
> post's preview media, so it shows on feed/list cards too. Inline (`--embed`)
> images are body content only — feed cards show just the text preview, and
> the images appear only once the post is opened. Pick `--image` for feed
> visibility, `--embed` for placement and captions. Combining `--embed` with
> `--image` gives both (live-verified): the feed card shows the attached image
> (inline embeds stay feed-hidden), and the opened post shows the inline
> embeds in place *plus* the attachment after the body — so attach a distinct
> image unless you want it appearing twice in the post.

> **Inline images in text posts:** each `--embed FILE` uploads an image and
> replaces one `![img]` marker in `--text` (markers are substituted in order).
> On publish, the body is sent editor-style as RTJSON (`content.richText`,
> *instead of* markdown — Reddit stores the RTJSON canonically and derives the
> markdown export from it): New Reddit renders each marker as a real inline
> image block (caption = file name). The RTJSON body is plain text, so keep
> `--text` unformatted when embedding — markdown syntax would show literally.
> Drafts are markdown-only, so embeds there stay links to the Reddit-hosted
> image (`https://i.redd.it/<id>.<ext>`, which serves unsigned —
> `preview.redd.it` URLs don't).

> **Note:** Reddit drafts store text/link only — not images (image drafts aren't
> supported by Reddit). Publishing any post requires a reCAPTCHA Enterprise
> token: capture one from a browser, or let rdt-cli buy one via
> [Solvecaptcha](https://solvecaptcha.com) — set `RDT_SOLVECAPTCHA_API_KEY` or
> `APIKEY_SOLVECAPTCHA` (its 2captcha-compatible API is called directly over
> httpx, so no extra dependencies). Each solve is a paid API call (~10-60s).

## Authentication

rdt-cli supports browser cookie extraction to authenticate with Reddit:
Expand Down Expand Up @@ -149,6 +207,21 @@ 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_SOLVECAPTCHA_API_KEY` | — | Solvecaptcha API key — enables automatic reCAPTCHA solving when publishing posts |
| `APIKEY_SOLVECAPTCHA` | — | Fallback for the above (solvecaptcha-python's own convention) |

## Config File

`~/.config/rdt-cli/config.json` (optional) — settings applied when no env var overrides them:

```json
{
"solvecaptcha_api_key": "your-solvecaptcha-key"
}
```

Solvecaptcha key priority: `RDT_SOLVECAPTCHA_API_KEY` → `APIKEY_SOLVECAPTCHA` → config file.
A missing or invalid file is ignored silently.

## Rate Limiting & Anti-Detection

Expand Down Expand Up @@ -210,16 +283,19 @@ rdt_cli/
├── cli.py # Click entry point & command registration
├── client.py # Reddit API client (rate-limit, retry, anti-detection)
├── auth.py # Cookie authentication + TTL refresh
├── captcha.py # reCAPTCHA Enterprise solving via the Solvecaptcha API
├── constants.py # URLs, headers, sort options
├── exceptions.py # Error hierarchy (6 exception types)
├── index_cache.py # Short-index cache for show/open commands
├── richtext.py # RTJSON document building (inline images in self-posts)
└── commands/
├── _common.py # Shared helpers (envelope, output routing, formatters)
├── auth.py # login, logout, status, whoami
├── browse.py # feed, popular, all, sub, sub-info, user, user-posts, user-comments, saved, upvoted, open
├── browse.py # feed, popular, all, sub, sub-info, rules, user, user-posts, user-comments, saved, upvoted, open
├── post.py # read, show
├── search.py # search, export
└── social.py # upvote, save, subscribe, comment
├── social.py # upvote, save, subscribe, comment, delete
└── submit.py # post (text/link/image, drafts)
```

## Development
Expand Down Expand Up @@ -334,6 +410,7 @@ rdt all # /r/all
rdt sub python # 浏览子版块
rdt sub programming -s top -t week # 排序 + 时间过滤
rdt sub-info python # 子版块信息
rdt rules python # 子版块规则
rdt user spez # 用户资料
rdt user-posts spez # 用户发帖
rdt user-comments spez # 用户评论
Expand All @@ -357,6 +434,7 @@ rdt upvote 3 # 点赞
rdt save 3 # 收藏
rdt subscribe python # 订阅
rdt comment 3 "Great post!" # 评论
rdt delete 1abc123 -y # 删除自己的帖子/评论
```

## 认证策略
Expand Down
27 changes: 25 additions & 2 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Payloads live under `.data`.
- `--output file.json` → save structured output to file
- Rich output → **stderr** (safe for pipes: `rdt search X --json | jq .data`)
- Most read commands work without auth (public Reddit JSON API)
- Write actions (upvote, save, subscribe) require auth + built-in 1.5-4s delay
- Write actions (upvote, save, subscribe, comment, delete, post) require auth + built-in 1.5-4s delay

## Command Reference

Expand All @@ -87,6 +87,7 @@ Payloads live under `.data`.
| `rdt all` | Browse /r/all | `rdt all -n 10 --compact --json` |
| `rdt sub <name>` | Browse a subreddit | `rdt sub python -s top -t week` |
| `rdt sub-info <name>` | View subreddit info | `rdt sub-info rust --json` |
| `rdt rules <name>` | View subreddit rules | `rdt rules rust --json` |
| `rdt user <name>` | View user profile | `rdt user spez --json` |
| `rdt user-posts <name>` | View user's posts | `rdt user-posts spez -n 5 --json` |
| `rdt user-comments <name>` | View user's comments | `rdt user-comments spez -n 5 --json` |
Expand Down Expand Up @@ -125,6 +126,28 @@ Payloads live under `.data`.
| `rdt subscribe <sub>` | Subscribe | `rdt subscribe python` |
| `rdt subscribe <sub> --undo` | Unsubscribe | `rdt subscribe python --undo` |
| `rdt comment <id> <text>` | Post a comment | `rdt comment 3 "Great post!"` |
| `rdt delete <id> -y` | Delete your own post/comment (irreversible; `-y` skips the prompt; use `t1_<id>` for comments) | `rdt delete 1abc123 -y` |

### Create posts (require auth)

Exactly one of `--text` / `--url` / `--image`. Target a subreddit or a profile (`u_<name>`). Add `--json` for a structured result.

**Drafts** save headlessly. **Publishing** is gated behind reCAPTCHA Enterprise (invisible, score-based). Token sources, checked in order: `--recaptcha-token` captured from a browser (single-use, ~2 min), else automatic solving via solvecaptcha.com when a Solvecaptcha API key is configured — env `RDT_SOLVECAPTCHA_API_KEY` / `APIKEY_SOLVECAPTCHA`, or `solvecaptcha_api_key` in `~/.config/rdt-cli/config.json`. The key's presence opts in to a paid solve (~10-60s) right before publishing.

| Command | Description | Example |
|---------|-------------|---------|
| `rdt post <sub> <title> --text <body> --draft` | Save a text draft | `rdt post python "WIP" --text "..." --draft` |
| `rdt post <sub> <title> --url <url> --draft` | Save a link draft | `rdt post news "Read" --url https://ex.com --draft` |
| `rdt post <sub> <title> --text <body> --recaptcha-token <tok>` | Publish text post (browser token) | `rdt post python "Hi" --text "..." --recaptcha-token <tok>` |
| `rdt post <sub> <title> --image <file>` | Publish image post (captcha auto-solved if key set) | `rdt post pics "Cat" --image cat.jpg` |
| `rdt post <sub> <title> --text <body> --embed <file>` | Text post with inline image(s) | `rdt post python "Guide" --text "step 1 ![img]" --embed s1.jpg` |
| `rdt post <sub> <title> --text <body> --image <file>` | Text post + attached image (after body, no caption) | `rdt post python "Report" --text "summary" --image chart.png` |
| `rdt flairs <sub>` | List post flair templates (id + text) | `rdt flairs askscience` |
| `rdt post <sub> <title> … --flair-id <id>` | Publish with post flair (required by some subs; `--flair-text` for editable templates) | `rdt post askscience "Q" --text "…" --flair-id abc-123` |

Flair gotcha: a template with empty default text applies fine but renders as a blank label — pass `--flair-text` with it (the `rdt flairs` listing flags such templates).

Optional flags: `--nsfw`, `--spoiler`. Notes: Reddit drafts can't store images (text/link only); `--image` requires publishing with a token (flag or solver); `--embed` may combine with `--image`: the attachment becomes the feed-card image and also renders after the body in the opened post (inline embeds render in place there) — attach a distinct image to avoid an in-post duplicate. `--embed` needs one `![img]` marker per file in `--text` (substituted in order). On publish with embeds the body goes out as RTJSON (`content.richText`, replacing markdown; without embeds it stays `content.markdown`): New Reddit renders true inline image blocks with captions. Keep `--text` plain when embedding (markdown formatting isn't translated to RTJSON and shows literally). Drafts are markdown-only — embeds stay `https://i.redd.it/<id>.<ext>` links there. Feed visibility: `--embed` images show only inside the opened post (feed cards show text only); an attached `--image` also shows on feed/list cards — choose by whether feed visibility or inline placement/captions matters more.

### Account

Expand Down Expand Up @@ -237,7 +260,7 @@ Structured error codes returned in the `error.code` field (see [SCHEMA.md](./SCH
## Anti-Detection Notes for Agents

- **Do NOT parallelize requests** — the built-in rate-limit delay is for account safety
- **Write operation delay**: 1.5-4s random delay after each write (upvote/save/subscribe/comment)
- **Write operation delay**: 1.5-4s random delay after each write (upvote/save/subscribe/comment/delete)
- **Batch operations**: add delays between CLI calls when doing bulk work
- **Chrome 133 fingerprint**: all requests use consistent browser identity
- **Exponential backoff**: 429/5xx errors are auto-retried with backoff
Expand Down
143 changes: 143 additions & 0 deletions rdt_cli/captcha.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""reCAPTCHA Enterprise solving via the Solvecaptcha API (solvecaptcha.com).

Publishing a Reddit post is gated behind reCAPTCHA Enterprise (invisible,
score-based, action ``post_submit`` — see rdt_cli.client.create_post). A token
can be captured from a browser, or bought from a captcha-solving service. This
module talks to the Solvecaptcha API directly — the 2captcha-compatible
protocol that the ``solvecaptcha-python`` package wraps (POST /in.php, poll
/res.php) — over httpx, so no extra runtime dependency is needed.

API key resolution (first match wins, see SOLVECAPTCHA_KEY_ENV_VARS):
1. ``RDT_SOLVECAPTCHA_API_KEY``
2. ``APIKEY_SOLVECAPTCHA`` (solvecaptcha-python's own convention)
3. ``solvecaptcha_api_key`` in the user config file
(``~/.config/rdt-cli/config.json``)

The returned token is single-use with a ~2 minute TTL — submit the post
immediately after solving.
"""

from __future__ import annotations

import logging
import os
import time

import httpx

from .config import load_user_config
from .constants import (
BASE_URL,
RECAPTCHA_ACTION,
RECAPTCHA_SITEKEY,
SOLVECAPTCHA_API_URL,
SOLVECAPTCHA_KEY_ENV_VARS,
)
from .exceptions import RedditApiError

logger = logging.getLogger(__name__)

__all__ = [
"CaptchaSolveError",
"get_solvecaptcha_api_key",
"solve_recaptcha_token",
]


class CaptchaSolveError(RedditApiError):
"""Raised when the captcha-solving service rejects the task or times out."""

def __init__(self, message: str, code: int | str | None = None):
super().__init__(f"Captcha solving failed: {message}", code=code)


def get_solvecaptcha_api_key() -> str | None:
"""Return the Solvecaptcha API key: env vars first, then the config file."""
for var in SOLVECAPTCHA_KEY_ENV_VARS:
key = os.environ.get(var, "").strip()
if key:
return key
key = str(load_user_config().get("solvecaptcha_api_key", "")).strip()
return key or None


def solve_recaptcha_token(
api_key: str,
*,
sitekey: str = RECAPTCHA_SITEKEY,
page_url: str = f"{BASE_URL}/",
action: str = RECAPTCHA_ACTION,
min_score: float = 0.3,
timeout: float = 180.0,
polling_interval: float = 5.0,
) -> str:
"""Buy a reCAPTCHA Enterprise token for Reddit from Solvecaptcha.

Submits a score-based Enterprise task (``method=userrecaptcha``,
``version=v3``, ``enterprise=1`` — how 2captcha-style APIs model
reCAPTCHA Enterprise) and polls for the solution. ``min_score`` is the
worker's target score; scores above ~0.3 are rarely achievable. Returns
the ``g-recaptcha-response`` token (single-use, ~2 min TTL — use it
immediately). Raises CaptchaSolveError on service errors or timeout.
"""
task = {
"key": api_key,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": page_url,
"version": "v3",
"enterprise": 1,
"action": action,
"min_score": min_score,
}
with httpx.Client(base_url=SOLVECAPTCHA_API_URL, timeout=httpx.Timeout(30.0)) as http:
captcha_id = _submit(http, task)
logger.debug(
"Solvecaptcha task %s submitted (sitekey=%s, action=%s)",
captcha_id, sitekey, action,
)
return _poll(http, api_key, captcha_id, timeout=timeout, polling_interval=polling_interval)


def _submit(http: httpx.Client, task: dict) -> str:
"""POST the task to in.php and return the captcha id."""
try:
resp = http.post("/in.php", data=task)
resp.raise_for_status()
except httpx.HTTPError as exc:
raise CaptchaSolveError(f"could not reach Solvecaptcha: {exc}") from exc
body = resp.text.strip()
if body.startswith("OK|"):
return body[3:]
# ERROR_WRONG_USER_KEY, ERROR_ZERO_BALANCE, ERROR_WRONG_GOOGLEKEY, ...
raise CaptchaSolveError(f"task rejected: {body}")


def _poll(
http: httpx.Client,
api_key: str,
captcha_id: str,
*,
timeout: float,
polling_interval: float,
) -> str:
"""Poll res.php until the token is ready; returns the token."""
deadline = time.monotonic() + timeout
while True:
try:
resp = http.get(
"/res.php",
params={"key": api_key, "action": "get", "id": captcha_id},
)
resp.raise_for_status()
except httpx.HTTPError as exc:
raise CaptchaSolveError(f"result polling failed: {exc}") from exc
body = resp.text.strip()
if body.startswith("OK|"):
return body[3:]
if body == "CAPCHA_NOT_READY":
if time.monotonic() >= deadline:
raise CaptchaSolveError(f"no solution after {timeout:.0f}s")
time.sleep(polling_interval)
continue
raise CaptchaSolveError(f"service error: {body}")
11 changes: 9 additions & 2 deletions rdt_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
rdt search <query> / export <query>
rdt user <username> / user-posts <username> / user-comments <username>
rdt saved / upvoted
rdt upvote / save / subscribe / comment
rdt upvote / save / subscribe / comment / delete
"""

from __future__ import annotations
Expand All @@ -17,7 +17,7 @@
import click

from . import __version__
from .commands import auth, browse, post, search, social
from .commands import auth, browse, post, search, social, submit


@click.group()
Expand Down Expand Up @@ -47,6 +47,7 @@ def cli(ctx: click.Context, verbose: bool) -> None:
cli.add_command(browse.all_cmd)
cli.add_command(browse.sub)
cli.add_command(browse.sub_info)
cli.add_command(browse.rules)
cli.add_command(browse.user)
cli.add_command(browse.user_posts)
cli.add_command(browse.user_comments)
Expand All @@ -70,6 +71,12 @@ def cli(ctx: click.Context, verbose: bool) -> None:
cli.add_command(social.save)
cli.add_command(social.subscribe)
cli.add_command(social.comment)
cli.add_command(social.delete)

# ─── Create commands ────────────────────────────────────────────────

cli.add_command(submit.post)
cli.add_command(submit.flairs)


if __name__ == "__main__":
Expand Down
Loading