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.
270 lines
8.1 KiB
TypeScript
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(/&/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}`;
|
|
}
|
|
}
|