1import { Actor } from 'apify';
2import axios from 'axios';
3import * as cheerio from 'cheerio';
4
5const API_BASE = 'https://disdex.io/bots';
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 DiscordBotSearchScraper {
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}/bots?${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 parseBots(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 botId = href.split('/').pop() || null;
105 const nameText = row.find('a.row-link.user-display').text().trim();
106 const badges = [];
107
108 row.find('.badges .badge-icon').each((__, badge) => {
109 badges.push({
110 title: $(badge).attr('title') || null,
111 icon: $(badge).attr('src') || null,
112 });
113 });
114
115 results.push({
116 searchKeyword: keyword,
117 botId,
118 name: nameText.replace(/^@/, '') || null,
119 avatar: row.find('img.icon-xs').attr('src') || null,
120 badges,
121 createdAt: row.find('[data-col="created"] .date-full').text().trim() || null,
122 servers: this.parseNumber(row.find('[data-col="servers"]').text()),
123 invites: this.parseNumber(row.find('[data-col="invites"]').text()),
124 scrapedAt: new Date().toISOString(),
125 });
126 });
127
128 return results;
129 }
130
131 async run(input) {
132 const { keywords, maxItems = Infinity, maxPages = Infinity, proxyConfiguration } = input;
133
134 if (!Array.isArray(keywords) || keywords.length === 0) {
135 throw new Error('Input must include a non-empty keywords array');
136 }
137
138 this.maxItems = maxItems;
139 this.maxPages = maxPages;
140 this.filters = this.buildFilters(input);
141
142 const proxyConfig = proxyConfiguration
143 ? await Actor.createProxyConfiguration(proxyConfiguration)
144 : undefined;
145
146 for (const rawKeyword of keywords) {
147 const keyword = String(rawKeyword || '').trim();
148 if (!keyword) continue;
149
150 const proxyUrl = proxyConfig ? await proxyConfig.newUrl() : undefined;
151 const axiosConfig = this.getAxiosConfig(proxyUrl);
152
153 await this.scrapeKeyword(keyword, axiosConfig);
154 await this.randomDelay(500, 1500);
155 }
156 }
157
158 async scrapeKeyword(keyword, axiosConfig) {
159 const searchUrl = this.buildSearchUrl(keyword);
160 const seenIds = new Set();
161 let page = 1;
162 let totalSaved = 0;
163
164 console.log(`Searching Discord bots for "${keyword}"...`);
165
166 while (page <= this.maxPages && totalSaved < this.maxItems) {
167 const apiUrl = this.buildApiUrl(keyword, page);
168 console.log(`Fetching page ${page} for "${keyword}"...`);
169
170 let html;
171 try {
172 const response = await axios.get(apiUrl, {
173 ...axiosConfig,
174 headers: {
175 ...DEFAULT_HEADERS,
176 'hx-current-url': searchUrl,
177 Referer: searchUrl,
178 },
179 timeout: 60000,
180 });
181 html = response.data;
182 } catch (error) {
183 const message = error.response?.data?.message || error.message;
184 console.error(`API request failed for "${keyword}" page ${page}:`, message);
185 if (totalSaved === 0) {
186 await Actor.pushData([
187 {
188 searchKeyword: keyword,
189 error: message,
190 scrapedAt: new Date().toISOString(),
191 },
192 ]);
193 }
194 break;
195 }
196
197 const bots = this.parseBots(html, keyword);
198 if (bots.length === 0) {
199 console.log(`No more bots on page ${page}`);
200 break;
201 }
202
203 const currentData = [];
204 for (const bot of bots) {
205 if (totalSaved >= this.maxItems) break;
206
207 const dedupeKey = bot.botId || `${bot.name}`;
208 if (seenIds.has(dedupeKey)) continue;
209 seenIds.add(dedupeKey);
210
211 currentData.push(bot);
212 totalSaved++;
213 }
214
215 if (currentData.length > 0) {
216 console.log(`Saved ${currentData.length} bots from page ${page}`);
217 await Actor.pushData(currentData);
218 }
219
220 if (totalSaved >= this.maxItems) break;
221
222 page++;
223 await this.randomDelay(500, 1200);
224 }
225
226 console.log(`Finished "${keyword}" with ${totalSaved} bots`);
227 }
228
229 async randomDelay(min = 1000, max = 3000) {
230 const delay = Math.floor(Math.random() * (max - min + 1) + min);
231 await new Promise((resolve) => setTimeout(resolve, delay));
232 }
233}
234
235await Actor.init();
236
237Actor.main(async () => {
238 const input = await Actor.getInput();
239
240 const scraper = new DiscordBotSearchScraper();
241 await scraper.run(input);
242});