├── config.json ├── manifest.json └── __init__.py /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "qlts": "", 3 | "cookies": "", 4 | "rich_text_formatting": false 5 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Improved Quizlet to Anki 21 Importer", 3 | "package": "538351043" 4 | } -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | #------------------------------------------------------------------------------- 2 | # 3 | # Name: Quizlet plugin for Anki 2.0 4 | # Purpose: Import decks from Quizlet into Anki 2.0 5 | # Author: 6 | # - Original: (c) Rolph Recto 2012, last updated 12/06/2012 7 | # https://github.com/rolph-recto/Anki-Quizlet 8 | # - Also: Contributions from https://ankiweb.net/shared/info/1236400902 9 | # - Current: JDMaybeMD 10 | # Created: 04/07/2017 11 | # 12 | # Changlog: Inital release 13 | # - Rolph's plugin functionality was broken, so... 14 | # - removed search tables and associated functions to KISS 15 | # - reused the original API key, dunno if that's OK 16 | # - replaced with just one box, for a quizlet URL 17 | # - added basic error handling for dummies 18 | # 19 | # Update 04/09/2017 20 | # - modified to now take a full Quizlet url for ease of use 21 | # - provide feedback if trying to download a private deck 22 | # - return RFC 2616 response codes when error handling 23 | # - don't make a new card type every time a new deck imported 24 | # - better code documentation so people can modify it 25 | # 26 | # Update 01/31/2018 27 | # - get original quality images instead of mobile version 28 | # 29 | # Changlog (by kelciour): 30 | # Update 09/12/2018 31 | # - updated to Anki 2.1 32 | # 33 | # Update 04/02/2020 34 | # - download a set without API key since it's no longer working 35 | # 36 | # Update 19/02/2020 37 | # - download private or password-protected sets using cookies 38 | # 39 | # Update 25/02/2020 40 | # - make it work again by adding the User-Agent header 41 | # 42 | # Update 14/04/2020 43 | # - try to get title from HTML a bit differently 44 | # 45 | # Update 29/04/2020 46 | # - suggest to disable VPN if a set is blocked by a captcha 47 | # 48 | # Update 04/05/2020 49 | # - remove Flashcards from the name of the deck 50 | # - rename and create a new Basic Quizlet note type if some fields doesn't exist 51 | # 52 | # Update 17/05/2020 53 | # - use setPageData and assistantModeData as a possible source for flashcards data 54 | # 55 | # Update 22/07/2020 56 | # - fix for Anki 2.1.28 57 | # 58 | # Update 30/08/2020 59 | # - add Return shortcut 60 | # 61 | # Update 31/08/2020 62 | # - add rich text formatting 63 | # 64 | # Update 03/09/2020 65 | # - make it working again after Quizlet update 66 | 67 | # Update 04/09/2020 68 | # - move the add-on to GitHub 69 | 70 | # Update 17/10/2020 71 | # - added functionality to import multiple urls (with liutiming) 72 | 73 | #------------------------------------------------------------------------------- 74 | #!/usr/bin/env python 75 | 76 | __window = None 77 | 78 | import sys, math, time, urllib.parse, json, re, os 79 | 80 | # Anki 81 | from aqt import mw 82 | from aqt.qt import * 83 | from aqt.utils import showText 84 | from anki.utils import checksum 85 | 86 | import requests 87 | import shutil 88 | 89 | requests.packages.urllib3.disable_warnings() 90 | 91 | headers = { 92 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36" 93 | } 94 | 95 | rich_text_css = """ 96 | :root { 97 | --yellow_light_background: #fff4e5; 98 | --blue_light_background: #cde7fa; 99 | --pink_light_background: #fde8ff; 100 | } 101 | 102 | .nightMode { 103 | --yellow_light_background: #8c7620; 104 | --blue_light_background: #295f87; 105 | --pink_light_background: #7d537f; 106 | } 107 | 108 | .bgY { 109 | background-color: var(--yellow_light_background); 110 | } 111 | 112 | .bgB { 113 | background-color: var(--blue_light_background); 114 | } 115 | 116 | .bgP { 117 | background-color: var(--pink_light_background); 118 | } 119 | """ 120 | 121 | # add custom model if needed 122 | def addCustomModel(name, col): 123 | 124 | # create custom model for imported deck 125 | mm = col.models 126 | existing = mm.byName("Basic Quizlet") 127 | if existing: 128 | fields = mm.fieldNames(existing) 129 | if "Front" in fields and "Back" in fields: 130 | return existing 131 | else: 132 | existing['name'] += "-" + checksum(str(time.time()))[:5] 133 | mm.save(existing) 134 | m = mm.new("Basic Quizlet") 135 | 136 | # add fields 137 | mm.addField(m, mm.newField("Front")) 138 | mm.addField(m, mm.newField("Back")) 139 | mm.addField(m, mm.newField("Add Reverse")) 140 | 141 | # add cards 142 | t = mm.newTemplate("Normal") 143 | 144 | # front 145 | t['qfmt'] = "{{Front}}" 146 | t['afmt'] = "{{FrontSide}}\n\n
\n\n{{Back}}" 147 | mm.addTemplate(m, t) 148 | 149 | # back 150 | t = mm.newTemplate("Reverse") 151 | t['qfmt'] = "{{#Add Reverse}}{{Back}}{{/Add Reverse}}" 152 | t['afmt'] = "{{FrontSide}}\n\n
\n\n{{Front}}" 153 | mm.addTemplate(m, t) 154 | 155 | mm.add(m) 156 | return m 157 | 158 | # throw up a window with some info (used for testing) 159 | def debug(message): 160 | QMessageBox.information(QWidget(), "Message", message) 161 | 162 | class QuizletWindow(QWidget): 163 | 164 | # used to access Quizlet API 165 | __APIKEY = "ke9tZw8YM6" 166 | 167 | # main window of Quizlet plugin 168 | def __init__(self): 169 | super(QuizletWindow, self).__init__() 170 | 171 | self.results = None 172 | self.thread = None 173 | self.closed = False 174 | 175 | self.cookies = self.getCookies() 176 | 177 | self.initGUI() 178 | 179 | # create GUI skeleton 180 | def initGUI(self): 181 | 182 | self.box_top = QVBoxLayout() 183 | self.box_upper = QHBoxLayout() 184 | 185 | # left side 186 | self.box_left = QVBoxLayout() 187 | 188 | # quizlet url field 189 | self.box_name = QHBoxLayout() 190 | self.label_url = QLabel("Quizlet URL:") 191 | self.text_url = QLineEdit("",self) 192 | self.text_url.setMinimumWidth(300) 193 | 194 | self.box_name.addWidget(self.label_url) 195 | self.box_name.addWidget(self.text_url) 196 | # parentDeck field 197 | 198 | self.box_parent = QHBoxLayout() 199 | self.label_parentDeck = QLabel("Parent deck name") 200 | self.parentDeck = QLineEdit ("",self) 201 | self.parentDeck.setMinimumWidth(300) 202 | 203 | self.box_parent.addWidget(self.label_parentDeck) 204 | self.box_parent.addWidget(self.parentDeck) 205 | 206 | # add layouts to left 207 | 208 | self.box_left.addLayout(self.box_name) 209 | self.box_left.addLayout(self.box_parent) 210 | # right side 211 | self.box_right = QVBoxLayout() 212 | 213 | # code (import set) button 214 | self.box_code = QHBoxLayout() 215 | self.button_code = QPushButton("Import Deck", self) 216 | self.button_code.setShortcut(QKeySequence("Return")) 217 | self.box_code.addStretch(1) 218 | self.box_code.addWidget(self.button_code) 219 | self.button_code.clicked.connect(self.onCode) 220 | 221 | # add layouts to right 222 | self.box_right.addLayout(self.box_code) 223 | 224 | # add left and right layouts to upper 225 | self.box_upper.addLayout(self.box_left) 226 | self.box_upper.addSpacing(20) 227 | self.box_upper.addLayout(self.box_right) 228 | 229 | # results label 230 | self.label_results = QLabel("This importer has three use cases: 1. single url; 2. multiple urls on multiple lines and 3. folder.\n Parent deck name can be cutomized. If not provided, it will either use the folder name \n(if a folder url is provided) or save the deck as a first-level deck.\n\n Single url example: https://quizlet.com/515858716/japanese-shops-fruit-flash-cards/") 231 | 232 | # add all widgets to top layout 233 | self.box_top.addLayout(self.box_upper) 234 | self.box_top.addSpacing(10) 235 | self.box_top.addWidget(self.label_results) 236 | self.box_top.addStretch(1) 237 | self.setLayout(self.box_top) 238 | 239 | # go, baby go! 240 | self.setMinimumWidth(500) 241 | self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) 242 | self.setWindowTitle("Improved Quizlet to Anki Importer") 243 | self.resize(self.minimumSizeHint()) 244 | self.show() 245 | 246 | def getCookies(self): 247 | config = mw.addonManager.getConfig(__name__) 248 | 249 | cookies = {} 250 | if config["qlts"]: 251 | cookies = { "qlts": config["qlts"] } 252 | elif config["cookies"]: 253 | from http.cookies import SimpleCookie 254 | C = SimpleCookie() 255 | C.load(config["cookies"]) 256 | cookies = { key: morsel.value for key, morsel in C.items() } 257 | return cookies 258 | 259 | def onCode(self): 260 | parentDeck = self.parentDeck.text() 261 | # grab url input 262 | report = {'error': [], 'success': []} 263 | urls = self.text_url.text().splitlines() 264 | self.label_results.setText(("There are {0} urls in total. Starting".format(len(urls)))) 265 | self.sleep(0.5) 266 | for url in urls: 267 | if not url: 268 | continue 269 | 270 | # voodoo needed for some error handling 271 | if urllib.parse.urlparse(url).scheme: 272 | urlDomain = urllib.parse.urlparse(url).netloc 273 | urlPath = urllib.parse.urlparse(url).path 274 | else: 275 | urlDomain = urllib.parse.urlparse("https://"+url).netloc 276 | urlPath = urllib.parse.urlparse("https://"+url).path 277 | 278 | # validate quizlet URL 279 | if url == "": 280 | self.label_results.setText("Oops! You forgot the deck URL :(") 281 | return 282 | elif not "quizlet.com" in urlDomain: 283 | self.label_results.setText("Oops! That's not a Quizlet URL :(") 284 | return 285 | self.button_code.setEnabled(False) 286 | 287 | if "/folders/" not in url: 288 | self.downloadSet(url, parentDeck) 289 | self.sleep(1.5) 290 | elif "/folders/" in url : 291 | r = requests.get(url, verify=False, headers=headers, cookies=self.cookies) 292 | r.raise_for_status() 293 | 294 | regex = re.escape('window.Quizlet["dashboardData"] = ') 295 | regex += r'(.+?)' 296 | regex += re.escape('; QLoad("Quizlet.dashboardData");') 297 | 298 | m = re.search(regex, r.text) 299 | 300 | data = m.group(1).strip() 301 | results = json.loads(data) 302 | 303 | assert len(results["models"]["folder"]) == 1 304 | 305 | quizletFolder = results["models"]["folder"][0] 306 | setMap = { s["id"]:s for s in results["models"]["set"] } 307 | for folderSet in results["models"]["folderSet"]: 308 | if self.closed: 309 | return 310 | quizletSet = setMap[folderSet["setId"]] 311 | if parentDeck == "": 312 | self.downloadSet(quizletSet["_webUrl"], quizletFolder["name"]) 313 | else: 314 | self.downloadSet(quizletSet["_webUrl"], parentDeck) 315 | self.sleep(1.5) 316 | 317 | self.button_code.setEnabled(True) 318 | 319 | def closeEvent(self, evt): 320 | self.closed = True 321 | evt.accept() 322 | 323 | def sleep(self, seconds): 324 | start = time.time() 325 | while time.time() - start < seconds: 326 | time.sleep(0.01) 327 | QApplication.instance().processEvents() 328 | 329 | def downloadSet(self, urlPath, parentDeck=""): 330 | # validate and set Quizlet deck ID 331 | quizletDeckID = urlPath.strip("/") 332 | if quizletDeckID == "": 333 | self.label_results.setText("Oops! Please use the full deck URL :(") 334 | return 335 | elif not bool(re.search(r'\d', quizletDeckID)): 336 | self.label_results.setText("Oops! No deck ID found in path {0} :(".format(quizletDeckID)) 337 | return 338 | else: # get first set of digits from url path 339 | quizletDeckID = re.search(r"\d+", quizletDeckID).group(0) 340 | 341 | # and aaawaaaay we go... 342 | self.label_results.setText("Connecting to Quizlet...") 343 | 344 | # build URL 345 | # deck_url = ("https://api.quizlet.com/2.0/sets/{0}".format(quizletDeckID)) 346 | # deck_url += ("?client_id={0}".format(QuizletWindow.__APIKEY)) 347 | # deck_url = "https://quizlet.com/{}/flashcards".format(quizletDeckID) 348 | deck_url = urlPath 349 | 350 | # stop previous thread first 351 | # if self.thread is not None: 352 | # self.thread.terminate() 353 | 354 | # download the data! 355 | self.thread = QuizletDownloader(self, deck_url) 356 | self.thread.start() 357 | 358 | while not self.thread.isFinished(): 359 | mw.app.processEvents() 360 | self.thread.wait(50) 361 | 362 | # error fetching data 363 | if self.thread.error: 364 | if self.thread.errorCode == 403: 365 | if self.thread.errorCaptcha: 366 | self.label_results.setText("Sorry, it's behind a captcha. Try to disable VPN") 367 | else: 368 | self.label_results.setText("Sorry, this is a private deck :(") 369 | elif self.thread.errorCode == 404: 370 | self.label_results.setText("Can't find a deck with the ID {0}".format(quizletDeckID)) 371 | else: 372 | self.label_results.setText("Unknown Error") 373 | # errorMessage = json.loads(self.thread.errorMessage) 374 | # showText(json.dumps(errorMessage, indent=4)) 375 | showText(self.thread.errorMessage) 376 | else: # everything went through, let's roll! 377 | deck = self.thread.results 378 | # self.label_results.setText(("Importing deck {0} by {1}...".format(deck["title"], deck["created_by"]))) 379 | self.label_results.setText(("Importing deck {0}...".format(deck["title"]))) 380 | self.createDeck(deck, parentDeck) 381 | # self.label_results.setText(("Success! Imported {0} ({1} cards by {2})".format(deck["title"], deck["term_count"], deck["created_by"]))) 382 | self.label_results.setText(("Success! Imported {0} ({1} cards)".format(deck["title"], deck["term_count"]))) 383 | 384 | # self.thread.terminate() 385 | self.thread = None 386 | 387 | def createDeck(self, result, parentDeck=""): 388 | config = mw.addonManager.getConfig(__name__) 389 | 390 | if config["rich_text_formatting"] and not os.path.exists("_quizlet.css"): 391 | with open("_quizlet.css", "w") as f: 392 | f.write(rich_text_css.lstrip()) 393 | 394 | # create new deck and custom model 395 | if "set" in result: 396 | name = result['set']['title'] 397 | elif "studyable" in result: 398 | name = result['studyable']['title'] 399 | else: 400 | name = result['title'] 401 | 402 | if parentDeck: 403 | name = "{}::{}".format(parentDeck, name) 404 | 405 | if "termIdToTermsMap" in result: 406 | terms = [] 407 | for c in sorted(result['termIdToTermsMap'].values(), key=lambda v: v["rank"]): 408 | terms.append({ 409 | 'word': c['word'], 410 | 'definition': c['definition'], 411 | '_imageUrl': c["_imageUrl"] or '', 412 | 'wordRichText': c.get('wordRichText', ''), 413 | 'definitionRichText': c.get('definitionRichText', ''), 414 | }) 415 | elif "studiableData" in result: 416 | terms = {} 417 | data = result["studiableData"] 418 | for d in data["studiableItems"]: 419 | terms[d["id"]] = {} 420 | smc = {} 421 | for d in data["studiableMediaConnections"]: 422 | id_ = d["connectionModelId"] 423 | if id_ not in smc: 424 | smc[id_] = {} 425 | # "plainText", "languageCode", "ttsUrl", "ttsSlowUrl", "richText" 426 | for k, v in d.get("text", {}).items(): 427 | smc[id_][k] = v 428 | if "image" in d: 429 | smc[id_]["_imageUrl"] = d["image"]["url"] 430 | for d in data["studiableCardSides"]: 431 | id_ = d["studiableItemId"] 432 | terms[id_][d["label"]] = smc[d["id"]].get("plainText", "") 433 | terms[id_]["{}RichText".format(d["label"])] = smc[d["id"]].get("richText", "") 434 | terms[id_]["_imageUrl"] = smc[d["id"]].get("_imageUrl", "") 435 | terms = terms.values() 436 | else: 437 | terms = result['terms'] 438 | 439 | result['term_count'] = len(terms) 440 | 441 | deck = mw.col.decks.get(mw.col.decks.id(name)) 442 | model = addCustomModel(name, mw.col) 443 | 444 | if config["rich_text_formatting"] and ".bgY" not in model["css"]: 445 | model["css"] += rich_text_css 446 | 447 | # assign custom model to new deck 448 | mw.col.decks.select(deck["id"]) 449 | mw.col.decks.save(deck) 450 | 451 | # assign new deck to custom model 452 | mw.col.models.setCurrent(model) 453 | model["did"] = deck["id"] 454 | mw.col.models.save(model) 455 | 456 | def getText(d, text=''): 457 | if d is None: 458 | return text 459 | if d['type'] == 'text': 460 | text = d['text'] 461 | if 'marks' in d: 462 | for m in d['marks']: 463 | if m['type'] in ['b', 'i', 'u']: 464 | text = '<{0}>{1}'.format(m['type'], text) 465 | if 'attrs' in m: 466 | attrs = " ".join(['{}="{}"'.format(k, v) for k, v in m['attrs'].items()]) 467 | text = '{}'.format(attrs, text) 468 | return text 469 | text = ''.join([getText(c) if c else '
' for c in d.get('content', [''])]) 470 | if d['type'] == 'paragraph': 471 | text = '
{}
'.format(text) 472 | return text 473 | 474 | def ankify(text): 475 | text = text.replace('\n','
') 476 | text = re.sub(r'\*(.+?)\*', r'\1', text) 477 | return text 478 | 479 | for term in terms: 480 | note = mw.col.newNote() 481 | note["Front"] = ankify(term['word']) 482 | note["Back"] = ankify(term['definition']) 483 | if config["rich_text_formatting"]: 484 | note["Front"] = getText(term['wordRichText'], note["Front"]) 485 | note["Back"] = getText(term['definitionRichText'], note["Back"]) 486 | if "photo" in term and term["photo"]: 487 | photo_urls = { 488 | "1": "https://farm{1}.staticflickr.com/{2}/{3}_{4}.jpg", 489 | "2": "https://o.quizlet.com/i/{1}.jpg", 490 | "3": "https://o.quizlet.com/{1}.{2}" 491 | } 492 | img_tkns = term["photo"].split(',') 493 | img_type = img_tkns[0] 494 | term["_imageUrl"] = photo_urls[img_type].format(*img_tkns) 495 | if '_imageUrl' in term and term["_imageUrl"]: 496 | # file_name = self.fileDownloader(term["image"]["url"]) 497 | file_name = self.fileDownloader(term["_imageUrl"]) 498 | if note["Back"]: 499 | note["Back"] += "

" 500 | note["Back"] += '
'.format(file_name) 501 | mw.app.processEvents() 502 | if config["rich_text_formatting"]: 503 | note["Front"] = '' + note["Front"] 504 | mw.col.addNote(note) 505 | mw.col.reset() 506 | mw.reset() 507 | 508 | # download the images 509 | def fileDownloader(self, url): 510 | url = url.replace('_m', '') 511 | file_name = "quizlet-" + url.split('/')[-1] 512 | # get original, non-mobile version of images 513 | r = requests.get(url, stream=True, verify=False, headers=headers) 514 | if r.status_code == 200: 515 | with open(file_name, 'wb') as f: 516 | r.raw.decode_content = True 517 | shutil.copyfileobj(r.raw, f) 518 | return file_name 519 | 520 | class QuizletDownloader(QThread): 521 | 522 | # thread that downloads results from the Quizlet API 523 | def __init__(self, window, url): 524 | super(QuizletDownloader, self).__init__() 525 | self.window = window 526 | 527 | self.url = url 528 | self.results = None 529 | 530 | self.error = False 531 | self.errorCode = None 532 | self.errorCaptcha = False 533 | self.errorReason = None 534 | self.errorMessage = None 535 | 536 | def run(self): 537 | r = None 538 | try: 539 | r = requests.get(self.url, verify=False, headers=headers, cookies=self.window.cookies) 540 | r.raise_for_status() 541 | 542 | regex = re.escape('window.Quizlet["setPasswordData"]') 543 | 544 | if re.search(regex, r.text): 545 | self.error = True 546 | self.errorCode = 403 547 | return 548 | 549 | regex = re.escape('window.Quizlet["setPageData"] = ') 550 | regex += r'(.+?)' 551 | regex += re.escape('; QLoad("Quizlet.setPageData");') 552 | m = re.search(regex, r.text) 553 | 554 | if not m: 555 | regex = re.escape('window.Quizlet["assistantModeData"] = ') 556 | regex += r'(.+?)' 557 | regex += re.escape('; QLoad("Quizlet.assistantModeData");') 558 | m = re.search(regex, r.text) 559 | 560 | if not m: 561 | regex = re.escape('window.Quizlet["cardsModeData"] = ') 562 | regex += r'(.+?)' 563 | regex += re.escape('; QLoad("Quizlet.cardsModeData");') 564 | m = re.search(regex, r.text) 565 | 566 | data = m.group(1).strip() 567 | self.results = json.loads(data) 568 | 569 | title = os.path.basename(self.url.strip()) or "Quizlet Flashcards" 570 | m = re.search(r'(.+?)', r.text) 571 | if m: 572 | title = m.group(1) 573 | title = re.sub(r' \| Quizlet$', '', title) 574 | title = re.sub(r'^Flashcards ', '', title) 575 | title = re.sub(r'\s+', ' ', title) 576 | title = title.strip() 577 | self.results['title'] = title 578 | except requests.HTTPError as e: 579 | self.error = True 580 | self.errorCode = e.response.status_code 581 | self.errorMessage = e.response.text 582 | if "CF-Chl-Bypass" in e.response.headers: 583 | self.errorCaptcha = True 584 | except ValueError as e: 585 | self.error = True 586 | self.errorMessage = "Invalid json: {0}".format(e) 587 | except Exception as e: 588 | self.error = True 589 | self.errorMessage = "{}\n-----------------\n{}".format(e, r.text if r else "") 590 | # yep, we got it 591 | 592 | # plugin was called from Anki 593 | def runQuizletPlugin(): 594 | global __window 595 | __window = QuizletWindow() 596 | 597 | # create menu item in Anki 598 | action = QAction("Import from Quizlet", mw) 599 | action.triggered.connect(runQuizletPlugin) 600 | mw.form.menuTools.addAction(action) --------------------------------------------------------------------------------