├── 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}{0}>'.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)
--------------------------------------------------------------------------------