Initial commit with translated description
This commit is contained in:
154
README.md
Normal file
154
README.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Reddit Skill for Clawdbot
|
||||
|
||||
Browse, search, post to, and moderate any subreddit from your agent.
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Read-only** (no setup needed):
|
||||
```bash
|
||||
node scripts/reddit.mjs posts news --limit 5
|
||||
node scripts/reddit.mjs search all "breaking news"
|
||||
```
|
||||
|
||||
**Posting & Moderation** (requires OAuth):
|
||||
1. Create a Reddit app at https://www.reddit.com/prefs/apps
|
||||
2. Set environment variables (see Setup below)
|
||||
3. Run `node scripts/reddit.mjs login` once to authorize
|
||||
|
||||
---
|
||||
|
||||
## Setup for Posting/Moderation
|
||||
|
||||
### 1. Create a Reddit App
|
||||
|
||||
1. Go to https://www.reddit.com/prefs/apps
|
||||
2. Scroll down and click **"create another app..."**
|
||||
3. Fill in:
|
||||
- **name**: anything (e.g., "clawdbot")
|
||||
- **type**: select **script**
|
||||
- **redirect uri**: `http://localhost:8080/callback`
|
||||
4. Click **Create app**
|
||||
5. Note your:
|
||||
- **Client ID** — the string under your app name
|
||||
- **Client Secret** — labeled "secret"
|
||||
|
||||
### 2. Set Environment Variables
|
||||
|
||||
Add these to your shell profile or Clawdbot's environment:
|
||||
|
||||
```bash
|
||||
export REDDIT_CLIENT_ID="your_client_id"
|
||||
export REDDIT_CLIENT_SECRET="your_client_secret"
|
||||
export REDDIT_USERNAME="your_reddit_username"
|
||||
export REDDIT_PASSWORD="your_reddit_password"
|
||||
```
|
||||
|
||||
### 3. Authorize (One Time)
|
||||
|
||||
```bash
|
||||
node scripts/reddit.mjs login
|
||||
```
|
||||
|
||||
This opens a browser for OAuth. After authorizing, a token is saved to `~/.reddit-token.json` and auto-refreshes.
|
||||
|
||||
---
|
||||
|
||||
## Personalizing the Skill
|
||||
|
||||
The `SKILL.md` file tells your agent how to use this skill. You'll want to customize it for your setup:
|
||||
|
||||
### Update the Examples
|
||||
|
||||
Replace the generic subreddit names (`wallstreetbets`, `yoursubreddit`) with the ones you actually use:
|
||||
|
||||
```markdown
|
||||
# Before
|
||||
node {baseDir}/scripts/reddit.mjs posts wallstreetbets
|
||||
|
||||
# After
|
||||
node {baseDir}/scripts/reddit.mjs posts mysubreddit
|
||||
```
|
||||
|
||||
### Add Your Subreddits to the Notes
|
||||
|
||||
At the bottom of `SKILL.md`, add a section listing your subreddits:
|
||||
|
||||
```markdown
|
||||
## My Subreddits
|
||||
|
||||
- **r/mysubreddit** — I'm a mod here (full access)
|
||||
- **r/interestingtopic** — I follow this one
|
||||
- **r/anotherone** — Read-only
|
||||
```
|
||||
|
||||
This helps your agent know what it can do where.
|
||||
|
||||
### Customize the User-Agent (Optional)
|
||||
|
||||
In `scripts/reddit.mjs`, you can personalize the User-Agent string:
|
||||
|
||||
```javascript
|
||||
// Find this line near the top:
|
||||
const USER_AGENT = 'script:clawdbot-reddit:v1.0.0';
|
||||
|
||||
// Change to something like:
|
||||
const USER_AGENT = 'script:my-reddit-bot:v1.0.0 (by /u/your_username)';
|
||||
```
|
||||
|
||||
Reddit recommends including your username so they can contact you if needed.
|
||||
|
||||
---
|
||||
|
||||
## Commands Reference
|
||||
|
||||
| Command | Auth Required | Description |
|
||||
|---------|---------------|-------------|
|
||||
| `posts <subreddit>` | No | Get hot/new/top posts |
|
||||
| `search <subreddit\|all> <query>` | No | Search posts |
|
||||
| `comments <post_id>` | No | Get comments on a post |
|
||||
| `submit <subreddit> --title "..." --text "..."` | Yes | Create a text post |
|
||||
| `submit <subreddit> --title "..." --url "..."` | Yes | Create a link post |
|
||||
| `reply <thing_id> "text"` | Yes | Reply to a post or comment |
|
||||
| `mod remove <thing_id>` | Yes + Mod | Remove post/comment |
|
||||
| `mod approve <thing_id>` | Yes + Mod | Approve post/comment |
|
||||
| `mod sticky <post_id>` | Yes + Mod | Sticky a post |
|
||||
| `mod queue <subreddit>` | Yes + Mod | View mod queue |
|
||||
| `login` | — | Start OAuth flow |
|
||||
| `whoami` | Yes | Check logged-in user |
|
||||
|
||||
### Options
|
||||
|
||||
- `--sort hot|new|top|controversial` — Sort order for posts
|
||||
- `--time day|week|month|year|all` — Time filter for top/controversial
|
||||
- `--limit N` — Number of results (default: 25)
|
||||
|
||||
---
|
||||
|
||||
## Rate Limits
|
||||
|
||||
- **With OAuth**: ~60 requests/minute
|
||||
- **Without OAuth**: ~10 requests/minute
|
||||
|
||||
The skill handles token refresh automatically.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Missing REDDIT_CLIENT_ID or REDDIT_CLIENT_SECRET"**
|
||||
→ Environment variables aren't set. Check your shell profile or Clawdbot config.
|
||||
|
||||
**"Not logged in. Run: node reddit.mjs login"**
|
||||
→ You need to authorize first. Run the login command.
|
||||
|
||||
**"Reddit returned HTML instead of JSON"**
|
||||
→ Reddit sometimes does this under load. Wait a moment and try again.
|
||||
|
||||
**Token file location**: `~/.reddit-token.json`
|
||||
→ Delete this file to force re-authorization.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — do whatever you want with it.
|
||||
103
SKILL.md
Normal file
103
SKILL.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: reddit
|
||||
description: "浏览、搜索、发布和管理Reddit。无需身份验证即可只读;发布/管理需要OAuth设置。"
|
||||
metadata: {"clawdbot":{"emoji":"📣","requires":{"bins":["node"]}}}
|
||||
---
|
||||
|
||||
# Reddit
|
||||
|
||||
Browse, search, post to, and moderate subreddits. Read-only actions work without auth; posting/moderation requires OAuth setup.
|
||||
|
||||
## Setup (for posting/moderation)
|
||||
|
||||
1. Go to https://www.reddit.com/prefs/apps
|
||||
2. Click "create another app..."
|
||||
3. Select "script" type
|
||||
4. Set redirect URI to `http://localhost:8080`
|
||||
5. Note your client ID (under app name) and client secret
|
||||
6. Set environment variables:
|
||||
```bash
|
||||
export REDDIT_CLIENT_ID="your_client_id"
|
||||
export REDDIT_CLIENT_SECRET="your_client_secret"
|
||||
export REDDIT_USERNAME="your_username"
|
||||
export REDDIT_PASSWORD="your_password"
|
||||
```
|
||||
|
||||
## Read Posts (no auth required)
|
||||
|
||||
```bash
|
||||
# Hot posts from a subreddit
|
||||
node {baseDir}/scripts/reddit.mjs posts wallstreetbets
|
||||
|
||||
# New posts
|
||||
node {baseDir}/scripts/reddit.mjs posts wallstreetbets --sort new
|
||||
|
||||
# Top posts (day/week/month/year/all)
|
||||
node {baseDir}/scripts/reddit.mjs posts wallstreetbets --sort top --time week
|
||||
|
||||
# Limit results
|
||||
node {baseDir}/scripts/reddit.mjs posts wallstreetbets --limit 5
|
||||
```
|
||||
|
||||
## Search Posts
|
||||
|
||||
```bash
|
||||
# Search within a subreddit
|
||||
node {baseDir}/scripts/reddit.mjs search wallstreetbets "YOLO"
|
||||
|
||||
# Search all of Reddit
|
||||
node {baseDir}/scripts/reddit.mjs search all "stock picks"
|
||||
```
|
||||
|
||||
## Get Comments on a Post
|
||||
|
||||
```bash
|
||||
# By post ID or full URL
|
||||
node {baseDir}/scripts/reddit.mjs comments POST_ID
|
||||
node {baseDir}/scripts/reddit.mjs comments "https://reddit.com/r/subreddit/comments/abc123/..."
|
||||
```
|
||||
|
||||
## Submit a Post (requires auth)
|
||||
|
||||
```bash
|
||||
# Text post
|
||||
node {baseDir}/scripts/reddit.mjs submit yoursubreddit --title "Weekly Discussion" --text "What's on your mind?"
|
||||
|
||||
# Link post
|
||||
node {baseDir}/scripts/reddit.mjs submit yoursubreddit --title "Great article" --url "https://example.com/article"
|
||||
```
|
||||
|
||||
## Reply to a Post/Comment (requires auth)
|
||||
|
||||
```bash
|
||||
node {baseDir}/scripts/reddit.mjs reply THING_ID "Your reply text here"
|
||||
```
|
||||
|
||||
## Moderation (requires auth + mod permissions)
|
||||
|
||||
```bash
|
||||
# Remove a post/comment
|
||||
node {baseDir}/scripts/reddit.mjs mod remove THING_ID
|
||||
|
||||
# Approve a post/comment
|
||||
node {baseDir}/scripts/reddit.mjs mod approve THING_ID
|
||||
|
||||
# Sticky a post
|
||||
node {baseDir}/scripts/reddit.mjs mod sticky POST_ID
|
||||
|
||||
# Unsticky
|
||||
node {baseDir}/scripts/reddit.mjs mod unsticky POST_ID
|
||||
|
||||
# Lock comments
|
||||
node {baseDir}/scripts/reddit.mjs mod lock POST_ID
|
||||
|
||||
# View modqueue
|
||||
node {baseDir}/scripts/reddit.mjs mod queue yoursubreddit
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Read actions use Reddit's public JSON API (no auth needed)
|
||||
- Post/mod actions require OAuth - run `login` command once to authorize
|
||||
- Token stored at `~/.reddit-token.json` (auto-refreshes)
|
||||
- Rate limits: ~60 requests/minute for OAuth, ~10/minute for unauthenticated
|
||||
6
_meta.json
Normal file
6
_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn77kg8v670jkmnjb96amp5ywd7yjr0k",
|
||||
"slug": "reddit",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1767605786690
|
||||
}
|
||||
549
scripts/reddit.mjs
Normal file
549
scripts/reddit.mjs
Normal file
@@ -0,0 +1,549 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Reddit CLI - Browse, search, post, and moderate subreddits
|
||||
*
|
||||
* Read-only: Uses public JSON API (no auth)
|
||||
* Write/Mod: Requires OAuth - run `login` command first
|
||||
*/
|
||||
|
||||
import { createServer } from 'http';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
const BASE_URL = 'https://www.reddit.com';
|
||||
const OAUTH_URL = 'https://oauth.reddit.com';
|
||||
const USER_AGENT = 'script:clawdbot-reddit:v1.0.0';
|
||||
const TOKEN_FILE = join(homedir(), '.reddit-token.json');
|
||||
const REDIRECT_URI = 'http://localhost:8080/callback';
|
||||
const SCOPES = 'read submit edit identity mysubreddits modposts modcontributors modmail modconfig modlog modself flair';
|
||||
|
||||
let tokenCache = null;
|
||||
|
||||
function getClientCreds() {
|
||||
const { REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET } = process.env;
|
||||
if (!REDDIT_CLIENT_ID || !REDDIT_CLIENT_SECRET) {
|
||||
throw new Error('Missing REDDIT_CLIENT_ID or REDDIT_CLIENT_SECRET');
|
||||
}
|
||||
return { clientId: REDDIT_CLIENT_ID, clientSecret: REDDIT_CLIENT_SECRET };
|
||||
}
|
||||
|
||||
function loadToken() {
|
||||
if (tokenCache) return tokenCache;
|
||||
if (!existsSync(TOKEN_FILE)) return null;
|
||||
try {
|
||||
tokenCache = JSON.parse(readFileSync(TOKEN_FILE, 'utf-8'));
|
||||
return tokenCache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveToken(token) {
|
||||
tokenCache = token;
|
||||
writeFileSync(TOKEN_FILE, JSON.stringify(token, null, 2));
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
const token = loadToken();
|
||||
if (!token?.refresh_token) {
|
||||
throw new Error('Not logged in. Run: node reddit.mjs login');
|
||||
}
|
||||
|
||||
const { clientId, clientSecret } = getClientCreds();
|
||||
const auth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
||||
|
||||
const res = await fetch('https://www.reddit.com/api/v1/access_token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: token.refresh_token,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Token refresh failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const newToken = {
|
||||
access_token: data.access_token,
|
||||
refresh_token: token.refresh_token, // Reddit doesn't always return a new refresh token
|
||||
expires_at: Date.now() + (data.expires_in * 1000),
|
||||
};
|
||||
saveToken(newToken);
|
||||
return newToken.access_token;
|
||||
}
|
||||
|
||||
async function getAccessToken() {
|
||||
const token = loadToken();
|
||||
if (!token) {
|
||||
throw new Error('Not logged in. Run: node reddit.mjs login');
|
||||
}
|
||||
|
||||
// Refresh if expired or expiring soon (5 min buffer)
|
||||
if (Date.now() > (token.expires_at - 300000)) {
|
||||
return refreshAccessToken();
|
||||
}
|
||||
|
||||
return token.access_token;
|
||||
}
|
||||
|
||||
async function publicFetch(path) {
|
||||
// Insert .json before query string if present
|
||||
let url;
|
||||
if (path.includes('?')) {
|
||||
const [basePath, query] = path.split('?');
|
||||
url = `${BASE_URL}${basePath}.json?${query}`;
|
||||
} else {
|
||||
url = `${BASE_URL}${path}.json`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Request failed: ${res.status} ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
// Check if we got HTML instead of JSON (Reddit sometimes does this)
|
||||
if (text.trim().startsWith('<')) {
|
||||
throw new Error('Reddit returned HTML instead of JSON. Try again in a moment.');
|
||||
}
|
||||
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
async function oauthFetch(path, options = {}) {
|
||||
const token = await getAccessToken();
|
||||
const url = `${OAUTH_URL}${path}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'User-Agent': USER_AGENT,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Request failed: ${res.status} ${text.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function oauthPost(path, data) {
|
||||
return oauthFetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(data),
|
||||
});
|
||||
}
|
||||
|
||||
// OAuth Login Flow
|
||||
|
||||
async function login() {
|
||||
const { clientId, clientSecret } = getClientCreds();
|
||||
|
||||
const state = Math.random().toString(36).slice(2);
|
||||
const authUrl = `https://www.reddit.com/api/v1/authorize?client_id=${clientId}&response_type=code&state=${state}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&duration=permanent&scope=${encodeURIComponent(SCOPES)}`;
|
||||
|
||||
console.log('\n🔐 Reddit OAuth Login\n');
|
||||
console.log('Open this URL in your browser:\n');
|
||||
console.log(authUrl);
|
||||
console.log('\nWaiting for authorization...\n');
|
||||
|
||||
// Start local server to catch the callback
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer(async (req, res) => {
|
||||
if (!req.url.startsWith('/callback')) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url, 'http://localhost:8080');
|
||||
const code = url.searchParams.get('code');
|
||||
const returnedState = url.searchParams.get('state');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
res.writeHead(400);
|
||||
res.end(`Authorization failed: ${error}`);
|
||||
server.close();
|
||||
reject(new Error(`Authorization failed: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (returnedState !== state) {
|
||||
res.writeHead(400);
|
||||
res.end('State mismatch - possible CSRF attack');
|
||||
server.close();
|
||||
reject(new Error('State mismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
const auth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
||||
|
||||
try {
|
||||
const tokenRes = await fetch('https://www.reddit.com/api/v1/access_token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
throw new Error(`Token exchange failed: ${await tokenRes.text()}`);
|
||||
}
|
||||
|
||||
const tokenData = await tokenRes.json();
|
||||
|
||||
const token = {
|
||||
access_token: tokenData.access_token,
|
||||
refresh_token: tokenData.refresh_token,
|
||||
expires_at: Date.now() + (tokenData.expires_in * 1000),
|
||||
};
|
||||
|
||||
saveToken(token);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end('<html><body><h1>✅ Logged in!</h1><p>You can close this tab.</p></body></html>');
|
||||
|
||||
console.log('✅ Successfully logged in! Token saved to ~/.reddit-token.json\n');
|
||||
|
||||
server.close();
|
||||
resolve();
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
res.end(`Error: ${err.message}`);
|
||||
server.close();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(8080, () => {
|
||||
// Try to open browser automatically
|
||||
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
||||
exec(`${cmd} "${authUrl}"`);
|
||||
});
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(() => {
|
||||
server.close();
|
||||
reject(new Error('Login timed out'));
|
||||
}, 300000);
|
||||
});
|
||||
}
|
||||
|
||||
// Commands
|
||||
|
||||
async function getPosts(subreddit, { sort = 'hot', time = 'day', limit = 25 }) {
|
||||
const timeSuffix = sort === 'top' || sort === 'controversial' ? `?t=${time}&limit=${limit}` : `?limit=${limit}`;
|
||||
const data = await publicFetch(`/r/${subreddit}/${sort}${timeSuffix}`);
|
||||
|
||||
const posts = data.data.children.map(p => ({
|
||||
id: p.data.id,
|
||||
title: p.data.title,
|
||||
author: p.data.author,
|
||||
score: p.data.score,
|
||||
comments: p.data.num_comments,
|
||||
url: p.data.url,
|
||||
permalink: `https://reddit.com${p.data.permalink}`,
|
||||
created: new Date(p.data.created_utc * 1000).toISOString(),
|
||||
selftext: p.data.selftext?.slice(0, 500) || null,
|
||||
flair: p.data.link_flair_text || null,
|
||||
}));
|
||||
|
||||
console.log(JSON.stringify(posts, null, 2));
|
||||
}
|
||||
|
||||
async function searchPosts(subreddit, query, { sort = 'relevance', time = 'all', limit = 25 }) {
|
||||
const path = subreddit === 'all'
|
||||
? `/search?q=${encodeURIComponent(query)}&sort=${sort}&t=${time}&limit=${limit}`
|
||||
: `/r/${subreddit}/search?q=${encodeURIComponent(query)}&restrict_sr=on&sort=${sort}&t=${time}&limit=${limit}`;
|
||||
|
||||
const data = await publicFetch(path);
|
||||
|
||||
const posts = data.data.children.map(p => ({
|
||||
id: p.data.id,
|
||||
subreddit: p.data.subreddit,
|
||||
title: p.data.title,
|
||||
author: p.data.author,
|
||||
score: p.data.score,
|
||||
comments: p.data.num_comments,
|
||||
permalink: `https://reddit.com${p.data.permalink}`,
|
||||
created: new Date(p.data.created_utc * 1000).toISOString(),
|
||||
}));
|
||||
|
||||
console.log(JSON.stringify(posts, null, 2));
|
||||
}
|
||||
|
||||
async function getComments(postId, { limit = 50 }) {
|
||||
// Handle full URLs
|
||||
if (postId.startsWith('http')) {
|
||||
const match = postId.match(/comments\/([a-z0-9]+)/i);
|
||||
if (match) postId = match[1];
|
||||
}
|
||||
|
||||
// Need to find the subreddit - fetch post info first
|
||||
const postData = await publicFetch(`/by_id/t3_${postId}`);
|
||||
const subreddit = postData.data.children[0]?.data?.subreddit;
|
||||
|
||||
if (!subreddit) throw new Error('Could not find post');
|
||||
|
||||
const data = await publicFetch(`/r/${subreddit}/comments/${postId}?limit=${limit}`);
|
||||
|
||||
function parseComments(children, depth = 0) {
|
||||
const results = [];
|
||||
for (const c of children) {
|
||||
if (c.kind !== 't1') continue;
|
||||
results.push({
|
||||
id: c.data.id,
|
||||
author: c.data.author,
|
||||
body: c.data.body?.slice(0, 1000),
|
||||
score: c.data.score,
|
||||
depth,
|
||||
created: new Date(c.data.created_utc * 1000).toISOString(),
|
||||
});
|
||||
if (c.data.replies?.data?.children) {
|
||||
results.push(...parseComments(c.data.replies.data.children, depth + 1));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const comments = parseComments(data[1].data.children);
|
||||
console.log(JSON.stringify(comments, null, 2));
|
||||
}
|
||||
|
||||
async function submitPost(subreddit, { title, text, url }) {
|
||||
const data = {
|
||||
sr: subreddit,
|
||||
title,
|
||||
kind: url ? 'link' : 'self',
|
||||
api_type: 'json',
|
||||
};
|
||||
|
||||
if (url) data.url = url;
|
||||
if (text) data.text = text;
|
||||
|
||||
const result = await oauthPost('/api/submit', data);
|
||||
|
||||
if (result.json?.errors?.length) {
|
||||
throw new Error(`Submit failed: ${JSON.stringify(result.json.errors)}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
url: result.json?.data?.url,
|
||||
id: result.json?.data?.id,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
async function reply(thingId, text) {
|
||||
// Ensure proper prefix
|
||||
if (!thingId.startsWith('t1_') && !thingId.startsWith('t3_')) {
|
||||
// Guess based on length - posts are usually shorter IDs
|
||||
thingId = `t1_${thingId}`; // Assume comment by default
|
||||
}
|
||||
|
||||
const result = await oauthPost('/api/comment', {
|
||||
thing_id: thingId,
|
||||
text,
|
||||
api_type: 'json',
|
||||
});
|
||||
|
||||
if (result.json?.errors?.length) {
|
||||
throw new Error(`Reply failed: ${JSON.stringify(result.json.errors)}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
id: result.json?.data?.things?.[0]?.data?.id,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
async function modAction(action, thingId, subreddit) {
|
||||
// Ensure proper prefix for IDs
|
||||
if (thingId && !thingId.startsWith('t1_') && !thingId.startsWith('t3_')) {
|
||||
thingId = `t3_${thingId}`; // Assume post
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'remove':
|
||||
await oauthPost('/api/remove', { id: thingId, spam: false });
|
||||
console.log(JSON.stringify({ success: true, action: 'removed', id: thingId }));
|
||||
break;
|
||||
|
||||
case 'approve':
|
||||
await oauthPost('/api/approve', { id: thingId });
|
||||
console.log(JSON.stringify({ success: true, action: 'approved', id: thingId }));
|
||||
break;
|
||||
|
||||
case 'sticky':
|
||||
await oauthPost('/api/set_subreddit_sticky', { id: thingId, state: true });
|
||||
console.log(JSON.stringify({ success: true, action: 'stickied', id: thingId }));
|
||||
break;
|
||||
|
||||
case 'unsticky':
|
||||
await oauthPost('/api/set_subreddit_sticky', { id: thingId, state: false });
|
||||
console.log(JSON.stringify({ success: true, action: 'unstickied', id: thingId }));
|
||||
break;
|
||||
|
||||
case 'lock':
|
||||
await oauthPost('/api/lock', { id: thingId });
|
||||
console.log(JSON.stringify({ success: true, action: 'locked', id: thingId }));
|
||||
break;
|
||||
|
||||
case 'unlock':
|
||||
await oauthPost('/api/unlock', { id: thingId });
|
||||
console.log(JSON.stringify({ success: true, action: 'unlocked', id: thingId }));
|
||||
break;
|
||||
|
||||
case 'queue':
|
||||
if (!subreddit) throw new Error('Subreddit required for modqueue');
|
||||
const data = await oauthFetch(`/r/${subreddit}/about/modqueue?limit=25`);
|
||||
const items = data.data.children.map(p => ({
|
||||
id: p.data.id,
|
||||
type: p.kind === 't1' ? 'comment' : 'post',
|
||||
author: p.data.author,
|
||||
title: p.data.title || p.data.body?.slice(0, 100),
|
||||
reports: p.data.num_reports,
|
||||
created: new Date(p.data.created_utc * 1000).toISOString(),
|
||||
}));
|
||||
console.log(JSON.stringify(items, null, 2));
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown mod action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function whoami() {
|
||||
const data = await oauthFetch('/api/v1/me');
|
||||
console.log(JSON.stringify({
|
||||
username: data.name,
|
||||
id: data.id,
|
||||
karma: data.total_karma,
|
||||
created: new Date(data.created_utc * 1000).toISOString(),
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
// CLI Parser
|
||||
|
||||
function parseArgs(args) {
|
||||
const result = { _: [] };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg.startsWith('--')) {
|
||||
const key = arg.slice(2);
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith('--')) {
|
||||
result[key] = next;
|
||||
i++;
|
||||
} else {
|
||||
result[key] = true;
|
||||
}
|
||||
} else {
|
||||
result._.push(arg);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const [command, ...rest] = args._;
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'login': {
|
||||
await login();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'whoami': {
|
||||
await whoami();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'posts': {
|
||||
const [subreddit] = rest;
|
||||
if (!subreddit) throw new Error('Usage: posts <subreddit> [--sort hot|new|top] [--time day|week|month|year|all] [--limit N]');
|
||||
await getPosts(subreddit, args);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'search': {
|
||||
const [subreddit, ...queryParts] = rest;
|
||||
const query = queryParts.join(' ');
|
||||
if (!subreddit || !query) throw new Error('Usage: search <subreddit|all> <query> [--sort relevance|top|new] [--time all|day|week|month|year] [--limit N]');
|
||||
await searchPosts(subreddit, query, args);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'comments': {
|
||||
const [postId] = rest;
|
||||
if (!postId) throw new Error('Usage: comments <post_id|url> [--limit N]');
|
||||
await getComments(postId, args);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'submit': {
|
||||
const [subreddit] = rest;
|
||||
if (!subreddit || !args.title) throw new Error('Usage: submit <subreddit> --title "Title" [--text "Body"] [--url "URL"]');
|
||||
await submitPost(subreddit, args);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'reply': {
|
||||
const [thingId, ...textParts] = rest;
|
||||
const text = textParts.join(' ');
|
||||
if (!thingId || !text) throw new Error('Usage: reply <thing_id> <text>');
|
||||
await reply(thingId, text);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'mod': {
|
||||
const [action, targetOrSubreddit] = rest;
|
||||
if (!action) throw new Error('Usage: mod <remove|approve|sticky|unsticky|lock|unlock|queue> <thing_id|subreddit>');
|
||||
await modAction(action, targetOrSubreddit, targetOrSubreddit);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(`Commands: login, whoami, posts, search, comments, submit, reply, mod`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user