-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathgoatbotsCardWatcher.user.js
2321 lines (2215 loc) · 130 KB
/
goatbotsCardWatcher.user.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// ==UserScript==
// @name GoatBots Card Watcher
// @version 2.2.2
// @author aminomancer
// @homepageURL https://github.com/aminomancer/GoatBots-Card-Watcher
// @supportURL https://github.com/aminomancer/GoatBots-Card-Watcher
// @downloadURL https://cdn.jsdelivr.net/gh/aminomancer/GoatBots-Card-Watcher@latest/goatbotsCardWatcher.user.js
// @updateURL https://cdn.jsdelivr.net/gh/aminomancer/GoatBots-Card-Watcher@latest/goatbotsCardWatcher.user.js
// @namespace https://github.com/aminomancer
// @match https://www.goatbots.com/*
// @description Configure a list of cards to watch GoatBots until in stock. Pick a GoatBots page to watch, and click the cards you want to watch for. Then leave the script to automatically refresh that page on a set timer, check if any of the cards are in stock, and if so, add them to cart, play an audio alert, and (optionally) start delivery.
// @license CC-BY-NC-SA-4.0
// The alert will use text-to-speech to audibly speak the names of the new cards
// if text-to-speech is available on your computer. Otherwise it will just play
// a predefined sound file that says "New cards in stock." You can watch
// multiple pages, and you can scan for multiple cards per-page.
// Configure the script by setting a URL path and cards to scan for in the
// config settings. You can access the config settings through the "Card
// Watcher" menu at the top of the GoatBots window, or through your script
// manager. Violentmonkey and the latest Greasemonkey versions have a toolbar
// button that opens a popup showing script settings for the current page. These
// are the same settings as in the "Card Watcher" menu. So if you go to
// www.goatbots.com you can open the popup or the menu and use the buttons in
// there to configure your watchlist. If you're not sure, there are further
// instructions and details in the script.
// When you first open a GoatBots page, open the menu popup and click the button
// that says "Add Page to Watchlist" to make a watchlist editor appear. The top
// text field is the path field. It defaults to the path of the current page. If
// you want to still be able to use the normal GoatBots page without it
// constantly reloading, add a + at the end of the page URL, like in the page
// paths for the example watchlist. You can navigate to the URL just fine with +
// at the end, and the script will correctly recognize it as a different URL.
// Then, the script will only activate when you explicitly navigate to the +
// version of the URL, which you can bookmark.
// Then proceed to the big text area — the cards list. Technically, you can
// manually input the cards you want to add, in YAML or JSON format. But because
// there are many versions of some cards, the cards are uniquely identified by a
// random ID that is not easily visible on the page. So it's recommended to
// click the "Select by clicking" button at the bottom. This will allow you to
// add cards to the watchlist just by clicking them. Clicking a card row will
// toggle it on or off. Then click the "Confirm" button to return to the
// watchlist editor — the cards list will now be filled with the cards you
// selected. Then, click the "Save" button and it will store this in your script
// settings. They will persist even after updating the script.
// By default, this script will refresh the page every 10 seconds, provided the
// tab the page is loaded in is not active. It basically pauses refreshing while
// the tab is active, so that you can still use the page as normal. That way, it
// will only scan in the background, and alert you when it finds something.
// However, this pausing behavior can be disabled by setting "Refresh while
// active" to true in the advanced settings, which can be accessed through the
// menu. You can set the values of any of the settings in this interface, except
// for the watchlist. If you need to make bulk changes to the watchlist, go to
// the script page in your script manager and click on the "Values" tab.
// The "Automatically start delivery" setting lets you begin delivery as soon as
// new cards are detected. This increases the chances that you'll get the cards,
// since there's no risk of fumbling around trying to find the tab and click the
// "start delivery" button yourself. The script will just handle it for you. But
// if you're going to use this, you have to make sure the script isn't operating
// when you're away from the computer, since it will be a major public nuisance
// if you're not actually present to conduct the delivery.
// Otherwise, it's useful to have this feature since it takes a few minutes for
// GoatBots to actually send a trade request. So there's time for you to get
// ready. You just want to make sure you enter the delivery queue as soon as
// possible, so that someone else doesn't queue up first. If you're first in
// line, I think it's guaranteed you'll get the card. You can toggle this
// setting from the menu popup or your script manager (popup or values page).
// For additional efficiency, when the delivery is finished, the script will
// automatically go back to the page it was watching before it started delivery.
// If you're using Firefox and you want the text-to-speech alerts, make sure the
// following pref is enabled in about:config - media.webspeech.synth.enabled
// If you don't want or can't use text-to-speech, and the default sound file is
// not to your liking, you can replace it with your own base64-encoded audio
// file. You can convert any mp3 file to base64 by uploading it to this encoder:
// https://codepen.io/xewl/pen/NjyRJx
// Then just copy the resulting string and replace the "Voice audio file" value
// at the bottom with your new string. The script will decode and play it at
// runtime. This audio file is not a user setting because that would slow the
// script down, so you have to edit the script directly.
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_addValueChangeListener
// @grant GM_getResourceURL
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.addStyle
// @require https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js
// @resource texture https://cdn.jsdelivr.net/gh/aminomancer/Netflix-Marathon-Pausable@latest/texture/noise-512x512.png
// @run-at document-start
// @license This Source Code Form is subject to the terms of the Creative Commons Attribution-NonCommercial-ShareAlike International License, v. 4.0. If a copy of the CC BY-NC-SA 4.0 was not distributed with this file, You can obtain one at http://creativecommons.org/licenses/by-nc-sa/4.0/ or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
// @icon data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 250"><path d="M209.82 4.76c6.89-2.17 14.1-3.64 21.35-3.39-3.84 5.25-10.98 5.94-15.65 10.18-3.46 3.21-6.81 6.64-9.42 10.59-5.7 9.12-8.51 19.69-14.2 28.81-5.78 9.3-13.58 17.08-20.24 25.72-1.45 1.98-3.04 3.99-3.69 6.4-.34 3.05.6 6.05.74 9.08l.05 2.13c-4.95-4.04-9.21-8.86-14.4-12.62-2.88-2.07-5.77-4.39-7.25-7.71-1.9-3.22-3.03-7.96-7.28-8.75-7.22-1.55-14.52.8-21.8.47-5.74.22-12.08-.92-17.31 2-3.97 3.64-4.81 9.54-8.84 13.18-4.53 4.17-9.48 7.88-14.38 11.6l-.4-3.44c-.32-3.18-1.1-6.41-3.14-8.96-7.28-9.66-17.82-16.31-25.03-26.03-4.49-7.25-4.69-16.16-8.06-23.89-3.43-8.09-7.86-16.86-16.25-20.71-3.78-2.02-8.23-3.37-10.79-7.05 7.25-.25 14.46 1.22 21.35 3.39 4.92 1.55 9.31 4.48 12.81 8.24 7.79 8.3 14.09 17.87 21.73 26.29 5.96 5.31 12.47 10.13 19.88 13.24 8.95 4.94 19.27 6.73 29.41 6.51 7.76-.21 15.79.74 23.3-1.71 7.83-2.72 13.95-8.6 19.6-14.44 6.63-6.79 15.51-11.01 21.55-18.43 2.99-3.67 6.31-7.06 10-10.04 5.05-4.09 10-8.71 16.36-10.66zM114.1 113.84c3.61.62 6.75 2.57 9.89 4.33 3.14-1.78 6.28-3.72 9.89-4.33 2.06 6.59 5.3 13.45 3.39 20.49-2.87 4.34-7.31 7.39-11.21 10.75-2.15 2.25-4.57-.47-6.31-1.81-3.09-2.86-6.82-5.26-8.95-8.97-2.1-7.03 1.49-13.81 3.3-20.46zm-58.06 2.13c6.37 2.83 11.79 7.51 16.26 12.8 1.35 1.43 1.52 3.44 1.82 5.29-9.66.14-18.59-8.24-18.08-18.09zm128.03 5.08c2.71-1.94 5.49-4.35 9.03-4.32.01 9.08-8.75 16.15-17.31 17.1-1.4-5.95 4.39-9.51 8.28-12.78zm-71.88 31.12c5.66-1.78 11.7-2.26 17.6-1.7 6.43.65 12.07 4.59 16.22 9.36-7.22 2.36-14.22 6.39-22.05 5.95-6.67.14-12.78-3.25-18.1-6.95 1.17-2.88 3.21-5.73 6.33-6.66z" fill="%23735141"/><path d="M118.03 65.67c7.28.33 14.58-2.02 21.8-.47 4.25.79 5.38 5.53 7.28 8.75 1.48 3.32 4.37 5.64 7.25 7.71 5.19 3.76 9.45 8.58 14.4 12.62 4.83 4.66 12.1 4.35 18.3 3.78 8.88-.73 17.18-5.71 26.26-4.12-1.24 1.55-2.55 3.07-4.17 4.23-8.33 6.01-18.2 9.44-26.49 15.52-8.35 6.22-12.61 16.36-14.92 26.22-1.34 5.2-1.8 11.08-5.69 15.16-5.87 6.19-13.54 10.4-19.08 16.94-2.96 3.37-6.09 7.07-10.6 8.29-4.66 1.19-9.59 1.42-14.34.76-4.55-.71-6.87-5.21-9.85-8.19-6.43-7.67-15.54-12.48-21.92-20.16-6.32-8.02-4.82-19.25-10.33-27.66-5.93-9.38-15.17-16.08-24.91-21.1-5.79-3.07-12.4-5.47-16.36-11.01 6.19-.98 12.22 1.13 18.37 1.33 4.98.23 9.96.18 14.94.11 3.24-.12 6.7-.12 9.53-1.93 4.9-3.72 9.85-7.43 14.38-11.6 4.03-3.64 4.87-9.54 8.84-13.18 5.23-2.92 11.57-1.78 17.31-2m34.74 31.09c-4.84 5.32-12.49 8.51-14.11 16.21 2.22 2.38 5.01 4.66 8.42 4.77 4.72.14 9.24-1.97 13.04-4.61 4.45-3.14 5.68-9.01 5.27-14.14-.33-3.91-4.29-7.47-8.3-6.25-1.63 1.12-2.94 2.63-4.32 4.02m-74.95 4.16c2.79 8.98 11.12 15.94 20.23 17.78 4.61 1.2 7.32-3.37 9.4-6.63-2.18-4.17-6.05-6.97-9.24-10.3-4.04-3.81-9.05-7.99-14.98-7.42-3.02.44-6.85 3.06-5.41 6.57m36.28 12.92c-1.81 6.65-5.4 13.43-3.3 20.46 2.13 3.71 5.86 6.11 8.95 8.97 1.74 1.34 4.16 4.06 6.31 1.81 3.9-3.36 8.34-6.41 11.21-10.75 1.91-7.04-1.33-13.9-3.39-20.49-3.61.61-6.75 2.55-9.89 4.33-3.14-1.76-6.28-3.71-9.89-4.33m-1.91 38.33c-3.12.93-5.16 3.78-6.33 6.66 5.32 3.7 11.43 7.09 18.1 6.95 7.83.44 14.83-3.59 22.05-5.95-4.15-4.77-9.79-8.71-16.22-9.36-5.9-.56-11.94-.08-17.6 1.7zm46.15 25.22c2.66-8.12 4.5-17.94 12.52-22.6.74 4.36.84 8.78 1.03 13.19.45 10.5 2.72 21.08 7.73 30.39 2.99 5.62 5.65 11.48 6.72 17.8l-49.1 28.43c-1.64-7.43-.74-15.06-.98-22.59.05-4.78-.52-9.8 1.41-14.32 4.77-11.51 17-18.17 20.67-30.3zm-78.08-20.56c7.52 8.48 10.21 19.98 16.93 29.01 5.08 7.16 13.12 11.63 17.93 19.01 3.45 5.37 3.63 11.96 3.76 18.13.04 7.38.13 14.79-.89 22.12-2.08.16-4.16.36-6.24.43l-41.04-23.32c-.03-9.13-.51-18.48 2.13-27.33 3.74-12.39 6.36-25.15 7.42-38.05z" fill="%23e4dfd1"/><path d="M208.39 88.39c10.26-2.56 20.49-5.45 31.02-6.69.53 6.12-3.34 11.21-7.53 15.18-5.77 5.58-11.14 11.74-18.05 15.97-4.04 2.75-8.7 4.45-12.65 7.34-5.22 4.37-6.75 11.63-11.85 16.13-3.53 3.34-8.33 4.89-11.9 8.16-1.31 4.33-.83 8.97-1.11 13.44.06 7.56-.61 15.18.24 22.7 2.74 11.04 11.96 19.31 14.21 30.53.77 2.74-2.67 3.78-4.43 5.02-1.07-6.32-3.73-12.18-6.72-17.8-5.01-9.31-7.28-19.89-7.73-30.39-.19-4.41-.29-8.83-1.03-13.19-8.02 4.66-9.86 14.48-12.52 22.6-3.67 12.13-15.9 18.79-20.67 30.3-1.93 4.52-1.36 9.54-1.41 14.32.24 7.53-.66 15.16.98 22.59-2.56 1.8-4.95 3.83-7.66 5.4h-12.26c-1.84-1.51-3.67-3.05-5.57-4.47 2.08-.07 4.16-.27 6.24-.43 1.02-7.33.93-14.74.89-22.12-.13-6.17-.31-12.76-3.76-18.13-4.81-7.38-12.85-11.85-17.93-19.01-6.72-9.03-9.41-20.53-16.93-29.01-1.06 12.9-3.68 25.66-7.42 38.05-2.64 8.85-2.16 18.2-2.13 27.33-3.2-1.63-6.82-3.13-8.74-6.36-.32-2.72.21-5.44.76-8.1 2.03-8.82 4.96-17.42 6.74-26.31 2.32-10.98 3.38-22.26 2.81-33.47-.27-1.35-.05-3.1-1.23-4.04-3.51-2.92-8.07-4.37-11.24-7.74-4.62-4.71-5.79-11.8-10.73-16.23-4.84-4.05-11.29-5.73-15.78-10.26-4.2-4.23-7.08-9.54-11-14.01-3.59-4.44-8.35-7.8-11.58-12.53 6.64-.16 13.27.74 19.68 2.42 6.81 1.74 13.5 4.25 20.59 4.58 8.7.36 17.54.76 26.11-1.15l.4 3.44c-2.83 1.81-6.29 1.81-9.53 1.93-4.98.07-9.96.12-14.94-.11-6.15-.2-12.18-2.31-18.37-1.33 3.96 5.54 10.57 7.94 16.36 11.01 9.74 5.02 18.98 11.72 24.91 21.1 5.51 8.41 4.01 19.64 10.33 27.66 6.38 7.68 15.49 12.49 21.92 20.16 2.98 2.98 5.3 7.48 9.85 8.19 4.75.66 9.68.43 14.34-.76 4.51-1.22 7.64-4.92 10.6-8.29 5.54-6.54 13.21-10.75 19.08-16.94 3.89-4.08 4.35-9.96 5.69-15.16 2.31-9.86 6.57-20 14.92-26.22 8.29-6.08 18.16-9.51 26.49-15.52 1.62-1.16 2.93-2.68 4.17-4.23-9.08-1.59-17.38 3.39-26.26 4.12-6.2.57-13.47.88-18.3-3.78l-.05-2.13c6.44.32 12.88 1.26 19.33.7 6.95-.59 13.61-2.79 20.35-4.46M56.04 115.97c-.51 9.85 8.42 18.23 18.08 18.09-.3-1.85-.47-3.86-1.82-5.29-4.47-5.29-9.89-9.97-16.26-12.8m128.03 5.08c-3.89 3.27-9.68 6.83-8.28 12.78 8.56-.95 17.32-8.02 17.31-17.1-3.54-.03-6.32 2.38-9.03 4.32zm-31.3-24.29c1.38-1.39 2.69-2.9 4.32-4.02 4.01-1.22 7.97 2.34 8.3 6.25.41 5.13-.82 11-5.27 14.14-3.8 2.64-8.32 4.75-13.04 4.61-3.41-.11-6.2-2.39-8.42-4.77 1.62-7.7 9.27-10.89 14.11-16.21zm-74.95 4.16c-1.44-3.51 2.39-6.13 5.41-6.57 5.93-.57 10.94 3.61 14.98 7.42 3.19 3.33 7.06 6.13 9.24 10.3-2.08 3.26-4.79 7.83-9.4 6.63-9.11-1.84-17.44-8.8-20.23-17.78z" fill="%230c0b09"/></svg>
// ==/UserScript==
/**
* @typedef {Object} Card An object representing a card to watch for.
* @property {string} name The name of the card, e.g. "Lightning Bolt"
* @property {string} id The GoatBots ID of the card, e.g. "A4zDSnxvlEjn"
* @property {string} [set] The set code, usually 3 characters.
* @property {string} [frame] The card style, e.g. "Borderless"
* @property {string} [rarity] The rarity, e.g. "Mythic Rare"
* @property {number} [wanted] The max number of copies to order, or a value
* less than 1 for no limit.
* @property {string} [alert] Optional alert text to read aloud when found.
* @property {string} [notes] Optional notes about the card (not read aloud).
*/
/**
* @typedef {Object} Page An object representing a GoatBots page to watch.
* @property {string} path The pathname of the GoatBots page to watch.
* @property {Card[]} cards The cards to scan for on the page.
*/
const GMObj =
"GM" in window && typeof GM === "object" && typeof GM.getValue === "function";
// check if the script handler is GM4, since if it is, we can't add a menu command
const GM4 =
GMObj &&
GM.info.scriptHandler === "Greasemonkey" &&
GM.info.version.split(".")[0] >= 4;
if (GM4) {
GM_getValue = GM.getValue;
GM_setValue = GM.setValue;
GM_addStyle = GM.addStyle;
GM_getResourceURL = GM.getResourceUrl;
GM_registerMenuCommand = () => {};
GM_unregisterMenuCommand = () => {};
GM_addValueChangeListener = () => {};
}
class CardWatcher {
config = {};
requests = {};
logger = {};
LOG_LEVELS = {
off: Number.MAX_VALUE,
error: 5,
warn: 4,
info: 3,
debug: 2,
all: Number.MIN_VALUE,
};
shouldLog(level, maxLevel = this.config["Debug log level"]) {
return this.LOG_LEVELS[maxLevel] <= this.LOG_LEVELS[level];
}
/**
* Log a message to the console, if the user's log level is less than or equal
* to the message's. The lower the level, the more important the message is.
* By default, a level 0 message will be logged in the console, but a level 1
* message will not. For debugging purposes you can increase your log level
* setting to 4. That will capture all messages.
* @param {string} [mode] the console method ("log" or "error" for example).
* @param {any} message anything worth logging.
*/
_log(mode = "debug", ...message) {
if (this.shouldLog(mode)) {
console[mode](...message);
}
}
get yamlEnabled() {
return this.config["Editor format"] === "yaml" && window.jsyaml;
}
/**
* Parse a YAML or JSON string into a JS value.
* @param {string} string The string to parse.
* @param {string} [expectedType="array"] The expected parsed value type.
* @returns {any} The parsed value.
*/
fromString(string, expectedType = "array") {
let parsed;
if (window.jsyaml) {
parsed = window.jsyaml.load(string, {
json: true,
onWarning: error => {
this.logger.warn(
"GoatBots Card Watcher: invalid cards value :>> ",
error
);
},
});
} else {
parsed = JSON.parse(string);
}
function throwTypeError() {
throw new Error(
`Expected ${expectedType}, but got ${typeof parsed}: ${parsed}`
);
}
switch (expectedType) {
case "array":
if (!Array.isArray(parsed)) throwTypeError();
break;
case "integer":
if (!Number.isInteger(parsed)) throwTypeError();
break;
default:
// eslint-disable-next-line valid-typeof
if (typeof parsed !== expectedType) throwTypeError();
break;
}
return parsed;
}
/**
* Convert a JS value into a YAML or JSON string (based on user settings).
* @param {any} thing The value to convert.
* @returns {string} The converted value.
*/
toFormatString(thing) {
if (this.yamlEnabled) {
return window.jsyaml.dump(thing, {
indent: 2,
noRefs: true,
noCompatMode: true,
});
}
return JSON.stringify(thing, null, 2);
}
migrateSettings() {
// Check for old watchlist format { path: string, cards: string[] }[]
// The new format is { path: string, cards: object[] }[]
// Just wipe it since we can't get the card ids from the old format.
if (typeof GM_getValue("Watchlist")[0]?.cards[0] === "string") {
GM_setValue("Watchlist", this.defaults.Watchlist);
}
}
constructor() {
// Add methods for all log levels.
for (const level of Object.keys(this.LOG_LEVELS)) {
if (["all", "off"].includes(level)) continue;
this.logger[level] = (...args) => this._log(level, ...args);
}
// Maybe migrate old settings.
this.migrateSettings();
// Get the user settings or set them if they aren't already set.
for (const [key, value] of Object.entries(this.defaults)) {
const saved = GM_getValue(key);
if (saved === undefined) {
this.logger.debug(
`GoatBots Card Watcher: writing setting ${key} :>> `,
value
);
GM_setValue(key, value);
this.config[key] = value;
} else {
this.config[key] = saved;
}
GM_addValueChangeListener(key, (...args) => this.onValueChange(...args));
}
// Add menu commands for auto-start delivery and pause watching.
GM_registerMenuCommand(
this.config["Automatically start delivery"]
? "Disable Automatic Delivery"
: "Enable Automatic Delivery",
() =>
GM_setValue(
"Automatically start delivery",
!this.config["Automatically start delivery"]
)
);
GM_registerMenuCommand(
this.config["Pause watching"] ? "Resume Watching" : "Pause Watching",
() => {
GM_setValue("Pause watching", !this.config["Pause watching"]);
if (this.config["Pause watching"] && this.cards?.length) {
window.location.reload();
}
}
);
GM_registerMenuCommand("Advanced Settings", () => this.advancedDialog());
// Add styles to highlight watched cards.
GM_addStyle(this.css);
// Check if the current page is not in the watchlist.
const page = this.config.Watchlist.find(
page => page.path === window.location.pathname
);
// Add a custom menu to the page DOM.
document.addEventListener(
"DOMContentLoaded",
() => this.createMenu(!!page),
{ once: true }
);
if (this.config["Use text-to-speech"] && window.speechSynthesis) {
this.voices = window.speechSynthesis.getVoices();
window.speechSynthesis.onvoiceschanged = () => {
this.voices = window.speechSynthesis.getVoices();
};
}
// If we're on the delivery page...
if (window.location.pathname === "/delivery") {
// Check if we're here because we triggered delivery automatically...
if (
window.history.state?.autostart &&
this.config["Automatically start delivery"]
) {
this.logger.info(
"GoatBots Card Watcher: Automatically started delivery"
);
const { previousURL } = window.history.state;
// We use the history to store state between page loads. That way we can
// avoid messing with the normal usage of GoatBots. When we auto-start
// delivery, we mark the history with an autostart property and a
// previousURL property. The previousURL property stores the URL we were
// on when we triggered delivery. So it's for whichever page we were
// watching when we added cards to cart and started delivery. We store
// it so we can trigger delivery, let GoatBots go through the motions of
// processing the delivery (which causes page loads), and then, when
// delivery is finally finished, go back and resume scanning for cards.
if (previousURL) {
document.addEventListener("DOMContentLoaded", () => {
// If #delivery-steps is present, then the delivery is in progress.
// Which means we don't want to go back yet. The page will reload
// when delivery finishes, so we'll reach this code again.
// #delivery-count represents the number of items in cart. This is
// supposed to take us back to the previous page when a delivery has
// successfully finished. But sometimes a delivery might fail, in
// which case we want to let the user handle it. If a delivery was
// successful, our cart will be empty when the page reloads. So just
// check that the cart is empty before proceeding.
if (
!document.getElementById("delivery-steps") &&
!document.getElementById("delivery-count")?.textContent
) {
window.location.href = previousURL;
}
});
}
}
return;
}
// Add menu commands for editing watchlist.
document.addEventListener(
"DOMContentLoaded",
() => {
if (this.pageCanBeWatched()) {
GM_registerMenuCommand(
page ? "Edit Cards List" : "Add Page to Watchlist",
() => this.editCardListDialog()
);
}
},
{ once: true }
);
if (!page) return;
GM_registerMenuCommand("Remove Page from Watchlist", () =>
this.removeFromWatchlist()
);
this.logger.info("GoatBots Card Watcher: watching the page");
this.path = page.path;
this.cards = page.cards;
// If the page's cards list is empty for some reason, do nothing.
if (!this.cards?.length) return;
document.addEventListener("DOMContentLoaded", this, { once: true });
// Construct the predefined audio files.
this.voiceAudio = new Audio(
`data:audio/mp3;base64,${this.audio["Voice audio file"]}`
);
this.alertAudio = new Audio(
`data:audio/mp3;base64,${this.audio["Alert audio file"]}`
);
}
/**
* Send an XMLHttpRequest with given parameters to the GoatBots server.
* Communicate with the server using the same AJAX syntax as GoatBots'
* client-side code, but with some modifications to maintain control.
* @param {string} [url] URL to send request to via XMLHttpRequest.
* @param {FormData|Object} [data] Form data to send with request.
* @param {function} [success] Callback to invoke on successful request load.
* @param {function} [reject] Callback to invoke for unsuccessful response.
* @param {boolean} [leaving] Will we leave the current page on success?
* @param {boolean} [debug] Should we log info to console?
*/
makeRequest({
url,
data,
success,
reject,
leaving,
debug = this.shouldLog("debug"),
} = {}) {
const key = leaving ? "leaving" : url;
if (key in this.requests) {
if (key === "leaving") {
if (debug) {
console.log(
`GoatBots Card Watcher AJAX: blocking requests because ${key}`
);
}
return;
}
this.requests[key].abort();
delete this.requests[key];
if (debug) {
console.log(
`GoatBots Card Watcher AJAX: aborting previous request ${key}`
);
}
}
const request = new XMLHttpRequest();
request.open("POST", url);
request.timeout = 30000;
request.onload = () => {
if (debug) {
console.log(
`GoatBots Card Watcher AJAX: response status: ${request.status}`
);
}
if (request.status === 403) {
window.location.href = "/login";
} else if (request.status === 200) {
if (debug) {
console.log(
`GoatBots Card Watcher AJAX: response: ${request.response}`
);
}
if (success) {
if (debug) {
console.log("GoatBots Card Watcher AJAX: calling success function");
}
success(request.response);
}
if (key in this.requests && key !== "leaving") {
delete this.requests[key];
}
} else if (reject) {
reject({ status: request.status, response: request.response });
} else if (
window.confirm("GoatBots Card Watcher hit a server error. Reload?")
) {
window.location.reload();
}
};
let form = new FormData();
if (data) {
if (Object.prototype.isPrototypeOf.call(FormData, data)) {
form = data;
} else {
for (const el in data) {
if (Object.hasOwn(data, el)) {
form.append(el, data[el]);
}
}
}
if (debug) {
console.log(`GoatBots Card Watcher AJAX: sending data: ${form}`);
}
} else if (debug) {
console.log("GoatBots Card Watcher AJAX: sending no data");
}
request.send(form);
this.requests[key] = request;
}
/** Invoked when the page finishes loading. Scan for new cards. */
handleEvent() {
this.logger.debug("GoatBots Card Watcher: loaded");
const words = [];
const cards = [];
const pageType = window.location.pathname.split("/")[1];
// Scan every card in the page's price lists.
for (const pricelist of document.querySelectorAll(
"#main .price-list:not(.redeemable-cards)"
)) {
for (const row of pricelist.children) {
const id = row.dataset.item;
const found = id && this.cards?.find(c => c.id === id);
if (found) {
row.toggleAttribute("watching", true);
// Only handle the card if it's in stock.
if (row.querySelector(".stock")?.classList.contains("out")) {
continue;
}
this.logger.debug(
`GoatBots Card Watcher: ${found.name} in stock`,
found
);
let word;
switch (pageType) {
case "card": {
if (!words.length) {
words.push(
found.name ??
document
.querySelector("body > main > h1")
?.innerText?.trim()
);
}
let { set } = found;
if (
found.name.endsWith(" Redeemable Set") ||
found.name.endsWith(" Booster")
) {
set = "";
}
word = `${set ?? ""} ${found.frame ?? ""} ${
found.alert ?? ""
}`.trim();
break;
}
case "set": {
word = `${found.frame ?? ""} ${found.name ?? ""} ${
found.alert ?? ""
}`.trim();
break;
}
default: {
word = found.name;
break;
}
}
if (word) words.push(word);
// If the card isn't already in our cart, add it.
if (
row
.querySelector(".delivery")
?.firstElementChild?.classList.contains("delivery-count")
) {
continue;
}
cards.push({ ...found, row });
}
}
}
if (this.config["Pause watching"]) {
this.countdown();
return;
}
// Check delivery status. If a delivery is in progress, adding cards to cart
// or starting delivery would fail and cause repeated reloads.
this.makeRequest({
url: "/ajax/delivery-status",
success: data => {
data = JSON.parse(data);
if (!data) {
// If we found cards, start the alert process.
if (words.length) {
this.addToCart(cards);
if (this.config["Use text-to-speech"] && window.speechSynthesis) {
// Limit the length of the speech if user chose to.
let limit = this.config["Limit number of card names to speak"];
if (limit && typeof limit === "number") {
if (pageType === "card") {
// Don't count the card name for the limit if on a card page.
limit += 1;
}
words.splice(limit);
}
this.playSynthAlert(words.join("; "));
} else {
this.playVoiceAlert();
}
return;
}
} else {
this.logger.debug(
"GoatBots Card Watcher: delivery active :>> ",
data.text
);
this.logger.debug(
"GoatBots Card Watcher: delivery hash :>> ",
data.hash
);
}
this.countdown();
},
reject: () => this.countdown(),
});
}
/** Start the reload timer. */
countdown() {
this.logger.debug("GoatBots Card Watcher: waiting to reload");
window.clearTimeout(this.timer);
this.timer = window.setTimeout(() => {
// Check delivery status. We don't reload if a delivery is in progress.
this.makeRequest({
url: "/ajax/delivery-status",
success: data => {
data = JSON.parse(data);
if (!data) {
if (this.path === window.location.pathname) {
// Don't reload if the dialog is open or if watching is paused. Also
// don't reload if the tab is active unless the setting is enabled.
if (
document.querySelector(".card-watcher-dialog") ||
this.config["Pause watching"] ||
!(document.hidden || this.config["Refresh while active"])
) {
this.countdown();
} else {
window.location.reload();
}
}
} else {
this.logger.debug(
"GoatBots Card Watcher: delivery active :>> ",
data.text
);
this.logger.debug(
"GoatBots Card Watcher: delivery hash :>> ",
data.hash
);
this.countdown();
}
},
reject: () => this.countdown(),
});
}, this.config["Refresh interval"]);
}
/**
* Add the passed items to the cart. When all of them are finished, try to
* start delivery automatically. This relies on ajax requests and we can't add
* multiple items in one request, so we need to iterate over the items, only
* adding the next item when the current item finishes successfully.
* @param {Card[]} [cards] An array containing all the items to add.
* @param {number} [i] The current array index.
*/
addToCart(cards = [], i = 0) {
const card = cards[i];
const row = card?.row;
// If the current row is valid, add it to cart.
if (card) {
this.logger.debug(
`GoatBots Card Watcher: adding ${
card.wanted ? `${card.wanted}x ` : ""
}${card.name} to cart`
);
const item = card.id || card?.row.dataset.item;
if (item) {
this.makeRequest({
url: "/ajax/delivery-item",
data: { item: row.dataset.item },
abort: `item${row.dataset.item}`,
success: rv => {
if (card.wanted && card.wanted > 1) {
// If the card has a wanted value less than the returned quantity,
// we need to make the request again until the quantity in cart is
// less than or equal to the wanted value set by the user.
rv = JSON.parse(rv);
if (rv !== null && typeof rv === "object") {
if (
typeof rv.quantity === "number" &&
rv.quantity > card.wanted
) {
this.addToCart(cards, i);
return;
}
}
}
i += 1;
this.addToCart(cards, i);
},
});
} else {
this.logger.error(
`GoatBots Card Watcher: could not find item id for ${card.name}`
);
i += 1;
this.addToCart(cards, i);
}
} else if (cards.length) {
// If the row is undefined, that means either 1) the array was somehow
// empty from the start, in which case we should do nothing; or 2) we got
// to the end of the array, meaning we're finished adding items to cart.
this.finishedAddingToCart = true;
this.tryDelivery();
}
}
/** Either start delivery or go to the delivery page. */
tryDelivery() {
// Only proceed if the speech/audio is finished (async) and we're finished
// adding items to cart (iterative). This will be called multiple times and
// only the last call will actually start delivery.
if (this.finishedSpeaking && this.finishedAddingToCart) {
if (this.config["Automatically start delivery"]) {
// If the setting is enabled, start delivery automatically.
this.makeRequest({
url: "/ajax/delivery-start",
leaving: true,
success: () => {
// If the delivery start request succeeds as it should, go the
// delivery page and save the current URL with the history API so we
// can recover it after the delivery is finished. That's necessary
// because this CardWatcher instance will be flushed when the
// document changes and replaced by a new instance with no memory of
// the previous one or its triggering delivery. We want to be able
// to distinguish between the *user* starting delivery manually and
// the *script* starting delivery automatically. That way we don't
// screw up the normal usage of GoatBots.
window.history.pushState(
{ autostart: true, previousURL: window.location.href },
"",
"/delivery"
);
window.location.reload();
},
reject: () => {
window.location.href = "/delivery";
},
});
this.logger.info("GoatBots Card Watcher: starting delivery");
} else {
// Just go to the delivery page without starting delivery.
window.location.href = "/delivery";
}
} else {
this.logger.info(
`GoatBots Card Watcher: waiting for ${
this.finishedSpeaking
? "items to be added to cart"
: "speech/audio to finish"
}`
);
}
}
/**
* Convert some text into audible speech.
* @param {string} text The input text, e.g. card names.
*/
playSynthAlert(text) {
if (text) {
this.logger.debug("GoatBots Card Watcher: speech synth startup");
const speech = new SpeechSynthesisUtterance();
this.voices = window.speechSynthesis.getVoices();
speech.lang = "en-US";
speech.text = text;
speech.rate = this.config["Text-to-speech rate"];
speech.volume = this.config["Alert volume"];
// If speech synthesis isn't working, use the generic voice file.
if (this.voices) {
if (!window.speechSynthesis.speaking) {
if (this.voices?.length > 0 && this.config["Text-to-speech voice"]) {
const voice = this.voices.find(
voice => voice?.voiceURI === this.config["Text-to-speech voice"]
);
if (voice) speech.voice = voice;
}
// Trigger delivery when the alert is finished. I'd prefer to do this
// sooner but navigation ends the speech synthesis.
speech.onend = () => {
this.finishedSpeaking = true;
this.tryDelivery();
speech.onend = null;
};
this.logger.debug("GoatBots Card Watcher: speaking words :>> ", text);
this.alertAudio.volume = this.config["Alert volume"];
this.alertAudio.play();
window.speechSynthesis.speak(speech);
} else {
this.logger.debug("GoatBots Card Watcher: speech synth busy");
}
return;
}
} else {
this.logger.error(
"GoatBots Card Watcher: speech synth sent invalid card names"
);
}
this.playVoiceAlert();
}
/** Play a predefined audio alert. */
playVoiceAlert() {
this.alertAudio.volume = this.config["Alert volume"];
this.voiceAudio.volume = this.config["Alert volume"];
this.voiceAudio.onended = () => {
// Trigger delivery when the alert is finished.
this.finishedSpeaking = true;
this.tryDelivery();
this.voiceAudio.onended = null;
};
this.logger.debug("GoatBots Card Watcher: playing audio");
this.alertAudio.play();
this.voiceAudio.play();
}
/**
* User setting change event handler. This is how we handle real-time update
* of settings. When a setting changes, we hear about it and can respond
* accordingly, changing values and labels and so on.
* @param {string} id The name of the setting changed.
* @param {any} oldValue The setting's previous value.
* @param {any} newValue The setting's new value, after this value change.
* @param {boolean} remote Whether the value was changed in an instance of the
* script running in another tab. If true, we don't need to reload the page.
*/
onValueChange(id, oldValue, newValue, remote) {
if (oldValue === newValue) return;
this.logger.debug(
`GoatBots Card Watcher: setting updated — ${id} :>> `,
newValue
);
this.config[id] = newValue;
const menu = document.getElementById("card-watcher-menu");
switch (id) {
case "Pause watching":
if (menu) {
const label = this.config["Pause watching"]
? "Resume Watching"
: "Pause Watching";
const item = menu.querySelector("a[href='#pause']");
item.setAttribute("aria-label", label);
item.textContent = label;
} else {
this.logger.warn("GoatBots Card Watcher: menu missing");
}
if (!remote && !newValue && oldValue && this.cards?.length) {
window.location.reload();
}
GM_unregisterMenuCommand(
oldValue ? "Resume Watching" : "Pause Watching"
);
GM_registerMenuCommand(
newValue ? "Resume Watching" : "Pause Watching",
() => {
GM_setValue("Pause watching", !newValue);
}
);
break;
case "Automatically start delivery":
if (menu) {
const label = this.config["Automatically start delivery"]
? "Disable Automatic Delivery"
: "Enable Automatic Delivery";
const item = menu.querySelector("a[href='#autostart']");
item.setAttribute("aria-label", label);
item.textContent = label;
} else {
this.logger.warn("GoatBots Card Watcher: menu missing");
}
GM_unregisterMenuCommand(
oldValue ? "Disable Automatic Delivery" : "Enable Automatic Delivery"
);
GM_registerMenuCommand(
newValue ? "Disable Automatic Delivery" : "Enable Automatic Delivery",
() => GM_setValue("Automatically start delivery", !newValue)
);
break;
default:
break;
}
}
/** Whether this page is compatible with the script's core functions. */
pageCanBeWatched() {
const path = window.location.pathname.split("/");
return (
document.querySelector("#main .price-list:not(.redeemable-cards)") &&
["card", "set", "boosters"].includes(path[1]) &&
!["treasure-chest-booster", "magic-online-player-reward-pack"].includes(
path[2]
)
);
}
/**
* For a given card row, return the card's rarity.
* @param {Element} row The <li> element containing the card.
* @returns {string|null} The rarity, e.g. "Common", or null if not found.
*/
getRarity(row) {
const rarityElm = row?.querySelector(".rarity");
if (rarityElm) {
switch (
[...rarityElm.classList].find(c =>
["common", "uncommon", "rare", "mythic"].includes(c)
)
) {
case "common":
return "Common";
case "uncommon":
return "Uncommon";
case "rare":
return "Rare";
case "mythic":
return "Mythic Rare";
default:
}
}
return null;
}
/**
* For a given card row, return a Card object identifying the card.
* @param {Element} row The <li> element containing the card.
* @returns {Card|null} The card object, or null if not found.
*/
getCardObject(row) {
const id = row.dataset.item;
if (!id) return null;
/** @type {Card} */
let item;
const rowName = row.querySelector(".name").innerText?.trim() ?? "";
const pageType = window.location.pathname.split("/")[1];
const set = row
.querySelector(".rarity use")
?.getAttribute("href")
.match(/.svg#cardset-(.*)/)?.[1]
?.toUpperCase();
const rarity = this.getRarity(row);
switch (pageType) {
case "card": {
const name =
document.querySelector("body > main > h1")?.innerText?.trim() ??
"Unknown";
item = { name, set };
if (rowName && rowName !== name) {
// If rowName and name both end with Redeemable Set, remove
// "Redeemable Set" from rowName since it'll already be in the name.
if (
rowName.endsWith(" Redeemable Set") &&
name.endsWith(" Redeemable Set")
) {
item.frame = rowName.slice(0, -15);
} else {
item.frame = rowName?.split("(")[0]?.trim();
}
}
if (rarity) item.rarity = rarity;
item.wanted = -1;
item.id = id;
break;
}
case "set":
default: {
const section = row.parentElement.previousElementSibling;
const sectionName =
(section.classList.contains("next-frame") &&
section?.innerText?.trim()) ||
"";
const cardType = document.getElementById("cardtype-input")?.placeholder;
let frame = sectionName?.match(/(.*) Cards/)?.[1]?.trim() ?? "";
let name = rowName;
if (cardType && name?.startsWith(cardType)) {
// we may need to remove Foil from the card name and add it to the
// frame, e.g. cardType is "Foil" and rowName is "Foil Ragavan". If
// card type is "Regular", the row name won't include it, so we don't
// need to do anything.
name = name.slice(cardType.length).trim();
frame = `${cardType} ${frame}`.trim();
}
item = { name, set };
if (frame) item.frame = frame;
if (rarity) item.rarity = rarity;
item.wanted = -1;
item.id = id;
break;
}
}
return item;
}
/**
* For a given string, return an array of Card objects.
* @param {string} string The string to parse.
* @param {boolean} log Whether to log errors.
* @returns {Card[]} The cards parsed from the string.
*/
getCardsFromString(string, log = true) {
/** @type {Card[]} */
let cards = [];
try {
cards = this.fromString(string);
} catch (error) {
if (log) {
this.logger.warn(
"GoatBots Card Watcher: invalid cards value :>> ",
error
);
}
}
return cards;
}
/**
* Try to find up to two random cards on the page to use as examples, e.g. for
* the placeholder text in the card list editor. If no cards are found, return