feat(reddit): add 11 new adapters borrowed from rdt-cli
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
This commit is contained in:
1 parent
1576396a21
commit
7f55950fed
14 files changed
+767
-15
No files matched your search
@@ -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 }];
|
||||
}
|
||||
});
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }];
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
+37
-11
@@ -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 }}
|
||||
@@ -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 || [];
|
||||
})()
|
||||
|
||||
@@ -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 }];
|
||||
}
|
||||
});
|
||||
@@ -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 }];
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user