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
17 changes: 16 additions & 1 deletion 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`); publishing is reCAPTCHA-gated and needs a browser token (`--recaptcha-token`)
- 🛡️ **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 @@ -120,8 +121,21 @@ 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

# ─── Create posts (require login) ─────────────────
# Drafts save headlessly. Publishing is gated behind reCAPTCHA Enterprise, so it
# needs a --recaptcha-token captured from a browser (single-use, ~2 min).
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 --recaptcha-token <tok> # Publish image post
rdt post u_yourname "On my profile" --text "hi" --recaptcha-token <tok>
```

> **Note:** Reddit drafts store text/link only — not images (image drafts aren't
> supported by Reddit). And publishing any post requires a reCAPTCHA Enterprise
> token that only a browser can produce; rdt-cli never solves the captcha.

## Authentication

rdt-cli supports browser cookie extraction to authenticate with Reddit:
Expand Down Expand Up @@ -219,7 +233,8 @@ rdt_cli/
├── browse.py # feed, popular, all, sub, sub-info, 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
└── submit.py # post (text/link/image, drafts)
```

## Development
Expand Down
17 changes: 16 additions & 1 deletion 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, post) require auth + built-in 1.5-4s delay

## Command Reference

Expand Down Expand Up @@ -126,6 +126,21 @@ Payloads live under `.data`.
| `rdt subscribe <sub> --undo` | Unsubscribe | `rdt subscribe python --undo` |
| `rdt comment <id> <text>` | Post a comment | `rdt comment 3 "Great post!"` |

### 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) — it needs a `--recaptcha-token` captured from a browser (single-use, ~2 min). rdt-cli never solves the captcha.

| 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 | `rdt post python "Hi" --text "..." --recaptcha-token <tok>` |
| `rdt post <sub> <title> --image <file> --recaptcha-token <tok>` | Publish image post | `rdt post pics "Cat" --image cat.jpg --recaptcha-token <tok>` |

Optional flags: `--nsfw`, `--spoiler`. Notes: Reddit drafts can't store images (text/link only); `--image` requires publishing with a token.

### Account

| Command | Description |
Expand Down
6 changes: 5 additions & 1 deletion rdt_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 @@ -71,6 +71,10 @@ def cli(ctx: click.Context, verbose: bool) -> None:
cli.add_command(social.subscribe)
cli.add_command(social.comment)

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

cli.add_command(submit.post)


if __name__ == "__main__":
cli()
186 changes: 185 additions & 1 deletion rdt_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,28 @@
from __future__ import annotations

import logging
import mimetypes
import uuid
from pathlib import Path
from typing import Any

import httpx

from .config import DEFAULT_CONFIG, RuntimeConfig
from .constants import (
ALL_URL,
BASE_URL,
COMMENT_URL,
DEFAULT_LIMIT,
GRAPHQL_URL,
HOME_URL,
IMAGE_MIME_TYPES,
MEDIA_S3_HOST,
MORECHILDREN_URL,
OP_CREATE_DRAFT,
OP_CREATE_POST,
OP_CREATE_PROFILE_POST,
OP_MEDIA_LEASE,
POPULAR_URL,
POST_COMMENTS_SHORT_URL,
POST_COMMENTS_URL,
Expand All @@ -34,7 +47,7 @@
)
from .fingerprint import BrowserFingerprint
from .session import SessionState
from .transports import ReadTransport, WriteTransport
from .transports import SSL_CONTEXT, ReadTransport, WriteTransport

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -351,6 +364,177 @@ def post_comment(self, parent_fullname: str, text: str) -> dict:
"""Post a comment."""
return self._post(COMMENT_URL, data={"parent": parent_fullname, "text": text})

# ── Post creation (GraphQL) ─────────────────────────────────────

def _graphql(self, operation: str, variables: dict[str, Any]) -> Any:
"""POST a shreddit GraphQL operation and return its ``data`` payload.

Sends ``{operation, variables, csrf_token}`` as JSON. The csrf_token is
echoed into the write transport's cookie jar so cookie == body (Reddit's
double-submit CSRF). Raises RedditApiError on GraphQL-level errors.
"""
if self._write_transport is None:
raise RuntimeError("Client not initialized. Use 'with RedditClient() as client:'")
token = self.session.ensure_csrf_token()
self._write_transport.client.cookies.set("csrf_token", token)
payload = {"operation": operation, "variables": variables, "csrf_token": token}
result = self._write_request("POST", GRAPHQL_URL, json=payload)
if isinstance(result, dict) and result.get("errors"):
errors = result["errors"]
msg = "; ".join(
e.get("message", str(e)) if isinstance(e, dict) else str(e) for e in errors
) or str(errors)
raise RedditApiError(f"GraphQL {operation} failed: {msg}")
if isinstance(result, dict):
return result.get("data", result)
return result

@staticmethod
def _markdown_content(body: str | None) -> dict[str, Any]:
"""Content payload for a markdown body (empty dict for no body)."""
return {"markdown": body} if body else {}

def resolve_subreddit_id(self, subreddit: str) -> str:
"""Resolve a subreddit name to its ``t5_`` fullname (for GraphQL input)."""
about = self.get_subreddit_about(subreddit)
sub_id = about.get("name") if isinstance(about, dict) else None
if not sub_id:
raise RedditApiError(f"Could not resolve subreddit r/{subreddit}")
return sub_id

def create_media_lease(self, mimetype_token: str) -> dict[str, Any]:
"""Request an S3 upload lease for an image (``mimetype_token`` e.g. 'JPEG')."""
data = self._graphql(OP_MEDIA_LEASE, {"input": {"mimetype": mimetype_token}})
lease = data.get("createMediaUploadLease") if isinstance(data, dict) else None
if not isinstance(lease, dict) or not lease.get("uploadLease"):
raise RedditApiError("Media lease request returned no upload lease")
return lease

def upload_image(self, path: str) -> str:
"""Upload an image to Reddit's S3 and return its media asset id.

Flow: lease (GraphQL) → multipart POST to the S3 URL from the lease
(lease fields verbatim + ``file`` last, no reddit cookies) → mediaId.
"""
file_path = Path(path)
if not file_path.is_file():
raise RedditApiError(f"Image file not found: {path}")
content_type, _ = mimetypes.guess_type(file_path.name)
if content_type not in IMAGE_MIME_TYPES:
raise RedditApiError(
f"Unsupported image type for {path} (allowed: {', '.join(sorted(IMAGE_MIME_TYPES))})"
)
token = content_type.split("/")[-1].upper() # image/jpeg → JPEG

lease = self.create_media_lease(token)
upload_lease = lease["uploadLease"]
upload_url = upload_lease["uploadLeaseUrl"]
if upload_url.startswith("//"):
upload_url = "https:" + upload_url
fields = {h["header"]: h["value"] for h in upload_lease.get("uploadLeaseHeaders", [])}
media_id = lease.get("mediaId")
if not media_id:
raise RedditApiError("Media lease did not return a mediaId")

# Separate client: cross-site S3 host, must NOT carry reddit cookies.
headers = {
"User-Agent": self._fingerprint.user_agent,
"Origin": BASE_URL,
"Referer": f"{BASE_URL}/",
}
with httpx.Client(
follow_redirects=True, timeout=httpx.Timeout(self._timeout), verify=SSL_CONTEXT
) as s3:
resp = s3.post(
upload_url,
data=fields,
files={"file": (file_path.name, file_path.read_bytes(), content_type)},
headers=headers,
)
if resp.status_code not in (200, 201):
raise RedditApiError(f"Image upload failed (HTTP {resp.status_code})")
return media_id

def create_post(
self,
subreddit_id: str,
title: str,
*,
recaptcha_token: str,
kind: str = "self",
body: str | None = None,
url: str | None = None,
media_id: str | None = None,
nsfw: bool = False,
spoiler: bool = False,
is_profile: bool = False,
) -> Any:
"""Publish a post (subreddit ``CreatePost`` / profile ``CreateProfilePost``).

Reddit gates post submission behind **reCAPTCHA Enterprise** (invisible,
score-based, action ``post_submit``). ``recaptcha_token`` MUST be a fresh
token obtained from a browser (single-use, ~2 min TTL) — it cannot be
produced headlessly, so this call fails without one. Field shapes come
from captured live requests + ``ValidateCreatePostInput``:
self→``content.markdown``, link→``url``, image→``gallery.items[].mediaId``
(subreddit) or ``image.url`` (profile).
"""
inp: dict[str, Any] = {
"title": title,
"isNsfw": bool(nsfw),
"isSpoiler": bool(spoiler),
"content": self._markdown_content(body) if kind == "self" else {},
"recaptchaToken": recaptcha_token,
"correlationId": str(uuid.uuid4()),
}
if is_profile:
inp["isCommercialCommunication"] = False
inp["targetLanguage"] = ""
if kind == "link":
inp["url"] = url
elif kind == "image":
inp["image"] = {"url": f"{MEDIA_S3_HOST}/{media_id}"}
return self._graphql(OP_CREATE_PROFILE_POST, {"input": inp})

inp["subredditId"] = subreddit_id
inp["postType"] = {"self": "TEXT", "link": "LINK", "image": "IMAGE"}[kind]
if kind == "link":
inp["url"] = url
elif kind == "image":
inp["gallery"] = {"items": [{"mediaId": media_id}]}
return self._graphql(OP_CREATE_POST, {"input": inp})

def create_draft(
self,
subreddit_id: str,
title: str,
*,
body: str | None = None,
url: str | None = None,
nsfw: bool = False,
spoiler: bool = False,
) -> Any:
"""Save a post draft via ``CreateDraft`` (text or link — no captcha needed).

Reddit drafts store text (markdown) or a link URL only; they cannot hold
images (the draft input type has no media field). Text is confirmed live;
the link shape is best-effort.
"""
inp: dict[str, Any] = {
"subredditId": subreddit_id,
"title": title,
"isNsfw": bool(nsfw),
"isSpoiler": bool(spoiler),
}
if url:
inp["kind"] = "LINK"
inp["url"] = url
inp["content"] = {}
else:
inp["kind"] = "MARKDOWN"
inp["content"] = self._markdown_content(body)
return self._graphql(OP_CREATE_DRAFT, {"input": inp})

# ── Subscription feed ───────────────────────────────────────────

def get_my_subscriptions(
Expand Down
Loading