speedruncom.js 1.2.8 → 1.2.10

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/README.md CHANGED
@@ -1,49 +1,49 @@
1
- ## `npm i retrozy1/speedruncom.js`
2
-
3
- # About
4
-
5
- Speedrun.com has two API versions. Version 1 is officially documented, REST, public and directly exposed to users. Version 2 is a pre-production RPC API designed only for internal use of site functions, and is much more complicated and less user-friendly, especially regarding authentication and browser CORS.
6
-
7
- Version 1 is mainly read-only, and when it isn't read only it may just be broken. If you want to use any of the modern features of the site now, you'll use version 2. This is a NodeJS wrapper for Speedrun API version 2, with parameters and responses all documented in TypeScript and JSDoc.
8
-
9
- # Usage
10
-
11
- ## Installation
12
-
13
- **`npm i retrozy1/speedruncom.js`**
14
-
15
- ## Importing
16
-
17
- `Client` is the default export, and enums and interfaces are available in named exports for TypeScript users.
18
-
19
- ```js
20
- import SpeedrunClient from 'speedruncom.js';
21
-
22
- const client = new SpeedrunClient({
23
- userAgent: 'RunCollabGetter'
24
- });
25
-
26
- const getCollabs = async (userUrl) => {
27
- // Fetch user summary
28
- const {
29
- user: { id: userId, name }
30
- } = await client.GetUserSummary({ url: userUrl });
31
-
32
- // Fetch leaderboard and extract player names
33
- const { players } = await client.GetUserLeaderboard({ userId });
34
- const otherPlayerNames = players.map(player => player.name).filter(n => n !== name); // Exclude self
35
-
36
- if (otherPlayerNames.length === 0) {
37
- return `${name} hasn't ran with anyone.`;
38
- }
39
-
40
- const formattedNames =
41
- otherPlayerNames.length === 1
42
- ? otherPlayerNames[0]
43
- : `${otherPlayerNames.slice(0, -1).join(', ')} and ${otherPlayerNames.slice(-1)}`;
44
-
45
- return `${name} has ran with ${formattedNames}.`;
46
- };
47
-
48
- console.log(await getCollabs('retrozy'));
49
- ```
1
+ ## `npm i retrozy1/speedruncom.js`
2
+
3
+ # About
4
+
5
+ Speedrun.com has two API versions. Version 1 is officially documented, REST, public and directly exposed to users. Version 2 is a pre-production RPC API designed only for internal use of site functions, and is much more complicated and less user-friendly, especially regarding authentication and browser CORS.
6
+
7
+ Version 1 is mainly read-only, and when it isn't read only it may just be broken. If you want to use any of the modern features of the site now, you'll use version 2. This is a NodeJS wrapper for Speedrun API version 2, with parameters and responses all documented in TypeScript and JSDoc.
8
+
9
+ # Usage
10
+
11
+ ## Installation
12
+
13
+ **`npm i retrozy1/speedruncom.js`**
14
+
15
+ ## Importing
16
+
17
+ `Client` is the default export, and enums and interfaces are available in named exports for TypeScript users.
18
+
19
+ ```js
20
+ import SpeedrunClient from 'speedruncom.js';
21
+
22
+ const client = new SpeedrunClient({
23
+ userAgent: 'RunCollabGetter'
24
+ });
25
+
26
+ const getCollabs = async (userUrl) => {
27
+ // Fetch user summary
28
+ const {
29
+ user: { id: userId, name }
30
+ } = await client.GetUserSummary({ url: userUrl });
31
+
32
+ // Fetch leaderboard and extract player names
33
+ const { players } = await client.GetUserLeaderboard({ userId });
34
+ const otherPlayerNames = players.map(player => player.name).filter(n => n !== name); // Exclude self
35
+
36
+ if (otherPlayerNames.length === 0) {
37
+ return `${name} hasn't ran with anyone.`;
38
+ }
39
+
40
+ const formattedNames =
41
+ otherPlayerNames.length === 1
42
+ ? otherPlayerNames[0]
43
+ : `${otherPlayerNames.slice(0, -1).join(', ')} and ${otherPlayerNames.slice(-1)}`;
44
+
45
+ return `${name} has ran with ${formattedNames}.`;
46
+ };
47
+
48
+ console.log(await getCollabs('retrozy'));
49
+ ```
@@ -172,6 +172,10 @@ export interface GetLatestLeaderboard {
172
172
  * @default 25
173
173
  */
174
174
  limit?: number;
175
+ /**
176
+ * The run list page, in relation to `limit`.
177
+ */
178
+ page?: number;
175
179
  }
176
180
  /**
177
181
  * Gets a single run.
@@ -309,6 +313,10 @@ export interface GetGameList {
309
313
  * @default 500
310
314
  */
311
315
  limit?: number;
316
+ /**
317
+ * The game list page, in relation to `limit`.
318
+ */
319
+ page?: number;
312
320
  }
313
321
  /**
314
322
  * Gets a list of Platforms in the site.
@@ -336,6 +344,10 @@ export interface GetSeriesList {
336
344
  * @default 500
337
345
  */
338
346
  limit?: number;
347
+ /**
348
+ * The leaderboard page, in relation to `limit`.
349
+ */
350
+ page?: number;
339
351
  }
340
352
  /**
341
353
  * Gets most information pertinent to a series.
@@ -356,10 +368,6 @@ export interface GetGameLevelSummary {
356
368
  * When not included, `runList[]` will be empty.
357
369
  */
358
370
  params?: Interfaces.LeaderboardParams;
359
- /**
360
- * The leaderboard page, in relation to `limit`.
361
- */
362
- page?: number;
363
371
  /**
364
372
  * The limit of Runs per page.
365
373
  *
@@ -367,6 +375,10 @@ export interface GetGameLevelSummary {
367
375
  * @default Unknown - likely around 99999.
368
376
  */
369
377
  limit?: number;
378
+ /**
379
+ * The leaderboard page, in relation to `limit`.
380
+ */
381
+ page?: number;
370
382
  }
371
383
  /**
372
384
  * Gets a random Game.
@@ -428,9 +428,9 @@ export interface GetModerationRuns {
428
428
  search?: string;
429
429
  verified?: Enums.RunStatus;
430
430
  verifiedById?: string;
431
- ideoState?: Enums.VideoState;
432
- page?: number;
431
+ videoState?: Enums.VideoState;
433
432
  limit?: number;
433
+ page?: number;
434
434
  }
435
435
  /**
436
436
  * Assigns a verifier to a run.
@@ -449,6 +449,7 @@ export interface PutRunDelete {
449
449
  export interface PutRunVerification {
450
450
  runId: string;
451
451
  verified: Enums.RunStatus;
452
+ reason?: string;
452
453
  }
453
454
  /**
454
455
  * Assigns a video-at-risk state to a run.
package/package.json CHANGED
@@ -1,30 +1,30 @@
1
- {
2
- "name": "speedruncom.js",
3
- "version": "1.2.8",
4
- "description": "WIP NodeJS module for Speedrun's version 2 API.",
5
- "type": "module",
6
- "author": {
7
- "name": "retrozy"
8
- },
9
- "engines": {
10
- "node": ">=18"
11
- },
12
- "dependencies": {
13
- "axios": "^1.9.0",
14
- "ts-morph": "^26.0.0"
15
- },
16
- "types": "dist/index.d.ts",
17
- "scripts": {
18
- "clean": "rimraf dist src/Client.ts",
19
- "build": "tsc",
20
- "render": "tsx src/build-client.ts",
21
- "prepare": "npm run render && npm run build"
22
- },
23
- "devDependencies": {
24
- "@types/node": "^22.15.24",
25
- "rimraf": "^6.0.1",
26
- "ts-node": "^10.9.2",
27
- "typescript": "^5.8.3"
28
- },
29
- "main": "dist/index.js"
30
- }
1
+ {
2
+ "name": "speedruncom.js",
3
+ "version": "1.2.10",
4
+ "description": "WIP NodeJS module for Speedrun's version 2 API.",
5
+ "type": "module",
6
+ "author": {
7
+ "name": "retrozy"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "dependencies": {
13
+ "axios": "^1.9.0",
14
+ "ts-morph": "^26.0.0"
15
+ },
16
+ "types": "dist/index.d.ts",
17
+ "scripts": {
18
+ "clean": "rimraf dist",
19
+ "build": "tsc",
20
+ "render": "tsx src/build-client.ts",
21
+ "prepare": "npm run render && npm run build"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.15.24",
25
+ "rimraf": "^6.0.1",
26
+ "tsx": "^4.20.3",
27
+ "typescript": "^5.8.3"
28
+ },
29
+ "main": "dist/index.js"
30
+ }
package/src/BaseClient.ts CHANGED
@@ -1,108 +1,108 @@
1
- import axios, { AxiosResponse, AxiosInstance, AxiosError } from 'axios';
2
- import * as GetEndpoints from './endpoints/endpoints.get.js';
3
- import * as PostEndpoints from './endpoints/endpoints.post.js'
4
- import * as Responses from './responses.js';
5
-
6
- const BASE_USER_AGENT = 'speedruncom.js';
7
- const BASE_URL = 'https://www.speedrun.com/api/v2/';
8
- const HEADERS = {
9
- 'Accept-Language': 'en',
10
- 'Accept': 'application/json'
11
- }
12
-
13
- const isBrowser = typeof window !== 'undefined';
14
-
15
- const objectToBase64 = (obj: object) => {
16
- const jsonString = JSON.stringify(obj).replace(/\s+/g, '');
17
- return Buffer.from(jsonString).toString('base64');
18
- }
19
-
20
- export interface config {
21
- PHPSESSID?: string;
22
- userAgent?: string;
23
- }
24
-
25
- export class APIError extends Error {
26
- status: number;
27
-
28
- constructor(message: string, status: number) {
29
- super(message);
30
- this.message = message;
31
- this.status = status;
32
- }
33
- }
34
-
35
- export default class Client {
36
-
37
- /**
38
- * `AxiosInstance` used on instance-called methods (called with `POST`).
39
- */
40
- axiosClient = axios.create({
41
- baseURL: BASE_URL,
42
- method: 'POST',
43
- withCredentials: true,
44
- headers: HEADERS
45
- });
46
-
47
- /**
48
- * `AxiosInstance` used on Client-called methods (called with `GET`).
49
- */
50
- static axiosClient = axios.create({
51
- baseURL: BASE_URL,
52
- method: 'GET',
53
- headers: HEADERS
54
- });
55
-
56
- private username!: string;
57
- private password!: string;
58
-
59
- private headers = this.axiosClient.defaults.headers.common;
60
-
61
- constructor(config?: config) {
62
- if (config) this.config(config);
63
-
64
- this.axiosClient.interceptors.response.use(
65
- (response: AxiosResponse) => response,
66
- (error: AxiosError) => {
67
- const data = error.response.data as { error?: string };
68
- throw new APIError(data.error || 'Unknown error', error.response.status);
69
- }
70
- );
71
- }
72
-
73
- config(config: config) {
74
- if (!isBrowser) this.headers['User-Agent'] = BASE_USER_AGENT + (config.userAgent ? `/${config.userAgent}` : '');
75
-
76
- if (config.PHPSESSID) {
77
- if (isBrowser) {
78
- console.error('You cannot use a PHPSESSID to authenticate in a browser environment.');
79
- } else {
80
- this.headers['Cookie'] = `PHPSESSID=${config.PHPSESSID}`;
81
- }
82
- }
83
- }
84
-
85
- async request<T>(endpoint: string, params: object = {}): Promise<T> {
86
- const response = await this.axiosClient.post<T>(endpoint, params);
87
-
88
- const cookie = response.headers['set-cookie'];
89
- if (cookie && !isBrowser) this.headers['Cookie'] = cookie[0].split(';')[0];
90
-
91
- return response.data;
92
- }
93
-
94
- static async request<T>(endpoint: string, params: object = {}): Promise<T> {
95
- return (await this.axiosClient.get(`${endpoint}?_r=${objectToBase64(params)}`)).data;
96
- }
97
-
98
- /**
99
- * Attempts to remove the PHPSESSID cookie if using a browser, otherwise removes your Client's authentication.
100
- */
101
- async logout() {
102
- if (isBrowser) return await this.request('PutAuthLogout');
103
-
104
- delete this.headers['Cookie'];
105
- }
106
-
107
- // Endpoints (auto-generated with build-client)
1
+ import axios, { AxiosResponse, AxiosInstance, AxiosError } from 'axios';
2
+ import * as GetEndpoints from './endpoints/endpoints.get.js';
3
+ import * as PostEndpoints from './endpoints/endpoints.post.js'
4
+ import * as Responses from './responses.js';
5
+
6
+ const BASE_USER_AGENT = 'speedruncom.js';
7
+ const BASE_URL = 'https://www.speedrun.com/api/v2/';
8
+ const HEADERS = {
9
+ 'Accept-Language': 'en',
10
+ 'Accept': 'application/json'
11
+ }
12
+
13
+ const isBrowser = typeof window !== 'undefined';
14
+
15
+ const objectToBase64 = (obj: object) => {
16
+ const jsonString = JSON.stringify(obj).replace(/\s+/g, '');
17
+ return Buffer.from(jsonString).toString('base64');
18
+ }
19
+
20
+ export interface config {
21
+ PHPSESSID?: string;
22
+ userAgent?: string;
23
+ }
24
+
25
+ export class APIError extends Error {
26
+ status: number;
27
+
28
+ constructor(message: string, status: number) {
29
+ super(message);
30
+ this.message = message;
31
+ this.status = status;
32
+ }
33
+ }
34
+
35
+ export default class Client {
36
+
37
+ /**
38
+ * `AxiosInstance` used on instance-called methods (called with `POST`).
39
+ */
40
+ axiosClient = axios.create({
41
+ baseURL: BASE_URL,
42
+ method: 'POST',
43
+ withCredentials: true,
44
+ headers: HEADERS
45
+ });
46
+
47
+ /**
48
+ * `AxiosInstance` used on Client-called methods (called with `GET`).
49
+ */
50
+ static axiosClient = axios.create({
51
+ baseURL: BASE_URL,
52
+ method: 'GET',
53
+ headers: HEADERS
54
+ });
55
+
56
+ private username!: string;
57
+ private password!: string;
58
+
59
+ private headers = this.axiosClient.defaults.headers.common;
60
+
61
+ constructor(config?: config) {
62
+ if (config) this.config(config);
63
+
64
+ this.axiosClient.interceptors.response.use(
65
+ (response: AxiosResponse) => response,
66
+ (error: AxiosError) => {
67
+ const data = error.response.data as { error?: string };
68
+ throw new APIError(data.error || 'Unknown error', error.response.status);
69
+ }
70
+ );
71
+ }
72
+
73
+ config(config: config) {
74
+ if (!isBrowser) this.headers['User-Agent'] = BASE_USER_AGENT + (config.userAgent ? `/${config.userAgent}` : '');
75
+
76
+ if (config.PHPSESSID) {
77
+ if (isBrowser) {
78
+ console.error('You cannot use a PHPSESSID to authenticate in a browser environment.');
79
+ } else {
80
+ this.headers['Cookie'] = `PHPSESSID=${config.PHPSESSID}`;
81
+ }
82
+ }
83
+ }
84
+
85
+ async request<T>(endpoint: string, params: object = {}): Promise<T> {
86
+ const response = await this.axiosClient.post<T>(endpoint, params);
87
+
88
+ const cookie = response.headers['set-cookie'];
89
+ if (cookie && !isBrowser) this.headers['Cookie'] = cookie[0].split(';')[0];
90
+
91
+ return response.data;
92
+ }
93
+
94
+ static async request<T>(endpoint: string, params: object = {}): Promise<T> {
95
+ return (await this.axiosClient.get(`${endpoint}?_r=${objectToBase64(params)}`)).data;
96
+ }
97
+
98
+ /**
99
+ * Attempts to remove the PHPSESSID cookie if using a browser, otherwise removes your Client's authentication.
100
+ */
101
+ async logout() {
102
+ if (isBrowser) return await this.request('PutAuthLogout');
103
+
104
+ delete this.headers['Cookie'];
105
+ }
106
+
107
+ // Endpoints (auto-generated with build-client)
108
108
  }
@@ -1,80 +1,80 @@
1
- import { Project, SourceFile, Node, OptionalKind, MethodDeclarationStructure } from 'ts-morph';
2
-
3
- const project = new Project({
4
- tsConfigFilePath: "tsconfig.json",
5
- });
6
-
7
- const isInterfaceEmpty = (interfaceName: string, sourceFile: SourceFile) => {
8
- const declarations = sourceFile.getExportedDeclarations().get(interfaceName);
9
- if (!declarations || declarations.length === 0) return false;
10
-
11
- const decl = declarations[0];
12
- if (!Node.isInterfaceDeclaration(decl)) return false;
13
-
14
- const type = decl.getType();
15
- const allProperties = type.getProperties();
16
-
17
- return allProperties.length === 0;
18
- };
19
-
20
-
21
- const isInterfaceAllOptional = (name: string, sourceFile: SourceFile) => {
22
- const iface = sourceFile.getInterface(name);
23
- if (iface) {
24
- return iface.getType().getProperties().every(p => p.isOptional());
25
- }
26
-
27
- const typeNode = sourceFile.getTypeAliasOrThrow(name).getType();
28
- return typeNode
29
- .getProperties()
30
- .every(p => p.isOptional());
31
- };
32
-
33
- const baseClient = project.getSourceFileOrThrow('src/BaseClient.ts');
34
- const clientFile = project.createSourceFile('src/Client.ts', baseClient.getFullText(), { overwrite: true });
35
- const clientClass = clientFile.getClasses()[1];
36
-
37
- const getEndpointsFile = project.getSourceFileOrThrow('src/endpoints/endpoints.get.ts');
38
- const postEndpointsFile = project.getSourceFileOrThrow('src/endpoints/endpoints.post.ts');
39
- const responsesFile = project.getSourceFileOrThrow('src/responses.ts');
40
-
41
- const getEndpointNames = Array.from(getEndpointsFile.getExportedDeclarations().keys());
42
- const postEndpointNames = Array.from(postEndpointsFile.getExportedDeclarations().keys());
43
-
44
- const responseNames = new Set(Array.from(responsesFile.getExportedDeclarations().keys()));
45
-
46
- const makeMethod = (name: string, isStatic: boolean, returnType: string, isEmpty: boolean, interfaces: string, isOptional: boolean) => {
47
- const method: OptionalKind<MethodDeclarationStructure> = {
48
- name,
49
- isStatic,
50
- isAsync: true,
51
- returnType,
52
- statements: [`return await this.request('${name}'${!isEmpty ? ', params' : ''});`]
53
- }
54
- if (!isEmpty) method.parameters = [{
55
- name: 'params',
56
- type: `${interfaces}.${name}`,
57
- hasQuestionToken: isOptional
58
- }];
59
-
60
- clientClass.addMethod(method);
61
- }
62
-
63
- for (const endpointName of getEndpointNames) {
64
- const returnType = responseNames.has(endpointName) ? `Promise<Readonly<Responses.${endpointName}>>` : 'Promise<void>';
65
- const isEmpty = isInterfaceEmpty(endpointName, getEndpointsFile);
66
- const isAllOptional = isInterfaceAllOptional(endpointName, getEndpointsFile);
67
-
68
- makeMethod(endpointName, false, returnType, isEmpty, 'GetEndpoints', isAllOptional);
69
- makeMethod(endpointName, true, returnType, isEmpty, 'GetEndpoints', isAllOptional);
70
- }
71
-
72
- for (const endpointName of postEndpointNames) {
73
- const returnType = responseNames.has(endpointName) ? `Promise<Readonly<Responses.${endpointName}>>` : 'Promise<void>';
74
- const isEmpty = isInterfaceEmpty(endpointName, postEndpointsFile);
75
- const isAllOptional = isInterfaceAllOptional(endpointName, postEndpointsFile);
76
-
77
- makeMethod(endpointName, false, returnType, isEmpty, 'PostEndpoints', isAllOptional);
78
- }
79
-
1
+ import { Project, SourceFile, Node, OptionalKind, MethodDeclarationStructure } from 'ts-morph';
2
+
3
+ const project = new Project({
4
+ tsConfigFilePath: "tsconfig.json",
5
+ });
6
+
7
+ const isInterfaceEmpty = (interfaceName: string, sourceFile: SourceFile) => {
8
+ const declarations = sourceFile.getExportedDeclarations().get(interfaceName);
9
+ if (!declarations || declarations.length === 0) return false;
10
+
11
+ const decl = declarations[0];
12
+ if (!Node.isInterfaceDeclaration(decl)) return false;
13
+
14
+ const type = decl.getType();
15
+ const allProperties = type.getProperties();
16
+
17
+ return allProperties.length === 0;
18
+ };
19
+
20
+
21
+ const isInterfaceAllOptional = (name: string, sourceFile: SourceFile) => {
22
+ const iface = sourceFile.getInterface(name);
23
+ if (iface) {
24
+ return iface.getType().getProperties().every(p => p.isOptional());
25
+ }
26
+
27
+ const typeNode = sourceFile.getTypeAliasOrThrow(name).getType();
28
+ return typeNode
29
+ .getProperties()
30
+ .every(p => p.isOptional());
31
+ };
32
+
33
+ const baseClient = project.getSourceFileOrThrow('src/BaseClient.ts');
34
+ const clientFile = project.createSourceFile('src/Client.ts', baseClient.getFullText(), { overwrite: true });
35
+ const clientClass = clientFile.getClasses()[1];
36
+
37
+ const getEndpointsFile = project.getSourceFileOrThrow('src/endpoints/endpoints.get.ts');
38
+ const postEndpointsFile = project.getSourceFileOrThrow('src/endpoints/endpoints.post.ts');
39
+ const responsesFile = project.getSourceFileOrThrow('src/responses.ts');
40
+
41
+ const getEndpointNames = Array.from(getEndpointsFile.getExportedDeclarations().keys());
42
+ const postEndpointNames = Array.from(postEndpointsFile.getExportedDeclarations().keys());
43
+
44
+ const responseNames = new Set(Array.from(responsesFile.getExportedDeclarations().keys()));
45
+
46
+ const makeMethod = (name: string, isStatic: boolean, returnType: string, isEmpty: boolean, interfaces: string, isOptional: boolean) => {
47
+ const method: OptionalKind<MethodDeclarationStructure> = {
48
+ name,
49
+ isStatic,
50
+ isAsync: true,
51
+ returnType,
52
+ statements: [`return await this.request('${name}'${!isEmpty ? ', params' : ''});`]
53
+ }
54
+ if (!isEmpty) method.parameters = [{
55
+ name: 'params',
56
+ type: `${interfaces}.${name}`,
57
+ hasQuestionToken: isOptional
58
+ }];
59
+
60
+ clientClass.addMethod(method);
61
+ }
62
+
63
+ for (const endpointName of getEndpointNames) {
64
+ const returnType = responseNames.has(endpointName) ? `Promise<Readonly<Responses.${endpointName}>>` : 'Promise<void>';
65
+ const isEmpty = isInterfaceEmpty(endpointName, getEndpointsFile);
66
+ const isAllOptional = isInterfaceAllOptional(endpointName, getEndpointsFile);
67
+
68
+ makeMethod(endpointName, false, returnType, isEmpty, 'GetEndpoints', isAllOptional);
69
+ makeMethod(endpointName, true, returnType, isEmpty, 'GetEndpoints', isAllOptional);
70
+ }
71
+
72
+ for (const endpointName of postEndpointNames) {
73
+ const returnType = responseNames.has(endpointName) ? `Promise<Readonly<Responses.${endpointName}>>` : 'Promise<void>';
74
+ const isEmpty = isInterfaceEmpty(endpointName, postEndpointsFile);
75
+ const isAllOptional = isInterfaceAllOptional(endpointName, postEndpointsFile);
76
+
77
+ makeMethod(endpointName, false, returnType, isEmpty, 'PostEndpoints', isAllOptional);
78
+ }
79
+
80
80
  clientFile.saveSync();