diff --git a/.github/workflows/build-deck.yml b/.github/workflows/build-deck.yml
new file mode 100644
index 0000000..39987ce
--- /dev/null
+++ b/.github/workflows/build-deck.yml
@@ -0,0 +1,74 @@
+---
+name: Build Anki deck
+on: [push, pull_request]
+jobs:
+  build-anki-deck:
+    name: Build Anki deck
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@master
+      - name: Set up Python 3.9
+        uses: actions/setup-python@v1
+        with:
+          python-version: 3.9
+      - name: Install requirements
+        run: pip install -r requirements.txt
+      - name: Install sqlite
+        run: sudo apt-get install sqlite3 unzip
+      - name: Get current date
+        id: date
+        run: echo "::set-output name=date::$(date +'%Y-%m-%d_%H:%M:%S')"
+      - name: Get current timestamp
+        id: timestamp
+        run: echo "::set-output name=timestamp::$(date +'%s')"
+      - name: Test build Anki Deck
+        run: >
+          git clean -f -x -d
+          && python generate.py --start 1 --stop 5 --page-size 2
+          && unzip leetcode.apkg
+          && sqlite3 collection.anki2 .schema
+          && sqlite3 collection.anki2 .dump
+        env:
+          LEETCODE_SESSION_ID: ${{ secrets.LEETCODE_SESSION_ID }}
+          LEETCODE_CSRF_TOKEN: ${{ secrets.LEETCODE_CSRF_TOKEN }}
+      - name: Test build Anki Deck (non-default output file)
+        run: >
+          git clean -f -x -d
+          && python generate.py --stop 3 --output-file test.apkg
+          && unzip test.apkg
+          && sqlite3 collection.anki2 .schema
+          && sqlite3 collection.anki2 .dump
+        env:
+          LEETCODE_SESSION_ID: ${{ secrets.LEETCODE_SESSION_ID }}
+          LEETCODE_CSRF_TOKEN: ${{ secrets.LEETCODE_CSRF_TOKEN }}
+      - name: Test build Anki Deck with Amazon list id
+        run: python generate.py --stop 10 --list-id 7p5x763
+        env:
+          LEETCODE_SESSION_ID: ${{ secrets.LEETCODE_SESSION_ID }}
+          LEETCODE_CSRF_TOKEN: ${{ secrets.LEETCODE_CSRF_TOKEN }}
+      - name: Build Anki Deck
+        run: python generate.py
+        if: github.ref == 'refs/heads/master'
+        env:
+          LEETCODE_SESSION_ID: ${{ secrets.LEETCODE_SESSION_ID }}
+          LEETCODE_CSRF_TOKEN: ${{ secrets.LEETCODE_CSRF_TOKEN }}
+      - name: Create Release
+        id: create_release
+        uses: actions/create-release@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          tag_name: ${{ github.ref }}-${{ steps.timestamp.outputs.timestamp }}
+          release_name: >
+            Anki Deck from ${{ github.ref }} on ${{ steps.date.outputs.date }}
+          draft: true
+          prerelease: true
+      - name: Upload release asset
+        uses: actions/upload-release-asset@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          upload_url: ${{ steps.create_release.outputs.upload_url }}
+          asset_path: ./leetcode.apkg
+          asset_name: leetcode.apkg
+          asset_content_type: application/octet-stream
diff --git a/.github/workflows/style-check.yml b/.github/workflows/style-check.yml
new file mode 100644
index 0000000..b0ab073
--- /dev/null
+++ b/.github/workflows/style-check.yml
@@ -0,0 +1,51 @@
+---
+name: Style
+on: [push, pull_request]
+jobs:
+  pylint:
+    name: pylint
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@master
+      - name: Set up Python 3.9
+        uses: actions/setup-python@v1
+        with:
+          python-version: 3.9
+      - name: Install requirements
+        run: pip install -r requirements.txt
+      - name: Install test requirements
+        run: pip install -r test-requirements.txt
+      - name: Install pylint
+        run: pip install pylint
+      - name: Run pylint
+        run: find . -type f -name "*.py" | xargs pylint -E
+  black:
+    name: black
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@master
+      - name: Set up Python 3.9
+        uses: actions/setup-python@v1
+        with:
+          python-version: 3.9
+      - name: Install requirements
+        run: pip install -r requirements.txt
+      - name: Install black
+        run: pip install black
+      - name: Run black
+        run: black --check --diff .
+  isort:
+    name: isort
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@master
+      - name: Set up Python 3.9
+        uses: actions/setup-python@v1
+        with:
+          python-version: 3.9
+      - name: Install requirements
+        run: pip install -r requirements.txt
+      - name: Install isort
+        run: pip install isort
+      - name: Run isort
+        run: isort --ensure-newline-before-comments --diff -v .
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..d9f854e
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,21 @@
+---
+name: Tests
+on: [push, pull_request]
+jobs:
+  pytest:
+    name: pytest
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@master
+      - name: Set up Python 3.9
+        uses: actions/setup-python@v1
+        with:
+          python-version: 3.9
+      - name: Install requirements
+        run: pip install -r requirements.txt
+      - name: Install test requirements
+        run: pip install -r test-requirements.txt
+      - name: Install pytest
+        run: pip install pytest
+      - name: Run pytest
+        run: pytest
diff --git a/.github/workflows/type-check.yml b/.github/workflows/type-check.yml
new file mode 100644
index 0000000..bc70904
--- /dev/null
+++ b/.github/workflows/type-check.yml
@@ -0,0 +1,38 @@
+---
+name: Typing
+on: [push, pull_request]
+jobs:
+  mypy:
+    name: mypy
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@master
+      - name: Set up Python 3.9
+        uses: actions/setup-python@v1
+        with:
+          python-version: 3.9
+      - name: Install requirements
+        run: pip install -r requirements.txt
+      - name: Install test requirements
+        run: pip install -r test-requirements.txt
+      - name: Install mypy
+        run: pip install mypy
+      - name: Run mypy
+        run: mypy .
+  pyre:
+    name: pyre
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@master
+      - name: Set up Python 3.9
+        uses: actions/setup-python@v1
+        with:
+          python-version: 3.9
+      - name: Install requirements
+        run: pip install -r requirements.txt
+      - name: Install test requirements
+        run: pip install -r test-requirements.txt
+      - name: Install pyre
+        run: pip install pyre-check
+      - name: Run pyre
+        run: pyre check
diff --git a/.gitignore b/.gitignore
index 7e22e8d..617a5fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ cache
 leetcode.apkg
 .mypy_cache
 .cookies.sh
