Improve seller account filtering
This commit is contained in:
1 parent
d819a0f1e6
commit
f4e8fdc42f
5 files changed
+178
-52
No files matched your search
+95
-29
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user