1import { Actor } from 'apify';
2import axios from 'axios';
3import * as cheerio from 'cheerio';
4
5const API_BASE = 'https://disdex.io/games';
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 = ['from_servers', 'to_servers', 'from_score', 'to_score'];
25
26class DiscordGameSearchScraper {
27 normalizeFilterValue(value) {
28 if (value === undefined || value === null) return '';
29 return String(value).trim();
30 }
31
32 buildFilters(input) {
33 const filters = {};
34
35 for (const field of FILTER_FIELDS) {
36 filters[field] = this.normalizeFilterValue(input[field]);
37 }
38
39 return filters;
40 }
41
42 buildQueryParams(keyword, page) {
43 const params = new URLSearchParams({
44 q: keyword,
45 ...this.filters,
46 });
47
48 if (page) {
49 params.set('page', String(page));
50 }
51
52 return params;
53 }
54
55 buildSearchUrl(keyword) {
56 return `${SITE_BASE}/games?${this.buildQueryParams(keyword).toString()}`;
57 }
58
59 buildApiUrl(keyword, page) {
60 return `${API_BASE}?${this.buildQueryParams(keyword, page).toString()}`;
61 }
62
63 getAxiosConfig(proxyUrl) {
64 if (!proxyUrl) return {};
65
66 const parsed = new URL(proxyUrl);
67 return {
68 proxy: {
69 protocol: parsed.protocol.replace(':', ''),
70 host: parsed.hostname,
71 port: Number(parsed.port),
72 ...(parsed.username
73 ? {
74 auth: {
75 username: decodeURIComponent(parsed.username),
76 password: decodeURIComponent(parsed.password),
77 },
78 }
79 : {}),
80 },
81 };
82 }
83
84 parseNumber(text) {
85 if (!text) return null;
86 const value = Number(String(text).replace(/,/g, '').trim());
87 return Number.isFinite(value) ? value : null;
88 }
89
90 parseGames(html, keyword) {
91 const $ = cheerio.load(`<table>${html}</table>`);
92 const results = [];
93
94 $('tr').each((_, element) => {
95 const row = $(element);
96 const href = row.find('a.row-link').attr('href') || '';
97 const gameId = href.split('/').pop() || row.find('[data-col="appid"] code').text().trim() || null;
98
99 results.push({
100 searchKeyword: keyword,
101 gameId,
102 name: row.find('a.row-link').text().trim() || null,
103 icon: row.find('img.icon-xs').attr('src') || null,
104 appId: row.find('[data-col="appid"] code').text().trim() || gameId,
105 servers: this.parseNumber(row.find('[data-col="servers"]').text()),
106 score: this.parseNumber(row.find('[data-col="score"]').text()),
107 scrapedAt: new Date().toISOString(),
108 });
109 });
110
111 return results;
112 }
113
114 async run(input) {
115 const { keywords, maxItems = Infinity, maxPages = Infinity, proxyConfiguration } = input;
116
117 if (!Array.isArray(keywords) || keywords.length === 0) {
118 throw new Error('Input must include a non-empty keywords array');
119 }
120
121 this.maxItems = maxItems;
122 this.maxPages = maxPages;
123 this.filters = this.buildFilters(input);
124
125 const proxyConfig = proxyConfiguration
126 ? await Actor.createProxyConfiguration(proxyConfiguration)
127 : undefined;
128
129 for (const rawKeyword of keywords) {
130 const keyword = String(rawKeyword || '').trim();
131 if (!keyword) continue;
132
133 const proxyUrl = proxyConfig ? await proxyConfig.newUrl() : undefined;
134 const axiosConfig = this.getAxiosConfig(proxyUrl);
135
136 await this.scrapeKeyword(keyword, axiosConfig);
137 await this.randomDelay(500, 1500);
138 }
139 }
140
141 async scrapeKeyword(keyword, axiosConfig) {
142 const searchUrl = this.buildSearchUrl(keyword);
143 const seenIds = new Set();
144 let page = 1;
145 let totalSaved = 0;
146
147 console.log(`Searching Discord games for "${keyword}"...`);
148
149 while (page <= this.maxPages && totalSaved < this.maxItems) {
150 const apiUrl = this.buildApiUrl(keyword, page);
151 console.log(`Fetching page ${page} for "${keyword}"...`);
152
153 let html;
154 try {
155 const response = await axios.get(apiUrl, {
156 ...axiosConfig,
157 headers: {
158 ...DEFAULT_HEADERS,
159 'hx-current-url': searchUrl,
160 Referer: searchUrl,
161 },
162 timeout: 60000,
163 });
164 html = response.data;
165 } catch (error) {
166 const message = error.response?.data?.message || error.message;
167 console.error(`API request failed for "${keyword}" page ${page}:`, message);
168 if (totalSaved === 0) {
169 await Actor.pushData([
170 {
171 searchKeyword: keyword,
172 error: message,
173 scrapedAt: new Date().toISOString(),
174 },
175 ]);
176 }
177 break;
178 }
179
180 const games = this.parseGames(html, keyword);
181 if (games.length === 0) {
182 console.log(`No more games on page ${page}`);
183 break;
184 }
185
186 const currentData = [];
187 for (const game of games) {
188 if (totalSaved >= this.maxItems) break;
189
190 const dedupeKey = game.gameId || game.name;
191 if (seenIds.has(dedupeKey)) continue;
192 seenIds.add(dedupeKey);
193
194 currentData.push(game);
195 totalSaved++;
196 }
197
198 if (currentData.length > 0) {
199 console.log(`Saved ${currentData.length} games from page ${page}`);
200 await Actor.pushData(currentData);
201 }
202
203 if (totalSaved >= this.maxItems) break;
204
205 page++;
206 await this.randomDelay(500, 1200);
207 }
208
209 console.log(`Finished "${keyword}" with ${totalSaved} games`);
210 }
211
212 async randomDelay(min = 1000, max = 3000) {
213 const delay = Math.floor(Math.random() * (max - min + 1) + min);
214 await new Promise((resolve) => setTimeout(resolve, delay));
215 }
216}
217
218await Actor.init();
219
220Actor.main(async () => {
221 const input = await Actor.getInput();
222
223 const scraper = new DiscordGameSearchScraper();
224 await scraper.run(input);
225});