1import { Actor } from 'apify';
2import axios from 'axios';
3import * as cheerio from 'cheerio';
4
5const API_BASE = 'https://disdex.io/users';
6const SITE_BASE = 'https://disdex.io';
7
8const DEFAULT_HEADERS = {
9 accept: '*/*',
10 'accept-language': 'en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7',
11 'cache-control': 'no-cache',
12 'hx-request': 'true',
13 pragma: 'no-cache',
14 'sec-ch-ua': '"Chromium";v="148", "Google Chrome";v="148", "Not/A)Brand";v="99"',
15 'sec-ch-ua-mobile': '?0',
16 'sec-ch-ua-platform': '"macOS"',
17 'sec-fetch-dest': 'empty',
18 'sec-fetch-mode': 'cors',
19 'sec-fetch-site': 'same-origin',
20 'user-agent':
21 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36',
22};
23
24const FILTER_FIELDS = [
25 'from_created',
26 'to_created',
27 'from_invites',
28 'to_invites',
29 'from_servers',
30 'to_servers',
31];
32
33class DiscordUserSearchScraper {
34 normalizeFilterValue(value) {
35 if (value === undefined || value === null) return '';
36 return String(value).trim();
37 }
38
39 buildFilters(input) {
40 const filters = {};
41
42 for (const field of FILTER_FIELDS) {
43 filters[field] = this.normalizeFilterValue(input[field]);
44 }
45
46 return filters;
47 }
48
49 buildQueryParams(keyword, page) {
50 const params = new URLSearchParams({
51 q: keyword,
52 ...this.filters,
53 });
54
55 if (page) {
56 params.set('page', String(page));
57 }
58
59 return params;
60 }
61
62 buildSearchUrl(keyword) {
63 return `${SITE_BASE}/users?${this.buildQueryParams(keyword).toString()}`;
64 }
65
66 buildApiUrl(keyword, page) {
67 return `${API_BASE}?${this.buildQueryParams(keyword, page).toString()}`;
68 }
69
70 getAxiosConfig(proxyUrl) {
71 if (!proxyUrl) return {};
72
73 const parsed = new URL(proxyUrl);
74 return {
75 proxy: {
76 protocol: parsed.protocol.replace(':', ''),
77 host: parsed.hostname,
78 port: Number(parsed.port),
79 ...(parsed.username
80 ? {
81 auth: {
82 username: decodeURIComponent(parsed.username),
83 password: decodeURIComponent(parsed.password),
84 },
85 }
86 : {}),
87 },
88 };
89 }
90
91 parseNumber(text) {
92 if (!text) return null;
93 const value = Number(String(text).replace(/,/g, '').trim());
94 return Number.isFinite(value) ? value : null;
95 }
96
97 parseUsers(html, keyword) {
98 const $ = cheerio.load(`<table>${html}</table>`);
99 const results = [];
100
101 $('tr').each((_, element) => {
102 const row = $(element);
103 const href = row.find('a.row-link.user-display').attr('href') || '';
104 const userId = href.split('/').pop() || null;
105 const handleText = row.find('.user-handle').text().trim();
106 const username = handleText.replace(/^@/, '') || null;
107 const clanTagEl = row.find('a.user-tag');
108 const badges = [];
109
110 row.find('.badges .badge-icon').each((__, badge) => {
111 badges.push({
112 title: $(badge).attr('title') || null,
113 icon: $(badge).attr('src') || null,
114 });
115 });
116
117 results.push({
118 searchKeyword: keyword,
119 userId,
120 displayName: row.find('a.row-link.user-display').text().trim() || null,
121 username,
122 avatar: row.find('img.icon-xs').attr('src') || null,
123 clanTag: clanTagEl.find('span').text().trim() || null,
124 clanBadge: clanTagEl.find('img').attr('src') || null,
125 clanServerId: (clanTagEl.attr('href') || '').split('/').pop() || null,
126 badges,
127 createdAt: row.find('[data-col="created"] .date-full').text().trim() || null,
128 servers: this.parseNumber(row.find('[data-col="servers"]').text()),
129 invites: this.parseNumber(row.find('[data-col="invites"]').text()),
130 scrapedAt: new Date().toISOString(),
131 });
132 });
133
134 return results;
135 }
136
137 async run(input) {
138 const { keywords, maxItems = Infinity, maxPages = Infinity, proxyConfiguration } = input;
139
140 if (!Array.isArray(keywords) || keywords.length === 0) {
141 throw new Error('Input must include a non-empty keywords array');
142 }
143
144 this.maxItems = maxItems;
145 this.maxPages = maxPages;
146 this.filters = this.buildFilters(input);
147
148 const proxyConfig = proxyConfiguration
149 ? await Actor.createProxyConfiguration(proxyConfiguration)
150 : undefined;
151
152 for (const rawKeyword of keywords) {
153 const keyword = String(rawKeyword || '').trim();
154 if (!keyword) continue;
155
156 const proxyUrl = proxyConfig ? await proxyConfig.newUrl() : undefined;
157 const axiosConfig = this.getAxiosConfig(proxyUrl);
158
159 await this.scrapeKeyword(keyword, axiosConfig);
160 await this.randomDelay(500, 1500);
161 }
162 }
163
164 async scrapeKeyword(keyword, axiosConfig) {
165 const searchUrl = this.buildSearchUrl(keyword);
166 const seenIds = new Set();
167 let page = 1;
168 let totalSaved = 0;
169
170 console.log(`Searching Discord users for "${keyword}"...`);
171
172 while (page <= this.maxPages && totalSaved < this.maxItems) {
173 const apiUrl = this.buildApiUrl(keyword, page);
174 console.log(`Fetching page ${page} for "${keyword}"...`);
175
176 let html;
177 try {
178 const response = await axios.get(apiUrl, {
179 ...axiosConfig,
180 headers: {
181 ...DEFAULT_HEADERS,
182 'hx-current-url': searchUrl,
183 Referer: searchUrl,
184 },
185 timeout: 60000,
186 });
187 html = response.data;
188 } catch (error) {
189 const message = error.response?.data?.message || error.message;
190 console.error(`API request failed for "${keyword}" page ${page}:`, message);
191 if (totalSaved === 0) {
192 await Actor.pushData([
193 {
194 searchKeyword: keyword,
195 error: message,
196 scrapedAt: new Date().toISOString(),
197 },
198 ]);
199 }
200 break;
201 }
202
203 const users = this.parseUsers(html, keyword);
204 if (users.length === 0) {
205 console.log(`No more users on page ${page}`);
206 break;
207 }
208
209 const currentData = [];
210 for (const user of users) {
211 if (totalSaved >= this.maxItems) break;
212
213 const dedupeKey = user.userId || `${user.username}-${user.displayName}`;
214 if (seenIds.has(dedupeKey)) continue;
215 seenIds.add(dedupeKey);
216
217 currentData.push(user);
218 totalSaved++;
219 }
220
221 if (currentData.length > 0) {
222 console.log(`Saved ${currentData.length} users from page ${page}`);
223 await Actor.pushData(currentData);
224 }
225
226 if (totalSaved >= this.maxItems) break;
227
228 page++;
229 await this.randomDelay(500, 1200);
230 }
231
232 console.log(`Finished "${keyword}" with ${totalSaved} users`);
233 }
234
235 async randomDelay(min = 1000, max = 3000) {
236 const delay = Math.floor(Math.random() * (max - min + 1) + min);
237 await new Promise((resolve) => setTimeout(resolve, delay));
238 }
239}
240
241await Actor.init();
242
243Actor.main(async () => {
244 const input = await Actor.getInput();
245
246 const scraper = new DiscordUserSearchScraper();
247 await scraper.run(input);
248});