reddit-client.tsβ’22.3 kB
import axios, { AxiosInstance } from "axios";
import {
RedditClientConfig,
RedditUser,
RedditPost,
RedditComment,
RedditSubreddit,
} from "../types";
export class RedditClient {
private clientId: string;
private clientSecret: string;
private userAgent: string;
private username?: string;
private password?: string;
private accessToken?: string;
private tokenExpiry: number = 0;
private api: AxiosInstance;
private authenticated: boolean = false;
constructor(config: RedditClientConfig) {
this.clientId = config.clientId;
this.clientSecret = config.clientSecret;
this.userAgent = config.userAgent;
this.username = config.username;
this.password = config.password;
this.api = axios.create({
baseURL: "https://oauth.reddit.com",
headers: {
"User-Agent": this.userAgent,
},
});
// Add response interceptor to handle token refresh
this.api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401 && this.authenticated) {
await this.authenticate();
const originalRequest = error.config;
originalRequest.headers[
"Authorization"
] = `Bearer ${this.accessToken}`;
return this.api(originalRequest);
}
return Promise.reject(error);
}
);
}
async authenticate(): Promise<void> {
try {
const now = Date.now();
if (this.accessToken && now < this.tokenExpiry) {
return;
}
const authUrl = "https://www.reddit.com/api/v1/access_token";
const authData = new URLSearchParams();
if (this.username && this.password) {
console.log(
`[Auth] Authenticating with user credentials for ${this.username}`
);
authData.append("grant_type", "password");
authData.append("username", this.username);
authData.append("password", this.password);
} else {
console.log(
"[Auth] Authenticating with client credentials (read-only)"
);
authData.append("grant_type", "client_credentials");
}
const response = await axios.post(authUrl, authData, {
auth: {
username: this.clientId,
password: this.clientSecret,
},
headers: {
"User-Agent": this.userAgent,
"Content-Type": "application/x-www-form-urlencoded",
},
});
this.accessToken = response.data.access_token;
this.tokenExpiry = now + response.data.expires_in * 1000;
this.authenticated = true;
this.api.defaults.headers.common[
"Authorization"
] = `Bearer ${this.accessToken}`;
console.log("[Auth] Successfully authenticated with Reddit API");
} catch (error) {
console.error("[Auth] Authentication error:", error);
throw new Error("Failed to authenticate with Reddit API");
}
}
async checkAuthentication(): Promise<boolean> {
if (!this.authenticated) {
try {
await this.authenticate();
return true;
} catch (error) {
return false;
}
}
return true;
}
async getUser(username: string): Promise<RedditUser> {
await this.authenticate();
try {
const response = await this.api.get(`/user/${username}/about.json`);
const data = response.data.data;
return {
name: data.name,
id: data.id,
commentKarma: data.comment_karma,
linkKarma: data.link_karma,
totalKarma: data.total_karma || data.comment_karma + data.link_karma,
isMod: data.is_mod,
isGold: data.is_gold,
isEmployee: data.is_employee,
createdUtc: data.created_utc,
profileUrl: `https://reddit.com/user/${data.name}`,
};
} catch (error) {
console.error(`[Error] Failed to get user info for ${username}:`, error);
throw new Error(`Failed to get user info for ${username}`);
}
}
async getSubredditInfo(subredditName: string): Promise<RedditSubreddit> {
await this.authenticate();
try {
const response = await this.api.get(`/r/${subredditName}/about.json`);
const data = response.data.data;
return {
displayName: data.display_name,
title: data.title,
description: data.description || "",
publicDescription: data.public_description || "",
subscribers: data.subscribers,
activeUserCount: data.active_user_count,
createdUtc: data.created_utc,
over18: data.over18,
subredditType: data.subreddit_type,
url: data.url,
};
} catch (error) {
console.error(
`[Error] Failed to get subreddit info for ${subredditName}:`,
error
);
throw new Error(`Failed to get subreddit info for ${subredditName}`);
}
}
async getTopPosts(
subreddit: string,
timeFilter: string = "week",
limit: number = 10
): Promise<RedditPost[]> {
await this.authenticate();
try {
const endpoint = subreddit ? `/r/${subreddit}/top.json` : "/top.json";
const response = await this.api.get(endpoint, {
params: {
t: timeFilter,
limit,
},
});
return response.data.data.children.map((child: any) => {
const post = child.data;
return {
id: post.id,
title: post.title,
author: post.author,
subreddit: post.subreddit,
selftext: post.selftext,
url: post.url,
score: post.score,
upvoteRatio: post.upvote_ratio,
numComments: post.num_comments,
createdUtc: post.created_utc,
over18: post.over_18,
spoiler: post.spoiler,
edited: !!post.edited,
isSelf: post.is_self,
linkFlairText: post.link_flair_text,
permalink: post.permalink,
};
});
} catch (error) {
console.error(
`[Error] Failed to get top posts for ${subreddit || "home"}:`,
error
);
throw new Error(`Failed to get top posts for ${subreddit || "home"}`);
}
}
async getPost(postId: string, subreddit?: string): Promise<RedditPost> {
await this.authenticate();
try {
const endpoint = subreddit
? `/r/${subreddit}/comments/${postId}.json`
: `/api/info.json?id=t3_${postId}`;
const response = await this.api.get(endpoint);
let post;
if (subreddit) {
// When using the comments endpoint
post = response.data[0].data.children[0].data;
} else {
// When using the info endpoint
if (!response.data.data.children.length) {
throw new Error(`Post with ID ${postId} not found`);
}
post = response.data.data.children[0].data;
}
return {
id: post.id,
title: post.title,
author: post.author,
subreddit: post.subreddit,
selftext: post.selftext,
url: post.url,
score: post.score,
upvoteRatio: post.upvote_ratio,
numComments: post.num_comments,
createdUtc: post.created_utc,
over18: post.over_18,
spoiler: post.spoiler,
edited: !!post.edited,
isSelf: post.is_self,
linkFlairText: post.link_flair_text,
permalink: post.permalink,
};
} catch (error) {
console.error(`[Error] Failed to get post with ID ${postId}:`, error);
throw new Error(`Failed to get post with ID ${postId}`);
}
}
async getTrendingSubreddits(limit: number = 5): Promise<string[]> {
await this.authenticate();
try {
const response = await this.api.get("/subreddits/popular.json", {
params: { limit },
});
return response.data.data.children.map(
(child: any) => child.data.display_name
);
} catch (error) {
console.error("[Error] Failed to get trending subreddits:", error);
throw new Error("Failed to get trending subreddits");
}
}
async createPost(
subreddit: string,
title: string,
content: string,
isSelf: boolean = true
): Promise<RedditPost> {
await this.authenticate();
if (!this.username || !this.password) {
throw new Error("User authentication required for posting");
}
try {
console.log(`[Post] Creating post in r/${subreddit}: "${title}"`);
const kind = isSelf ? "self" : "link";
const params = new URLSearchParams();
params.append("sr", subreddit);
params.append("kind", kind);
params.append("title", title);
params.append(isSelf ? "text" : "url", content);
const response = await this.api.post("/api/submit", params, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
timeout: 30000, // 30 seconds timeout
});
console.log(`[Post] Reddit API response:`, response.data);
if (response.data.success) {
const postId = response.data.data.id;
const postName = response.data.data.name;
console.log(`[Post] Post created successfully! ID: ${postId}, Name: ${postName}`);
try {
// Try to get the newly created post
const newPost = await this.getPost(postId);
console.log(`[Post] Successfully retrieved created post`);
return newPost;
} catch (getError) {
console.log(`[Post] Could not retrieve post details, but post was created successfully`);
// Return a basic post object if we can't retrieve the full details
return {
id: postId,
title: title,
author: this.username!,
subreddit: subreddit,
selftext: isSelf ? content : "",
url: isSelf ? "" : content,
score: 1,
upvoteRatio: 1.0,
numComments: 0,
createdUtc: Math.floor(Date.now() / 1000),
over18: false,
spoiler: false,
edited: false,
isSelf: isSelf,
linkFlairText: "",
permalink: `/r/${subreddit}/comments/${postId}/`,
};
}
} else {
console.error(`[Post] Reddit API returned success=false:`, response.data);
const errors = response.data.errors || [];
const errorMsg = errors.length > 0 ? errors.join(", ") : "Unknown error";
throw new Error(`Failed to create post: ${errorMsg}`);
}
} catch (error: any) {
console.error(`[Error] Failed to create post in r/${subreddit}:`, error);
if (error.response) {
console.error(`[Error] Reddit API error response:`, error.response.data);
throw new Error(`Failed to create post: ${error.response.data?.message || error.message}`);
} else if (error.code === 'ECONNABORTED') {
console.error(`[Error] Request timeout when creating post`);
throw new Error(`Post creation timed out - check Reddit manually to see if it was created`);
} else {
throw new Error(`Failed to create post in r/${subreddit}: ${error.message}`);
}
}
}
async checkPostExists(postId: string): Promise<boolean> {
await this.authenticate();
try {
const response = await this.api.get(`/api/info.json?id=t3_${postId}`);
return response.data.data.children.length > 0;
} catch (error) {
return false;
}
}
async replyToPost(postId: string, content: string): Promise<RedditComment> {
await this.authenticate();
if (!this.username || !this.password) {
throw new Error("User authentication required for posting replies");
}
try {
if (!(await this.checkPostExists(postId))) {
throw new Error(
`Post with ID ${postId} does not exist or is not accessible`
);
}
const params = new URLSearchParams();
params.append("thing_id", `t3_${postId}`);
params.append("text", content);
const response = await this.api.post("/api/comment", params, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
// Extract comment data from response
const commentData = response.data;
return {
id: commentData.id,
author: this.username,
body: content,
score: 1,
controversiality: 0,
subreddit: commentData.subreddit,
submissionTitle: commentData.link_title,
createdUtc: Date.now() / 1000,
edited: false,
isSubmitter: false,
permalink: commentData.permalink,
};
} catch (error) {
console.error(`[Error] Failed to reply to post ${postId}:`, error);
throw new Error(`Failed to reply to post ${postId}`);
}
}
async getComment(commentId: string): Promise<RedditComment> {
await this.authenticate();
try {
const response = await this.api.get(`/api/info.json?id=t1_${commentId}`);
if (!response.data.data.children.length) {
throw new Error(`Comment with ID ${commentId} not found`);
}
const comment = response.data.data.children[0].data;
return {
id: comment.id,
author: comment.author,
body: comment.body,
score: comment.score,
controversiality: comment.controversiality,
subreddit: comment.subreddit,
submissionTitle: comment.link_title || "",
createdUtc: comment.created_utc,
edited: !!comment.edited,
isSubmitter: comment.is_submitter,
permalink: comment.permalink,
};
} catch (error) {
console.error(`[Error] Failed to get comment with ID ${commentId}:`, error);
throw new Error(`Failed to get comment with ID ${commentId}`);
}
}
async getCommentsBySubmission(submissionId: string, limit: number = 20): Promise<RedditComment[]> {
await this.authenticate();
try {
const response = await this.api.get(`/comments/${submissionId}.json`, {
params: { limit }
});
if (!response.data || response.data.length < 2) {
throw new Error(`Submission with ID ${submissionId} not found or has no comments`);
}
const comments = response.data[1].data.children;
return comments.map((child: any) => {
const comment = child.data;
return {
id: comment.id,
author: comment.author,
body: comment.body,
score: comment.score,
controversiality: comment.controversiality,
subreddit: comment.subreddit,
submissionTitle: response.data[0].data.children[0].data.title,
createdUtc: comment.created_utc,
edited: !!comment.edited,
isSubmitter: comment.is_submitter,
permalink: comment.permalink,
};
});
} catch (error) {
console.error(`[Error] Failed to get comments for submission ${submissionId}:`, error);
throw new Error(`Failed to get comments for submission ${submissionId}`);
}
}
async getSubmission(submissionId: string): Promise<RedditPost> {
// Cette mΓ©thode est similaire Γ getPost, mais optimisΓ©e pour les submissions
return await this.getPost(submissionId);
}
async getSubreddit(subredditName: string): Promise<RedditSubreddit> {
// Cette mΓ©thode est identique Γ getSubredditInfo
return await this.getSubredditInfo(subredditName);
}
async searchPosts(subreddit: string, query: string, sort: string = "relevance", limit: number = 25): Promise<RedditPost[]> {
await this.authenticate();
try {
const response = await this.api.get(`/r/${subreddit}/search.json`, {
params: {
q: query,
restrict_sr: true,
sort,
limit,
type: "link"
}
});
return response.data.data.children.map((child: any) => {
const post = child.data;
return {
id: post.id,
title: post.title,
author: post.author,
subreddit: post.subreddit,
selftext: post.selftext,
url: post.url,
score: post.score,
upvoteRatio: post.upvote_ratio,
numComments: post.num_comments,
createdUtc: post.created_utc,
over18: post.over_18,
spoiler: post.spoiler,
edited: !!post.edited,
isSelf: post.is_self,
linkFlairText: post.link_flair_text,
permalink: post.permalink,
};
});
} catch (error) {
console.error(`[Error] Failed to search posts in ${subreddit}:`, error);
throw new Error(`Failed to search posts in ${subreddit}`);
}
}
async searchSubreddits(query: string, limit: number = 25): Promise<RedditSubreddit[]> {
await this.authenticate();
try {
const response = await this.api.get("/subreddits/search.json", {
params: {
q: query,
limit,
type: "sr",
},
});
return response.data.data.children.map((child: any) => {
const subreddit = child.data;
return {
displayName: subreddit.display_name,
title: subreddit.title,
description: subreddit.description || "",
publicDescription: subreddit.public_description || "",
subscribers: subreddit.subscribers,
activeUserCount: subreddit.active_user_count,
createdUtc: subreddit.created_utc,
over18: subreddit.over18,
subredditType: subreddit.subreddit_type,
url: subreddit.url,
};
});
} catch (error) {
console.error(`[Error] Failed to search subreddits:`, error);
throw new Error(`Failed to search subreddits: ${error}`);
}
}
// NEW METHODS FOR USER ACTIVITY
async getUserPosts(username: string, sort: string = "new", limit: number = 25): Promise<RedditPost[]> {
await this.authenticate();
try {
const response = await this.api.get(`/user/${username}/submitted.json`, {
params: {
sort,
limit,
},
});
return response.data.data.children.map((child: any) => {
const post = child.data;
return {
id: post.id,
title: post.title,
author: post.author,
subreddit: post.subreddit,
selftext: post.selftext,
url: post.url,
score: post.score,
upvoteRatio: post.upvote_ratio,
numComments: post.num_comments,
createdUtc: post.created_utc,
over18: post.over_18,
spoiler: post.spoiler,
edited: !!post.edited,
isSelf: post.is_self,
linkFlairText: post.link_flair_text,
permalink: post.permalink,
};
});
} catch (error) {
console.error(`[Error] Failed to get user posts for ${username}:`, error);
throw new Error(`Failed to get user posts for ${username}`);
}
}
async getUserComments(username: string, sort: string = "new", limit: number = 25): Promise<RedditComment[]> {
await this.authenticate();
try {
const response = await this.api.get(`/user/${username}/comments.json`, {
params: {
sort,
limit,
},
});
return response.data.data.children.map((child: any) => {
const comment = child.data;
return {
id: comment.id,
author: comment.author,
body: comment.body,
score: comment.score,
controversiality: comment.controversiality,
subreddit: comment.subreddit,
submissionTitle: comment.link_title || "Unknown Post",
createdUtc: comment.created_utc,
edited: !!comment.edited,
isSubmitter: comment.is_submitter,
permalink: `https://reddit.com${comment.permalink}`,
};
});
} catch (error) {
console.error(`[Error] Failed to get user comments for ${username}:`, error);
throw new Error(`Failed to get user comments for ${username}`);
}
}
async getUserOverview(username: string, sort: string = "new", limit: number = 25): Promise<{posts: RedditPost[], comments: RedditComment[]}> {
await this.authenticate();
try {
const response = await this.api.get(`/user/${username}/overview.json`, {
params: {
sort,
limit,
},
});
const posts: RedditPost[] = [];
const comments: RedditComment[] = [];
response.data.data.children.forEach((child: any) => {
const item = child.data;
if (child.kind === "t3") { // Post
posts.push({
id: item.id,
title: item.title,
author: item.author,
subreddit: item.subreddit,
selftext: item.selftext,
url: item.url,
score: item.score,
upvoteRatio: item.upvote_ratio,
numComments: item.num_comments,
createdUtc: item.created_utc,
over18: item.over_18,
spoiler: item.spoiler,
edited: !!item.edited,
isSelf: item.is_self,
linkFlairText: item.link_flair_text,
permalink: item.permalink,
});
} else if (child.kind === "t1") { // Comment
comments.push({
id: item.id,
author: item.author,
body: item.body,
score: item.score,
controversiality: item.controversiality,
subreddit: item.subreddit,
submissionTitle: item.link_title || "Unknown Post",
createdUtc: item.created_utc,
edited: !!item.edited,
isSubmitter: item.is_submitter,
permalink: `https://reddit.com${item.permalink}`,
});
}
});
return { posts, comments };
} catch (error) {
console.error(`[Error] Failed to get user overview for ${username}:`, error);
throw new Error(`Failed to get user overview for ${username}`);
}
}
}
// Create and export singleton instance
let redditClient: RedditClient | null = null;
export function initializeRedditClient(
config: RedditClientConfig
): RedditClient {
redditClient = new RedditClient(config);
return redditClient;
}
export function getRedditClient(): RedditClient | null {
return redditClient;
}