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:
jackwener committed 2026-03-16 19:56:55 +08:00
1 parent 1576396a21
commit 7f55950fed
14 files changed
+767 -15

No files matched your search

+60
View File
@@ -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 }];
}
});
+40
View File
@@ -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 }}
+76
View File
@@ -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 }}
+54
View File
@@ -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 }];
}
});
+48
View File
@@ -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
View File
@@ -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 }}
+14 -4
View File
@@ -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 || [];
})()
+53
View File
@@ -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 }];
}
});
+67
View File
@@ -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 }];
}
});
+48
View File
@@ -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);
}
});
+45
View File
@@ -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 }}
+43
View File
@@ -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 }}
+39
View File
@@ -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 }}
+143
View File
@@ -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);
}
});