kol.js 0.1.4 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kol.js",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "main": "src/index.ts",
5
5
  "type": "module",
6
6
  "files": [
@@ -13,21 +13,20 @@
13
13
  "format": "prettier --write ."
14
14
  },
15
15
  "devDependencies": {
16
- "@types/tough-cookie": "^4",
17
- "prettier": "^3.2.5",
18
- "typescript": "^5.4.5",
19
- "vitest": "^1.6.0"
16
+ "prettier": "^3.3.3",
17
+ "typescript": "^5.6.2",
18
+ "vitest": "^2.1.0"
20
19
  },
21
20
  "dependencies": {
22
- "@xmldom/xmldom": "^0.8.10",
21
+ "@xmldom/xmldom": "^0.9.2",
23
22
  "async-mutex": "^0.5.0",
24
23
  "date-fns": "^3.6.0",
25
- "got": "^14.3.0",
24
+ "got": "^14.4.2",
26
25
  "html-entities": "^2.5.2",
27
26
  "image-size": "^1.1.1",
28
27
  "node-html-parser": "^6.1.13",
29
28
  "querystring": "^0.2.1",
30
- "tough-cookie": "^4.1.4",
29
+ "tough-cookie": "^5.0.0",
31
30
  "ts-dedent": "^2.2.0",
32
31
  "typed-emitter": "^2.1.0",
33
32
  "xpath": "^0.0.34"
@@ -204,3 +204,44 @@ describe("Familiars", () => {
204
204
  expect(familiars).toHaveLength(206);
205
205
  });
206
206
  });
207
+
208
+ describe("Raffle", () => {
209
+ it("can fetch the current raffle", async () => {
210
+ text.mockResolvedValueOnce(await loadFixture(__dirname, "raffle.html"));
211
+ text.mockResolvedValueOnce("<!-- itemid: 1 -->");
212
+ text.mockResolvedValueOnce("<!-- itemid: 2 -->");
213
+ text.mockResolvedValueOnce("<!-- itemid: 3 -->");
214
+ text.mockResolvedValueOnce("<!-- itemid: 4 -->");
215
+
216
+ const raffle = await client.getRaffle();
217
+
218
+ expect(raffle).toMatchObject({
219
+ today: {
220
+ first: 1,
221
+ second: 2,
222
+ },
223
+ yesterday: [
224
+ {
225
+ player: { id: 809337, name: "Collective Consciousness" },
226
+ item: 3,
227
+ tickets: 3333,
228
+ },
229
+ {
230
+ player: { id: 852958, name: "Ryo_Sangnoir" },
231
+ item: 4,
232
+ tickets: 1011,
233
+ },
234
+ {
235
+ player: { id: 1765063, name: "SSpectre_Karasu" },
236
+ item: 4,
237
+ tickets: 1000,
238
+ },
239
+ {
240
+ player: { id: 1652370, name: "yueli7" },
241
+ item: 4,
242
+ tickets: 2040,
243
+ },
244
+ ],
245
+ });
246
+ });
247
+ });
package/src/Client.ts CHANGED
@@ -42,7 +42,7 @@ type Familiar = {
42
42
  image: string;
43
43
  };
44
44
 
