Files
RedditBot/src/reddit/api.ts
T
jjbrunton 633bb0ec04 Restructure chat service, web routes, and add test suite
Split monolithic chat service into modular structure with Matrix client
support, extract web server into per-resource route modules, introduce
bootstrap/main-loop separation, and add comprehensive vitest coverage
across services, repositories, and utilities. Adds project docs and
CLAUDE.md agent instructions.
2026-05-21 01:32:10 +01:00

270 lines
8.1 KiB
TypeScript

import axios, { AxiosInstance } from 'axios';
import type { BrowserContext } from 'playwright';
import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js';
export interface RedditPost {
id: string;
name: string; // fullname like "t3_xxxxx"
subreddit: string;
author: string;
title: string;
url: string;
permalink: string;
thumbnail: string;
is_self: boolean;
selftext?: string;
post_hint?: string;
preview?: {
images: Array<{
source: { url: string; width: number; height: number };
resolutions: Array<{ url: string; width: number; height: number }>;
}>;
};
created_utc: number;
over_18: boolean;
spoiler: boolean;
}
// Extracts URL-ish tokens (with or without scheme) and returns the first
// domain match. Matches `host` or any subdomain (`*.host`), case-insensitive.
export function findSellerDomain(text: string, domains: string[]): string | null {
if (!text || domains.length === 0) return null;
const normalized = text.toLowerCase();
const urlRegex = /(?:https?:\/\/|(?<![\w.@])(?=[a-z0-9-]+\.[a-z]))([a-z0-9.-]+\.[a-z]{2,})(?:[/?#][^\s)]*)?/gi;
const hosts: string[] = [];
for (const m of normalized.matchAll(urlRegex)) {
hosts.push(m[1].replace(/\.+$/, ''));
}
for (const host of hosts) {
for (const domain of domains) {
const d = domain.toLowerCase();
if (host === d || host.endsWith('.' + d)) return domain;
}
}
return null;
}
export class RedditApiClient {
private client: AxiosInstance;
private browserContext: BrowserContext | null = null;
constructor(
config: Config,
private logger: Logger
) {
this.client = axios.create({
baseURL: 'https://www.reddit.com',
headers: {
'User-Agent': config.reddit.userAgent,
Accept: 'application/json',
},
timeout: 30000,
});
}
setBrowserContext(context: BrowserContext): void {
this.browserContext = context;
this.logger.debug('Browser context set for authenticated API requests');
}
private async getCookieHeader(): Promise<string> {
if (!this.browserContext) {
return '';
}
try {
const cookies = await this.browserContext.cookies('https://www.reddit.com');
return cookies.map((c) => `${c.name}=${c.value}`).join('; ');
} catch (error) {
this.logger.warn('Failed to get cookies from browser context:', error);
return '';
}
}
async getNewPosts(subreddit: string, limit = 25, type: 'image' | 'chat' = 'image'): Promise<RedditPost[]> {
try {
const cookieHeader = await this.getCookieHeader();
const headers: Record<string, string> = {};
if (cookieHeader) {
headers['Cookie'] = cookieHeader;
}
const response = await this.client.get(`/r/${subreddit}/new.json`, {
params: { limit, raw_json: 1 },
headers,
});
const posts: RedditPost[] = response.data.data.children
.map((child: { data: RedditPost }) => child.data)
.filter((post: RedditPost) => this.isValidPost(post, type));
this.logger.debug(`Fetched ${posts.length} valid ${type} posts from r/${subreddit}`);
return posts;
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response?.status === 404) {
this.logger.warn(`Subreddit r/${subreddit} not found`);
return [];
}
if (error.response?.status === 403) {
this.logger.warn(`Subreddit r/${subreddit} is private or quarantined`);
return [];
}
if (error.response?.status === 429) {
this.logger.warn(`Rate limited by Reddit, waiting...`);
throw error;
}
}
this.logger.error(`Error fetching posts from r/${subreddit}:`, error);
throw error;
}
}
private isValidPost(post: RedditPost, type: 'image' | 'chat' = 'image'): boolean {
// Skip deleted/removed authors
if (post.author === '[deleted]' || post.author === 'AutoModerator') return false;
if (type === 'chat') {
// Chat subs are text-driven. Require a title (always present) and
// ideally some self text — but allow title-only since some tags live
// in the title alone (e.g. "[25F] up late, dm me").
return true;
}
// image type:
if (post.is_self) return false;
return this.hasImage(post);
}
private hasImage(post: RedditPost): boolean {
// Check post_hint
if (post.post_hint === 'image') return true;
// Check for preview images
if (post.preview?.images?.length) return true;
// Check URL patterns for direct image links
const url = post.url.toLowerCase();
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
if (imageExtensions.some((ext) => url.includes(ext))) return true;
// Check for known image hosts
const imageHosts = ['i.redd.it', 'i.imgur.com', 'imgur.com'];
if (imageHosts.some((host) => url.includes(host))) return true;
return false;
}
getImageUrl(post: RedditPost): string | null {
// Prefer high-quality preview images (already processed by Reddit)
if (post.preview?.images?.[0]?.source?.url) {
// Reddit encodes URLs in preview, need to decode HTML entities
return this.decodeRedditUrl(post.preview.images[0].source.url);
}
// Direct i.redd.it links
if (post.url.includes('i.redd.it')) {
return post.url;
}
// Direct imgur links
if (post.url.includes('i.imgur.com')) {
return post.url;
}
// Imgur album/page links - try to get direct image
if (post.url.includes('imgur.com') && !post.url.includes('i.imgur.com')) {
// Try adding .jpg extension
const imgurId = post.url.split('/').pop()?.split('.')[0];
if (imgurId) {
return `https://i.imgur.com/${imgurId}.jpg`;
}
}
// Check if URL has image extension
const url = post.url.toLowerCase();
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
if (imageExtensions.some((ext) => url.includes(ext))) {
return post.url;
}
// Use thumbnail as last resort if it's a valid URL
if (post.thumbnail && post.thumbnail.startsWith('http')) {
return post.thumbnail;
}
return null;
}
private decodeRedditUrl(url: string): string {
return url.replace(/&amp;/g, '&');
}
async isSellerProfile(
username: string,
sellerKeywords: string[],
sellerDomains: string[] = []
): Promise<boolean> {
if (sellerKeywords.length === 0 && sellerDomains.length === 0) return false;
try {
const cookieHeader = await this.getCookieHeader();
const headers: Record<string, string> = {};
if (cookieHeader) {
headers['Cookie'] = cookieHeader;
}
const response = await this.client.get(`/user/${username}/about.json`, {
params: { raw_json: 1 },
headers,
});
const userData = response.data?.data;
if (!userData) return false;
// Combine all text fields to scan
const textToCheck = [
userData.subreddit?.public_description || '',
userData.subreddit?.title || '',
userData.subreddit?.display_name_prefixed || '',
]
.join(' ')
.toLowerCase();
for (const keyword of sellerKeywords) {
if (textToCheck.includes(keyword.toLowerCase())) {
this.logger.info(
`u/${username} flagged as seller profile (matched keyword: "${keyword}")`
);
return true;
}
}
if (sellerDomains.length > 0) {
const matchedDomain = findSellerDomain(textToCheck, sellerDomains);
if (matchedDomain) {
this.logger.info(
`u/${username} flagged as seller profile (matched domain: "${matchedDomain}")`
);
return true;
}
}
return false;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
this.logger.warn(`User u/${username} profile not found`);
return false;
}
this.logger.warn(`Failed to check profile for u/${username}:`, error);
return false; // Don't block on errors - let it through
}
}
getPostUrl(post: RedditPost): string {
return `https://www.reddit.com${post.permalink}`;
}
}