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.
- package/CHANGELOG.md +9 -0
- package/CONTRIBUTING.md +32 -0
- package/LICENSE +22 -0
- package/README.md +32 -2
- package/SECURITY.md +15 -0
- package/package.json +67 -4
- package/src/api.js +267 -0
- package/src/client.js +231 -0
- package/src/downloads.js +131 -0
- package/src/errors.js +33 -0
- package/src/http.js +103 -0
- package/src/index.js +40 -0
- package/src/parsers/browse.js +247 -0
- package/src/parsers/cards.js +36 -0
- package/src/parsers/feed.js +31 -0
- package/src/parsers/game.js +164 -0
- package/src/parsers/html.js +114 -0
- package/src/parsers/profile.js +33 -0
- package/src/routes.js +26 -0
- package/src/urls.js +69 -0
- package/types/index.d.ts +420 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
- Game info: `getGame`, `getUploads`, `getComments`, `getProfile`.
|
|
6
|
+
- Discovery: `searchGames`, `getSuggestions`, `getBrowsePage`, `getFeed`.
|
|
7
|
+
- Downloads: `getDownloadUploads`, `getDownloadUrl`.
|
|
8
|
+
- Authenticated API: profile, games, ownership, purchases, rewards, keys, collections, and latest-version helpers.
|
|
9
|
+
- Developer tools: reusable `ItchClient`, TypeScript types, request retries, and cancellation with `AbortSignal`.
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for helping improve itchlib!
|
|
4
|
+
|
|
5
|
+
## Development
|
|
6
|
+
|
|
7
|
+
Use Node.js 22.19 or later.
|
|
8
|
+
|
|
9
|
+
Install dependencies:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm ci
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Before opening a pull request, run:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm run check
|
|
19
|
+
npm run typecheck
|
|
20
|
+
npm run pack:check
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Guidelines
|
|
24
|
+
|
|
25
|
+
- When itch.io changes its page structure, update only the affected parser.
|
|
26
|
+
- Preserve original site data where possible instead of inventing classifications.
|
|
27
|
+
|
|
28
|
+
## Pull Requests
|
|
29
|
+
|
|
30
|
+
Describe what your changes do, any public API changes, and which checks you ran.
|
|
31
|
+
|
|
32
|
+
Never include real credentials, cookies, purchase keys, or private download URLs in logs.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Neutralinojs and contributors
|
|
4
|
+
Copyright (c) 2026 itchlib contributors
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# itchlib
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Node.js library for exploring itch.io games, creators, downloads, and more stuff.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 22.19 or later
|
|
8
|
+
- Also ESM support
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install itchlib
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import { ItchClient } from "itchlib";
|
|
20
|
+
|
|
21
|
+
const itch = new ItchClient();
|
|
22
|
+
const game = await itch.getGame("https://ninja-muffin24.itch.io/funkin");
|
|
23
|
+
// heh,, Friday Night Funkin'
|
|
24
|
+
|
|
25
|
+
console.log(game.title);
|
|
26
|
+
console.log(game.descriptionHtml);
|
|
27
|
+
console.log(game.images);
|
|
28
|
+
console.log(game.uploads);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`getGame()` returns information available on the game's itch.io page, including its description, media, links, details, downloads, and comments.
|
|
32
|
+
|
|
33
|
+
Documentation is WIP still so wait a bit for that :)
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
Security fixes target the latest release of itchlib.
|
|
6
|
+
|
|
7
|
+
## Reporting a Vulnerability
|
|
8
|
+
|
|
9
|
+
Please report security vulnerabilities through GitHub's private vulnerability reporting feature, if enabled. Avoid disclosing vulnerabilities in public issues.
|
|
10
|
+
|
|
11
|
+
Never include API keys, access tokens, purchase keys, cookies, or private download URLs in public reports or logs. If credentials have been exposed, revoke or rotate them before sharing diagnostics.
|
|
12
|
+
|
|
13
|
+
## Credential Handling
|
|
14
|
+
|
|
15
|
+
Authenticated API requests send bearer tokens only to `https://api.itch.io`. itchlib does not intentionally log credentials or response bodies.
|
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "itchlib",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A Node.js client for reading itch.io games, creators, search, community, and downloads.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/immalloy/itchlib.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/immalloy/itchlib#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/immalloy/itchlib/issues"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./src/index.js",
|
|
15
|
+
"module": "./src/index.js",
|
|
16
|
+
"types": "./types/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./types/index.d.ts",
|
|
20
|
+
"import": "./src/index.js",
|
|
21
|
+
"default": "./src/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"check": "node --check src/index.js && npm run format:check && npm run lint",
|
|
27
|
+
"typecheck": "tsc --project tsconfig.json",
|
|
28
|
+
"pack:check": "npm pack --dry-run",
|
|
29
|
+
"format": "prettier --write .",
|
|
30
|
+
"format:check": "prettier --check .",
|
|
31
|
+
"lint": "eslint ."
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22.19.0"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"src/",
|
|
38
|
+
"types/",
|
|
39
|
+
"README.md",
|
|
40
|
+
"CONTRIBUTING.md",
|
|
41
|
+
"SECURITY.md",
|
|
42
|
+
"LICENSE",
|
|
43
|
+
"CHANGELOG.md"
|
|
44
|
+
],
|
|
45
|
+
"sideEffects": false,
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"keywords": [
|
|
48
|
+
"itch.io",
|
|
49
|
+
"itchio",
|
|
50
|
+
"games",
|
|
51
|
+
"game-discovery",
|
|
52
|
+
"metadata",
|
|
53
|
+
"downloads",
|
|
54
|
+
"scraper"
|
|
55
|
+
],
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"cheerio": "^1.2.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@eslint/js": "^10.0.1",
|
|
61
|
+
"eslint": "^10.14.0",
|
|
62
|
+
"globals": "^17.14.0",
|
|
63
|
+
"prettier": "^3.10.0",
|
|
64
|
+
"typescript": "^7.0.2"
|
|
65
|
+
},
|
|
66
|
+
"overrides": {
|
|
67
|
+
"minimatch": "^11.0.8"
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { ItchError, readJson } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
async function parseApiResponse(response, endpoint, invalidJsonMessage, failureMessage) {
|
|
4
|
+
if (!response.ok) {
|
|
5
|
+
let result = null;
|
|
6
|
+
try {
|
|
7
|
+
result = await response.clone().json();
|
|
8
|
+
} catch {
|
|
9
|
+
// Some edge and rate-limit responses are HTML, even for JSON endpoints.
|
|
10
|
+
}
|
|
11
|
+
throw new ItchError(result?.errors?.join("; ") || `${failureMessage} (HTTP ${response.status})`, {
|
|
12
|
+
status: response.status,
|
|
13
|
+
url: endpoint.href,
|
|
14
|
+
code: "ITCH_HTTP_ERROR",
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
const result = await readJson(response, invalidJsonMessage, endpoint);
|
|
18
|
+
if (result?.errors?.length) {
|
|
19
|
+
throw new ItchError(result.errors.join("; "), {
|
|
20
|
+
status: response.status,
|
|
21
|
+
url: endpoint.href,
|
|
22
|
+
code: "ITCH_API_ERROR",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function apiRequest(client, path, { query, method = "GET", body, headers = {}, signal } = {}) {
|
|
29
|
+
if (!client.apiKey)
|
|
30
|
+
throw new ItchError("An itch.io API key is required for this method", { code: "ITCH_AUTH_REQUIRED" });
|
|
31
|
+
const endpoint = new URL(String(path).replace(/^\//, ""), "https://api.itch.io/");
|
|
32
|
+
if (endpoint.hostname !== "api.itch.io" || endpoint.protocol !== "https:") {
|
|
33
|
+
throw new ItchError("API requests must target api.itch.io", {
|
|
34
|
+
url: endpoint.href,
|
|
35
|
+
code: "ITCH_INVALID_URL",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
for (const [key, value] of Object.entries(query || {})) {
|
|
39
|
+
if (value !== undefined && value !== null)
|
|
40
|
+
endpoint.searchParams.set(key, Array.isArray(value) ? value.join(",") : String(value));
|
|
41
|
+
}
|
|
42
|
+
const requestHeaders = { Authorization: `Bearer ${client.apiKey}`, Accept: "application/json", ...headers };
|
|
43
|
+
const requestOptions = { method, headers: requestHeaders, ...(signal ? { signal } : {}) };
|
|
44
|
+
if (body !== undefined) {
|
|
45
|
+
requestOptions.body = new URLSearchParams(
|
|
46
|
+
Object.entries(body)
|
|
47
|
+
.filter(([, value]) => value !== undefined && value !== null)
|
|
48
|
+
.map(([key, value]) => [key, String(value)]),
|
|
49
|
+
);
|
|
50
|
+
requestHeaders["Content-Type"] = "application/x-www-form-urlencoded";
|
|
51
|
+
}
|
|
52
|
+
const response = await client.request(endpoint, requestOptions);
|
|
53
|
+
return parseApiResponse(response, endpoint, "Itch.io API returned invalid JSON", "Itch.io API failed");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getLatestVersion(client, { channelName, target, gameId, signal } = {}) {
|
|
57
|
+
if (!channelName || (!target && !gameId) || (target && gameId)) {
|
|
58
|
+
throw new TypeError("Provide channelName and exactly one of target or gameId");
|
|
59
|
+
}
|
|
60
|
+
if (gameId !== undefined) validateApiId(gameId, "gameId");
|
|
61
|
+
const endpoint = new URL("https://api.itch.io/wharf/latest");
|
|
62
|
+
endpoint.searchParams.set("channel_name", channelName);
|
|
63
|
+
if (target) endpoint.searchParams.set("target", target);
|
|
64
|
+
if (gameId) endpoint.searchParams.set("game_id", String(gameId));
|
|
65
|
+
return client
|
|
66
|
+
.request(endpoint, { ...(signal ? { signal } : {}), headers: { Accept: "application/json" } })
|
|
67
|
+
.then((response) =>
|
|
68
|
+
parseApiResponse(
|
|
69
|
+
response,
|
|
70
|
+
endpoint,
|
|
71
|
+
"Itch.io version endpoint returned invalid JSON",
|
|
72
|
+
"Itch.io version request failed",
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function getAllOwnedKeys(client, { perPage = 500, gameIds, signal } = {}) {
|
|
78
|
+
validatePage(perPage, "perPage", 500);
|
|
79
|
+
validateGameIds(gameIds);
|
|
80
|
+
const keys = [];
|
|
81
|
+
for (let page = 1; ; page += 1) {
|
|
82
|
+
const result = await apiRequest(client, "profile/owned-keys", {
|
|
83
|
+
query: { page, per_page: perPage, game_ids: gameIds },
|
|
84
|
+
signal,
|
|
85
|
+
});
|
|
86
|
+
const current = result.owned_keys || [];
|
|
87
|
+
keys.push(...current);
|
|
88
|
+
if (!current.length) return keys;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function validatePage(page, name, maximum = Number.MAX_SAFE_INTEGER) {
|
|
93
|
+
if (!Number.isSafeInteger(page) || page < 1 || page > maximum) {
|
|
94
|
+
throw new TypeError(`${name} must be an integer between 1 and ${maximum}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function validateApiId(id, name) {
|
|
99
|
+
if (!/^\d+$/.test(String(id ?? ""))) throw new TypeError(`${name} must be a numeric itch.io ID`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function validateGameIds(gameIds) {
|
|
103
|
+
if (gameIds !== undefined && (!Array.isArray(gameIds) || gameIds.length > 50)) {
|
|
104
|
+
throw new TypeError("gameIds must be an array with no more than 50 IDs");
|
|
105
|
+
}
|
|
106
|
+
for (const gameId of gameIds || []) validateApiId(gameId, "gameId");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function getApiCredentials(client, options) {
|
|
110
|
+
return apiRequest(client, "credentials/info", options);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getGameDownloadKey(client, gameId, { downloadKey, userId, email, signal } = {}) {
|
|
114
|
+
validateApiId(gameId, "gameId");
|
|
115
|
+
const identifiers = [downloadKey, userId, email].filter(
|
|
116
|
+
(value) => value !== undefined && value !== null && value !== "",
|
|
117
|
+
);
|
|
118
|
+
if (identifiers.length !== 1) {
|
|
119
|
+
throw new TypeError("Provide exactly one of downloadKey, userId, or email");
|
|
120
|
+
}
|
|
121
|
+
return apiRequest(client, `games/${encodeURIComponent(gameId)}/download_keys`, {
|
|
122
|
+
query: { download_key: downloadKey, user_id: userId, email },
|
|
123
|
+
signal,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getGamePurchases(client, gameId, { userId, email, signal } = {}) {
|
|
128
|
+
validateApiId(gameId, "gameId");
|
|
129
|
+
if (
|
|
130
|
+
(userId === undefined || userId === null || userId === "") ===
|
|
131
|
+
(email === undefined || email === null || email === "")
|
|
132
|
+
) {
|
|
133
|
+
throw new TypeError("Provide exactly one of userId or email");
|
|
134
|
+
}
|
|
135
|
+
return apiRequest(client, `games/${encodeURIComponent(gameId)}/purchases`, {
|
|
136
|
+
query: { user_id: userId, email },
|
|
137
|
+
signal,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function getClaimedRewards(client, gameId, { page = 1, signal } = {}) {
|
|
142
|
+
validateApiId(gameId, "gameId");
|
|
143
|
+
validatePage(page, "page");
|
|
144
|
+
return apiRequest(client, `games/${encodeURIComponent(gameId)}/claimed-rewards`, {
|
|
145
|
+
query: { page },
|
|
146
|
+
signal,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function getAllClaimedRewards(client, gameId, { signal } = {}) {
|
|
151
|
+
validateApiId(gameId, "gameId");
|
|
152
|
+
const rewards = [];
|
|
153
|
+
for (let page = 1; ; page += 1) {
|
|
154
|
+
const result = await getClaimedRewards(client, gameId, { page, signal });
|
|
155
|
+
const current = result.claimed_rewards || [];
|
|
156
|
+
rewards.push(...current);
|
|
157
|
+
if (!current.length) return rewards;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function checkGameOwnership(client, gameId, options) {
|
|
162
|
+
validateApiId(gameId, "gameId");
|
|
163
|
+
return apiRequest(client, `games/${encodeURIComponent(gameId)}/ownership`, options);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function getCollections(client, { gameId, signal } = {}) {
|
|
167
|
+
if (gameId !== undefined && gameId !== null) validateApiId(gameId, "gameId");
|
|
168
|
+
return apiRequest(client, "profile/collections", { query: { game_id: gameId }, signal });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function getCollection(client, collectionId, options) {
|
|
172
|
+
validateApiId(collectionId, "collectionId");
|
|
173
|
+
return apiRequest(client, `collections/${encodeURIComponent(collectionId)}`, options);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function getCollectionGames(client, collectionId, { page = 1, perPage = 100, signal } = {}) {
|
|
177
|
+
validateApiId(collectionId, "collectionId");
|
|
178
|
+
validatePage(page, "page");
|
|
179
|
+
validatePage(perPage, "perPage", 500);
|
|
180
|
+
return apiRequest(client, `collections/${encodeURIComponent(collectionId)}/collection-games`, {
|
|
181
|
+
query: { page, per_page: perPage },
|
|
182
|
+
signal,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function getAllCollectionGames(client, collectionId, { perPage = 100, signal } = {}) {
|
|
187
|
+
validateApiId(collectionId, "collectionId");
|
|
188
|
+
validatePage(perPage, "perPage", 500);
|
|
189
|
+
const games = [];
|
|
190
|
+
for (let page = 1; ; page += 1) {
|
|
191
|
+
const result = await getCollectionGames(client, collectionId, { page, perPage, signal });
|
|
192
|
+
const current = result.collection_games || [];
|
|
193
|
+
games.push(...current);
|
|
194
|
+
if (!current.length) return games;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function createCollection(client, collection, { signal } = {}) {
|
|
199
|
+
return apiRequest(client, "collections", { method: "POST", body: collection, signal });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function updateCollection(client, collectionId, collection, { signal } = {}) {
|
|
203
|
+
validateApiId(collectionId, "collectionId");
|
|
204
|
+
return apiRequest(client, `collections/${encodeURIComponent(collectionId)}`, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
body: collection,
|
|
207
|
+
signal,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function deleteCollection(client, collectionId, options) {
|
|
212
|
+
validateApiId(collectionId, "collectionId");
|
|
213
|
+
return apiRequest(client, `collections/${encodeURIComponent(collectionId)}/delete`, {
|
|
214
|
+
method: "POST",
|
|
215
|
+
...options,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function addGameToCollection(client, collectionId, { gameId, blurb }, { signal } = {}) {
|
|
220
|
+
validateApiId(collectionId, "collectionId");
|
|
221
|
+
validateApiId(gameId, "gameId");
|
|
222
|
+
return apiRequest(client, `collections/${encodeURIComponent(collectionId)}/add-game`, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
body: { game_id: gameId, blurb },
|
|
225
|
+
signal,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function removeGameFromCollection(client, collectionId, gameId, { signal } = {}) {
|
|
230
|
+
validateApiId(collectionId, "collectionId");
|
|
231
|
+
validateApiId(gameId, "gameId");
|
|
232
|
+
return apiRequest(client, `collections/${encodeURIComponent(collectionId)}/remove-game`, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
body: { game_id: gameId },
|
|
235
|
+
signal,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function updateCollectionGame(client, collectionId, gameId, { blurb }, { signal } = {}) {
|
|
240
|
+
validateApiId(collectionId, "collectionId");
|
|
241
|
+
validateApiId(gameId, "gameId");
|
|
242
|
+
return apiRequest(
|
|
243
|
+
client,
|
|
244
|
+
`collections/${encodeURIComponent(collectionId)}/collection-games/${encodeURIComponent(gameId)}`,
|
|
245
|
+
{ method: "POST", body: { blurb }, signal },
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function orderCollection(client, collectionId, { gameIds, removeGameIds }, { signal } = {}) {
|
|
250
|
+
validateApiId(collectionId, "collectionId");
|
|
251
|
+
if (!Array.isArray(gameIds) || gameIds.length > 500) {
|
|
252
|
+
throw new TypeError("gameIds must be an array with no more than 500 IDs");
|
|
253
|
+
}
|
|
254
|
+
if (removeGameIds !== undefined && (!Array.isArray(removeGameIds) || removeGameIds.length > 500)) {
|
|
255
|
+
throw new TypeError("removeGameIds must be an array with no more than 500 IDs");
|
|
256
|
+
}
|
|
257
|
+
for (const gameId of gameIds) validateApiId(gameId, "gameId");
|
|
258
|
+
for (const gameId of removeGameIds || []) validateApiId(gameId, "gameId");
|
|
259
|
+
return apiRequest(client, `collections/${encodeURIComponent(collectionId)}/order`, {
|
|
260
|
+
method: "POST",
|
|
261
|
+
body: {
|
|
262
|
+
game_ids: JSON.stringify(gameIds),
|
|
263
|
+
remove_game_ids: removeGameIds === undefined ? undefined : JSON.stringify(removeGameIds),
|
|
264
|
+
},
|
|
265
|
+
signal,
|
|
266
|
+
});
|
|
267
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import * as api from "./api.js";
|
|
2
|
+
import { getDownloadUploads, getDownloadUrl } from "./downloads.js";
|
|
3
|
+
import { ItchError, assertResponse, readJson } from "./errors.js";
|
|
4
|
+
import { ItchTransport } from "./http.js";
|
|
5
|
+
import { parseBrowsePage } from "./parsers/browse.js";
|
|
6
|
+
import { parseFeed } from "./parsers/feed.js";
|
|
7
|
+
import { parseGame } from "./parsers/game.js";
|
|
8
|
+
import { parseProfile } from "./parsers/profile.js";
|
|
9
|
+
import { ITCH_BROWSE_PAGES } from "./routes.js";
|
|
10
|
+
import { assertItchUrl, normalizeProfileUrl, validateGameUrl } from "./urls.js";
|
|
11
|
+
|
|
12
|
+
const FEEDS = Object.freeze({
|
|
13
|
+
new: "https://itch.io/feed/new.xml",
|
|
14
|
+
featured: "https://itch.io/feed/featured.xml",
|
|
15
|
+
sales: "https://itch.io/feed/sales.xml",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export class ItchClient {
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
const { apiKey, accessToken, token, ...transportOptions } = options;
|
|
21
|
+
this.apiKey = accessToken || token || apiKey;
|
|
22
|
+
this.transport = new ItchTransport(transportOptions);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
request(url, options) {
|
|
26
|
+
return this.transport.request(url, options);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async getGame(gameValue, { signal } = {}) {
|
|
30
|
+
const url = validateGameUrl(gameValue);
|
|
31
|
+
const response = assertResponse(await this.request(url, { signal }), "Itch.io game page failed", url);
|
|
32
|
+
return parseGame(await response.text(), response.url || url.href);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async getUploads(gameUrl, options) {
|
|
36
|
+
return (await this.getGame(gameUrl, options)).uploads;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getDownloadUploads(gameUrl, options) {
|
|
40
|
+
return getDownloadUploads(this, gameUrl, options);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getDownloadUrl(gameUrl, uploadId, options) {
|
|
44
|
+
return getDownloadUrl(this, gameUrl, uploadId, options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async getComments(gameUrl, options) {
|
|
48
|
+
return (await this.getGame(gameUrl, options)).comments;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async getProfile(profile, { signal } = {}) {
|
|
52
|
+
const url = normalizeProfileUrl(profile);
|
|
53
|
+
const response = assertResponse(await this.request(url, { signal }), "Itch.io profile page failed", url);
|
|
54
|
+
return parseProfile(await response.text(), response.url || url.href);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async getBrowsePage(section = "games", { query, page, params = {}, signal } = {}) {
|
|
58
|
+
const route = ITCH_BROWSE_PAGES[section] || section;
|
|
59
|
+
let url;
|
|
60
|
+
try {
|
|
61
|
+
url = /^https:\/\//i.test(route) ? new URL(route) : new URL(route, "https://itch.io");
|
|
62
|
+
} catch (cause) {
|
|
63
|
+
throw new ItchError("Expected a valid itch.io route or URL", { code: "ITCH_INVALID_URL", cause });
|
|
64
|
+
}
|
|
65
|
+
assertItchUrl(url, { allowSubdomains: false });
|
|
66
|
+
if (query !== undefined && query !== null) url.searchParams.set("q", String(query));
|
|
67
|
+
if (page !== undefined && page !== null) url.searchParams.set("page", String(page));
|
|
68
|
+
for (const [key, value] of Object.entries(params)) {
|
|
69
|
+
if (value === undefined || value === null) continue;
|
|
70
|
+
url.searchParams.delete(key);
|
|
71
|
+
for (const item of Array.isArray(value) ? value : [value]) url.searchParams.append(key, String(item));
|
|
72
|
+
}
|
|
73
|
+
const response = assertResponse(
|
|
74
|
+
await this.request(url, { signal }),
|
|
75
|
+
"Itch.io browse request failed",
|
|
76
|
+
url,
|
|
77
|
+
);
|
|
78
|
+
return parseBrowsePage(await response.text(), url.href);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
searchGames(query, { page = 1, signal, ...params } = {}) {
|
|
82
|
+
return this.getBrowsePage("search", { query, page, params, signal });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async getSuggestions(query, { signal } = {}) {
|
|
86
|
+
const url = new URL("https://itch.io/autocomplete");
|
|
87
|
+
url.searchParams.set("query", String(query));
|
|
88
|
+
const response = assertResponse(
|
|
89
|
+
await this.request(url, { signal, headers: { Accept: "application/json" } }),
|
|
90
|
+
"Itch.io suggestions request failed",
|
|
91
|
+
url,
|
|
92
|
+
);
|
|
93
|
+
return readJson(response, "Itch.io suggestions returned invalid JSON", url);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getFeed(feed = "new", { signal } = {}) {
|
|
97
|
+
let url;
|
|
98
|
+
try {
|
|
99
|
+
const browseRoute = ITCH_BROWSE_PAGES[feed];
|
|
100
|
+
const source = FEEDS[feed] || (browseRoute ? `${browseRoute}.xml` : feed);
|
|
101
|
+
url = /^https:\/\//i.test(source) ? new URL(source) : new URL(source, "https://itch.io");
|
|
102
|
+
} catch (cause) {
|
|
103
|
+
throw new ItchError("Expected an itch.io feed name or URL", { code: "ITCH_INVALID_URL", cause });
|
|
104
|
+
}
|
|
105
|
+
assertItchUrl(url);
|
|
106
|
+
if (!url.pathname.endsWith(".xml"))
|
|
107
|
+
throw new ItchError("Expected an itch.io RSS feed URL ending in .xml", {
|
|
108
|
+
url: url.href,
|
|
109
|
+
code: "ITCH_INVALID_URL",
|
|
110
|
+
});
|
|
111
|
+
const response = assertResponse(
|
|
112
|
+
await this.request(url, {
|
|
113
|
+
signal,
|
|
114
|
+
headers: { Accept: "application/rss+xml, application/xml, text/xml" },
|
|
115
|
+
}),
|
|
116
|
+
"Itch.io feed request failed",
|
|
117
|
+
url,
|
|
118
|
+
);
|
|
119
|
+
return parseFeed(await response.text(), feed, url.href);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
apiRequest(path, options) {
|
|
123
|
+
return api.apiRequest(this, path, options);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getApiCredentials(options) {
|
|
127
|
+
return api.getApiCredentials(this, options);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
getApiProfile(options) {
|
|
131
|
+
return this.apiRequest("profile", options);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
getDeveloperGames(options) {
|
|
135
|
+
return this.apiRequest("profile/games", options);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getApiUploads(gameId, options) {
|
|
139
|
+
api.validateApiId(gameId, "gameId");
|
|
140
|
+
return this.apiRequest(`games/${encodeURIComponent(gameId)}/uploads`, options);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
getApiDownloadUrl(uploadId, options) {
|
|
144
|
+
api.validateApiId(uploadId, "uploadId");
|
|
145
|
+
return this.apiRequest(`uploads/${encodeURIComponent(uploadId)}/download`, options);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getGameDownloadKey(gameId, options) {
|
|
149
|
+
return api.getGameDownloadKey(this, gameId, options);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
getGamePurchases(gameId, options) {
|
|
153
|
+
return api.getGamePurchases(this, gameId, options);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
getClaimedRewards(gameId, options) {
|
|
157
|
+
return api.getClaimedRewards(this, gameId, options);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
getAllClaimedRewards(gameId, options) {
|
|
161
|
+
return api.getAllClaimedRewards(this, gameId, options);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
checkGameOwnership(gameId, options) {
|
|
165
|
+
return api.checkGameOwnership(this, gameId, options);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
getCollections(options) {
|
|
169
|
+
return api.getCollections(this, options);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
getCollection(collectionId, options) {
|
|
173
|
+
return api.getCollection(this, collectionId, options);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
getCollectionGames(collectionId, options) {
|
|
177
|
+
return api.getCollectionGames(this, collectionId, options);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
getAllCollectionGames(collectionId, options) {
|
|
181
|
+
return api.getAllCollectionGames(this, collectionId, options);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
createCollection(collection, options) {
|
|
185
|
+
return api.createCollection(this, collection, options);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
updateCollection(collectionId, collection, options) {
|
|
189
|
+
return api.updateCollection(this, collectionId, collection, options);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
deleteCollection(collectionId, options) {
|
|
193
|
+
return api.deleteCollection(this, collectionId, options);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
addGameToCollection(collectionId, game, options) {
|
|
197
|
+
return api.addGameToCollection(this, collectionId, game, options);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
removeGameFromCollection(collectionId, gameId, options) {
|
|
201
|
+
return api.removeGameFromCollection(this, collectionId, gameId, options);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
updateCollectionGame(collectionId, gameId, game, options) {
|
|
205
|
+
return api.updateCollectionGame(this, collectionId, gameId, game, options);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
orderCollection(collectionId, order, options) {
|
|
209
|
+
return api.orderCollection(this, collectionId, order, options);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
getLatestVersion(options) {
|
|
213
|
+
return api.getLatestVersion(this, options);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
getOwnedKeys({ page = 1, perPage = 500, gameIds, ...options } = {}) {
|
|
217
|
+
if (!Number.isSafeInteger(page) || page < 1) throw new TypeError("page must be a positive integer");
|
|
218
|
+
if (!Number.isSafeInteger(perPage) || perPage < 1 || perPage > 500) {
|
|
219
|
+
throw new TypeError("perPage must be an integer between 1 and 500");
|
|
220
|
+
}
|
|
221
|
+
api.validateGameIds(gameIds);
|
|
222
|
+
return this.apiRequest("profile/owned-keys", {
|
|
223
|
+
query: { page, per_page: perPage, game_ids: gameIds },
|
|
224
|
+
...options,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
getAllOwnedKeys(options) {
|
|
229
|
+
return api.getAllOwnedKeys(this, options);
|
|
230
|
+
}
|
|
231
|
+
}
|