Reduce Reddit profile screening requests
This commit is contained in:
1 parent
3d015c4e80
commit
1833e94a10
4 files changed
+97
-37
No files matched your search
@@ -331,6 +331,11 @@ export class RedditApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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) {
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||||
this.logger.warn(`User u/${username} profile not found`);
|
this.logger.warn(`User u/${username} profile not found`);
|
||||||
return false;
|
return false;
|
||||||
@@ -367,6 +372,11 @@ export class RedditApiClient {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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);
|
this.logger.warn(`Failed to check recent activity for u/${username}:`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-37
@@ -36,9 +36,12 @@ export class MonitorService {
|
|||||||
this.logger.debug(`Checking r/${subreddit.name} (${subreddit.type}) for new posts...`);
|
this.logger.debug(`Checking r/${subreddit.name} (${subreddit.type}) for new posts...`);
|
||||||
const posts = await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type);
|
const posts = await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type);
|
||||||
|
|
||||||
// Filter for valid, unprocessed posts
|
// Reddit normally returns /new in descending order, but sort explicitly
|
||||||
const validPosts: RedditPost[] = [];
|
// so we can stop as soon as the first fully eligible candidate is found.
|
||||||
for (const post of posts) {
|
// 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)) {
|
if (this.postsRepo.isProcessed(post.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -60,16 +63,7 @@ export class MonitorService {
|
|||||||
);
|
);
|
||||||
if (isSeller) {
|
if (isSeller) {
|
||||||
this.logger.info(`Skipping post ${post.id} from u/${post.author} - seller signals detected`);
|
this.logger.info(`Skipping post ${post.id} from u/${post.author} - seller signals detected`);
|
||||||
this.postsRepo.insert({
|
this.recordRejection(post, subreddit.type, 'Seller signals detected');
|
||||||
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');
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,41 +75,33 @@ export class MonitorService {
|
|||||||
const tag = parseGenderTag(`${post.title}\n${post.selftext ?? ''}`);
|
const tag = parseGenderTag(`${post.title}\n${post.selftext ?? ''}`);
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
this.logger.debug(`Skipping chat post ${post.id} - no gender tag in title/body`);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (!targetGenders.includes(tag)) {
|
if (!targetGenders.includes(tag)) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Skipping chat post ${post.id} - gender tag "${tag}" not in target list [${targetGenders.join(', ')}]`,
|
`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;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
validPosts.push(post);
|
// 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 (validPosts.length > 0) {
|
if (!prioritizeRecent || post.created_utc >= recentCutoffSec) {
|
||||||
// Sort by creation time (newest first)
|
this.logger.info(
|
||||||
validPosts.sort((a, b) => b.created_utc - a.created_utc);
|
`Found eligible post in r/${subreddit.name}${prioritizeRecent ? ' (recent match)' : ''}`,
|
||||||
|
);
|
||||||
if (prioritizeRecent) {
|
return [post];
|
||||||
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]];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
} catch (error) {
|
||||||
if (error instanceof RedditRateLimitError) throw error;
|
if (error instanceof RedditRateLimitError) throw error;
|
||||||
@@ -186,4 +172,22 @@ export class MonitorService {
|
|||||||
private sleep(ms: number): Promise<void> {
|
private sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -152,6 +152,40 @@ describe('MonitorService', () => {
|
|||||||
|
|
||||||
expect(result.length).toBe(1);
|
expect(result.length).toBe(1);
|
||||||
expect(result[0].id).toBe('t3_2');
|
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 () => {
|
it('tries next subreddit when first has no valid posts', async () => {
|
||||||
|
|||||||
@@ -364,6 +364,18 @@ describe('RedditApiClient', () => {
|
|||||||
expect(result).toBe(false);
|
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 () => {
|
it('returns true when a domain in the bio matches sellerDomains', async () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
Reference in new issue
Block a user