Improve seller account filtering

This commit is contained in:
jjbrunton committed 2026-08-08 19:15:05 +01:00
1 parent d819a0f1e6
commit f4e8fdc42f
5 files changed
+178 -52

No files matched your search

+95 -29
View File
@@ -48,6 +48,38 @@ export interface RedditPost {
link_flair_text?: string | null;
}
interface RedditListingChild {
data?: {
title?: unknown;
selftext?: unknown;
body?: unknown;
url?: unknown;
subreddit?: unknown;
};
}
function joinText(values: unknown[]): string {
return values.filter((value): value is string => typeof value === 'string').join(' ');
}
/** Match configured seller signals in user-controlled text. */
export function findSellerSignal(
text: string,
keywords: string[],
domains: string[],
): { kind: 'keyword' | 'domain'; value: string } | null {
const normalized = text.toLocaleLowerCase();
for (const keyword of keywords) {
const candidate = keyword.trim().toLocaleLowerCase();
if (candidate && normalized.includes(candidate)) {
return { kind: 'keyword', value: keyword };
}
}
const domain = findSellerDomain(text, domains);
return domain ? { kind: 'domain', value: domain } : null;
}
// 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 {
@@ -249,10 +281,28 @@ export class RedditApiClient {
async isSellerProfile(
username: string,
sellerKeywords: string[],
sellerDomains: string[] = []
sellerDomains: string[] = [],
currentPost?: RedditPost,
): Promise<boolean> {
if (sellerKeywords.length === 0 && sellerDomains.length === 0) return false;
// Check the post first. This catches direct promotional posts without an
// extra request and also covers accounts whose profile bio is deliberately
// vague.
if (currentPost) {
const postSignal = findSellerSignal(
joinText([currentPost.title, currentPost.selftext, currentPost.url]),
sellerKeywords,
sellerDomains,
);
if (postSignal) {
this.logger.info(
`u/${username} flagged as seller from current post (matched ${postSignal.kind}: "${postSignal.value}")`,
);
return true;
}
}
try {
const cookieHeader = await this.getCookieHeader();
const headers: Record<string, string> = {};
@@ -260,51 +310,67 @@ export class RedditApiClient {
headers['Cookie'] = cookieHeader;
}
const response = await this.client.get(`/user/${username}/about.json`, {
const response = await this.client.get(`/user/${encodeURIComponent(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())) {
if (userData) {
const profileText = joinText([
userData.subreddit?.public_description || '',
userData.subreddit?.title || '',
userData.subreddit?.display_name_prefixed || '',
]);
const profileSignal = findSellerSignal(profileText, sellerKeywords, sellerDomains);
if (profileSignal) {
this.logger.info(
`u/${username} flagged as seller profile (matched keyword: "${keyword}")`
`u/${username} flagged as seller profile (matched ${profileSignal.kind}: "${profileSignal.value}")`,
);
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
}
// Bios are often clean while recent posts advertise the paid account.
// Scan a small overview page, including comment bodies and subreddit
// names, before allowing the candidate through.
try {
const cookieHeader = await this.getCookieHeader();
const headers: Record<string, string> = {};
if (cookieHeader) headers.Cookie = cookieHeader;
const response = await this.client.get(`/user/${encodeURIComponent(username)}/overview.json`, {
params: { limit: 25, raw_json: 1 },
headers,
});
const children: RedditListingChild[] = Array.isArray(response.data?.data?.children)
? response.data.data.children
: [];
const activityText = children.map(({ data }) => joinText([
data?.title,
data?.selftext,
data?.body,
data?.url,
data?.subreddit,
])).join(' ');
const activitySignal = findSellerSignal(activityText, sellerKeywords, sellerDomains);
if (activitySignal) {
this.logger.info(
`u/${username} flagged as seller from recent activity (matched ${activitySignal.kind}: "${activitySignal.value}")`,
);
return true;
}
} catch (error) {
this.logger.warn(`Failed to check recent activity for u/${username}:`, error);
}
return false; // Fail open if Reddit profile endpoints are unavailable.
}
getPostUrl(post: RedditPost): string {