From 7f55950fed0df301e6f6c24f92c0c8e8b9761523 Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 16 Mar 2026 19:56:55 +0800 Subject: [PATCH] feat(reddit): add 11 new adapters borrowed from rdt-cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 - YAML adapters (read-only): - popular: /r/popular feed - read: read post + comments by ID - user: view user profile (karma, account age) - user-posts: user's submitted posts - user-comments: user's comment history - search: enhanced with sort/time/subreddit params - subreddit: enhanced with time filter for top/controversial Phase 2 - TypeScript adapters (write operations): - upvote: upvote/downvote posts via /api/vote - save: save/unsave posts via /api/save - comment: post comments via /api/comment - subscribe: subscribe/unsubscribe subreddits - saved: browse saved posts (auto-resolves username) - upvoted: browse upvoted posts (auto-resolves username) Reddit adapters: 4 → 15 --- src/clis/reddit/comment.ts | 60 ++++++++++++ src/clis/reddit/popular.yaml | 40 ++++++++ src/clis/reddit/read.yaml | 76 +++++++++++++++ src/clis/reddit/save.ts | 54 +++++++++++ src/clis/reddit/saved.ts | 48 ++++++++++ src/clis/reddit/search.yaml | 48 +++++++--- src/clis/reddit/subreddit.yaml | 18 +++- src/clis/reddit/subscribe.ts | 53 +++++++++++ src/clis/reddit/upvote.ts | 67 ++++++++++++++ src/clis/reddit/upvoted.ts | 48 ++++++++++ src/clis/reddit/user-comments.yaml | 45 +++++++++ src/clis/reddit/user-posts.yaml | 43 +++++++++ src/clis/reddit/user.yaml | 39 ++++++++ src/clis/twitter/thread.ts | 143 +++++++++++++++++++++++++++++ 14 files changed, 767 insertions(+), 15 deletions(-) create mode 100644 src/clis/reddit/comment.ts create mode 100644 src/clis/reddit/popular.yaml create mode 100644 src/clis/reddit/read.yaml create mode 100644 src/clis/reddit/save.ts create mode 100644 src/clis/reddit/saved.ts create mode 100644 src/clis/reddit/subscribe.ts create mode 100644 src/clis/reddit/upvote.ts create mode 100644 src/clis/reddit/upvoted.ts create mode 100644 src/clis/reddit/user-comments.yaml create mode 100644 src/clis/reddit/user-posts.yaml create mode 100644 src/clis/reddit/user.yaml create mode 100644 src/clis/twitter/thread.ts diff --git a/src/clis/reddit/comment.ts b/src/clis/reddit/comment.ts new file mode 100644 index 0000000..2065448 --- /dev/null +++ b/src/clis/reddit/comment.ts @@ -0,0 +1,60 @@ +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: 'reddit', + name: 'comment', + description: 'Post a comment on a Reddit post', + domain: 'reddit.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'post_id', type: 'string', required: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' }, + { name: 'text', type: 'string', required: true, help: 'Comment text' }, + ], + columns: ['status', 'message'], + func: async (page, kwargs) => { + if (!page) throw new Error('Requires browser'); + + await page.goto('https://www.reddit.com'); + await page.wait(3); + + const result = await page.evaluate(`(async () => { + try { + let postId = ${JSON.stringify(kwargs.post_id)}; + const urlMatch = postId.match(/comments\\/([a-z0-9]+)/); + if (urlMatch) postId = urlMatch[1]; + const fullname = postId.startsWith('t3_') || postId.startsWith('t1_') + ? postId : 't3_' + postId; + + const text = ${JSON.stringify(kwargs.text)}; + + // Get modhash + const meRes = await fetch('/api/me.json', { credentials: 'include' }); + const me = await meRes.json(); + const modhash = me?.data?.modhash || ''; + + const res = await fetch('/api/comment', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'parent=' + encodeURIComponent(fullname) + + '&text=' + encodeURIComponent(text) + + '&api_type=json' + + (modhash ? '&uh=' + encodeURIComponent(modhash) : ''), + }); + + if (!res.ok) return { ok: false, message: 'HTTP ' + res.status }; + const data = await res.json(); + const errors = data?.json?.errors; + if (errors && errors.length > 0) { + return { ok: false, message: errors.map(e => e.join(': ')).join('; ') }; + } + return { ok: true, message: 'Comment posted on ' + fullname }; + } catch (e) { + return { ok: false, message: e.toString() }; + } + })()`); + + return [{ status: result.ok ? 'success' : 'failed', message: result.message }]; + } +}); diff --git a/src/clis/reddit/popular.yaml b/src/clis/reddit/popular.yaml new file mode 100644 index 0000000..032c4c4 --- /dev/null +++ b/src/clis/reddit/popular.yaml @@ -0,0 +1,40 @@ +site: reddit +name: popular +description: Reddit Popular posts (/r/popular) +domain: reddit.com +strategy: cookie +browser: true + +args: + limit: + type: int + default: 20 + +columns: [rank, title, subreddit, score, comments, url] + +pipeline: + - navigate: https://www.reddit.com + - evaluate: | + (async () => { + const limit = ${{ args.limit }}; + const res = await fetch('/r/popular.json?limit=' + limit + '&raw_json=1', { + credentials: 'include' + }); + const d = await res.json(); + return (d?.data?.children || []).map(c => ({ + title: c.data.title, + subreddit: c.data.subreddit_name_prefixed, + score: c.data.score, + comments: c.data.num_comments, + author: c.data.author, + url: 'https://www.reddit.com' + c.data.permalink, + })); + })() + - map: + rank: ${{ index + 1 }} + title: ${{ item.title }} + subreddit: ${{ item.subreddit }} + score: ${{ item.score }} + comments: ${{ item.comments }} + url: ${{ item.url }} + - limit: ${{ args.limit }} diff --git a/src/clis/reddit/read.yaml b/src/clis/reddit/read.yaml new file mode 100644 index 0000000..5f3418b --- /dev/null +++ b/src/clis/reddit/read.yaml @@ -0,0 +1,76 @@ +site: reddit +name: read +description: Read a Reddit post and its comments +domain: reddit.com +strategy: cookie +browser: true + +args: + post_id: + type: string + required: true + description: "Post ID (e.g. 1abc123) or full URL" + sort: + type: string + default: best + description: "Comment sort: best, top, new, controversial, old, qa" + limit: + type: int + default: 25 + description: Number of top-level comments to fetch + +columns: [type, author, score, text] + +pipeline: + - navigate: https://www.reddit.com + - evaluate: | + (async () => { + let postId = ${{ args.post_id | json }}; + const urlMatch = postId.match(/comments\/([a-z0-9]+)/); + if (urlMatch) postId = urlMatch[1]; + + const sort = ${{ args.sort | json }}; + const limit = ${{ args.limit }}; + const res = await fetch('/comments/' + postId + '.json?sort=' + sort + '&limit=' + limit + '&raw_json=1', { + credentials: 'include' + }); + const data = await res.json(); + if (!Array.isArray(data) || data.length < 1) return []; + + const results = []; + + // First element: post itself + const post = data[0]?.data?.children?.[0]?.data; + if (post) { + let body = post.selftext || ''; + if (body.length > 2000) body = body.slice(0, 2000) + '\n... [truncated]'; + results.push({ + type: 'šŸ“° POST', + author: post.author, + score: post.score, + text: post.title + (body ? '\n\n' + body : '') + (post.url && !post.is_self ? '\nšŸ”— ' + post.url : ''), + }); + } + + // Second element: comments + const comments = data[1]?.data?.children || []; + for (const c of comments) { + if (c.kind !== 't1') continue; + const d = c.data; + let body = d.body || ''; + if (body.length > 500) body = body.slice(0, 500) + '...'; + results.push({ + type: 'šŸ’¬ COMMENT', + author: d.author || '[deleted]', + score: d.score || 0, + text: body, + }); + } + + return results; + })() + - map: + type: ${{ item.type }} + author: ${{ item.author }} + score: ${{ item.score }} + text: ${{ item.text }} diff --git a/src/clis/reddit/save.ts b/src/clis/reddit/save.ts new file mode 100644 index 0000000..2d712e5 --- /dev/null +++ b/src/clis/reddit/save.ts @@ -0,0 +1,54 @@ +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: 'reddit', + name: 'save', + description: 'Save or unsave a Reddit post', + domain: 'reddit.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'post_id', type: 'string', required: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' }, + { name: 'undo', type: 'boolean', default: false, help: 'Unsave instead of save' }, + ], + columns: ['status', 'message'], + func: async (page, kwargs) => { + if (!page) throw new Error('Requires browser'); + + await page.goto('https://www.reddit.com'); + await page.wait(3); + + const result = await page.evaluate(`(async () => { + try { + let postId = ${JSON.stringify(kwargs.post_id)}; + const urlMatch = postId.match(/comments\\/([a-z0-9]+)/); + if (urlMatch) postId = urlMatch[1]; + const fullname = postId.startsWith('t3_') || postId.startsWith('t1_') + ? postId : 't3_' + postId; + + const undo = ${kwargs.undo ? 'true' : 'false'}; + const endpoint = undo ? '/api/unsave' : '/api/save'; + + // Get modhash + const meRes = await fetch('/api/me.json', { credentials: 'include' }); + const me = await meRes.json(); + const modhash = me?.data?.modhash || ''; + + const res = await fetch(endpoint, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'id=' + encodeURIComponent(fullname) + + (modhash ? '&uh=' + encodeURIComponent(modhash) : ''), + }); + + if (!res.ok) return { ok: false, message: 'HTTP ' + res.status }; + return { ok: true, message: (undo ? 'Unsaved' : 'Saved') + ' ' + fullname }; + } catch (e) { + return { ok: false, message: e.toString() }; + } + })()`); + + return [{ status: result.ok ? 'success' : 'failed', message: result.message }]; + } +}); diff --git a/src/clis/reddit/saved.ts b/src/clis/reddit/saved.ts new file mode 100644 index 0000000..fea3d90 --- /dev/null +++ b/src/clis/reddit/saved.ts @@ -0,0 +1,48 @@ +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: 'reddit', + name: 'saved', + description: 'Browse your saved Reddit posts', + domain: 'reddit.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'limit', type: 'int', default: 15 }, + ], + columns: ['title', 'subreddit', 'score', 'comments', 'url'], + func: async (page, kwargs) => { + if (!page) throw new Error('Requires browser'); + + await page.goto('https://www.reddit.com'); + await page.wait(3); + + const result = await page.evaluate(`(async () => { + try { + // Get current username + const meRes = await fetch('/api/me.json?raw_json=1', { credentials: 'include' }); + const me = await meRes.json(); + const username = me?.name || me?.data?.name; + if (!username) return { error: 'Not logged in — cannot determine username' }; + + const limit = ${kwargs.limit}; + const res = await fetch('/user/' + username + '/saved.json?limit=' + limit + '&raw_json=1', { + credentials: 'include' + }); + const d = await res.json(); + return (d?.data?.children || []).map(c => ({ + title: c.data.title || c.data.body?.slice(0, 100) || '-', + subreddit: c.data.subreddit_name_prefixed || 'r/' + (c.data.subreddit || '?'), + score: c.data.score || 0, + comments: c.data.num_comments || 0, + url: 'https://www.reddit.com' + (c.data.permalink || ''), + })); + } catch (e) { + return { error: e.toString() }; + } + })()`); + + if (result?.error) throw new Error(result.error); + return (result || []).slice(0, kwargs.limit); + } +}); diff --git a/src/clis/reddit/search.yaml b/src/clis/reddit/search.yaml index 83dab93..70f5987 100644 --- a/src/clis/reddit/search.yaml +++ b/src/clis/reddit/search.yaml @@ -9,26 +9,52 @@ args: query: type: string required: true + subreddit: + type: string + default: "" + description: "Search within a specific subreddit" + sort: + type: string + default: relevance + description: "Sort order: relevance, hot, top, new, comments" + time: + type: string + default: all + description: "Time filter: hour, day, week, month, year, all" limit: type: int default: 15 -columns: [title, subreddit, author, upvotes, comments, url] +columns: [title, subreddit, author, score, comments, url] pipeline: - navigate: https://www.reddit.com - evaluate: | (async () => { - const q = encodeURIComponent('${{ args.query }}'); - const res = await fetch('/search.json?q=' + q + '&limit=${{ args.limit }}', { credentials: 'include' }); - const j = await res.json(); - return j?.data?.children || []; + const q = encodeURIComponent(${{ args.query | json }}); + const sub = ${{ args.subreddit | json }}; + const sort = ${{ args.sort | json }}; + const time = ${{ args.time | json }}; + const limit = ${{ args.limit }}; + const basePath = sub ? '/r/' + sub + '/search.json' : '/search.json'; + const params = 'q=' + q + '&sort=' + sort + '&t=' + time + '&limit=' + limit + + '&restrict_sr=' + (sub ? 'on' : 'off') + '&raw_json=1'; + const res = await fetch(basePath + '?' + params, { credentials: 'include' }); + const d = await res.json(); + return (d?.data?.children || []).map(c => ({ + title: c.data.title, + subreddit: c.data.subreddit_name_prefixed, + author: c.data.author, + score: c.data.score, + comments: c.data.num_comments, + url: 'https://www.reddit.com' + c.data.permalink, + })); })() - map: - title: ${{ item.data.title }} - subreddit: ${{ item.data.subreddit_name_prefixed }} - author: ${{ item.data.author }} - upvotes: ${{ item.data.score }} - comments: ${{ item.data.num_comments }} - url: https://www.reddit.com${{ item.data.permalink }} + title: ${{ item.title }} + subreddit: ${{ item.subreddit }} + author: ${{ item.author }} + score: ${{ item.score }} + comments: ${{ item.comments }} + url: ${{ item.url }} - limit: ${{ args.limit }} diff --git a/src/clis/reddit/subreddit.yaml b/src/clis/reddit/subreddit.yaml index fe2f630..fc61b2f 100644 --- a/src/clis/reddit/subreddit.yaml +++ b/src/clis/reddit/subreddit.yaml @@ -12,7 +12,11 @@ args: sort: type: string default: hot - description: "Sorting method: hot, new, top, rising" + description: "Sorting method: hot, new, top, rising, controversial" + time: + type: string + default: all + description: "Time filter for top/controversial: hour, day, week, month, year, all" limit: type: int default: 15 @@ -23,10 +27,16 @@ pipeline: - navigate: https://www.reddit.com - evaluate: | (async () => { - let sub = '${{ args.name }}'; + let sub = ${{ args.name | json }}; if (sub.startsWith('r/')) sub = sub.slice(2); - const sort = '${{ args.sort }}'; - const res = await fetch('/r/' + sub + '/' + sort + '.json?limit=${{ args.limit }}', { credentials: 'include' }); + const sort = ${{ args.sort | json }}; + const time = ${{ args.time | json }}; + const limit = ${{ args.limit }}; + let url = '/r/' + sub + '/' + sort + '.json?limit=' + limit + '&raw_json=1'; + if ((sort === 'top' || sort === 'controversial') && time) { + url += '&t=' + time; + } + const res = await fetch(url, { credentials: 'include' }); const j = await res.json(); return j?.data?.children || []; })() diff --git a/src/clis/reddit/subscribe.ts b/src/clis/reddit/subscribe.ts new file mode 100644 index 0000000..206826f --- /dev/null +++ b/src/clis/reddit/subscribe.ts @@ -0,0 +1,53 @@ +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: 'reddit', + name: 'subscribe', + description: 'Subscribe or unsubscribe to a subreddit', + domain: 'reddit.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'subreddit', type: 'string', required: true, help: 'Subreddit name (e.g. python)' }, + { name: 'undo', type: 'boolean', default: false, help: 'Unsubscribe instead of subscribe' }, + ], + columns: ['status', 'message'], + func: async (page, kwargs) => { + if (!page) throw new Error('Requires browser'); + + await page.goto('https://www.reddit.com'); + await page.wait(3); + + const result = await page.evaluate(`(async () => { + try { + let sub = ${JSON.stringify(kwargs.subreddit)}; + if (sub.startsWith('r/')) sub = sub.slice(2); + + const undo = ${kwargs.undo ? 'true' : 'false'}; + const action = undo ? 'unsub' : 'sub'; + + // Get modhash + const meRes = await fetch('/api/me.json', { credentials: 'include' }); + const me = await meRes.json(); + const modhash = me?.data?.modhash || ''; + + const res = await fetch('/api/subscribe', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'sr_name=' + encodeURIComponent(sub) + + '&action=' + action + + (modhash ? '&uh=' + encodeURIComponent(modhash) : ''), + }); + + if (!res.ok) return { ok: false, message: 'HTTP ' + res.status }; + const label = undo ? 'Unsubscribed from' : 'Subscribed to'; + return { ok: true, message: label + ' r/' + sub }; + } catch (e) { + return { ok: false, message: e.toString() }; + } + })()`); + + return [{ status: result.ok ? 'success' : 'failed', message: result.message }]; + } +}); diff --git a/src/clis/reddit/upvote.ts b/src/clis/reddit/upvote.ts new file mode 100644 index 0000000..1f0f130 --- /dev/null +++ b/src/clis/reddit/upvote.ts @@ -0,0 +1,67 @@ +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: 'reddit', + name: 'upvote', + description: 'Upvote or downvote a Reddit post', + domain: 'reddit.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'post_id', type: 'string', required: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' }, + { name: 'direction', type: 'string', default: 'up', help: 'Vote direction: up, down, none' }, + ], + columns: ['status', 'message'], + func: async (page, kwargs) => { + if (!page) throw new Error('Requires browser'); + + await page.goto('https://www.reddit.com'); + await page.wait(3); + + const result = await page.evaluate(`(async () => { + try { + let postId = ${JSON.stringify(kwargs.post_id)}; + // Extract ID from URL if needed + const urlMatch = postId.match(/comments\\/([a-z0-9]+)/); + if (urlMatch) postId = urlMatch[1]; + // Build fullname + const fullname = postId.startsWith('t3_') || postId.startsWith('t1_') + ? postId : 't3_' + postId; + + const dir = ${JSON.stringify(kwargs.direction)}; + const direction = dir === 'down' ? -1 : dir === 'none' ? 0 : 1; + + // Get modhash from Reddit config + const configEl = document.getElementById('config'); + let modhash = ''; + if (configEl) { + modhash = configEl.querySelector('[name="uh"]')?.getAttribute('content') || ''; + } + if (!modhash) { + // Try fetching from /api/me.json + const meRes = await fetch('/api/me.json', { credentials: 'include' }); + const me = await meRes.json(); + modhash = me?.data?.modhash || ''; + } + + const res = await fetch('/api/vote', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'id=' + encodeURIComponent(fullname) + + '&dir=' + direction + + (modhash ? '&uh=' + encodeURIComponent(modhash) : ''), + }); + + if (!res.ok) return { ok: false, message: 'HTTP ' + res.status }; + + const labels = { '1': 'Upvoted', '-1': 'Downvoted', '0': 'Vote removed' }; + return { ok: true, message: (labels[String(direction)] || 'Voted') + ' ' + fullname }; + } catch (e) { + return { ok: false, message: e.toString() }; + } + })()`); + + return [{ status: result.ok ? 'success' : 'failed', message: result.message }]; + } +}); diff --git a/src/clis/reddit/upvoted.ts b/src/clis/reddit/upvoted.ts new file mode 100644 index 0000000..a2e1501 --- /dev/null +++ b/src/clis/reddit/upvoted.ts @@ -0,0 +1,48 @@ +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: 'reddit', + name: 'upvoted', + description: 'Browse your upvoted Reddit posts', + domain: 'reddit.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'limit', type: 'int', default: 15 }, + ], + columns: ['title', 'subreddit', 'score', 'comments', 'url'], + func: async (page, kwargs) => { + if (!page) throw new Error('Requires browser'); + + await page.goto('https://www.reddit.com'); + await page.wait(3); + + const result = await page.evaluate(`(async () => { + try { + // Get current username + const meRes = await fetch('/api/me.json?raw_json=1', { credentials: 'include' }); + const me = await meRes.json(); + const username = me?.name || me?.data?.name; + if (!username) return { error: 'Not logged in — cannot determine username' }; + + const limit = ${kwargs.limit}; + const res = await fetch('/user/' + username + '/upvoted.json?limit=' + limit + '&raw_json=1', { + credentials: 'include' + }); + const d = await res.json(); + return (d?.data?.children || []).map(c => ({ + title: c.data.title || '-', + subreddit: c.data.subreddit_name_prefixed || 'r/' + (c.data.subreddit || '?'), + score: c.data.score || 0, + comments: c.data.num_comments || 0, + url: 'https://www.reddit.com' + (c.data.permalink || ''), + })); + } catch (e) { + return { error: e.toString() }; + } + })()`); + + if (result?.error) throw new Error(result.error); + return (result || []).slice(0, kwargs.limit); + } +}); diff --git a/src/clis/reddit/user-comments.yaml b/src/clis/reddit/user-comments.yaml new file mode 100644 index 0000000..056f38e --- /dev/null +++ b/src/clis/reddit/user-comments.yaml @@ -0,0 +1,45 @@ +site: reddit +name: user-comments +description: View a Reddit user's comment history +domain: reddit.com +strategy: cookie +browser: true + +args: + username: + type: string + required: true + limit: + type: int + default: 15 + +columns: [subreddit, score, body, url] + +pipeline: + - navigate: https://www.reddit.com + - evaluate: | + (async () => { + const username = ${{ args.username | json }}; + const name = username.startsWith('u/') ? username.slice(2) : username; + const limit = ${{ args.limit }}; + const res = await fetch('/user/' + name + '/comments.json?limit=' + limit + '&raw_json=1', { + credentials: 'include' + }); + const d = await res.json(); + return (d?.data?.children || []).map(c => { + let body = c.data.body || ''; + if (body.length > 300) body = body.slice(0, 300) + '...'; + return { + subreddit: c.data.subreddit_name_prefixed, + score: c.data.score, + body: body, + url: 'https://www.reddit.com' + c.data.permalink, + }; + }); + })() + - map: + subreddit: ${{ item.subreddit }} + score: ${{ item.score }} + body: ${{ item.body }} + url: ${{ item.url }} + - limit: ${{ args.limit }} diff --git a/src/clis/reddit/user-posts.yaml b/src/clis/reddit/user-posts.yaml new file mode 100644 index 0000000..759a82f --- /dev/null +++ b/src/clis/reddit/user-posts.yaml @@ -0,0 +1,43 @@ +site: reddit +name: user-posts +description: View a Reddit user's submitted posts +domain: reddit.com +strategy: cookie +browser: true + +args: + username: + type: string + required: true + limit: + type: int + default: 15 + +columns: [title, subreddit, score, comments, url] + +pipeline: + - navigate: https://www.reddit.com + - evaluate: | + (async () => { + const username = ${{ args.username | json }}; + const name = username.startsWith('u/') ? username.slice(2) : username; + const limit = ${{ args.limit }}; + const res = await fetch('/user/' + name + '/submitted.json?limit=' + limit + '&raw_json=1', { + credentials: 'include' + }); + const d = await res.json(); + return (d?.data?.children || []).map(c => ({ + title: c.data.title, + subreddit: c.data.subreddit_name_prefixed, + score: c.data.score, + comments: c.data.num_comments, + url: 'https://www.reddit.com' + c.data.permalink, + })); + })() + - map: + title: ${{ item.title }} + subreddit: ${{ item.subreddit }} + score: ${{ item.score }} + comments: ${{ item.comments }} + url: ${{ item.url }} + - limit: ${{ args.limit }} diff --git a/src/clis/reddit/user.yaml b/src/clis/reddit/user.yaml new file mode 100644 index 0000000..882870a --- /dev/null +++ b/src/clis/reddit/user.yaml @@ -0,0 +1,39 @@ +site: reddit +name: user +description: View a Reddit user profile +domain: reddit.com +strategy: cookie +browser: true + +args: + username: + type: string + required: true + +columns: [field, value] + +pipeline: + - navigate: https://www.reddit.com + - evaluate: | + (async () => { + const username = ${{ args.username | json }}; + const name = username.startsWith('u/') ? username.slice(2) : username; + const res = await fetch('/user/' + name + '/about.json?raw_json=1', { + credentials: 'include' + }); + const d = await res.json(); + const u = d?.data || d || {}; + const created = u.created_utc ? new Date(u.created_utc * 1000).toISOString().split('T')[0] : '-'; + return [ + { field: 'Username', value: 'u/' + (u.name || name) }, + { field: 'Post Karma', value: String(u.link_karma || 0) }, + { field: 'Comment Karma', value: String(u.comment_karma || 0) }, + { field: 'Total Karma', value: String(u.total_karma || (u.link_karma||0) + (u.comment_karma||0)) }, + { field: 'Account Created', value: created }, + { field: 'Gold', value: u.is_gold ? '⭐ Yes' : 'No' }, + { field: 'Verified', value: u.verified ? 'āœ… Yes' : 'No' }, + ]; + })() + - map: + field: ${{ item.field }} + value: ${{ item.value }} diff --git a/src/clis/twitter/thread.ts b/src/clis/twitter/thread.ts new file mode 100644 index 0000000..e84d208 --- /dev/null +++ b/src/clis/twitter/thread.ts @@ -0,0 +1,143 @@ +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: 'twitter', + name: 'thread', + description: 'Get a tweet thread (original + all replies)', + domain: 'x.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'tweet_id', type: 'string', required: true }, + { name: 'limit', type: 'int', default: 50 }, + ], + columns: ['id', 'author', 'text', 'likes', 'retweets', 'url'], + func: async (page, kwargs) => { + // Extract tweet ID from URL if needed + let tweetId = kwargs.tweet_id; + const urlMatch = tweetId.match(/\/status\/(\d+)/); + if (urlMatch) tweetId = urlMatch[1]; + + // Navigate to x.com so we have the right cookie context + await page.goto('https://x.com'); + await page.wait(3); + + // Use direct GraphQL fetch (like bb-sites) — runs in browser context with cookies + const result = await page.evaluate(` + async () => { + const tweetId = "${tweetId}"; + const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1]; + if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'}; + + const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'; + const headers = { + 'Authorization': 'Bearer ' + decodeURIComponent(bearer), + 'X-Csrf-Token': ct0, + 'X-Twitter-Auth-Type': 'OAuth2Session', + 'X-Twitter-Active-User': 'yes' + }; + + const features = JSON.stringify({ + responsive_web_graphql_exclude_directive_enabled: true, + verified_phone_label_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + longform_notetweets_consumption_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + freedom_of_speech_not_reach_fetch_enabled: true + }); + const fieldToggles = JSON.stringify({withArticleRichContentState: true, withArticlePlainText: false}); + + const tweets = []; + const seen = new Set(); + let cursor = null; + const maxPages = 5; + + function extractTweet(r) { + if (!r) return; + const tw = r.tweet || r; + const l = tw.legacy || {}; + if (!tw.rest_id || seen.has(tw.rest_id)) return; + seen.add(tw.rest_id); + const u = tw.core?.user_results?.result; + const nt = tw.note_tweet?.note_tweet_results?.result?.text; + const screenName = u?.legacy?.screen_name || u?.core?.screen_name || 'unknown'; + tweets.push({ + id: tw.rest_id, + author: screenName, + text: nt || l.full_text || '', + likes: l.favorite_count || 0, + retweets: l.retweet_count || 0, + in_reply_to: l.in_reply_to_status_id_str || undefined, + created_at: l.created_at, + url: 'https://x.com/' + screenName + '/status/' + tw.rest_id + }); + } + + for (let page = 0; page < maxPages; page++) { + const vars = { + focalTweetId: tweetId, + referrer: 'tweet', + with_rux_injections: false, + includePromotedContent: false, + rankingMode: 'Recency', + withCommunity: true, + withQuickPromoteEligibilityTweetFields: true, + withBirdwatchNotes: true, + withVoice: true + }; + if (cursor) vars.cursor = cursor; + + const url = '/i/api/graphql/nBS-WpgA6ZG0CyNHD517JQ/TweetDetail?variables=' + + encodeURIComponent(JSON.stringify(vars)) + + '&features=' + encodeURIComponent(features) + + '&fieldToggles=' + encodeURIComponent(fieldToggles); + + const resp = await fetch(url, {headers, credentials: 'include'}); + if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: 'Tweet may not exist or queryId expired'}; + const d = await resp.json(); + + const instructions = d.data?.threaded_conversation_with_injections_v2?.instructions + || d.data?.tweetResult?.result?.timeline?.instructions || []; + let nextCursor = null; + + for (const inst of instructions) { + for (const entry of (inst.entries || [])) { + // Extract cursor for pagination + if (entry.content?.entryType === 'TimelineTimelineCursor' + || entry.content?.__typename === 'TimelineTimelineCursor') { + if (entry.content.cursorType === 'Bottom' || entry.content.cursorType === 'ShowMore') { + nextCursor = entry.content.value; + } + continue; + } + if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) { + const cv = entry.content?.itemContent?.value || entry.content?.value; + if (cv) nextCursor = cv; + continue; + } + + extractTweet(entry.content?.itemContent?.tweet_results?.result); + for (const item of (entry.content?.items || [])) { + extractTweet(item.item?.itemContent?.tweet_results?.result); + } + } + } + + if (!nextCursor || nextCursor === cursor) break; + cursor = nextCursor; + } + + return tweets; + } + `); + + if (result?.error) { + throw new Error(result.error + (result.hint ? ` (${result.hint})` : '')); + } + + return (result || []).slice(0, kwargs.limit); + } +});