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?:\/\/|(? { 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 { try { const cookieHeader = await this.getCookieHeader(); const headers: Record = {}; 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(/&/g, '&'); } async isSellerProfile( username: string, sellerKeywords: string[], sellerDomains: string[] = [] ): Promise { if (sellerKeywords.length === 0 && sellerDomains.length === 0) return false; try { const cookieHeader = await this.getCookieHeader(); const headers: Record = {}; 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}`; } }