diff --git a/src/reddit/api.ts b/src/reddit/api.ts index f75ec13..14bd8c0 100644 --- a/src/reddit/api.ts +++ b/src/reddit/api.ts @@ -331,6 +331,11 @@ export class RedditApiClient { } } } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 429) { + const rateLimitError = new RedditRateLimitError(getRateLimitDelayMs(error.response.headers)); + this.logger.warn(rateLimitError.message); + throw rateLimitError; + } if (axios.isAxiosError(error) && error.response?.status === 404) { this.logger.warn(`User u/${username} profile not found`); return false; @@ -367,6 +372,11 @@ export class RedditApiClient { return true; } } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 429) { + const rateLimitError = new RedditRateLimitError(getRateLimitDelayMs(error.response.headers)); + this.logger.warn(rateLimitError.message); + throw rateLimitError; + } this.logger.warn(`Failed to check recent activity for u/${username}:`, error); } diff --git a/src/services/monitor.ts b/src/services/monitor.ts index 2428ab6..9218688 100644 --- a/src/services/monitor.ts +++ b/src/services/monitor.ts @@ -36,9 +36,12 @@ export class MonitorService { this.logger.debug(`Checking r/${subreddit.name} (${subreddit.type}) for new posts...`); const posts = await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type); - // Filter for valid, unprocessed posts - const validPosts: RedditPost[] = []; - for (const post of posts) { + // Reddit normally returns /new in descending order, but sort explicitly + // so we can stop as soon as the first fully eligible candidate is found. + // Previously we screened the entire batch before choosing one, causing up + // to two profile requests for every otherwise-valid post in the listing. + const newestFirst = [...posts].sort((a, b) => b.created_utc - a.created_utc); + for (const post of newestFirst) { if (this.postsRepo.isProcessed(post.id)) { continue; } @@ -60,16 +63,7 @@ export class MonitorService { ); if (isSeller) { this.logger.info(`Skipping post ${post.id} from u/${post.author} - seller signals detected`); - this.postsRepo.insert({ - id: post.id, - subreddit: post.subreddit, - author: post.author, - title: post.title, - postUrl: this.redditApi.getPostUrl(post), - imageUrl: subreddit.type === 'image' ? this.redditApi.getImageUrl(post) : null, - processedAt: Date.now(), - }); - this.postsRepo.setError(post.id, 'Seller signals detected'); + this.recordRejection(post, subreddit.type, 'Seller signals detected'); continue; } } @@ -81,41 +75,33 @@ export class MonitorService { const tag = parseGenderTag(`${post.title}\n${post.selftext ?? ''}`); if (!tag) { this.logger.debug(`Skipping chat post ${post.id} - no gender tag in title/body`); + this.recordRejection(post, subreddit.type, 'No gender tag in title/body'); continue; } if (!targetGenders.includes(tag)) { this.logger.debug( `Skipping chat post ${post.id} - gender tag "${tag}" not in target list [${targetGenders.join(', ')}]`, ); + this.recordRejection(post, subreddit.type, `Gender tag "${tag}" not targeted`); continue; } } - validPosts.push(post); - } - - if (validPosts.length > 0) { - // Sort by creation time (newest first) - validPosts.sort((a, b) => b.created_utc - a.created_utc); - - if (prioritizeRecent) { - const recent = validPosts.find((p) => p.created_utc >= recentCutoffSec); - if (recent) { - this.logger.info(`Found ${validPosts.length} new posts in r/${subreddit.name} (recent match)`); - return [recent]; - } - // Remember the freshest non-recent candidate as a fallback and - // keep scanning other subs for an actually-recent post. - if (!fallback || validPosts[0].created_utc > fallback.created_utc) { - fallback = validPosts[0]; - } - } else { - this.logger.info(`Found ${validPosts.length} new posts in r/${subreddit.name}`); - // Return just the first (newest) valid post - // This ensures we switch to a different random subreddit on the next poll cycle - // instead of processing a batch from the same subreddit - return [validPosts[0]]; + // At this point the candidate has passed every check. Select it now + // rather than spending more Reddit requests screening users that + // cannot be acted on during this poll cycle. + if (!prioritizeRecent || post.created_utc >= recentCutoffSec) { + this.logger.info( + `Found eligible post in r/${subreddit.name}${prioritizeRecent ? ' (recent match)' : ''}`, + ); + return [post]; } + + // This is the newest eligible post in this subreddit. Keep it as a + // possible fallback, then move to the next subreddit to look for a + // recent candidate. Older posts in this batch cannot improve it. + if (!fallback || post.created_utc > fallback.created_utc) fallback = post; + break; } } catch (error) { if (error instanceof RedditRateLimitError) throw error; @@ -186,4 +172,22 @@ export class MonitorService { private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + + /** Persist a completed screening decision so later polls do not repeat it. */ + private recordRejection( + post: RedditPost, + subredditType: 'image' | 'chat', + reason: string, + ): void { + this.postsRepo.insert({ + id: post.id, + subreddit: post.subreddit, + author: post.author, + title: post.title, + postUrl: this.redditApi.getPostUrl(post), + imageUrl: subredditType === 'image' ? this.redditApi.getImageUrl(post) : null, + processedAt: Date.now(), + }); + this.postsRepo.setError(post.id, reason); + } } diff --git a/tests/monitor.test.ts b/tests/monitor.test.ts index d93ed42..38d9024 100644 --- a/tests/monitor.test.ts +++ b/tests/monitor.test.ts @@ -152,6 +152,40 @@ describe('MonitorService', () => { expect(result.length).toBe(1); expect(result[0].id).toBe('t3_2'); + expect(redditApi.isSellerProfile).toHaveBeenCalledTimes(1); + expect(redditApi.isSellerProfile).toHaveBeenCalledWith( + 'user2', config.reddit.sellerKeywords, config.reddit.sellerDomains, post2, + ); + }); + + it('finishes the seller and gender checks for one candidate before moving to the next', async () => { + const newer: RedditPost = { + id: 't3_newer', subreddit: 'chat-sub', author: 'newer', title: '[30M] hello', + url: '', permalink: '/r/chat-sub/newer', thumbnail: '', is_self: true, + created_utc: 2, over_18: false, spoiler: false, + }; + const eligible: RedditPost = { + id: 't3_eligible', subreddit: 'chat-sub', author: 'eligible', title: '[25F] hello', + url: '', permalink: '/r/chat-sub/eligible', thumbnail: '', is_self: true, + created_utc: 1, over_18: false, spoiler: false, + }; + const redditApi = createMockRedditApi([eligible, newer]); + const config = getTestConfig({ + reddit: { + subreddits: [{ name: 'chat-sub', type: 'chat' }], + targetGenders: ['female'], + }, + }); + const postsRepo = createMockPostsRepo(); + monitor = new MonitorService( + redditApi, postsRepo, createMockMessagesRepo(), config, logger, 0, + ); + + await expect(monitor.getUnprocessedPosts()).resolves.toEqual([eligible]); + expect(vi.mocked(redditApi.isSellerProfile).mock.calls.map(([username]) => username)) + .toEqual(['newer', 'eligible']); + expect(postsRepo.insert).toHaveBeenCalledWith(expect.objectContaining({ id: 't3_newer' })); + expect(postsRepo.setError).toHaveBeenCalledWith('t3_newer', 'Gender tag "male" not targeted'); }); it('tries next subreddit when first has no valid posts', async () => { diff --git a/tests/reddit-api.test.ts b/tests/reddit-api.test.ts index 3174e15..7805841 100644 --- a/tests/reddit-api.test.ts +++ b/tests/reddit-api.test.ts @@ -364,6 +364,18 @@ describe('RedditApiClient', () => { expect(result).toBe(false); }); + it('propagates profile rate limits so the main loop backs off', async () => { + const error = { + isAxiosError: true, + response: { status: 429, headers: { 'retry-after': '42' } }, + }; + vi.mocked(axios.create).mockReturnValue({ get: vi.fn().mockRejectedValue(error) } as any); + + const client2 = new RedditApiClient(getTestConfig(), logger); + await expect(client2.isSellerProfile('testuser', ['onlyfans'])) + .rejects.toMatchObject({ retryAfterMs: 42_000 }); + }); + it('returns true when a domain in the bio matches sellerDomains', async () => { const mockResponse = { data: {