itchlib 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ import { load } from "cheerio";
2
+ import { assertResponse, ItchError, readJson } from "./errors.js";
3
+ import { parseUploads } from "./parsers/game.js";
4
+ import { resolveGamePath, validateGameUrl } from "./urls.js";
5
+
6
+ async function getCsrfToken(client, gameUrl, signal) {
7
+ const purchaseUrl = resolveGamePath(gameUrl, "purchase");
8
+ const response = assertResponse(
9
+ await client.request(purchaseUrl, { signal }),
10
+ "Itch.io purchase page failed",
11
+ purchaseUrl,
12
+ );
13
+ const purchaseHtml = await response.text();
14
+ const $ = load(purchaseHtml);
15
+ const token = $('meta[name="csrf_token"]').attr("value") || $('meta[name="csrf_token"]').attr("content");
16
+ if (!token)
17
+ throw new ItchError("Itch.io download token is unavailable", {
18
+ url: purchaseUrl.href,
19
+ code: "ITCH_ACCESS_ERROR",
20
+ });
21
+ return { purchaseUrl, token };
22
+ }
23
+
24
+ async function getDownloadPageUrl(client, gameUrl, token, purchaseUrl, signal) {
25
+ const endpoint = resolveGamePath(gameUrl, "download_url");
26
+ const response = assertResponse(
27
+ await client.request(endpoint, {
28
+ method: "POST",
29
+ signal,
30
+ headers: {
31
+ "Content-Type": "application/x-www-form-urlencoded",
32
+ "X-Requested-With": "XMLHttpRequest",
33
+ Referer: purchaseUrl.href,
34
+ },
35
+ body: new URLSearchParams({ csrf_token: token, reward_id: "" }),
36
+ }),
37
+ "Itch.io download page request failed",
38
+ endpoint,
39
+ );
40
+ const result = await readJson(response, "Itch.io download endpoint returned invalid JSON", endpoint);
41
+ const rawUrl = result?.url;
42
+ if (!rawUrl)
43
+ throw new ItchError("Itch.io did not return a download page URL", {
44
+ url: endpoint.href,
45
+ code: "ITCH_ACCESS_ERROR",
46
+ });
47
+ let downloadPageUrl;
48
+ try {
49
+ downloadPageUrl = new URL(rawUrl, gameUrl);
50
+ } catch (cause) {
51
+ throw new ItchError("Itch.io returned an invalid download page URL", {
52
+ url: endpoint.href,
53
+ code: "ITCH_INVALID_RESPONSE",
54
+ cause,
55
+ });
56
+ }
57
+ if (downloadPageUrl.protocol !== "https:")
58
+ throw new ItchError("Itch.io returned an unsupported download page URL", {
59
+ url: downloadPageUrl.href,
60
+ code: "ITCH_INVALID_RESPONSE",
61
+ });
62
+ return downloadPageUrl;
63
+ }
64
+
65
+ export async function getDownloadUploads(client, gameValue, { signal } = {}) {
66
+ const gameUrl = validateGameUrl(gameValue);
67
+ const { purchaseUrl, token } = await getCsrfToken(client, gameUrl, signal);
68
+ const downloadPageUrl = await getDownloadPageUrl(client, gameUrl, token, purchaseUrl, signal);
69
+ const pageResponse = assertResponse(
70
+ await client.request(downloadPageUrl, { signal }),
71
+ "Itch.io download page failed",
72
+ downloadPageUrl,
73
+ );
74
+ return parseUploads(await pageResponse.text());
75
+ }
76
+
77
+ export async function getDownloadUrl(client, gameValue, uploadId, { signal } = {}) {
78
+ const gameUrl = validateGameUrl(gameValue);
79
+ if (!/^\d+$/.test(String(uploadId))) throw new TypeError("uploadId must be a numeric itch.io upload ID");
80
+ const { purchaseUrl, token } = await getCsrfToken(client, gameUrl, signal);
81
+ const downloadPageUrl = await getDownloadPageUrl(client, gameUrl, token, purchaseUrl, signal);
82
+ const pageResponse = assertResponse(
83
+ await client.request(downloadPageUrl, { signal }),
84
+ "Itch.io download page failed",
85
+ downloadPageUrl,
86
+ );
87
+ const upload = parseUploads(await pageResponse.text()).find((item) => item.id === String(uploadId));
88
+ if (!upload)
89
+ throw new ItchError(`Itch.io upload ${uploadId} is unavailable`, {
90
+ url: downloadPageUrl.href,
91
+ code: "ITCH_ACCESS_ERROR",
92
+ });
93
+
94
+ const fileEndpoint = resolveGamePath(gameUrl, `file/${encodeURIComponent(uploadId)}?source=game_download`);
95
+ const fileResponse = assertResponse(
96
+ await client.request(fileEndpoint, {
97
+ method: "POST",
98
+ signal,
99
+ headers: {
100
+ "Content-Type": "application/x-www-form-urlencoded",
101
+ "X-Requested-With": "XMLHttpRequest",
102
+ Referer: purchaseUrl.href,
103
+ },
104
+ body: new URLSearchParams({ csrf_token: token }),
105
+ }),
106
+ "Itch.io file download request failed",
107
+ fileEndpoint,
108
+ );
109
+ const result = await readJson(fileResponse, "Itch.io file endpoint returned invalid JSON", fileEndpoint);
110
+ if (!result?.url)
111
+ throw new ItchError("Itch.io did not return a download URL", {
112
+ url: fileEndpoint.href,
113
+ code: "ITCH_ACCESS_ERROR",
114
+ });
115
+ let fileUrl;
116
+ try {
117
+ fileUrl = new URL(result.url, gameUrl);
118
+ } catch (cause) {
119
+ throw new ItchError("Itch.io returned an invalid download URL", {
120
+ url: fileEndpoint.href,
121
+ code: "ITCH_INVALID_RESPONSE",
122
+ cause,
123
+ });
124
+ }
125
+ if (!/^https?:$/.test(fileUrl.protocol))
126
+ throw new ItchError("Itch.io returned an unsupported download URL", {
127
+ url: fileUrl.href,
128
+ code: "ITCH_INVALID_RESPONSE",
129
+ });
130
+ return fileUrl.href;
131
+ }
package/src/errors.js ADDED
@@ -0,0 +1,33 @@
1
+ export class ItchError extends Error {
2
+ constructor(message, { status, url, code = "ITCH_ERROR", cause } = {}) {
3
+ super(message, { cause });
4
+ this.name = "ItchError";
5
+ this.code = code;
6
+ if (status !== undefined) this.status = status;
7
+ if (url) this.url = url;
8
+ }
9
+ }
10
+
11
+ export function assertResponse(response, message, url) {
12
+ if (!response.ok) {
13
+ throw new ItchError(`${message} (HTTP ${response.status})`, {
14
+ status: response.status,
15
+ url: String(url),
16
+ code: "ITCH_HTTP_ERROR",
17
+ });
18
+ }
19
+ return response;
20
+ }
21
+
22
+ export async function readJson(response, message, url) {
23
+ try {
24
+ return await response.json();
25
+ } catch (cause) {
26
+ throw new ItchError(message, {
27
+ status: response.status,
28
+ url: String(url),
29
+ code: "ITCH_INVALID_RESPONSE",
30
+ cause,
31
+ });
32
+ }
33
+ }
package/src/http.js ADDED
@@ -0,0 +1,103 @@
1
+ import { ItchError } from "./errors.js";
2
+
3
+ const DEFAULT_TIMEOUT_MS = 15_000;
4
+ const DEFAULT_ATTEMPTS = 3;
5
+ const DEFAULT_MAX_RETRY_DELAY_MS = 30_000;
6
+ const RETRYABLE_STATUS = new Set([408, 429]);
7
+
8
+ function retryDelay(response, attempt) {
9
+ const retryAfter = response.headers?.get?.("retry-after");
10
+ const seconds = Number(retryAfter);
11
+ if (retryAfter && Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
12
+ if (retryAfter) {
13
+ const timestamp = Date.parse(retryAfter);
14
+ if (Number.isFinite(timestamp)) return Math.max(0, timestamp - Date.now());
15
+ }
16
+ const base = response.status === 429 ? 2_000 : 500;
17
+ return Math.round(base * 2 ** (attempt - 1) * (0.8 + Math.random() * 0.4));
18
+ }
19
+
20
+ function isRetryableStatus(status) {
21
+ return RETRYABLE_STATUS.has(status) || status >= 500;
22
+ }
23
+
24
+ export class ItchTransport {
25
+ constructor({
26
+ fetch: fetchImpl = globalThis.fetch,
27
+ timeout = DEFAULT_TIMEOUT_MS,
28
+ headers = {},
29
+ userAgent = "itchlib/0.1.0",
30
+ maxRetryDelay = DEFAULT_MAX_RETRY_DELAY_MS,
31
+ } = {}) {
32
+ if (typeof fetchImpl !== "function") throw new TypeError("A Fetch API implementation is required");
33
+ if (!Number.isFinite(timeout) || timeout <= 0)
34
+ throw new TypeError("timeout must be a positive number of milliseconds");
35
+ if (!Number.isFinite(maxRetryDelay) || maxRetryDelay < 0)
36
+ throw new TypeError("maxRetryDelay must be a non-negative number of milliseconds");
37
+ this.fetch = fetchImpl;
38
+ this.timeout = timeout;
39
+ this.maxRetryDelay = maxRetryDelay;
40
+ this.headers = { "User-Agent": userAgent, Accept: "text/html,application/xhtml+xml", ...headers };
41
+ }
42
+
43
+ async request(url, options = {}) {
44
+ const timeout = options.timeout ?? this.timeout;
45
+ const { timeout: _timeout, maxRetryDelay: _maxRetryDelay, ...fetchOptions } = options;
46
+ let lastError;
47
+
48
+ for (let attempt = 1; attempt <= DEFAULT_ATTEMPTS; attempt += 1) {
49
+ const controller = new AbortController();
50
+ const externalSignal = fetchOptions.signal;
51
+ const abortFromCaller = () => controller.abort(externalSignal.reason);
52
+ if (externalSignal?.aborted)
53
+ throw new ItchError("Itch.io request was aborted", { url: String(url), code: "ITCH_ABORTED" });
54
+ externalSignal?.addEventListener("abort", abortFromCaller, { once: true });
55
+ const timer = setTimeout(
56
+ () => controller.abort(new Error(`Request timed out after ${timeout} ms`)),
57
+ timeout,
58
+ );
59
+
60
+ try {
61
+ const response = await this.fetch(url, {
62
+ ...fetchOptions,
63
+ headers: { ...this.headers, ...fetchOptions.headers },
64
+ signal: controller.signal,
65
+ });
66
+ if (!isRetryableStatus(response.status) || attempt === DEFAULT_ATTEMPTS) return response;
67
+ const delay = retryDelay(response, attempt);
68
+ if (delay > this.maxRetryDelay) return response;
69
+ try {
70
+ await response.body?.cancel?.();
71
+ } catch {
72
+ /* A body may already have been consumed or closed. */
73
+ }
74
+ lastError = new ItchError(`Itch.io request failed with HTTP ${response.status}`, {
75
+ status: response.status,
76
+ url: String(url),
77
+ code: "ITCH_HTTP_ERROR",
78
+ });
79
+ await new Promise((resolve) => setTimeout(resolve, delay));
80
+ } catch (cause) {
81
+ if (externalSignal?.aborted)
82
+ throw new ItchError("Itch.io request was aborted", {
83
+ url: String(url),
84
+ code: "ITCH_ABORTED",
85
+ cause,
86
+ });
87
+ lastError = new ItchError(`Itch.io request failed: ${cause.message}`, {
88
+ url: String(url),
89
+ code: "ITCH_NETWORK_ERROR",
90
+ cause,
91
+ });
92
+ if (attempt === DEFAULT_ATTEMPTS) throw lastError;
93
+ await new Promise((resolve) =>
94
+ setTimeout(resolve, Math.round(500 * 2 ** (attempt - 1) * (0.8 + Math.random() * 0.4))),
95
+ );
96
+ } finally {
97
+ clearTimeout(timer);
98
+ externalSignal?.removeEventListener("abort", abortFromCaller);
99
+ }
100
+ }
101
+ throw lastError;
102
+ }
103
+ }
package/src/index.js ADDED
@@ -0,0 +1,40 @@
1
+ export { ItchClient } from "./client.js";
2
+ export { ItchError } from "./errors.js";
3
+ export { ITCH_BROWSE_PAGES } from "./routes.js";
4
+
5
+ import { ItchClient } from "./client.js";
6
+
7
+ export function createItchClient(options) {
8
+ return new ItchClient(options);
9
+ }
10
+
11
+ export async function getGame(gameUrl, options) {
12
+ return new ItchClient(options).getGame(gameUrl);
13
+ }
14
+ export async function getUploads(gameUrl, options) {
15
+ return new ItchClient(options).getUploads(gameUrl);
16
+ }
17
+ export async function getDownloadUploads(gameUrl, options) {
18
+ return new ItchClient(options).getDownloadUploads(gameUrl);
19
+ }
20
+ export async function getDownloadUrl(gameUrl, uploadId, options) {
21
+ return new ItchClient(options).getDownloadUrl(gameUrl, uploadId);
22
+ }
23
+ export async function getComments(gameUrl, options) {
24
+ return new ItchClient(options).getComments(gameUrl);
25
+ }
26
+ export async function getProfile(profile, options) {
27
+ return new ItchClient(options).getProfile(profile);
28
+ }
29
+ export async function searchGames(query, options, clientOptions) {
30
+ return new ItchClient(clientOptions).searchGames(query, options);
31
+ }
32
+ export async function getSuggestions(query, options) {
33
+ return new ItchClient(options).getSuggestions(query);
34
+ }
35
+ export async function getBrowsePage(section, browseOptions, clientOptions) {
36
+ return new ItchClient(clientOptions).getBrowsePage(section, browseOptions);
37
+ }
38
+ export async function getFeed(feed, options) {
39
+ return new ItchClient(options).getFeed(feed);
40
+ }
@@ -0,0 +1,247 @@
1
+ import { absoluteUrl, parseHtml, readLinks, textOf } from "./html.js";
2
+ import { parseGameCards } from "./cards.js";
3
+
4
+ function parseFilterPickers($, baseUrl) {
5
+ const pickers = [];
6
+ $(".filter_picker_widget").each((_, element) => {
7
+ const picker = $(element);
8
+ const options = [];
9
+ picker.find(".filter_option").each((__, optionElement) => {
10
+ const option = $(optionElement);
11
+ options.push({
12
+ label: textOf(option),
13
+ url: absoluteUrl(option.attr("href"), baseUrl),
14
+ value: option.attr("data-value") || null,
15
+ selected: option.hasClass("selected"),
16
+ });
17
+ });
18
+ if (options.length)
19
+ pickers.push({
20
+ id: picker.attr("id") || null,
21
+ selected: textOf(picker.find(".value_label").first()) || null,
22
+ options,
23
+ });
24
+ });
25
+ return pickers;
26
+ }
27
+
28
+ function parseFilterGroups($, baseUrl) {
29
+ const groups = [];
30
+ $(".browse_filter_group_widget").each((_, element) => {
31
+ const group = $(element);
32
+ const options = [];
33
+ group.find("a[href]").each((__, optionElement) => {
34
+ const option = $(optionElement);
35
+ const href = option.attr("href");
36
+ if (!href || href === "#") return;
37
+ options.push({
38
+ label: textOf(option),
39
+ url: absoluteUrl(href, baseUrl),
40
+ value: option.attr("data-value") || null,
41
+ selected: option.hasClass("selected"),
42
+ });
43
+ });
44
+ const label = textOf(group.find(".filter_group_label").first());
45
+ if (label && options.length) groups.push({ id: group.attr("id") || null, label, options });
46
+ });
47
+ return groups;
48
+ }
49
+
50
+ function parseFacets($, baseUrl) {
51
+ const facets = [];
52
+ $(".facet_group").each((_, element) => {
53
+ const group = $(element);
54
+ const label = textOf(group.find(".facet_group_label").first());
55
+ const values = [];
56
+ group.find("a.facet_value[href]").each((__, valueElement) => {
57
+ const value = $(valueElement);
58
+ const countText = textOf(value.find(".facet_count").first()).replace(/,/g, "");
59
+ values.push({
60
+ label: textOf(value.find(".facet_name").first()) || textOf(value),
61
+ count: Number(countText) || null,
62
+ url: absoluteUrl(value.attr("href"), baseUrl),
63
+ });
64
+ });
65
+ if (label && values.length) facets.push({ label, values });
66
+ });
67
+ return facets;
68
+ }
69
+
70
+ function parseJams($, baseUrl) {
71
+ const jams = [];
72
+ $(".jam_cell").each((_, element) => {
73
+ const cell = $(element);
74
+ const anchor = cell.find("a[href]").first();
75
+ const url = absoluteUrl(anchor.attr("href"), baseUrl);
76
+ if (!url) return;
77
+ const count = textOf(cell.find(".joined_count").first()).match(/[\d,]+/)?.[0];
78
+ jams.push({ title: textOf(anchor), url, joinedCount: count ? Number(count.replace(/,/g, "")) : null });
79
+ });
80
+ return jams;
81
+ }
82
+
83
+ function parseTags($, baseUrl) {
84
+ const tags = [];
85
+ $(".tag_cell").each((_, element) => {
86
+ const cell = $(element);
87
+ const anchor = cell.find(".tag_title[href]").first().length
88
+ ? cell.find(".tag_title[href]").first()
89
+ : cell.find("a[href]").first();
90
+ const url = absoluteUrl(anchor.attr("href"), baseUrl);
91
+ if (!url) return;
92
+ const image = cell.find("img").first();
93
+ tags.push({
94
+ title: textOf(anchor),
95
+ url,
96
+ cover: absoluteUrl(image.attr("src") || image.attr("data-lazy_src"), baseUrl),
97
+ count: textOf(cell.find(".games_count").first()) || null,
98
+ description: textOf(cell.find(".tag_description").first()) || null,
99
+ });
100
+ });
101
+ return tags;
102
+ }
103
+
104
+ function parseBlogPosts($, baseUrl, allLinks) {
105
+ const posts = [];
106
+ $(".post_preview").each((_, element) => {
107
+ const preview = $(element);
108
+ const summaryLink = preview.find("a.summary_link[href]").first();
109
+ const localTitle = preview.find("a.title[href]").first();
110
+ const url = absoluteUrl(localTitle.attr("href") || summaryLink.attr("href"), baseUrl);
111
+ if (!url) return;
112
+ const title =
113
+ textOf(localTitle) ||
114
+ allLinks.find((link) => link.url === url && !/continue reading/i.test(link.text))?.text;
115
+ if (!title) return;
116
+ posts.push({ title, url, summary: textOf(preview.find(".summary_text").first()) || null });
117
+ });
118
+ return posts;
119
+ }
120
+
121
+ function parseDevlogs($, baseUrl) {
122
+ const posts = [];
123
+ $(".post_title").each((_, element) => {
124
+ const anchor = $(element).find("a[href]").first();
125
+ const url = absoluteUrl(anchor.attr("href"), baseUrl);
126
+ const title = textOf(anchor);
127
+ if (url && title) posts.push({ title, url });
128
+ });
129
+ return posts;
130
+ }
131
+
132
+ function parseTopics($, allLinks) {
133
+ const topics = [];
134
+ $(".topic_title").each((_, element) => {
135
+ const title = textOf($(element));
136
+ const link = allLinks.find((candidate) => candidate.text.includes(title) && /\/t\//.test(candidate.url));
137
+ if (title && link) topics.push({ title, url: link.url });
138
+ });
139
+ return topics;
140
+ }
141
+
142
+ function parseCategories($, baseUrl) {
143
+ const categories = [];
144
+ $(".category_row").each((_, element) => {
145
+ const row = $(element);
146
+ const titleLink = row.find(".category_title a[href]").first();
147
+ const url = absoluteUrl(titleLink.attr("href"), baseUrl);
148
+ const title = textOf(titleLink);
149
+ if (!url || !title) return;
150
+ const latestTopicLink = row.find(".last_topic[href], .last_topic a[href]").first();
151
+ const latestTopicUrl = absoluteUrl(latestTopicLink.attr("href"), baseUrl);
152
+ const latestTopicTitle = textOf(latestTopicLink.find(".topic_title").first());
153
+ const topicCount = textOf(row.find(".topics_count").first()).match(/[\d,]+/)?.[0];
154
+ categories.push({
155
+ title,
156
+ url,
157
+ description: textOf(row.find(".short_description").first()) || null,
158
+ topicCount: topicCount ? Number(topicCount.replace(/,/g, "")) : null,
159
+ latestTopic:
160
+ latestTopicUrl && latestTopicTitle
161
+ ? {
162
+ title: latestTopicTitle,
163
+ url: latestTopicUrl,
164
+ author: textOf(row.find(".last_topic_author").first()) || null,
165
+ }
166
+ : null,
167
+ });
168
+ });
169
+ return categories;
170
+ }
171
+
172
+ function parseSales($, baseUrl) {
173
+ const sales = [];
174
+ $(".sale_row").each((_, element) => {
175
+ const row = $(element);
176
+ const titleLink = row.find(".sale_title a[href]").first();
177
+ const url = absoluteUrl(titleLink.attr("href"), baseUrl);
178
+ const title = textOf(titleLink);
179
+ if (!url || !title) return;
180
+ const authorLink = row.find(".sale_author a[href]").first();
181
+ const itemCount = textOf(row.find(".game_count").first()).match(/[\d,]+/)?.[0];
182
+ const endsAt = row.find(".sale_end .date_format").first().attr("title") || null;
183
+ const platforms = [];
184
+ row.find(".game_platforms [title]").each((__, label) => platforms.push($(label).attr("title")));
185
+ sales.push({
186
+ id: row.attr("data-sale_id") || null,
187
+ type: row.attr("data-sale_type") || null,
188
+ title,
189
+ url,
190
+ author: authorLink.length
191
+ ? { url: absoluteUrl(authorLink.attr("href"), baseUrl), text: textOf(authorLink) }
192
+ : null,
193
+ itemCount: itemCount ? Number(itemCount.replace(/,/g, "")) : null,
194
+ endsAt,
195
+ platforms: [...new Set(platforms.filter(Boolean))],
196
+ covers: row
197
+ .find("img.game_thumb")
198
+ .map((__, image) => absoluteUrl($(image).attr("data-lazy_src") || $(image).attr("src"), baseUrl))
199
+ .get()
200
+ .filter(Boolean),
201
+ });
202
+ });
203
+ return sales;
204
+ }
205
+
206
+ export function parseBrowsePage(markup, pageUrl) {
207
+ const $ = parseHtml(markup);
208
+ const allLinks = readLinks($, $("body"), pageUrl);
209
+ const sortRoot = $(".browse_sort_options_widget .sorts").first();
210
+ const mobileSort = $(".sort_options_mobile .filter_option.selected").first();
211
+ const activeButton = sortRoot.find("button.active").first();
212
+ const activeSort = textOf(mobileSort) || textOf(activeButton) || null;
213
+ const sortOptions = readLinks($, sortRoot, pageUrl).map((option) => ({
214
+ ...option,
215
+ selected: option.text === activeSort,
216
+ }));
217
+ if (activeSort && !sortOptions.some((option) => option.selected))
218
+ sortOptions.unshift({ text: activeSort, url: pageUrl, selected: true });
219
+ const countText = textOf($(".game_count").first());
220
+ const nextHref = $("a[rel~='next']").first().attr("href");
221
+ const sales = parseSales($, pageUrl);
222
+ const bundleUrls = new Set(sales.filter((sale) => sale.type === "bundle").map((sale) => sale.url));
223
+ const bundleLinks = allLinks.filter((link) => /\/b\/\d+\//.test(link.url) && !bundleUrls.has(link.url));
224
+
225
+ return {
226
+ url: pageUrl,
227
+ title: textOf($("title").first()),
228
+ query: new URL(pageUrl).searchParams.get("q"),
229
+ resultCount: Number(countText.match(/[\d,]+/)?.[0]?.replace(/,/g, "")) || null,
230
+ games: parseGameCards(markup, pageUrl),
231
+ jams: parseJams($, pageUrl),
232
+ categories: parseCategories($, pageUrl),
233
+ tags: parseTags($, pageUrl),
234
+ bundles: [...sales.filter((sale) => sale.type === "bundle"), ...bundleLinks],
235
+ sales,
236
+ devlogs: parseDevlogs($, pageUrl),
237
+ blogPosts: parseBlogPosts($, pageUrl, allLinks),
238
+ topics: parseTopics($, allLinks),
239
+ filters: parseFilterGroups($, pageUrl),
240
+ filterPickers: parseFilterPickers($, pageUrl),
241
+ facets: parseFacets($, pageUrl),
242
+ sortOptions,
243
+ activeSort,
244
+ relatedTags: readLinks($, $(".browse_top_tags").first(), pageUrl),
245
+ nextPageUrl: absoluteUrl(nextHref, pageUrl),
246
+ };
247
+ }
@@ -0,0 +1,36 @@
1
+ import { absoluteUrl, parseHtml, textOf } from "./html.js";
2
+
3
+ export function parseGameCards(markup, baseUrl) {
4
+ const $ = parseHtml(markup);
5
+ const games = [];
6
+ $(".game_cell").each((_, element) => {
7
+ const card = $(element);
8
+ const anchor = card.find("a.title[href]").first().length
9
+ ? card.find("a.title[href]").first()
10
+ : card.find("a[href]").first();
11
+ const url = absoluteUrl(anchor.attr("href"), baseUrl);
12
+ const image = card.find("img").first();
13
+ const title = textOf(anchor) || image.attr("alt") || "";
14
+ if (!url || !title) return;
15
+ const id = Number(card.attr("data-game_id")) || null;
16
+ const cover = absoluteUrl(
17
+ image.attr("src") || image.attr("data-src") || image.attr("data-lazy_src"),
18
+ baseUrl,
19
+ );
20
+ const description = textOf(card.find(".game_text").first());
21
+ const author = textOf(card.find(".game_author").first());
22
+ const genre = textOf(card.find(".game_genre").first());
23
+ const platforms = textOf(card.find(".game_platform").first());
24
+ games.push({
25
+ id,
26
+ title,
27
+ url,
28
+ cover,
29
+ description: description || null,
30
+ author: author || null,
31
+ genre: genre || null,
32
+ platforms: platforms || null,
33
+ });
34
+ });
35
+ return games;
36
+ }
@@ -0,0 +1,31 @@
1
+ import { load } from "cheerio";
2
+ import { parseHtml, textOf } from "./html.js";
3
+
4
+ function elementText($, scope, name) {
5
+ const localName = name.includes(":") ? name.split(":").at(-1) : name;
6
+ let value;
7
+ scope.find("*").each((_, element) => {
8
+ if (!value && element.name?.split(":").at(-1)?.toLowerCase() === localName.toLowerCase()) {
9
+ value = textOf($(element));
10
+ }
11
+ });
12
+ return value || "";
13
+ }
14
+
15
+ export function parseFeed(markup, feed, url) {
16
+ const $ = load(String(markup ?? ""), { xmlMode: true, decodeEntities: true });
17
+ const items = [];
18
+ $("item").each((_, element) => {
19
+ const item = $(element);
20
+ const field = (selector, fallbackName = selector) =>
21
+ textOf(item.find(selector).first()) || elementText($, item, fallbackName);
22
+ items.push({
23
+ title: field("title"),
24
+ url: field("link"),
25
+ description: textOf(parseHtml(field("description"))("body")),
26
+ publishedAt: field("pubDate"),
27
+ author: field("dc\\:creator", "dc:creator") || null,
28
+ });
29
+ });
30
+ return { feed, url, items };
31
+ }