kahoot-cli 1.0.0__py3-none-any.whl
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.
- kahoot_cli/__init__.py +11 -0
- kahoot_cli/__main__.py +413 -0
- kahoot_cli/kahoot_cli.py +637 -0
- kahoot_cli-1.0.0.dist-info/METADATA +38 -0
- kahoot_cli-1.0.0.dist-info/RECORD +8 -0
- kahoot_cli-1.0.0.dist-info/WHEEL +5 -0
- kahoot_cli-1.0.0.dist-info/licenses/LICENSE +674 -0
- kahoot_cli-1.0.0.dist-info/top_level.txt +1 -0
kahoot_cli/kahoot_cli.py
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
# Used by module
|
|
2
|
+
from selenium import webdriver
|
|
3
|
+
from selenium.webdriver.chrome.options import Options
|
|
4
|
+
from selenium.webdriver.common.by import By
|
|
5
|
+
import time
|
|
6
|
+
# Used by CLI
|
|
7
|
+
from termcolor import colored
|
|
8
|
+
import os
|
|
9
|
+
from readchar import readkey, key
|
|
10
|
+
import random
|
|
11
|
+
|
|
12
|
+
def cls(): # Source - https://stackoverflow.com/a/684344
|
|
13
|
+
os.system('cls' if os.name=='nt' else 'clear')
|
|
14
|
+
|
|
15
|
+
class InvalidPinError(Exception):
|
|
16
|
+
"""Exception raised when the provided pin did not correspond to any current game, or link provided is not a link to kahoot.it"""
|
|
17
|
+
|
|
18
|
+
class InvalidNameError(Exception):
|
|
19
|
+
"""Exception raised when the provided nickname was deemed invalid. This can be due to 3 reasons:
|
|
20
|
+
- The nickname was empty (This does not apply if a random name generator is used.)
|
|
21
|
+
- The nickname was longer than 15 characters.
|
|
22
|
+
- The nickname was taken."""
|
|
23
|
+
|
|
24
|
+
class InvalidFunctionUsage(Exception):
|
|
25
|
+
"""Exception raised when a function was improperly used.
|
|
26
|
+
Example: Using Namerator() without a name generator being present."""
|
|
27
|
+
|
|
28
|
+
class NameratorError(Exception):
|
|
29
|
+
"""Errors related to the random name generator function. These are usually:
|
|
30
|
+
- You haven't rolled at least once
|
|
31
|
+
- You have zero rolls left"""
|
|
32
|
+
|
|
33
|
+
class KahootClient():
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
"""A KahootClient() object, used to interact with the actuall Kahoot Client.
|
|
36
|
+
"""
|
|
37
|
+
return
|
|
38
|
+
def JoinGame(self, pin: int | str) -> str:
|
|
39
|
+
# Note: The pin can also be the kahoot.it invitation website. It will only accept a string if it contains "https://kahoot.it"
|
|
40
|
+
"""The joining function. Joins a game, and waits for a nickname.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
pin (int | str): The pin to the corresponding game. This can also be a URL.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
InvalidPinError: If the provided pin was not a valid link, or it did not lead to a valid session.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
str: "ok" if the pin was correct, and the client is currently waiting for a nickname
|
|
50
|
+
Note: It can also return "namerator" if a name generator is present.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
self.pin = pin
|
|
54
|
+
|
|
55
|
+
# Set up and launch selenium driver
|
|
56
|
+
options = Options()
|
|
57
|
+
optionsToAdd = ["--disable-component-extensions-with-background-pages", "--disable-default-apps", "--disable-extensions",
|
|
58
|
+
"--disable-features=InterestFeedContentSuggestions", "--disable-features=Translate", "--mute-audio",
|
|
59
|
+
"--no-default-browser-check", "--no-first-run", "--ash-no-nudges", "--disable-search-engine-choice-screen",
|
|
60
|
+
"--propagate-iph-for-testing", "--disable-back-forward-cache", "--disable-features=BackForwardCache",
|
|
61
|
+
"--disable-features=HeavyAdPrivacyMitigations", "--no-process-per-site", "--disable-background-networking",
|
|
62
|
+
"--disable-component-update", "--disable-domain-reliability", "--disable-features=AutofillServerCommunication",
|
|
63
|
+
"--disable-features=CertificateTransparencyComponentUpdater", "--disable-sync", "--metrics-recording-only",
|
|
64
|
+
"--disable-features=OptimizationHints", "--single-process", "--headless=new"]
|
|
65
|
+
for arg in optionsToAdd:
|
|
66
|
+
options.add_argument(arg)
|
|
67
|
+
global _driver
|
|
68
|
+
_driver = webdriver.Chrome(options=options)
|
|
69
|
+
_driver.implicitly_wait(0.5)
|
|
70
|
+
|
|
71
|
+
# Get webpage and join session
|
|
72
|
+
if type(pin) == str:
|
|
73
|
+
if "kahoot.it" in self.pin:
|
|
74
|
+
_driver.get(self.pin)
|
|
75
|
+
else:
|
|
76
|
+
raise InvalidPinError("The provided link does not lead to kahoot.it")
|
|
77
|
+
elif type(pin) == int:
|
|
78
|
+
_driver.get("https://kahoot.it/")
|
|
79
|
+
time.sleep(0.5)
|
|
80
|
+
pin_box = _driver.find_element(by=By.NAME, value="gameId")
|
|
81
|
+
pin_submit = _driver.find_element(By.CSS_SELECTOR, ("button[type='submit']"))
|
|
82
|
+
|
|
83
|
+
pin_box.send_keys(self.pin)
|
|
84
|
+
pin_submit.click()
|
|
85
|
+
|
|
86
|
+
time.sleep(1.5)
|
|
87
|
+
# Check if pin led to a valid session, raise InvalidPinError if not
|
|
88
|
+
if _driver.current_url == "https://kahoot.it/join" or _driver.current_url == "https://kahoot.it/namerator":
|
|
89
|
+
pass
|
|
90
|
+
else:
|
|
91
|
+
raise InvalidPinError(f"The pin ({self.pin}) does not lead to any current game.")
|
|
92
|
+
|
|
93
|
+
if _driver.current_url == "https://kahoot.it/join":
|
|
94
|
+
return "ok"
|
|
95
|
+
|
|
96
|
+
elif _driver.current_url == "https://kahoot.it/namerator":
|
|
97
|
+
return "namerator"
|
|
98
|
+
|
|
99
|
+
time.sleep(1)
|
|
100
|
+
# Set language to English, to avoid missing elements due to language differences
|
|
101
|
+
lang = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='kahoot-settings__language-picker']")
|
|
102
|
+
lang.click()
|
|
103
|
+
langEN = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='option-with-value-en']")
|
|
104
|
+
langEN.click()
|
|
105
|
+
|
|
106
|
+
def SetNickname(self, nickname: str) -> str:
|
|
107
|
+
"""Sets the nickname. This can only be used if JoinGame() was successful and no name generator is present
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
nickname (str): The desired nickname
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
InvalidFunctionUsage: If this function was called without being able to set a nickname
|
|
114
|
+
InvalidNameError: If the provided nickname is invalid (see InvalidNameError exception for reasons why).
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
str: Basically a status code. It'll return **"ok"** if the nickname was valid, **"twoauth"** if 2FA is needed, **"started"** if the game joined has already started, and if the name was changed, it'll return the new name.
|
|
118
|
+
"""
|
|
119
|
+
if _driver.current_url != "https://kahoot.it/join":
|
|
120
|
+
raise InvalidFunctionUsage("SetNickname() was called without being able to set a nickname.")
|
|
121
|
+
if len(nickname) > 15:
|
|
122
|
+
raise InvalidNameError("The nickname provided is longer than 15 characters.")
|
|
123
|
+
elif nickname == "":
|
|
124
|
+
raise InvalidNameError("The nickname provided is empty.")
|
|
125
|
+
self.nickname = nickname
|
|
126
|
+
name_box = _driver.find_element(by=By.NAME, value="nickname")
|
|
127
|
+
name_submit = _driver.find_element(By.CSS_SELECTOR, ("button[type='submit']"))
|
|
128
|
+
|
|
129
|
+
name_box.send_keys(self.nickname)
|
|
130
|
+
name_submit.click()
|
|
131
|
+
time.sleep(1.5)
|
|
132
|
+
if _driver.current_url == "https://kahoot.it/join":
|
|
133
|
+
try:
|
|
134
|
+
error_name_box = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='duplicate-name-error-notification']") # This variable is not made to be accessed, it was just made to check if the error box is there or not
|
|
135
|
+
except:
|
|
136
|
+
pass
|
|
137
|
+
else:
|
|
138
|
+
raise InvalidNameError("The provided nickname was taken.")
|
|
139
|
+
elif _driver.current_url == "https://kahoot.it/instructions":
|
|
140
|
+
nicknameElement = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='nickname']")
|
|
141
|
+
if nicknameElement.text != nickname:
|
|
142
|
+
self.nickname = nicknameElement.text
|
|
143
|
+
return nicknameElement.text
|
|
144
|
+
else:
|
|
145
|
+
return "ok"
|
|
146
|
+
elif _driver.current_url == "https://kahoot.it/twoauth":
|
|
147
|
+
return "twoauth"
|
|
148
|
+
elif _driver.current_url == "https://kahoot.it/gameblock":
|
|
149
|
+
return "started"
|
|
150
|
+
def Namerator(self, roll: bool = False, keep: bool = False) -> str:
|
|
151
|
+
"""A function to interact with the random name generator, if there's one
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
roll (bool, optional): If it should roll a new nickname. Defaults to False.
|
|
155
|
+
keep (bool, optional): If it should select the current nickname. Defaults to False.
|
|
156
|
+
Note: These arguments are **exclusive**, meaning they cannot be used with each other.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
InvalidFunctionUsage: If this function was called without a name generator present, or both arguments are used.
|
|
160
|
+
NameratorError: Errors related to the random name generator.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
str: The current nickname.
|
|
164
|
+
Note: This will also be returned if "keep" is True.
|
|
165
|
+
Note:
|
|
166
|
+
This function keeps track of the remaining rolls by itself. You can access this by using "self.namerator_spins" You have a total of 3 rolls.
|
|
167
|
+
"""
|
|
168
|
+
if _driver.current_url != "https://kahoot.it/namerator":
|
|
169
|
+
raise InvalidFunctionUsage("Namerator() was used without a random name generator.")
|
|
170
|
+
try:
|
|
171
|
+
if _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='namerator-continue-button']") == "": # Basically check if the user has rolled before, without the user knowing
|
|
172
|
+
pass
|
|
173
|
+
except:
|
|
174
|
+
global namerator_spins
|
|
175
|
+
namerator_spins = 3
|
|
176
|
+
self.namerator_spins = namerator_spins
|
|
177
|
+
else:
|
|
178
|
+
nickname = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='name-spinner-selected-name']")
|
|
179
|
+
name_submit = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='namerator-continue-button']")
|
|
180
|
+
finally:
|
|
181
|
+
try:
|
|
182
|
+
spin_button = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='namerator-spin-button']")
|
|
183
|
+
except:
|
|
184
|
+
pass
|
|
185
|
+
if roll == True and keep == True:
|
|
186
|
+
raise InvalidFunctionUsage("Namerator() was called with both flags set to True.")
|
|
187
|
+
elif roll == True:
|
|
188
|
+
if namerator_spins > 0:
|
|
189
|
+
namerator_spins -= 1
|
|
190
|
+
spin_button.click()
|
|
191
|
+
while True:
|
|
192
|
+
try:
|
|
193
|
+
if namerator_spins > 0:
|
|
194
|
+
spin_button = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='namerator-spin-button']")
|
|
195
|
+
nickname = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='name-spinner-selected-name']")
|
|
196
|
+
else:
|
|
197
|
+
name_submit = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='namerator-continue-button']")
|
|
198
|
+
nickname = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='name-spinner-selected-name']")
|
|
199
|
+
except:
|
|
200
|
+
pass
|
|
201
|
+
else:
|
|
202
|
+
self.nickname = nickname.text
|
|
203
|
+
return nickname.text
|
|
204
|
+
else:
|
|
205
|
+
raise NameratorError("Tried to roll with 0 rolls left.")
|
|
206
|
+
elif keep == True:
|
|
207
|
+
try:
|
|
208
|
+
if _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='namerator-continue-button']") == "": # Basically check if the user has rolled before, without the user knowing
|
|
209
|
+
pass
|
|
210
|
+
except:
|
|
211
|
+
raise NameratorError("Tried to keep a username without rolling first.")
|
|
212
|
+
else:
|
|
213
|
+
name_submit = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='namerator-continue-button']")
|
|
214
|
+
self.nickname = nickname.text
|
|
215
|
+
name_submit.click()
|
|
216
|
+
return nickname
|
|
217
|
+
def TwoAuth(self, order: list) -> bool:
|
|
218
|
+
"""A function to interact with the 2FA stage, if there is one.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
order (list): A 4 item list, containing the code. The code should be:
|
|
222
|
+
- "b" for blue
|
|
223
|
+
- "r" for red
|
|
224
|
+
- "g" for green
|
|
225
|
+
- "y" for yellow.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
InvalidFunctionUsage: If function was called without a random name generator, or "order" doesn't follow the ettiquete above.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
bool: Returns "True" if the code was correct, and "False" if it wasn't.
|
|
232
|
+
"""
|
|
233
|
+
if _driver.current_url != "https://kahoot.it/twoauth":
|
|
234
|
+
raise InvalidFunctionUsage("TwoAuth function was called without 2FA needed.")
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
if red_auth == "":
|
|
238
|
+
print("", end="\r") # This is supposed to check if red_auth exists, AKA if it's the first time inputting the code
|
|
239
|
+
except NameError:
|
|
240
|
+
red_auth = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='two-factor-cards__triangle-button']")
|
|
241
|
+
blue_auth = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='two-factor-cards__diamond-button']")
|
|
242
|
+
yellow_auth = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='two-factor-cards__circle-button']")
|
|
243
|
+
green_auth = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='two-factor-cards__square-button']")
|
|
244
|
+
combo = order
|
|
245
|
+
while True:
|
|
246
|
+
if len(combo) > 4:
|
|
247
|
+
raise InvalidFunctionUsage("The provided code is invalid.")
|
|
248
|
+
else:
|
|
249
|
+
for char in combo:
|
|
250
|
+
if char not in ["r", "b", "y", "g", "R", "B", "Y", "G"]:
|
|
251
|
+
raise InvalidFunctionUsage("The provided code is invalid.")
|
|
252
|
+
if len(combo) == 4: # Not the best solution, but I guess it works?
|
|
253
|
+
break
|
|
254
|
+
for char in combo:
|
|
255
|
+
if char == "r" or char == "R":
|
|
256
|
+
red_auth.click()
|
|
257
|
+
time.sleep(0.25)
|
|
258
|
+
elif char == "b" or char == "B":
|
|
259
|
+
blue_auth.click()
|
|
260
|
+
time.sleep(0.25)
|
|
261
|
+
elif char == "y" or char == "Y":
|
|
262
|
+
yellow_auth.click()
|
|
263
|
+
time.sleep(0.25)
|
|
264
|
+
elif char == "g" or char == "G":
|
|
265
|
+
green_auth.click()
|
|
266
|
+
time.sleep(0.25)
|
|
267
|
+
time.sleep(0.5)
|
|
268
|
+
if _driver.current_url == "https://kahoot.it/twoauth":
|
|
269
|
+
return False
|
|
270
|
+
elif _driver.current_url == "https://kahoot.it/instructions":
|
|
271
|
+
return True
|
|
272
|
+
def ClientState(self, fast: bool = False) -> str:
|
|
273
|
+
"""Returns where the client is (a question, podium, results, etc.). Note: The function waits 1 second before returning. This is to account for loading times. (This can be skipped by setting fast to True)
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
str: Returns:
|
|
277
|
+
- "Quiz" if in a question
|
|
278
|
+
- "Start" if the game is just starting
|
|
279
|
+
- "Res" if it's on the result page (of a question)
|
|
280
|
+
- "End" if it's in the end of the game
|
|
281
|
+
- "Wait" if it's on the question loading screen
|
|
282
|
+
- "Lobby" if the game hasn't started yet
|
|
283
|
+
- "Out" if you've been kicked out
|
|
284
|
+
- "TwoAuth" if 2FA is required
|
|
285
|
+
- "Namerator" if there's a random name generator present
|
|
286
|
+
"""
|
|
287
|
+
if fast == False:
|
|
288
|
+
time.sleep(1)
|
|
289
|
+
if _driver.current_url == "https://kahoot.it/gameblock":
|
|
290
|
+
return "Quiz"
|
|
291
|
+
elif _driver.current_url == "https://kahoot.it/answer/result":
|
|
292
|
+
return "Res"
|
|
293
|
+
elif _driver.current_url == "https://kahoot.it/instructions":
|
|
294
|
+
return "Lobby"
|
|
295
|
+
elif _driver.current_url == "https://kahoot.it/":
|
|
296
|
+
return "Out"
|
|
297
|
+
elif _driver.current_url == "https://kahoot.it/getready":
|
|
298
|
+
return "Wait"
|
|
299
|
+
elif _driver.current_url == "https://kahoot.it/ranking":
|
|
300
|
+
return "End"
|
|
301
|
+
elif _driver.current_url == "https://kahoot.it/twoauth":
|
|
302
|
+
return "TwoAuth"
|
|
303
|
+
elif _driver.current_url == "https://kahoot.it/start":
|
|
304
|
+
return "Start"
|
|
305
|
+
elif _driver.current_url == "https://kahoot.it/namerator":
|
|
306
|
+
return "Namerator"
|
|
307
|
+
def GetQuestion(self) -> list:
|
|
308
|
+
"""Returns the current question type, title, and answers, if possible.
|
|
309
|
+
|
|
310
|
+
Raises:
|
|
311
|
+
InvalidFunctionUsage: If function was called outside a question
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
list: A list of ["the question type", "the question title", question number (as an int), "top left answer", "top right answer", "bottom left answer", "bottom right answer"]
|
|
315
|
+
|
|
316
|
+
The question types are:
|
|
317
|
+
- "Quiz" for a regular quiz
|
|
318
|
+
- "Multi" for a multiple choice quiz
|
|
319
|
+
- "TorF" for a True or False quiz (This is separated from "Quiz" because in True or False, red and blue are swapped)
|
|
320
|
+
|
|
321
|
+
Note: If the host hid the options to users, it'll return ["the question type", "", question number (as an int), "Red triangle", "Blue diamond", "Yellow circle", "Green square"]
|
|
322
|
+
If it's a true or false question (with the answers hidden to users) it'll return ["(true or false)", "", "Blue diamond", "Red triangle"]
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
if _driver.current_url != "https://kahoot.it/gameblock":
|
|
326
|
+
raise InvalidFunctionUsage("GetQuestion() was called outside a question.")
|
|
327
|
+
|
|
328
|
+
# Question type identificator thingy
|
|
329
|
+
try:
|
|
330
|
+
question_type = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='question-type-heading-trueOrFalseTitle']")
|
|
331
|
+
except:
|
|
332
|
+
question_type = "Quiz"
|
|
333
|
+
else:
|
|
334
|
+
question_type = "TorF"
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
submit_multi = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='multi-select-submit-button']") # This variable is not supposed to be used *here*, it should just be used to check the question type
|
|
338
|
+
except:
|
|
339
|
+
pass
|
|
340
|
+
else:
|
|
341
|
+
question_type = "Multi"
|
|
342
|
+
|
|
343
|
+
# Title identification thingy
|
|
344
|
+
try:
|
|
345
|
+
question_title = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='block-title']")
|
|
346
|
+
except:
|
|
347
|
+
question_title = ""
|
|
348
|
+
|
|
349
|
+
# Red and blue options identificator
|
|
350
|
+
ans_red = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='answer-0']")
|
|
351
|
+
ans_blue = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='answer-1']")
|
|
352
|
+
|
|
353
|
+
# Check if yellow option exists
|
|
354
|
+
try:
|
|
355
|
+
ans_yellow = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='answer-2']")
|
|
356
|
+
amountOptions = 2
|
|
357
|
+
except:
|
|
358
|
+
ans_yellow = ""
|
|
359
|
+
amountOptions = 1
|
|
360
|
+
|
|
361
|
+
# Check if green option exists
|
|
362
|
+
try:
|
|
363
|
+
ans_green = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='answer-3']")
|
|
364
|
+
amountOptions = 3
|
|
365
|
+
except:
|
|
366
|
+
ans_green = ""
|
|
367
|
+
if ans_yellow != "":
|
|
368
|
+
amountOptions = 2
|
|
369
|
+
else:
|
|
370
|
+
amountOptions = 1
|
|
371
|
+
|
|
372
|
+
# Checking question number
|
|
373
|
+
question_no = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='question-index-counter']")
|
|
374
|
+
question_no = int(question_no.text)
|
|
375
|
+
|
|
376
|
+
# Returns
|
|
377
|
+
if question_title == "":
|
|
378
|
+
if amountOptions == 3:
|
|
379
|
+
return [question_type, "", question_no, ans_red.text, ans_blue.text, ans_yellow.text, ans_green.text]
|
|
380
|
+
elif amountOptions == 2:
|
|
381
|
+
return [question_type, "", question_no, ans_red.text, ans_blue.text, ans_yellow.text]
|
|
382
|
+
elif amountOptions == 1:
|
|
383
|
+
return [question_type, "", question_no, ans_red.text, ans_blue.text]
|
|
384
|
+
else:
|
|
385
|
+
if amountOptions == 3:
|
|
386
|
+
return [question_type, question_title.text, question_no, ans_red.text, ans_blue.text, ans_yellow.text, ans_green.text]
|
|
387
|
+
elif amountOptions == 2:
|
|
388
|
+
return [question_type, question_title.text, question_no, ans_red.text, ans_blue.text, ans_yellow.text]
|
|
389
|
+
elif amountOptions == 1:
|
|
390
|
+
return [question_type, question_title.text, question_no, ans_red.text, ans_blue.text]
|
|
391
|
+
def AnswerQuestion(self, options: int | list):
|
|
392
|
+
"""Answers the current question.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
options (int | list): The index of the desired response, or a list in the case of multiple choice questions.
|
|
396
|
+
0 for top right, 1 for top left, 2 for bottom right, and 3 for bottom left.
|
|
397
|
+
**Note**: For a regular quiz or true or false quiz, an interger is required. On the other hand, a multiple choice quiz requires a list, even if it's for one element.
|
|
398
|
+
|
|
399
|
+
Raises:
|
|
400
|
+
InvalidFunctionUsage: If the function was called outside a question, or the question was called incorrectly (for example, a list for a single choice question)
|
|
401
|
+
"""
|
|
402
|
+
if _driver.current_url != "https://kahoot.it/gameblock":
|
|
403
|
+
raise InvalidFunctionUsage("AnswerQuestion() was called outside a question.")
|
|
404
|
+
|
|
405
|
+
# Question type identificator thingy
|
|
406
|
+
try:
|
|
407
|
+
submit_multi = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='multi-select-submit-button']")
|
|
408
|
+
except:
|
|
409
|
+
try:
|
|
410
|
+
question_type = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='question-type-heading-trueOrFalseTitle']")
|
|
411
|
+
except:
|
|
412
|
+
question_type = "Quiz"
|
|
413
|
+
else:
|
|
414
|
+
question_type = "TorF"
|
|
415
|
+
else:
|
|
416
|
+
question_type = "Multi"
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# Options identificator thingy
|
|
421
|
+
if type(options) == int:
|
|
422
|
+
if 0 == options:
|
|
423
|
+
ans_red = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='answer-0']")
|
|
424
|
+
elif 1 == options:
|
|
425
|
+
ans_blue = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='answer-1']")
|
|
426
|
+
|
|
427
|
+
elif 2 == options:
|
|
428
|
+
# Check if yellow option exists
|
|
429
|
+
try:
|
|
430
|
+
ans_yellow = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='answer-2']")
|
|
431
|
+
except:
|
|
432
|
+
ans_yellow = ""
|
|
433
|
+
|
|
434
|
+
elif 3 == options:
|
|
435
|
+
# Check if green option exists
|
|
436
|
+
try:
|
|
437
|
+
ans_green = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='answer-3']")
|
|
438
|
+
except:
|
|
439
|
+
ans_green = ""
|
|
440
|
+
elif type(options) == list:
|
|
441
|
+
if 0 in options:
|
|
442
|
+
ans_red = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='answer-0']")
|
|
443
|
+
elif 1 in options:
|
|
444
|
+
ans_blue = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='answer-1']")
|
|
445
|
+
|
|
446
|
+
elif 2 in options:
|
|
447
|
+
# Check if yellow option exists
|
|
448
|
+
try:
|
|
449
|
+
ans_yellow = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='answer-2']")
|
|
450
|
+
except:
|
|
451
|
+
ans_yellow = ""
|
|
452
|
+
|
|
453
|
+
elif 3 in options:
|
|
454
|
+
# Check if green option exists
|
|
455
|
+
try:
|
|
456
|
+
ans_green = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='answer-3']")
|
|
457
|
+
except:
|
|
458
|
+
ans_green = ""
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
if type(options) == list and question_type == "Quiz":
|
|
462
|
+
raise InvalidFunctionUsage("A list was used to define the options of a regular quiz.")
|
|
463
|
+
elif type(options) == list and question_type == "TorF":
|
|
464
|
+
raise InvalidFunctionUsage("A list was used to define the options of a True or False quiz.")
|
|
465
|
+
elif type(options) == int and question_type == "Multi":
|
|
466
|
+
raise InvalidFunctionUsage("An interger was used to define the options of a Multiple Choice quiz.")
|
|
467
|
+
|
|
468
|
+
if _driver.current_url == "https://kahoot.it/answer/result":
|
|
469
|
+
return
|
|
470
|
+
if question_type == "Quiz" or question_type == "TorF":
|
|
471
|
+
if options == 0:
|
|
472
|
+
ans_red.click()
|
|
473
|
+
elif options == 1:
|
|
474
|
+
ans_blue.click()
|
|
475
|
+
elif options == 2 and ans_yellow != "":
|
|
476
|
+
ans_yellow.click()
|
|
477
|
+
elif options == 3 and ans_green != "":
|
|
478
|
+
ans_green.click()
|
|
479
|
+
return
|
|
480
|
+
elif question_type == "Multi":
|
|
481
|
+
if 0 in options:
|
|
482
|
+
ans_red.click()
|
|
483
|
+
if 1 in options:
|
|
484
|
+
ans_blue.click()
|
|
485
|
+
if 2 in options and ans_yellow != "":
|
|
486
|
+
ans_yellow.click()
|
|
487
|
+
if 3 in options and ans_green != "":
|
|
488
|
+
ans_green.click()
|
|
489
|
+
submit_multi.click()
|
|
490
|
+
return
|
|
491
|
+
def GetResult(self) -> str | list:
|
|
492
|
+
"""Checks the result of the latest question, and returns the result and points (if possible).
|
|
493
|
+
|
|
494
|
+
Raises:
|
|
495
|
+
InvalidFunctionUsage: If the function was called outside the results page.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
str|list: If you haven't won, it'll return a string. It will be either:
|
|
499
|
+
- "Lose" (if you've lost)
|
|
500
|
+
- "NoTime" (if you ran out of time)
|
|
501
|
+
However, if you have won, it will return a list, containing: ["Win", "+(the amount of points you've gained)"]
|
|
502
|
+
(If it's partially correct, it'll return ["(correct)/(total)", "+(the amount of points you gained"])
|
|
503
|
+
"""
|
|
504
|
+
if _driver.current_url != "https://kahoot.it/answer/result":
|
|
505
|
+
raise InvalidFunctionUsage("GetResult() was called outside the results page.")
|
|
506
|
+
try:
|
|
507
|
+
ranOutOfTime = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='time-up-answer']")
|
|
508
|
+
except:
|
|
509
|
+
ranOutOfTime = False
|
|
510
|
+
else:
|
|
511
|
+
ranOutOfTime = True
|
|
512
|
+
if ranOutOfTime == False:
|
|
513
|
+
result_logo = _driver.find_element(By.CSS_SELECTOR, "circle[cx='40']")
|
|
514
|
+
if result_logo.get_attribute("fill") == "#66BF39":
|
|
515
|
+
result = "Win"
|
|
516
|
+
points_increment = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='score-increment']")
|
|
517
|
+
elif result_logo.get_attribute("fill") == "#F35":
|
|
518
|
+
result = "Lose"
|
|
519
|
+
else:
|
|
520
|
+
result_logo = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='partial-correct-answer']")
|
|
521
|
+
result = result_logo.text
|
|
522
|
+
final_res = ""
|
|
523
|
+
temp_res = ""
|
|
524
|
+
save_line = False
|
|
525
|
+
for char in result:
|
|
526
|
+
temp_res += char
|
|
527
|
+
if char == "/" and final_res == "":
|
|
528
|
+
save_line = True
|
|
529
|
+
if char == "\n" and save_line == True:
|
|
530
|
+
final_res = temp_res
|
|
531
|
+
break
|
|
532
|
+
elif char == "\n" and save_line == False:
|
|
533
|
+
temp_res = ""
|
|
534
|
+
final_res = final_res.replace("\n", "")
|
|
535
|
+
result = final_res
|
|
536
|
+
if result == "Lose" or result == "NoTime":
|
|
537
|
+
return result
|
|
538
|
+
else:
|
|
539
|
+
return [result, points_increment.text]
|
|
540
|
+
else:
|
|
541
|
+
return "NoTime"
|
|
542
|
+
def GetLeaderboardPos(self) -> list:
|
|
543
|
+
"""Returns the current leaderboard rank, and nemesis (if possible)
|
|
544
|
+
|
|
545
|
+
Raises:
|
|
546
|
+
InvalidFunctionUsage: Raised when this function was called outside the results page.
|
|
547
|
+
|
|
548
|
+
Returns:
|
|
549
|
+
list: If the user is not in the podium, it'll return [(position as int), "x points behind nemesis"].
|
|
550
|
+
If the user IS in the podium, it'll return ["Podium"]
|
|
551
|
+
"""
|
|
552
|
+
if _driver.current_url != "https://kahoot.it/answer/result":
|
|
553
|
+
raise InvalidFunctionUsage("GetLeaderboardPos() was called outside the results page.")
|
|
554
|
+
|
|
555
|
+
leaderboard_pos = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='player-rank']")
|
|
556
|
+
try:
|
|
557
|
+
nemesis = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='nemesis']")
|
|
558
|
+
except:
|
|
559
|
+
nemesis = ""
|
|
560
|
+
else:
|
|
561
|
+
nemesis_text = nemesis.text
|
|
562
|
+
leaderboard_pos_int = ""
|
|
563
|
+
for char in nemesis_text:
|
|
564
|
+
nemesis_text = nemesis_text.replace(char, "", 1)
|
|
565
|
+
if char == "\n":
|
|
566
|
+
break
|
|
567
|
+
for char in leaderboard_pos.text:
|
|
568
|
+
if char in ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]:
|
|
569
|
+
leaderboard_pos_int += char
|
|
570
|
+
try:
|
|
571
|
+
leaderboard_pos_int = int(leaderboard_pos_int)
|
|
572
|
+
except:
|
|
573
|
+
pass
|
|
574
|
+
if nemesis != "":
|
|
575
|
+
return [leaderboard_pos_int, nemesis_text]
|
|
576
|
+
else:
|
|
577
|
+
return ["Podium"]
|
|
578
|
+
def GetPoints(self) -> int:
|
|
579
|
+
"""Returns the total amount of points.
|
|
580
|
+
_(for returning the point increment, see GetResult())_
|
|
581
|
+
|
|
582
|
+
Raises:
|
|
583
|
+
InvalidFunctionUsage: If this function was called outside a game.
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
int: The total amount of points.
|
|
587
|
+
"""
|
|
588
|
+
if _driver.current_url in ["https://kahoot.it/", "https://kahoot.it/join"]:
|
|
589
|
+
raise InvalidFunctionUsage("GetPoints() was called outside a game.")
|
|
590
|
+
total_points_element = _driver.find_element(By.CSS_SELECTOR, "[data-functional-selector='bottom-bar-score']")
|
|
591
|
+
total_points = int(total_points_element.text)
|
|
592
|
+
return total_points
|
|
593
|
+
def GetFinalRanking(self) -> int:
|
|
594
|
+
"""Returns the final ranking, as an interger.
|
|
595
|
+
- If the position is not in the podium, it will return a number from 4 to the total amount of players (inclusive)
|
|
596
|
+
- If the position is in the podium, but hasn't been shown yet it'll return 0.
|
|
597
|
+
- If the position is in the podium, and has been shown, it'll return a number from 1 to 3 (inclusive)
|
|
598
|
+
|
|
599
|
+
Raises:
|
|
600
|
+
InvalidFunctionUsage: If the function was called outside the ranking phase
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
int: The ranking.
|
|
604
|
+
"""
|
|
605
|
+
if _driver.current_url != "https://kahoot.it/ranking":
|
|
606
|
+
raise InvalidFunctionUsage("GetFinalRanking() was called outside ranking phase.")
|
|
607
|
+
try:
|
|
608
|
+
non_podium = _driver.find_element(By.TAG_NAME, "text")
|
|
609
|
+
except:
|
|
610
|
+
non_podium = False
|
|
611
|
+
|
|
612
|
+
if non_podium != False:
|
|
613
|
+
ranking = non_podium.text
|
|
614
|
+
final_str = ""
|
|
615
|
+
for char in ranking:
|
|
616
|
+
if char in ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]:
|
|
617
|
+
final_str += char
|
|
618
|
+
else:
|
|
619
|
+
pass
|
|
620
|
+
return final_str
|
|
621
|
+
else:
|
|
622
|
+
try:
|
|
623
|
+
podiumPlace = _driver.find_element(By.CSS_SELECTOR, "path[fill='#FFC00A']")
|
|
624
|
+
except:
|
|
625
|
+
try:
|
|
626
|
+
podiumPlace = _driver.find_element(By.CSS_SELECTOR, "path[fill='#CCC']")
|
|
627
|
+
except:
|
|
628
|
+
try:
|
|
629
|
+
podiumPlace = _driver.find_element(By.CSS_SELECTOR, "path[fill='#EB670F']")
|
|
630
|
+
except:
|
|
631
|
+
return 0
|
|
632
|
+
else:
|
|
633
|
+
return 3
|
|
634
|
+
else:
|
|
635
|
+
return 2
|
|
636
|
+
else:
|
|
637
|
+
return 1
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kahoot_cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A module/CLI tool to interact with a kahoot client (via Selenium) and play a quiz.
|
|
5
|
+
Author-email: Aquaticsanti <aquaticsanti@gmail.com>
|
|
6
|
+
License-Expression: GPL-3.0-only
|
|
7
|
+
Project-URL: Homepage, https://github.com/Aquaticsanti/kahoot_cli
|
|
8
|
+
Project-URL: Issues, https://github.com/Aquaticsanti/kahoot_cli/issues
|
|
9
|
+
Project-URL: Documentation, https://github.com/Aquaticsanti/kahoot_cli/wiki
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# Kahoot_CLI
|
|
18
|
+
|
|
19
|
+
Interact with any Kahoot quiz right from your terminal! Or import it and use it on your own scripts! The possibilities are endless! (I hope)
|
|
20
|
+
|
|
21
|
+
# Features:
|
|
22
|
+
- [X] Joining a game
|
|
23
|
+
- [ ] Reactions
|
|
24
|
+
- [ ] Avatars
|
|
25
|
+
- [X] Answering single choice questions
|
|
26
|
+
- [X] Answering multiple choice questions
|
|
27
|
+
- [X] Answering True or False questions
|
|
28
|
+
- [X] Displaying points
|
|
29
|
+
- [X] Displaying ranking
|
|
30
|
+
- [X] Finishing a game
|
|
31
|
+
- [X] Error handling (this name is taken, pin is invalid, kicked out, etc.)
|
|
32
|
+
|
|
33
|
+
# Current Bugs:
|
|
34
|
+
- [ ] Won't detect irregular gamemodes (that it can't play)
|
|
35
|
+
|
|
36
|
+
# Why would anyone need this, since this just uses Selenium?
|
|
37
|
+
|
|
38
|
+
Kahoot-CLI is designed in mind of devices that can't show a browser tab, like handheld like devices. With Kahoot-CLI, you could make something like a Kahoot handheld, something you couldn't make before.
|