45
- export class Client extends (EventEmitter as new () => TypedEmitter<Events>) {
45
+ export class Client extends (EventEmitter as unknown as new () => TypedEmitter<Events>) {
46
46
  actionMutex = new Mutex();
47
47
  session = got.extend({
48
48
  cookieJar: new CookieJar(),
@@ -55,7 +55,8 @@ export class Client extends (EventEmitter as new () => TypedEmitter<Events>) {
55
55
 
56
56
  if (options.searchParams) {
57
57
  const searchParams = options.searchParams as URLSearchParams;
58
- if (searchParams.get("pwd") !== "false") searchParams.set("pwd", this.#pwd);
58
+ if (searchParams.get("pwd") !== "false")
59
+ searchParams.set("pwd", this.#pwd);
59
60
  }
60
61
 
61
62
  return next(options);
@@ -269,14 +270,20 @@ export class Client extends (EventEmitter as new () => TypedEmitter<Events>) {
269
270
  }
270
271
 
271
272
  async deleteKmails(ids: number[]) {
272
- await this.fetchText("messages.php", {
273
- searchParams: {
273
+ if (ids.length === 0) return true;
274
+
275
+ const response = await this.fetchText("messages.php", {
276
+ form: {
274
277
  the_action: "delete",
275
278
  box: "Inbox",
276
279
  ...Object.fromEntries(ids.map((id) => [`sel${id}`, "on"])),
277
280
  pwd: true,
278
281
  },
279
282
  });
283
+
284
+ return response.includes(
285
+ `<td>${ids.length} message${ids.length === 1 ? "" : "s"} deleted.</td>`,
286
+ );
280
287
  }
281
288
 
282
289
  async checkKmails() {
@@ -348,9 +355,9 @@ export class Client extends (EventEmitter as new () => TypedEmitter<Events>) {
348
355
  : minPrice
349
356
  : minPrice;
350
357
  const formattedMinPrice = minPrice
351
- ? (minPrice === unlimitedPrice
358
+ ? ((minPrice === unlimitedPrice
352
359
  ? unlimitedMatch?.[1]
353
- : limitedMatch?.[1]) ?? ""
360
+ : limitedMatch?.[1]) ?? "")
354
361
  : "";
355
362
  return {
356
363
  mallPrice: unlimitedPrice,
@@ -503,4 +510,47 @@ export class Client extends (EventEmitter as new () => TypedEmitter<Events>) {
503
510
 
504
511
  return familiars;
505
512
  }
513
+
514
+ static #descIdToIdCache: Map<number, number> = new Map();
515
+
516
+ async descIdToId(descId: number): Promise<number> {
517
+ if (Client.#descIdToIdCache.has(descId)) return Client.#descIdToIdCache.get(descId)!;
518
+ const page = await this.fetchText("desc_item.php", {
519
+ searchParams: {
520
+ whichitem: descId,
521
+ },
522
+ });
523
+ const id = Number(page.match(/<!-- itemid: (\d+) -->/)?.[1] ?? -1);
524
+ Client.#descIdToIdCache.set(descId, id);
525
+ return id;
526
+ }
527
+
528
+ async getRaffle() {
529
+ const page = await this.fetchText("raffle.php");
530
+ const today = page.matchAll(
531
+ /<tr><td align=right>(?:First|Second) Prize:<\/td>.*?descitem\((\d+)\)/g,
532
+ );
533
+ const [first, second] = await Promise.all(
534
+ today
535
+ ? [...today].map(async (p) => await this.descIdToId(Number(p[1])))
536
+ : [null, null],
537
+ );
538
+ const winners = page.matchAll(
539
+ /<tr><td class=small><a href='showplayer\.php\?who=\d+'>(.*?) \(#(\d+)\).*?descitem\((\d+)\).*?([\d,]+)<\/td><\/tr>/g,
540
+ );
541
+ const yesterday = await Promise.all(
542
+ winners
543
+ ? [...winners].map(async (w) => ({
544
+ player: new Player(this, Number(w[2]), w[1]),
545
+ item: await this.descIdToId(Number(w[3])),
546
+ tickets: Number(w[4].replace(",", "")),
547
+ }))
548
+ : [],
549
+ );
550
+
551
+ return {
552
+ today: { first, second },
553
+ yesterday,
554
+ };
555
+ }
506
556
  }
@@ -17,18 +17,37 @@ vi.mock("./Client.js", async (importOriginal) => {
17
17
 
18
18
  const client = new Client("", "");
19
19
 
20
- test("Can search for a player by name", async () => {
21
- vi.mocked(text).mockResolvedValueOnce(
22
- await loadFixture(__dirname, "searchplayer_mad_carew.html"),
23
- );
20
+ describe("Player searching", () => {
21
+ test("Can search for a player by name", async () => {
22
+ vi.mocked(text).mockResolvedValueOnce(
23
+ await loadFixture(__dirname, "searchplayer_mad_carew.html"),
24
+ );
25
+
26
+ const player = await Player.fromName(client, "mad carew");
27
+
28
+ expectNotNull(player);
29
+
30
+ expect(player.id).toBe(263717);
31
+ // Learns correct capitalisation
32
+ expect(player.name).toBe("Mad Carew");
33
+ });
34
+
35
+ test("Can search for a player in Valhalla by name ", async () => {
36
+ vi.mocked(text).mockResolvedValueOnce(
37
+ await loadFixture(__dirname, "searchplayer_beldur.html"),
38
+ );
24
39
 
25
- const player = await Player.fromName(client, "mad carew");
40
+ const player = await Player.fromName(client, "Beldur");
26
41
 
27
- expectNotNull(player);
42
+ expectNotNull(player);
28
43
 
29
- expect(player.id).toBe(263717);
30
- // Learns correct capitalisation
31
- expect(player.name).toBe("Mad Carew");
44
+ expect(player.id).toBe(1046951);
45
+ // Learns correct capitalisation
46
+ expect(player.name).toBe("Beldur");
47
+ expect(player.level).toBe(0);
48
+ // We display Astral Spirit even though the search page says Seal Clubber.
49
+ expect(player.kolClass).toBe("Astral Spirit");
50
+ });
32
51
  });
33
52
 
34
53
  describe("Profile parsing", () => {
package/src/Player.ts CHANGED
@@ -82,7 +82,7 @@ export class Player<IsFull extends boolean = boolean> {
82
82
  ): Promise<Player<false> | null> {
83
83
  try {
84
84
  const matcher =
85
- /href="showplayer.php\?who=(?<playerId>\d+)">(?<playerName>.*?)<\/a>\D+(clan=\d+[^<]+\D+)?\d+\D*(?<level>(\d+)|(inf_large\.gif))\D+valign=top>(?<class>[^<]*)<\/td>/i;
85
+ /<tr><td class=small><b><a target=mainpane href="showplayer\.php\?who=(?<playerId>\d+)">(?<playerName>[^<]+)<\/a><\/b>.*?<\/td><td valign=top class=small>\d*<\/td><td valign=top class=small>(?:<img src=".*?">|(?<level>\d+))<\/td><td class=small valign=top>(?<class>[^<]+)<\/td><\/tr>/i;
86
86
  const search = await client.fetchText("searchplayer.php", {
87
87
  searchParams: {
88
88
  searchstring: name.replace(/_/g, "\\_"),
@@ -98,12 +98,14 @@ export class Player<IsFull extends boolean = boolean> {
98
98
  return null;
99
99
  }
100
100
 
101
+ const clazz = match.level ? match.class : "Astral Spirit";
102
+
101
103
  return new Player(
102
104
  client,
103
105
  Number(match.playerId),
104
106
  match.playerName,
105
- parseInt(match.level),
106
- match.class,
107
+ parseInt(match.level) || 0,
108
+ clazz,
107
109
  );
108
110
  } catch (error) {
109
111
  return null;
@@ -206,4 +208,8 @@ export class Player<IsFull extends boolean = boolean> {
206
208
  response?.output.includes("This player is currently online") ?? false
207
209
  );
208
210
  }
211
+
212
+ toString() {
213
+ return `${this.name} (#${this.id})`;
214
+ }
209
215
  }
@@ -0,0 +1,41 @@
1
+ <html><head>
2
+ <script language=Javascript>
3
+ <!--
4
+ if (parent.frames.length == 0) location.href="game.php";
5
+ var actions = { "sendmessage.php" : { "action" : 1, "title" : "Send Message", "arg" : "toid" } }
6
+ var notchat = true;//-->
7
+ </script>
8
+ <script language=Javascript src="https://d2uyhvukfffg5a.cloudfront.net/scripts/keybinds.min.2.js"></script>
9
+ <script language=Javascript src="https://d2uyhvukfffg5a.cloudfront.net/scripts/window.20111231.js"></script>
10
+ <script language="javascript">function chatFocus(){if(top.chatpane.document.chatform.graf) top.chatpane.document.chatform.graf.focus();}
11
+ if (typeof defaultBind != 'undefined') { defaultBind(47, 2, chatFocus); defaultBind(190, 2, chatFocus);defaultBind(191, 2, chatFocus); defaultBind(47, 8, chatFocus);defaultBind(190, 8, chatFocus); defaultBind(191, 8, chatFocus); }</script><script language=Javascript>
12
+ var confirm_ = 0;
13
+ function conf()
14
+ {
15
+ if (!confirm_)
16
+ return true;
17
+
18
+ var select = document.f.where;
19
+ var where = select.options[select.selectedIndex].value;
20
+ if (where == 1)
21
+ return true;
22
+ else
23
+ return confirm("You are currently in ronin/hardcore. Are you sure you want to buy these tickets using your on-hand Meat?");
24
+ }
25
+ </script><script language=Javascript src="https://d2uyhvukfffg5a.cloudfront.net/scripts/jquery-1.3.1.min.js"></script>
26
+ <script language=Javascript src='https://d2uyhvukfffg5a.cloudfront.net/scripts/rcm.20160406.js'></script> <link rel="stylesheet" type="text/css" href="https://d2uyhvukfffg5a.cloudfront.net/styles.20230117d.css">
27
+ <style type='text/css'>
28
+ .faded {
29
+ zoom: 1;
30
+ filter: alpha(opacity=35);
31
+ opacity: 0.35;
32
+ -khtml-opacity: 0.35;
33
+ -moz-opacity: 0.35;
34
+ }
35
+ </style>
36
+
37
+ </head>
38
+
39
+ <body>
40
+ <div id='menu' class=rcm></div><center><table width=95% cellspacing=0 cellpadding=0><tr><td style="color: white;" align=center bgcolor=blue><b>Raffle House</b></td></tr><tr><td style="padding: 5px; border: 1px solid blue;"><center><table><tr><td><table><tr><td valign=center><img src='https://d2uyhvukfffg5a.cloudfront.net/otherimages/town/shoppeng.gif'></td><td>Greetings, Adventurer, and welcome to our Raffle House, which is completely and totally legitimate and fully-licensed by the Council of Loathing.<p> Here's how it works: you buy some raffle tickets, and every day (at rollover) we'll draw one to see who wins our Fabulous Prize, which will be delivered to you in one of our special packages. Tickets are non-transferrable, but we will accept tickets from Hagnk's if you should ascend before the drawing.<p> All proceeds will be donated to an unspecified but very worthy charity of our choosing. So, how many tickets do you want?</td></tr></table><center><b>Today's Raffle Prize:</b></center><center><table><tr><td align=right>First Prize:</td><td><img class=hand onclick='descitem(352170676);' alt='maypole' style='vertical-align: middle' src='https://d2uyhvukfffg5a.cloudfront.net/itemimages/maypole.gif'> <b>miniature gravy-covered maypole</b></td></tr></table><center><table><tr><td align=right>Second Prize:</td><td><img class=hand onclick='descitem(992809861);' alt='vcase' style='vertical-align: middle' src='https://d2uyhvukfffg5a.cloudfront.net/itemimages/vcase.gif'> <b>Doll Moll violin case</b></td></tr></table><table><tr><td>1 first prize lot and 3 second prize lots are available. One prize lot per winner. All ticket sales are final. Void where prohibited by law. Part of a complete breakfast.<p></td></tr></table><p>You haven't bought any tickets for today's raffle.<p><b>Winners of Yesterday's Raffle:</b><br><div style='max-height: 300px; overflow: auto; border: 1px dashed black; margin-left: 5%; margin-right: 5%'><table><tr><td align=center><b>Name</b></td><td align=center><b>Prize</b></td><td align=center><b>Tickets<br>Purchased</b></td></tr><tr><td class=small><a href='showplayer.php?who=809337'>Collective Consciousness (#809337)</a></td><td><img class=hand onclick='descitem(828455718);' style='vertical-align: middle' src='https://d2uyhvukfffg5a.cloudfront.net/itemimages/wintercatalog.gif'> <b>Discontent&trade; Winter Garden Catalog</b></td><td class=small>&nbsp;&nbsp;3,333</td></tr><tr><td class=small><a href='showplayer.php?who=852958'>Ryo_Sangnoir (#852958)</a></td><td><img class=hand onclick='descitem(211748296);' style='vertical-align: middle' src='https://d2uyhvukfffg5a.cloudfront.net/itemimages/wine.gif'> <b>Colera Peste Nebbiolo</b></td><td class=small>&nbsp;&nbsp;1,011</td></tr><tr><td class=small><a href='showplayer.php?who=1765063'>SSpectre_Karasu (#1765063)</a></td><td><img class=hand onclick='descitem(211748296);' style='vertical-align: middle' src='https://d2uyhvukfffg5a.cloudfront.net/itemimages/wine.gif'> <b>Colera Peste Nebbiolo</b></td><td class=small>&nbsp;&nbsp;1,000</td></tr><tr><td class=small><a href='showplayer.php?who=1652370'>yueli7 (#1652370)</a></td><td><img class=hand onclick='descitem(211748296);' style='vertical-align: middle' src='https://d2uyhvukfffg5a.cloudfront.net/itemimages/wine.gif'> <b>Colera Peste Nebbiolo</b></td><td class=small>&nbsp;&nbsp;2,040</td></tr></table></div><form name=f style='display: inline' action=raffle.php method=post onsubmit='return conf();'><input type=hidden name=action value=buy><input type=hidden name=pwd value=4cf70f166096b3abab3d98431af7094a><table><tr><td colspan=3 align=center><b>Buy Raffle Tickets:</b></td></tr><tr><td align=right><img style='vertical-align: middle' class=hand src='https://d2uyhvukfffg5a.cloudfront.net/itemimages/raffle.gif' onclick='descitem(488779797)'></td><td><b>raffle ticket</b></td><td>10,000 Meat</td></tr><tr><td colspan=3><center><input type=hidden name=where value=0><input type=submit class=button value='Buy Tickets'> <input type=text class=text name=quantity value=1 size=3></center></td></tr></table></form></center><center><p><a href='bordertown.php'>Go back to Bordertown</a></center></td></tr></table></center></td></tr><tr><td height=4></td></tr></table></center></body></html>
41
+
@@ -0,0 +1,54 @@
1
+ <html><head><title>Search for a player</title>
2
+ <link rel="stylesheet" type="text/css" href="/images/styles.20230117d.css">
3
+ <script>
4
+ var actions = { "sendmessage.php" : { "action" : 1, "title" : "Send Message", "arg" : "toid" }, "town_sendgift.php" : { "action" : 1, "title" : "Send Gift", "arg" : "towho" }, "makeoffer.php" : { "action" : 1, "title" : "Propose Trade", "arg" : "towho" }, "mallstore.php" : { "action" : 1, "title" : "Mall Store", "arg" : "whichstore" }, "/./curse.php" : { "action" : 1, "title" : "Throw TP", "arg" : "whichitem=1923&targetplayer" }, "/msg" : { "action" : 3, "useid" : true, "query" : "Enter message to send to %:" } }
5
+ var notchat = true;
6
+ </script>
7
+ <script language=Javascript src='/images/scripts/rcm.3.js'></script>
8
+ <script language="Javascript" src="/basics.js"></script><link rel="stylesheet" href="/basics.1.css" /></head>
9
+ <style type="text/css">
10
+ <!--
11
+ BODY, TD, UL {
12
+ size : 1;
13
+ }
14
+ a {
15
+ text-decoration: none;
16
+ }
17
+ .small { font-size: .8em; }
18
+ -->
19
+ </style>
20
+
21
+ <script src="/onfocus.1.js"></script></html>
22
+ <body bgcolor=white text=black link=black alink=black vlink=black>
23
+ <div id='menu' class=rcm></div>
24
+
25
+ <center><b>Search Results:<br><table align=center><tr><td class=small><b><u>Name</u></b></td><td class=small><b><u>PlayerID</u></b></td><td class=small><b><u>Level</u></b></td><td class=small><b><u>Class</u></b></td></tr><tr><td class=small><b><a target=mainpane href="showplayer.php?who=1046951">Beldur</a></b> </td><td valign=top class=small>1046951</td><td valign=top class=small><img src="https://d2uyhvukfffg5a.cloudfront.net/otherimages/inf_small.gif"></td><td class=small valign=top>Seal Clubber</td></tr><tr><td class=small><b><a target=mainpane href="showplayer.php?who=3498868">Beldur2</a></b> </td><td valign=top class=small>3498868</td><td valign=top class=small>1</td><td class=small valign=top>Sauceror</td></tr></table></center>
26
+ <form name="search" action="searchplayer.php" method="post">
27
+ <input type="hidden" name="searching" value="Yep.">
28
+ <input type="hidden" name="for" value="">
29
+ <center>
30
+ <b>Search for names:</b>
31
+ <input class="text" type="text" name="searchstring" size="30" value="Beldur"><br />
32
+ <div style="font-weight: normal">
33
+ starting with <input type="radio" name="startswith" value="1" checked="checked">&nbsp;&nbsp;
34
+ containing<input type="radio" name="startswith" value="2">&nbsp;&nbsp;
35
+ ending with <input type="radio" name="startswith" value="3">
36
+ </div>
37
+ <table>
38
+ <tr><td valign="center" align="center">
39
+ Level: <input class="text" type="text" size=3 name=searchlevel>
40
+ Fame: <input class="text" type="text" size=3 name=searchranking>
41
+ </td></tr>
42
+ <tr><td valign="center"><input type="checkbox" name="pvponly" >PvP players only</td>
43
+ <td><input type="radio" name="hardcoreonly" value="1">Hardcore only<br>
44
+ <input type="radio" name="hardcoreonly" value="2">Not in Hardcore<br>
45
+ <input type="radio" name="hardcoreonly" value="0" checked>Don't Care</td>
46
+ </tr>
47
+ </table>
48
+ <input class="button" type="submit" value="Search">
49
+ </form>
50
+
51
+ </font>
52
+ </body>
53
+ </html>
54
+
@@ -1,5 +1,5 @@
1
1
  import xpath, { select } from "xpath";
2
- import { DOMParser } from "@xmldom/xmldom";
2
+ import { DOMParser, MIME_TYPE } from "@xmldom/xmldom";
3
3
 
4
4
  export type LeaderboardInfo = {
5
5
  name: string;
@@ -19,11 +19,8 @@ type RunInfo = {
19
19
  };
20
20
 
21
21
  const parser = new DOMParser({
22
- locator: {},
23
- errorHandler: {
24
- warning: () => {},
25
- error: () => {},
26
- fatalError: console.error,
22
+ onError: (level, message) => {
23
+ if (level === "fatalError") console.error(message);
27
24
  },
28
25
  });
29
26
 
@@ -34,8 +31,9 @@ const selectMulti = (expression: string, node: Node) => {
34
31
  };
35
32
 
36
33
  export function parseLeaderboard(page: string): LeaderboardInfo {
37
- const document = parser.parseFromString(page);
38
- const [board, ...boards] = selectMulti("//table", document);
34
+ const doc = parser.parseFromString(page, MIME_TYPE.HTML);
35
+ // @ts-expect-error see https://github.com/xmldom/xmldom/issues/724
36
+ const [board, ...boards] = selectMulti("//table", doc);
39
37
 
40
38
  return {
41
39
  name: selectMulti(".//text()", board.firstChild!)