+__pycache__
diff --git a/.isort.cfg b/.isort.cfg
new file mode 100644
index 0000000..b130d96
--- /dev/null
+++ b/.isort.cfg
@@ -0,0 +1,7 @@
+[settings]
+force_grid_wrap=0
+include_trailing_comma=True
+line_length=88
+multi_line_output=3
+use_parentheses=True
+ensure_newline_before_comments=True
diff --git a/.pyre_configuration b/.pyre_configuration
new file mode 100644
index 0000000..cf73b56
--- /dev/null
+++ b/.pyre_configuration
@@ -0,0 +1,7 @@
+{
+  "source_directories": [
+    "."
+  ],
+  "site_package_search_strategy": "all",
+  "strict": true
+}
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..1d28c2b
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,56 @@
+language: python
+python:
+  - '3.9'
+install:
+  - pip install -r requirements.txt
+  - pip install awscli
+script:
+jobs:
+  include:
+    # Each step caches fetched problems from the previous one
+    # so the next one runs faster.
+    # This is a hack because travis CI has a time limit of 30
+    # minutes for each individual job
+    - stage: 0 to 2 (test run)
+      script:
+        - python generate.py --start 0 --stop 2
+        - aws s3 sync cache s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER
+    - stage: 2 to 500
+      script:
+        - aws s3 sync s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER cache
+        - python generate.py --start 0 --stop 500
+        - aws s3 sync cache s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER
+    - stage: 500 to 1000
+      script:
+        - aws s3 sync s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER cache
+        - python generate.py --start 0 --stop 1000
+        - aws s3 sync cache s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER
+    - stage: 1000 to 1500
+      script:
+        - aws s3 sync s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER cache
+        - python generate.py --start 0 --stop 1500
+        - aws s3 sync cache s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER
+    - stage: 1500 to 2000
+      script:
+        - aws s3 sync s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER cache
+        - python generate.py --start 0 --stop 2000
+        - aws s3 sync cache s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER
+    - stage: 2000 to 2500
+      script:
+        - aws s3 sync s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER cache
+        - python generate.py --start 0 --stop 2500
+        - aws s3 sync cache s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER
+    - stage: 2500 to 3000
+      script:
+        - aws s3 sync s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER cache
+        - python generate.py --start 0 --stop 3000
+        - aws s3 rm --recursive s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER
+      deploy:
+        provider: releases
+        api_key: $GITHUB_TOKEN
+        file: leetcode.apkg
+        skip_cleanup: true
+        on:
+          branch: master
+after_failure:
+  - aws s3 rm --recursive s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..8aa2645
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) [year] [fullname]
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 8c6fdc9..8fbd8a3 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,10 @@
+
+![build](https://github.com/prius/leetcode-anki/actions/workflows/build-deck.yml/badge.svg)
+![style](https://github.com/prius/leetcode-anki/actions/workflows/style-check.yml/badge.svg)
+![tests](https://github.com/prius/leetcode-anki/actions/workflows/tests.yml/badge.svg)
+![types](https://github.com/prius/leetcode-anki/actions/workflows/type-check.yml/badge.svg)
+![license](https://img.shields.io/github/license/prius/leetcode-anki)
+
 # Leetcode Anki card generator
 
 ## Summary
@@ -11,25 +18,56 @@ I personally use it to track my grinding progress.
 ![photo_2021-09-29_08-58-21 jpg 2](https://user-images.githubusercontent.com/1616237/135676123-106871e0-bc8e-4d23-acef-c27ebe034ecf.png)
 ![photo_2021-09-29_08-58-23 jpg 2](https://user-images.githubusercontent.com/1616237/135676125-90067ea3-e111-49da-ae13-7bce81040c37.png)
 
+## Prerequisites
+1. [python3.8+](https://www.python.org/downloads/) installed
+2. [python virtualenv](https://pypi.org/project/virtualenv/) installed
+3. [git cli](https://github.com/git-guides/install-git) installed
+4. [GNU make](https://www.gnu.org/software/make/) installed (optional, can run the script directly)
+5. \*nix operating system (Linux, MacOS, FreeBSD, ...). Should also work for Windows, but commands will be different. I'm not a Windows expert, so can't figure out how to make it work there, but contributions are welcome.
 
-## Installation
-First initialize and activate python virtualenv somewhere
+## How to run
+First download the source code
+```sh
+git clone https://github.com/prius/leetcode-anki.git
+cd leetcode-anki
 ```
-virtualenv -p python3 leetcode-anki
+
+After that initialize and activate python virtualenv somewhere
+
+Linux/MacOS
+```sh
+virtualenv -p python leetcode-anki
 . leetcode-anki/bin/activate
 ```
 
-Then initialize necessary environment variables. You can get the values directly from your browser
+Windows
+```sh
+python -m venv leetcode-anki
+.\leetcode-anki\Scripts\activate.bat
 ```
+
+Then initialize necessary environment variables. You can get it directly from your browser cookies (`csrftoken` and `LEETCODE_SESSION`)
+
+Linux/Macos
+```sh
 export LEETCODE_CSRF_TOKEN="xxx"
 export LEETCODE_SESSION_ID="yyy"
 ```
 
-And then run
+Windows
+```sh
+set LEETCODE_CSRF_TOKEN="xxx"
+set LEETCODE_SESSION_ID="yyy"
 ```
+
+And finally run for Linux/MacOS
+```sh
 make generate
 ```
+Or for Windows
+```sh
+pip install -r requirements.txt
+python generate.py
+```
 
 You'll get `leetcode.apkg` file, which you can import directly to your anki app.
-
-There also will be a `cache` directory created for the cached data about the problems. If you want to fetch more up to date information about the existing problems, delete this dir. Just keep in mind, it'll take a while to re-download the data about all the problems.
diff --git a/generate.py b/generate.py
index f2dae06..dafcfc5 100755
--- a/generate.py
+++ b/generate.py
@@ -1,227 +1,117 @@
 #!/usr/bin/env python3
-import json
+"""
+This script generates an Anki deck with all the leetcode problems currently
+known.
+"""
+
+import argparse
+import asyncio
 import logging
-import os
-import time
-from functools import lru_cache
-from typing import Dict, Iterator, List, Tuple
+from pathlib import Path
+from typing import Any, Awaitable, Callable, Coroutine, List
 
-import diskcache
 # https://github.com/kerrickstaley/genanki
 import genanki  # type: ignore
-# https://github.com/prius/python-leetcode
-import leetcode  # type: ignore
-from tqdm import tqdm
+from tqdm import tqdm  # type: ignore
 
-cookies = {
-    "csrftoken": os.environ["LEETCODE_CSRF_TOKEN"],
-    "LEETCODE_SESSION": os.environ["LEETCODE_SESSION_ID"],
-}
+import leetcode_anki.helpers.leetcode
 
 LEETCODE_ANKI_MODEL_ID = 4567610856
 LEETCODE_ANKI_DECK_ID = 8589798175
 OUTPUT_FILE = "leetcode.apkg"
-CACHE_DIR = "cache"
 ALLOWED_EXTENSIONS = {".py", ".go"}
 
 
 logging.getLogger().setLevel(logging.INFO)
 
 
-class LeetcodeData:
-    def __init__(self) -> None:
-        # Initialize leetcode API client
-        cookies = {
-            "csrftoken": os.environ["LEETCODE_CSRF_TOKEN"],
-            "LEETCODE_SESSION": os.environ["LEETCODE_SESSION_ID"],
-        }
-
-        configuration = leetcode.Configuration()
-
-        configuration.api_key["x-csrftoken"] = cookies["csrftoken"]
-        configuration.api_key["csrftoken"] = cookies["csrftoken"]
-        configuration.api_key["LEETCODE_SESSION"] = cookies["LEETCODE_SESSION"]
-        configuration.api_key["Referer"] = "https://leetcode.com"
-        configuration.debug = False
-        self._api_instance = leetcode.DefaultApi(leetcode.ApiClient(configuration))
-
-        # Init problem data cache
-        if not os.path.exists(CACHE_DIR):
-            os.mkdir(CACHE_DIR)
-        self._cache = diskcache.Cache(CACHE_DIR)
-
-    def _get_problem_data(self, problem_slug: str) -> Dict[str, str]:
-        if problem_slug in self._cache:
-            return self._cache[problem_slug]
-
-        time.sleep(2)  # Leetcode has a rate limiter
-
-        api_instance = self._api_instance
-
-        graphql_request = leetcode.GraphqlQuery(
-            query="""
-                query getQuestionDetail($titleSlug: String!) {
-                  question(titleSlug: $titleSlug) {
-                    questionId
-                    questionFrontendId
-                    boundTopicId
-                    title
-                    content
-                    translatedTitle
-                    translatedContent
-                    isPaidOnly
-                    difficulty
-                    likes
-                    dislikes
-                    isLiked
-                    similarQuestions
-                    contributors {
-                      username
-                      profileUrl
-                      avatarUrl
-                      __typename
-                    }
-                    langToValidPlayground
-                    topicTags {
-                      name
-                      slug
-                      translatedName
-                      __typename
-                    }
-                    companyTagStats
-                    codeSnippets {
-                      lang
-                      langSlug
-                      code
-                      __typename
-                    }
-                    stats
-                    hints
-                    solution {
-                      id
-                      canSeeDetail
-                      __typename
-                    }
-                    status
-                    sampleTestCase
-                    metaData
-                    judgerAvailable
-                    judgeType
-                    mysqlSchemas
-                    enableRunCode
-                    enableTestMode
-                    envInfo
-                    __typename
-                  }
-                }
-            """,
-            variables=leetcode.GraphqlQueryVariables(title_slug=problem_slug),
-            operation_name="getQuestionDetail",
-        )
-        data = api_instance.graphql_post(body=graphql_request).data.question
-
-        # Save data in the cache
-        self._cache[problem_slug] = data
-
-        return data
-
-    def _get_description(self, problem_slug: str) -> str:
-        data = self._get_problem_data(problem_slug)
-        return data.content or "No content"
-
-    def _stats(self, problem_slug: str) -> Dict[str, str]:
-        data = self._get_problem_data(problem_slug)
-        return json.loads(data.stats)
-
-    def submissions_total(self, problem_slug: str) -> int:
-        return self._stats(problem_slug)["totalSubmissionRaw"]
-
-    def submissions_accepted(self, problem_slug: str) -> int:
-        return self._stats(problem_slug)["totalAcceptedRaw"]
-
-    def description(self, problem_slug: str) -> str:
-        return self._get_description(problem_slug)
-
-    def solution(self, problem_slug: str) -> str:
-        return ""
-
-    def difficulty(self, problem_slug: str) -> str:
-        data = self._get_problem_data(problem_slug)
-        diff = data.difficulty
-
-        if diff == "Easy":
-            return "<font color='green'>Easy</font>"
-        elif diff == "Medium":
-            return "<font color='orange'>Medium</font>"
-        elif diff == "Hard":
-            return "<font color='red'>Hard</font>"
-        else:
-            raise ValueError(f"Incorrect difficulty: {diff}")
-
-    def paid(self, problem_slug: str) -> str:
-        data = self._get_problem_data(problem_slug)
-        return data.is_paid_only
-
-    def problem_id(self, problem_slug: str) -> str:
-        data = self._get_problem_data(problem_slug)
-        return data.question_frontend_id
-
-    def likes(self, problem_slug: str) -> int:
-        data = self._get_problem_data(problem_slug)
-        likes = data.likes
-
-        if not isinstance(likes, int):
-            raise ValueError(f"Likes should be int: {likes}")
-
-        return likes
-
-    def dislikes(self, problem_slug: str) -> int:
-        data = self._get_problem_data(problem_slug)
-        dislikes = data.dislikes
-
-        if not isinstance(dislikes, int):
-            raise ValueError(f"Dislikes should be int: {dislikes}")
+def parse_args() -> argparse.Namespace:
+    """
+    Parse command line arguments for the script
+    """
+    parser = argparse.ArgumentParser(description="Generate Anki cards for leetcode")
+    parser.add_argument(
+        "--start", type=int, help="Start generation from this problem", default=0
+    )
+    parser.add_argument(
+        "--stop", type=int, help="Stop generation on this problem", default=2**64
+    )
+    parser.add_argument(
+        "--page-size",
+        type=int,
+        help="Get at most this many problems (decrease if leetcode API times out)",
+        default=500,
+    )
+    parser.add_argument(
+        "--list-id",
+        type=str,
+        help="Get all questions from a specific list id (https://leetcode.com/list?selectedList=<list_id>",
+        default="",
+    )
+    parser.add_argument(
+        "--output-file", type=str, help="Output filename", default=OUTPUT_FILE
+    )
 
-        return dislikes
+    args = parser.parse_args()
 
-    def tags(self, problem_slug: str) -> List[str]:
-        data = self._get_problem_data(problem_slug)
-        return list(map(lambda x: x.slug, data.topic_tags))
+    return args
 
 
 class LeetcodeNote(genanki.Note):
+    """
+    Extended base class for the Anki note, that correctly sets the unique
+    identifier of the note.
+    """
+
     @property
-    def guid(self):
+    def guid(self) -> str:
         # Hash by leetcode task handle
         return genanki.guid_for(self.fields[0])
 
 
-@lru_cache(None)
-def get_leetcode_api_client() -> leetcode.DefaultApi:
-    configuration = leetcode.Configuration()
-
-    configuration.api_key["x-csrftoken"] = cookies["csrftoken"]
-    configuration.api_key["csrftoken"] = cookies["csrftoken"]
-    configuration.api_key["LEETCODE_SESSION"] = cookies["LEETCODE_SESSION"]
-    configuration.api_key["Referer"] = "https://leetcode.com"
-    configuration.debug = False
-    api_instance = leetcode.DefaultApi(leetcode.ApiClient(configuration))
-
-    return api_instance
-
-
-def get_leetcode_task_handles() -> Iterator[Tuple[str, str, str]]:
-    api_instance = get_leetcode_api_client()
-
-    for topic in ["algorithms", "database", "shell", "concurrency"]:
-        api_response = api_instance.api_problems_topic_get(topic=topic)
-        for stat_status_pair in api_response.stat_status_pairs:
-            stat = stat_status_pair.stat
-
-            yield (topic, stat.question__title, stat.question__title_slug)
+async def generate_anki_note(
+    leetcode_data: leetcode_anki.helpers.leetcode.LeetcodeData,
+    leetcode_model: genanki.Model,
+    leetcode_task_handle: str,
+) -> LeetcodeNote:
+    """
+    Generate a single Anki flashcard
+    """
+    return LeetcodeNote(
+        model=leetcode_model,
+        fields=[
+            leetcode_task_handle,
+            str(await leetcode_data.problem_id(leetcode_task_handle)),
+            str(await leetcode_data.title(leetcode_task_handle)),
+            str(await leetcode_data.category(leetcode_task_handle)),
+            await leetcode_data.description(leetcode_task_handle),
+            await leetcode_data.difficulty(leetcode_task_handle),
+            "yes" if await leetcode_data.paid(leetcode_task_handle) else "no",
+            str(await leetcode_data.likes(leetcode_task_handle)),
+            str(await leetcode_data.dislikes(leetcode_task_handle)),
+            str(await leetcode_data.submissions_total(leetcode_task_handle)),
+            str(await leetcode_data.submissions_accepted(leetcode_task_handle)),
+            str(
+                int(
+                    await leetcode_data.submissions_accepted(leetcode_task_handle)
+                    / await leetcode_data.submissions_total(leetcode_task_handle)
+                    * 100
+                )
+            ),
+            str(await leetcode_data.freq_bar(leetcode_task_handle)),
+        ],
+        tags=await leetcode_data.tags(leetcode_task_handle),
+        # FIXME: sort field doesn't work doesn't work
+        sort_field=str(await leetcode_data.freq_bar(leetcode_task_handle)).zfill(3),
+    )
 
 
-def generate() -> None:
+async def generate(
+    start: int, stop: int, page_size: int, list_id: str, output_file: str
+) -> None:
+    """
+    Generate an Anki deck
+    """
     leetcode_model = genanki.Model(
         LEETCODE_ANKI_MODEL_ID,
         "Leetcode model",
@@ -238,6 +128,7 @@ def generate() -> None:
             {"name": "SubmissionsTotal"},
             {"name": "SubmissionsAccepted"},
             {"name": "SumissionAcceptRate"},
+            {"name": "Frequency"},
             # TODO: add hints
         ],
         templates=[
@@ -252,6 +143,11 @@ def generate() -> None:
                 ({{SumissionAcceptRate}}%)
                 <br/>
                 <b>Topic:</b> {{Topic}}<br/>
+                <b>Frequency:</b>
+                <progress value="{{Frequency}}" max="100">
+                {{Frequency}}%
+                </progress>
+                <br/>
                 <b>URL:</b>
                 <a href='https://leetcode.com/problems/{{Slug}}/'>
                     https://leetcode.com/problems/{{Slug}}/
@@ -274,44 +170,47 @@ def generate() -> None:
                 </a>
                 <br/>
                 """,
-            },
+            }
         ],
     )
-    leetcode_deck = genanki.Deck(LEETCODE_ANKI_DECK_ID, "leetcode")
-    leetcode_data = LeetcodeData()
-    for topic, leetcode_task_title, leetcode_task_handle in tqdm(
-        list(get_leetcode_task_handles())
-    ):
+    leetcode_deck = genanki.Deck(LEETCODE_ANKI_DECK_ID, Path(output_file).stem)
+
+    leetcode_data = leetcode_anki.helpers.leetcode.LeetcodeData(
+        start, stop, page_size, list_id
+    )
+
+    note_generators: List[Awaitable[LeetcodeNote]] = []
 
-        leetcode_note = LeetcodeNote(
-            model=leetcode_model,
-            fields=[
-                leetcode_task_handle,
-                str(leetcode_data.problem_id(leetcode_task_handle)),
-                leetcode_task_title,
-                topic,
-                leetcode_data.description(leetcode_task_handle),
-                leetcode_data.difficulty(leetcode_task_handle),
-                "yes" if leetcode_data.paid(leetcode_task_handle) else "no",
-                str(leetcode_data.likes(leetcode_task_handle)),
-                str(leetcode_data.dislikes(leetcode_task_handle)),
-                str(leetcode_data.submissions_total(leetcode_task_handle)),
-                str(leetcode_data.submissions_accepted(leetcode_task_handle)),
-                str(
-                    int(
-                        leetcode_data.submissions_accepted(leetcode_task_handle)
-                        / leetcode_data.submissions_total(leetcode_task_handle)
-                        * 100
-                    )
-                ),
-            ],
-            tags=leetcode_data.tags(leetcode_task_handle),
+    task_handles = await leetcode_data.all_problems_handles()
+
+    logging.info("Generating flashcards")
+    for leetcode_task_handle in task_handles:
+        note_generators.append(
+            generate_anki_note(leetcode_data, leetcode_model, leetcode_task_handle)
         )
-        leetcode_deck.add_note(leetcode_note)
 
-        # Write each time due to swagger bug causing the app hang indefinitely
-        genanki.Package(leetcode_deck).write_to_file(OUTPUT_FILE)
+    for leetcode_note in tqdm(note_generators, unit="flashcard"):
+        leetcode_deck.add_note(await leetcode_note)
+
+    genanki.Package(leetcode_deck).write_to_file(output_file)
+
+
+async def main() -> None:
+    """
+    The main script logic
+    """
+    args = parse_args()
+
+    start, stop, page_size, list_id, output_file = (
+        args.start,
+        args.stop,
+        args.page_size,
+        args.list_id,
+        args.output_file,
+    )
+    await generate(start, stop, page_size, list_id, output_file)
 
 
 if __name__ == "__main__":
-    generate()
+    loop: asyncio.events.AbstractEventLoop = asyncio.get_event_loop()
+    loop.run_until_complete(main())
diff --git a/leetcode_anki/__init__.py b/leetcode_anki/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode_anki/helpers/__init__.py b/leetcode_anki/helpers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode_anki/helpers/leetcode.py b/leetcode_anki/helpers/leetcode.py
new file mode 100644
index 0000000..e7b810b
--- /dev/null
+++ b/leetcode_anki/helpers/leetcode.py
@@ -0,0 +1,406 @@
+# pylint: disable=missing-module-docstring
+import functools
+import json
+import logging
+import math
+import os
+import time
+from functools import cached_property
+from typing import Any, Callable, Dict, List, Tuple, Type, TypeVar
+
+# https://github.com/prius/python-leetcode
+import leetcode.api.default_api  # type: ignore
+import leetcode.api_client  # type: ignore
+import leetcode.auth  # type: ignore
+import leetcode.configuration  # type: ignore
+import leetcode.models.graphql_query  # type: ignore
+import leetcode.models.graphql_query_get_question_detail_variables  # type: ignore
+import leetcode.models.graphql_query_problemset_question_list_variables  # type: ignore
+import leetcode.models.graphql_query_problemset_question_list_variables_filter_input  # type: ignore
+import leetcode.models.graphql_question_detail  # type: ignore
+import urllib3  # type: ignore
+from tqdm import tqdm  # type: ignore
+
+CACHE_DIR = "cache"
+
+
+def _get_leetcode_api_client() -> leetcode.api.default_api.DefaultApi:
+    """
+    Leetcode API instance constructor.
+
+    This is a singleton, because we don't need to create a separate client
+    each time
+    """
+
+    configuration = leetcode.configuration.Configuration()
+
+    session_id = os.environ["LEETCODE_SESSION_ID"]
+    csrf_token = os.environ["LEETCODE_CSRF_TOKEN"]
+
+    configuration.api_key["x-csrftoken"] = csrf_token
+    configuration.api_key["csrftoken"] = csrf_token
+    configuration.api_key["LEETCODE_SESSION"] = session_id
+    configuration.api_key["Referer"] = "https://leetcode.com"
+    configuration.debug = False
+    api_instance = leetcode.api.default_api.DefaultApi(
+        leetcode.api_client.ApiClient(configuration)
+    )
+
+    return api_instance
+
+
+_T = TypeVar("_T")
+
+
+class _RetryDecorator:
+    _times: int
+    _exceptions: Tuple[Type[Exception]]
+    _delay: float
+
+    def __init__(
+        self, times: int, exceptions: Tuple[Type[Exception]], delay: float
+    ) -> None:
+        self._times = times
+        self._exceptions = exceptions
+        self._delay = delay
+
+    def __call__(self, func: Callable[..., _T]) -> Callable[..., _T]:
+        times: int = self._times
+        exceptions: Tuple[Type[Exception]] = self._exceptions
+        delay: float = self._delay
+
+        @functools.wraps(func)
+        def wrapper(*args: Any, **kwargs: Any) -> _T:
+            for attempt in range(times - 1):
+                try:
+                    return func(*args, **kwargs)
+                except exceptions:
+                    logging.exception(
+                        "Exception occured, try %s/%s", attempt + 1, times
+                    )
+                    time.sleep(delay)
+
+            logging.error("Last try")
+            return func(*args, **kwargs)
+
+        return wrapper
+
+
+def retry(
+    times: int, exceptions: Tuple[Type[Exception]], delay: float
+) -> _RetryDecorator:
+    """
+    Retry Decorator
+    Retries the wrapped function/method `times` times if the exceptions listed
+    in `exceptions` are thrown
+    """
+
+    return _RetryDecorator(times, exceptions, delay)
+
+
+class LeetcodeData:
+    """
+    Retrieves and caches the data for problems, acquired from the leetcode API.
+
+    This data can be later accessed using provided methods with corresponding
+    names.
+    """
+
+    def __init__(
+        self, start: int, stop: int, page_size: int = 1000, list_id: str = ""
+    ) -> None:
+        """
+        Initialize leetcode API and disk cache for API responses
+        """
+        if start < 0:
+            raise ValueError(f"Start must be non-negative: {start}")
+
+        if stop < 0:
+            raise ValueError(f"Stop must be non-negative: {start}")
+
+        if page_size < 0:
+            raise ValueError(f"Page size must be greater than 0: {page_size}")
+
+        if start > stop:
+            raise ValueError(f"Start (){start}) must be not greater than stop ({stop})")
+
+        self._start = start
+        self._stop = stop
+        self._page_size = page_size
+        self._list_id = list_id
+
+    @cached_property
+    def _api_instance(self) -> leetcode.api.default_api.DefaultApi:
+        return _get_leetcode_api_client()
+
+    @cached_property
+    def _cache(
+        self,
+    ) -> Dict[str, leetcode.models.graphql_question_detail.GraphqlQuestionDetail]:
+        """
+        Cached method to return dict (problem_slug -> question details)
+        """
+        problems = self._get_problems_data()
+        return {problem.title_slug: problem for problem in problems}
+
+    @retry(times=3, exceptions=(urllib3.exceptions.ProtocolError,), delay=5)
+    def _get_problems_count(self) -> int:
+        api_instance = self._api_instance
+
+        graphql_request = leetcode.models.graphql_query.GraphqlQuery(
+            query="""
+            query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) {
+              problemsetQuestionList: questionList(
+                categorySlug: $categorySlug
+                limit: $limit
+                skip: $skip
+                filters: $filters
+              ) {
+                totalNum
+              }
+            }
+            """,
+            variables=leetcode.models.graphql_query_problemset_question_list_variables.GraphqlQueryProblemsetQuestionListVariables(
+                category_slug="",
+                limit=1,
+                skip=0,
+                filters=leetcode.models.graphql_query_problemset_question_list_variables_filter_input.GraphqlQueryProblemsetQuestionListVariablesFilterInput(
+                    tags=[],
+                    list_id=self._list_id,
+                    # difficulty="MEDIUM",
+                    # status="NOT_STARTED",
+                    # list_id="7p5x763",  # Top Amazon Questions
+                    # premium_only=False,
+                ),
+            ),
+            operation_name="problemsetQuestionList",
+        )
+
+        time.sleep(2)  # Leetcode has a rate limiter
+        data = api_instance.graphql_post(body=graphql_request).data
+
+        return data.problemset_question_list.total_num or 0
+
+    @retry(times=3, exceptions=(urllib3.exceptions.ProtocolError,), delay=5)
+    def _get_problems_data_page(
+        self, offset: int, page_size: int, page: int
+    ) -> List[leetcode.models.graphql_question_detail.GraphqlQuestionDetail]:
+        api_instance = self._api_instance
+        graphql_request = leetcode.models.graphql_query.GraphqlQuery(
+            query="""
+            query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) {
+              problemsetQuestionList: questionList(
+                categorySlug: $categorySlug
+                limit: $limit
+                skip: $skip
+                filters: $filters
+              ) {
+                questions: data {
+                    questionFrontendId
+                    title
+                    titleSlug
+                    categoryTitle
+                    freqBar
+                    content
+                    isPaidOnly
+                    difficulty
+                    likes
+                    dislikes
+                    topicTags {
+                      name
+                      slug
+                    }
+                    stats
+                    hints
+                }
+              }
+            }
+            """,
+            variables=leetcode.models.graphql_query_problemset_question_list_variables.GraphqlQueryProblemsetQuestionListVariables(
+                category_slug="",
+                limit=page_size,
+                skip=offset + page * page_size,
+                filters=leetcode.models.graphql_query_problemset_question_list_variables_filter_input.GraphqlQueryProblemsetQuestionListVariablesFilterInput(
+                    list_id=self._list_id
+                ),
+            ),
+            operation_name="problemsetQuestionList",
+        )
+
+        time.sleep(2)  # Leetcode has a rate limiter
+        data = api_instance.graphql_post(
+            body=graphql_request
+        ).data.problemset_question_list.questions
+
+        return data
+
+    def _get_problems_data(
+        self,
+    ) -> List[leetcode.models.graphql_question_detail.GraphqlQuestionDetail]:
+        problem_count = self._get_problems_count()
+
+        if self._start > problem_count:
+            raise ValueError(
+                "Start ({self._start}) is greater than problems count ({problem_count})"
+            )
+
+        start = self._start
+        stop = min(self._stop, problem_count)
+
+        page_size = min(self._page_size, stop - start + 1)
+
+        problems: List[
+            leetcode.models.graphql_question_detail.GraphqlQuestionDetail
+        ] = []
+
+        logging.info("Fetching %s problems %s per page", stop - start + 1, page_size)
+
+        for page in tqdm(
+            range(math.ceil((stop - start + 1) / page_size)),
+            unit="problem",
+            unit_scale=page_size,
+        ):
+            data = self._get_problems_data_page(start, page_size, page)
+            problems.extend(data)
+
+        return problems
+
+    async def all_problems_handles(self) -> List[str]:
+        """
+        Get all problem handles known.
+
+        Example: ["two-sum", "three-sum"]
+        """
+        return list(self._cache.keys())
+
+    def _get_problem_data(
+        self, problem_slug: str
+    ) -> leetcode.models.graphql_question_detail.GraphqlQuestionDetail:
+        """
+        TODO: Legacy method. Needed in the old architecture. Can be replaced
+        with direct cache calls later.
+        """
+        cache = self._cache
+        if problem_slug in cache:
+            return cache[problem_slug]
+
+        raise ValueError(f"Problem {problem_slug} is not in cache")
+
+    async def _get_description(self, problem_slug: str) -> str:
+        """
+        Problem description
+        """
+        data = self._get_problem_data(problem_slug)
+        return data.content or "No content"
+
+    async def _stats(self, problem_slug: str) -> Dict[str, str]:
+        """
+        Various stats about problem. Such as number of accepted solutions, etc.
+        """
+        data = self._get_problem_data(problem_slug)
+        return json.loads(data.stats)
+
+    async def submissions_total(self, problem_slug: str) -> int:
+        """
+        Total number of submissions of the problem
+        """
+        return int((await self._stats(problem_slug))["totalSubmissionRaw"])
+
+    async def submissions_accepted(self, problem_slug: str) -> int:
+        """
+        Number of accepted submissions of the problem
+        """
+        return int((await self._stats(problem_slug))["totalAcceptedRaw"])
+
+    async def description(self, problem_slug: str) -> str:
+        """
+        Problem description
+        """
+        return await self._get_description(problem_slug)
+
+    async def difficulty(self, problem_slug: str) -> str:
+        """
+        Problem difficulty. Returns colored HTML version, so it can be used
+        directly in Anki
+        """
+        data = self._get_problem_data(problem_slug)
+        diff = data.difficulty
+
+        if diff == "Easy":
+            return "<font color='green'>Easy</font>"
+
+        if diff == "Medium":
+            return "<font color='orange'>Medium</font>"
+
+        if diff == "Hard":
+            return "<font color='red'>Hard</font>"
+
+        raise ValueError(f"Incorrect difficulty: {diff}")
+
+    async def paid(self, problem_slug: str) -> str:
+        """
+        Problem's "available for paid subsribers" status
+        """
+        data = self._get_problem_data(problem_slug)
+        return data.is_paid_only
+
+    async def problem_id(self, problem_slug: str) -> str:
+        """
+        Numerical id of the problem
+        """
+        data = self._get_problem_data(problem_slug)
+        return data.question_frontend_id
+
+    async def likes(self, problem_slug: str) -> int:
+        """
+        Number of likes for the problem
+        """
+        data = self._get_problem_data(problem_slug)
+        likes = data.likes
+
+        if not isinstance(likes, int):
+            raise ValueError(f"Likes should be int: {likes}")
+
+        return likes
+
+    async def dislikes(self, problem_slug: str) -> int:
+        """
+        Number of dislikes for the problem
+        """
+        data = self._get_problem_data(problem_slug)
+        dislikes = data.dislikes
+
+        if not isinstance(dislikes, int):
+            raise ValueError(f"Dislikes should be int: {dislikes}")
+
+        return dislikes
+
+    async def tags(self, problem_slug: str) -> List[str]:
+        """
+        List of the tags for this problem (string slugs)
+        """
+        data = self._get_problem_data(problem_slug)
+        tags = list(map(lambda x: x.slug, data.topic_tags))
+        tags.append(f"difficulty-{data.difficulty.lower()}-tag")
+        return tags
+
+    async def freq_bar(self, problem_slug: str) -> float:
+        """
+        Returns percentage for frequency bar
+        """
+        data = self._get_problem_data(problem_slug)
+        return data.freq_bar or 0
+
+    async def title(self, problem_slug: str) -> float:
+        """
+        Returns problem title
+        """
+        data = self._get_problem_data(problem_slug)
+        return data.title
+
+    async def category(self, problem_slug: str) -> float:
+        """
+        Returns problem category title
+        """
+        data = self._get_problem_data(problem_slug)
+        return data.category_title
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..953fe9f
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,9 @@
+[tool.pytest.ini_options]
+asyncio_mode = "strict"
+testpaths = [
+    "test",
+]
+
+[tool.pylint]
+max-line-length = 88
+disable = ["line-too-long"]
diff --git a/requirements.txt b/requirements.txt
index 73d9c06..951466f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-git+https://github.com/prius/python-leetcode.git
-diskcache
+python-leetcode==1.2.1
+setuptools==57.5.0
 genanki
 tqdm
diff --git a/test-requirements.txt b/test-requirements.txt
new file mode 100644
index 0000000..ee4ba01
--- /dev/null
+++ b/test-requirements.txt
@@ -0,0 +1,2 @@
+pytest
+pytest-asyncio
diff --git a/test/__init__.py b/test/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/helpers/__init__.py b/test/helpers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/helpers/test_leetcode.py b/test/helpers/test_leetcode.py
new file mode 100644
index 0000000..19c4f41
--- /dev/null
+++ b/test/helpers/test_leetcode.py
@@ -0,0 +1,341 @@
+from typing import Dict, List, Optional
+from unittest import mock
+
+import leetcode.models.graphql_data  # type: ignore
+import leetcode.models.graphql_problemset_question_list  # type: ignore
+import leetcode.models.graphql_question_contributor  # type: ignore
+import leetcode.models.graphql_question_detail  # type: ignore
+import leetcode.models.graphql_question_solution  # type: ignore
+import leetcode.models.graphql_question_topic_tag  # type: ignore
+import leetcode.models.graphql_response  # type: ignore
+import leetcode.models.problems  # type: ignore
+import leetcode.models.stat  # type: ignore
+import leetcode.models.stat_status_pair  # type: ignore
+import pytest
+
+import leetcode_anki.helpers.leetcode
+
+QUESTION_DETAIL = leetcode.models.graphql_question_detail.GraphqlQuestionDetail(
+    freq_bar=1.1,
+    question_id="1",
+    question_frontend_id="1",
+    bound_topic_id=1,
+    title="test title",
+    title_slug="test",
+    content="test content",
+    translated_title="test",
+    translated_content="test translated content",
+    is_paid_only=False,
+    difficulty="Hard",
+    likes=1,
+    dislikes=1,
+    is_liked=False,
+    similar_questions="{}",
+    contributors=[
+        leetcode.models.graphql_question_contributor.GraphqlQuestionContributor(
+            username="testcontributor",
+            profile_url="test://profile/url",
+            avatar_url="test://avatar/url",
+        )
+    ],
+    lang_to_valid_playground="{}",
+    topic_tags=[
+        leetcode.models.graphql_question_topic_tag.GraphqlQuestionTopicTag(
+            name="test tag",
+            slug="test-tag",
+            translated_name="translated test tag",
+            typename="test type name",
+        )
+    ],
+    company_tag_stats="{}",
+    code_snippets="{}",
+    stats='{"totalSubmissionRaw": 1, "totalAcceptedRaw": 1}',
+    hints=["test hint 1", "test hint 2"],
+    solution=[
+        leetcode.models.graphql_question_solution.GraphqlQuestionSolution(
+            id=1, can_see_detail=False, typename="test type name"
+        )
+    ],
+    status="ac",
+    sample_test_case="test case",
+    meta_data="{}",
+    judger_available=False,
+    judge_type="large",
+    mysql_schemas="test schema",
+    enable_run_code=False,
+    enable_test_mode=False,
+    env_info="{}",
+)
+
+
+def dummy_return_question_detail_dict(
+    question_detail: leetcode.models.graphql_question_detail.GraphqlQuestionDetail,
+) -> Dict[str, leetcode.models.graphql_question_detail.GraphqlQuestionDetail]:
+    return {"test": question_detail}
+
+
+@mock.patch("os.environ", mock.MagicMock(return_value={"LEETCODE_SESSION_ID": "test"}))
+@mock.patch("os.environ", mock.MagicMock(return_value={"LEETCODE_CSRF_TOKEN": "test"}))
+@mock.patch("leetcode.auth", mock.MagicMock())
+class TestLeetcode:
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    async def test_get_leetcode_api_client(self) -> None:
+        assert leetcode_anki.helpers.leetcode._get_leetcode_api_client()
+
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    async def test_retry(self) -> None:
+        decorator = leetcode_anki.helpers.leetcode.retry(
+            times=3, exceptions=(RuntimeError,), delay=0.01
+        )
+
+        async def test() -> str:
+            return "test"
+
+        func = mock.Mock(side_effect=[RuntimeError, RuntimeError, test()])
+
+        wrapper = decorator(func)
+
+        assert (await wrapper()) == "test"
+
+        assert func.call_count == 3
+
+
+@mock.patch("leetcode_anki.helpers.leetcode._get_leetcode_api_client", mock.Mock())
+class TestLeetcodeData:
+    _question_detail_singleton: Optional[
+        leetcode.models.graphql_question_detail.GraphqlQuestionDetail
+    ] = None
+    _leetcode_data_singleton: Optional[leetcode_anki.helpers.leetcode.LeetcodeData] = (
+        None
+    )
+
+    @property
+    def _question_details(
+        self,
+    ) -> leetcode.models.graphql_question_detail.GraphqlQuestionDetail:
+        question_detail = self._question_detail_singleton
+
+        if not question_detail:
+            raise ValueError("Question detail must not be None")
+
+        return question_detail
+
+    @property
+    def _leetcode_data(self) -> leetcode_anki.helpers.leetcode.LeetcodeData:
+        leetcode_data = self._leetcode_data_singleton
+
+        if not leetcode_data:
+            raise ValueError("Leetcode data must not be None")
+
+        return leetcode_data
+
+    def setup_method(self) -> None:
+        self._question_detail_singleton = QUESTION_DETAIL
+        self._leetcode_data_singleton = leetcode_anki.helpers.leetcode.LeetcodeData(
+            0, 10000
+        )
+
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    @mock.patch(
+        "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data",
+        mock.Mock(return_value=[QUESTION_DETAIL]),
+    )
+    async def test_init(self) -> None:
+        self._leetcode_data._cache["test"] = QUESTION_DETAIL
+
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    @mock.patch(
+        "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data",
+        mock.Mock(return_value=[QUESTION_DETAIL]),
+    )
+    async def test_get_description(self) -> None:
+        self._leetcode_data._cache["test"] = QUESTION_DETAIL
+        assert (await self._leetcode_data.description("test")) == "test content"
+
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    @mock.patch(
+        "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data",
+        mock.Mock(return_value=[QUESTION_DETAIL]),
+    )
+    async def test_submissions(self) -> None:
+        self._leetcode_data._cache["test"] = QUESTION_DETAIL
+        assert (await self._leetcode_data.description("test")) == "test content"
+        assert (await self._leetcode_data.submissions_total("test")) == 1
+        assert (await self._leetcode_data.submissions_accepted("test")) == 1
+
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    @mock.patch(
+        "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data",
+        mock.Mock(return_value=[QUESTION_DETAIL]),
+    )
+    async def test_difficulty_easy(self) -> None:
+        self._leetcode_data._cache["test"] = QUESTION_DETAIL
+
+        QUESTION_DETAIL.difficulty = "Easy"
+        assert "Easy" in (await self._leetcode_data.difficulty("test"))
+
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    @mock.patch(
+        "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data",
+        mock.Mock(return_value=[QUESTION_DETAIL]),
+    )
+    async def test_difficulty_medium(self) -> None:
+        self._leetcode_data._cache["test"] = QUESTION_DETAIL
+
+        QUESTION_DETAIL.difficulty = "Medium"
+        assert "Medium" in (await self._leetcode_data.difficulty("test"))
+
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    @mock.patch(
+        "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data",
+        mock.Mock(return_value=[QUESTION_DETAIL]),
+    )
+    async def test_difficulty_hard(self) -> None:
+        self._leetcode_data._cache["test"] = QUESTION_DETAIL
+
+        QUESTION_DETAIL.difficulty = "Hard"
+        assert "Hard" in (await self._leetcode_data.difficulty("test"))
+
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    @mock.patch(
+        "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data",
+        mock.Mock(return_value=[QUESTION_DETAIL]),
+    )
+    async def test_paid(self) -> None:
+        self._leetcode_data._cache["test"] = QUESTION_DETAIL
+
+        assert (await self._leetcode_data.paid("test")) is False
+
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    @mock.patch(
+        "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data",
+        mock.Mock(return_value=[QUESTION_DETAIL]),
+    )
+    async def test_problem_id(self) -> None:
+        self._leetcode_data._cache["test"] = QUESTION_DETAIL
+
+        assert (await self._leetcode_data.problem_id("test")) == "1"
+
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    @mock.patch(
+        "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data",
+        mock.Mock(return_value=[QUESTION_DETAIL]),
+    )
+    async def test_likes(self) -> None:
+        self._leetcode_data._cache["test"] = QUESTION_DETAIL
+
+        assert (await self._leetcode_data.likes("test")) == 1
+
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    @mock.patch(
+        "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data",
+        mock.Mock(return_value=[QUESTION_DETAIL]),
+    )
+    async def test_dislikes(self) -> None:
+        self._leetcode_data._cache["test"] = QUESTION_DETAIL
+
+        assert (await self._leetcode_data.dislikes("test")) == 1
+
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    @mock.patch(
+        "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data",
+        mock.Mock(return_value=[QUESTION_DETAIL]),
+    )
+    async def test_tags(self) -> None:
+        self._leetcode_data._cache["test"] = QUESTION_DETAIL
+
+        assert (await self._leetcode_data.tags("test")) == [
+            "test-tag",
+            "difficulty-hard-tag",
+        ]
+
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    @mock.patch(
+        "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data",
+        mock.Mock(return_value=[QUESTION_DETAIL]),
+    )
+    async def test_freq_bar(self) -> None:
+        self._leetcode_data._cache["test"] = QUESTION_DETAIL
+
+        assert (await self._leetcode_data.freq_bar("test")) == 1.1
+
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    @mock.patch(
+        "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data",
+        mock.Mock(return_value=[QUESTION_DETAIL]),
+    )
+    async def test_get_problem_data(self) -> None:
+        assert self._leetcode_data._cache["test"] == QUESTION_DETAIL
+
+    @mock.patch("time.sleep", mock.Mock())
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    async def test_get_problems_data_page(self) -> None:
+        data = leetcode.models.graphql_data.GraphqlData(
+            problemset_question_list=leetcode.models.graphql_problemset_question_list.GraphqlProblemsetQuestionList(
+                questions=[QUESTION_DETAIL], total_num=1
+            )
+        )
+        response = leetcode.models.graphql_response.GraphqlResponse(data=data)
+        self._leetcode_data._api_instance.graphql_post.return_value = response
+
+        assert self._leetcode_data._get_problems_data_page(0, 10, 0) == [
+            QUESTION_DETAIL
+        ]
+
+    # pyre-fixme[56]: Pyre was not able to infer the type of the decorator
+    #  `pytest.mark.asyncio`.
+    @pytest.mark.asyncio
+    @mock.patch(
+        "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_count",
+        mock.Mock(return_value=234),
+    )
+    @mock.patch("leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data_page")
+    async def test_get_problems_data(
+        self, mock_get_problems_data_page: mock.Mock
+    ) -> None:
+        question_list: List[
+            leetcode.models.graphql_question_detail.GraphqlQuestionDetail
+        ] = [QUESTION_DETAIL] * 234
+
+        def dummy(
+            offset: int, page_size: int, page: int
+        ) -> List[leetcode.models.graphql_question_detail.GraphqlQuestionDetail]:
+            return [
+                question_list.pop() for _ in range(min(page_size, len(question_list)))
+            ]
+
+        mock_get_problems_data_page.side_effect = dummy
+
+        assert len(self._leetcode_data._get_problems_data()) == 234
diff --git a/test/test_dummy.py b/test/test_dummy.py
new file mode 100644
index 0000000..b1c27b3
--- /dev/null
+++ b/test/test_dummy.py
@@ -0,0 +1,19 @@
+"""
+Just a placeholder
+"""
+
+
+class TestDummy:
+    """
+    Dummy test
+    """
+
+    @staticmethod
+    def test_do_nothing() -> None:
+        """Do nothing"""
+        assert True
+
+    @staticmethod
+    def test_do_nothing2() -> None:
+        """Do nothing"""
+        assert True