Repository: ClementJ18/moddb (language: python)

Issue:
Develop



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 5320654..285040b 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -1,11 +1,17 @@
 # This workflow will install Python dependencies, run tests and lint with a variety of Python versions
 # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
 
-name: Python package
+name: Test package
 
 on:
   pull_request:
     branches: [ master, develop ]
+    
+env:
+  USERNAME: ${{ secrets.USERNAME }}
+  PASSWORD: ${{ secrets.PASSWORD }}
+  SENDER_USERNAME: ${{ secrets.SENDER_USERNAME }}
+  SENDER_PASSWORD: ${{ secrets.SENDER_PASSWORD }}
 
 jobs:
   build:
@@ -29,12 +35,13 @@ jobs:
     - name: Echo env
       run: |
         echo ${{ github.event.pull_request.base.ref }}
+        echo ${{ github.event.pull_request.base.ref_name }}
     - name: Test changes with light tests
       run: |
         bash run_tests.sh
-      if: github.event.pull_request.base.ref_name == "develop"
+      if: github.event.pull_request.base.ref == 'develop'
     - name: Test changes with full tests
       run: |
         bash run_tests.sh full
-      if: github.event.pull_request.base.ref_name == "master"
+      if: github.event.pull_request.base.ref == 'master'
   
diff --git a/.gitignore b/.gitignore
index b9804e2..d8829db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,7 @@
 *.pyc
 docs/build
 moddb.log
-enum_fix.py
-toy_box
-main.py
-test/test_config.py
-test.py
+.env/
+tests/test_config.py
+tests/fixtures/
+report.html
diff --git a/.readthedocs.yml b/.readthedocs.yml
index 4057211..3c252a5 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -2,5 +2,5 @@ build:
   image: latest
 
 python:
-  version: 3.6
+  version: 3.8
   setup_py_install: true
diff --git a/README.md b/README.md
index ea54c32..75e98ac 100644
--- a/README.md
+++ b/README.md
@@ -23,17 +23,10 @@ Check out the [documentation](https://moddb.readthedocs.io) for more information
 [Support](https://discord.gg/Ape8bZt)
 
 ## Installing
-This package is not available on Pypi but can still be installed using pip if you also have git installed. Else you will have to download the repository and instal using the setup.py script located at root. To install with pip
+You can get it from pypi: https://pypi.org/project/moddb
 
 ```
-pip install -U git+git://github.com/ClementJ18/moddb.git@v0.7
-```
-
-## Uninstalling
-To uninstall the package
-
-```
-pip uninstall moddb
+pip install moddb
 ```
 
 ## Models
@@ -56,6 +49,8 @@ pip uninstall moddb
 * [x] HardwAre
 * [x] Updates
 * [x] Friend Requests
+* [ ] Watchers
+* [ ] Tags
 
 Maybe
 * [ ] Messages
@@ -66,3 +61,6 @@ Maybe
 * **Boxes**: Containers present on pages, a **div** tag which contains information around a certain theme and as such have been grouped into Box Models of such.
 * **Pages**: Another name for Models.
 * **Thumbnails**: A very widely used model meant to represent models which are references but not expanded onto. Usually the model in question will only include a url and the name of the page. This is transformed into a thumbnail and the user can then parse it with the built-in method.
+
+## Development
+requirement.txt file contains the necessary additional libraries for develompment
diff --git a/docs/source/boxes.rst b/docs/source/boxes.rst
index 3cfa3ea..426e415 100644
--- a/docs/source/boxes.rst
+++ b/docs/source/boxes.rst
@@ -113,4 +113,10 @@ Option
 .. autoclass:: moddb.boxes.Option
     :members:
     :inherited-members:
+
+Mirror
+-------
+.. autoclass:: moddb.boxes.Mirror
+    :members:
+    :inherited-members:
     
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 2e60dbf..64363f6 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -20,13 +20,13 @@
 # -- Project information -----------------------------------------------------
 
 project = 'moddb'
-copyright = '2018, Clement Julia'
+copyright = '2022, Clement Julia'
 author = 'Clement Julia'
 
 # The short X.Y version
 version = ''
 # The full version, including alpha/beta/rc tags
-release = '1.0'
+release = '0.8.0'
 
 
 # -- General configuration ---------------------------------------------------
diff --git a/docs/source/index.rst b/docs/source/index.rst
index ae3f049..b3a1c60 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -40,7 +40,7 @@ Welcome to moddb's documentation!
     :alt: Commits Since
 
 The moddb python library is a module to scrape the ModDB.com website. The goal is to emulate
-the moddb website using function and OOP as if ModDB was simply an API. Ideally, in it's finished
+the moddb website using function and OOP as if ModDB was simply an API. Ideally, in its finished
 state you should be able to pair with a web framework to perfectly emulate the website and all
 its features.
 
@@ -57,7 +57,8 @@ The simplest way to use this library is to simply pass a ModDB url to the parse
    mod = moddb.parse("http://www.moddb.com/mods/edain-mod")
    print(mod.name) #Edain Mod
 
-The library tries to get the type of the url you are passing on its own but due to inconsistencies in the ModDB site this is not always correct, if you desire to be more specific you can pass a ThumbnailType to the function:: 
+The library tries to get the type of the url you are passing on its own but due to inconsistencies in the ModDB 
+site this is not always correct, if you desire to be more specific you can pass a ThumbnailType to the function:: 
 
    import moddb
    mod = moddb.parse(
@@ -68,7 +69,10 @@ The library tries to get the type of the url you are passing on its own but due
 
 Advanced Usage
 ---------------
-Of course, relying on the library isn't always what we want, you'd be forced to use requests plus nothing's perfect and errors might occur. In some cases, you might benefit from having a finer control upon the process, in which case, it is good to know that you can easily do this with few additional imports. Given you suddenly want to use something like aiohttp to get the pages you could easily do something like this:: 
+Of course, relying on the library isn't always what we want, you'd be forced to use requests plus nothing's 
+perfect and errors might occur. In some cases, you might benefit from having a finer control upon the process, 
+in which case, it is good to know that you can easily do this with few additional imports. Given you suddenly 
+want to use something like aiohttp to get the pages you could easily do something like this:: 
 
    import moddb
    import aiohttp
@@ -80,7 +84,9 @@ Of course, relying on the library isn't always what we want, you'd be forced to
 
       return moddb.Mod(soup)
 
-This would return a mod object, with you only having to import the bit you want to change from the original library, in that case aiohttp. This is not fully supported for all things however, for example trying to get the articles for that mod page you would be forced to completly parse the result page on your own or be forced to use requests.
+This would return a mod object, with you only having to import the bit you want to change from the original library, 
+in that case aiohttp. This is not fully supported for all things however, for example trying to get the articles for 
+that mod page you would be forced to completly parse the result page on your own or be forced to use requests.
 
 See more snippets there: :ref:`snippets-ref`.
 
@@ -88,7 +94,10 @@ See more snippets there: :ref:`snippets-ref`.
 Searching
 ----------
 
-The package supports searching moddb with an optional query, filters and to sort those results. Filters differ widely depending on which model is being searched therefore a good read of the documentation of the search function is recommended. The only required parameter of the search function is a valid moddb.SearchCategory as the first argument. All other arguments are optional. the `search` function returns a moddb.Search object. The most basic example is:: 
+The package supports searching moddb with an optional query, filters and to sort those results. Filters differ widely 
+depending on which model is being searched therefore a good read of the documentation of the search function is 
+recommended. The only required parameter of the search function is a valid moddb.SearchCategory as the first argument. 
+All other arguments are optional. the `search` function returns a moddb.Search object. The most basic example is:: 
 
    from moddb import search, SearchCategory
    search = search(SearchCategory.mods)
@@ -102,7 +111,9 @@ You can also search titles of the models with a string query::
    #returns a search objects with the results being a list of 
    #mod thumbnails which moddb has matched with the words age of the ring
 
-Many additional filters are available and listed in the documentation, each filter kwarg will expected a specific enum. The only exception is the game filters which expects either a game object or a moddb.Object with an id attribute. For example if we want to search for a released fantasy mod::
+Many additional filters are available and listed in the documentation, each filter kwarg will expected a specific enum. 
+The only exception is the game filters which expects either a game object or a moddb.Object with an id attribute. For example 
+if we want to search for a released fantasy mod::
 
    from moddb import search, SearchCategory, Theme, Status
    search = search(SearchCategory.mods, 
@@ -112,7 +123,8 @@ Many additional filters are available and listed in the documentation, each filt
    #thumbnails of mods which have been released and are labelled as 
    #fantasy themed.
 
-And finally, you can sort the results with the `sort` kwargs which expects a tuple with the first element being a sort category which is valid for the model you're sorting for and the second being whether you want the sort to be ascending or descending::
+And finally, you can sort the results with the `sort` kwargs which expects a tuple with the first element being a sort 
+category which is valid for the model you're sorting for and the second being whether you want the sort to be ascending or descending::
 
    from moddb import search, SearchCategory, Sort
    search = search(SearchCategory.mods, sort=("rating", "asc"))
@@ -122,7 +134,9 @@ And finally, you can sort the results with the `sort` kwargs which expects a tup
 Logging
 --------
 
-This package makes use of the powerful Python logging module. Whenever the scrapper is unable to find a specific field for a model, either because it does not exist or because the scrapper cannot see it with its current permissions, then it will log it as an info. All logs will be sent to the `moddb` logger, to have access to the stream of logs you can do things like::
+This package makes use of the powerful Python logging module. Whenever the scrapper is unable to find a specific field 
+for a model, either because it does not exist or because the scrapper cannot see it with its current permissions, then it 
+will log it as an info. All logs will be sent to the `moddb` logger, to have access to the stream of logs you can do things like::
 
    import logging
    logging.basicConfig(level=logging.INFO)
@@ -139,28 +153,25 @@ or::
 Account
 --------
 
-This package allows users to login into ModDB in order to have access to additional information which Guest users (the account the package uses by default) cannot see such as Guest comments waiting for approval or private groups/members. In order to login simply use the built-in library method::
+This package allows users to login into ModDB in order to have access to additional information which Guest users 
+(the account the package uses by default) cannot see such as Guest comments waiting for approval or private 
+groups/members. In order to login simply use the built-in library method::
 
    import moddb
    moddb.login("valid_username", "valid_password")
 
-The session the package uses will be updated and all further requests will now be made logged in as that user. The function will raise a ValueError if the login fails. You can also log out of the account to disable access to those restricted elements with the `logout` function::
+The session the package uses will be updated and all further requests will now be made logged in as that user. 
+The function will raise a ValueError if the login fails. You can also log out of the account to disable access 
+to those restricted elements with the `logout` function::
 
    import moddb
    moddb.logout()
 
 Installing
 ------------
-This package is not available on Pypi but can still be installed using pip if you also have git installed. Else you will have to download the repository and instal using the setup.py script located at root. To install with pip:: 
-
-    pip install -U git+git://github.com/ClementJ18/moddb.git@v0.7
-
-Uninstalling
--------------
-To uninstall the package:: 
-
-    pip uninstall moddb
+Install with pip::
 
+   pip install moddb==0.8.0
 
 .. toctree::
    :maxdepth: 2
diff --git a/moddb/__init__.py b/moddb/__init__.py
index 8bcf51d..e6cdacb 100644
--- a/moddb/__init__.py
+++ b/moddb/__init__.py
@@ -5,7 +5,7 @@
 from .client import Client
 
 import requests
+
 SESSION = requests.Session()
 
-__version__ = "0.7"
-    
\ No newline at end of file
+__version__ = "0.8.0"
diff --git a/moddb/base.py b/moddb/base.py
index 143ef45..e08d73c 100644
--- a/moddb/base.py
+++ b/moddb/base.py
@@ -1,20 +1,25 @@
 from .enums import SearchCategory, ThumbnailType, RSSType
 from .boxes import Thumbnail
-from .utils import get_page, LOGGER, normalize, get_type_from, BASE_URL, request
+from .utils import (
+    get_page,
+    LOGGER,
+    normalize,
+    get_type_from,
+    BASE_URL,
+    generate_login_cookies,
+)
 from .pages import FrontPage, Member
 
-import re
 import sys
 import toolz
-import feedparser
 import collections
 from typing import Tuple, Any
-from robobrowser import RoboBrowser
 
 __all__ = ["Search", "search", "parse", "login", "logout", "front_page"]
 
+
 class Search(collections.abc.MutableSequence):
-    """Represents the search you just conducted through the library's search function. Can be used to navigate 
+    """Represents the search you just conducted through the library's search function. Can be used to navigate
     the search page efficiently. Behaves like a list.
 
     Attributes
@@ -37,6 +42,7 @@ class Search(collections.abc.MutableSequence):
         Dictionnary of filters used to keep the query list persistent, can also be
         unpacked and passed to the search function.
     """
+
     def __init__(self, **kwargs):
         self._results = kwargs.get("results")
         self.category = kwargs.get("category")
@@ -47,8 +53,8 @@ def __init__(self, **kwargs):
         self.results_max = kwargs.get("results_max")
         self.sort = kwargs.get("sort")
 
-    def next_page(self) -> 'Search':
-        """Returns a new search object with the next page of results, will raise ValueError 
+    def next_page(self) -> "Search":
+        """Returns a new search object with the next page of results, will raise ValueError
         if the last page is the current one
 
         Returns
@@ -64,10 +70,10 @@ def next_page(self) -> 'Search':
         if self.page == self.max_page:
             raise ValueError("Reached last page already")
 
-        return self.to_page(self.page+1)
+        return self.to_page(self.page + 1)
 
-    def previous_page(self) -> 'Search': 
-        """Returns a new search object with the previous page of results, will raise 
+    def previous_page(self) -> "Search":
+        """Returns a new search object with the previous page of results, will raise
         ValueError if the first page is the current one
 
         Returns
@@ -83,13 +89,13 @@ def previous_page(self) -> 'Search':
         if self.page == 1:
             raise ValueError("Reached first page already")
 
-        return self.to_page(self.page-1)
+        return self.to_page(self.page - 1)
 
-    def to_page(self, page : int) -> 'Search': 
-        """Returns a new search object with results to a specific page in the search results 
-        allowing for fast navigation. Will raise ValueError if you attempt to navigate out 
+    def to_page(self, page: int) -> "Search":
+        """Returns a new search object with results to a specific page in the search results
+        allowing for fast navigation. Will raise ValueError if you attempt to navigate out
         of bounds.
-    
+
         Parameters
         -----------
         page : int
@@ -108,9 +114,11 @@ def to_page(self, page : int) -> 'Search':
         if page < 1 or page > self.max_page:
             raise ValueError(f"Please pick a page between 1 and {self.max_page}")
 
-        return search(self.category, query=self.query, page=page, sort=self.sort, **self.filters)
+        return search(
+            self.category, query=self.query, page=page, sort=self.sort, **self.filters
+        )
 
-    def resort(self, new_sort : Tuple[str, str]) -> 'Search': 
+    def resort(self, new_sort: Tuple[str, str]) -> "Search":
         """Allows you to sort the whole search by a new sorting parameters. Returns a new search object.
 
         Parameters
@@ -123,12 +131,14 @@ def resort(self, new_sort : Tuple[str, str]) -> 'Search':
         Search
             The new set of results with the updated sort order
         """
-        return search(self.category, query=self.query, page=1, sort=new_sort, **self.filters)
+        return search(
+            self.category, query=self.query, page=1, sort=new_sort, **self.filters
+        )
 
     def get_all_results(self):
         """An expensive methods that iterates over every page of the search and returns all
         the results. This may return more results than you expected if new page have fit the criteria
-        while iterating. 
+        while iterating.
 
         Returns
         --------
@@ -169,16 +179,25 @@ def insert(self, index, value):
 
     def __add__(self, sequence):
         if not isinstance(sequence, Search):
-            raise TypeError(f'can only concatenate Search (not "{sequence.__class__.__name__}") to Search')
+            raise TypeError(
+                f'can only concatenate Search (not "{sequence.__class__.__name__}") to Search'
+            )
 
         return self._results + sequence.results
 
     def __contains__(self, element):
         return get(self._results, name=element.name) is not None
 
-def search(category : SearchCategory, *, query : str = None, sort : Tuple[str, str] = None,
-           page : int = 1, **filters) -> Search: 
-    """ Search for for a certain type of models and return a list of thumbnails of that 
+
+def search(
+    category: SearchCategory,
+    *,
+    query: str = None,
+    sort: Tuple[str, str] = None,
+    page: int = 1,
+    **filters,
+) -> Search:
+    """Search for for a certain type of models and return a list of thumbnails of that
     model. This function was created to make full use of moddb's filter and sorting system as
     long as the appropriate parameters are passed. Because this function is a single one for every
     model type in moddb the parameters that can be passed vary between model type but the
@@ -194,7 +213,7 @@ def search(category : SearchCategory, *, query : str = None, sort : Tuple[str, s
     query : str
         String to search for in the model title
     sort : Tuple[str, str]
-        The tuple to sort by, look at the models your are searching for documentation 
+        The tuple to sort by, look at the models your are searching for documentation
         on sorting.
     page : int
         The page of results to get first
@@ -213,20 +232,25 @@ def search(category : SearchCategory, *, query : str = None, sort : Tuple[str, s
     game = game.id if game else None
 
     url = f"{BASE_URL}/{category.name}/page/{page}"
-    filter_parsed = {key : value.value for key, value in filters.items() if hasattr(value, "value")}
+    filter_parsed = {
+        key: value.value for key, value in filters.items() if hasattr(value, "value")
+    }
     cat = ThumbnailType[category.name[0:-1]]
 
-    html = get_page(url, params={
-        "filter": "t", 
-        "kw": query, 
-        "sort": sort_ready, 
-        "game": game, 
-        "year": filters.get("years", None), 
-        **filter_parsed
-    })
+    html = get_page(
+        url,
+        params={
+            "filter": "t",
+            "kw": query,
+            "sort": sort_ready,
+            "game": game,
+            "year": filters.get("years", None),
+            **filter_parsed,
+        },
+    )
 
     search_raws = html.find("div", class_="table").find_all("div", recursive=False)[1:]
-    
+
     try:
         pages = int(html.find("div", class_="pages").find_all()[-1].string)
         page = int(html.find("span", class_="current").string)
@@ -235,12 +259,28 @@ def search(category : SearchCategory, *, query : str = None, sort : Tuple[str, s
         pages = 1
         page = 1
 
-    results = [Thumbnail(url=x.a["href"], name=x.a["title"], type=cat, image=x.a.img["src"]) for x in search_raws]
-    results_max = int(normalize(html.find("h5", string=category.name.title()).parent.span.string))
-    return Search(results=results, max_page=pages, page=page, filters=filters, 
-                  category=category, query=query, results_max=results_max, sort=sort)
-
-def parse(url : str, *, page_type : ThumbnailType = None) -> Any: 
+    results = []
+    for x in search_raws:
+        image = x.a.img["src"] if x.a.img else None
+        thumbnail = Thumbnail(url=x.a["href"], name=x.a["title"], type=cat, image=image)
+        results.append(thumbnail)
+
+    results_max = int(
+        normalize(html.find("h5", string=category.name.title()).parent.span.string)
+    )
+    return Search(
+        results=results,
+        max_page=pages,
+        page=page,
+        filters=filters,
+        category=category,
+        query=query,
+        results_max=results_max,
+        sort=sort,
+    )
+
+
+def parse(url: str, *, page_type: ThumbnailType = None) -> Any:
     """Parse a url and return the appropriate object.
 
     Parameters
@@ -248,8 +288,8 @@ def parse(url : str, *, page_type : ThumbnailType = None) -> Any:
     url : str
         The url to parse
     page_type : Optional[ThumbnailType]
-        An optional argument which allows to specify a different model to be used to parse this page. In 
-        general there is no reason to touch this but in case an error happens or you wish to force 
+        An optional argument which allows to specify a different model to be used to parse this page. In
+        general there is no reason to touch this but in case an error happens or you wish to force
         something to happen out of the regular behavior the option is there.
 
     Returns
@@ -266,7 +306,7 @@ def parse(url : str, *, page_type : ThumbnailType = None) -> Any:
     return model
 
 
-def login(username : str, password : str) -> Member: 
+def login(username: str, password: str) -> Member:
     """Login the user to moddb through the library, this allows user to see guest comments and see
     private groups they are part of.
 
@@ -288,31 +328,17 @@ def login(username : str, password : str) -> Member:
         The member you are logged in as
     """
 
-    browser = RoboBrowser(history=True, parser='html.parser')
-    browser.open(f'{BASE_URL}/members/login')
-    t = browser.find_all("form")[1].find_all("input", class_="text", type="text")
-    t.remove(browser.find("input", id="membersusername"))
-    form = browser.get_form(attrs={"name": "membersform"})
-
-    form["password"].value = password
-    form["referer"].value = ""
-    form[browser.find("input", id="membersusername")["name"]].value = username
-    form[t[0]["name"]].value = ""
-
-    browser.submit_form(form)
-    sys.modules["moddb"].SESSION = browser.session
-
-    if "freeman" not in browser.session.cookies:
-        raise ValueError(f"Login failed for user {username}")
-
+    sys.modules["moddb"].SESSION.cookies = generate_login_cookies(username, password)
     return Member(get_page(f"{BASE_URL}/members/{username.replace('_', '-')}"))
 
-def logout(): 
-    """Logs the user out by clearing the cookies, all unapproved guest comments will be hidden and 
+
+def logout():
+    """Logs the user out by clearing the cookies, all unapproved guest comments will be hidden and
     all private groups will be hidden once more
     """
     sys.modules["moddb"].SESSION.cookies.clear()
 
+
 def front_page() -> FrontPage:
     """This returns a model representing the front page of  May sound fancy but it is no more
     than a collection of popular mods, files, games and articles. In addition jobs are listed
@@ -322,29 +348,23 @@ def front_page() -> FrontPage:
     --------
     FrontPage
         The front page object.
-        
+
     """
     html = get_page(BASE_URL)
     return FrontPage(html)
 
-def rss(type : RSSType, *, parse_feed = False):
+
+def rss(type: RSSType):
     """Get the RSS feed url for the entire site depending on which feed type you want
 
     Parameters
     -----------
     type : RSSType
         The type of feed you desire to get
-    parse_feed : Optional[bool]:
-        Set to true if you want the library to parse the rss feed for you and return the entries as a dict
-        rather than returning the url for the rss feed.
 
     Returns
     --------
-    Union[str, dict]
+    str
         URL for the feed type
     """
-    url = f'https://rss.moddb.com/{type.name}/feed/rss.xml'
-    if parse_feed:
-        return feedparser.parse(request(url).text)
-
-    return url
+    return f"https://rss.moddb.com/{type.name}/feed/rss.xml"
diff --git a/moddb/boxes.py b/moddb/boxes.py
index a5dbadd..88f9713 100644
--- a/moddb/boxes.py
+++ b/moddb/boxes.py
@@ -1,8 +1,32 @@
-from .enums import ThumbnailType, SearchCategory, Membership, Licence, Genre, Theme, \
-                   PlayerStyle, Scope, ArticleCategory, HardwareCategory, Status, \
-                   SoftwareCategory, AddonCategory, GroupCategory, TeamCategory
-from .utils import get_date, get_page, get_views, join, normalize, LOGGER, time_mapping, \
-                   get_type_from, concat_docs, get
+from .enums import (
+    ThumbnailType,
+    SearchCategory,
+    Membership,
+    Licence,
+    Genre,
+    Theme,
+    PlayerStyle,
+    Scope,
+    ArticleCategory,
+    HardwareCategory,
+    Status,
+    SoftwareCategory,
+    AddonCategory,
+    GroupCategory,
+    TeamCategory,
+)
+from .utils import (
+    get_date,
+    get_page,
+    get_views,
+    join,
+    normalize,
+    LOGGER,
+    time_mapping,
+    get_type_from,
+    concat_docs,
+    get,
+)
 
 import re
 import sys
@@ -10,9 +34,24 @@
 import collections
 from typing import List, Any, Tuple, Union
 
-__all__ = ['Statistics', 'Profile', 'Style', 'Thumbnail', 'Comment', 'CommentList', 'MemberProfile', 
-           'MemberStatistics', 'PlatformStatistics', 'PartialArticle', 'Option', 'ResultList',
-           'Request', 'Update', 'MissingComment']
+__all__ = [
+    "Statistics",
+    "Profile",
+    "Style",
+    "Thumbnail",
+    "Comment",
+    "CommentList",
+    "MemberProfile",
+    "MemberStatistics",
+    "PlatformStatistics",
+    "PartialArticle",
+    "Option",
+    "ResultList",
+    "Request",
+    "Update",
+    "MissingComment",
+]
+
 
 class Statistics:
     """The stats box, on pages that have one. This represents total stats and daily stats in one
@@ -45,9 +84,23 @@ class Statistics:
     updated : datetime.datetime
         The last time this page was updated
     """
+
     def __init__(self, html):
-        misc = html.find_all("h5", string=("Files", "Articles", "Reviews", "Watchers", "Mods", "Addons", "Members"))
-        self.__dict__.update({stat.string.lower() : int(normalize(stat.parent.a.string)) for stat in misc})
+        misc = html.find_all(
+            "h5",
+            string=(
+                "Files",
+                "Articles",
+                "Reviews",
+                "Watchers",
+                "Mods",
+                "Addons",
+                "Members",
+            ),
+        )
+        self.__dict__.update(
+            {stat.string.lower(): int(normalize(stat.parent.a.string)) for stat in misc}
+        )
 
         visits = normalize(html.find("h5", string="Visits").parent.a.string)
         self.visits, self.today = get_views(visits)
@@ -57,18 +110,21 @@ def __init__(self, html):
         self.total = int(rank[1].replace(",", ""))
 
         try:
-            self.updated = get_date(html.find("time", itemprop="dateModified")["datetime"])
+            self.updated = get_date(
+                html.find("time", itemprop="dateModified")["datetime"]
+            )
         except TypeError:
             self.updated = None
 
     def __repr__(self):
         return f"<Statistics rank={self.rank}/{self.total}>"
 
+
 class Profile:
     """The profile object is used for several models and as such attribute vary based on which model
     the profile is attached too. Profiles are only present on Mod, Game, Member, Addon, Engine, Company,
     Hardware, Software and Group pages.
-    
+
     Parameters
     -----------
     html : bs4.BeautifulSoup
@@ -94,7 +150,7 @@ class Profile:
     icon : str
         Exclusive to Game, Mod and Addon pages. URL of the icon image
     developers : dict
-        Exclusive to Game, Mods, Engine and Addon pages. Dictionnary of member/team like thumbnails as 
+        Exclusive to Game, Mods, Engine and Addon pages. Dictionnary of member/team like thumbnails as
         values and the role of the member/team as the key (creator, publisher, developer, ect...)
     release : datetime.datetime
         Exclusive to Game, Mods, Engine and Addon pages. Datetime object of when the page was
@@ -115,6 +171,7 @@ class Profile:
         Exclusive to Games, Mods, Addons, Engines, Hardware .Whether the thing is released, unreleased, ect...
 
     """
+
     def __init__(self, html):
         try:
             _name = html.find("a", itemprop="mainEntityOfPage").string
@@ -127,33 +184,56 @@ def __init__(self, html):
             url = html.find("meta", property="og:url")["content"]
         except TypeError:
             url = join(html.find("a", string=self.name)["href"])
-            
+
         regex = r"\/([a-z]+)\/"
         matches = re.findall(regex, url)
         matches.reverse()
-        page_type = SearchCategory[matches[0] if matches[0].endswith("s") else matches[0]+"s"]
-        
+        page_type = SearchCategory[
+            matches[0] if matches[0].endswith("s") else matches[0] + "s"
+        ]
+
         self.category = page_type
-        profile_raw = html.find("span", string="Profile").parent.parent.parent.find("div", class_="table tablemenu")
+        profile_raw = html.find("span", string="Profile").parent.parent.parent.find(
+            "div", class_="table tablemenu"
+        )
         self.contact = join(html.find("h5", string="Contact").parent.span.a["href"])
-        self.follow = join(profile_raw.find_all("h5", string=["Mod watch", "Game watch", "Group watch", "Engine watch", "Hardware watch", "Software watch"])[0].parent.span.a["href"])
-        
+        self.follow = join(
+            profile_raw.find_all(
+                "h5",
+                string=[
+                    "Mod watch",
+                    "Game watch",
+                    "Group watch",
+                    "Engine watch",
+                    "Hardware watch",
+                    "Software watch",
+                ],
+            )[0].parent.span.a["href"]
+        )
+
         try:
             share = profile_raw.find("h5", string="Share").parent.span.find_all("a")
             self.share = {
                 "reddit": share[0]["href"],
                 "mail": share[1]["href"],
                 "twitter": share[2]["href"],
-                "facebook": share[3]["href"]
+                "facebook": share[3]["href"],
             }
         except (AttributeError, IndexError):
-            LOGGER.info("Something funky about share box of %s %s", page_type.name, _name)
+            LOGGER.info(
+                "Something funky about share box of %s %s", page_type.name, _name
+            )
             self.share = None
 
         if page_type in [SearchCategory.developers, SearchCategory.groups]:
-            self.private = profile_raw.find("h5", string="Privacy").parent.span.string.strip() != "Public"
-
-            membership = profile_raw.find("h5", string="Subscription").parent.span.string.strip()
+            self.private = (
+                profile_raw.find("h5", string="Privacy").parent.span.string.strip()
+                != "Public"
+            )
+
+            membership = profile_raw.find(
+                "h5", string="Subscription"
+            ).parent.span.string.strip()
             if membership == "Open to all members":
                 self.membership = Membership(3)
             elif membership == "Must apply to join":
@@ -161,12 +241,45 @@ def __init__(self, html):
             else:
                 self.membership = Membership(1)
 
-        if page_type in [SearchCategory.games, SearchCategory.mods, SearchCategory.addons]:
-            self.icon = profile_raw.find("h5", string="Icon").parent.span.img["src"]
-
-        if page_type in [SearchCategory.games, SearchCategory.mods, SearchCategory.engines, SearchCategory.addons, SearchCategory.hardwares, SearchCategory.softwares]:
-            people = profile_raw.find_all("h5", string=["Developer", "Publisher", "Developer & Publisher","Creator", "Company"])
-            self.developers = {x.string.lower() : Thumbnail(url=x.parent.a["href"], name=x.parent.a.string, type=ThumbnailType.team if x.string != "Creator" else ThumbnailType.member) for x in people}            
+        if page_type in [
+            SearchCategory.games,
+            SearchCategory.mods,
+            SearchCategory.addons,
+        ]:
+            try:
+                self.icon = profile_raw.find("h5", string="Icon").parent.span.img["src"]
+            except AttributeError:
+                self.icon = None
+                LOGGER.info("%s '%s' does not have an icon", page_type, _name)
+
+        if page_type in [
+            SearchCategory.games,
+            SearchCategory.mods,
+            SearchCategory.engines,
+            SearchCategory.addons,
+            SearchCategory.hardwares,
+            SearchCategory.softwares,
+        ]:
+            people = profile_raw.find_all(
+                "h5",
+                string=[
+                    "Developer",
+                    "Publisher",
+                    "Developer & Publisher",
+                    "Creator",
+                    "Company",
+                ],
+            )
+            self.developers = {
+                x.string.lower(): Thumbnail(
+                    url=x.parent.a["href"],
+                    name=x.parent.a.string,
+                    type=ThumbnailType.team
+                    if x.string != "Creator"
+                    else ThumbnailType.member,
+                )
+                for x in people
+            }
 
             try:
                 d = profile_raw.find("h5", string="Release date").parent.span.time
@@ -185,12 +298,17 @@ def __init__(self, html):
                 self.status = Status.unreleased
 
             if page_type != SearchCategory.mods:
-                platforms = profile_raw.find("h5", string="Platforms").parent.span.find_all("a")
-                self.platforms = [Thumbnail(name=x.string, url=x["href"], type=ThumbnailType.platform) for x in platforms]
+                platforms = profile_raw.find(
+                    "h5", string="Platforms"
+                ).parent.span.find_all("a")
+                self.platforms = [
+                    Thumbnail(name=x.string, url=x["href"], type=ThumbnailType.platform)
+                    for x in platforms
+                ]
 
         if page_type != SearchCategory.groups:
             try:
-                self.homepage =  html.find("h5", string="Homepage").parent.span.a["href"]
+                self.homepage = html.find("h5", string="Homepage").parent.span.a["href"]
             except AttributeError:
                 self.homepage = None
                 LOGGER.info("%s %s has no homepage", page_type.name, _name)
@@ -208,17 +326,41 @@ def __init__(self, html):
             self.game = Thumbnail(url=url, name=name, type=ThumbnailType.game)
 
         if page_type in [SearchCategory.engines, SearchCategory.addons]:
-            self.licence = Licence(int(profile_raw.find("h5", string="Licence").parent.span.a["href"].split("=")[-1]))
+            self.licence = Licence(
+                int(
+                    profile_raw.find("h5", string="Licence")
+                    .parent.span.a["href"]
+                    .split("=")[-1]
+                )
+            )
 
         if page_type == SearchCategory.hardwares:
-            self.category = HardwareCategory(int(profile_raw.find("h5", string="Category").parent.span.a["href"].split("=")[-1]))
+            self.category = HardwareCategory(
+                int(
+                    profile_raw.find("h5", string="Category")
+                    .parent.span.a["href"]
+                    .split("=")[-1]
+                )
+            )
 
         if page_type == SearchCategory.softwares:
-            self.category = SoftwareCategory(int(profile_raw.find("h5", string="Category").parent.span.a["href"].split("=")[-1]))
+            self.category = SoftwareCategory(
+                int(
+                    profile_raw.find("h5", string="Category")
+                    .parent.span.a["href"]
+                    .split("=")[-1]
+                )
+            )
 
         if page_type == SearchCategory.addons:
-            self.category = AddonCategory(int(profile_raw.find("h5", string="Category").parent.span.a["href"].split("=")[-1]))
-        
+            self.category = AddonCategory(
+                int(
+                    profile_raw.find("h5", string="Category")
+                    .parent.span.a["href"]
+                    .split("=")[-1]
+                )
+            )
+
         if page_type == SearchCategory.developers:
             category = html.find("h3").string.strip().lower()
             try:
@@ -233,9 +375,10 @@ def __init__(self, html):
     def __repr__(self):
         return f"<Profile category={self.category.name}>"
 
+
 class Style:
-    """Represents semantic information on the page's theme. 
-    
+    """Represents semantic information on the page's theme.
+
     Parameters
     -----------
     html : bs4.BeautifulSoup
@@ -254,16 +397,22 @@ class Style:
     boxart : str
         URL of the boxart for the page.
     """
+
     def __init__(self, html):
         misc = html.find_all("h5", string=("Theme", "Genre", "Players"))
-        styles = {style.string.lower() : re.findall(r"(\d*)$", style.parent.a["href"])[0] for style in misc}
+        styles = {
+            style.string.lower(): re.findall(r"(\d*)$", style.parent.a["href"])[0]
+            for style in misc
+        }
 
         self.theme = Theme(int(styles["theme"]))
         self.genre = Genre(int(styles["genre"]))
-        self.players = PlayerStyle(int(styles["theme"])) 
+        self.players = PlayerStyle(int(styles["theme"]))
 
         try:
-            self.scope = Scope(int(html.find("h5", string="Project").parent.a["href"][-1]))
+            self.scope = Scope(
+                int(html.find("h5", string="Project").parent.a["href"][-1])
+            )
         except AttributeError:
             LOGGER.info("Has no scope")
 
@@ -275,10 +424,11 @@ def __init__(self, html):
     def __repr__(self):
         return f"<Style genre={self.genre.name} theme={self.theme.name} players={str(self.players)}>"
 
+
 class Thumbnail:
     """Thumbnail objects are minature version of ModDB models. They can be parsed to return the full
     version of the model.
-    
+
     Attributes
     -----------
     url : str
@@ -295,6 +445,7 @@ class Thumbnail:
         The type of the resource, mandatory attribute
 
     """
+
     def __init__(self, **attrs):
         self.url = join(attrs.get("url"))
         self.name = attrs.get("name", None)
@@ -318,9 +469,10 @@ def parse(self) -> Any:
         """
         return getattr(sys.modules["moddb"], self.type.name.title())(get_page(self.url))
 
+
 @concat_docs
 class Update(Thumbnail):
-    """An update object. Which is basically just a fancy thumbnail with a couple extra attributes and 
+    """An update object. Which is basically just a fancy thumbnail with a couple extra attributes and
     methods.
 
     Attributes
@@ -353,13 +505,13 @@ def clear(self):
         bool
             True if the updates were successfully cleared
         """
-        r = self._client._request("post", self._clear_url, data = {"ajax": "t"})
+        r = self._client._request("POST", self._clear_url, data={"ajax": "t"})
 
         return "successfully removed" in r.json()["text"]
 
     def unfollow(self):
         """Unfollows the page. This will also clear the updates
-        
+
         Raises
         -------
         ModdbException
@@ -370,13 +522,15 @@ def unfollow(self):
         bool
             True if the page was successfully unfollowed
         """
-        r = self._client._request("post", self._unfollow_url, data = {"ajax": "t"})
+        r = self._client._request("POST", self._unfollow_url, data={"ajax": "t"})
 
         return "no longer watching" in r.json()["text"]
 
+
 @concat_docs
 class Request(Thumbnail):
     """A thumbnail with two extra methods used to clear and accept requests."""
+
     def __init__(self, **attrs):
         super().__init__(**attrs)
 
@@ -397,7 +551,7 @@ def accept(self):
         bool
             True if the request was successfully accepted
         """
-        r = self._client._request("post", self._accept, data = {"ajax": "t"})
+        r = self._client._request("POST", self._accept, data={"ajax": "t"})
 
         return "now friends with" in r.json()["text"]
 
@@ -414,16 +568,17 @@ def decline(self):
         bool
             True if the page was successfully declined
         """
-        r = self._client._request("post", self._decline, data = {"ajax": "t"})
+        r = self._client._request("POST", self._decline, data={"ajax": "t"})
 
         return "successfully removed" in r.json()["text"]
 
     def __repr__(self):
         return f"<Request from={self.name}>"
 
+
 class Comment:
     """A moddb comment object.
-    
+
     Parameters
     -----------
     html : bs4.Tag
@@ -468,14 +623,20 @@ class Comment:
         thumbnail does not guarantee that you will find the comment if you parse it, since the url does not
         contain the page number.
     """
+
     def __init__(self, html):
         author = html.find("a", class_="avatar")
         self.id = int(html["id"])
-        self.author = Thumbnail(name=author["title"], url=author["href"], image=author.img["src"], type=ThumbnailType.member)
+        self.author = Thumbnail(
+            name=author["title"],
+            url=author["href"],
+            image=author.img["src"],
+            type=ThumbnailType.member,
+        )
         self.date = get_date(html.find("time")["datetime"])
         actions = html.find("span", class_="actions")
 
-        position = html["class"] 
+        position = html["class"]
         if "reply1" in position:
             self.position = 1
         elif "reply2" in position:
@@ -490,7 +651,11 @@ def __init__(self, html):
                 link.string = link["href"]
             self.content = html.find("div", class_="comment").text
         except AttributeError:
-            LOGGER.info("Comment %s by %s has no content, likely embed", self.id, self.author.name)
+            LOGGER.info(
+                "Comment %s by %s has no content, likely embed",
+                self.id,
+                self.author.name,
+            )
             self.content = None
 
         try:
@@ -519,19 +684,43 @@ def __init__(self, html):
         if self.location is not None:
             url = join(self.location["href"])
             page_type = get_type_from(url)
-            self.location = Thumbnail(name=self.location.string, url=url, type=page_type)
+            self.location = Thumbnail(
+                name=self.location.string, url=url, type=page_type
+            )
 
         try:
-            self._hash = html.find("a", title=("Delete", "Undelete"))["href"].split("=")[-1]
-        except  TypeError:
+            self._hash = html.find("a", title=("Delete", "Undelete"))["href"].split(
+                "="
+            )[-1]
+        except TypeError:
             self._hash = None
 
+    def is_stale(self):
+        """Comments are very volatile. If they are pushed onto another page by other comments
+        it becomes impossible to use objects with the previous page number. In addition,
+        calculating the new page number is no possible. Pages do not have a defined size but
+        rather grow and shrink based on sizes of individual comments. Finally, comments
+        also have token that can be used to modify them. These tokens have a hard life of
+        30 minutes from the time of the request. This function puts in place several mechanism
+        to verify wether or not the object can still be trusted.
+
+
+        Returns
+        --------
+        bool
+            True, the comment is stale and you should fetch a new version, False you **should**
+            be good to continue using it.
+        """
+
+        return False
+
     def __repr__(self):
         return f"<Comment author={self.author.name} position={self.position} approved={self.approved}>"
 
+
 class MissingComment:
     """An object to represent a missing comment. This used in the cases where a parent comment with
-    children is deleted so that the children may still be accessible, missing comment will have the 
+    children is deleted so that the children may still be accessible, missing comment will have the
     same attributes as a :class:`.Comment` but they will all be equal to None or False apart from children
     and the comment position, which will have the children of the comment that was deleted attached to it."""
 
@@ -552,11 +741,12 @@ def __init__(self, position):
         self.subscriber = False
         self.guest = False
         self.embeds = []
-        self.location = None 
+        self.location = None
 
     def __repr__(self):
         return f"<MissingComment position={self.position}>"
 
+
 class MemberProfile:
     """Member profiles are separate entities because they share nothing with the other profile boxes. Where as all
     other profile boxes share at least 4 attributes, a member shares none.
@@ -591,36 +781,49 @@ class MemberProfile:
     follow : str
         Link to follow a member
     """
+
     def __init__(self, html):
-        profile_raw = html.find("span", string="Profile").parent.parent.parent.find("div", class_="table tablemenu")
+        profile_raw = html.find("span", string="Profile").parent.parent.parent.find(
+            "div", class_="table tablemenu"
+        )
         level_raw = profile_raw.find("h5", string="Level").parent.span.div
         self.name = html.find("meta", property="og:title")["content"]
 
         self.level = int(level_raw.find("span", class_="level").string)
-        self.progress = float("0." + level_raw.find("span", class_="info").strong.string.replace("%", ""))
+        self.progress = float(
+            "0." + level_raw.find("span", class_="info").strong.string.replace("%", "")
+        )
         self.title = level_raw.find("span", class_="info").a.string
 
         self.avatar = profile_raw.find("div", class_="avatarinfo").img["src"]
         self.online = bool(profile_raw.find("h5", string="Status"))
         last_online = profile_raw.find("h5", string="Last Online")
-        self.last_online = get_date(last_online.parent.span.time["datetime"]) if last_online else None
+        self.last_online = (
+            get_date(last_online.parent.span.time["datetime"]) if last_online else None
+        )
 
         try:
-            self.gender = profile_raw.find("h5", string="Gender").parent.span.string.strip()
+            self.gender = profile_raw.find(
+                "h5", string="Gender"
+            ).parent.span.string.strip()
         except AttributeError:
             LOGGER.info("Member %s has not publicized their gender", self.name)
-            self.gender = None  
+            self.gender = None
 
         try:
-            self.homepage =  html.find("h5", string="Homepage").parent.span.a["href"]
+            self.homepage = html.find("h5", string="Homepage").parent.span.a["href"]
         except AttributeError:
             self.homepage = None
-            LOGGER.info("Member %s has no homepage", self.name)      
+            LOGGER.info("Member %s has no homepage", self.name)
 
-        self.country = profile_raw.find("h5", string="Country").parent.span.string.strip()
+        self.country = profile_raw.find(
+            "h5", string="Country"
+        ).parent.span.string.strip()
 
         try:
-            self.follow = join(profile_raw.find("h5", string="Member watch").parent.span.a["href"])
+            self.follow = join(
+                profile_raw.find("h5", string="Member watch").parent.span.a["href"]
+            )
         except AttributeError:
             LOGGER.info("Can't watch yourself, narcissist...")
             self.follow = None
@@ -628,8 +831,9 @@ def __init__(self, html):
     def __repr__(self):
         return f"<MemberProfile name={self.name}>"
 
+
 class MemberStatistics:
-    """Similarly, a member statistics shared no common ground with other stats and therefore there was a 
+    """Similarly, a member statistics shared no common ground with other stats and therefore there was a
     need for a separate object.
 
     Parameters
@@ -660,22 +864,35 @@ class MemberStatistics:
     total : int
         the maximum rank
     """
+
     def __init__(self, html):
         def get(parent):
             return parent.a.string.strip() if parent.a else parent.span.string.strip()
 
         name = html.find("meta", property="og:title")["content"]
-        misc = html.find_all("h5", string=("Watchers", "Activity Points", "Comments", "Tags", "Site visits"))
-        self.__dict__.update({stat.string.lower().replace(" ", "_") : int(normalize(get(stat.parent))) for stat in misc})
+        misc = html.find_all(
+            "h5",
+            string=("Watchers", "Activity Points", "Comments", "Tags", "Site visits"),
+        )
+        self.__dict__.update(
+            {
+                stat.string.lower().replace(" ", "_"): int(normalize(get(stat.parent)))
+                for stat in misc
+            }
+        )
 
         visits = normalize(html.find("h5", string="Visitors").parent.a.string)
         self.visits, self.today = get_views(visits)
 
-        time, mapping = html.find("h5", string="Time Online").parent.span.string.strip().split(" ")
-        self.time = time_mapping[mapping.replace('s', '')] * int(time)
+        time, mapping = (
+            html.find("h5", string="Time Online").parent.span.string.strip().split(" ")
+        )
+        self.time = time_mapping[mapping.replace("s", "")] * int(time)
 
         try:
-            rank = normalize(html.find("h5", string="Rank").parent.span.string).split("of")
+            rank = normalize(html.find("h5", string="Rank").parent.span.string).split(
+                "of"
+            )
             self.rank = int(rank[0].replace(",", ""))
             self.total = int(rank[1].replace(",", ""))
         except AttributeError:
@@ -686,9 +903,10 @@ def get(parent):
     def __repr__(self):
         return f"<MemberStatistics rank={self.rank}/{self.total}>"
 
+
 class PlatformStatistics:
     """Stats for platform pages.
-    
+
     Parameters
     -----------
     html : bs4.BeautifulSoup
@@ -707,14 +925,23 @@ class PlatformStatistics:
     mods : int
         Number of mods created for this platform
     """
+
     def __init__(self, html):
         headings = ("Hardware", "Software", "Engines", "Games", "Mods")
         html_headings = html.find_all("h5", string=headings)
-        self.__dict__.update({headings[html_headings.index(x)].lower() : int(normalize(x.parent.span.a.string)) for x in html_headings})
+        self.__dict__.update(
+            {
+                headings[html_headings.index(x)].lower(): int(
+                    normalize(x.parent.span.a.string)
+                )
+                for x in html_headings
+            }
+        )
 
     def __repr__(self):
         return f"<PlatformStatistics>"
 
+
 class PartialArticle:
     """A partial article is an article object missing attributes due to being parsed from the front page
     intead of from the article page itself. In general, it' is simple enough for previewing the article
@@ -737,9 +964,10 @@ class PartialArticle:
         Type of the article
     content : str
         html of the article content
-    plaintext : str 
+    plaintext : str
         plaintext of the article content (without html)
     """
+
     def __init__(self, html):
         meta_raw = html.find("div", class_="row rowcontent rownoimage clear")
 
@@ -747,7 +975,12 @@ def __init__(self, html):
         self.url = join(meta_raw.h4.a["href"])
         self.date = get_date(meta_raw.find("time")["datetime"])
         try:
-            self.type = ArticleCategory[meta_raw.find("span", class_="subheading").text.strip().split(" ")[0].lower()]
+            self.type = ArticleCategory[
+                meta_raw.find("span", class_="subheading")
+                .text.strip()
+                .split(" ")[0]
+                .lower()
+            ]
         except KeyError:
             self.type = ArticleCategory.news
 
@@ -758,7 +991,7 @@ def __init__(self, html):
     def __repr__(self):
         return f"<PartialArticle title={self.name}>"
 
-    def get_article(self) -> 'Article':
+    def get_article(self) -> "Article":
         """Returns the full article object of this article.
 
         Returns
@@ -768,6 +1001,7 @@ def get_article(self) -> 'Article':
         """
         return getattr(sys.modules["moddb"], "Article")(get_page(self.url))
 
+
 class Option:
     """Represents one of the choice from the poll they are attached to, should not be created
     manually, prefer relying on the Poll.
@@ -777,12 +1011,13 @@ class Option:
     id : int
         The id of the option, can be None and will be None in most cases.
     text : str
-        The option's text 
+        The option's text
     votes : int
         The number of votes that have been cast on this option
     percent : int
         The percent of all votes that have been cast on this option
     """
+
     def __init__(self, **kwargs):
         self.id = kwargs.get("id", None)
         self.text = kwargs.get("text")
@@ -792,6 +1027,7 @@ def __init__(self, **kwargs):
     def __repr__(self):
         return f"<Option text={self.text}>"
 
+
 class ModDBList(collections.abc.MutableSequence):
     """Base List type for the lib
 
@@ -814,6 +1050,7 @@ class ModDBList(collections.abc.MutableSequence):
         used to get another set of results. Must be able to take a positional argument `url`
         and a keyword argument `params`.
     """
+
     def __init__(self, **kwargs):
         self._results = kwargs.get("results")
         self._params = kwargs.get("params", {})
@@ -828,8 +1065,7 @@ def _do_action(self, url, params={}):
 
         return self._action(url)
 
-
-    def next_page(self) -> Union['ResultList', 'CommentList']:
+    def next_page(self) -> Union["ResultList", "CommentList"]:
         """Returns the next page of results as either a CommentList if you are retriving comments or
         as a ResultList if it's literally anything else.
 
@@ -846,9 +1082,9 @@ def next_page(self) -> Union['ResultList', 'CommentList']:
         if self.page == self.max_page:
             raise ValueError("Reached last page already")
 
-        return self.to_page(self.page+1)
+        return self.to_page(self.page + 1)
 
-    def previous_page(self) -> Union['ResultList', 'CommentList']: 
+    def previous_page(self) -> Union["ResultList", "CommentList"]:
         """Returns the previous page of results as either a CommentList if you are retriving comments or
         as a ResultList if it's literally anything else.
 
@@ -865,12 +1101,12 @@ def previous_page(self) -> Union['ResultList', 'CommentList']:
         if self.page == 1:
             raise ValueError("Reached first page already")
 
-        return self.to_page(self.page-1)
+        return self.to_page(self.page - 1)
 
-    def to_page(self, page : int) -> Union['ResultList', 'CommentList']: 
+    def to_page(self, page: int) -> Union["ResultList", "CommentList"]:
         """Returns the desired page of results as either a CommentList if you are retriving comments or
         as a ResultList if it's literally anything else.
-    
+
         Parameters
         -----------
         page : int
@@ -895,7 +1131,7 @@ def to_page(self, page : int) -> Union['ResultList', 'CommentList']:
     def get_all_results(self):
         """An expensive methods that iterates over every page of the result query and returns all
         the results. This may return more results than you expected if new page have fit the criteria
-        while iterating. 
+        while iterating.
 
         Returns
         --------
@@ -941,11 +1177,12 @@ def __setitem__(self, key, value):
     def insert(self, index, value):
         self._results.insert(index, value)
 
+
 class ResultList(ModDBList):
-    """Represents a list of result gotten from one of the many get methods the library uses. This is returned 
+    """Represents a list of result gotten from one of the many get methods the library uses. This is returned
     over a regular list because it has additional methods that allow for easily go through all the results. In
     the same way that the moddb site works, you don't have to re-run the query manually to get the next page,
-    you simply click a button, same here, you don't have to recall the base get method, simply use on of the 
+    you simply click a button, same here, you don't have to recall the base get method, simply use on of the
     methods here to traverse the results. This emulates a list and will behave like one, so you
     can use any of the regular list operators in addition to the methods defined below
 
@@ -957,7 +1194,7 @@ class ResultList(ModDBList):
         The maximum amount of result pages available
     """
 
-    def resort(self, new_sort : Tuple[str, str]) -> 'ResultList': 
+    def resort(self, new_sort: Tuple[str, str]) -> "ResultList":
         """Allows you to sort the whole search by a new sorting parameters. Returns a new search object.
 
         Parameters
@@ -980,7 +1217,7 @@ def __contains__(self, element):
 class CommentList(ModDBList):
     """Represents a list of comments. This emulates a list and will behave like one, so you
     can use any of the regular list operators in addition to the methods defined below.
-    
+
     Attributes
     -----------
     page : int
@@ -995,17 +1232,17 @@ def __contains__(self, element):
     def flatten(self) -> List[Comment]:
         """Returns a 'flattened' list of comments where children of comments are added right
         after the parent comment so:
-        
+
         [ Comment1 ]
             ├── Comment2\n
-                ├── Comment3\n
-                └── Comment4\n
+            |   ├── Comment3\n
+            |   └── Comment4\n
             └── Comment5
 
         would become:
-        
+
         [Comment1, Comment2, Comment3, Comment4, Comment5]
-        
+
         Returns
         --------
         List[Comment]
@@ -1019,3 +1256,40 @@ def flatten(self) -> List[Comment]:
                 top_list.extend(child.children)
 
         return top_list
+
+
+class Mirror:
+    """Represents a download mirror from which the user can download a file
+
+    Attributes
+    -----------
+    name : str
+        The name of the mirror
+    index : int
+        The index of the mirror, as multiple mirrors
+        have the same name. Index starts at 1
+    city : str
+        Alpha 2 code for the city the server is located
+        in
+    country : str
+        Alpha 2 code for the country the server is
+        located in
+    served : int
+        How many downloads of this file this mirror has
+        served
+    capacity : float
+        The current capacity of this server as a percentage.
+        E.g. 35.5 -> 35.5%. Lower is better for speed.
+    """
+
+    def __init__(self, **kwargs):
+        self.name = kwargs.get("name")
+        self.index = kwargs.get("index")
+        self.city = kwargs.get("city")
+        self.country = kwargs.get("country")
+        self.served = kwargs.get("served")
+        self.capacity = kwargs.get("capacity")
+        self._url = kwargs.get("url")
+
+    def __repr__(self):
+        return f"<Mirror name={self.name} index={self.index} >"
diff --git a/moddb/client.py b/moddb/client.py
index 4b58983..cd9fdf9 100644
--- a/moddb/client.py
+++ b/moddb/client.py
@@ -1,20 +1,32 @@
 import sys
-import json
 import time
 import random
 import requests
-from robobrowser import RoboBrowser
 
-from .utils import soup, get_type_from, get_date, BASE_URL, get_page_number, generate_hash, get, LOGGER, user_agent_list
+from .utils import (
+    generate_login_cookies,
+    soup,
+    get_type_from,
+    get_date,
+    BASE_URL,
+    get_page_number,
+    generate_hash,
+    get,
+    LOGGER,
+    user_agent_list,
+    raise_for_status,
+)
 from .boxes import Update, Thumbnail, Request, Comment, ResultList
-from .pages import Member, Group, Mod, Game, Engine, Team
+from .pages import Member
 from .enums import ThumbnailType, WatchType, Status
 from .errors import ModdbException
 from .base import parse
+from .utils import LIMITER
+
 
 class Client:
     """Login the user to moddb through the library, this allows user to see guest comments and see
-    private groups they are part of. In addition, this can be used for a lot of the operation 
+    private groups they are part of. In addition, this can be used for a lot of the operation
 
     Parameters
     -----------
@@ -35,27 +47,19 @@ class Client:
         The member objects this client instance represents
     """
 
-    def __init__(self, username : str, password : str):
-        browser = RoboBrowser(history=True, parser='html.parser')
-        browser.open(f'{BASE_URL}/members/login')
-        t = browser.find_all("form")[1].find_all("input", class_="text", type="text")
-        t.remove(browser.find("input", id="membersusername"))
-        form = browser.get_form(attrs={"name": "membersform"})
-
-        form["password"].value = password
-        form["referer"].value = ""
-        form[browser.find("input", id="membersusername")["name"]].value = username
-        form[t[0]["name"]].value = ""
-
-        browser.submit_form(form)
-        self._session = browser.session
-
-        if "freeman" not in browser.session.cookies:
-            raise ValueError(f"Login failed for user {username}")
-
+    def __init__(self, username: str, password: str):
+        session = requests.Session()
+        session.cookies = generate_login_cookies(username, password)
+        self._session = session
         LOGGER.info("Login successful for %s", username)
 
-        self.member = Member(soup(self._request("get", f"{BASE_URL}/members/{username.replace('_', '-')}").text))
+        self.member = Member(
+            soup(
+                self._request(
+                    "GET", f"{BASE_URL}/members/{username.replace('_', '-')}"
+                ).text
+            )
+        )
         self._last_comment_time = 0
 
     def __repr__(self):
@@ -69,42 +73,54 @@ def __exit__(self, exc_type, exc_val, exc_tb):
         sys.modules["moddb"].SESSION = self._fake_session
         delattr(self, "_fake_session")
 
+    @LIMITER.ratelimit("moddb", delay=True)
     def _request(self, method, url, **kwargs):
         """Making sure we do our request with the cookies from this client rather than the cookies
         of the library."""
-        route = getattr(requests, method)
         cookies = cookies = requests.utils.dict_from_cookiejar(self._session.cookies)
-        headers = {**kwargs.pop("headers", {}), "User-Agent": random.choice(user_agent_list)}
-        r = route(url, cookies=cookies, headers=headers, **kwargs)
-        return self._proccess_response(r)
+        headers = {
+            **kwargs.pop("headers", {}),
+            "User-Agent": random.choice(user_agent_list),
+        }
 
-    def _proccess_response(self, r):
-        #if we're making an ajax request we'll get a json response that we decode and check for errors
-        try:
-            text = r.json()
-            if text.get("error", False):
-                LOGGER.error(text["text"])
-                raise ModdbException(text["text"])
-        except json.decoder.JSONDecodeError:
-            r.raise_for_status()
+        req = requests.Request(
+            method, url, headers=headers, cookies=cookies, data=kwargs.pop("data", {})
+        )
+        prepped = self._session.prepare_request(req)
+
+        r = self._session.send(
+            prepped, allow_redirects=kwargs.pop("allow_redirects", True)
+        )
+        raise_for_status(r)
 
         return r
 
     def get_updates(self):
         """Get the current updates the user has for models they are subscribed to.
-        
+
         Returns
         --------
         List[Update]
             List of updates (thumbnail like objects with extra methods and an extra attribute)
         """
-        r = self._request("get", f"{BASE_URL}/messages/updates")
+        r = self._request("GET", f"{BASE_URL}/messages/updates")
         html = soup(r.text)
         updates = []
-        
-        strings = ("Mods Watch", "Members Watch", "Engines Watch", "Groups Watch", "Games Watch")
+
+        strings = (
+            "Mods Watch",
+            "Members Watch",
+            "Engines Watch",
+            "Groups Watch",
+            "Games Watch",
+        )
         raw = html.find_all("span", string=strings)
-        objects = [e.parent.parent.parent.find("div", class_="table").find_all("div", recursive=False) for e in raw]
+        objects = [
+            e.parent.parent.parent.find("div", class_="table").find_all(
+                "div", recursive=False
+            )
+            for e in raw
+        ]
 
         objects_raw = [item for sublist in objects for item in sublist[:-1]]
         for update in objects_raw:
@@ -113,45 +129,66 @@ def get_updates(self):
             clear = update.find("a", title="Clear")["href"]
             updates_raw = update.find("p").find_all("a")
 
-            updates.append(Update(
-                name=thumbnail["title"], url=thumbnail["href"], type=get_type_from(thumbnail["href"]), 
-                image=thumbnail.img["src"], client=self, unfollow=unfollow, clear=clear,
-                updates = [Thumbnail(name=x.string, url=x["href"], type=get_type_from(x["href"])) for x in updates_raw],
-                date=get_date(update.find("time")["datetime"])
-            ))
+            updates.append(
+                Update(
+                    name=thumbnail["title"],
+                    url=thumbnail["href"],
+                    type=get_type_from(thumbnail["href"]),
+                    image=thumbnail.img["src"],
+                    client=self,
+                    unfollow=unfollow,
+                    clear=clear,
+                    updates=[
+                        Thumbnail(
+                            name=x.string, url=x["href"], type=get_type_from(x["href"])
+                        )
+                        for x in updates_raw
+                    ],
+                    date=get_date(update.find("time")["datetime"]),
+                )
+            )
 
         return updates
 
     def get_friend_requests(self):
         """Get the current friend requests the user has.
-        
+
         Returns
         --------
         List[Request]
             List of requests (thumbnail like objects with extra methods)
         """
-        r = self._request("get", f"{BASE_URL}/messages/updates")
+        r = self._request("GET", f"{BASE_URL}/messages/updates")
         html = soup(r.text)
         requests = []
         raw = html.find("span", string="Friend Requests")
-        raw_requests = raw.parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)
+        raw_requests = raw.parent.parent.parent.find("div", class_="table").find_all(
+            "div", recursive=False
+        )
 
         for request in raw_requests[:-1]:
             thumbnail = request.find("a")
             accept = request.find("a", title="Accept")["href"]
             decline = request.find("a", title="Decline")["href"]
 
-            requests.append(Request(
-                name=thumbnail["title"], url=thumbnail["href"], type=get_type_from(thumbnail["href"]), 
-                image=thumbnail.img["src"], client=self, accept=accept, decline=decline,
-                date=get_date(request.find("time")["datetime"])
-            ))
+            requests.append(
+                Request(
+                    name=thumbnail["title"],
+                    url=thumbnail["href"],
+                    type=get_type_from(thumbnail["href"]),
+                    image=thumbnail.img["src"],
+                    client=self,
+                    accept=accept,
+                    decline=decline,
+                    date=get_date(request.find("time")["datetime"]),
+                )
+            )
 
         return requests
 
-    def get_watched(self, category :  WatchType, page : int = 1):
+    def get_watched(self, category: WatchType, page: int = 1):
         """Get a list of thumbnails of watched items based on the type parameters. Eventually, you'll also be
-        able to paginate your mods. 
+        able to paginate your mods.
 
         Parameters
         -----------
@@ -167,10 +204,25 @@ def get_watched(self, category :  WatchType, page : int = 1):
 
         """
         url = f"{BASE_URL}/messages/watching/{category.name}s/page/{page}"
-        html = soup(self._request("get", url).text)
+        html = soup(self._request("GET", url).text)
 
-        results_raw = html.find("div", class_="table").find_all("div", recursive=False)[1:]
-        results = [Thumbnail(url=x.a["href"], name=x.a["title"], type=ThumbnailType[category.name], image=x.a.img["src"]) for x in results_raw]
+        results_raw = html.find("div", class_="table").find_all("div", recursive=False)[
+            1:
+        ]
+
+        try:
+            results = [
+                Thumbnail(
+                    url=x.a["href"],
+                    name=x.a["title"],
+                    type=ThumbnailType[category.name],
+                    image=x.a.img["src"],
+                )
+                for x in results_raw
+            ]
+        except TypeError:
+            LOGGER.info("No %ss watched for %s", category.name, self.member.name)
+            results = []
 
         page, max_page = get_page_number(html)
 
@@ -179,12 +231,12 @@ def get_watched(self, category :  WatchType, page : int = 1):
             url=url,
             action=self.get_watched,
             page=page,
-            max_page=max_page
+            max_page=max_page,
         )
 
     def tracking(self, page):
         """Follow/unfollow this page.
-        
+
         Parameters
         -----------
         page : Union[Mod, Game, Engine, Group, Member]
@@ -200,19 +252,21 @@ def tracking(self, page):
         bool
             True if the page has been successfully followed, False if it has been successfully unfollowed
         """
-        r = self._request("post", f"{BASE_URL}/messages/ajax/action/",
-            data = {
+        r = self._request(
+            "POST",
+            f"{BASE_URL}/messages/ajax/action/",
+            data={
                 "ajax": "t",
                 "action": "watch",
                 "sitearea": page.url.split("/")[-2],
-                "siteareaid": page.id
+                "siteareaid": page.id,
             },
-            allow_redirects=False
+            allow_redirects=False,
         )
 
         return "be notified" in r.json()["text"]
 
-    def like_comment(self, comment : Comment):
+    def like_comment(self, comment: Comment):
         """Like a comment, if the comment has already been liked nothing will happen.
 
         Parameters
@@ -230,19 +284,21 @@ def like_comment(self, comment : Comment):
         bool
             True if the comment has been successfully liked
         """
-        r = self._request("post", f"{BASE_URL}/messages/ajax/action/",
-            data = {
+        r = self._request(
+            "POST",
+            f"{BASE_URL}/messages/ajax/action/",
+            data={
                 "ajax": "t",
                 "action": "karmagood",
                 "sitearea": "comment",
-                "siteareaid": comment.id
+                "siteareaid": comment.id,
             },
-            allow_redirects=False
+            allow_redirects=False,
         )
 
         return "successfully issued" in r.json()["text"]
 
-    def dislike_comment(self, comment : Comment):
+    def dislike_comment(self, comment: Comment):
         """Dislike a comment, if the comment has already been disliked nothing will happen.
 
         Parameters
@@ -263,14 +319,16 @@ def dislike_comment(self, comment : Comment):
         if not hasattr(comment, "downvote"):
             raise TypeError("Argument must be a Comment-like object")
 
-        r = self._request("post", f"{BASE_URL}/messages/ajax/action/",
-            data = {
+        r = self._request(
+            "POST",
+            f"{BASE_URL}/messages/ajax/action/",
+            data={
                 "ajax": "t",
                 "action": "karmabad",
                 "sitearea": "comment",
-                "siteareaid": comment.id
+                "siteareaid": comment.id,
             },
-            allow_redirects=False
+            allow_redirects=False,
         )
 
         return "successfully issued" in r.json()["text"]
@@ -295,9 +353,11 @@ def membership(self, page):
             successfully left.
 
         """
-        r = self._request("post", f"{BASE_URL}/groups/ajax/members/change/{page.id}",
-            data = {"ajax": "t"},
-            allow_redirects=False
+        r = self._request(
+            "POST",
+            f"{BASE_URL}/groups/ajax/members/change/{page.id}",
+            data={"ajax": "t"},
+            allow_redirects=False,
         )
 
         return "successfully joined" in r.json()["text"]
@@ -320,21 +380,23 @@ def report(self, page):
         bool
             True if the page has been successfully reported
         """
-        r = self._request("post", f"{BASE_URL}/messages/ajax/action/",
-            data = {
+        r = self._request(
+            "POST",
+            f"{BASE_URL}/messages/ajax/action/",
+            data={
                 "ajax": "t",
                 "action": "report",
                 "sitearea": page.url.split("/")[-2],
-                "siteareaid": page.id
+                "siteareaid": page.id,
             },
-            allow_redirects=False
+            allow_redirects=False,
         )
 
         return not "already reported this content" in r.json()["text"]
 
-    def unfriend(self, member : Member):
+    def unfriend(self, member: Member):
         """Unfriend this member if you are friends with them.
-        
+
         Parameters
         -----------
         member : Member
@@ -350,17 +412,19 @@ def unfriend(self, member : Member):
         bool
             True if the user was succesfully unfriended
         """
-        r = self._request("post", f"{BASE_URL}/members/ajax/friends/delete/{member.id}",
-            data = {"ajax": "t"},
-            allow_redirects=False
+        r = self._request(
+            "POST",
+            f"{BASE_URL}/members/ajax/friends/delete/{member.id}",
+            data={"ajax": "t"},
+            allow_redirects=False,
         )
 
         return "no longer friends with this member" in r.json()["text"]
 
-    def send_request(self, member :  Member):
+    def send_request(self, member: Member):
         """Send a friend request to a user. You will not instantly become friends with them,
         they will have to accept the friend request you sent them first.
-        
+
         Parameters
         -----------
         member : Member
@@ -376,14 +440,16 @@ def send_request(self, member :  Member):
         bool
             True if the user was succesfully sent a friend request
         """
-        r = self._request("post", f"{BASE_URL}/members/ajax/friends/add/{member.id}",
-            data = {"ajax": "t"},
-            allow_redirects=False
+        r = self._request(
+            "POST",
+            f"{BASE_URL}/members/ajax/friends/add/{member.id}",
+            data={"ajax": "t"},
+            allow_redirects=False,
         )
 
         return "friend request has been sent" in r.json()["text"]
 
-    def add_comment(self, page, text, *, comment = None):
+    def add_comment(self, page, text, *, comment=None):
         """Add a comment to a page.
 
         Parameters
@@ -392,27 +458,31 @@ def add_comment(self, page, text, *, comment = None):
             Must be a moddb.page, the page you wish to add the comment to.
         test : str
             The content of the comment you wish to post
-        comment : Optional[Comment] 
+        comment : Optional[Comment]
             If you wish to reply to another comment you must provide the comment
             object for it there.
 
         Returns
         --------
         Union[Addon, Article, BaseMetaClass, Blog, Engine, File, Game, Group, Hardware, HardwareSoftwareMetaClass, Media, Member, Mod, PageMetaClass, Platform, Poll, Software, Team]
-            The page's updated object containing the new comment and any other new data that 
+            The page's updated object containing the new comment and any other new data that
             has been posted since then
         """
         if self._last_comment_time + 60 > time.time():
-            raise ModdbException("You must wait at least one minute between each comment")
+            raise ModdbException(
+                "You must wait at least one minute between each comment"
+            )
 
-        r = self._request("post", page.url,
-            data = {
+        r = self._request(
+            "POST",
+            page.url,
+            data={
                 "formhash": generate_hash(),
                 "replyid": comment.id if comment else 0,
                 "page": 1,
                 "summary": text,
-                "comment": "Save comment"
-            }
+                "comment": "Save comment",
+            },
         )
 
         self._last_comment_time = time.time()
@@ -421,17 +491,21 @@ def add_comment(self, page, text, *, comment = None):
 
     def _comment_state_update(self, comment):
         if comment is None:
-            raise ModdbException("This comment no longer exists or is no longer on the page it was initially retrieved from.")
+            raise ModdbException(
+                "This comment no longer exists or is no longer on the page it was initially retrieved from."
+            )
 
-        r = self._request("post", f"{BASE_URL}/messages/ajax/action/",
-            data = {
+        r = self._request(
+            "POST",
+            f"{BASE_URL}/messages/ajax/action/",
+            data={
                 "ajax": "t",
                 "action": "delete",
                 "sitearea": "comment",
                 "siteareaid": comment.id,
-                "hash": comment._hash
+                "hash": comment._hash,
             },
-            allow_redirects=False
+            allow_redirects=False,
         )
 
         return r
@@ -490,16 +564,19 @@ def undelete_comment(self, comment):
         """
         with self:
             page = parse(comment._url)
-            updated_comment = get(page._get_comments_from_url(comment._url, show_deleted=True), id=comment.id)
+            updated_comment = get(
+                page._get_comments_from_url(comment._url, show_deleted=True),
+                id=comment.id,
+            )
 
         r = self._comment_state_update(updated_comment)
 
         return "You have <u>authorized</u> this comment" in r.json()["text"]
 
     def edit_comment(self, comment, new_text):
-        """Edit the contents of a comment. You can only edit your comment 120 minutes after it has 
+        """Edit the contents of a comment. You can only edit your comment 120 minutes after it has
         been posted
-        
+
         Parameters
         -----------
         comment : Comment
@@ -515,19 +592,17 @@ def edit_comment(self, comment, new_text):
         Returns
         --------
         bool
-            True if the comment was successfully edited        
+            True if the comment was successfully edited
         """
-        r = self._request("post", f"{BASE_URL}/comment/ajax/post",
-            data = {
-                "ajax": "t",
-                "id": comment.id,
-                "summary": new_text
-            }
+        r = self._request(
+            "POST",
+            f"{BASE_URL}/comment/ajax/post",
+            data={"ajax": "t", "id": comment.id, "summary": new_text},
         )
 
         return "Your comment has been saved" in r.json()["text"]
 
-    def add_review(self, page, rating, *, text = None, has_spoilers = False):
+    def add_review(self, page, rating, *, text=None, has_spoilers=False):
         """Rate and review a page. If you rating is below 3 or above 8 you will be asked
         to also provide a review or else the request will not be made. This is also
         used to edit existing reviews.
@@ -555,26 +630,30 @@ def add_review(self, page, rating, *, text = None, has_spoilers = False):
 
         """
         if not (2 < rating < 9) and review is None:
-            raise ModdbException("Please include a review to justify such a low/high rating.") 
+            raise ModdbException(
+                "Please include a review to justify such a low/high rating."
+            )
 
         with self:
-            page = parse(page.url)    
+            page = parse(page.url)
 
-        r = self._request("post", f"{BASE_URL}/reviews/ajax", 
+        r = self._request(
+            "POST",
+            f"{BASE_URL}/reviews/ajax",
             data={
-                "ajax" : "t",
+                "ajax": "t",
                 "sitearea": page.url.split("/")[-2],
                 "siteareaid": page.id,
                 "hash": page._review_hash,
                 "earlyaccess": int(page.profile.status == Status.early_access),
                 "rating": rating,
                 "summary": text,
-                "spoiler": int(has_spoilers)
+                "spoiler": int(has_spoilers),
             },
-            allow_redirects=False
+            allow_redirects=False,
         )
 
-        return "Your rating has been saved" in r.json()["text"] 
+        return "Your rating has been saved" in r.json()["text"]
 
     def delete_review(self, review):
         """Delete your review on the given page. This function will do two requests in order
@@ -598,16 +677,18 @@ def delete_review(self, review):
         with self:
             hash_review = self.member.get_reviews()[0]
 
-        r = self._request("post", f"{BASE_URL}/messages/ajax/action/",
+        r = self._request(
+            "POST",
+            f"{BASE_URL}/messages/ajax/action/",
             data={
                 "ajax": "t",
                 "action": "delete",
                 "sitearea": "reviews",
                 "siteareaid": review.id,
                 "hash": hash_review._hash,
-                "ispd": 1
+                "ispd": 1,
             },
-            allow_redirects = False
+            allow_redirects=False,
         )
 
         return "You have <u>deleted</u> this review." in r.json()["text"]
diff --git a/moddb/enums.py b/moddb/enums.py
index 35caf07..a8a64c4 100644
--- a/moddb/enums.py
+++ b/moddb/enums.py
@@ -1,7 +1,10 @@
+# GENERATED AUTOMATICALLY, DO NOT EDIT
 import enum
 
+
 class SearchCategory(enum.Enum):
     """The list of things you can search for"""
+
     games = 0
     mods = 1
     addons = 2
@@ -26,15 +29,19 @@ class SearchCategory(enum.Enum):
     companys = developers
     features = articles
 
+
 class Category(enum.Enum):
     """Enum for the different areas of the site which things can be attached to"""
+
     mods = "mods"
     games = "games"
     engines = "engines"
     downloads = "downloads"
 
+
 class RSSType(enum.Enum):
     """Enum to define the type of RSS you want to get from this page"""
+
     articles = 0
     downloads = 1
     images = 2
@@ -52,92 +59,47 @@ class RSSType(enum.Enum):
 
 class MediaCategory(enum.Enum):
     """What category a media object is, use for read purposes."""
+
     video = 0
     image = 1
     audio = 2
 
+
 class ArticleCategory(enum.Enum):
     """Category of the article"""
+
     news = 1
     features = 2
     tutorials = 4
 
-class Difficulty(enum.Enum):
-    """Difficulty of the tutorial"""
-    basic = 1
-    intermediate = 2
-    advanced = 3
-
-class TutorialCategory(enum.Enum):
-    """Skill covered by the tutorial"""
-    coding = 1
-    graphics = 2
-    concept_art = 3
-    ui_hud = 4
-    textures = 5
-    mapping_technical = 6
-    modelling = 7
-    players_modelling = 8
-    weapons_modelling = 9
-    animation = 10
-    skinning = 11
-    sound = 12
-    sound_effects = 13
-    voice_acting = 14
-    management = 15
-    client_side_coding = 16
-    server_side_coding = 17
-    pr = 18
-    props_modelling = 19
-    website = 21
-    level_design = 23
-    level_design_theory = 24
-    music = 25
-    general = 26
-    installers = 27
-    server_tools = 28
-    qa_testing = 29
-    design_concepts = 31
-    starting_a_mod = 32
-    
 
 class Membership(enum.Enum):
     """Member ship settings of Groups and Teams"""
+
     invitation = 1
     application = 2
     open_to_all = 3
 
+
 class TeamCategory(enum.IntFlag):
     """Category of companies, either publisher, developer or both"""
+
     def __repr__(self):
         cls = self.__class__
         if self._name_ is not None:
             return self._name_
         members, uncovered = enum._decompose(cls, self._value_)
-        return ' & '.join([str(m._name_ or m._value_) for m in members])
+        return " & ".join([str(m._name_ or m._value_) for m in members])
 
     __str__ = __repr__
 
     developer = 3
     publisher = 4
 
-class GroupCategory(enum.Enum):
-    """Category of fan groups"""
-    official = 1
-    educational = 2
-    fans_clans = 3
-    arts_literature = 4
-    hardware_tech = 5
-    hobbies_interests = 6
-    geographic = 7
-    other = 8
-    entertainment_press = 9
-    web_community = 10
-    event = 11
-    developer_publisher = 99
 
 class ThumbnailType(enum.Enum):
     """The various types of thunbails that can be created"""
+
     mod = 0
     game = 1
     engine = 2
@@ -156,8 +118,165 @@ class ThumbnailType(enum.Enum):
     hardware = 15
     company = team
 
+
+class PlayerStyle(enum.IntFlag):
+    """The player style of the game"""
+
+    def __repr__(self):
+        cls = self.__class__
+        if self._name_ is not None:
+            return self._name_
+        members, uncovered = enum._decompose(cls, self._value_)
+        return "|".join([str(m._name_ or m._value_) for m in members])
+
+    __str__ = __repr__
+
+    singleplayer = 1
+    multiplayer = 2
+    coop = 4
+    mmo = 8
+
+
+class TimeFrame(enum.Enum):
+    """How recently the page was updated/uploaded, 24 hours, last week, last month, ect..."""
+
+    day = 1
+    week = 2
+    month = 3
+    year = 4
+    more = 5
+
+
+class WatchType(enum.Enum):
+    mod = 0
+    game = 1
+    engine = 2
+    group = 3
+    member = 4
+
+
+class Month(enum.Enum):
+    january = "01"
+    february = "02"
+    march = "03"
+    april = "04"
+    may = "05"
+    june = "06"
+    july = "07"
+    august = "08"
+    september = "09"
+    october = "10"
+    november = "11"
+    december = "12"
+
+
+# BELOW THIS LINE ENUMS ARE GENERATED AUTOMATICALLY
+# PR changes to scripts/generate_enums.py if you want to
+# change something
+
+
+class SoftwareCategory(enum.Enum):
+    """The category of the software"""
+
+    programming = 1
+    ide = 2
+    sdk = 3
+    emulator = 4
+    d3_art = 5
+    d3_graphics = 6
+    voxel_editor = 7
+    normal_map_generator = 8
+    uv_mapping_and_unwrapping = 9
+    heightmap_or_terrain_generator = 10
+    shader_editor = 11
+    d2_art = 12
+    raster_graphics_editor = 13
+    vector_graphics_editor = 14
+    sprite_editor_and_rigger = 15
+    tile_or_map_editor = 16
+    sound_and_music = 17
+    audio_editing = 18
+    digital_audio_workstation = 19
+    music_sequencer = 20
+    software_synthesizer = 21
+    gaming = 22
+    gaming_client = 23
+    voip = 24
+    overclocking = 25
+    benchmarking = 26
+    streaming_and_recording = 27
+    web_pr_and_other = 28
+    game_design = 29
+    web_design = 30
+    browser = 31
+    social_networking = 32
+    video_editing = 33
+    vr = 34
+
+
+class Difficulty(enum.Enum):
+    """Difficulty of the tutorial"""
+
+    basic = 1
+    intermediate = 2
+    advanced = 3
+
+
+class TutorialCategory(enum.Enum):
+    """Skill covered by the tutorial"""
+
+    coding = 1
+    client_side_coding = 16
+    server_side_coding = 17
+    graphics = 2
+    ui_hud = 4
+    textures = 5
+    concept_art = 3
+    level_design = 23
+    mapping_technical = 6
+    level_design_theory = 24
+    modelling = 7
+    players_modelling = 8
+    weapons_modelling = 9
+    props_modelling = 19
+    animation = 10
+    skinning = 11
+    sound = 12
+    sound_effects = 13
+    voice_acting = 14
+    music = 25
+    general = 26
+    pr = 18
+    website = 21
+    design_concepts = 31
+    management = 15
+    starting_a_mod = 32
+    installers = 27
+    server_tools = 28
+    qa_testing = 29
+    other = 30
+
+
+class GroupCategory(enum.Enum):
+    """Category of fan groups"""
+
+    official = 1
+    arts_literature = 4
+    developer_publisher = 99
+    educational = 2
+    entertainment_press = 9
+    event = 11
+    fans_clans = 3
+    geographic = 7
+    hardware_tech = 5
+    hobbies_interests = 6
+    web_community = 10
+    other = 8
+
+
 class AddonCategory(enum.Enum):
     """Category of addons"""
+
     maps = 100
     multiplayer_map = 101
     singleplayer_map = 102
@@ -165,262 +284,245 @@ class AddonCategory(enum.Enum):
     models = 104
     animal_model = 105
     players_model = 106
+    prop_model = 132
     vehicle_model = 107
     weapons_model = 108
+    models_pack = 131
     skins = 110
     animal_skin = 111
     players_skin = 112
+    prop_skin = 133
     vehicle_skin = 113
     weapons_skin = 114
+    skin_pack = 134
     audio = 116
     music = 117
-    audio_pack = 118
+    animal_audio = 135
     players_audio = 119
     vehicle_audio = 120
     weapons_audio = 121
+    ambience_sounds = 137
+    language_sounds = 138
+    audio_pack = 118
     graphics = 123
     decals = 124
+    effects_gfx = 136
     guis = 125
     huds = 126
     sprays = 127
     sprites = 128
     textures = 129
-    models_pack = 131
-    prop_model = 132
-    prop_skin = 133
-    skin_pack = 134
-    animal_audio = 135
-    effects_gfx = 136
-    ambience_sounds = 137
-    language_sounds = 138
+
+
+class Licence(enum.Enum):
+    """The licence of the object"""
+
+    bsd = 7
+    commercial = 1
+    creative_commons = 2
+    gpl = 5
+    lgpl = 6
+    zlib = 9
+    mit = 8
+    proprietary = 3
+    public_domain = 4
+
 
 class Status(enum.Enum):
     """Status of a page"""
+
     released = 1
-    unreleased = 3
     early_access = 4
     coming_soon = 2
+    unreleased = 3
+
 
 class Theme(enum.Enum):
     """Theme of the page"""
+
+    abstract = 18
     anime = 1
+    antiquity = 14
     comedy = 2
     comic = 3
+    education = 17
     fantasy = 4
     fighter = 5
+    history = 15
     horror = 6
+    mafia = 20
+    medieval = 16
     movie = 7
-    realism = 8
-    sci_fi = 9
-    war = 10
-    western = 11
-    sport = 12
+    music = 21
     nature = 13
-    antiquity = 14
-    history = 15
-    medieval = 16
-    education = 17
-    abstract = 18
     noire = 19
-    mafia = 20
-    music = 21
     pirate = 22
+    realism = 8
+    scifi = 9
     survival = 23
-    
+    sport = 12
+    war = 10
+    western = 11
+
+
 class Genre(enum.Enum):
     """Genre of the page"""
+
     action = 1
-    sub_adventure = 2
     first_person_shooter = 3
+    third_person_shooter = 34
     tactical_shooter = 4
+    fighting = 20
+    arcade = 39
+    stealth = 43
     adventure = 5
+    sub_adventure = 2
+    platformer = 19
+    point_and_click = 40
+    visual_novel = 50
     driving = 6
     racing = 7
     car_combat = 8
     rpg = 10
     role_playing = 11
-    baseball = 13
+    roguelike = 44
+    hack_n_slash = 51
+    party_based = 53
     strategy = 14
     real_time_strategy = 15
+    real_time_shooter = 42
+    real_time_tactics = 45
     turn_based_strategy = 16
-    basketball = 17
+    turn_based_tactics = 46
+    tower_defense = 47
+    grand_strategy = 49
+    x4 = 48
+    moba = 52
     sport = 18
-    platformer = 19
-    fighting = 20
+    baseball = 13
+    basketball = 17
     football = 21
+    golf = 33
+    hockey = 35
+    soccer = 36
+    wrestling = 37
+    sub_sport = 26
     simulation = 22
-    realistic_sim = 23
     combat_sim = 24
     futuristic_sim = 25
+    realistic_sim = 23
     puzzle = 27
-    rhythm = 28
+    cinematic = 41
+    educational = 38
+    family = 31
     party = 29
+    rhythm = 28
     virtual_life = 30
-    family = 31
     sub_puzzle = 32
-    golf = 33
-    third_person_shooter = 34
-    hockey = 35
-    soccer = 36
-    wrestling = 37
-    educational = 38
-    arcade = 39
-    point_and_click = 40
-    cinematic = 41
-    real_time_shooter = 42
-    stealth = 43
-    roguelike = 44
-    real_time_tactics = 45
-    turn_based_tactics = 46
-    tower_defense = 47
-    fourx = 48
-    grand_strategy = 49
-    visual_novel = 50
-    hack_n_slash = 51
-    moba = 52
-    party_based = 53
 
-class PlayerStyle(enum.IntFlag):
-    """The player style of the game"""
-    def __repr__(self):
-        cls = self.__class__
-        if self._name_ is not None:
-            return self._name_
-        members, uncovered = enum._decompose(cls, self._value_)
-        return '|'.join([str(m._name_ or m._value_) for m in members])
-
-    __str__ = __repr__
-
-    singleplayer = 1
-    multiplayer = 2
-    coop = 4
-    mmo = 8
-
-class TimeFrame(enum.Enum):
-    """How recently the page was updated/uploaded, 24 hours, last week, last month, ect..."""
-    day = 1
-    week = 2
-    month = 3
-    year = 4
-    more = 5
-
-class Licence(enum.Enum):
-    """The licence of the object"""
-    commercial = 1
-    creative_commons = 2
-    proprietary = 3
-    public_domain = 4
-    gpl = 5
-    l_gpl = 6
-    bsd = 7
-    mit = 8
-    zlib = 9
 
 class Scope(enum.Enum):
     """Scope of the game"""
+
     aaa = 1
     indie = 2
 
+
 class FileCategory(enum.Enum):
     """The category of the File"""
+
     releases = 1
     full_version = 2
     demo = 3
     patch = 4
+    script = 28
     server = 5
+    trainer = 29
     media = 6
     trailers = 7
     movies = 8
     music = 9
+    audio = 25
     wallpapers = 10
     tools = 11
     audio_tool = 12
+    archive_tool = 20
     graphics_tool = 13
     mapping_tool = 14
     modelling_tool = 15
     installer_tool = 16
     server_tool = 17
+    plugin = 27
     ides = 18
     sdk = 19
-    archive_tool = 20
+    source_code = 26
     miscellaneous = 21
     guides = 22
     tutorials = 23
+    language_pack = 30
     other = 24
-    audio = 25
-    source_code = 26
-    plugin = 27
-    script = 28
+
 
 class JobSkill(enum.Enum):
-    """The skill required for q job"""
+    """The skill required for a job"""
+
     artists = 1
-    audio_or_music = 2
+    audio_music = 2
     human_resources = 3
     level_designers = 4
     management = 5
     marketing = 6
     programmers = 7
     public_relations = 8
-    qa_or_testers = 9
+    qa_testers = 9
     sales = 10
-    web_or_other = 11
+    web_other = 11
+
 
 class HardwareCategory(enum.Enum):
     """The category of the hardware"""
+
     headset = 1
     haptics = 2
     controller = 3
     video = 4
     sound = 5
 
+
 class SoftwareCategory(enum.Enum):
     """The category of the software"""
-    commerical = 1
-    communication = 2
-    development = 3
-    educational = 4
-    entertainment = 5
-    media = 6
-    architecture = 7
-    retail = 8
-    tours = 9
-    browser = 10
-    social_networking = 11
-    editor = 12
-    sdk = 13
-    sub_educational = 14
-    environment_mapping = 15
-    medical = 16
-    simulation = 17
-    adult = 18
-    books = 19
-    movies = 20
-    sport = 21
-    audio_recording_editing = 22
-    video_capture_editing = 23
-    motion_capture = 24
-    graphics_editing = 25
-    music = 26
-    games = 27
-
-class WatchType(enum.Enum):
-    mod = 0
-    game = 1
-    engine = 2
-    group = 3
-    member = 4
 
-class Month(enum.Enum):
-    january = "01"
-    february = "02"
-    march = "03"
-    april = "04"
-    may = "05"
-    june = "06"
-    july = "07"
-    august = "08"
-    september = "09"
-    october = "10"
-    november = "11"
-    december = "12"
+    programming = 1
+    ide = 2
+    sdk = 3
+    emulator = 4
+    d3_art = 5
+    d3_graphics = 6
+    voxel_editor = 7
+    normal_map_generator = 8
+    uv_mapping_and_unwrapping = 9
+    heightmap_or_terrain_generator = 10
+    shader_editor = 11
+    d2_art = 12
+    raster_graphics_editor = 13
+    vector_graphics_editor = 14
+    sprite_editor_and_rigger = 15
+    tile_or_map_editor = 16
+    sound_and_music = 17
+    audio_editing = 18
+    digital_audio_workstation = 19
+    music_sequencer = 20
+    software_synthesizer = 21
+    gaming = 22
+    gaming_client = 23
+    voip = 24
+    overclocking = 25
+    benchmarking = 26
+    streaming_and_recording = 27
+    web_pr_and_other = 28
+    game_design = 29
+    web_design = 30
+    browser = 31
+    social_networking = 32
+    video_editing = 33
+    vr = 34
diff --git a/moddb/errors.py b/moddb/errors.py
index 48fea77..aeb6241 100644
--- a/moddb/errors.py
+++ b/moddb/errors.py
@@ -1,4 +1,12 @@
 class ModdbException(Exception):
     """Standard exception raised when the moddb.Client encounters an error related to the request
     being made"""
+
+    pass
+
+
+class AwaitingAuthorisation(ModdbException):
+    """This object is awaiting authorisation and can therefore not be accessed at the
+    present time."""
+
     pass
diff --git a/moddb/pages.py b/moddb/pages.py
deleted file mode 100644
index 2cc3a39..0000000
--- a/moddb/pages.py
+++ /dev/null
@@ -1,2521 +0,0 @@
-from .boxes import CommentList, Comment, Thumbnail, MemberProfile, MemberStatistics, \
-                   Profile, Statistics, Style, PartialArticle, Option, PlatformStatistics, \
-                   MissingComment, ResultList
-from .enums import ThumbnailType, SearchCategory, TimeFrame, FileCategory, AddonCategory, \
-                   MediaCategory, JobSkill, ArticleCategory, Difficulty, TutorialCategory, Licence, \
-                   Status, PlayerStyle, Scope, Theme, HardwareCategory, SoftwareCategory, Genre, \
-                   Membership, GroupCategory, RSSType
-from .utils import get_page, join, LOGGER, get_date, get_views, get_type, concat_docs, Object, request, \
-                   get_type_from, get_page_number
-
-import re
-import bs4
-import json
-import datetime
-import feedparser
-from typing import List, Tuple, Union
-
-__all__ = ['Mod', 'Game', 'Engine', 'File', 'Addon', 'Media', 'Article',
-           'Team', 'Group', 'Job', 'Blog', 'Member', 'FrontPage', 'Review',
-           'Platform', 'Poll', 'Software', 'Hardware']
-
-class BaseMetaClass:
-    """An abstract class that implements the attributes present on nearly every page. In addition, it implements
-    some shared hidden methods and the top level get_comments method.
-
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The html containing the comments
-
-    Attributes
-    -----------
-    id : int
-        The id of the page
-    name_id : str
-        The name_id of the member, cannot be changed, it's a mix of the original username lowercased with
-        spaces removed and shortened.
-    url : str
-        The url of the page
-    comments : CommentList[Comment]
-        The comments scrapped on this list in order.
-    report : str
-        URL to report the page
-    """
-    def __init__(self, html):
-        if not getattr(self, "name", None):
-            try:
-                self.name = html.find("a", itemprop="mainEntityOfPage").string
-            except AttributeError:
-                self.name = html.find("meta", property="og:title")["content"]
-
-        try:
-            self.id = int(re.search(r"siteareaid=(\d*)", html.find("a", class_="reporticon")["href"])[1])
-        except TypeError:
-            self.id = None
-            LOGGER.info("'%s' '%s' has no id", self.__class__.__name__, self.name)
-
-        try:
-            self.url = html.find("meta", property="og:url")["content"]
-        except TypeError:
-            self.url = join(html.find("a", string=self.name)["href"])
-
-        self.name_id = self.url.split("/")[-1]
-
-        try:
-            self.report = join(html.find("a", string="Report")["href"])
-        except TypeError:
-            self.report = None
-            LOGGER.info("'%s' '%s' cannot be reported", self.__class__.__name__, self.name)
-
-        self.comments = self._get_comments(html)
-
-        self._html = html
-
-    def __repr__(self):
-        return f"<{self.__class__.__name__} name={self.name}>"
-
-    def _get_comments(self, html : bs4.BeautifulSoup) -> CommentList:
-        """Extracts the comments from an html page and adds them to a CommentList. In addition
-        this method also adds them to the comments children as need be.
-
-        Parameters
-        -----------
-        html : bs4.BeautifulSoup
-            The html containing the comments
-        """
-        page, max_page = get_page_number(html)
-
-        comments = []
-        comments_raw = html.find_all("div", class_="row", id=True)
-        for raw in comments_raw:
-            comment = Comment(raw)
-            comment._url = f"{self.url}/page/{page}"
-            if comment.position == 1:
-                try:
-                    comments[-1].children.append(comment)
-                except IndexError:
-                    comment.append(MissingComment(0))
-                    comments[-1].children.append(comment)
-            elif comment.position == 2:
-                try:
-                    comments[-1].children[-1].children.append(comment)
-                except IndexError:
-                    comments[-1].children.append(MissingComment(1))
-                    comments[-1].children[-1].children.append(comment)
-            else:
-                comments.append(comment)
-
-        return CommentList(
-            results=comments, 
-            page=page,
-            max_page=max_page,
-            action=self._get_comments_from_url,
-            url=f"{self.url}/page/{page}",
-        )
-
-    def _get(self, url : str, *, params : dict = {}) -> ResultList:
-        """This function takes a list of thumbnails of `object_type` in html and returns
-        a list of Thumbnail of that object type.
-
-        Parameters
-        -----------
-        url : str
-            The url with the list
-        object_type : ThumbnailType
-            The type of objects to be expected. Easier to pass and hardcode then to guess
-        params : dict
-            A dictionnary of filters to pass on to the soup function used to filter the results.
-
-        Returns
-        -------
-        List[Thumbnail]
-            The list of objects present on the page as a list of thumbnails.
-        """
-        html = get_page(url, params=params)
-        try:
-            table = html.find("form", attrs={'name': "filterform"}).parent.find("div", class_="table")
-        except AttributeError:
-            table = None
-
-        if table is None:
-            return []
-
-        objects_raw = table.find_all("div", recursive=False)[1:]
-        objects = []
-        for obj in objects_raw:
-            date = obj.find("time")
-            summary = obj.find("p")
-            thumbnail = Thumbnail(
-                name=obj.a["title"], 
-                url=obj.a["href"], 
-                image=obj.a.img["src"], 
-                type=get_type_from(join(obj.a["href"])), 
-                summary=summary.string if summary else None, 
-                date=get_date(date["datetime"]) if date else None
-            )
-            objects.append(thumbnail)
-
-        page, max_page = get_page_number(html)
-
-        return ResultList(
-            results=objects, 
-            params=params, 
-            url=url, 
-            action=self._get, 
-            page=page, 
-            max_page=max_page
-        )
-
-    def _get_media(self, index : int, *, html) -> List[Thumbnail]:
-        """Hidden method used to parse media content from the page. Since the only difference is that pages 
-        with videos have one extra script that tells the window not do any sort of video playing assistance.
-        Also used to cached all media on a page.
-
-        Parameters
-        ----------
-        index : int
-            The index of the script containing the cached objects. 1 if it's only images, 2 if videos are
-            also included
-
-        Returns
-        --------
-        List[Thumbnail]
-            List of media like thumbnails that can be parsed individually. Can be a very long list.
-        """
-        script = html.find_all("script", text=True)[index]
-        regex = r'new Array\(0, "(\S*)", "(\S*)"'
-        matches = re.findall(regex, script.text)
-
-        name_finder = r"\/([a-z0-9-]*)#imagebox"
-        return [Thumbnail(name=re.search(name_finder, match[0])[1], url=match[0], type=ThumbnailType.media, image=match[1]) for match in matches]
-
-    def _get_comments_from_url(self, url, *, show_deleted = False):
-        """Extra method so we can get comments from a ResultList"""
-        params = {
-            "deleted" : "t" if show_deleted else None
-        }
-        return self._get_comments(get_page(url, params=params))
-
-    def get_comments(self, index : int = 1, *, show_deleted = False) -> CommentList:
-        """Used to get comments on the model regardless of what page they may be present in. The function
-        itself simply relies on two other to make the request and parse the table.
-
-        Parameters
-        ----------
-        index : int 
-            The page of the model to get the comments for.
-        show_deleted : Optional[bool]
-            Pass true to show deleted user comments. Only works if it is a page
-            you have permissions on.
-
-        Returns
-        --------
-        CommentList[Comment]
-            A list-like object containing the comments and additional methods
-        """
-        params = {
-            "deleted" : "t" if show_deleted else None
-        }
-
-        return self._get_comments(get_page(f"{self.url}/page/{index}", params=params))
-
-class GetGamesMixin:
-    """Abstract class containing the get_games method"""
-    def get_games(self, index : int = 1, *,  query : str = None, status : Status = None,
-        genre : Genre = None, theme : Theme = None, scope : Scope = None, players : PlayerStyle = None,
-        timeframe : TimeFrame = None, sort : Tuple[str, str] = None) -> ResultList:
-        """Get a page of games for the model. Each page will yield up to 30 games.
-
-        Parameters
-        -----------
-        index : Optional[int]
-            The page number to get the games for.
-        query : Optional[str]
-            The text to look for in the game names.
-        status : Optional[Status]
-            The status of the game (unreleased, released, early access, ect...)
-        genre : Optional[Genre]
-            The genre of the game (fps, tower defense)
-        theme : Optional[Theme]
-            The theme of the game (fantasy, action)
-        scope : Optional[Scope]
-            The scope of the game (AAA, indie)
-        players : Optional[PlayerStyle]
-            Player styles of the game (co-op, singleplayer)
-        timeframe : Optional[TimeFrame]
-            The time period this was released in (last 24hr, last week, last month)
-        sort : Optional[Tuple[str, str]]
-            The sorting tuple to sort by the results
-
-        Returns
-        --------
-        ResultList[Thumbnail]
-            List of game like thumbnails that can be parsed individually.
-        """
-        params = {
-            "filter": "t",
-            "kw": query,
-            "released": status.value if status else None,
-            "genre": genre.value if genre else None,
-            "theme": theme.value if theme else None,
-            "indie": scope.value if scope else None,
-            "players": players.value if players else None,
-            "timeframe": timeframe.value if timeframe else None,
-            "sort": f'{sort[0]}-{sort[1]}' if sort else None
-        }
-
-        return self._get(f"{self.url}/games/page/{index}", params=params)
-
-class GetModsMixin:
-    """Abstract class containing the get_mod method"""
-    def get_mods(self, index : int = 1, *,  query : str = None, status : Status = None,
-        genre : Genre = None, theme : Theme = None, players : PlayerStyle = None,
-        timeframe : TimeFrame = None, game : Union['Game', Object] = None,
-        sort : Tuple[str, str] = None) -> ResultList:
-        """Get a page of mods for the game. Each page will yield up to 30 mods. 
-        
-        Parameters
-        -----------
-        index : int
-            The page number to get the mods from.
-        query : Optional[str]
-            The text to look for in the mod names.
-        status : Optional[Status]
-            The status of the mod (unreleased, released, early access, ect...)
-        genre : Optional[Genre]
-            The genre of the mod (fps, tower defense)
-        theme : Optional[Theme]
-            The theme of the mod (fantasy, action)
-        players : Optional[PlayerStyle]
-            Player styles of the mod (co-op, singleplayer)
-        timeframe : Optional[TimeFrame]
-            The time period this was released in (last 24hr, last week, last month)
-        game : Union[mod, Object]
-            An mod object or an object with an id attribute which represents the
-            mod the mod belongs to.
-        sort : Optional[Tuple[str, str]]
-            The sorting tuple to sort by the results
-
-        Returns
-        --------
-        ResultList[Thumbnail]
-            The list of mods type thumbnails parsed from the game
-        """
-        params = {
-            "filter": "t",
-            "kw": query,
-            "released": status.value if status else None,
-            "genre": genre.value if genre else None,
-            "theme": theme.value if theme else None,
-            "players": players.value if players else None,
-            "timeframe": timeframe.value if timeframe else None,
-            "game": game.id if game else None,
-            "sort": f'{sort[0]}-{sort[1]}' if sort else None
-        }
-
-        return self._get(f"{self.url}/mods/page/{index}", params=params)
-
-class GetEnginesMixin:
-    """Abstract class containing the get_engines method"""
-    def get_engines(self, index : int = 1, *, query : str = None, status : Status = None, 
-        licence : Licence = None, timeframe : TimeFrame = None,
-        sort : Tuple[str, str] = None) -> ResultList:
-        """Get a page of engines for the game. Each page will yield up to 30 engines. 
-        
-        Parameters
-        -----------
-        index : int
-            The page number to get the engines from.
-        query : Optional[str]
-            The text to look for in the engine names.
-        status : Optional[Status]
-            The status of the game (unreleased, released, early access, ect...)
-        licence : Optional[Licence]
-            The licence of the engine (open source, proprietary, ect...)
-        timeframe : Optional[TimeFrame]
-            The time period this was released in (last 24hr, last week, last month)
-        sort : Optional[Tuple[str, str]]
-            The sorting tuple to sort by the results
-        Returns
-        --------
-        ResultList[Thumbnail]
-            The list of engine type thumbnails parsed from the game
-        """
-        params = {
-            "filter": "t",
-            "kw": query,
-            "released": status.value if status else None,
-            "licence": licence.value if licence else None,
-            "timeframe": timeframe.value if timeframe else None,
-            "sort": f'{sort[0]}-{sort[1]}' if sort else None
-        }
-
-        return self._get(f"{self.url}/engines/page/{index}", params=params)
-
-class SharedMethodsMixin:
-    """Abstract class that implements a certain amount of top level methods shared between Pages
-    and Hardware"""
-    def get_reviews(self, index : int = 1, *, query : str = None, rating : int = None, 
-        sort : Tuple[str, str] = None) -> ResultList:
-        """Get a page of reviews for the page. Each page will yield up to 10 reviews. 
-
-        Parameters
-        -----------
-        index : Optional[int]
-            The page number to get the reviews from.
-        query : Optional[str]
-            The string to look for in the review, optional.
-        rating : Optiona[int]
-            A number between 1 and 10 to get the ratings
-        sort : Optional[Tuple[str, str]]
-            The sorting tuple to sort by the results
-
-        Returns
-        --------
-        ResultList[Review]
-            The list of reviews parsed from the page
-        """
-        params = {
-            'filter': "t", 
-            'kw': query, 
-            'rating': rating, 
-            "sort": f'{sort[0]}-{sort[1]}' if sort else None
-        }
-
-        return self._get_reviews(f"{self.url}/reviews/page/{index}", params=params)
-
-    def _get_reviews(self, url, *, params):
-        """Backend class so we can use it with ResultList"""
-        html = get_page(url, params=params)
-
-        try:
-            table = html.find("form", attrs={'name': "filterform"}).parent.find("div", class_="table")
-        except AttributeError:
-            table = None
-
-        if table is None:
-            return []
-
-        raw_reviews = table.find_all("div", recursive=False)[2:]
-        reviews = []
-        e = 0
-        #This is very hacky because a page of reviews is actually a list of review titles and review contents
-        #I'm not fucking with you, its a list of divs which go [Div[Title of review one], Div[content of review one],
-        #Div[Title of review two], Div[content of review two]] It's dumb as fuck and I hate it.
-        for _ in range(len(raw_reviews)):
-            try:
-                review = raw_reviews[e]
-            except IndexError:
-                break
-
-            try:
-                text = raw_reviews[e+1]
-            except IndexError:
-                #some reviews don't have text, it's optional as long as you don't give lower than a three
-                text = {"class": "None"}
-
-            if "rowcontentnext" in text["class"]:
-                e += 1
-                review_obj = Review(review=review, text=text)
-            else:
-                review_obj = Review(review=review)
-
-            reviews.append(review_obj)
-            e += 1
-
-        page, max_page = get_page_number(html)
-
-        return ResultList(
-            results=reviews,
-            params=params,
-            action=self._get_reviews,
-            url=url,
-            page=page,
-            max_page=max_page
-        )
-
-    def get_articles(self, index : int = 1, *, query : str = None, category : ArticleCategory = None, 
-        timeframe : TimeFrame = None, sort : Tuple[str, str] = None) -> ResultList:
-        """Get a page of articles for the page. Each page will yield up to 30 articles. 
-
-        Parameters
-        -----------
-        index : Optional[int]
-            The page number to get the articles from.
-        query : Optional[str]
-            The string query to search for in the article name, optional.
-        category : Optional[ArticleCategory]
-            Type enum defining what the article is, optional
-        timeframe : Optional[TimeFrame]
-            The time period this was released in (last 24hr, last week, last month)
-        sort : Optional[Tuple[str, str]]
-            The sorting tuple to sort by the results
-
-        Returns
-        --------
-        ResultList[Thumbnail]
-            The list of article type thumbnails parsed from the page
-        """
-        params = {
-            "filter": "t", 
-            "kw": query, 
-            "type": category.value if category else None, 
-            "timeframe": timeframe.value if timeframe else None,
-            "sort": f'{sort[0]}-{sort[1]}' if sort else None
-        }
-
-        return self._get(f"{self.url}/articles/page/{index}", params=params)
-        
-    def get_files(self, index : int = 1, *, query : str = None, category : FileCategory = None, 
-        addon_type : AddonCategory = None, timeframe : TimeFrame = None,
-        sort : Tuple[str, str] = None) -> ResultList:
-        """Get a page of files for the page. Each page will yield up to 30 files. 
-
-        Parameters
-        -----------
-        index : Optional[int]
-            The page number to get the files from.
-        query : Optional[str]
-            The string query to search for in the file name, optional.
-        category : [FileCategory]
-            Type enum defining what the file is, optional
-        addon_type : Optional[AddonCategory]
-            Type enum defining what category the file is.
-        timeframe : Optional[TimeFrame]
-            The time period this was released in (last 24hr, last week, last month)
-        sort : Optional[Tuple[str, str]]
-            The sorting tuple to sort by the results
-
-        Returns
-        --------
-        ResultList[Thumbnail]
-            The list of file type thumbnails parsed from the page
-        """
-        params = {
-            "filter": "t", 
-            "kw": query, 
-            "category": category.value if category else None,
-            "categoryaddon": addon_type.value if addon_type else None,
-            "timeframe": timeframe.value if timeframe else None,
-            "sort": f'{sort[0]}-{sort[1]}' if sort else None
-        }
-
-        return self._get(f"{self.url}/downloads/page/{index}", params=params)
-
-    def get_images(self) -> List[Thumbnail]:
-        """Get all the images a page has uploaded. Literally all of them. As thumbnails. ModDB's imagebox
-        caches all the urls for images on the page so this grabs them all. Lists might be long, but this 
-        is all the images. They can be invidually parsed to get full fledged media objects from them.
-
-        Returns
-        --------
-        List[Thumbnail]
-            The list of image type thumbnails parsed from the page
-        """
-        html = get_page(f"{self.url}/images")
-        return self._get_media(1, html=html)
-        
-
-    def get_videos(self) -> List[Thumbnail]:
-        """Get all the videos a page has uploaded. Literally all of them. As thumbnails. ModDB's imagebox
-        caches all the urls for videos on the page so this grabs them all. Lists might be long, but this 
-        is all the videos. They can be invidually parsed to get full fledged media objects from them.
-
-        Returns
-        --------
-        List[Thumbnail]
-            The list of video type thumbnails parsed from the page
-        """
-        html = get_page(f"{self.url}/videos")
-        return self._get_media(2, html=html)
-
-    def get_tutorials(self, index : int = 1, *, query : str = None, difficulty : Difficulty = None, 
-        tutorial_type : TutorialCategory = None, sort : Tuple[str, str] = None) -> ResultList:
-        """Get a page of tutorial for the page. Each page will yield up to 30 tutorials. 
-
-        Parameters
-        -----------
-        index : int
-            The page number to get the tutorials from.
-        query : str
-            The string query to look for in the tutorial title, optional.
-        difficulty : Difficulty
-            Enum type representing the difficulty of the tutorial, optional.
-        tutorial_type : TutorialCategory
-            Enum type representing the theme/type/category of the tutorial, optional.
-        sort : Optional[Tuple[str, str]]
-            The sorting tuple to sort by the results
-
-        Returns
-        --------
-        ResultList[Thumbnail]
-            The list of article type thumbnails parsed from the page
-        """
-        params = {
-            "filter": "t", 
-            "kw": query, 
-            "subtype": tutorial_type.value if tutorial_type else None, 
-            "meta": difficulty.value if difficulty else None,
-            "sort": f'{sort[0]}-{sort[1]}' if sort else None
-        }
-
-        return self._get(f"{self.url}/tutorials/page/{index}", params=params)
-
-class GetSoftwareHardwareMixin:
-    """Abstrac class implementing get_software and get_hardware"""
-    def get_hardware(self, index : int = 1, *, query : str = None, status : Status = None,
-        category : HardwareCategory = None, timeframe : TimeFrame = None,
-        sort : Tuple[str, str] = None) -> ResultList:
-        """Get a page of hardware for the platform. Each page will yield up to 30 hardware.
-
-        Parameters
-        -----------
-        index : Optional[int]
-            The page number to get the hardware for.
-        query : Optional[str]
-            The text to look for in the hardware's names
-        status : Optional[Status]
-            Status of the hardware (released, unreleased, early access...)
-        category : Optional[HardwareCategory]
-            Category of the hardware (headset, controller, ect...)
-        timeframe : Optional[TimeFrame]
-            The time period this was released in (last 24hr, last week, last month)
-        sort : Optional[Tuple[str, str]]
-            The sorting tuple to sort by
-
-        Returns
-        --------
-        ResultList[Thumbnail]
-            List of hardware like thumbnails that can be parsed individually.
-        """
-        params = {
-            "filter": "t", 
-            "kw": query, 
-            "released": status.value if status else None,
-            "category": category.value if category else None,
-            "timeframe": timeframe.value if timeframe else None,
-            "sort": f'{sort[0]}-{sort[1]}' if sort else None
-        }
-
-        return self._get(f"{self.url}/hardware/page/{index}", params=params)
-
-    def get_software(self, index : int = 1, *, query : str = None, status : Status = None,
-        category : SoftwareCategory = None, timeframe : TimeFrame = None,
-        sort : Tuple[str, str] = None) -> ResultList:
-        """Get a page of software for the platform. Each page will yield up to 30 software.
-
-        Parameters
-        -----------
-        index : int
-            The page number to get the software for.
-        query : Optional[str]
-            The text to look for in the hardware's names
-        status : Optional[Status]
-            Status of the hardware (released, unreleased, early access...)
-        category : Optional[SoftwareCategory]
-            Category of the hardware (headset, controller, ect...)
-        timeframe : Optional[TimeFrame]
-            The time period this was released in (last 24hr, last week, last month)
-        sort : Optional[Tuple[str, str]]
-            The sorting tuple to sort by
-
-        Returns
-        --------
-        ResultList[Thumbnail]
-            List of software like thumbnails that can be parsed individually.
-        """
-        params = {
-            "filter": "t", 
-            "kw": query, 
-            "released": status.value if status else None,
-            "category": category.value if category else None,
-            "timeframe": timeframe.value if timeframe else None,
-            "sort": f'{sort[0]}-{sort[1]}' if sort else None
-        }
-
-        return self._get(f"{self.url}/software/page/{index}", params=params)
-
-class RSSFeedMixin:
-    def rss(self, type : RSSType, *, parse_feed = False):
-        """Get the RSS feed url for the page depending on which feed type you want
-
-        Parameters
-        -----------
-        type : RSSType
-            The type of feed you desire to get
-        parse_feed : Optional[bool]:
-            Set to true if you want the library to parse the rss feed for you and return the entries as a dict
-            rather than returning the url for the rss feed.
-
-        Returns
-        --------
-        str
-            URL for the feed type
-        """
-        url = f'https://rss.moddb.com/{self._type.name}/{self.name_id}/{type.name}/feed/rss.xml'    
-        
-        if parse_feed:
-            return feedparser.parse(request(url).text)
-
-        return url 
-
-class PageMetaClass(BaseMetaClass, SharedMethodsMixin, RSSFeedMixin):
-    """The common class representing the page for either a Mod, Game, Engine or a Member. Mostly used to be inherited by
-    those classes.
-
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The page to be parsed.
-
-    page_type : ThumbnailType
-        The type of pages, this is passed down be the base class to help with the parsing of 
-
-    Attributes
-    -----------
-    name : str
-        The name of the page
-    profile : Profile
-        The object with the content of the page's profile box.
-    stats : Statistics
-        The object containg stat data on the page such as views, followers, ect...
-    style : Style
-        The object containing data relevant to the type of the page, not valid for Engines. Multiplayer, singleplayer,
-        ect...
-    suggestions : List[Thumbnail]
-        A list of thumbnail object representing moddb's suggestion of similar pages for the visiting member.
-    files : List[Thumbnail]
-        A list of thumbnails representing a possible partial list of all the files. To get a guaranteed full
-        list either compare with statistics.files to see if the length of the list matches the number of files in the
-        stats or use get_files, although that will still not return the whole list if there are multiple pages of
-        files.
-    articles : List[Thumbnail]
-        A list of thumbnail objects representing articles present on the page. Usually 3 or 4 articles long. 
-    article :  PartialArticle
-        A partial representation of the frong page article. This does not include things like comments or any
-        of the sideba elements found in a full article. Can be parsed to return the complete Article object.
-    tags : dict{str : str}
-        A dict of key-values pair where the key is the name of the tag and the value is the url.
-    imagebox : List[Thumbnail]
-        A list of Thumbnail objects representing the image, videos and audio clips that are presented in the
-        image box on the front page.
-    embed : str
-        The html necessary to embed the a widget of the page.
-    rating : float
-        A float out of ten representing the average rating for the page
-    medias : List[Thumbnail]
-        list of thumbnails representing all the combined media objects of a page (videos and images)
-    summary : str
-        Short description of the page, in plaintext.
-    description : str
-        The full description of the page, contains html
-    plaintext : str
-        Plaintext version of the full description
-    """
-    def __init__(self, html : bs4.BeautifulSoup, page_type : SearchCategory):
-        super().__init__(html)
-        self._type = page_type
-
-        #boxes
-        if page_type != SearchCategory.members:
-            self.profile = Profile(html)
-            self.stats = Statistics(html)
-            if page_type != SearchCategory.engines:
-                self.style = Style(html)
-
-            #thumbnails
-            self.suggestions = self._get_suggestions(html)
-
-            #misc
-            try:
-                self.embed = html.find("input", type="text", class_="text textembed")["value"]
-            except TypeError:
-                self.embed = str(html.find_all("textarea")[1].a)
-       
-        try:
-            self.files = self._get_files(html)
-        except AttributeError:
-            LOGGER.info("'%s' '%s' has no files", self.__class__.__name__, self.name)
-            self.files = []
-
-        articles_raw = None
-        try:
-            raw = html.find("span", string="Articles") or html.find("span", string="Related Articles")
-            articles_raw = raw.parent.parent.parent.find("div", class_="table")
-            thumbnails = articles_raw.find_all("div", class_="row rowcontent clear")
-            self.articles = [Thumbnail(name=x.a["title"], url=x.a["href"], image=x.a.img["src"] if x.a.img else None, summary=x.find("p").string, date=x.find("time")["datetime"], type=ThumbnailType.article) for x in thumbnails]
-        except AttributeError:
-            LOGGER.info("'%s' '%s' has no article suggestions", self.__class__.__name__, self.name)
-            self.articles = []
-
-        #main page article
-        if articles_raw:
-            self.article = PartialArticle(articles_raw)
-        else:
-            self.article = None
-            LOGGER.info("'%s' '%s' has no front page article", self.__class__.__name__, self.name)
-
-        try:
-            raw_tags = html.find("form", attrs={"name": "tagsform"}).find_all("a")
-            self.tags = {x.string : join(x["href"]) for x in raw_tags if x.string is not None}
-        except AttributeError:
-            self.tags = {}
-            LOGGER.info("'%s' '%s' has no tags", self.__class__.__name__, self.name)
-
-        #imagebox
-        try:
-            imagebox = html.find("ul", id="imagebox").find_all("li")[1:-2]
-            self.imagebox = [Thumbnail(name=x.a["title"], url=x.a["href"], image=x.a.img["src"], type=ThumbnailType(get_type(x.a.img))) for x in imagebox if x.a]
-        except (AttributeError, TypeError):
-            LOGGER.info("'%s' '%s' has no images", self.__class__.__name__, self.name)
-            self.imagebox = []
-
-        try:
-            self.rating = float(html.find("div", class_="score").find("meta", itemprop="ratingValue")["content"])
-        except AttributeError:
-            self.rating = 0.0
-            LOGGER.info("'%s' '%s' is not rated", self.__class__.__name__, self.name)
-
-        try:
-            self._review_hash = html.find("form", class_="ratingform").find("input", {"name": "hash"})["value"]
-        except AttributeError:
-            self._review_hash = None
-
-        self.medias = self._get_media(2, html=html)
-
-        try:
-            self.summary = html.find("meta", itemprop="description")["content"]
-        except TypeError:
-            self.summary = None
-            LOGGER.info("'%s' '%s' has no summary", self.__class__.__name__, self.name)
-            
-        self.description = str(html.find("div", id="profiledescription"))
-
-        try:
-            self.description = str(html.find("div", id="profiledescription"))
-            self.plaintext = html.find("div", id="profiledescription").text
-        except AttributeError:
-            LOGGER.info("'%s' '%s' has no extended description")
-            self.description = None
-            self.plaintext = None
-
-    def _get_suggestions(self, html : bs4.BeautifulSoup) -> List[Thumbnail]:
-        """Hidden method used to get the list of suggestions on the page. As with most things, this list of suggestions
-        will be a list of Thumbnail objects that can be parsed individually.
-
-        Parameters
-        -----------
-        html : bs4.BeautifulSoup
-            The html page we are trying to parse the suggestions for
-
-        Returns
-        --------
-        List[Thumbnail]
-            The list of suggestions as thumbnails.
-        """
-        suggestions_raw = html.find("span", string="You may also like").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)
-        suggestions = []
-        for x in suggestions_raw:
-            try:
-                link = x.find("a", class_="image")
-                suggestion_type = get_type_from(link["href"])
-                suggestion = Thumbnail(name=link["title"], url=link["href"], image=link.img["src"], type=suggestion_type)
-                suggestions.append(suggestion)
-            except (AttributeError, TypeError):
-                pass
-
-        return suggestions
-
-    def _get_games(self, html : bs4.BeautifulSoup) -> List[Thumbnail]:
-        """Used both for Teams and Engines, returns a list of games  present on the page
-        as Thumbnail objects.
-
-        Parameters
-        ----------
-        html : bs4.BeautifulSoup
-            The html to extract the list of games from
-
-        Returns
-        -------
-        List[Thumbnail]
-            The list of games on the page as Thumbnail objects.
-        """
-        games_raw = html.find(string="Games").parent.parent.parent.parent.find_all(class_="row rowcontent clear")
-        games = []
-        for x in games_raw:
-            link = x.find("div", class_="content").h4.a
-            image_url = link.parent.parent.parent.find("img")["src"]
-            game = Thumbnail(name=link.string, url=link["href"], image=image_url, type=ThumbnailType.game)
-            games.append(game)
-
-        return games
-
-    def _get_files(self, html : bs4.BeautifulSoup) -> List[Thumbnail]:
-        """Cache the files present on the page. Up to 5 files might be present
-
-        Parameters
-        -----------
-        html : bs4.BeautifulSoup
-            The page to cache the files from
-
-        Returns
-        -------
-        List[Thumbnail]
-            The list of file like thumbnails
-        """
-
-        files_raw = html.find(string="Files").parent.parent.parent.parent.find_all(class_="row rowcontent clear")
-        files = []
-        for x in files_raw:
-            link = x.find("div", class_="content").h4.a
-            try:
-                image_url = link.parent.parent.parent.find("img")["src"]
-            except TypeError:
-                image_url = None
-                
-            file = Thumbnail(name=link.string, url=link["href"], image=image_url, type=ThumbnailType.file)
-            files.append(file)
-
-        return files
-
-    def _get_engines(self, html):
-        """Hidden method to get the engines showed currently on the page as a list of engine like thumbnails. Takes
-        an entire page of html and sorts it out
-
-        Parameters
-        -----------
-        html : bs4.BeautifulSoup
-            The page to cache the engines from
-
-        Returns
-        -------
-        List[Thumbnail]
-            The list of engine like thumbnail objects
-        """
-        engines_raw = html.find(string="Engines").parent.parent.parent.parent.find_all(class_="row rowcontent clear")
-        engines = []
-        for x in engines_raw:
-            link = x.find("div", class_="content").h4.a
-            image_url = link.parent.parent.parent.find("img")["src"]
-            engine = Thumbnail(name=link.string, url=link["href"], image=image_url, type=ThumbnailType.engine)
-            engines.append(engine)
-
-        return engines
-
-    def get_addons(self, index : int = 1, *, query : str = None, addon_type : AddonCategory = None,
-        timeframe : TimeFrame = None, licence : Licence = None) -> ResultList:
-        """Get a page of addons for the page. Each page will yield up to 30 addons. 
-
-        Parameters
-        -----------
-        index : int
-            The page number to get the addons from.
-        query : str
-            The string query to search for in the addon name, optional.
-        addon_type : AddonCategory
-            Type enum defining what category the file is.
-        timeframe : TimeFrame
-            Time frame of when the file was added, optional
-        licence : Licence
-            The licence for the addon, optional
-
-        Returns
-        --------
-        ResultList[Thumbnail]
-            The list of addon type thumbnails parsed from the page
-        """
-        params = {
-            "filter": "t",
-            "kw": query,
-            "category": addon_type.value if addon_type else None,
-            "timeframe": timeframe.value if timeframe else None,
-            "licence": licence.value if licence else None
-        }
-        return self._get(f"{self.url}/addons/page/{index}", params=params)
-
-    def __repr__(self):
-        return f"<{self.__class__.__name__} name={self.name}>"
-
-@concat_docs
-class Mod(PageMetaClass):
-    """Basically just a subclass of Page
-
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The html to parse. Allows for finer control.
-
-    Filtering
-    ----------
-    released : :class:`.Status`
-        The status of the mod (released, unreleased)
-    genre : :class:`.Genre`
-        The genre of the mod (fps, tower defense)
-    theme : :class:`.Theme`
-        The theme of the mod (fantasy, action)
-    players : :class:`.PlayerStyle`
-        Player styles of the mod (co-op, singleplayer)
-    timeframe : :class:`.TimeFrame`
-        The time period this was released in (last 24hr, last week, last month)
-    game : Union[:class:`.Game`, :class:`.Object`]
-        An game object or an object with an id attribute which represents the
-        game the mod belongs to.
-
-    Sorting
-    --------
-        * **released** - when the object was released, asc is oldest, desc is most recent
-        * **id** - when it was added to moddb, asc is oldest, desc is most recent
-        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
-        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
-        * **rating** - order by rating, asc is highest rating, desc is lowest rating
-        * **name** - order alphabetically, asc is a-z, desc is z-a
-        * **game** - order by game???
-        * **dateup** - order by latest update, asc is most recent update first, desc is oldest update first
-
-    """
-    def __init__(self, html : bs4.BeautifulSoup):
-        super().__init__(html, SearchCategory.mods)
-
-@concat_docs
-class Game(PageMetaClass, GetModsMixin):
-    """A subclass of Page plus a method to get all the mods.
-
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The html to parse. Allows for finer control.
-    
-
-    Filtering
-    ----------
-    released : :class:`.Status`
-        The status of the game (released, unreleased)
-    genre : :class:`.Genre`
-        The genre of the game (fps, tower defense)
-    theme : :class:`.Theme`
-        The theme of the game (fantasy, action)
-    indie : :class:`.Scope`
-        Whether the game is triple AAA or indie
-    players : :class:`.PlayerStyle`
-        Player styles of the game (co-op, singleplayer)
-    timeframe : :class:`.TimeFrame`
-        The time period this was released in (last 24hr, last week, last month)
-
-    Sorting
-    --------
-        * **released** - when the object was released, asc is oldest, desc is most recent
-        * **id** - when it was added to moddb, asc is oldest, desc is most recent
-        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
-        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
-        * **rating** - order by rating, asc is highest rating, desc is lowest rating
-        * **name** - order alphabetically, asc is a-z, desc is z-a
-        * **dateup** - order by latest update, asc is most recent update first, desc is oldest update first
-    """
-    def __init__(self, html : bs4.BeautifulSoup):
-        super().__init__(html, SearchCategory.games)
-
-@concat_docs
-class Engine(PageMetaClass, GetGamesMixin):
-    """A subclass of Page, however, it does not have the files attribute
-
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The html to parse. Allows for finer control.
-
-    Filtering
-    -----------
-    released : :class:`.Status`
-        The status of the engine (released, unreleased)
-    licence : :class:`.Licence`
-        The license of the engine
-    timeframe : :class:`.TimeFrame`
-        The time period this was released in (last 24hr, last week, last month)
-
-    Sorting
-    --------
-        * **released** - when the object was released, asc is oldest, desc is most recent
-        * **id** - when it was added to moddb, asc is oldest, desc is most recent
-        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
-        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
-        * **rating** - order by rating, asc is highest rating, desc is lowest rating
-        * **name** - order alphabetically, asc is a-z, desc is z-a
-        * **dateup** - order by latest update, asc is most recent update first, desc is oldest update first
-
-    Attributes
-    -----------
-    games : List[Thumbnail]
-        A list of games suggested on the engine main page.
-    """
-    def __init__(self, html : bs4.BeautifulSoup):
-        super().__init__(html, SearchCategory.engines)
-        delattr(self, "files")
-
-        try:
-            self.games = self._get_games(html)
-        except AttributeError:
-            LOGGER.info("Engine '%s' has no games", self.name)
-            self.games = []
-
-@concat_docs
-class File(BaseMetaClass):
-    """An oject representing a file on ModDB, a file is something posted by the page owner which is directly linked 
-    to the page. It is endorsed by the page owner and they should do everythign they can to make sure that it is safe.
-    As compared to an addon that may be added by fans to the page and that are files meant to work with the page but
-    that are not directly related to the page. E.x the file of a mod page would be the mod files used to install the
-    mod whereas an addon could be something like a fan-made texture pack for the mod or a map.
-
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The html to parse. Allows for finer control.
-
-    Filtering
-    ----------
-    category  : :class:`.FileCategory`
-        The type of file (audio, video, demo, full version....)
-    categoryaddon : :class:`.AddonCategory`
-        The type of addon (map, textures, ect...)
-    game : Union[:class:`.Game`, :class:`.Object`]
-        An game object or an object with an id attribute which represents the
-        game the file belongs to.
-    timeframe : :class:`.TimeFrame`
-        The time period this was released in (last 24hr, last week, last month)
-
-    Sorting
-    --------
-        * **released** - when the object was released, asc is oldest, desc is most recent
-        * **id** - when it was added to moddb, asc is oldest, desc is most recent
-        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
-        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
-        * **rating** - order by rating, asc is highest rating, desc is lowest rating
-        * **name** - order alphabetically, asc is a-z, desc is z-a
-        * **date** - order by upload date, asc is most recent first, desc is oldest first
-
-    Attributes
-    -----------
-    filename : str
-        The name of the file
-    hash : str
-        The MD5 hash of the file
-    name : str
-        The name of the page
-    size : int
-        the file size in bytes
-    today : int
-        The number of downloads today
-    downloads : int
-        The total number of times this file has been downloaded
-    category : FileCategory
-        The category of the file
-    author : Thumbnail
-        A member type thumbnail of the member who uploaded the file
-    date : datetime.datetime
-        The date the file was uploaded
-    button : str
-        html code for the embed button
-    widget : str
-        html code for the embed widget
-    description : str
-        Description of the file, as written by the author
-    preview : str
-        URL of the preview image for the file
-    """
-    def __init__(self, html : bs4.BeautifulSoup):
-        if html.find("span", string="File Deleted", class_="heading"):
-            raise ValueError("This file has been removed")
-
-        info = html.find("div", class_="table tablemenu")
-        file = {x.string.lower() : x.parent.span.string.strip() for x in info.find_all("h5", string=("Filename", "Size", "MD5 Hash"))}
-        self.name = html.find("a", title="Report").parent.parent.find("span", class_="heading").string
-        self.filename = file["filename"]
-        super().__init__(html)
-
-        self.hash = file["md5 hash"]
-        self.size = int(re.sub(r"[(),bytes]", "", file["size"].split(" ")[1]))
-
-        downloads = html.find("h5", string="Downloads").parent.a.string
-        self.today = int(re.sub(r"[(),today]", "", downloads.split(" ")[1]))
-        self.downloads = int(downloads.split(" ")[0].replace(",", ""))
-
-        try:
-            self.category = FileCategory(int(info.find("h5", string="Category").parent.a["href"].split("=")[-1]))
-        except ValueError:
-            self.category = AddonCategory(int(info.find("h5", string="Category").parent.a["href"].split("=")[-1]))
-        
-        uploader = info.find("h5", string="Uploader").parent.a
-        self.author = Thumbnail(url=uploader["href"], name=uploader.string, type=ThumbnailType.member)
-
-        self.date = get_date(info.find("h5", string="Added").parent.span.time["datetime"])
-        self.button = info.find("h5", string="Embed Button").parent.span.input["value"]
-        self.widget = info.find("h5", string="Embed Widget").parent.span.input["value"]
-
-        self.description = html.find("p", id="downloadsummary").string
-
-        self.preview = html.find_all("img", src=True)[0]["src"]
-
-    def __repr__(self):
-        return f"<{self.__class__.__name__} name={self.name} type={self.category.name}>"
-
-    def save(self, path = None): 
-        """Save the file to a location.
-
-        Parameters
-        -----------
-        path : Optional[str]
-            Path to the location you wish to save the file at. If none is provided
-            it will save in the current working directory.
-
-        """
-        download = get_page(f"https://www.moddb.com/downloads/start/{self.id}")
-        mirror = join(download.find("a", string=f"download {self.name}")["href"])
-        file = request(mirror)
-        path = f"{path}/{self.filename}" if path else self.filename
-        with open(path, "wb") as f:
-            f.write(file.content)
-
-@concat_docs
-class Addon(File):
-    """Object representing an addon. Seemingly the only difference between an addon and a file is in
-    the semantics. A file often represents something official released by the page, e.g. the mod installation
-    or an official guide where as addons are often fan made and might not be directly endorsed by the page owners
-    even if it is allowed. They literally add on to the page's content without becoming part of it. There is a slight
-    difference in their profiles but nothing beyond that.
-
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The html to parse. Allows for finer control.
-
-    Filtering
-    ----------
-    categoryaddon : :class:`.AddonCategory`
-        The type of addon (map, textures, ect...)
-    licence : :class:`.Licence`
-        The licence of the addon
-    game : Union[:class:`.Game`, :class:`.Object`]
-        An game object or an object with an id attribute which represents the
-        game the addon belongs to.
-    timeframe : :class:`.TimeFrame`
-        The time period this was released in (last 24hr, last week, last month)
-
-    Sorting
-    --------
-        * **released** - when the object was released, asc is oldest, desc is most recent
-        * **id** - when it was added to moddb, asc is oldest, desc is most recent
-        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
-        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
-        * **rating** - order by rating, asc is highest rating, desc is lowest rating
-        * **name** - order alphabetically, asc is a-z, desc is z-a
-        * **licence** - order based on licence
-        * **date** - order by upload date, asc is most recent first, desc is oldest first
-
-
-    """
-    pass
-
-@concat_docs
-class Media(BaseMetaClass):
-    """Represents an image, audio file or video file on 
-
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The html to parse. Allows for finer control.
-
-    Filtering
-    -----------
-    sitearea : :class:`.Category`
-        The type of model the media belongs to. Category.downloads is not valid for this.
-
-    Sorting
-    --------
-        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
-        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
-        * **name** - order alphabetically, asc is a-z, desc is z-a
-        * **id** - order by upload date, asc is most recent first, desc is oldest first
-
-        Exclusive to videos and audios
-        * **duration** - order by duration, asc is shortest to longest, desc is longest first
-
-    Attributes
-    -----------
-    date : datetime.datetime
-        The date the media was uploaded
-    name : str
-        The name of the media
-    author : Thumbnail
-        Member type thumbnail of the media uploader
-    duration : datetime.timedelta
-        Duration of the media in seconds, 0 if it's an image
-    size : int
-        Size of the files in bytes
-    views : int
-        Total amount of views
-    today : int
-        Amount of views today
-    filename : str
-        The name of the file for the media
-    fileurl : str
-        The url of the file for the media
-    category : MediaCategory
-        Whether the media is an image, a video or an audio
-    description : str
-        The description of the file as given by the file uploader.
-    """
-    def __init__(self, html : bs4.BeautifulSoup):
-        try:
-            self.name = html.find("meta", itemprop="name")["content"]
-        except TypeError:
-            self.name = html.find("img", id="mediaimage")["title"]
-            
-        super().__init__(html)
-        medias = html.find_all("h5", string=("Date", "By", "Duration", "Size", "Views", "Filename"))
-        raw_media = {media.string.lower() : media.parent for media in medias}
-        
-        self.date = get_date(raw_media["date"].span.time["datetime"])
-
-        author = raw_media["by"].span.a
-        self.author = Thumbnail(url=author["href"], name=author.string.strip(), type=ThumbnailType.member)
-
-        if "duration" in raw_media:
-            duration = raw_media["duration"].span.time.string.strip().split(":")
-            duration.reverse()
-            times = ["seconds", "minutes", "hours"]
-            self.duration = datetime.timedelta(**{times[duration.index(x)] : int(x) for x in duration})
-        else:
-            self.duration = 0
-
-        if "size" in raw_media:
-            self.size = tuple(raw_media["size"].span.string.strip().split("×"))
-
-        self.views, self.today = get_views(raw_media["views"].a.string)
-
-        if "size" in raw_media and "duration" in raw_media:
-            self.category = MediaCategory.video
-            self.fileurl = html.find("meta", property="og:image")["content"][:-4]
-        elif "size" in raw_media:
-            self.category = MediaCategory.image
-            self.fileurl = html.find("meta", property="og:image")["content"]
-        else:
-            self.category = MediaCategory.audio
-            self.fileurl = html.find("video", id="mediaplayer").find("source")["src"]
-
-        if "filename" in raw_media:
-            self.filename = raw_media["filename"].span.string.strip()
-        else:
-            self.filename = self.fileurl.split("/")[-1]
-
-        self.description = html.find("meta", {"name":"description"})["content"]
-
-    def __repr__(self):
-        return f"<Media name={self.name} type={self.type.name}>"
-
-    def save(self, path = None): 
-        """Save the media to a location.
-
-        Parameters
-        -----------
-        path : Optional[str]
-            Path to the location you wish to save the file at. If none is provided
-            it will save in the current working directory.
-
-        """
-        file = request(self.fileurl)
-        path = f"{path}/{self.filename}" if path else self.filename
-        with open(path, "wb") as f:
-            f.write(file.content)
-
-@concat_docs
-class Article(BaseMetaClass):
-    """This object represents an news article, a tutorial or a feature.
-
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The html to parse. Allows for finer control.
-
-    Filtering
-    ----------
-    category : :class:`.ArticleCategory`
-        Type of the article (news, feature)
-    timeframe : :class:`.TimeFrame`
-        The time period this was released in (last 24hr, last week, last month)
-    game : Union[:class:`.Game`, :class:`.Object`]
-        An game object or an object with an id attribute which represents the
-        game the article belongs to.
-
-    Sorting
-    --------
-        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
-        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
-        * **dateup** - order by article date, asc is most recent first, desc is oldest first
-        * **name** - order alphabetically, asc is a-z, desc is z-a
-        * **member** - order by member???
-        * **date** - order by upload date, asc is most recent first, desc is oldest first
-
-        Exclusive to tutorials
-        * **meta** - sort by difficulty, asc is most difficult, desc is least difficult
-        * **subtype** - sort by the area the tutorial covers
-
-    Attributes
-    -----------
-    category : ArticleCategory
-        Whether this article is a news article, a tutorial or a feature
-    name : str
-        The name of the article
-    profile : Profile
-        The profile object of the moddb model the article is for (engine, game, mod...). Can be none if it is not
-        rattached to anything, such as for site news.
-    tags : dict{str : str}
-        A dictionary of tags with the tag names as the key and the url to the tag
-        as the value.
-    views : int
-        Total amount of times this article was viewed
-    today : int
-        amount of time the article has been viewed today
-    intro : int
-        The intro/teaser paragraph of the article
-    author : Thumbnail
-        A member type thumbnail of the member who published the article
-    date : datetime.datetime
-        The date the article was published
-    html : str
-        The html of the article
-    plaintext : str
-        The article text without any html
-    summary : str
-        plaintext intro to the article
-    tutorial_category : TutorialCategory
-        If the article category is tutorial, this represents the area the tutorial covers, else it is None
-    difficulty : Difficulty
-        If the article category is tutorial, this represents how hard the tutorial is.
-    """
-    def __init__(self, html : bs4.BeautifulSoup):
-        try:
-            self.name = html.find("span", itemprop="headline").string
-        except AttributeError:
-            self.name = html.find("span", itemprop="heading").string
-
-        super().__init__(html)
-
-        raw_type = html.find("h5", string="Browse").parent.span.a.string
-        self.category = ArticleCategory[raw_type.lower()]
-
-        try:
-            raw = html.find("span", string=raw_type[0:-1]).parent.parent.parent.find("div", class_="table tablemenu")
-        except AttributeError:
-            raw = html.find("span", string=raw_type).parent.parent.parent.find("div", class_="table tablemenu")
-
-        try:
-            self.profile = Profile(html)
-        except AttributeError:
-            LOGGER.info("'%s' has no profile", self.name)
-            self.profile = None
-
-        try:
-            raw_tags = html.find("form", attrs={"name": "tagsform"}).find_all("a")
-            self.tags = {x.string : join(x["href"]) for x in raw_tags if x.string is not None}
-        except AttributeError:
-            self.tags = {}
-            LOGGER.info("'%s' '%s' has no tags", self.__class__.__name__, self.name) 
-        
-        views_raw = raw.find("h5", string="Views").parent.span.a.string
-        self.views, self.today = get_views(views_raw)
-        
-        self.intro = html.find("p", itemprop="description").string
-        author = html.find("span", itemprop="author").span.a
-        self.author = Thumbnail(name=author.string, url=author["href"], type=ThumbnailType.member)
-
-        self.date = get_date(html.find("time", itemprop="datePublished")["datetime"])
-        self.html = str(html.find("div", itemprop="articleBody"))
-        self.plaintext = html.find("div", itemprop="articleBody").text
-
-        self.summary = html.find("p", class_="introductiontext").string
-
-        if self.category == ArticleCategory.tutorials:
-            cat = html.find("span", itemprop="proficiencyLevel").nextSibling.strip()
-            self.tutorial_category = TutorialCategory[cat.replace("/", "_").replace(" ", "_").lower()]
-            self.difficulty = Difficulty[html.find("span", itemprop="proficiencyLevel").string.lower()]
-
-    def __repr__(self):
-        return f"<Article title={self.name} type={self.category.name}>"
-
-@concat_docs
-class Group(PageMetaClass):
-    """This object represents the group model of  Certain attributes can be None if the group
-    has been set to private. If you wish to see a group you have access to then you can login with the
-    login 
-
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The html to parse. Allows for finer control.
-
-    Filtering
-    ----------
-    subscriptions : :class:`.Membership`
-        The subscription system of the group (private, invitation)
-    category : :class:`.GroupCategory`
-        The category of the group (funny, literature)
-
-    Sorting
-    --------
-        * **id** - order group by date, asc is most recent first, desc is oldest first
-        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
-        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
-        * **name** - order alphabetically, asc is a-z, desc is z-a
-        * **membercount** - order by number of members, asc is most members first, desc is lest member first
-
-    Attributes
-    -----------
-    name : str
-        The name of the group
-    private : bool
-        Whether or not the group is private
-    profile : Profile
-        The profile object for the group
-    stats : Statistics
-        The stats of the Group
-    tags : dict{str, str}
-        A dictionnary of tags where the key is the tag name and the value is the tag url
-    embed : str
-        The html for athe group embed
-    medias : List[Thumbnail]
-        A list of media like thumbnail objects representing all the images, videos and audio
-        clips that a group has published.
-    suggestions : List[Thumbnail]
-        A list of group like thumbnail objects representing the suggestions made by moddb to
-        members visiting this group
-    articles : List[Thumbnail]
-        A list of article like thumbnail objects representing some of the articles published
-        by the group
-    description : str
-        The plaintext description of the group
-    """
-    def __init__(self, html : bs4.BeautifulSoup):
-        self.name = html.find("div", class_="title").h2.a.string
-        BaseMetaClass.__init__(self, html)
-        self.private = False
-
-        try:
-            self.profile = Profile(html)
-        except AttributeError:
-            LOGGER.info("Entity '%s' has no profile (private)", self.name)
-            self.profile = None
-            self.private = True
-
-        try:
-            self.stats = Statistics(html)
-        except AttributeError:
-            LOGGER.info("Entity '%s' has no stats (private)", self.name)
-            self.stats = None
-
-        try:
-            raw_tags = html.find("form", attrs={"name": "tagsform"}).find_all("a")
-            self.tags = {x.string : join(x["href"]) for x in raw_tags if x.string is not None}
-        except AttributeError:
-            LOGGER.info("Entity '%s' has no tags (private)", self.name)
-            self.tags = {}
-
-        try:
-            self.embed = html.find("input", type="text", class_="text textembed")["value"]
-        except TypeError:
-            try:
-                self.embed = str(html.find_all("textarea")[1].a)
-            except IndexError:
-                LOGGER.info("Group '%s' has no embed", self.name)
-                self.embed = None
-
-        self.suggestions = self._get_suggestions(html)
-
-        try:
-            articles_raw = html.find("span", string="Articles").parent.parent.parent.find("div", class_="table")
-            thumbnails = articles_raw.find_all("div", class_="row rowcontent clear", recursive=False)
-            self.articles = [Thumbnail(name=x.a["title"], url=x.a["href"], image=x.a.img["src"], summary=x.find("p").string, date=x.find("time")["datetime"], type=ThumbnailType.article) for x in thumbnails]
-        except AttributeError:
-            LOGGER.info("Group '%s' has no article suggestions", self.name)
-            self.articles = []
-
-        try:
-            self.description = html.find("div", id="profiledescription").text
-        except AttributeError:
-            self.description = html.find("div", class_="column span-all").find("div", class_="tooltip").parent.text
-
-        self.medias = self._get_media(2, html=html)
-
-    def get_reviews(self, *args, **kwargs):
-        """"""
-        raise AttributeError(f"{self.__class__.__name__} has no 'get_reviews' attribute")
-
-    def _get_suggestions(self, html : bs4.BeautifulSoup) -> List[Thumbnail]:
-        """Hidden method used to get the list of suggestions on the page. As with most things, this list of suggestions
-        will be a list of Thumbnail objects that can be parsed individually. Slightly modified to fit a group
-
-        Parameters
-        -----------
-        html : bs4.BeautifulSoup
-            The html page we are trying to parse the suggestions for
-
-        Returns
-        --------
-        List[Thumbnail]
-            The list of suggestions as thumbnails.
-        """
-        try:
-            suggestions_raw = html.find("span", string=("You may also like", "Popular Articles")).parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)
-        except AttributeError:
-            LOGGER.info("Group '%s' has no sidebar suggestions", self.name)
-            return []
-
-        suggestions = []
-        for x in suggestions_raw:
-            try:
-                link = x.find("a", class_="image")
-                suggestion_type = link["href"].split("/")[1].replace("s", "")
-                suggestion = Thumbnail(name=link["title"], url=link["href"], image=link.img["src"], type=ThumbnailType[suggestion_type])
-                suggestions.append(suggestion)
-            except (AttributeError, TypeError):
-                pass
-
-        return suggestions
-
-    def __repr__(self):
-        return f"<Group name={self.name}>"
-
-@concat_docs
-class Team(Group, GetEnginesMixin, GetGamesMixin, GetModsMixin, GetSoftwareHardwareMixin):
-    """A team is a group of people, which are the author of a game, a mod or an engine. A group has members which all
-    have rights on those page. Like a member but instead of a single person authoring various mods and games it's several.
-
-    Parameters
-    ------------
-    html : bs4.BeautifulSoup
-        The html to parse. Allows for finer control.
-
-    Filtering
-    ----------
-    subscriptions : :class:`.Membership`
-        The subscription system of the company (private, invitation)
-    category : :class:`.TeamCategory`
-        What does the team do (publisher, developer)
-
-    Sorting
-    --------
-        * **id** - order by creation, desc is most recent first, asc is oldest first
-        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
-        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
-        * **name** - order alphabetically, asc is a-z, desc is z-a
-        * **game** - order by game???
-        * **dateup** - order by latest update, asc is most recent update first, desc is oldest update first
-
-    Attributes
-    -----------
-    games : List[Thumbnail]
-        A list of game like thumbnails that the team has authored.
-    engines : List[Thumbnail]
-        A list of engine like objects that the team has authored.     
-
-    """
-    def __init__(self, html : bs4.BeautifulSoup):
-        super().__init__(html)
-        try:
-            self.games = self._get_games(html)
-        except AttributeError:
-            LOGGER.info("Team '%s' has no games", self.name)
-            self.games = []
-
-        try:
-            self.engines = self._get_engines(html)
-        except AttributeError:
-            LOGGER.info("Team '%s' has no engines", self.name)
-            self.engines = []
-        try:
-            mods = html.find("span", string="Popular Mods").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)[1:]
-            self.mods = [Thumbnail(name=x.a["title"], url=x.a["href"], type=ThumbnailType.mod, image=x.a.img["src"]) for x in mods]
-        except AttributeError:
-            LOGGER.info("Team '%s' has no mods", self.name)
-            self.mods = []
-
-@concat_docs
-class Job:
-    """Model representing a job proposed on ModDB
-    
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The html to parse. Allows for finer control.
-
-    Filtering
-    ----------
-    skill : :class:`.JobSkill`
-        The job skill looked for
-    earn : :class:`.bool`
-        Whether or not the job is paid
-
-    Sorting
-    --------
-        * **location** - order by the location of the job
-        * **name** - order alphabetically, asc is a-z, desc is z-a
-        * **skill** - order by the skill required
-        * **id** - order by upload date, asc is most recent, desc is oldest first
-
-    Attributes
-    -----------
-    id : int
-        id of the job
-    name_id : str
-        The name_id of the member, cannot be changed, it's a mix of the original username lowercased with
-        spaces removed and shortened.
-    author : Thumbnail
-        A member like thumbnail representing the poster of the job. Can be none if they desire to remain private.
-    paid : bool
-        Whether or not the job is paid
-    tags : dict{str : str}
-        A dict of key-values pair where the key is the name of the tag and the value is the url.
-    skill : JobSkill
-        the skill demanded for the job
-    location : str
-        The location the job will be at
-    name : str
-        The name of the job
-    text : str
-        The description of the job
-    related : List[Thumbnail]
-        A list of team like thumbnails of companies related to the job poster
-
-    """
-    def __init__(self, html : bs4.BeautifulSoup):
-        breadcrumb = json.loads(html.find("script", type="application/ld+json").string)["itemListElement"][-1]["Item"]
-        self.name = breadcrumb["name"]
-        self.url = breadcrumb["@id"]
-        self.name_id = self.url.split("/")[0]
-        self.text = html.find("div", id="articlecontent").text
-
-        profile_raw = html.find("span", string="Jobs").parent.parent.parent.find("div", class_="table tablemenu")
-
-        self.id = int(re.search(r"siteareaid=(\d*)", html.find("a", string="Report")["href"])[1])
-
-        try:
-            author = profile_raw.find("h5", string="Author").parent.span.a
-            self.author = Thumbnail(url=author["href"], name=author.string, type=ThumbnailType.member)
-        except AttributeError:
-            LOGGER.info("Job '%s' has no author", self.name)
-            self.author = None
-
-        self.paid = profile_raw.find("h5", string="Paid").parent.a.string == "Yes"
-
-        try:
-            raw_tags = html.find("form", attrs={"name": "tagsform"}).find_all("a")
-            self.tags = {x.string : join(x["href"]) for x in raw_tags if x.string is not None}
-        except AttributeError:
-            self.tags = {}
-            LOGGER.info("'%s' '%s' has no tags", self.__class__.__name__, self.name)
-
-        self.skill = JobSkill(int(profile_raw.find("h5", string="Skill").parent.span.a["href"][-1]))
-
-        self.location = profile_raw.find("h5", string="Location").parent.span.string.strip()
-
-        try:
-            related = html.find("div", class_="tablerelated").find_all("a", class_="image")
-            self.related = [Thumbnail(url=x["href"], name=x["title"], type=ThumbnailType.team) for x in related]
-        except AttributeError:
-            LOGGER.info("Job '%s' has no related companies", self.name)
-            self.related = []
-
-        self._html = html
-
-    def __repr__(self):
-        return f"<Job name={self.name}>"
-
-@concat_docs
-class Blog(BaseMetaClass):
-    """Object used to represent a member blog.
-
-    Filtering
-    ----------
-    timeframe : :class:`.TimeFrame`
-        The time period this was released in (last 24hr, last week, last month)
-
-    Sorting
-    --------
-        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
-        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
-        * **dateup** - order by blog date, asc is most recent first, desc is oldest first
-        * **name** - order alphabetically, asc is a-z, desc is z-a
-        * **member** - order by member???
-        * **date** - order by upload date, asc is most recent first, desc is oldest first
-
-    Attributes
-    -----------
-    name_id : str
-        The name_id of the member, cannot be changed, it's a mix of the original username lowercased with
-        spaces removed and shortened.
-
-    """
-    def __init__(self, *, heading, text):
-        author = heading.find("span", class_="subheading").a
-        self.author = Thumbnail(url=author["href"], name=author.string, type=ThumbnailType.member)
-
-        self.date = get_date(heading.find("span", class_="date").time["datetime"])
-
-        title = heading.div.h4.a
-        self.name = title.string
-        self.url = join(title["href"])
-        self.name_id = self.url.split("/")[0]
-
-        self.html = str(text.content)
-        self.plaintext = text.text
-
-    def __repr__(self):
-        return f"<Blog title={self.name}>"
-
-@concat_docs
-class Member(PageMetaClass, GetGamesMixin, GetModsMixin):
-    """The object to represent an individual member on ModDB
-
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The html to parse. Allows for finer control.
-
-    Sorting
-    --------
-    * **username** - sort alphabetically by username, asc is z-a and desc is a-z
-    * **id** - sort by member creation date, asc is most recent, desc is oldest
-    * **online** - sort by last online, asc is most recently seen online and desc is least recently
-
-    Attributes
-    -----------
-    profile : MemberProfile
-        Since member profile boxes have no overlap with other profiles, they are a separate object type
-        but serve the same function of making the side box "Profile" into an object, except exclusively
-        for a member page.
-    stats : MemberStatistics
-        Since member statistics have no overlap with regular statistic pages, they are a separate object type
-        but serve the same function of making the side box "Statistics" into an object, but exclusively for
-        the member page.
-    description : str
-        Description written on the member profile
-    groups : List[Thumbnail]
-        A list of group/team like thumbnail objects representing both the Teams the member is part of and the
-        Groups the member is part of.
-    blog : Blog
-        The front page blog shown on the member page
-    blogs : List[Thumbnail]
-        A list of blog like thumbnails representing the blog suggestion of a member's frontpage
-    friends : List[Thumnails]
-        A list of member like thumbnails representing some of the friends shown on the member's front
-        page
-    """
-    def __init__(self, html : bs4.BeautifulSoup):
-        super().__init__(html, SearchCategory.members)
-        try:
-            self.profile = MemberProfile(html)
-        except AttributeError:
-            LOGGER.info("Member '%s' has no profile (private)", self.name)
-            self.profile = None
-
-        try:
-            self.stats = MemberStatistics(html)
-        except  AttributeError:
-            LOGGER.info("Member '%s' has no stats (private)", self.name)
-            self.stats = None
-
-        try:
-            self.description = html.find("div", id="profiledescription").p.string
-        except AttributeError:
-            LOGGER.info("Member '%s' has no description", self.name)
-            self.description = None
-
-        try:
-            groups_raw = html.find("span", string="Groups").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)[:-2]
-            self.groups = [Thumbnail(name=div.a["title"], url=div.a["href"], type=ThumbnailType.group) for div in groups_raw]
-        except AttributeError:
-            LOGGER.info("Member '%s' doesn't have any groups", self.name)
-            self.groups = []
-
-        try:
-            blogs_raw = html.find("span", string="My Blogs").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)
-            self.blog = Blog(heading=blogs_raw.pop(0), text=blogs_raw.pop(0))
-        except (TypeError, AttributeError):
-            self.blog = None
-            LOGGER.info("Member '%s' has no front page blog", self.name)
-
-        try:
-            blogs_raw = html.find("span", string="My Blogs").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)
-            self.blogs = [Thumbnail(name=blog.a.string, url=blog.a["href"], type=ThumbnailType.blog) for blog in blogs_raw[:-2]]
-        except (TypeError, AttributeError):
-            self.blogs = []
-            LOGGER.info("Member '%s' has no blog suggestions", self.name)
-
-        try:
-            friends = html.find("div", class_="table tablerelated").find_all("div", recursive=False)[1:]
-            self.friends = [Thumbnail(name=friend.a["title"], url=friend.a["href"], type=ThumbnailType.member) for friend in friends]
-        except AttributeError:
-            self.friends = []
-            LOGGER.info("Member '%s' has no friends ;(", self.name)
-
-    def __repr__(self):
-        return f"<Member name={self.name} level={self.profile.level}>"
-
-    def get_blogs(self, index : int = 1, *, query : str = None, timeframe : TimeFrame = None, 
-                  sort : Tuple[str, str] = None) -> ResultList:
-        """Search through a member's blogs one page at a time with certain filters.
-
-        Parameters
-        -----------
-        index : int
-            The page index you wish to get the blogs for, allows to hop around.
-        timeframe : TimeFrame
-            The date the blog was added, optional
-        query : str
-            The string to look for in the blog title, optional.
-        sort : Tuple[str, str]
-            The sorting tuple to sort by
-
-        Returns
-        --------
-        ResultList[Blog]
-            The list of blogs on this page. 
-        """
-        params = {
-            "filter": "t",
-            "kw": query,
-            "timeframe": timeframe.value if timeframe else None,
-            "sort": f"{sort[0]}-{sort[1]}" if sort else None
-        }
-
-        return self._get_blogs(f"{self.url}/blogs/page/{index}", params=params)
-
-    def _get_blogs(self, url, *, params):
-        """Backend class so we can use it with ResultList"""
-        html = get_page(url, params=params)
-        try:
-            table = html.find("form", attrs={'name': "filterform"}).parent.find("div", class_="table")
-        except AttributeError:
-            table = None
-
-        if table is None:
-            return []
-
-        raw_blogs = table.find_all("div", recursive=False)[2:]
-        blogs = []
-        e = 0
-        for _ in range(len(raw_blogs)):
-            try:
-                heading = raw_blogs[e]
-            except IndexError:
-                break
-
-            try:
-                text = raw_blogs[e+1]
-            except IndexError:
-                text = {"class": None}
-
-            blog_obj = Blog(heading=heading, text=text)
-            blogs.append(blog_obj)
-            e += 2
-
-        page, max_page = get_page_number(html)
-
-        return ResultList(
-            results=blogs,
-            params=params,
-            url=url,
-            action=self._get_blogs,
-            page=page,
-            max_page=max_page
-        )
-
-    def get_member_comments(self, index : int = 1, *, show_deleted : bool = False) -> CommentList:
-        """Gets a page of all the comments a member has posted.
-        
-        Parameters
-        -----------
-        index : int
-            The page number to get the comments from.
-        show_deleted : Optional[bool]
-            Pass true to show deleted user comments. Only works if it is a page
-            you have permissions on.
-
-        Returns
-        --------
-        CommentList[Comment]
-            A list of the comments made by the user.
-
-        """
-        params = {
-            "deleted" : "t" if show_deleted else None
-        }
-
-        html = get_page(f"{self.url}/comments/page/{index}", params=params)
-        return self._get_comments(html)
-
-    def get_friends(self, index : int = 1, *, username : str = None) -> ResultList:
-        """Get a page of the friends of the member
-    
-        Parameters
-        -----------
-        index : int
-            The page number to get the friends from.
-        username : Optional[str]
-            The username of the user you are looking for
-
-        Returns
-        --------
-        ResultList[Thumbnail]
-            A list of member like thumbnails of the member's friends
-        """
-        params = {
-            "filter": "t",
-            "username": username
-        }
-
-        return self._get(f"{self.url}/friends/page/{index}", params=params)
-
-    def get_groups(self, index : int = 1, *, query : str = None, subscription : Membership = None,
-        category : GroupCategory = None) -> ResultList:
-        """Get a page of the groups and teams a member is part of.
-        
-        Parameters
-        -----------
-        index : int
-            The page number to get the friends from.
-        query : Optional[str]
-            The text to look for in the group's name
-        subscription : Optional[Membership]
-            The membership rules
-        category : Optional[GroupCategory]
-            The category of groups to search for
-
-        Returns
-        --------
-        ResultList[Thumbnail]
-            A list of team/group like thumbnails the member is part of
-        """
-        params = {
-            "filter": "t",
-            "kw": query,
-            "subscription": subscription.value if subscription else None,
-            "category": category.value if category else None
-        }
-
-        return self._get(f"{self.url}/groups/page/{index}", params=params)
-
-class FrontPage:
-    """An object representing the front page of  More of less just a long suggestion of the hottest mods,
-    games, articles and files of the moment
-
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The html to parse. Allows for finer control.
-
-    Attributes
-    -----------
-    slider : List[Thumnail]
-        A list of object like thumbnails presented by the slider present on
-        the front page, this is a catered list of promoted content, can contain
-        any type of object.
-    articles : List[Thumbnail]
-        A list of article like thumbnail objects representing the suggested
-        articles on the front page.
-    mods : List[Thumbnail]
-        A list of mod like thumbnail objects representing the suggested
-        mods on the front page.
-    games : List[Thumbnail]
-        A list of game like thumbnail objects representing the suggested
-        games on the front page.
-    files : List[Thumbnail]
-        A list of file like thumbnail objects representing the suggested
-        files on the front page.
-    poll : Poll
-        The current ongoing moddb poll. Currently cannot be voted on.
-
-    """
-    def __init__(self, html : bs4.BeautifulSoup):
-        slider = html.find("div", class_="rotatorholder").find_all("div", class_="rotatorbox")
-        self.slider = []
-        for x in slider:
-            name = x.a.find("h2")
-            summary = x.a.find("p")
-
-            image = re.search(r'\((.*)\)', x["style"])
-            if image:
-                image = image.group(1)
-            else:
-                try:
-                    image = x["data-bg"]
-                except KeyError:
-                    image = None
-
-            thumbnail = Thumbnail(
-                name=name.string if name else None, 
-                url=x.a["href"], 
-                summary=summary.string if summary else None, 
-                image=image, 
-                type=get_type_from(x.a["href"])
-            )
-
-            self.slider.append(thumbnail)
-
-
-        articles = html.find("span", string="Latest Articles").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)[:-1]
-        self.articles = [Thumbnail(name=x.a["title"], url=x.a["href"], type=ThumbnailType.article, image=x.a.img["src"], summary=x.find("p").string, date=x.find("time")["datetime"]) for x in articles]
-
-        mods = html.find("span", string="Popular Mods").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)[1:]
-        self.mods = [Thumbnail(name=x.a["title"], url=x.a["href"], type=ThumbnailType.mod, image=x.a.img["src"]) for x in mods]
-
-        games = html.find("span", string="Popular Games").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)[1:]
-        self.games = [Thumbnail(name=x.a["title"], url=x.a["href"], type=ThumbnailType.game, image=x.a.img["src"]) for x in games]
-
-        jobs = html.find("span", string="Jobs").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)[1:]
-        self.jobs = [Thumbnail(name=x.find("a").string, url=join(x.find("a")["href"]), type=ThumbnailType.job) for x in jobs]
-
-        files = html.find("span", string="Popular Files").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)[1:]
-        self.files = [Thumbnail(name=x.a["title"], url=x.a["href"], type=ThumbnailType.file, image=x.a.img["src"]) for x in files]
-
-        self.poll = Poll(get_page(html.find("div", class_="poll").form["action"]))
-
-        self._html = html
-
-    def __repr__(self):
-        return f"<FrontPage articles={len(self.articles)} mods={len(self.mods)} games={len(self.games)} files={len(self.files)}>"
-
-@concat_docs
-class Platform(BaseMetaClass, GetModsMixin, GetGamesMixin, GetEnginesMixin, GetSoftwareHardwareMixin):
-    """Represents the platform supporting the game/engines. Game and engines may have mutiple platforms.
-
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The html to parse. Allows for finer control.
-
-    Filtering
-    ----------
-    released : :class:`.Status`
-        Current status of release (unreleased, early access, ect...)
-
-    Sorting
-    --------
-        * **released** - when the object was released, asc is oldest, desc is most recent
-        * **id** - when it was added to moddb, asc is oldest, desc is most recent
-        * **porder** - what plaform it's on???
-        * **company** - company that made it in alphabetical order
-
-
-    Attributes
-    -----------
-    name : str
-        The name of the platform
-    name_id : str
-        The name_id of the member, cannot be changed, it's a mix of the original username lowercased with
-        spaces removed and shortened.
-    id : int
-        The moddb id of the platform
-    url : str
-        The url of the platform page
-    description : str
-        Description of the platform
-    company : Thumbnail
-        A team like thumbnail of the company.
-    homepage : str
-        Link to the homepage of the engine
-    date : datetime.datetime
-        Date the engine was published
-    stats : PlatformStatistics
-        Stat data on the platform
-    share : dict{str : str}
-        Share link of the platform with the name of the share as key and link of the share as url.
-    comments : CommentList[Comment]
-        Comments on this page
-    games : List[Thumbnail]
-        A list of games suggested on the platform main page.
-    hardware : List[Thumbnail]
-        A list of hardware suggested on the platform main page.
-    software : List[Thumbnail]
-        A list of software suggested on the platform main page.
-    engines : List[Thumbnail]
-        A list of engines suggested on the platform main page.
-    mods : List[Thumbnail]
-        A list of mods suggested on the platform main page.
-    """
-    def __init__(self, html):
-        self.name = html.find("a", itemprop="mainEntityOfPage").string
-        self.id = None
-
-        self.url = join(html.find("a", itemprop="mainEntityOfPage")["href"])
-        self.name_id = self.url.split("/")[0]
-        try:
-            self.description = html.find("div", id="profiledescription").p.string
-        except AttributeError:
-            self.description = html.find("p", itemprop="description").string
-
-        try:
-            company = html.find("h5", string="Company").parent.span.a
-            self.company = Thumbnail(name=company.string, url=company["href"], type=ThumbnailType.team)
-        except AttributeError:
-            LOGGER.info("Platform '%s' has no company", self.name)
-            self.company = None
-
-        self.homepage = html.find("h5", string="Homepage").parent.span.a["href"]
-
-        self.date = get_date(html.find("time", itemprop="releaseDate")["datetime"])
-
-        self.stats = PlatformStatistics(html)
-
-        try:
-            share = html.find("h5", string="Share").parent.span.find_all("a")
-            self.share = {
-                "reddit": share[0]["href"],
-                "mail": share[1]["href"],
-                "twitter": share[2]["href"],
-                "facebook": share[3]["href"]
-            }
-        except (AttributeError, IndexError):
-            LOGGER.info("Something funky about share box of platform '%s'", self.name)
-            self.share = None
-
-        self.comments = self._get_comments(html)
-        
-        hardware = html.find("span", string=" Hardware").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)[:-1]
-        self.hardware = [Thumbnail(name=x.a["title"], url=x.a["href"], type=ThumbnailType.hardware, image=x.a.img["src"]) for x in hardware]
-
-        software = html.find("span", string=" Software").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)[:-1]
-        self.software = [Thumbnail(name=x.a["title"], url=x.a["href"], type=ThumbnailType.software, image=x.a.img["src"]) for x in software]
-
-        engines = html.find("span", string=" Engines").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)[:-1]
-        self.engines = [Thumbnail(name=x.a["title"], url=x.a["href"], type=ThumbnailType.engine, image=x.a.img["src"]) for x in engines]
-
-        mods = html.find("span", string=" Mods").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)[:-1]
-        self.mods = [Thumbnail(name=x.a["title"], url=x.a["href"], type=ThumbnailType.mod, image=x.a.img["src"]) for x in mods]
-
-        games = html.find("span", string=" Games").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)[:-1]
-        self.games = [Thumbnail(name=x.a["title"], url=x.a["href"], type=ThumbnailType.game, image=x.a.img["src"]) for x in games]
-
-
-    def __repr__(self):
-        return f"<Platform name={self.name}>"
-
-class HardwareSoftwareMetaClass(BaseMetaClass, SharedMethodsMixin, RSSFeedMixin):
-    """Shared class for Hardware and Software
-
-    Attributes
-    -----------
-    description : str
-        Description of the page
-    profile : Profile
-        The page's profile
-    stats : Statistics
-        The page's stats
-    rating : float
-        The rating of the item
-    articles : List[Thumbnail]
-        List of article type thumbnails from the recommended articles
-    article : PartialArticle
-        The partial article presented on the front page
-    tags : Dict{str : str}
-        Dict of tags with the name as the key and the url as the value
-    medias : List[Thumbnail]
-        list of thumbnails representing all the combined media objects of a page (videos and images)
-    suggestions : List[Thumbnail]
-        list of suggested software/hardware type thumbnails 
-    """
-    def __init__(self, html):
-        super().__init__(html)
-        try:
-            self.description = html.find("div", id="profiledescription").p.string
-        except AttributeError:
-            self.description = html.find("p", itemprop="description").string
-
-        self.profile = Profile(html)
-        self.stats = Statistics(html)
-
-        try:
-            self.rating = float(html.find("div", class_="score").find("meta", itemprop="ratingValue")["content"])
-        except AttributeError:
-            self.rating = 0.0
-            LOGGER.info("'%s' '%s' is not rated", self.profile.category.name, self.name)
-
-        try:
-            self._review_hash = html.find("form", class_="ratingform").find("input", {"name": "hash"})["value"]
-        except AttributeError:
-            self._review_hash = None
-
-        articles_raw = None
-        try:
-            articles_raw = html.find("span", string="Related Articles").parent.parent.parent.find("div", class_="table")
-            thumbnails = articles_raw.find_all("div", class_="row rowcontent clear")
-            self.articles = [Thumbnail(name=x.a["title"], url=x.a["href"], image=x.a.img["src"] if x.a.img else None, summary=x.find("p").string, date=x.find("time")["datetime"], type=ThumbnailType.article) for x in thumbnails]
-        except AttributeError:
-            LOGGER.info("'%s' '%s' has no article suggestions", self.profile.category.name, self.name)
-            self.articles = []
-
-        if articles_raw:
-            self.article = PartialArticle(articles_raw)
-        else:
-            self.article = None
-            LOGGER.info("'%s' '%s' has no front page article", self.profile.category.name, self.name)
-
-        try:
-            raw_tags = html.find("form", attrs={"name": "tagsform"}).find_all("a")
-            self.tags = {x.string : join(x["href"]) for x in raw_tags if x.string is not None}
-        except AttributeError:
-            self.tags = {}
-            LOGGER.info("Hardware '%s' has no tags", self.name) 
-
-        self.medias = self._get_media(1, html=html)
-
-        try:
-            t = ThumbnailType[self.__class__.__name__.lower()]
-            suggestions = html.find("span", string="You may also like").parent.parent.parent.find_all("a", class_="image")
-            self.suggestions = [Thumbnail(url=x["href"], name=x["title"], type=t, image=x.img["src"]) for x in suggestions]
-        except AttributeError:
-            LOGGER.info("'%s' '%s' has no suggestions", self.__class__.__name__, self.name)
-
-@concat_docs
-class Hardware(HardwareSoftwareMetaClass, GetGamesMixin, GetSoftwareHardwareMixin):
-    """Represents a moddb Hardware page
-
-    Parameters
-    -----------
-    html : BeautifulSoup
-        The html file to parse, allows for finer control
-
-    Filtering
-    ----------
-    released : :class:`.Status`
-        Release status of the hardware (released, unreleased, early access)
-    category : :class:`.HardwareCategory`
-        Category of the hardware
-    timeframe : :class:`.TimeFrame`
-        How long ago the hardware was released
-
-    Sorting
-    --------
-        * **released** - when the object was released, asc is oldest, desc is most recent
-        * **id** - when it was added to moddb, asc is oldest, desc is most recent
-        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
-        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
-        * **rating** - order by rating, asc is highest rating, desc is lowest rating
-        * **category** - sort alphebatically by hardwarecategory
-        * **name** - order alphabetically, asc is a-z, desc is z-a
-        * **dateup** - order by latest update, asc is most recent update first, desc is oldest update first
-
-    Attributes
-    -----------
-    hardware : List[Thumbnail]
-        A list of hardware suggested on the hardware main page.
-    software : List[Thumbnail]
-        A list of software suggested on the hardware main page.
-    games : List[Thumbnail]
-        A list of games suggested on the hardware main page.
-    history : List[Thumbnail]
-        A list of previous iterations of the hardware
-    recommended : List[Thumbnail]
-        A list of recommended hardwares.
-    """
-    def __init__(self, html):
-        super().__init__(html)
-        self._type = SearchCategory.hardwares
-
-        try:
-            hardware = html.find("span", string="Hardware").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)[:-1]
-            self.hardware = [Thumbnail(name=x.a["title"], url=x.a["href"], type=ThumbnailType.hardware, image=x.a.img["src"]) for x in hardware]
-        except AttributeError:
-            LOGGER.info("Hardware '%s' has no hardware", self.name)
-            self.hardware = []
-
-        try:
-            software = html.find("span", string="Software").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)[:-1]
-            self.software = [Thumbnail(name=x.a["title"], url=x.a["href"], type=ThumbnailType.software, image=x.a.img["src"]) for x in software]
-        except AttributeError:
-            LOGGER.info("Hardware '%s' has no software", self.name)
-            self.software = []
-
-        try:
-            games = html.find("span", string="Games").parent.parent.parent.find("div", class_="table").find_all("div", recursive=False)[:-1]
-            self.games = [Thumbnail(name=x.a["title"], url=x.a["href"], type=ThumbnailType.game, image=x.a.img["src"]) for x in games]
-        except AttributeError:
-            LOGGER.info("Hardware '%s' has no games", self.name)
-            self.games = []
-
-        try:
-            history = html.find("span", string="History").parent.parent.parent.find_all("a", class_="image")
-            self.history = [Thumbnail(url=x["href"], name=x["title"], type=ThumbnailType.hardware, image=x.img["src"]) for x in history]
-        except AttributeError:
-            LOGGER.info("Harware '%s' has no history", self.name)
-            self.history = []
-
-        try:
-            recommended = html.find("span", string="Recommended").parent.parent.parent.find_all("a", class_="image")
-            self.recommended = [Thumbnail(url=x["href"], name=x["title"], type=ThumbnailType.hardware, image=x.img["src"]) for x in recommended]
-        except AttributeError:
-            LOGGER.info("Hardware '%s' has no recommended", self.name)
-            self.recommended = []
-
-@concat_docs
-class Software(HardwareSoftwareMetaClass):
-    """Represents a moddb Software page
-
-    Parameters
-    -----------
-    html : BeautifulSoup
-        The html file to parse, allows for finer control
-
-    Filtering
-    ----------
-    released : :class:`.Status`
-        Release status of the software (released, unreleased, early access)
-    category : :class:`.SoftwareCategory`
-        Category of the software
-    timeframe : :class:`.TimeFrame`
-        How long ago the software was released
-
-    Sorting
-    --------
-        * **released** - when the object was released, asc is oldest, desc is most recent
-        * **id** - when it was added to moddb, asc is oldest, desc is most recent
-        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
-        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
-        * **rating** - order by rating, asc is highest rating, desc is lowest rating
-        * **category** - sort alphebatically by softwarecategory
-        * **name** - order alphabetically, asc is a-z, desc is z-a
-        * **dateup** - order by latest update, asc is most recent update first, desc is oldest update first
-
-    """
-    def __init__(self, html):
-        super().__init__(html)
-        self._type = SearchCategory.softwares
-
-
-@concat_docs
-class Review:
-    """Represents a review.
-
-    Filtering
-    -----------
-    rating : int
-        A value from 1 to 10 denoting the rating number you're looking for
-    sitearea : Category
-        The type of model the rating is for (mod, engine, game)
-
-    Sorting
-    --------
-        * **ratingalt** - rating number, desc is biggest to lowest, asc is lowest to biggest
-        * **memberipid** - sort reviewer account age, asc is oldest reviewer first
-        * **positive** - how many people agree with it, desc is most to least people agreeing
-        * **negative** - how many people disagree with it, desc is most to least people disagreeing
-        * **id** - when it was added to moddb, asc is oldest, desc is most recent
-
-    Attributes
-    -----------
-    id : int
-        The review id
-    text : str
-        The contents of the review. Can be none if the member hasn't left any
-    rating : int
-        An int out of 10 representing the rating left with this review.
-    author : Thumbnail
-        A member like thumbnail of the member who left the review
-    date : datetime.datetime
-        Date and time of the review creation
-    agree : str
-            Link to agree with the review
-    disagree : str
-        Link to disagree with the review
-    """
-    def __init__(self, **attrs):
-        text = attrs.get("text")
-        if text:
-            self.text = text.text
-        else:
-            self.text = None
-
-        review = attrs.get("review")
-        self.rating = int(review.span.string)
-
-        try:
-            strings = ("Agree", "Delete", "Disagree")
-            self.id = int(re.findall(r"siteareaid=(\d*)", review.find("a", title=strings)["href"])[0])
-        except TypeError:
-            self.id = None
-
-        try:
-            self._hash = re.findall(r"hash=(.*)&", review.find("a", title="Delete")["href"])[0]
-        except TypeError:
-            self._hash = None
-
-        author = review.div.a
-        self.author = Thumbnail(url=author["href"], name=author.string.split(" ")[0], type=ThumbnailType.member)
-        self.date = get_date(review.div.span.time["datetime"])
-
-        try:
-            self.agree = join(review.find("a", title="Agree")["href"])
-            self.disagree = join(review.find("a", title="Disagree")["href"])
-        except TypeError:
-            self.agree = None
-            self.disagree = None
-
-    def __repr__(self):
-        return f"<Review author={self.author.name} rating={self.rating}>"
-
-@concat_docs
-class Poll(BaseMetaClass):
-    """Represents a poll. Cannot be voted for due to restrictions implemented by the website.
-
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The html to parse. Allows for finer control.
-
-    Filtering
-    ----------
-        month : Month
-            The month the poll you're looking for should be from
-        year : int
-            A int representing a year between 2002 and now. Anything below or above 2002 will
-            always return zero results.
-
-    Sorting
-    --------
-        * **totalvotes** - how many people voted on the poll, desc is most to least
-        * **name** - sort the poll alphabetically by name, asc is a-z
-        * **date** - when it was added to moddb, asc is oldest, desc is most recent
-
-    Attributes
-    -----------
-    question : str
-        The question of the poll
-    author : Thumbnail
-        A member like thumbnail of the member who posted the poll, usually ModDB staff
-    total : int
-        The total number of votes that have been cast
-    options : List[Option]
-        The list of available options for the poll
-    """
-    def __init__(self, html):
-        poll = html.find("div", class_="poll")
-        self.question = poll.parent.parent.parent.find("div", class_="normalcorner").find("span", class_="heading").string
-        self.name = self.question
-        super().__init__(html)
-        author = poll.find("p", class_="question").find("a")
-        self.author = Thumbnail(name=author.string, url=author["href"], type=ThumbnailType.member)
-
-        self.total = int(re.search(r"([\d,]*) votes", poll.find("p", class_="question").text)[1].replace(",", ""))
-
-        percentage = poll.find_all("div", class_="barouter")
-        rest = poll.find_all("p")[1:]
-
-        self.options = []
-        for index, _ in enumerate(percentage):
-            raw = percentage[index].div.string.replace('%', '').replace('\xa0', '')
-            percent = float(f"0.{raw}")
-            text = re.sub(r"\([\d,]* vote(s)?\)", '', rest[index].text)
-            votes = int(re.search(r"([\d,]*) vote(s)?", rest[index].span.string)[1].replace(',', ''))
-            self.options.append(Option(text=text, votes=votes, percent=percent))
-
-    def __repr__(self):
-        return f"<Poll question={self.question}>"
diff --git a/moddb/pages/__init__.py b/moddb/pages/__init__.py
new file mode 100644
index 0000000..2ada5f9
--- /dev/null
+++ b/moddb/pages/__init__.py
@@ -0,0 +1,32 @@
+from .fp import FrontPage
+from .mod import Mod
+from .game import Game
+from .engine import Engine
+from .article import Article, Blog
+from .entity import Team, Group, Member
+from .file import File, Addon, Media
+from .opinion import Poll, Review
+from .platform import Platform
+from .ware import Hardware, Software
+from .job import Job
+
+__all__ = [
+    "Mod",
+    "Game",
+    "Engine",
+    "File",
+    "Addon",
+    "Media",
+    "Article",
+    "Team",
+    "Group",
+    "Job",
+    "Blog",
+    "Member",
+    "FrontPage",
+    "Review",
+    "Platform",
+    "Poll",
+    "Software",
+    "Hardware",
+]
diff --git a/moddb/pages/article.py b/moddb/pages/article.py
new file mode 100644
index 0000000..04cdff7
--- /dev/null
+++ b/moddb/pages/article.py
@@ -0,0 +1,180 @@
+import bs4
+from .base import BaseMetaClass
+from ..utils import concat_docs, LOGGER, join, get_views, get_date
+from ..enums import ArticleCategory, ThumbnailType, TutorialCategory, Difficulty
+from ..boxes import Profile, Thumbnail
+
+
+@concat_docs
+class Article(BaseMetaClass):
+    """This object represents an news article, a tutorial or a feature.
+
+    Parameters
+    -----------
+    html : bs4.BeautifulSoup
+        The html to parse. Allows for finer control.
+
+    Filtering
+    ----------
+    category : :class:`.ArticleCategory`
+        Type of the article (news, feature)
+    timeframe : :class:`.TimeFrame`
+        The time period this was released in (last 24hr, last week, last month)
+    game : Union[:class:`.Game`, :class:`.Object`]
+        An game object or an object with an id attribute which represents the
+        game the article belongs to.
+
+    Sorting
+    --------
+        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
+        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
+        * **dateup** - order by article date, asc is most recent first, desc is oldest first
+        * **name** - order alphabetically, asc is a-z, desc is z-a
+        * **member** - order by member???
+        * **date** - order by upload date, asc is most recent first, desc is oldest first
+
+        Exclusive to tutorials
+        * **meta** - sort by difficulty, asc is most difficult, desc is least difficult
+        * **subtype** - sort by the area the tutorial covers
+
+    Attributes
+    -----------
+    category : ArticleCategory
+        Whether this article is a news article, a tutorial or a feature
+    name : str
+        The name of the article
+    profile : Profile
+        The profile object of the moddb model the article is for (engine, game, mod...). Can be none if it is not
+        rattached to anything, such as for site news.
+    tags : dict{str : str}
+        A dictionary of tags with the tag names as the key and the url to the tag
+        as the value.
+    views : int
+        Total amount of times this article was viewed
+    today : int
+        amount of time the article has been viewed today
+    intro : int
+        The intro/teaser paragraph of the article
+    author : Thumbnail
+        A member type thumbnail of the member who published the article
+    date : datetime.datetime
+        The date the article was published
+    html : str
+        The html of the article
+    plaintext : str
+        The article text without any html
+    summary : str
+        plaintext intro to the article
+    tutorial_category : TutorialCategory
+        If the article category is tutorial, this represents the area the tutorial covers, else it is None
+    difficulty : Difficulty
+        If the article category is tutorial, this represents how hard the tutorial is.
+    """
+
+    def __init__(self, html: bs4.BeautifulSoup):
+        try:
+            self.name = html.find("span", itemprop="headline").string
+        except AttributeError:
+            self.name = html.find("span", itemprop="heading").string
+
+        super().__init__(html)
+
+        raw_type = html.find("h5", string="Browse").parent.span.a.string
+        self.category = ArticleCategory[raw_type.lower()]
+
+        try:
+            raw = html.find("span", string=raw_type[0:-1]).parent.parent.parent.find(
+                "div", class_="table tablemenu"
+            )
+        except AttributeError:
+            raw = html.find("span", string=raw_type).parent.parent.parent.find(
+                "div", class_="table tablemenu"
+            )
+
+        try:
+            self.profile = Profile(html)
+        except AttributeError:
+            LOGGER.info("'%s' has no profile", self.name)
+            self.profile = None
+
+        try:
+            raw_tags = html.find("form", attrs={"name": "tagsform"}).find_all("a")
+            self.tags = {
+                x.string: join(x["href"]) for x in raw_tags if x.string is not None
+            }
+        except AttributeError:
+            self.tags = {}
+            LOGGER.info("'%s' '%s' has no tags", self.__class__.__name__, self.name)
+
+        views_raw = raw.find("h5", string="Views").parent.span.a.string
+        self.views, self.today = get_views(views_raw)
+
+        self.intro = html.find("p", itemprop="description").string
+        author = html.find("span", itemprop="author").span.a
+        self.author = Thumbnail(
+            name=author.string, url=author["href"], type=ThumbnailType.member
+        )
+
+        self.date = get_date(html.find("time", itemprop="datePublished")["datetime"])
+        self.html = str(html.find("div", itemprop="articleBody"))
+        self.plaintext = html.find("div", itemprop="articleBody").text
+
+        self.summary = html.find("p", class_="introductiontext").string
+
+        if self.category == ArticleCategory.tutorials:
+            cat = html.find("span", itemprop="proficiencyLevel").nextSibling.strip()
+            self.tutorial_category = TutorialCategory[
+                cat.replace("/", "_").replace(" ", "_").lower()
+            ]
+            self.difficulty = Difficulty[
+                html.find("span", itemprop="proficiencyLevel").string.lower()
+            ]
+
+    def __repr__(self):
+        return f"<Article title={self.name} type={self.category.name}>"
+
+
+@concat_docs
+class Blog(BaseMetaClass):
+    """Object used to represent a member blog.
+
+    Filtering
+    ----------
+    timeframe : :class:`.TimeFrame`
+        The time period this was released in (last 24hr, last week, last month)
+
+    Sorting
+    --------
+        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
+        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
+        * **dateup** - order by blog date, asc is most recent first, desc is oldest first
+        * **name** - order alphabetically, asc is a-z, desc is z-a
+        * **member** - order by member???
+        * **date** - order by upload date, asc is most recent first, desc is oldest first
+
+    Attributes
+    -----------
+    name_id : str
+        The name_id of the member, cannot be changed, it's a mix of the original username lowercased with
+        spaces removed and shortened.
+
+    """
+
+    def __init__(self, *, heading, text):
+        author = heading.find("span", class_="subheading").a
+        self.author = Thumbnail(
+            url=author["href"], name=author.string, type=ThumbnailType.member
+        )
+
+        self.date = get_date(heading.find("span", class_="date").time["datetime"])
+
+        title = heading.div.h4.a
+        self.name = title.string
+        self.url = join(title["href"])
+        self.name_id = self.url.split("/")[0]
+
+        self.html = str(text.content)
+        self.plaintext = text.text
+
+    def __repr__(self):
+        return f"<Blog title={self.name}>"
diff --git a/moddb/pages/base.py b/moddb/pages/base.py
new file mode 100644
index 0000000..5e77281
--- /dev/null
+++ b/moddb/pages/base.py
@@ -0,0 +1,713 @@
+import re
+import bs4
+from typing import List
+
+from ..utils import (
+    join,
+    LOGGER,
+    get_page,
+    get_page_number,
+    get_date,
+    get_type,
+    get_type_from,
+)
+from ..boxes import (
+    CommentList,
+    Comment,
+    Thumbnail,
+    MissingComment,
+    ResultList,
+    Profile,
+    Statistics,
+    PartialArticle,
+    Style,
+)
+from ..enums import ThumbnailType, SearchCategory
+from .mixins import SharedMethodsMixin, RSSFeedMixin, GetWatchersMixin
+
+
+class BaseMetaClass:
+    """An abstract class that implements the attributes present on nearly every page. In addition, it implements
+    some shared hidden methods and the top level get_comments method.
+
+    Parameters
+    -----------
+    html : bs4.BeautifulSoup
+        The html containing the comments
+
+    Attributes
+    -----------
+    id : int
+        The id of the page
+    name_id : str
+        The name_id of the member, cannot be changed, it's a mix of the original username lowercased with
+        spaces removed and shortened.
+    url : str
+        The url of the page
+    comments : CommentList[Comment]
+        The comments scrapped on this list in order.
+    report : str
+        URL to report the page
+    """
+
+    def __init__(self, html):
+        if not getattr(self, "name", None):
+            try:
+                self.name = html.find("a", itemprop="mainEntityOfPage").string
+            except AttributeError:
+                self.name = html.find("meta", property="og:title")["content"]
+
+        try:
+            self.id = int(
+                re.search(
+                    r"siteareaid=(\d*)", html.find("a", class_=["reporticon"])["href"]
+                )[1]
+            )
+        except TypeError:
+            try:
+                self.id = int(html.find("input", attrs={"name": "siteareaid"})["value"])
+            except (AttributeError, TypeError):
+                # really scratching the bottom here but a lot of "official" groups don't have the regular ID
+                self.id = int(
+                    html.find("meta", property="og:image")["content"].split("/")[-2]
+                )
+
+        try:
+            self.url = html.find("meta", property="og:url")["content"]
+        except TypeError:
+            self.url = join(html.find("a", string=self.name)["href"])
+
+        self.name_id = self.url.split("/")[-1]
+
+        try:
+            self.report = join(html.find("a", string="Report")["href"])
+        except TypeError:
+            self.report = None
+            LOGGER.info(
+                "'%s' '%s' cannot be reported", self.__class__.__name__, self.name
+            )
+
+        self.comments = self._get_comments(html)
+
+        self._html = html
+
+    def __repr__(self):
+        return f"<{self.__class__.__name__} name={self.name}>"
+
+    def _get_comments(self, html: bs4.BeautifulSoup) -> CommentList:
+        """Extracts the comments from an html page and adds them to a CommentList. In addition
+        this method also adds them to the comments children as need be.
+
+        Parameters
+        -----------
+        html : bs4.BeautifulSoup
+            The html containing the comments
+        """
+        page, max_page = get_page_number(html)
+
+        comments = []
+        comments_raw = html.find_all("div", class_="row", id=True)
+        for raw in comments_raw:
+            comment = Comment(raw)
+            comment._url = f"{self.url}/page/{page}"
+            if comment.position == 1:
+                try:
+                    comments[-1].children.append(comment)
+                except IndexError:
+                    comments.append(MissingComment(0))
+                    comments[-1].children.append(comment)
+            elif comment.position == 2:
+                try:
+                    comments[-1].children[-1].children.append(comment)
+                except IndexError:
+                    comments[-1].children.append(MissingComment(1))
+                    comments[-1].children[-1].children.append(comment)
+            else:
+                comments.append(comment)
+
+        return CommentList(
+            results=comments,
+            page=page,
+            max_page=max_page,
+            action=self._get_comments_from_url,
+            url=f"{self.url}/page/{page}",
+        )
+
+    def _get(self, url: str, *, params: dict = {}) -> ResultList:
+        """This function takes a list of thumbnails of `object_type` in html and returns
+        a list of Thumbnail of that object type.
+
+        Parameters
+        -----------
+        url : str
+            The url with the list
+        object_type : ThumbnailType
+            The type of objects to be expected. Easier to pass and hardcode then to guess
+        params : dict
+            A dictionnary of filters to pass on to the soup function used to filter the results.
+
+        Returns
+        -------
+        List[Thumbnail]
+            The list of objects present on the page as a list of thumbnails.
+        """
+        html = get_page(url, params=params)
+        try:
+            table = html.find("form", attrs={"name": "filterform"}).parent.find(
+                "div", class_="table"
+            )
+        except AttributeError:
+            table = None
+
+        if table is None:
+            return []
+
+        objects_raw = table.find_all("div", recursive=False)[1:]
+
+        try:
+            # parse as normal list of articles
+            objects = []
+            for obj in objects_raw:
+                date = obj.find("time")
+                summary = obj.find("p")
+                thumbnail = Thumbnail(
+                    name=obj.a["title"],
+                    url=obj.a["href"],
+                    image=obj.a.img["src"],
+                    type=get_type_from(join(obj.a["href"])),
+                    summary=summary.string if summary else None,
+                    date=get_date(date["datetime"]) if date else None,
+                )
+                objects.append(thumbnail)
+        except TypeError:
+            # parse as a title-content pair of articles
+            LOGGER.info("Parsing articles as key-pair list")
+            objects = []
+            objects_raw = objects_raw[1:]
+            for title, content in zip(objects_raw[::2], objects_raw[1::2]):
+                date = title.find("time")
+                url = title.find("h4").a
+                thumbnail = Thumbnail(
+                    name=url.text,
+                    url=url["href"],
+                    image=None,
+                    type=get_type_from(join(url["href"])),
+                    summary=content.text,
+                    date=get_date(date["datetime"]) if date else None,
+                )
+                objects.append(thumbnail)
+
+        page, max_page = get_page_number(html)
+
+        return ResultList(
+            results=objects,
+            params=params,
+            url=url,
+            action=self._get,
+            page=page,
+            max_page=max_page,
+        )
+
+    def _get_media(self, index: int, *, html) -> List[Thumbnail]:
+        """Hidden method used to parse media content from the page. Since the only difference is that pages
+        with videos have one extra script that tells the window not do any sort of video playing assistance.
+        Also used to cached all media on a page.
+
+        Parameters
+        ----------
+        index : int
+            The index of the script containing the cached objects. 1 if it's only images, 2 if videos are
+            also included
+
+        Returns
+        --------
+        List[Thumbnail]
+            List of media like thumbnails that can be parsed individually. Can be a very long list.
+        """
+        script = html.find_all("script", text=True)[index]
+        regex = r'new Array\(0, "(\S*)", "(\S*)"'
+        matches = re.findall(regex, script.text)
+
+        name_finder = r"\/([a-z0-9-]*)#imagebox"
+        return [
+            Thumbnail(
+                name=re.search(name_finder, match[0])[1],
+                url=match[0],
+                type=ThumbnailType.media,
+                image=match[1],
+            )
+            for match in matches
+        ]
+
+    def _get_comments_from_url(self, url, *, show_deleted=False):
+        """Extra method so we can get comments from a ResultList"""
+        params = {"deleted": "t" if show_deleted else None}
+        return self._get_comments(get_page(url, params=params))
+
+    def get_comments(self, index: int = 1, *, show_deleted=False) -> CommentList:
+        """Used to get comments on the model regardless of what page they may be present in. The function
+        itself simply relies on two other to make the request and parse the table.
+
+        Parameters
+        ----------
+        index : int
+            The page of the model to get the comments for.
+        show_deleted : Optional[bool]
+            Pass true to show deleted user comments. Only works if it is a page
+            you have permissions on.
+
+        Returns
+        --------
+        CommentList[Comment]
+            A list-like object containing the comments and additional methods
+        """
+        params = {"deleted": "t" if show_deleted else None}
+
+        return self._get_comments(get_page(f"{self.url}/page/{index}", params=params))
+
+
+class PageMetaClass(BaseMetaClass, SharedMethodsMixin, RSSFeedMixin, GetWatchersMixin):
+    """The common class representing the page for either a Mod, Game, Engine or a Member. Mostly used to be inherited by
+    those classes.
+
+    Parameters
+    -----------
+    html : bs4.BeautifulSoup
+        The page to be parsed.
+
+    page_type : ThumbnailType
+        The type of pages, this is passed down be the base class to help with the parsing of
+
+    Attributes
+    -----------
+    name : str
+        The name of the page
+    profile : Profile
+        The object with the content of the page's profile box.
+    stats : Statistics
+        The object containg stat data on the page such as views, followers, ect...
+    style : Style
+        The object containing data relevant to the type of the page, not valid for Engines. Multiplayer, singleplayer,
+        ect...
+    suggestions : List[Thumbnail]
+        A list of thumbnail object representing moddb's suggestion of similar pages for the visiting member.
+    files : List[Thumbnail]
+        A list of thumbnails representing a possible partial list of all the files. To get a guaranteed full
+        list either compare with statistics.files to see if the length of the list matches the number of files in the
+        stats or use get_files, although that will still not return the whole list if there are multiple pages of
+        files.
+    articles : List[Thumbnail]
+        A list of thumbnail objects representing articles present on the page. Usually 3 or 4 articles long.
+    article :  PartialArticle
+        A partial representation of the frong page article. This does not include things like comments or any
+        of the sideba elements found in a full article. Can be parsed to return the complete Article object.
+    tags : dict{str : str}
+        A dict of key-values pair where the key is the name of the tag and the value is the url.
+    imagebox : List[Thumbnail]
+        A list of Thumbnail objects representing the image, videos and audio clips that are presented in the
+        image box on the front page.
+    embed : str
+        The html necessary to embed the a widget of the page.
+    rating : float
+        A float out of ten representing the average rating for the page
+    medias : List[Thumbnail]
+        list of thumbnails representing all the combined media objects of a page (videos and images)
+    summary : str
+        Short description of the page, in plaintext.
+    description : str
+        The full description of the page, contains html
+    plaintext : str
+        Plaintext version of the full description
+    """
+
+    def __init__(self, html: bs4.BeautifulSoup, page_type: SearchCategory):
+        super().__init__(html)
+        self._type = page_type
+
+        # boxes
+        if page_type != SearchCategory.members:
+            self.profile = Profile(html)
+
+            try:
+                self.stats = Statistics(html)
+            except AttributeError:
+                LOGGER.info("Entity '%s' has no stats (idk why, ask moddb)", self.name)
+                self.stats = None
+
+            if page_type != SearchCategory.engines:
+                self.style = Style(html)
+
+            # thumbnails
+            self.suggestions = self._get_suggestions(html)
+
+            # misc
+            try:
+                self.embed = html.find("input", type="text", class_="text textembed")[
+                    "value"
+                ]
+            except TypeError:
+                self.embed = str(html.find_all("textarea")[1].a)
+
+        try:
+            self.files = self._get_files(html)
+        except AttributeError:
+            LOGGER.info("'%s' '%s' has no files", self.__class__.__name__, self.name)
+            self.files = []
+
+        articles_raw = None
+        try:
+            raw = html.find("span", string="Articles") or html.find(
+                "span", string="Related Articles"
+            )
+            articles_raw = raw.parent.parent.parent.find("div", class_="table")
+            thumbnails = articles_raw.find_all("div", class_="row rowcontent clear")
+            self.articles = [
+                Thumbnail(
+                    name=x.a["title"],
+                    url=x.a["href"],
+                    image=x.a.img["src"] if x.a.img else None,
+                    summary=x.find("p").string,
+                    date=x.find("time")["datetime"],
+                    type=ThumbnailType.article,
+                )
+                for x in thumbnails
+            ]
+        except AttributeError:
+            LOGGER.info(
+                "'%s' '%s' has no article suggestions",
+                self.__class__.__name__,
+                self.name,
+            )
+            self.articles = []
+
+        # main page article
+        if articles_raw:
+            self.article = PartialArticle(articles_raw)
+        else:
+            self.article = None
+            LOGGER.info(
+                "'%s' '%s' has no front page article",
+                self.__class__.__name__,
+                self.name,
+            )
+
+        try:
+            raw_tags = html.find("form", attrs={"name": "tagsform"}).find_all("a")
+            self.tags = {
+                x.string: join(x["href"]) for x in raw_tags if x.string is not None
+            }
+        except AttributeError:
+            self.tags = {}
+            LOGGER.info("'%s' '%s' has no tags", self.__class__.__name__, self.name)
+
+        # imagebox
+        try:
+            imagebox = html.find("ul", id="imagebox").find_all("li")[1:-2]
+            self.imagebox = [
+                Thumbnail(
+                    name=x.a["title"],
+                    url=x.a["href"],
+                    image=x.a.img["src"],
+                    type=ThumbnailType(get_type(x.a.img)),
+                )
+                for x in imagebox
+                if x.a
+            ]
+        except (AttributeError, TypeError):
+            LOGGER.info("'%s' '%s' has no images", self.__class__.__name__, self.name)
+            self.imagebox = []
+
+        try:
+            self.rating = float(
+                html.find("div", class_="score").find("meta", itemprop="ratingValue")[
+                    "content"
+                ]
+            )
+        except AttributeError:
+            self.rating = 0.0
+            LOGGER.info("'%s' '%s' is not rated", self.__class__.__name__, self.name)
+
+        try:
+            self._review_hash = html.find("form", class_="ratingform").find(
+                "input", {"name": "hash"}
+            )["value"]
+        except AttributeError:
+            self._review_hash = None
+
+        self.medias = self._get_media(2, html=html)
+
+        try:
+            self.summary = html.find("meta", itemprop="description")["content"]
+        except TypeError:
+            self.summary = None
+            LOGGER.info("'%s' '%s' has no summary", self.__class__.__name__, self.name)
+
+        self.description = str(html.find("div", id="profiledescription"))
+
+        try:
+            self.description = str(html.find("div", id="profiledescription"))
+            self.plaintext = html.find("div", id="profiledescription").text
+        except AttributeError:
+            LOGGER.info(
+                "'%s' '%s' has no extended description",
+                self.__class__.__name__,
+                self.name,
+            )
+            self.description = None
+            self.plaintext = None
+
+    def _get_suggestions(self, html: bs4.BeautifulSoup) -> List[Thumbnail]:
+        """Hidden method used to get the list of suggestions on the page. As with most things, this list of suggestions
+        will be a list of Thumbnail objects that can be parsed individually.
+
+        Parameters
+        -----------
+        html : bs4.BeautifulSoup
+            The html page we are trying to parse the suggestions for
+
+        Returns
+        --------
+        List[Thumbnail]
+            The list of suggestions as thumbnails.
+        """
+        suggestions_raw = (
+            html.find("span", string="You may also like")
+            .parent.parent.parent.find("div", class_="table")
+            .find_all("div", recursive=False)
+        )
+        suggestions = []
+        for x in suggestions_raw:
+            try:
+                link = x.find("a", class_="image")
+                suggestion_type = get_type_from(link["href"])
+                suggestion = Thumbnail(
+                    name=link["title"],
+                    url=link["href"],
+                    image=link.img["src"],
+                    type=suggestion_type,
+                )
+                suggestions.append(suggestion)
+            except (AttributeError, TypeError):
+                pass
+
+        return suggestions
+
+    def _get_games(self, html: bs4.BeautifulSoup) -> List[Thumbnail]:
+        """Used both for Teams and Engines, returns a list of games  present on the page
+        as Thumbnail objects.
+
+        Parameters
+        ----------
+        html : bs4.BeautifulSoup
+            The html to extract the list of games from
+
+        Returns
+        -------
+        List[Thumbnail]
+            The list of games on the page as Thumbnail objects.
+        """
+        games_raw = html.find(string="Games").parent.parent.parent.parent.find_all(
+            class_="row rowcontent clear"
+        )
+        games = []
+        for x in games_raw:
+            link = x.find("div", class_="content").h4.a
+            image_url = link.parent.parent.parent.find("img")["src"]
+            game = Thumbnail(
+                name=link.string,
+                url=link["href"],
+                image=image_url,
+                type=ThumbnailType.game,
+            )
+            games.append(game)
+
+        return games
+
+    def _get_files(self, html: bs4.BeautifulSoup) -> List[Thumbnail]:
+        """Cache the files present on the page. Up to 5 files might be present
+
+        Parameters
+        -----------
+        html : bs4.BeautifulSoup
+            The page to cache the files from
+
+        Returns
+        -------
+        List[Thumbnail]
+            The list of file like thumbnails
+        """
+
+        files_raw = html.find(string="Files").parent.parent.parent.parent.find_all(
+            class_="row rowcontent clear"
+        )
+        files = []
+        for x in files_raw:
+            link = x.find("div", class_="content").h4.a
+            try:
+                image_url = link.parent.parent.parent.find("img")["src"]
+            except TypeError:
+                image_url = None
+
+            file = Thumbnail(
+                name=link.string,
+                url=link["href"],
+                image=image_url,
+                type=ThumbnailType.file,
+            )
+            files.append(file)
+
+        return files
+
+    def _get_engines(self, html):
+        """Hidden method to get the engines showed currently on the page as a list of engine like thumbnails. Takes
+        an entire page of html and sorts it out
+
+        Parameters
+        -----------
+        html : bs4.BeautifulSoup
+            The page to cache the engines from
+
+        Returns
+        -------
+        List[Thumbnail]
+            The list of engine like thumbnail objects
+        """
+        engines_raw = html.find(string="Engines").parent.parent.parent.parent.find_all(
+            class_="row rowcontent clear"
+        )
+        engines = []
+        for x in engines_raw:
+            link = x.find("div", class_="content").h4.a
+            image_url = link.parent.parent.parent.find("img")["src"]
+            engine = Thumbnail(
+                name=link.string,
+                url=link["href"],
+                image=image_url,
+                type=ThumbnailType.engine,
+            )
+            engines.append(engine)
+
+        return engines
+
+    def __repr__(self):
+        return f"<{self.__class__.__name__} name={self.name}>"
+
+
+class HardwareSoftwareMetaClass(
+    BaseMetaClass, SharedMethodsMixin, RSSFeedMixin, GetWatchersMixin
+):
+    """Shared class for Hardware and Software
+
+    Attributes
+    -----------
+    description : str
+        Description of the page
+    profile : Profile
+        The page's profile
+    stats : Statistics
+        The page's stats
+    rating : float
+        The rating of the item
+    articles : List[Thumbnail]
+        List of article type thumbnails from the recommended articles
+    article : PartialArticle
+        The partial article presented on the front page
+    tags : Dict{str : str}
+        Dict of tags with the name as the key and the url as the value
+    medias : List[Thumbnail]
+        list of thumbnails representing all the combined media objects of a page (videos and images)
+    suggestions : List[Thumbnail]
+        list of suggested software/hardware type thumbnails
+    """
+
+    def __init__(self, html):
+        super().__init__(html)
+        try:
+            self.description = html.find("div", id="profiledescription").p.string
+        except AttributeError:
+            self.description = html.find("p", itemprop="description").string
+
+        self.profile = Profile(html)
+
+        try:
+            self.stats = Statistics(html)
+        except AttributeError:
+            LOGGER.info("Entity '%s' has no stats (idk why, ask moddb)", self.name)
+            self.stats = None
+
+        try:
+            self.rating = float(
+                html.find("div", class_="score").find("meta", itemprop="ratingValue")[
+                    "content"
+                ]
+            )
+        except AttributeError:
+            self.rating = 0.0
+            LOGGER.info("'%s' '%s' is not rated", self.profile.category.name, self.name)
+
+        try:
+            self._review_hash = html.find("form", class_="ratingform").find(
+                "input", {"name": "hash"}
+            )["value"]
+        except AttributeError:
+            self._review_hash = None
+
+        articles_raw = None
+        try:
+            articles_raw = html.find(
+                "span", string="Related Articles"
+            ).parent.parent.parent.find("div", class_="table")
+            thumbnails = articles_raw.find_all("div", class_="row rowcontent clear")
+            self.articles = [
+                Thumbnail(
+                    name=x.a["title"],
+                    url=x.a["href"],
+                    image=x.a.img["src"] if x.a.img else None,
+                    summary=x.find("p").string,
+                    date=x.find("time")["datetime"],
+                    type=ThumbnailType.article,
+                )
+                for x in thumbnails
+            ]
+        except AttributeError:
+            LOGGER.info(
+                "'%s' '%s' has no article suggestions",
+                self.profile.category.name,
+                self.name,
+            )
+            self.articles = []
+
+        if articles_raw:
+            self.article = PartialArticle(articles_raw)
+        else:
+            self.article = None
+            LOGGER.info(
+                "'%s' '%s' has no front page article",
+                self.profile.category.name,
+                self.name,
+            )
+
+        try:
+            raw_tags = html.find("form", attrs={"name": "tagsform"}).find_all("a")
+            self.tags = {
+                x.string: join(x["href"]) for x in raw_tags if x.string is not None
+            }
+        except AttributeError:
+            self.tags = {}
+            LOGGER.info("Hardware '%s' has no tags", self.name)
+
+        self.medias = self._get_media(1, html=html)
+
+        try:
+            t = ThumbnailType[self.__class__.__name__.lower()]
+            suggestions = html.find(
+                "span", string="You may also like"
+            ).parent.parent.parent.find_all("a", class_="image")
+            self.suggestions = [
+                Thumbnail(url=x["href"], name=x["title"], type=t, image=x.img["src"])
+                for x in suggestions
+            ]
+        except AttributeError:
+            LOGGER.info(
+                "'%s' '%s' has no suggestions", self.__class__.__name__, self.name
+            )
diff --git a/moddb/pages/engine.py b/moddb/pages/engine.py
new file mode 100644
index 0000000..ad0e1d8
--- /dev/null
+++ b/moddb/pages/engine.py
@@ -0,0 +1,51 @@
+import bs4
+
+from ..utils import concat_docs, LOGGER
+from .base import PageMetaClass
+from .mixins import GetGamesMixin
+from ..enums import SearchCategory
+
+
+@concat_docs
+class Engine(PageMetaClass, GetGamesMixin):
+    """A subclass of Page, however, it does not have the files attribute
+
+    Parameters
+    -----------
+    html : bs4.BeautifulSoup
+        The html to parse. Allows for finer control.
+
+    Filtering
+    -----------
+    released : :class:`.Status`
+        The status of the engine (released, unreleased)
+    licence : :class:`.Licence`
+        The license of the engine
+    timeframe : :class:`.TimeFrame`
+        The time period this was released in (last 24hr, last week, last month)
+
+    Sorting
+    --------
+        * **released** - when the object was released, asc is oldest, desc is most recent
+        * **id** - when it was added to moddb, asc is oldest, desc is most recent
+        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
+        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
+        * **rating** - order by rating, asc is highest rating, desc is lowest rating
+        * **name** - order alphabetically, asc is a-z, desc is z-a
+        * **dateup** - order by latest update, asc is most recent update first, desc is oldest update first
+
+    Attributes
+    -----------
+    games : List[Thumbnail]
+        A list of games suggested on the engine main page.
+    """
+
+    def __init__(self, html: bs4.BeautifulSoup):
+        super().__init__(html, SearchCategory.engines)
+        delattr(self, "files")
+
+        try:
+            self.games = self._get_games(html)
+        except AttributeError:
+            LOGGER.info("Engine '%s' has no games", self.name)
+            self.games = []
diff --git a/moddb/pages/entity.py b/moddb/pages/entity.py
new file mode 100644
index 0000000..fc971cd
--- /dev/null
+++ b/moddb/pages/entity.py
@@ -0,0 +1,541 @@
+import bs4
+from typing import List, Tuple
+
+from ..utils import concat_docs, LOGGER, join, get_page, get_page_number
+from .base import PageMetaClass, BaseMetaClass
+from .mixins import (
+    GetAddonsMixin,
+    GetEnginesMixin,
+    GetGamesMixin,
+    GetModsMixin,
+    GetWaresMixin,
+)
+from ..boxes import (
+    Profile,
+    Statistics,
+    Thumbnail,
+    MemberProfile,
+    MemberStatistics,
+    ResultList,
+    CommentList,
+)
+from ..enums import ThumbnailType, SearchCategory, TimeFrame, Membership, GroupCategory
+from .article import Blog
+
+
+@concat_docs
+class Group(PageMetaClass, GetAddonsMixin):
+    """This object represents the group model of  Certain attributes can be None if the group
+    has been set to private. If you wish to see a group you have access to then you can login with the
+    login
+
+    Parameters
+    -----------
+    html : bs4.BeautifulSoup
+        The html to parse. Allows for finer control.
+
+    Filtering
+    ----------
+    subscriptions : :class:`.Membership`
+        The subscription system of the group (private, invitation)
+    category : :class:`.GroupCategory`
+        The category of the group (funny, literature)
+
+    Sorting
+    --------
+        * **id** - order group by date, asc is most recent first, desc is oldest first
+        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
+        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
+        * **name** - order alphabetically, asc is a-z, desc is z-a
+        * **membercount** - order by number of members, asc is most members first, desc is lest member first
+
+    Attributes
+    -----------
+    name : str
+        The name of the group
+    private : bool
+        Whether or not the group is private
+    profile : Profile
+        The profile object for the group
+    stats : Statistics
+        The stats of the Group
+    tags : dict{str, str}
+        A dictionnary of tags where the key is the tag name and the value is the tag url
+    embed : str
+        The html for athe group embed
+    medias : List[Thumbnail]
+        A list of media like thumbnail objects representing all the images, videos and audio
+        clips that a group has published.
+    suggestions : List[Thumbnail]
+        A list of group like thumbnail objects representing the suggestions made by moddb to
+        members visiting this group
+    articles : List[Thumbnail]
+        A list of article like thumbnail objects representing some of the articles published
+        by the group
+    description : str
+        The plaintext description of the group
+    """
+
+    get_reviews = None
+
+    def __init__(self, html: bs4.BeautifulSoup):
+        self.name = html.find("div", class_="title").h2.a.string
+        BaseMetaClass.__init__(self, html)
+        self.private = False
+
+        try:
+            self.profile = Profile(html)
+        except AttributeError:
+            LOGGER.info("Entity '%s' has no profile (private)", self.name)
+            self.profile = None
+            self.private = True
+
+        try:
+            self.stats = Statistics(html)
+        except AttributeError:
+            LOGGER.info("Entity '%s' has no stats (private)", self.name)
+            self.stats = None
+
+        try:
+            raw_tags = html.find("form", attrs={"name": "tagsform"}).find_all("a")
+            self.tags = {
+                x.string: join(x["href"]) for x in raw_tags if x.string is not None
+            }
+        except AttributeError:
+            LOGGER.info("Entity '%s' has no tags (private)", self.name)
+            self.tags = {}
+
+        try:
+            self.embed = html.find("input", type="text", class_="text textembed")[
+                "value"
+            ]
+        except TypeError:
+            try:
+                self.embed = str(html.find_all("textarea")[1].a)
+            except IndexError:
+                LOGGER.info("Group '%s' has no embed", self.name)
+                self.embed = None
+
+        self.suggestions = self._get_suggestions(html)
+
+        try:
+            articles_raw = html.find(
+                "span", string="Articles"
+            ).parent.parent.parent.find("div", class_="table")
+            thumbnails = articles_raw.find_all(
+                "div", class_="row rowcontent clear", recursive=False
+            )
+            self.articles = [
+                Thumbnail(
+                    name=x.a["title"],
+                    url=x.a["href"],
+                    image=x.a.img["src"],
+                    summary=x.find("p").string,
+                    date=x.find("time")["datetime"],
+                    type=ThumbnailType.article,
+                )
+                for x in thumbnails
+            ]
+        except AttributeError:
+            LOGGER.info("Group '%s' has no article suggestions", self.name)
+            self.articles = []
+
+        try:
+            self.description = html.find("div", id="profiledescription").text
+        except AttributeError:
+            self.description = (
+                html.find("div", class_=["column", "span-all"])
+                .find("div", class_="tooltip")
+                .parent.text
+            )
+
+        self.medias = self._get_media(2, html=html)
+
+    def _get_suggestions(self, html: bs4.BeautifulSoup) -> List[Thumbnail]:
+        """Hidden method used to get the list of suggestions on the page. As with most things, this list of suggestions
+        will be a list of Thumbnail objects that can be parsed individually. Slightly modified to fit a group
+
+        Parameters
+        -----------
+        html : bs4.BeautifulSoup
+            The html page we are trying to parse the suggestions for
+
+        Returns
+        --------
+        List[Thumbnail]
+            The list of suggestions as thumbnails.
+        """
+        try:
+            suggestions_raw = (
+                html.find("span", string=("You may also like", "Popular Articles"))
+                .parent.parent.parent.find("div", class_="table")
+                .find_all("div", recursive=False)
+            )
+        except AttributeError:
+            LOGGER.info("Group '%s' has no sidebar suggestions", self.name)
+            return []
+
+        suggestions = []
+        for x in suggestions_raw:
+            try:
+                link = x.find("a", class_="image")
+                suggestion_type = link["href"].split("/")[1].replace("s", "")
+                suggestion = Thumbnail(
+                    name=link["title"],
+                    url=link["href"],
+                    image=link.img["src"],
+                    type=ThumbnailType[suggestion_type],
+                )
+                suggestions.append(suggestion)
+            except (AttributeError, TypeError):
+                pass
+
+        return suggestions
+
+    def __repr__(self):
+        return f"<Group name={self.name}>"
+
+
+@concat_docs
+class Team(Group, GetEnginesMixin, GetGamesMixin, GetModsMixin, GetWaresMixin):
+    """A team is a group of people, which are the author of a game, a mod or an engine. A group has members which all
+    have rights on those page. Like a member but instead of a single person authoring various mods and games it's several.
+
+    Parameters
+    ------------
+    html : bs4.BeautifulSoup
+        The html to parse. Allows for finer control.
+
+    Filtering
+    ----------
+    subscriptions : :class:`.Membership`
+        The subscription system of the company (private, invitation)
+    category : :class:`.TeamCategory`
+        What does the team do (publisher, developer)
+
+    Sorting
+    --------
+        * **id** - order by creation, desc is most recent first, asc is oldest first
+        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
+        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
+        * **name** - order alphabetically, asc is a-z, desc is z-a
+        * **game** - order by game???
+        * **dateup** - order by latest update, asc is most recent update first, desc is oldest update first
+
+    Attributes
+    -----------
+    games : List[Thumbnail]
+        A list of game like thumbnails that the team has authored.
+    engines : List[Thumbnail]
+        A list of engine like objects that the team has authored.
+
+    """
+
+    def __init__(self, html: bs4.BeautifulSoup):
+        super().__init__(html)
+        try:
+            self.games = self._get_games(html)
+        except AttributeError:
+            LOGGER.info("Team '%s' has no games", self.name)
+            self.games = []
+
+        try:
+            self.engines = self._get_engines(html)
+        except AttributeError:
+            LOGGER.info("Team '%s' has no engines", self.name)
+            self.engines = []
+        try:
+            mods = (
+                html.find("span", string="Popular Mods")
+                .parent.parent.parent.find("div", class_="table")
+                .find_all("div", recursive=False)[1:]
+            )
+            self.mods = [
+                Thumbnail(
+                    name=x.a["title"],
+                    url=x.a["href"],
+                    type=ThumbnailType.mod,
+                    image=x.a.img["src"],
+                )
+                for x in mods
+            ]
+        except AttributeError:
+            LOGGER.info("Team '%s' has no mods", self.name)
+            self.mods = []
+
+
+@concat_docs
+class Member(PageMetaClass, GetGamesMixin, GetModsMixin, GetAddonsMixin):
+    """The object to represent an individual member on ModDB
+
+    Parameters
+    -----------
+    html : bs4.BeautifulSoup
+        The html to parse. Allows for finer control.
+
+    Sorting
+    --------
+    * **username** - sort alphabetically by username, asc is z-a and desc is a-z
+    * **id** - sort by member creation date, asc is most recent, desc is oldest
+    * **online** - sort by last online, asc is most recently seen online and desc is least recently
+
+    Attributes
+    -----------
+    profile : MemberProfile
+        Since member profile boxes have no overlap with other profiles, they are a separate object type
+        but serve the same function of making the side box "Profile" into an object, except exclusively
+        for a member page.
+    stats : MemberStatistics
+        Since member statistics have no overlap with regular statistic pages, they are a separate object type
+        but serve the same function of making the side box "Statistics" into an object, but exclusively for
+        the member page.
+    description : str
+        Description written on the member profile
+    groups : List[Thumbnail]
+        A list of group/team like thumbnail objects representing both the Teams the member is part of and the
+        Groups the member is part of.
+    blog : Blog
+        The front page blog shown on the member page
+    blogs : List[Thumbnail]
+        A list of blog like thumbnails representing the blog suggestion of a member's frontpage
+    friends : List[Thumnails]
+        A list of member like thumbnails representing some of the friends shown on the member's front
+        page
+    """
+
+    def __init__(self, html: bs4.BeautifulSoup):
+        super().__init__(html, SearchCategory.members)
+        try:
+            self.profile = MemberProfile(html)
+        except AttributeError:
+            LOGGER.info("Member '%s' has no profile (private)", self.name)
+            self.profile = None
+
+        try:
+            self.stats = MemberStatistics(html)
+        except AttributeError:
+            LOGGER.info("Member '%s' has no stats (private)", self.name)
+            self.stats = None
+
+        try:
+            self.description = html.find("div", id="profiledescription").p.string
+        except AttributeError:
+            LOGGER.info("Member '%s' has no description", self.name)
+            self.description = None
+
+        try:
+            groups_raw = (
+                html.find("span", string="Groups")
+                .parent.parent.parent.find("div", class_="table")
+                .find_all("div", recursive=False)[:-2]
+            )
+            self.groups = [
+                Thumbnail(
+                    name=div.a["title"], url=div.a["href"], type=ThumbnailType.group
+                )
+                for div in groups_raw
+            ]
+        except AttributeError:
+            LOGGER.info("Member '%s' doesn't have any groups", self.name)
+            self.groups = []
+
+        try:
+            blogs_raw = (
+                html.find("span", string="My Blogs")
+                .parent.parent.parent.find("div", class_="table")
+                .find_all("div", recursive=False)
+            )
+            self.blog = Blog(heading=blogs_raw.pop(0), text=blogs_raw.pop(0))
+        except (TypeError, AttributeError):
+            self.blog = None
+            LOGGER.info("Member '%s' has no front page blog", self.name)
+
+        try:
+            blogs_raw = (
+                html.find("span", string="My Blogs")
+                .parent.parent.parent.find("div", class_="table")
+                .find_all("div", recursive=False)
+            )
+            self.blogs = [
+                Thumbnail(
+                    name=blog.a.string, url=blog.a["href"], type=ThumbnailType.blog
+                )
+                for blog in blogs_raw[:-2]
+            ]
+        except (TypeError, AttributeError):
+            self.blogs = []
+            LOGGER.info("Member '%s' has no blog suggestions", self.name)
+
+        try:
+            friends = html.find("div", class_="table tablerelated").find_all(
+                "div", recursive=False
+            )[1:]
+            self.friends = [
+                Thumbnail(
+                    name=friend.a["title"],
+                    url=friend.a["href"],
+                    type=ThumbnailType.member,
+                )
+                for friend in friends
+            ]
+        except AttributeError:
+            self.friends = []
+            LOGGER.info("Member '%s' has no friends ;(", self.name)
+
+    def __repr__(self):
+        return f"<Member name={self.name} level={self.profile.level}>"
+
+    def get_blogs(
+        self,
+        index: int = 1,
+        *,
+        query: str = None,
+        timeframe: TimeFrame = None,
+        sort: Tuple[str, str] = None,
+    ) -> ResultList:
+        """Search through a member's blogs one page at a time with certain filters.
+
+        Parameters
+        -----------
+        index : int
+            The page index you wish to get the blogs for, allows to hop around.
+        timeframe : TimeFrame
+            The date the blog was added, optional
+        query : str
+            The string to look for in the blog title, optional.
+        sort : Tuple[str, str]
+            The sorting tuple to sort by
+
+        Returns
+        --------
+        ResultList[Blog]
+            The list of blogs on this page.
+        """
+        params = {
+            "filter": "t",
+            "kw": query,
+            "timeframe": timeframe.value if timeframe else None,
+            "sort": f"{sort[0]}-{sort[1]}" if sort else None,
+        }
+
+        return self._get_blogs(f"{self.url}/blogs/page/{index}", params=params)
+
+    def _get_blogs(self, url, *, params):
+        """Backend class so we can use it with ResultList"""
+        html = get_page(url, params=params)
+        try:
+            table = html.find("form", attrs={"name": "filterform"}).parent.find(
+                "div", class_="table"
+            )
+        except AttributeError:
+            table = None
+
+        if table is None:
+            return []
+
+        raw_blogs = table.find_all("div", recursive=False)[2:]
+        blogs = []
+        e = 0
+        for _ in range(len(raw_blogs)):
+            try:
+                heading = raw_blogs[e]
+            except IndexError:
+                break
+
+            try:
+                text = raw_blogs[e + 1]
+            except IndexError:
+                text = {"class": None}
+
+            blog_obj = Blog(heading=heading, text=text)
+            blogs.append(blog_obj)
+            e += 2
+
+        page, max_page = get_page_number(html)
+
+        return ResultList(
+            results=blogs,
+            params=params,
+            url=url,
+            action=self._get_blogs,
+            page=page,
+            max_page=max_page,
+        )
+
+    def get_member_comments(
+        self, index: int = 1, *, show_deleted: bool = False
+    ) -> CommentList:
+        """Gets a page of all the comments a member has posted.
+
+        Parameters
+        -----------
+        index : int
+            The page number to get the comments from.
+        show_deleted : Optional[bool]
+            Pass true to show deleted user comments. Only works if it is a page
+            you have permissions on.
+
+        Returns
+        --------
+        CommentList[Comment]
+            A list of the comments made by the user.
+
+        """
+        params = {"deleted": "t" if show_deleted else None}
+
+        html = get_page(f"{self.url}/comments/page/{index}", params=params)
+        return self._get_comments(html)
+
+    def get_friends(self, index: int = 1, *, username: str = None) -> ResultList:
+        """Get a page of the friends of the member
+
+        Parameters
+        -----------
+        index : int
+            The page number to get the friends from.
+        username : Optional[str]
+            The username of the user you are looking for
+
+        Returns
+        --------
+        ResultList[Thumbnail]
+            A list of member like thumbnails of the member's friends
+        """
+        params = {"filter": "t", "username": username}
+
+        return self._get(f"{self.url}/friends/page/{index}", params=params)
+
+    def get_groups(
+        self,
+        index: int = 1,
+        *,
+        query: str = None,
+        subscription: Membership = None,
+        category: GroupCategory = None,
+    ) -> ResultList:
+        """Get a page of the groups and teams a member is part of.
+
+        Parameters
+        -----------
+        index : int
+            The page number to get the friends from.
+        query : Optional[str]
+            The text to look for in the group's name
+        subscription : Optional[Membership]
+            The membership rules
+        category : Optional[GroupCategory]
+            The category of groups to search for
+
+        Returns
+        --------
+        ResultList[Thumbnail]
+            A list of team/group like thumbnails the member is part of
+        """
+        params = {
+            "filter": "t",
+            "kw": query,
+            "subscription": subscription.value if subscription else None,
+            "category": category.value if category else None,
+        }
+
+        return self._get(f"{self.url}/groups/page/{index}", params=params)
diff --git a/moddb/pages/file.py b/moddb/pages/file.py
new file mode 100644
index 0000000..615e9cc
--- /dev/null
+++ b/moddb/pages/file.py
@@ -0,0 +1,367 @@
+import re
+import bs4
+import sys
+import datetime
+import requests
+
+from ..utils import (
+    concat_docs,
+    get_date,
+    get_page,
+    raise_for_status,
+    join,
+    get_views,
+    prepare_request,
+)
+from .base import BaseMetaClass
+from ..enums import FileCategory, AddonCategory, ThumbnailType, MediaCategory
+from ..boxes import Mirror, Thumbnail
+
+
+@concat_docs
+class File(BaseMetaClass):
+    """An oject representing a file on ModDB, a file is something posted by the page owner which is directly linked
+    to the page. It is endorsed by the page owner and they should do everythign they can to make sure that it is safe.
+    As compared to an addon that may be added by fans to the page and that are files meant to work with the page but
+    that are not directly related to the page. E.x the file of a mod page would be the mod files used to install the
+    mod whereas an addon could be something like a fan-made texture pack for the mod or a map.
+
+    Parameters
+    -----------
+    html : bs4.BeautifulSoup
+        The html to parse. Allows for finer control.
+
+    Filtering
+    ----------
+    category  : :class:`.FileCategory`
+        The type of file (audio, video, demo, full version....)
+    categoryaddon : :class:`.AddonCategory`
+        The type of addon (map, textures, ect...)
+    game : Union[:class:`.Game`, :class:`.Object`]
+        An game object or an object with an id attribute which represents the
+        game the file belongs to.
+    timeframe : :class:`.TimeFrame`
+        The time period this was released in (last 24hr, last week, last month)
+
+    Sorting
+    --------
+        * **released** - when the object was released, asc is oldest, desc is most recent
+        * **id** - when it was added to moddb, asc is oldest, desc is most recent
+        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
+        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
+        * **rating** - order by rating, asc is highest rating, desc is lowest rating
+        * **name** - order alphabetically, asc is a-z, desc is z-a
+        * **date** - order by upload date, asc is most recent first, desc is oldest first
+
+    Attributes
+    -----------
+    filename : str
+        The name of the file
+    hash : str
+        The MD5 hash of the file
+    name : str
+        The name of the page
+    size : int
+        the file size in bytes
+    today : int
+        The number of downloads today
+    downloads : int
+        The total number of times this file has been downloaded
+    category : FileCategory
+        The category of the file
+    author : Thumbnail
+        A member type thumbnail of the member who uploaded the file
+    date : datetime.datetime
+        The date the file was uploaded
+    button : str
+        html code for the embed button
+    widget : str
+        html code for the embed widget
+    description : str
+        Description of the file, as written by the author
+    preview : str
+        URL of the preview image for the file
+    """
+
+    def __init__(self, html: bs4.BeautifulSoup):
+        if html.find("span", string="File Deleted", class_="heading"):
+            raise ValueError("This file has been removed")
+
+        info = html.find("div", class_="table tablemenu")
+        file = {
+            x.string.lower(): x.parent.span.string.strip()
+            for x in info.find_all("h5", string=("Filename", "Size", "MD5 Hash"))
+        }
+        self.name = (
+            html.find("a", title="Report")
+            .parent.parent.find("span", class_="heading")
+            .string
+        )
+        self.filename = file["filename"]
+        super().__init__(html)
+
+        self.hash = file["md5 hash"]
+        self.size = int(re.sub(r"[(),bytes]", "", file["size"].split(" ")[1]))
+
+        downloads = html.find("h5", string="Downloads").parent.a.string
+        self.today = int(re.sub(r"[(),today]", "", downloads.split(" ")[1]))
+        self.downloads = int(downloads.split(" ")[0].replace(",", ""))
+
+        try:
+            self.category = FileCategory(
+                int(info.find("h5", string="Category").parent.a["href"].split("=")[-1])
+            )
+        except ValueError:
+            self.category = AddonCategory(
+                int(info.find("h5", string="Category").parent.a["href"].split("=")[-1])
+            )
+
+        uploader = info.find("h5", string="Uploader").parent.a
+        self.author = Thumbnail(
+            url=uploader["href"], name=uploader.string, type=ThumbnailType.member
+        )
+
+        self.date = get_date(
+            info.find("h5", string="Added").parent.span.time["datetime"]
+        )
+        self.button = info.find("h5", string="Embed Button").parent.span.input["value"]
+        self.widget = info.find("h5", string="Embed Widget").parent.span.input["value"]
+
+        self.description = html.find("p", id="downloadsummary").string
+
+        self.preview = html.find_all("img", src=True)[0]["src"]
+
+    def __repr__(self):
+        return f"<{self.__class__.__name__} name={self.name} type={self.category.name}>"
+
+    def save(self, file_obj, *, mirror=None):
+        """Save the file to an object. This functions makes
+        two requests. If you pass a valid mirror it will
+        make only one request.
+
+        Parameters
+        -----------
+        file_obj : typing.BinaryIO
+            The file obj to save the file to. The binary data
+            will be streamed to that object.
+        mirror : Optional[Mirror]
+            An optional mirror object to download the
+            file from a specific moddb mirror
+
+        """
+        if mirror is None:
+            download = get_page(f"https://www.moddb.com/downloads/start/{self.id}")
+            url = download.find("a", string=f"download {self.filename}")["href"]
+        else:
+            url = mirror._url
+
+        SESSION = sys.modules["moddb"].SESSION
+        prepped = prepare_request(requests.Request("GET", join(url)), SESSION)
+        with SESSION.send(prepped, stream=True) as r:
+            raise_for_status(r)
+            for chunk in r.iter_content(chunk_size=8192):
+                file_obj.write(chunk)
+
+    def get_mirrors(self):
+        """Get all the mirrors from which a file can be downloaded. This
+        can then be passed to File.save to download from a specific mirror.
+
+
+        Returns
+        --------
+        List[Mirror]
+            A list of Mirror objects"""
+
+        html = get_page(f"https://www.moddb.com/downloads/start/{self.id}/all")
+        mirrors_div = html.find("div", class_="mirrors").find_all(
+            "div", recursive=False
+        )
+        mirrors = []
+        for mirror in mirrors_div:
+            mirror_match = re.match(
+                r"(.*) #([0-9]*) \((.{2}), (.{2})\)", mirror.div.p.contents[-1].strip()
+            )
+            stats_match = re.match(
+                r"([0-9,]*) downloads? served, ([0-9.]*)% capacity",
+                mirror.div.span.string,
+            )
+
+            mirrors.append(
+                Mirror(
+                    name=mirror_match.group(1),
+                    index=int(mirror_match.group(2)),
+                    city=mirror_match.group(3),
+                    country=mirror_match.group(4),
+                    served=int(stats_match.group(1).replace(",", "")),
+                    capacity=float(stats_match.group(2)),
+                    url=mirror.div.p.a["href"],
+                )
+            )
+
+        return mirrors
+
+
+@concat_docs
+class Addon(File):
+    """Object representing an addon. Seemingly the only difference between an addon and a file is in
+    the semantics. A file often represents something official released by the page, e.g. the mod installation
+    or an official guide where as addons are often fan made and might not be directly endorsed by the page owners
+    even if it is allowed. They literally add on to the page's content without becoming part of it. There is a slight
+    difference in their profiles but nothing beyond that.
+
+    Parameters
+    -----------
+    html : bs4.BeautifulSoup
+        The html to parse. Allows for finer control.
+
+    Filtering
+    ----------
+    categoryaddon : :class:`.AddonCategory`
+        The type of addon (map, textures, ect...)
+    licence : :class:`.Licence`
+        The licence of the addon
+    game : Union[:class:`.Game`, :class:`.Object`]
+        An game object or an object with an id attribute which represents the
+        game the addon belongs to.
+    timeframe : :class:`.TimeFrame`
+        The time period this was released in (last 24hr, last week, last month)
+
+    Sorting
+    --------
+        * **released** - when the object was released, asc is oldest, desc is most recent
+        * **id** - when it was added to moddb, asc is oldest, desc is most recent
+        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
+        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
+        * **rating** - order by rating, asc is highest rating, desc is lowest rating
+        * **name** - order alphabetically, asc is a-z, desc is z-a
+        * **licence** - order based on licence
+        * **date** - order by upload date, asc is most recent first, desc is oldest first
+
+
+    """
+
+    pass
+
+
+@concat_docs
+class Media(BaseMetaClass):
+    """Represents an image, audio file or video file on
+
+    Parameters
+    -----------
+    html : bs4.BeautifulSoup
+        The html to parse. Allows for finer control.
+
+    Filtering
+    -----------
+    sitearea : :class:`.Category`
+        The type of model the media belongs to. Category.downloads is not valid for this.
+
+    Sorting
+    --------
+        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
+        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
+        * **name** - order alphabetically, asc is a-z, desc is z-a
+        * **id** - order by upload date, asc is most recent first, desc is oldest first
+
+        Exclusive to videos and audios
+        * **duration** - order by duration, asc is shortest to longest, desc is longest first
+
+    Attributes
+    -----------
+    date : datetime.datetime
+        The date the media was uploaded
+    name : str
+        The name of the media
+    author : Thumbnail
+        Member type thumbnail of the media uploader
+    duration : datetime.timedelta
+        Duration of the media in seconds, 0 if it's an image
+    size : int
+        Size of the files in bytes
+    views : int
+        Total amount of views
+    today : int
+        Amount of views today
+    filename : str
+        The name of the file for the media
+    fileurl : str
+        The url of the file for the media
+    category : MediaCategory
+        Whether the media is an image, a video or an audio
+    description : str
+        The description of the file as given by the file uploader.
+    """
+
+    def __init__(self, html: bs4.BeautifulSoup):
+        try:
+            self.name = html.find("meta", itemprop="name")["content"]
+        except TypeError:
+            self.name = html.find("img", id="mediaimage")["title"]
+
+        super().__init__(html)
+        medias = html.find_all(
+            "h5", string=("Date", "By", "Duration", "Size", "Views", "Filename")
+        )
+        raw_media = {media.string.lower(): media.parent for media in medias}
+
+        self.date = get_date(raw_media["date"].span.time["datetime"])
+
+        author = raw_media["by"].span.a
+        self.author = Thumbnail(
+            url=author["href"], name=author.string.strip(), type=ThumbnailType.member
+        )
+
+        if "duration" in raw_media:
+            duration = raw_media["duration"].span.time.string.strip().split(":")
+            duration.reverse()
+            times = ["seconds", "minutes", "hours"]
+            self.duration = datetime.timedelta(
+                **{times[duration.index(x)]: int(x) for x in duration}
+            )
+        else:
+            self.duration = 0
+
+        if "size" in raw_media:
+            self.size = tuple(raw_media["size"].span.string.strip().split("×"))
+
+        self.views, self.today = get_views(raw_media["views"].a.string)
+        media_player = html.find("video", id="mediaplayer")
+
+        if not media_player:
+            self.category = MediaCategory.image
+            self.fileurl = html.find("meta", property="og:image")["content"]
+        else:
+            self.fileurl = media_player.source["src"]
+            if "audio" in media_player.source["type"]:
+                self.category = MediaCategory.audio
+            else:
+                self.category = MediaCategory.video
+
+        if "filename" in raw_media:
+            self.filename = raw_media["filename"].span.string.strip()
+        else:
+            self.filename = self.fileurl.split("/")[-1]
+
+        self.description = html.find("meta", {"name": "description"})["content"]
+
+    def __repr__(self):
+        return f"<Media name={self.name} type={self.category.name}>"
+
+    def save(self, file_obj):
+        """Save the media to an object.
+
+        Parameters
+        -----------
+        file_obj : typing.BinaryIO
+            The file obj to save the file to. The binary data
+            will be streamed to that object.
+
+        """
+        SESSION = sys.modules["moddb"].SESSION
+        prepped = prepare_request(requests.Request("GET", self.fileurl), SESSION)
+
+        with SESSION.send(prepped, stream=True) as r:
+            raise_for_status(r)
+            for chunk in r.iter_content(chunk_size=8192):
+                file_obj.write(chunk)
diff --git a/moddb/pages/fp.py b/moddb/pages/fp.py
new file mode 100644
index 0000000..f0c6e61
--- /dev/null
+++ b/moddb/pages/fp.py
@@ -0,0 +1,150 @@
+import bs4
+import re
+
+from ..boxes import Thumbnail
+from ..enums import ThumbnailType
+from ..utils import get_type_from, join, get_page
+from .opinion import Poll
+
+
+class FrontPage:
+    """An object representing the front page of  More of less just a long suggestion of the hottest mods,
+    games, articles and files of the moment
+
+    Parameters
+    -----------
+    html : bs4.BeautifulSoup
+        The html to parse. Allows for finer control.
+
+    Attributes
+    -----------
+    slider : List[Thumnail]
+        A list of object like thumbnails presented by the slider present on
+        the front page, this is a catered list of promoted content, can contain
+        any type of object.
+    articles : List[Thumbnail]
+        A list of article like thumbnail objects representing the suggested
+        articles on the front page.
+    mods : List[Thumbnail]
+        A list of mod like thumbnail objects representing the suggested
+        mods on the front page.
+    games : List[Thumbnail]
+        A list of game like thumbnail objects representing the suggested
+        games on the front page.
+    files : List[Thumbnail]
+        A list of file like thumbnail objects representing the suggested
+        files on the front page.
+    poll : Poll
+        The current ongoing moddb poll. Currently cannot be voted on.
+
+    """
+
+    def __init__(self, html: bs4.BeautifulSoup):
+        slider = html.find("div", class_="rotatorholder").find_all(
+            "div", class_="rotatorbox"
+        )
+        self.slider = []
+        for x in slider:
+            name = x.a.find("h2")
+            summary = x.a.find("p")
+
+            image = None
+            if "style" in x.div:
+                re_search = re.search(r"\((.*)\)", x.div["style"])
+                if re_search:
+                    image = re_search.group(1)
+            elif "data-bg" in x.div:
+                image = x.div["data-bg"]
+
+            thumbnail = Thumbnail(
+                name=name.string if name else None,
+                url=x.a["href"],
+                summary=summary.string if summary else None,
+                image=image,
+                type=get_type_from(x.a["href"]),
+            )
+
+            self.slider.append(thumbnail)
+
+        articles = (
+            html.find("span", string="Latest Articles")
+            .parent.parent.parent.find("div", class_="table")
+            .find_all("div", recursive=False)[:-1]
+        )
+        self.articles = [
+            Thumbnail(
+                name=x.a["title"],
+                url=x.a["href"],
+                type=ThumbnailType.article,
+                image=x.a.img["src"],
+                summary=x.find("p").string,
+                date=x.find("time")["datetime"],
+            )
+            for x in articles
+        ]
+
+        mods = (
+            html.find("span", string="Popular Mods")
+            .parent.parent.parent.find("div", class_="table")
+            .find_all("div", recursive=False)[1:]
+        )
+        self.mods = [
+            Thumbnail(
+                name=x.a["title"],
+                url=x.a["href"],
+                type=ThumbnailType.mod,
+                image=x.a.img["src"],
+            )
+            for x in mods
+        ]
+
+        games = (
+            html.find("span", string="Popular Games")
+            .parent.parent.parent.find("div", class_="table")
+            .find_all("div", recursive=False)[1:]
+        )
+        self.games = [
+            Thumbnail(
+                name=x.a["title"],
+                url=x.a["href"],
+                type=ThumbnailType.game,
+                image=x.a.img["src"],
+            )
+            for x in games
+        ]
+
+        jobs = (
+            html.find("span", string="Jobs")
+            .parent.parent.parent.find("div", class_="table")
+            .find_all("div", recursive=False)[1:]
+        )
+        self.jobs = [
+            Thumbnail(
+                name=x.find("a").string,
+                url=join(x.find("a")["href"]),
+                type=ThumbnailType.job,
+            )
+            for x in jobs
+        ]
+
+        files = (
+            html.find("span", string="Popular Files")
+            .parent.parent.parent.find("div", class_="table")
+            .find_all("div", recursive=False)[1:]
+        )
+        self.files = [
+            Thumbnail(
+                name=x.a["title"],
+                url=x.a["href"],
+                type=ThumbnailType.file,
+                image=x.a.img["src"],
+            )
+            for x in files
+        ]
+
+        self.poll = Poll(get_page(html.find("div", class_="poll").form["action"]))
+
+        self._html = html
+
+    def __repr__(self):
+        return f"<FrontPage articles={len(self.articles)} mods={len(self.mods)} games={len(self.games)} files={len(self.files)}>"
diff --git a/moddb/pages/game.py b/moddb/pages/game.py
new file mode 100644
index 0000000..ea362fa
--- /dev/null
+++ b/moddb/pages/game.py
@@ -0,0 +1,46 @@
+import bs4
+
+from ..utils import concat_docs
+from .base import PageMetaClass
+from .mixins import GetModsMixin, GetAddonsMixin
+from ..enums import SearchCategory
+
+
+@concat_docs
+class Game(PageMetaClass, GetModsMixin, GetAddonsMixin):
+    """A subclass of Page plus a method to get all the mods.
+
+    Parameters
+    -----------
+    html : bs4.BeautifulSoup
+        The html to parse. Allows for finer control.
+
+
+    Filtering
+    ----------
+    released : :class:`.Status`
+        The status of the game (released, unreleased)
+    genre : :class:`.Genre`
+        The genre of the game (fps, tower defense)
+    theme : :class:`.Theme`
+        The theme of the game (fantasy, action)
+    indie : :class:`.Scope`
+        Whether the game is triple AAA or indie
+    players : :class:`.PlayerStyle`
+        Player styles of the game (co-op, singleplayer)
+    timeframe : :class:`.TimeFrame`
+        The time period this was released in (last 24hr, last week, last month)
+
+    Sorting
+    --------
+        * **released** - when the object was released, asc is oldest, desc is most recent
+        * **id** - when it was added to moddb, asc is oldest, desc is most recent
+        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
+        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
+        * **rating** - order by rating, asc is highest rating, desc is lowest rating
+        * **name** - order alphabetically, asc is a-z, desc is z-a
+        * **dateup** - order by latest update, asc is most recent update first, desc is oldest update first
+    """
+
+    def __init__(self, html: bs4.BeautifulSoup):
+        super().__init__(html, SearchCategory.games)
diff --git a/moddb/pages/job.py b/moddb/pages/job.py
new file mode 100644
index 0000000..e772929
--- /dev/null
+++ b/moddb/pages/job.py
@@ -0,0 +1,119 @@
+import bs4
+import json
+import re
+
+from ..utils import concat_docs, join, LOGGER
+from ..boxes import Thumbnail
+from ..enums import ThumbnailType, JobSkill
+
+
+@concat_docs
+class Job:
+    """Model representing a job proposed on ModDB
+
+    Parameters
+    -----------
+    html : bs4.BeautifulSoup
+        The html to parse. Allows for finer control.
+
+    Filtering
+    ----------
+    skill : :class:`.JobSkill`
+        The job skill looked for
+    earn : :class:`.bool`
+        Whether or not the job is paid
+
+    Sorting
+    --------
+        * **location** - order by the location of the job
+        * **name** - order alphabetically, asc is a-z, desc is z-a
+        * **skill** - order by the skill required
+        * **id** - order by upload date, asc is most recent, desc is oldest first
+
+    Attributes
+    -----------
+    id : int
+        id of the job
+    name_id : str
+        The name_id of the member, cannot be changed, it's a mix of the original username lowercased with
+        spaces removed and shortened.
+    author : Thumbnail
+        A member like thumbnail representing the poster of the job. Can be none if they desire to remain private.
+    paid : bool
+        Whether or not the job is paid
+    tags : dict{str : str}
+        A dict of key-values pair where the key is the name of the tag and the value is the url.
+    skill : JobSkill
+        the skill demanded for the job
+    location : str
+        The location the job will be at
+    name : str
+        The name of the job
+    text : str
+        The description of the job
+    related : List[Thumbnail]
+        A list of team like thumbnails of companies related to the job poster
+
+    """
+
+    def __init__(self, html: bs4.BeautifulSoup):
+        breadcrumb = json.loads(html.find("script", type="application/ld+json").string)[
+            "itemListElement"
+        ][-1]["Item"]
+        self.name = breadcrumb["name"]
+        self.url = breadcrumb["@id"]
+        self.name_id = self.url.split("/")[0]
+        self.text = html.find("div", id="articlecontent").text
+
+        profile_raw = html.find("span", string="Jobs").parent.parent.parent.find(
+            "div", class_="table tablemenu"
+        )
+
+        self.id = int(
+            re.search(r"siteareaid=(\d*)", html.find("a", string="Report")["href"])[1]
+        )
+
+        try:
+            author = profile_raw.find("h5", string="Author").parent.span.a
+            self.author = Thumbnail(
+                url=author["href"], name=author.string, type=ThumbnailType.member
+            )
+        except AttributeError:
+            LOGGER.info("Job '%s' has no author", self.name)
+            self.author = None
+
+        self.paid = profile_raw.find("h5", string="Paid").parent.a.string == "Yes"
+
+        try:
+            raw_tags = html.find("form", attrs={"name": "tagsform"}).find_all("a")
+            self.tags = {
+                x.string: join(x["href"]) for x in raw_tags if x.string is not None
+            }
+        except AttributeError:
+            self.tags = {}
+            LOGGER.info("'%s' '%s' has no tags", self.__class__.__name__, self.name)
+
+        self.skill = JobSkill(
+            int(profile_raw.find("h5", string="Skill").parent.span.a["href"][-1])
+        )
+
+        self.location = profile_raw.find(
+            "h5", string="Location"
+        ).parent.span.string.strip()
+
+        try:
+            related = html.find("div", class_="tablerelated").find_all(
+                "a", class_="image"
+            )
+            self.related = [
+                Thumbnail(url=x["href"], name=x["title"], type=ThumbnailType.team)
+                for x in related
+            ]
+        except AttributeError:
+            LOGGER.info("Job '%s' has no related companies", self.name)
+            self.related = []
+
+        self._html = html
+
+    def __repr__(self):
+        return f"<Job name={self.name}>"
diff --git a/moddb/pages/mixins.py b/moddb/pages/mixins.py
new file mode 100644
index 0000000..256ef44
--- /dev/null
+++ b/moddb/pages/mixins.py
@@ -0,0 +1,607 @@
+from typing import Tuple, Union, List
+
+from ..utils import LOGGER, get_page, get_page_number, Object
+from ..boxes import ResultList, Thumbnail
+from ..enums import (
+    Status,
+    Genre,
+    Theme,
+    Scope,
+    PlayerStyle,
+    TimeFrame,
+    Licence,
+    ArticleCategory,
+    FileCategory,
+    AddonCategory,
+    Difficulty,
+    TutorialCategory,
+    HardwareCategory,
+    SoftwareCategory,
+    RSSType,
+)
+from . import opinion
+
+
+class GetGamesMixin:
+    """Abstract class containing the get_games method"""
+
+    def get_games(
+        self,
+        index: int = 1,
+        *,
+        query: str = None,
+        status: Status = None,
+        genre: Genre = None,
+        theme: Theme = None,
+        scope: Scope = None,
+        players: PlayerStyle = None,
+        timeframe: TimeFrame = None,
+        sort: Tuple[str, str] = None,
+    ) -> ResultList:
+        """Get a page of games for the model. Each page will yield up to 30 games.
+
+        Parameters
+        -----------
+        index : Optional[int]
+            The page number to get the games for.
+        query : Optional[str]
+            The text to look for in the game names.
+        status : Optional[Status]
+            The status of the game (unreleased, released, early access, ect...)
+        genre : Optional[Genre]
+            The genre of the game (fps, tower defense)
+        theme : Optional[Theme]
+            The theme of the game (fantasy, action)
+        scope : Optional[Scope]
+            The scope of the game (AAA, indie)
+        players : Optional[PlayerStyle]
+            Player styles of the game (co-op, singleplayer)
+        timeframe : Optional[TimeFrame]
+            The time period this was released in (last 24hr, last week, last month)
+        sort : Optional[Tuple[str, str]]
+            The sorting tuple to sort by the results
+
+        Returns
+        --------
+        ResultList[Thumbnail]
+            List of game like thumbnails that can be parsed individually.
+        """
+        params = {
+            "filter": "t",
+            "kw": query,
+            "released": status.value if status else None,
+            "genre": genre.value if genre else None,
+            "theme": theme.value if theme else None,
+            "indie": scope.value if scope else None,
+            "players": players.value if players else None,
+            "timeframe": timeframe.value if timeframe else None,
+            "sort": f"{sort[0]}-{sort[1]}" if sort else None,
+        }
+
+        return self._get(f"{self.url}/games/page/{index}", params=params)
+
+
+class GetModsMixin:
+    """Abstract class containing the get_mod method"""
+
+    def get_mods(
+        self,
+        index: int = 1,
+        *,
+        query: str = None,
+        status: Status = None,
+        genre: Genre = None,
+        theme: Theme = None,
+        players: PlayerStyle = None,
+        timeframe: TimeFrame = None,
+        game: Union["Game", Object] = None,
+        sort: Tuple[str, str] = None,
+    ) -> ResultList:
+        """Get a page of mods for the game. Each page will yield up to 30 mods.
+
+        Parameters
+        -----------
+        index : int
+            The page number to get the mods from.
+        query : Optional[str]
+            The text to look for in the mod names.
+        status : Optional[Status]
+            The status of the mod (unreleased, released, early access, ect...)
+        genre : Optional[Genre]
+            The genre of the mod (fps, tower defense)
+        theme : Optional[Theme]
+            The theme of the mod (fantasy, action)
+        players : Optional[PlayerStyle]
+            Player styles of the mod (co-op, singleplayer)
+        timeframe : Optional[TimeFrame]
+            The time period this was released in (last 24hr, last week, last month)
+        game : Union[mod, Object]
+            An mod object or an object with an id attribute which represents the
+            mod the mod belongs to.
+        sort : Optional[Tuple[str, str]]
+            The sorting tuple to sort by the results
+
+        Returns
+        --------
+        ResultList[Thumbnail]
+            The list of mods type thumbnails parsed from the game
+        """
+        params = {
+            "filter": "t",
+            "kw": query,
+            "released": status.value if status else None,
+            "genre": genre.value if genre else None,
+            "theme": theme.value if theme else None,
+            "players": players.value if players else None,
+            "timeframe": timeframe.value if timeframe else None,
+            "game": game.id if game else None,
+            "sort": f"{sort[0]}-{sort[1]}" if sort else None,
+        }
+
+        return self._get(f"{self.url}/mods/page/{index}", params=params)
+
+
+class GetEnginesMixin:
+    """Abstract class containing the get_engines method"""
+
+    def get_engines(
+        self,
+        index: int = 1,
+        *,
+        query: str = None,
+        status: Status = None,
+        licence: Licence = None,
+        timeframe: TimeFrame = None,
+        sort: Tuple[str, str] = None,
+    ) -> ResultList:
+        """Get a page of engines for the game. Each page will yield up to 30 engines.
+
+        Parameters
+        -----------
+        index : int
+            The page number to get the engines from.
+        query : Optional[str]
+            The text to look for in the engine names.
+        status : Optional[Status]
+            The status of the game (unreleased, released, early access, ect...)
+        licence : Optional[Licence]
+            The licence of the engine (open source, proprietary, ect...)
+        timeframe : Optional[TimeFrame]
+            The time period this was released in (last 24hr, last week, last month)
+        sort : Optional[Tuple[str, str]]
+            The sorting tuple to sort by the results
+        Returns
+        --------
+        ResultList[Thumbnail]
+            The list of engine type thumbnails parsed from the game
+        """
+        params = {
+            "filter": "t",
+            "kw": query,
+            "released": status.value if status else None,
+            "licence": licence.value if licence else None,
+            "timeframe": timeframe.value if timeframe else None,
+            "sort": f"{sort[0]}-{sort[1]}" if sort else None,
+        }
+
+        return self._get(f"{self.url}/engines/page/{index}", params=params)
+
+
+class SharedMethodsMixin:
+    """Abstract class that implements a certain amount of top level methods shared between Pages
+    and Hardware"""
+
+    def get_reviews(
+        self,
+        index: int = 1,
+        *,
+        query: str = None,
+        rating: int = None,
+        sort: Tuple[str, str] = None,
+    ) -> ResultList:
+        """Get a page of reviews for the page. Each page will yield up to 10 reviews.
+
+        Parameters
+        -----------
+        index : Optional[int]
+            The page number to get the reviews from.
+        query : Optional[str]
+            The string to look for in the review, optional.
+        rating : Optiona[int]
+            A number between 1 and 10 to get the ratings
+        sort : Optional[Tuple[str, str]]
+            The sorting tuple to sort by the results
+
+        Returns
+        --------
+        ResultList[Review]
+            The list of reviews parsed from the page
+        """
+        params = {
+            "filter": "t",
+            "kw": query,
+            "rating": rating,
+            "sort": f"{sort[0]}-{sort[1]}" if sort else None,
+        }
+
+        return self._get_reviews(f"{self.url}/reviews/page/{index}", params=params)
+
+    def _get_reviews(self, url, *, params):
+        """Backend class so we can use it with ResultList"""
+        html = get_page(url, params=params)
+
+        try:
+            table = html.find("form", attrs={"name": "filterform"}).parent.find(
+                "div", class_="table"
+            )
+        except AttributeError:
+            table = None
+
+        if table is None:
+            return []
+
+        raw_reviews = table.find_all("div", recursive=False)[2:]
+        reviews = []
+        e = 0
+        # This is very hacky because a page of reviews is actually a list of review titles and review contents
+        # I'm not fucking with you, its a list of divs which go [Div[Title of review one], Div[content of review one],
+        # Div[Title of review two], Div[content of review two]] It's dumb as fuck and I hate it.
+        for _ in range(len(raw_reviews)):
+            try:
+                review = raw_reviews[e]
+            except IndexError:
+                break
+
+            try:
+                text = raw_reviews[e + 1]
+            except IndexError:
+                # some reviews don't have text, it's optional as long as you don't give lower than a three
+                text = {"class": "None"}
+
+            if "rowcontentnext" in text["class"]:
+                e += 1
+                review_obj = opinion.Review(review=review, text=text)
+            else:
+                review_obj = opinion.Review(review=review)
+
+            reviews.append(review_obj)
+            e += 1
+
+        page, max_page = get_page_number(html)
+
+        return ResultList(
+            results=reviews,
+            params=params,
+            action=self._get_reviews,
+            url=url,
+            page=page,
+            max_page=max_page,
+        )
+
+    def get_articles(
+        self,
+        index: int = 1,
+        *,
+        query: str = None,
+        category: ArticleCategory = None,
+        timeframe: TimeFrame = None,
+        sort: Tuple[str, str] = None,
+    ) -> ResultList:
+        """Get a page of articles for the page. Each page will yield up to 30 articles.
+
+        Parameters
+        -----------
+        index : Optional[int]
+            The page number to get the articles from.
+        query : Optional[str]
+            The string query to search for in the article name, optional.
+        category : Optional[ArticleCategory]
+            Type enum defining what the article is, optional
+        timeframe : Optional[TimeFrame]
+            The time period this was released in (last 24hr, last week, last month)
+        sort : Optional[Tuple[str, str]]
+            The sorting tuple to sort by the results
+
+        Returns
+        --------
+        ResultList[Thumbnail]
+            The list of article type thumbnails parsed from the page
+        """
+        params = {
+            "filter": "t",
+            "kw": query,
+            "type": category.value if category else None,
+            "timeframe": timeframe.value if timeframe else None,
+            "sort": f"{sort[0]}-{sort[1]}" if sort else None,
+        }
+
+        return self._get(f"{self.url}/articles/page/{index}", params=params)
+
+    def get_files(
+        self,
+        index: int = 1,
+        *,
+        query: str = None,
+        category: FileCategory = None,
+        addon_type: AddonCategory = None,
+        timeframe: TimeFrame = None,
+        sort: Tuple[str, str] = None,
+    ) -> ResultList:
+        """Get a page of files for the page. Each page will yield up to 30 files.
+
+        Parameters
+        -----------
+        index : Optional[int]
+            The page number to get the files from.
+        query : Optional[str]
+            The string query to search for in the file name, optional.
+        category : [FileCategory]
+            Type enum defining what the file is, optional
+        addon_type : Optional[AddonCategory]
+            Type enum defining what category the file is.
+        timeframe : Optional[TimeFrame]
+            The time period this was released in (last 24hr, last week, last month)
+        sort : Optional[Tuple[str, str]]
+            The sorting tuple to sort by the results
+
+        Returns
+        --------
+        ResultList[Thumbnail]
+            The list of file type thumbnails parsed from the page
+        """
+        params = {
+            "filter": "t",
+            "kw": query,
+            "category": category.value if category else None,
+            "categoryaddon": addon_type.value if addon_type else None,
+            "timeframe": timeframe.value if timeframe else None,
+            "sort": f"{sort[0]}-{sort[1]}" if sort else None,
+        }
+
+        return self._get(f"{self.url}/downloads/page/{index}", params=params)
+
+    def get_images(self) -> List[Thumbnail]:
+        """Get all the images a page has uploaded. Literally all of them. As thumbnails. ModDB's imagebox
+        caches all the urls for images on the page so this grabs them all. Lists might be long, but this
+        is all the images. They can be invidually parsed to get full fledged media objects from them.
+
+        Returns
+        --------
+        List[Thumbnail]
+            The list of image type thumbnails parsed from the page
+        """
+        html = get_page(f"{self.url}/images")
+        return self._get_media(1, html=html)
+
+    def get_videos(self) -> List[Thumbnail]:
+        """Get all the videos a page has uploaded. Literally all of them. As thumbnails. ModDB's imagebox
+        caches all the urls for videos on the page so this grabs them all. Lists might be long, but this
+        is all the videos. They can be invidually parsed to get full fledged media objects from them.
+
+        Returns
+        --------
+        List[Thumbnail]
+            The list of video type thumbnails parsed from the page
+        """
+        html = get_page(f"{self.url}/videos")
+        return self._get_media(2, html=html)
+
+    def get_tutorials(
+        self,
+        index: int = 1,
+        *,
+        query: str = None,
+        difficulty: Difficulty = None,
+        tutorial_type: TutorialCategory = None,
+        sort: Tuple[str, str] = None,
+    ) -> ResultList:
+        """Get a page of tutorial for the page. Each page will yield up to 30 tutorials.
+
+        Parameters
+        -----------
+        index : Optional[int]
+            The page number to get the tutorials from.
+        query : Optional[str]
+            The string query to look for in the tutorial title, optional.
+        difficulty : Optional[Difficulty]
+            Enum type representing the difficulty of the tutorial, optional.
+        tutorial_type : Optional[TutorialCategory]
+            Enum type representing the theme/type/category of the tutorial, optional.
+        sort : Optional[Tuple[str, str]]
+            The sorting tuple to sort by the results
+
+        Returns
+        --------
+        ResultList[Thumbnail]
+            The list of article type thumbnails parsed from the page
+        """
+        params = {
+            "filter": "t",
+            "kw": query,
+            "subtype": tutorial_type.value if tutorial_type else None,
+            "meta": difficulty.value if difficulty else None,
+            "sort": f"{sort[0]}-{sort[1]}" if sort else None,
+        }
+
+        return self._get(f"{self.url}/tutorials/page/{index}", params=params)
+
+
+class GetWaresMixin:
+    """Abstrac class implementing get_software and get_hardware"""
+
+    def get_hardware(
+        self,
+        index: int = 1,
+        *,
+        query: str = None,
+        status: Status = None,
+        category: HardwareCategory = None,
+        timeframe: TimeFrame = None,
+        sort: Tuple[str, str] = None,
+    ) -> ResultList:
+        """Get a page of hardware for the platform. Each page will yield up to 30 hardware.
+
+        Parameters
+        -----------
+        index : Optional[int]
+            The page number to get the hardware for.
+        query : Optional[str]
+            The text to look for in the hardware's names
+        status : Optional[Status]
+            Status of the hardware (released, unreleased, early access...)
+        category : Optional[HardwareCategory]
+            Category of the hardware (headset, controller, ect...)
+        timeframe : Optional[TimeFrame]
+            The time period this was released in (last 24hr, last week, last month)
+        sort : Optional[Tuple[str, str]]
+            The sorting tuple to sort by
+
+        Returns
+        --------
+        ResultList[Thumbnail]
+            List of hardware like thumbnails that can be parsed individually.
+        """
+        params = {
+            "filter": "t",
+            "kw": query,
+            "released": status.value if status else None,
+            "category": category.value if category else None,
+            "timeframe": timeframe.value if timeframe else None,
+            "sort": f"{sort[0]}-{sort[1]}" if sort else None,
+        }
+
+        return self._get(f"{self.url}/hardware/page/{index}", params=params)
+
+    def get_software(
+        self,
+        index: int = 1,
+        *,
+        query: str = None,
+        status: Status = None,
+        category: SoftwareCategory = None,
+        timeframe: TimeFrame = None,
+        sort: Tuple[str, str] = None,
+    ) -> ResultList:
+        """Get a page of software for the platform. Each page will yield up to 30 software.
+
+        Parameters
+        -----------
+        index : Optional[int]
+            The page number to get the software for.
+        query : Optional[str]
+            The text to look for in the hardware's names
+        status : Optional[Status]
+            Status of the hardware (released, unreleased, early access...)
+        category : Optional[SoftwareCategory]
+            Category of the hardware (headset, controller, ect...)
+        timeframe : Optional[TimeFrame]
+            The time period this was released in (last 24hr, last week, last month)
+        sort : Optional[Tuple[str, str]]
+            The sorting tuple to sort by
+
+        Returns
+        --------
+        ResultList[Thumbnail]
+            List of software like thumbnails that can be parsed individually.
+        """
+        params = {
+            "filter": "t",
+            "kw": query,
+            "released": status.value if status else None,
+            "category": category.value if category else None,
+            "timeframe": timeframe.value if timeframe else None,
+            "sort": f"{sort[0]}-{sort[1]}" if sort else None,
+        }
+
+        return self._get(f"{self.url}/software/page/{index}", params=params)
+
+
+class RSSFeedMixin:
+    def rss(self, type: RSSType):
+        """Get the RSS feed url for the page depending on which feed type you want
+
+        Parameters
+        -----------
+        type : RSSType
+            The type of feed you desire to get
+
+        Returns
+        --------
+        str
+            URL for the feed type
+        """
+        return f"https://rss.moddb.com/{self._type.name}/{self.name_id}/{type.name}/feed/rss.xml"
+
+
+class GetAddonsMixin:
+    def get_addons(
+        self,
+        index: int = 1,
+        *,
+        query: str = None,
+        addon_type: AddonCategory = None,
+        timeframe: TimeFrame = None,
+        licence: Licence = None,
+        sort: Tuple[str, str] = None,
+    ) -> ResultList:
+        """Get a page of addons for the page. Each page will yield up to 30 addons.
+
+        Parameters
+        -----------
+        index : Optional[int]
+            The page number to get the addons from.
+        query : Optional[str]
+            The string query to search for in the addon name, optional.
+        addon_type : Optional[AddonCategory]
+            Type enum defining what category the file is.
+        timeframe : Optional[TimeFrame]
+            Time frame of when the file was added, optional
+        licence : Optional[Licence]
+            The licence for the addon, optional
+        sort : Optional[Tuple[str, str]]
+            The sorting tuple to sort by the results
+
+        Returns
+        --------
+        ResultList[Thumbnail]
+            The list of addon type thumbnails parsed from the page
+        """
+        params = {
+            "filter": "t",
+            "kw": query,
+            "category": addon_type.value if addon_type else None,
+            "timeframe": timeframe.value if timeframe else None,
+            "licence": licence.value if licence else None,
+            "sort": f"{sort[0]}-{sort[1]}" if sort else None,
+        }
+        return self._get(f"{self.url}/addons/page/{index}", params=params)
+
+
+class GetWatchersMixin:
+    def get_watchers(
+        self, index: int = 1, *, query: str = None, sort: Tuple[str, str] = None
+    ) -> ResultList:
+        """Get a page of watchers for the page. Each page will yield up to 30 members.
+
+        Parameters
+        -----------
+        index : Optional[int]
+            The page number to get the watchers from.
+        query : Optional[str]
+            The string query to search for in the watcher name, optional.
+        sort : Optional[Tuple[str, str]]
+            The sorting tuple to sort by the results
+
+        Returns
+        --------
+        ResultList[Thumbnail]
+            The list of member type thumbnails parsed from the page
+        """
+
+        params = {
+            "filter": "t",
+            "kw": query,
+            "sort": f"{sort[0]}-{sort[1]}" if sort else None,
+        }
+
+        return self._get(f"{self.url}/watchers/page/{index}", params=params)
diff --git a/moddb/pages/mod.py b/moddb/pages/mod.py
new file mode 100644
index 0000000..38ed2d9
--- /dev/null
+++ b/moddb/pages/mod.py
@@ -0,0 +1,48 @@
+import bs4
+
+from ..utils import concat_docs
+from .base import PageMetaClass
+from .mixins import GetAddonsMixin
+from ..enums import SearchCategory
+
+
+@concat_docs
+class Mod(PageMetaClass, GetAddonsMixin):
+    """Basically just a subclass of Page
+
+    Parameters
+    -----------
+    html : bs4.BeautifulSoup
+        The html to parse. Allows for finer control.
+
+    Filtering
+    ----------
+    released : :class:`.Status`
+        The status of the mod (released, unreleased)
+    genre : :class:`.Genre`
+        The genre of the mod (fps, tower defense)
+    theme : :class:`.Theme`
+        The theme of the mod (fantasy, action)
+    players : :class:`.PlayerStyle`
+        Player styles of the mod (co-op, singleplayer)
+    timeframe : :class:`.TimeFrame`
+        The time period this was released in (last 24hr, last week, last month)
+    game : Union[:class:`.Game`, :class:`.Object`]
+        An game object or an object with an id attribute which represents the
+        game the mod belongs to.
+
+    Sorting
+    --------
+        * **released** - when the object was released, asc is oldest, desc is most recent
+        * **id** - when it was added to moddb, asc is oldest, desc is most recent
+        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
+        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
+        * **rating** - order by rating, asc is highest rating, desc is lowest rating
+        * **name** - order alphabetically, asc is a-z, desc is z-a
+        * **game** - order by game???
+        * **dateup** - order by latest update, asc is most recent update first, desc is oldest update first
+
+    """
+
+    def __init__(self, html: bs4.BeautifulSoup):
+        super().__init__(html, SearchCategory.mods)
diff --git a/moddb/pages/opinion.py b/moddb/pages/opinion.py
new file mode 100644
index 0000000..9d5e6b3
--- /dev/null
+++ b/moddb/pages/opinion.py
@@ -0,0 +1,164 @@
+import re
+
+from ..utils import concat_docs, get_date, join
+from ..boxes import Thumbnail, Option
+from ..enums import ThumbnailType
+from .base import BaseMetaClass
+
+
+@concat_docs
+class Review:
+    """Represents a review.
+
+    Filtering
+    -----------
+    rating : int
+        A value from 1 to 10 denoting the rating number you're looking for
+    sitearea : Category
+        The type of model the rating is for (mod, engine, game)
+
+    Sorting
+    --------
+        * **ratingalt** - rating number, desc is biggest to lowest, asc is lowest to biggest
+        * **memberipid** - sort reviewer account age, asc is oldest reviewer first
+        * **positive** - how many people agree with it, desc is most to least people agreeing
+        * **negative** - how many people disagree with it, desc is most to least people disagreeing
+        * **id** - when it was added to moddb, asc is oldest, desc is most recent
+
+    Attributes
+    -----------
+    id : int
+        The review id
+    text : str
+        The contents of the review. Can be none if the member hasn't left any
+    rating : int
+        An int out of 10 representing the rating left with this review.
+    author : Thumbnail
+        A member like thumbnail of the member who left the review
+    date : datetime.datetime
+        Date and time of the review creation
+    agree : str
+            Link to agree with the review
+    disagree : str
+        Link to disagree with the review
+    """
+
+    def __init__(self, **attrs):
+        text = attrs.get("text")
+        if text:
+            self.text = text.text
+        else:
+            self.text = None
+
+        review = attrs.get("review")
+        self.rating = int(review.span.string)
+
+        # id and hash are none if the review doesn't have content
+        try:
+            strings = ("Agree", "Delete", "Disagree")
+            self.id = int(
+                re.findall(
+                    r"siteareaid=(\d*)", review.find("a", title=strings)["href"]
+                )[0]
+            )
+        except TypeError:
+            self.id = None
+
+        try:
+            self._hash = re.findall(
+                r"hash=(.*)&", review.find("a", title="Delete")["href"]
+            )[0]
+        except TypeError:
+            self._hash = None
+
+        author = review.div.a
+        self.author = Thumbnail(
+            url=author["href"],
+            name=author.string.split(" ")[0],
+            type=ThumbnailType.member,
+        )
+        self.date = get_date(review.div.span.time["datetime"])
+
+        try:
+            self.agree = join(review.find("a", title="Agree")["href"])
+            self.disagree = join(review.find("a", title="Disagree")["href"])
+        except TypeError:
+            self.agree = None
+            self.disagree = None
+
+    def __repr__(self):
+        return f"<Review author={self.author.name} rating={self.rating}>"
+
+
+@concat_docs
+class Poll(BaseMetaClass):
+    """Represents a poll. Cannot be voted for due to restrictions implemented by the website.
+
+    Parameters
+    -----------
+    html : bs4.BeautifulSoup
+        The html to parse. Allows for finer control.
+
+    Filtering
+    ----------
+        month : Month
+            The month the poll you're looking for should be from
+        year : int
+            A int representing a year between 2002 and now. Anything below or above 2002 will
+            always return zero results.
+
+    Sorting
+    --------
+        * **totalvotes** - how many people voted on the poll, desc is most to least
+        * **name** - sort the poll alphabetically by name, asc is a-z
+        * **date** - when it was added to moddb, asc is oldest, desc is most recent
+
+    Attributes
+    -----------
+    question : str
+        The question of the poll
+    author : Thumbnail
+        A member like thumbnail of the member who posted the poll, usually ModDB staff
+    total : int
+        The total number of votes that have been cast
+    options : List[Option]
+        The list of available options for the poll
+    """
+
+    def __init__(self, html):
+        poll = html.find("div", class_="poll")
+        self.question = (
+            poll.parent.parent.parent.find("div", class_="normalcorner")
+            .find("span", class_="heading")
+            .string
+        )
+        self.name = self.question
+        super().__init__(html)
+        author = poll.find("p", class_="question").find("a")
+        self.author = Thumbnail(
+            name=author.string, url=author["href"], type=ThumbnailType.member
+        )
+
+        self.total = int(
+            re.search(r"([\d,]*) votes", poll.find("p", class_="question").text)[
+                1
+            ].replace(",", "")
+        )
+
+        percentage = poll.find_all("div", class_="barouter")
+        rest = poll.find_all("p")[1:]
+
+        self.options = []
+        for index, _ in enumerate(percentage):
+            raw = percentage[index].div.string.replace("%", "").replace("\xa0", "")
+            percent = float(f"0.{raw}")
+            text = re.sub(r"\([\d,]* vote(s)?\)", "", rest[index].text)
+            votes = int(
+                re.search(r"([\d,]*) vote(s)?", rest[index].span.string)[1].replace(
+                    ",", ""
+                )
+            )
+            self.options.append(Option(text=text, votes=votes, percent=percent))
+
+    def __repr__(self):
+        return f"<Poll question={self.question}>"
diff --git a/moddb/pages/platform.py b/moddb/pages/platform.py
new file mode 100644
index 0000000..bc136dd
--- /dev/null
+++ b/moddb/pages/platform.py
@@ -0,0 +1,189 @@
+from ..utils import concat_docs, join, LOGGER, get_date
+from ..boxes import Thumbnail, PlatformStatistics
+from ..enums import ThumbnailType
+from .mixins import GetModsMixin, GetGamesMixin, GetEnginesMixin, GetWaresMixin
+from .base import BaseMetaClass
+
+
+@concat_docs
+class Platform(
+    BaseMetaClass,
+    GetModsMixin,
+    GetGamesMixin,
+    GetEnginesMixin,
+    GetWaresMixin,
+):
+    """Represents the platform supporting the game/engines. Game and engines may have mutiple platforms.
+
+    Parameters
+    -----------
+    html : bs4.BeautifulSoup
+        The html to parse. Allows for finer control.
+
+    Filtering
+    ----------
+    released : :class:`.Status`
+        Current status of release (unreleased, early access, ect...)
+
+    Sorting
+    --------
+        * **released** - when the object was released, asc is oldest, desc is most recent
+        * **id** - when it was added to moddb, asc is oldest, desc is most recent
+        * **porder** - what plaform it's on???
+        * **company** - company that made it in alphabetical order
+
+
+    Attributes
+    -----------
+    name : str
+        The name of the platform
+    name_id : str
+        The name_id of the member, cannot be changed, it's a mix of the original username lowercased with
+        spaces removed and shortened.
+    id : int
+        The moddb id of the platform
+    url : str
+        The url of the platform page
+    description : str
+        Description of the platform
+    company : Thumbnail
+        A team like thumbnail of the company.
+    homepage : str
+        Link to the homepage of the engine
+    date : datetime.datetime
+        Date the engine was published
+    stats : PlatformStatistics
+        Stat data on the platform
+    share : dict{str : str}
+        Share link of the platform with the name of the share as key and link of the share as url.
+    comments : CommentList[Comment]
+        Comments on this page
+    games : List[Thumbnail]
+        A list of games suggested on the platform main page.
+    hardware : List[Thumbnail]
+        A list of hardware suggested on the platform main page.
+    software : List[Thumbnail]
+        A list of software suggested on the platform main page.
+    engines : List[Thumbnail]
+        A list of engines suggested on the platform main page.
+    mods : List[Thumbnail]
+        A list of mods suggested on the platform main page.
+    """
+
+    def __init__(self, html):
+        self.name = html.find("a", itemprop="mainEntityOfPage").string
+        self.id = None
+
+        self.url = join(html.find("a", itemprop="mainEntityOfPage")["href"])
+        self.name_id = self.url.split("/")[0]
+        try:
+            self.description = html.find("div", id="profiledescription").p.string
+        except AttributeError:
+            self.description = html.find("p", itemprop="description").string
+
+        try:
+            company = html.find("h5", string="Company").parent.span.a
+            self.company = Thumbnail(
+                name=company.string, url=company["href"], type=ThumbnailType.team
+            )
+        except AttributeError:
+            LOGGER.info("Platform '%s' has no company", self.name)
+            self.company = None
+
+        self.homepage = html.find("h5", string="Homepage").parent.span.a["href"]
+
+        self.date = get_date(html.find("time", itemprop="releaseDate")["datetime"])
+
+        self.stats = PlatformStatistics(html)
+
+        try:
+            share = html.find("h5", string="Share").parent.span.find_all("a")
+            self.share = {
+                "reddit": share[0]["href"],
+                "mail": share[1]["href"],
+                "twitter": share[2]["href"],
+                "facebook": share[3]["href"],
+            }
+        except (AttributeError, IndexError):
+            LOGGER.info("Something funky about share box of platform '%s'", self.name)
+            self.share = None
+
+        self.comments = self._get_comments(html)
+
+        hardware = (
+            html.find("span", string=" Hardware")
+            .parent.parent.parent.find("div", class_="table")
+            .find_all("div", recursive=False)[:-1]
+        )
+        self.hardware = [
+            Thumbnail(
+                name=x.a["title"],
+                url=x.a["href"],
+                type=ThumbnailType.hardware,
+                image=x.a.img["src"],
+            )
+            for x in hardware
+        ]
+
+        software = (
+            html.find("span", string=" Software")
+            .parent.parent.parent.find("div", class_="table")
+            .find_all("div", recursive=False)[:-1]
+        )
+        self.software = [
+            Thumbnail(
+                name=x.a["title"],
+                url=x.a["href"],
+                type=ThumbnailType.software,
+                image=x.a.img["src"],
+            )
+            for x in software
+        ]
+
+        engines = (
+            html.find("span", string=" Engines")
+            .parent.parent.parent.find("div", class_="table")
+            .find_all("div", recursive=False)[:-1]
+        )
+        self.engines = [
+            Thumbnail(
+                name=x.a["title"],
+                url=x.a["href"],
+                type=ThumbnailType.engine,
+                image=x.a.img["src"],
+            )
+            for x in engines
+        ]
+
+        mods = (
+            html.find("span", string=" Mods")
+            .parent.parent.parent.find("div", class_="table")
+            .find_all("div", recursive=False)[:-1]
+        )
+        self.mods = [
+            Thumbnail(
+                name=x.a["title"],
+                url=x.a["href"],
+                type=ThumbnailType.mod,
+                image=x.a.img["src"],
+            )
+            for x in mods
+        ]
+
+        games = (
+            html.find("span", string=" Games")
+            .parent.parent.parent.find("div", class_="table")
+            .find_all("div", recursive=False)[:-1]
+        )
+        self.games = [
+            Thumbnail(
+                name=x.a["title"],
+                url=x.a["href"],
+                type=ThumbnailType.game,
+                image=x.a.img["src"],
+            )
+            for x in games
+        ]
+
+    def __repr__(self):
+        return f"<Platform name={self.name}>"
diff --git a/moddb/pages/ware.py b/moddb/pages/ware.py
new file mode 100644
index 0000000..19938f8
--- /dev/null
+++ b/moddb/pages/ware.py
@@ -0,0 +1,180 @@
+from ..utils import concat_docs, LOGGER
+from .base import HardwareSoftwareMetaClass
+from .mixins import GetGamesMixin, GetWaresMixin
+from ..enums import SearchCategory, ThumbnailType
+from ..boxes import Thumbnail
+
+
+@concat_docs
+class Hardware(HardwareSoftwareMetaClass, GetGamesMixin, GetWaresMixin):
+    """Represents a moddb Hardware page
+
+    Parameters
+    -----------
+    html : BeautifulSoup
+        The html file to parse, allows for finer control
+
+    Filtering
+    ----------
+    released : :class:`.Status`
+        Release status of the hardware (released, unreleased, early access)
+    category : :class:`.HardwareCategory`
+        Category of the hardware
+    timeframe : :class:`.TimeFrame`
+        How long ago the hardware was released
+
+    Sorting
+    --------
+        * **released** - when the object was released, asc is oldest, desc is most recent
+        * **id** - when it was added to moddb, asc is oldest, desc is most recent
+        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
+        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
+        * **rating** - order by rating, asc is highest rating, desc is lowest rating
+        * **category** - sort alphebatically by hardwarecategory
+        * **name** - order alphabetically, asc is a-z, desc is z-a
+        * **dateup** - order by latest update, asc is most recent update first, desc is oldest update first
+
+    Attributes
+    -----------
+    hardware : List[Thumbnail]
+        A list of hardware suggested on the hardware main page.
+    software : List[Thumbnail]
+        A list of software suggested on the hardware main page.
+    games : List[Thumbnail]
+        A list of games suggested on the hardware main page.
+    history : List[Thumbnail]
+        A list of previous iterations of the hardware
+    recommended : List[Thumbnail]
+        A list of recommended hardwares.
+    """
+
+    def __init__(self, html):
+        super().__init__(html)
+        self._type = SearchCategory.hardwares
+
+        try:
+            hardware = (
+                html.find("span", string="Hardware")
+                .parent.parent.parent.find("div", class_="table")
+                .find_all("div", recursive=False)[:-1]
+            )
+            self.hardware = [
+                Thumbnail(
+                    name=x.a["title"],
+                    url=x.a["href"],
+                    type=ThumbnailType.hardware,
+                    image=x.a.img["src"],
+                )
+                for x in hardware
+            ]
+        except AttributeError:
+            LOGGER.info("Hardware '%s' has no hardware", self.name)
+            self.hardware = []
+
+        try:
+            software = (
+                html.find("span", string="Software")
+                .parent.parent.parent.find("div", class_="table")
+                .find_all("div", recursive=False)[:-1]
+            )
+            self.software = [
+                Thumbnail(
+                    name=x.a["title"],
+                    url=x.a["href"],
+                    type=ThumbnailType.software,
+                    image=x.a.img["src"],
+                )
+                for x in software
+            ]
+        except AttributeError:
+            LOGGER.info("Hardware '%s' has no software", self.name)
+            self.software = []
+
+        try:
+            games = (
+                html.find("span", string="Games")
+                .parent.parent.parent.find("div", class_="table")
+                .find_all("div", recursive=False)[:-1]
+            )
+            self.games = [
+                Thumbnail(
+                    name=x.a["title"],
+                    url=x.a["href"],
+                    type=ThumbnailType.game,
+                    image=x.a.img["src"],
+                )
+                for x in games
+            ]
+        except AttributeError:
+            LOGGER.info("Hardware '%s' has no games", self.name)
+            self.games = []
+
+        try:
+            history = html.find("span", string="History").parent.parent.parent.find_all(
+                "a", class_="image"
+            )
+            self.history = [
+                Thumbnail(
+                    url=x["href"],
+                    name=x["title"],
+                    type=ThumbnailType.hardware,
+                    image=x.img["src"],
+                )
+                for x in history
+            ]
+        except AttributeError:
+            LOGGER.info("Harware '%s' has no history", self.name)
+            self.history = []
+
+        try:
+            recommended = html.find(
+                "span", string="Recommended"
+            ).parent.parent.parent.find_all("a", class_="image")
+            self.recommended = [
+                Thumbnail(
+                    url=x["href"],
+                    name=x["title"],
+                    type=ThumbnailType.hardware,
+                    image=x.img["src"],
+                )
+                for x in recommended
+            ]
+        except AttributeError:
+            LOGGER.info("Hardware '%s' has no recommended", self.name)
+            self.recommended = []
+
+
+@concat_docs
+class Software(HardwareSoftwareMetaClass):
+    """Represents a moddb Software page
+
+    Parameters
+    -----------
+    html : BeautifulSoup
+        The html file to parse, allows for finer control
+
+    Filtering
+    ----------
+    released : :class:`.Status`
+        Release status of the software (released, unreleased, early access)
+    category : :class:`.SoftwareCategory`
+        Category of the software
+    timeframe : :class:`.TimeFrame`
+        How long ago the software was released
+
+    Sorting
+    --------
+        * **released** - when the object was released, asc is oldest, desc is most recent
+        * **id** - when it was added to moddb, asc is oldest, desc is most recent
+        * **ranktoday** - order by daily ranking, asc is highest ranked, desc is lowest rank
+        * **visitstotal** - order by most views, asc is highest views, desc is lowest views
+        * **rating** - order by rating, asc is highest rating, desc is lowest rating
+        * **category** - sort alphebatically by softwarecategory
+        * **name** - order alphabetically, asc is a-z, desc is z-a
+        * **dateup** - order by latest update, asc is most recent update first, desc is oldest update first
+
+    """
+
+    def __init__(self, html):
+        super().__init__(html)
+        self._type = SearchCategory.softwares
diff --git a/moddb/utils.py b/moddb/utils.py
index 3707398..dd6c22d 100644
--- a/moddb/utils.py
+++ b/moddb/utils.py
@@ -1,8 +1,10 @@
 from .enums import ThumbnailType
+from .errors import ModdbException, AwaitingAuthorisation
 
 import re
 import sys
 import uuid
+import json
 import random
 import inspect
 import logging
@@ -11,68 +13,76 @@
 from typing import Tuple
 from urllib.parse import urljoin
 from bs4 import BeautifulSoup, Tag
+from pyrate_limiter import Duration, Limiter, RequestRate
 
 LOGGER = logging.getLogger("moddb")
 BASE_URL = "https://www.moddb.com"
+LIMITER = Limiter(
+    # request stuff slowly, like a human
+    RequestRate(1, Duration.SECOND * 5),
+    # take breaks when requesting stuff, like a human
+    RequestRate(30, Duration.MINUTE * 5),
+)
 
 time_mapping = {
-    "year" : 125798400,
+    "year": 125798400,
     "month": 2419200,
     "week": 604800,
     "day": 86400,
     "hour": 3600,
     "minute": 60,
-    "econd": 1
+    "econd": 1,
 }
 
 user_agent_list = [
-    #Chrome
-    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36',
-    'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
-    'Mozilla/5.0 (Windows NT 5.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
-    'Mozilla/5.0 (Windows NT 6.2; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
-    'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.157 Safari/537.36',
-    'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36',
-    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36',
-    'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36',
-    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 Safari/537.36',
-    'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 Safari/537.36',
-    #Firefox
-    'Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)',
-    'Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko',
-    'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
-    'Mozilla/5.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko',
-    'Mozilla/5.0 (Windows NT 6.2; WOW64; Trident/7.0; rv:11.0) like Gecko',
-    'Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko',
-    'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.0; Trident/5.0)',
-    'Mozilla/5.0 (Windows NT 6.3; WOW64; Trident/7.0; rv:11.0) like Gecko',
-    'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)',
-    'Mozilla/5.0 (Windows NT 6.1; Win64; x64; Trident/7.0; rv:11.0) like Gecko',
-    'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)',
-    'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0)',
-    'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; .NET CLR 2.0.50727; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729)'
+    # Chrome
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 5.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 6.2; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36",
+    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.157 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 Safari/537.36",
+    # Firefox
+    "Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)",
+    "Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko",
+    "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)",
+    "Mozilla/5.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko",
+    "Mozilla/5.0 (Windows NT 6.2; WOW64; Trident/7.0; rv:11.0) like Gecko",
+    "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko",
+    "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.0; Trident/5.0)",
+    "Mozilla/5.0 (Windows NT 6.3; WOW64; Trident/7.0; rv:11.0) like Gecko",
+    "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)",
+    "Mozilla/5.0 (Windows NT 6.1; Win64; x64; Trident/7.0; rv:11.0) like Gecko",
+    "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)",
+    "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0)",
+    "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; .NET CLR 2.0.50727; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729)",
 ]
 
+
 def concat_docs(cls):
     """Does it look like I'm enjoying this?"""
     attributes = []
 
     def get_docs(parent):
         nonlocal attributes
-        if parent.__name__ == 'object':
+        if parent.__name__ == "object":
             return
 
         docs = parent.__doc__.splitlines()
         if "    Attributes" in docs:
-            attributes = docs[docs.index("    Attributes") + 2:] + attributes
+            attributes = docs[docs.index("    Attributes") + 2 :] + attributes
 
         source = inspect.getsource(parent.__init__)
-        source = source[source.index('):'):]
+        source = source[source.index("):") :]
 
-        if 'super().__init__' in source:
+        if "super().__init__" in source:
             get_docs(parent.__base__)
-        elif '__init__' in source:
-            get_docs(parent.__base__.__base__)            
+        elif "__init__" in source:
+            get_docs(parent.__base__.__base__)
 
     get_docs(cls)
     original = cls.__doc__.splitlines()
@@ -80,16 +90,17 @@ def get_docs(parent):
         original.append("    Attributes")
         original.append("    -----------")
 
-    final = original[:original.index("    Attributes") + 2]
+    final = original[: original.index("    Attributes") + 2]
     final.extend([x for x in attributes if x.strip()])
     cls.__doc__ = "\n".join(final)
 
     return cls
 
-def get_date(d : str) -> datetime.datetime:
-    """A helper function that takes a ModDB string representation of time and returns an equivalent 
+
+def get_date(d: str) -> datetime.datetime:
+    """A helper function that takes a ModDB string representation of time and returns an equivalent
     datetime.datetime object. This can range from a datetime with the full year to
-    second to just a year and a month. 
+    second to just a year and a month.
 
     Parameters
     -----------
@@ -100,30 +111,92 @@ def get_date(d : str) -> datetime.datetime:
     -------
     datetime.datetime
         The datetime object for the given string
-    """ 
+    """
     try:
-        return datetime.datetime.strptime(d[:-3] + d[-2:], '%Y-%m-%dT%H:%M:%S%z')
+        return datetime.datetime.strptime(d[:-3] + d[-2:], "%Y-%m-%dT%H:%M:%S%z")
     except ValueError:
         pass
 
     try:
-        return datetime.datetime.strptime(d, '%Y-%m-%d')
+        return datetime.datetime.strptime(d, "%Y-%m-%d")
     except ValueError:
         pass
 
-    return datetime.datetime.strptime(d, '%Y-%m')
+    return datetime.datetime.strptime(d, "%Y-%m")
+
+
+def prepare_request(req: requests.Request, session):
+    cookies = requests.utils.dict_from_cookiejar(session.cookies)
+
+    if req.cookies is not None:
+        req.cookies = {**req.cookies, **cookies}
+    else:
+        req.cookies = cookies
+
+    req.headers["User-Agent"] = random.choice(user_agent_list)
+
+    prepped = session.prepare_request(req)
+    return prepped
+
 
-def request(url, *, params = {}, post = False):
+def raise_for_status(response):
+    try:
+        text = response.json()
+        if text.get("error", False):
+            LOGGER.error(text["text"])
+            LOGGER.error(response.request.url)
+            LOGGER.error(response.request.body)
+            raise ModdbException(text["text"])
+    except json.decoder.JSONDecodeError:
+        response.raise_for_status()
+
+    if (
+        "is currently awaiting authorisation, which can take a couple of days while a"
+        in response.text.lower()
+    ):
+        raise AwaitingAuthorisation(
+            "This page is still await authorisation and cannot currently be parsed"
+        )
+
+
+def generate_login_cookies(username, password):
+    resp = requests.get(f"{BASE_URL}/members/login")
+    html = soup(resp.text)
+    form = html.find("form", attrs={"name": "membersform"})
+
+    username_input = form.find("input", id="membersusername")
+    botcatcher = form.find("input", type="text", id=False)
+
+    data = {
+        "referer": "",
+        username_input["name"]: username,
+        botcatcher["name"]: "",
+        "password": password,
+        "rememberme": ["1"],
+        "members": "Sign in",
+    }
+
+    login = requests.post(
+        "https://www.moddb.com/members/login",
+        data=data,
+        cookies=resp.cookies,
+        allow_redirects=False,
+    )
+
+    if "freeman" not in login.cookies:
+        raise ValueError(f"Login failed for user {username}")
+
+    return login.cookies
+
+
+@LIMITER.ratelimit("moddb", delay=True)
+def request(req: requests.Request):
     """Helper function to make get/post requests with the current SESSION object.
 
     Parameters
     -----------
-    url : str
-        url to get
-    params : dict
-        A dict of paramaters to be passed along
-    post : bool
-        Whether or not this a post request
+    req : requests.Request
+        The request to perform
 
     Return
     -------
@@ -132,26 +205,21 @@ def request(url, *, params = {}, post = False):
 
     """
     SESSION = sys.modules["moddb"].SESSION
-    cookies = requests.utils.dict_from_cookiejar(SESSION.cookies)
-    if "query" in params:
-        params["query"] = params["query"].replace(" ", "+")
+    prepped = prepare_request(req, SESSION)
+    r = SESSION.send(prepped)
 
-    if post:
-        r = SESSION.post(url, data=params.get("data", {}), cookies=cookies, headers={"User-Agent": random.choice(user_agent_list)})
-    else:
-        r = SESSION.get(url, cookies=cookies, params=params, headers={"User-Agent": random.choice(user_agent_list)})
-
-    r.raise_for_status()
+    raise_for_status(r)
     return r
 
-def soup(html : str) -> BeautifulSoup:
+
+def soup(html: str) -> BeautifulSoup:
     """Simple helper function that takes a string representation of an html page and
     returns a beautiful soup object"""
-    
-    soupd = BeautifulSoup(html, "html.parser")
-    return soupd
 
-def get_page(url : str, *, params : dict = {}):
+    return BeautifulSoup(html, "html.parser")
+
+
+def get_page(url: str, *, params: dict = {}):
     """A helper function that takes a url and returns a beautiful soup objects. This is used to center
     the request making section of the library. Can also be passed a set of paramaters, used for sorting
     and filtering in the search function.
@@ -168,10 +236,11 @@ def get_page(url : str, *, params : dict = {}):
     -------
     bs4.BeautifulSoup
     """
-    r = request(url, params=params)
+    r = request(requests.Request("GET", url, params=params))
     return soup(r.text)
 
-def get_views(string : str) -> Tuple[int, int]:
+
+def get_views(string: str) -> Tuple[int, int]:
     """A helper function that takes a string reresentation of total something and
     daily amount of that same thing and returns both as a tuple of ints.
 
@@ -191,9 +260,10 @@ def get_views(string : str) -> Tuple[int, int]:
 
     return views, today
 
-def join(path : str) -> str:
+
+def join(path: str) -> str:
     """Joins a partial moddb url with the base url and returns the combined url
-    
+
     Parameters
     -----------
     path : str
@@ -205,20 +275,26 @@ def join(path : str) -> str:
         The full url.
 
     """
-    return urljoin(BASE_URL, path)
+    if not path.startswith(BASE_URL):
+        return urljoin(BASE_URL, path)
+
+    return path
 
-def normalize(string : str) -> str:
+
+def normalize(string: str) -> str:
     """Removes all extra fluff from a text to get the barebone content"""
     return string.replace(",", "").replace("members", "").replace("member", "").strip()
 
-def get_type(img : Tag) -> int:
+
+def get_type(img: Tag) -> int:
     """Determines the type of the image through some very hacky stuff, might break"""
     if img is None:
         return 2
     elif img["src"][-8:-5] == ".mp4":
         return 0
     else:
-        return 1 
+        return 1
+
 
 def get_type_from(url):
     """Get the page type based on a url.
@@ -226,7 +302,7 @@ def get_type_from(url):
     Parameters
     -----------
     url : str
-        The url to get 
+        The url to get
 
     Return
     -------
@@ -241,11 +317,11 @@ def get_type_from(url):
         "download": "file",
         "image": "media",
         "audio": " media",
-        "video": "media"
+        "video": "media",
     }
 
     matches = re.findall(regex, url)
-    match = matches[-1][0:-1] if matches[0].endswith("s") else matches[0]      
+    match = matches[-1][0:-1] if matches[0].endswith("s") else matches[0]
 
     try:
         page_type = ThumbnailType[match]
@@ -255,6 +331,7 @@ def get_type_from(url):
     LOGGER.info("%s is type %s", url, page_type)
     return page_type
 
+
 def get_page_number(html):
     """Central function for retrieving the page numbers of result pages
 
@@ -281,12 +358,15 @@ def get_page_number(html):
         page = 1
 
     return page, max_page
-        
+
+
 class Object:
     """A dud objects that will transform every kwarg given into an attribute"""
+
     def __init__(self, **kwargs):
         self.__dict__.update(kwargs)
 
+
 def find(predicate, seq):
     """A helper to return the first element found in the sequence
     that meets the predicate. For example: ::
@@ -314,6 +394,7 @@ def find(predicate, seq):
             return element
     return None
 
+
 def get(iterable, **attrs):
     r"""A helper that returns the first element in the iterable that meets
     all the traits passed in ``attrs``. This is an alternative for
@@ -360,7 +441,7 @@ def get(iterable, **attrs):
 
     def predicate(elem):
         for attr, val in attrs.items():
-            nested = attr.split('__')
+            nested = attr.split("__")
             obj = elem
             for attribute in nested:
                 obj = getattr(obj, attribute)
@@ -371,5 +452,6 @@ def predicate(elem):
 
     return find(predicate, iterable)
 
+
 def generate_hash():
     return uuid.uuid4().hex
diff --git a/requirements.txt b/requirements.txt
index 2e949b0..416e39b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,15 +1,11 @@
-# moddb\pages.py: 8
-# moddb\utils.py: 8
-# test.py: 4
 beautifulsoup4 == 4.6.3
-
-# moddb\__init__.py: 7
-# moddb\utils.py: 5
 requests>=2.20.0
+toolz==0.11.2
+pyrate-limiter==2.8.1
 
-# moddb\base.py: 9
-robobrowser == 0.5.3
-
-# setup.py: 3
 sphinx == 1.8.3
 sphinx-autodoc-typehints == 1.6.0
+
+pytest==7.1.2
+pytest_html==3.1.1
+pytest-rerunfailures==10.2
diff --git a/scripts/data/enums_base.txt b/scripts/data/enums_base.txt
new file mode 100644
index 0000000..bd218f1
--- /dev/null
+++ b/scripts/data/enums_base.txt
@@ -0,0 +1,167 @@
+import enum
+
+
+class SearchCategory(enum.Enum):
+    """The list of things you can search for"""
+
+    games = 0
+    mods = 1
+    addons = 2
+    downloads = 3
+    videos = 4
+    articles = 5
+    engines = 6
+    developers = 7
+    groups = 8
+    forum = 9
+    jobs = 10
+    images = 11
+    audio = 12
+    reviews = 13
+    headlines = 14
+    blogs = 15
+    hardwares = 16
+    softwares = 17
+    members = 18
+    news = articles
+    tutorials = articles
+    companys = developers
+    features = articles
+
+
+class Category(enum.Enum):
+    """Enum for the different areas of the site which things can be attached to"""
+
+    mods = "mods"
+    games = "games"
+    engines = "engines"
+    downloads = "downloads"
+
+
+class RSSType(enum.Enum):
+    """Enum to define the type of RSS you want to get from this page"""
+
+    articles = 0
+    downloads = 1
+    images = 2
+    videos = 3
+    tutorials = 4
+    reviews = 5
+    addons = 6
+    blogs = 7
+    headlines = 8
+    news = 9
+    audio = 10
+    jobs = 11
+    poll = 12
+
+
+class MediaCategory(enum.Enum):
+    """What category a media object is, use for read purposes."""
+
+    video = 0
+    image = 1
+    audio = 2
+
+class ArticleCategory(enum.Enum):
+    """Category of the article"""
+
+    news = 1
+    features = 2
+    tutorials = 4
+
+class Membership(enum.Enum):
+    """Member ship settings of Groups and Teams"""
+
+    invitation = 1
+    application = 2
+    open_to_all = 3
+
+
+class TeamCategory(enum.IntFlag):
+    """Category of companies, either publisher, developer or both"""
+
+    def __repr__(self):
+        cls = self.__class__
+        if self._name_ is not None:
+            return self._name_
+        members, uncovered = enum._decompose(cls, self._value_)
+        return " & ".join([str(m._name_ or m._value_) for m in members])
+
+    __str__ = __repr__
+
+    developer = 3
+    publisher = 4
+
+class ThumbnailType(enum.Enum):
+    """The various types of thunbails that can be created"""
+
+    mod = 0
+    game = 1
+    engine = 2
+    member = 3
+    group = 4
+    article = 5
+    review = 6
+    team = 7
+    blog = 8
+    addon = 9
+    file = 10
+    job = 11
+    platform = 12
+    media = 13
+    software = 14
+    hardware = 15
+    company = team
+
+class PlayerStyle(enum.IntFlag):
+    """The player style of the game"""
+
+    def __repr__(self):
+        cls = self.__class__
+        if self._name_ is not None:
+            return self._name_
+        members, uncovered = enum._decompose(cls, self._value_)
+        return "|".join([str(m._name_ or m._value_) for m in members])
+
+    __str__ = __repr__
+
+    singleplayer = 1
+    multiplayer = 2
+    coop = 4
+    mmo = 8
+
+class TimeFrame(enum.Enum):
+    """How recently the page was updated/uploaded, 24 hours, last week, last month, ect..."""
+
+    day = 1
+    week = 2
+    month = 3
+    year = 4
+    more = 5
+
+class WatchType(enum.Enum):
+    mod = 0
+    game = 1
+    engine = 2
+    group = 3
+    member = 4
+
+class Month(enum.Enum):
+    january = "01"
+    february = "02"
+    march = "03"
+    april = "04"
+    may = "05"
+    june = "06"
+    july = "07"
+    august = "08"
+    september = "09"
+    october = "10"
+    november = "11"
+    december = "12"
+
+# BELOW THIS LINE ENUMS ARE GENERATED AUTOMATICALLY
+# PR changes to scripts/generate_enums.py if you want to
+# change something
+
diff --git a/scripts/generate_enums.py b/scripts/generate_enums.py
new file mode 100644
index 0000000..91b4160
--- /dev/null
+++ b/scripts/generate_enums.py
@@ -0,0 +1,148 @@
+import bs4
+import requests
+import re
+import time
+import logging
+
+logging.basicConfig(level=logging.INFO)
+
+enum_dict = [
+    {
+        "name": "SoftwareCategory",
+        "url": "https://www.moddb.com/software",
+        "doc": "The category of the software",
+        "key": "category"
+    },
+    {
+        "name": "Difficulty",
+        "url": "https://www.moddb.com/tutorials",
+        "doc": "Difficulty of the tutorial",
+        "key": "meta"
+    },
+    {
+        "name": "TutorialCategory",
+        "url": "https://www.moddb.com/tutorials",
+        "doc": "Skill covered by the tutorial",
+        "key": "subtype"
+    },
+    {
+        "name": "GroupCategory",
+        "url": "https://www.moddb.com/groups",
+        "doc": "Category of fan groups",
+        "key": "category"
+    },
+    {
+        "name": "AddonCategory",
+        "url": "https://www.moddb.com/addons",
+        "doc": "Category of addons",
+        "key": "category"
+    },
+    {
+        "name": "Licence",
+        "url": "https://www.moddb.com/addons",
+        "doc": "The licence of the object",
+        "key": "licence"
+    },
+    {
+        "name": "Status",
+        "url": "https://www.moddb.com/mods",
+        "doc": "Status of a page",
+        "key": "released"
+    },
+    {
+        "name": "Theme",
+        "url": "https://www.moddb.com/mods",
+        "doc": "Theme of the page",
+        "key": "theme"
+    },
+    {
+        "name": "Genre",
+        "url": "https://www.moddb.com/mods",
+        "doc": "Genre of the page",
+        "key": "genre"
+    },
+    {
+        "name": "Scope",
+        "url": "https://www.moddb.com/games",
+        "doc": "Scope of the game",
+        "key": "indie"
+    },
+    {
+        "name": "FileCategory",
+        "url": "https://www.moddb.com/downloads",
+        "doc": "The category of the File",
+        "key": "category"
+    },
+    {
+        "name": "JobSkill",
+        "url": "https://www.moddb.com/jobs",
+        "doc": "The skill required for a job",
+        "key": "skill"
+    },
+    {
+        "name": "HardwareCategory",
+        "url": "https://www.moddb.com/hardware",
+        "doc": "The category of the hardware",
+        "key": "category"
+    },
+    {
+        "name": "SoftwareCategory",
+        "url": "https://www.moddb.com/software",
+        "doc": "The category of the software",
+        "key": "category"
+    },
+]
+
+def generate_member_list(html, enum):
+    soup = bs4.BeautifulSoup(html, "html.parser")
+    selects = soup.find("select", attrs={"name": enum["key"]}).find_all("option")
+    max_size = 0
+
+    names = []
+    members = []
+    for select in selects:
+        if not select["value"]:
+            continue
+        
+        name_raw = select.string.lower().replace("-", "").strip().replace("3d", "d3").replace("2d", "d2").replace("4x", "x4")
+        name = re.sub(r'[\s\&\/\',]+', '_', name_raw)
+
+        if name in names:
+            name = f'sub_{name}'
+        
+        members.append((int(select["value"]), name))
+        names.append(name)
+        max_size = len(name) if len(name) > max_size else max_size
+
+    logging.info("%s: size %s max size %s", enum["name"], len(members), max_size)
+    logging.debug("%s: %s", len(selects), selects)
+    return members, max_size
+
+def generate_string(members, max_size, enum):
+    string = f'class {enum["name"]}(enum.Enum):\n'
+    string += f'    """{enum["doc"]}"""\n\n'
+    for member in members:
+        string += f"    {member[1]}{' '*(max_size - len(member[1]))} = {member[0]}\n"
+
+    string += "\n\n"
+
+    return string
+
+def generate_enums(enums):
+    string = "# GENERATED AUTOMATICALLY, DO NOT EDIT\n"
+    with open("scripts/data/enums_base.txt", "r") as f:
+        string += f.read()
+    
+    for enum in enums:
+        r = requests.get(enum["url"])
+        members, max_size = generate_member_list(r.text, enum)
+        string += generate_string(members, max_size, enum)
+        time.sleep(15)
+
+    with open("moddb/enums.py", "w") as f:
+        f.write(string)
+
+
+if __name__ == '__main__':
+    generate_enums(enum_dict)
+
diff --git a/setup.py b/setup.py
index 5a448c5..e02927b 100644
--- a/setup.py
+++ b/setup.py
@@ -1,20 +1,33 @@
 #!/usr/bin/env python
 
-from setuptools import setup
+from setuptools import setup, find_packages
+import re
 
+version = ''
+with open('moddb/__init__.py') as f:
+    version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE).group(1)
+
+if not version:
+    raise RuntimeError('version is not set')
+
+readme = ''
+with open('README.md') as f:
+    readme = f.read()
+  
 setup(
     name = "moddb",
-    version = "0.7",
+    version = version,
     description = "A scrapper for ModDB Mod and Game pages",
     author = "Clement Julia",
     author_email = "clement.julia13@gmail.com",
     url = "https://github.com/ClementJ18/moddb",
-    packages = ["moddb"],
+    long_description_content_type="text/markdown",
+    long_description=readme,
+    packages=find_packages(include=['moddb', 'moddb.*']),
     install_requires=[
         'beautifulsoup4 == 4.6.3',
         'requests>=2.20.0',
-        'robobrowser == 0.5.3',
-        'feedparser == 5.2.1',
-        'toolz == 0.9.0',
+        'toolz == 0.11.2',
+        'pyrate-limiter==2.8.1'
       ]
     )
\ No newline at end of file


Test patch:
diff --git a/run_tests.sh b/run_tests.sh
new file mode 100755
index 0000000..56df26a
--- /dev/null
+++ b/run_tests.sh
@@ -0,0 +1,6 @@
+if [ ${1:-not_full} == "full" ]
+then
+    pytest tests/test_main.py --log-level="INFO" --reruns 3 --reruns-delay 15 --html=report.html --self-contained-html "${@:2}"
+else
+    pytest -k "not test_main and not test_client" --log-level="INFO" --reruns 3 --reruns-delay 15 --html=report.html --self-contained-html "$@"
+fi
diff --git a/test/test_addon.py b/test/test_addon.py
deleted file mode 100644
index 157c574..0000000
--- a/test/test_addon.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import unittest
-import moddb
-
-class TestAddon(unittest.TestCase):
-    def setUp(self):
-        self.addon = moddb.pages.Addon(moddb.get_page(getattr(self, "url", "https://www.moddb.com/games/grand-theft-auto-san-andreas/addons/superman-sa-beta-v10")))
-
-    def test_get_comments(self):
-        self.addon.get_comments()
-        self.addon.get_comments(5)
diff --git a/test/test_article.py b/test/test_article.py
deleted file mode 100644
index a7757c3..0000000
--- a/test/test_article.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import unittest
-import moddb
-
-class TestArticle(unittest.TestCase):
-    def setUp(self):
-        self.article = moddb.pages.Article(moddb.get_page(getattr(self, "url", "https://www.moddb.com/mods/third-age-total-war/videos/rohan7")))
-
-    def test_get_comments(self):
-        self.article.get_comments()
-        self.article.get_comments(4)
diff --git a/test/test_base.py b/test/test_base.py
deleted file mode 100644
index c20fdad..0000000
--- a/test/test_base.py
+++ /dev/null
@@ -1,55 +0,0 @@
-import unittest
-import moddb
-
-from test.test_config import username, password
-
-class TestFrontPage(unittest.TestCase):
-    def setUp(self):
-        self.fp = moddb.front_page()
-
-    def get_articles(self):
-        for article in self.fp.articles:
-            article.parse()
-
-    def get_games(self):
-        for game in self.fp.games:
-            game.parse()
-
-    def get_files(self):
-        for file in self.fp.files:
-            file.parse()
-
-class TestSearch(unittest.TestCase):
-    def setUp(self):
-        cat = getattr(self, "category", moddb.SearchCategory.mods)
-        self.search= moddb.search(cat)
-
-    def test_resort(self):
-        results = self.search.results
-        search2 = self.search.resort(("visitstotal", "asc"))
-        self.assertNotEqual(results, search2.results)
-
-    def test_next_page(self):
-        self.search.next_page()
-
-    def test_previous_pages(self):
-        search = self.search.next_page()
-        search.previous_page()
-
-class TestParse(unittest.TestCase):
-    def setUp(self):
-        self.model = moddb.parse(getattr(self, "url", "https://www.moddb.com/mods/edain-mod"))
-
-    def test_check(self):
-        pass
-
-class TestLogin(unittest.TestCase):
-    def test_login(self):
-        moddb.login(username, password)
-
-    def test_fake_login(self):
-        with self.assertRaises(ValueError):
-            moddb.login("tico", "ticoisgod")
-
-    def tearDown(self):
-        moddb.SESSION.close()
diff --git a/test/test_client.py b/test/test_client.py
deleted file mode 100644
index 8c1f362..0000000
--- a/test/test_client.py
+++ /dev/null
@@ -1,61 +0,0 @@
-import unittest
-import moddb
-import random
-
-from test.test_config import username, password, sender_username, sender_password
-
-class TestClient(unittest.TestCase):
-    def setUp(self):
-        self.client = moddb.Client(username, password)
-        self.sender = moddb.Client(sender_username, sender_password)
-
-    def test_get_watched(self):
-        for _, value in moddb.WatchType.__members__:
-            self.client.get_watched(value)
-
-    def test_get_updates(self):
-        updates = self.client.get_updates()
-        random.choice(updates).clear()
-
-    def test_posts(self):
-        urls = [
-            "https://www.moddb.com/mods/edain-mod",
-            "https://www.moddb.com/mods/the-horse-lords-a-total-modification-for-bfme",
-            "https://www.moddb.com/mods/third-age-total-war",
-            "https://www.moddb.com/games/battle-for-middle-earth-ii-rise-of-the-witch-king",
-            "https://www.moddb.com/games/battle-for-middle-earth-ii",
-            "https://www.moddb.com/games/battle-for-middle-earth",
-            "https://www.moddb.com/engines/sage-strategy-action-game-engine",
-            "https://www.moddb.com/engines/source",
-            "https://www.moddb.com/engines/cryengine-3",
-            "https://www.moddb.com/groups/humour-satire-parody",
-            "https://www.moddb.com/groups/star-wars-empire-at-war-forces-of-corruption-mods",
-            "https://www.moddb.com/groups/warhammer-40k-fans-group",
-            "https://www.moddb.com/members/na3703168",
-            "https://www.moddb.com/members/barneypan",
-            "https://www.moddb.com/members/officialnecro"
-        ]
-
-        for url in urls:
-            e = moddb.parse(url)
-            self.client.tracking(e) #follow
-            self.client.tracking(e) #unfollow
-
-            comment = random.choice(e.comments.flatten())
-            self.client.like_comment(comment)
-            self.client.dislike_comment(comment)
-
-            if isinstance(e, moddb.Group):
-                self.client.membership(e) #join
-                self.client.membership(e) #leave
-
-    def test_friends(self):
-        self.sender.send_request(self.client.member)
-        request = moddb.utils.get(self.client.get_friend_requests(), name=self.sender.member.username)
-        request.decline()
-
-        self.sender.send_request(self.client.member)
-        request = moddb.utils.get(self.client.get_friend_requests(), name=self.sender.member.username)
-        request.accept()
-
-        self.client.unfriend(self.sender.member)
diff --git a/test/test_file.py b/test/test_file.py
deleted file mode 100644
index 64b30f2..0000000
--- a/test/test_file.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import unittest
-import moddb
-
-class TestFile(unittest.TestCase):
-    def setUp(self):
-        self.file = moddb.pages.File(moddb.get_page(getattr(self, "url", "https://www.moddb.com/games/pokemon-generations/downloads/pokegen-v2-launcher")))
-
-    def test_get_comments(self):
-        self.file.get_comments()
-        self.file.get_comments(5)
diff --git a/test/test_job.py b/test/test_job.py
deleted file mode 100644
index 83efffa..0000000
--- a/test/test_job.py
+++ /dev/null
@@ -1,9 +0,0 @@
-import unittest
-import moddb
-
-class TestJob(unittest.TestCase):
-    def setUp(self):
-        self.job = moddb.pages.Job(moddb.get_page(getattr(self, "url", "https://www.moddb.com/jobs/programmer-enviro-artist-audio-specialist-needed-to-expand-our-launched-game")))
-
-    def test_author_parse(self):
-        self.job.author.parse()
\ No newline at end of file
diff --git a/test/test_media.py b/test/test_media.py
deleted file mode 100644
index 6ee5fa9..0000000
--- a/test/test_media.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import unittest
-import moddb
-
-class TestMedia(unittest.TestCase):
-    def setUp(self):
-        self.media = moddb.pages.Media(moddb.get_page(getattr(self, "url", "https://www.moddb.com/mods/third-age-total-war/videos/rohan7")))
-
-    def test_get_comments(self):
-        self.media.get_comments()
-        self.media.get_comments(4)
diff --git a/test/test_parse.py b/test/test_parse.py
deleted file mode 100644
index 10fc570..0000000
--- a/test/test_parse.py
+++ /dev/null
@@ -1,300 +0,0 @@
-import unittest
-import moddb
-
-import logging
-logger = logging.getLogger('moddb')
-logger.setLevel(logging.DEBUG)
-handler = logging.FileHandler(filename='moddb.log', encoding='utf-8', mode='w')
-handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s'))
-logger.addHandler(handler)
-
-class TestParsers(unittest.TestCase):
-    @classmethod
-    def setUpClass(cls):
-        cls.mods = []
-        cls.games = []
-        cls.engines = []
-        cls.members = []
-
-    def test_parse_mods(self):
-        urls = [
-            "https://www.moddb.com/mods/edain-mod",
-            "https://www.moddb.com/mods/the-horse-lords-a-total-modification-for-bfme",
-            "https://www.moddb.com/mods/third-age-total-war",
-            "https://www.moddb.com/mods/ilty-re-back-to-earth",
-            "https://www.moddb.com/mods/armory-world-of-warcraft",
-            "https://www.moddb.com/mods/perisno",
-            "https://www.moddb.com/mods/middle-earth-at-war",
-            "https://www.moddb.com/mods/zeruel87-mod"
-        ]
-
-        for url in urls:
-            print(url)
-            soup = moddb.utils.get_page(url)
-            self.mods.append(moddb.pages.Mod(soup))
-
-    def test_parse_games(self):
-        urls = [
-            "https://www.moddb.com/games/mount-blade-warband",
-            "https://www.moddb.com/games/minecraft",
-            "https://www.moddb.com/games/zombie-treat",
-            "https://www.moddb.com/games/spellhack",
-            "https://www.moddb.com/games/half-life",
-            "https://www.moddb.com/games/diablo-2-lod",
-            "https://www.moddb.com/games/car-chase-simulator",
-            "https://www.moddb.com/games/project-harpy",
-            "https://www.moddb.com/games/fated-kingdom"
-        ]
-
-        for url in urls:
-            print(url)
-            soup = moddb.utils.get_page(url)
-            self.games.append(moddb.pages.Game(soup))
-
-    def test_parse_engines(self):
-        urls = [
-            "https://www.moddb.com/engines/source",
-            "https://www.moddb.com/engines/cryengine-3",
-            "https://www.moddb.com/engines/unity",
-            "https://www.moddb.com/engines/simmetri",
-            "https://www.moddb.com/engines/limon-engine",
-            "https://www.moddb.com/engines/unknown",
-            "https://www.moddb.com/engines/shmup-creator",
-            "https://www.moddb.com/engines/limon-engine"
-        ]
-
-        for url in urls:
-            print(url)
-            soup = moddb.utils.get_page(url)
-            self.engines.append(moddb.pages.Engine(soup))
-
-    def test_parse_files(self):
-        urls = [
-            "https://www.moddb.com/games/pokemon-generations/downloads/pokegen-v2-launcher",
-            "https://www.moddb.com/mods/hidden-source/downloads/hidden-source-beta-4b",
-            "https://www.moddb.com/mods/third-age-total-war/downloads/third-age-total-war-30-part-1of2",
-            "https://www.moddb.com/groups/scgames/downloads/bottom-defender-101",
-            "https://www.moddb.com/games/shifting-dungeons/downloads/shifting-dungeons-update-1-mac-osx",
-            "https://www.moddb.com/games/grofast-industries/downloads/linux-offline-version",
-            "https://www.moddb.com/mods/cleric-mod-for-legend-farewell-edition-83/downloads/legend-farewell-edition-833-ultimate",
-            "https://www.moddb.com/games/na1549814307/downloads/gdz-simulator",
-            "https://www.moddb.com/mods/legend-farewell-edition/downloads/mercenary-dark-power"
-        ]
-
-        for url in urls:
-            print(url)
-            soup = moddb.utils.get_page(url)
-            try:
-                moddb.pages.File(soup)
-            except ValueError as e:
-                print(e)
-
-    def test_parse_addons(self):
-        urls = [
-            "https://www.moddb.com/games/grand-theft-auto-san-andreas/addons/superman-sa-beta-v10",
-            "https://www.moddb.com/mods/brutal-doom/addons/project-brutality",
-            "https://www.moddb.com/games/sudoku-e-sport-world-cup-2017/addons/collector-files",
-            "https://www.moddb.com/addons/hl2dm-20b",
-            "https://www.moddb.com/mods/call-of-chernobyl/addons/doctorx-dynamic-faction-relations",
-            "https://www.moddb.com/mods/call-of-chernobyl/addons/arsenal-overhaul",
-            "https://www.moddb.com/games/cc-generals-zero-hour/addons/usa-challenge",
-            "https://www.moddb.com/games/axis-allies/addons/simple-vanilla-sai-buff"
-        ]
-
-        for url in urls:
-            print(url)
-            soup = moddb.utils.get_page(url)
-            moddb.pages.Addon(soup)
-
-    def test_parse_medias(self):
-        urls = [
-            "https://www.moddb.com/mods/third-age-total-war/videos/rohan7",
-            "https://www.moddb.com/games/erie/videos/pewdiepie-approved",
-            "https://www.moddb.com/games/the-last-tinker-city-of-colors/videos/movie-max-1",
-            "https://www.moddb.com/games/dawns-light/videos/movie-max-1",
-            "https://www.moddb.com/mods/doom-exp/videos/doom-exp-15g-flamethrower",
-            "https://www.moddb.com/mods/saxondragon/videos/prophesy-of-pendor-39-official-trailer",
-            "https://www.moddb.com/mods/extreme-vs-full-boost-mod/images/new-dlc-released",
-            "https://www.moddb.com/mods/third-age-total-war/images/the-gates-of-argonath",
-            "https://www.moddb.com/games/panoramical/images/screenshot2",
-            "https://www.moddb.com/games/grave-matters/images/image-3",
-            "https://www.moddb.com/mods/calradia-1417/images/new-khergit-heavy-armor1",
-            "https://www.moddb.com/mods/calradia-1417/images/new-khergit-armors-will-be-included-in-the-next-patch",
-            "https://www.moddb.com/mods/tiberium-essence/videos/vlado32-fields-of-green",
-            "https://www.moddb.com/mods/goldeneye-source/videos/podcast-17-v41-interview-with-ges",
-            "https://www.moddb.com/mods/tiberium-essence/videos/vlado32-fields-of-green",
-            "https://www.moddb.com/mods/goldeneye-source/videos/podcast-17-v41-interview-with-ges",
-            "https://www.moddb.com/mods/war-of-the-gods-wrath-of-egypt/videos/the-mummy-returns-complete-score-23-leaving-clues-sandcastles",
-            "https://www.moddb.com/mods/recovered-operations/videos/recoil-tension"
-        ]
-
-        for url in urls:
-            print(url)
-            soup = moddb.utils.get_page(url)
-            moddb.pages.Media(soup)
-
-    def test_parse_articles(self):
-        urls = [
-            "https://www.moddb.com/mods/the-consequence-of-infection/news/whats-happening-to-this-project-in-2019",
-            "https://www.moddb.com/mods/project-zer0/news/new-intro-wip",
-            "https://www.moddb.com/news/the-challenge-of-adblock",
-            "https://www.moddb.com/groups/editors/news/d2multires",
-            "https://www.moddb.com/games/patient-2045/news/patient-2045-devlog-1-january-2019",
-            "https://www.moddb.com/news/dungeon-siege-skinning-contest",
-            "https://www.moddb.com/games/neon-knigh/tutorials/neon-knight-manual",
-            "https://www.moddb.com/games/fear/tutorials/alma1",
-            "https://www.moddb.com/games/katharsis/news/katharsis-devlog-11"
-        ]  
-
-        for url in urls:
-            print(url)
-            soup = moddb.utils.get_page(url)
-            moddb.pages.Article(soup)  
-
-    def test_parse_groups(self):
-        urls = [
-            "https://www.moddb.com/groups/humour-satire-parody",
-            "https://www.moddb.com/groups/star-wars-empire-at-war-forces-of-corruption-mods",
-            "https://www.moddb.com/groups/warhammer-40k-fans-group",
-            "https://www.moddb.com/groups/tanks",
-            "https://www.moddb.com/groups/moddb",
-            "https://www.moddb.com/groups/dreamhack-activities",
-            "https://www.moddb.com/groups/indie-game-contest-strasbourg-2016",
-            "https://www.moddb.com/groups/custom-night",
-            "https://www.moddb.com/groups/2013-mod-of-the-year-awards"
-        ]  
-
-        for url in urls:
-            print(url)
-            soup = moddb.utils.get_page(url)
-            moddb.pages.Group(soup)
-
-    def test_parse_teams(self):
-        urls = [
-            "https://www.moddb.com/company/wallace2anjos",
-            "https://www.moddb.com/company/valve",
-            "https://www.moddb.com/company/draignet",
-            "https://www.moddb.com/company/left-4-dead-mods",
-            "https://www.moddb.com/company/taleworlds",
-            "https://www.moddb.com/company/belaruso500",
-            "https://www.moddb.com/company/v1-infinity"
-        ]
-
-        for url in urls:
-            print(url)
-            soup = moddb.utils.get_page(url)
-            moddb.pages.Team(soup) 
-
-    def test_parse_jobs(self):
-        urls = [
-            "https://www.moddb.com/jobs/programmer-enviro-artist-audio-specialist-needed-to-expand-our-launched-game",
-            "https://www.moddb.com/jobs/looking-for-3d-modeller-for-fast-past-shooting-game1",
-            "https://www.moddb.com/jobs/revshareroyalty-3d-character-artist",
-            "https://www.moddb.com/jobs/seeking-2d-uiux-artist-3d-weapon-modler-character-animator",
-            "https://www.moddb.com/jobs/2d-top-down-shooter-artistanimator",
-            "https://www.moddb.com/jobs/player-modeler-modeler1"
-        ] 
-
-        for url in urls:
-            print(url)
-            soup = moddb.utils.get_page(url)
-            moddb.pages.Job(soup)
-
-    def test_parse_members(self):
-        urls = [
-            "https://www.moddb.com/members/mladen1996",
-            "https://www.moddb.com/members/na3703168",
-            "https://www.moddb.com/members/barneypan",
-            "https://www.moddb.com/members/officialnecro",
-            "https://www.moddb.com/members/yizhang",
-            "https://www.moddb.com/members/thibault60",
-            "https://www.moddb.com/members/intense",
-            "https://www.moddb.com/members/hunter",
-            "https://www.moddb.com/members/upstart",
-        ]
-
-        for url in urls:
-            print(url)
-            soup = moddb.utils.get_page(url)
-            self.members.append(moddb.pages.Member(soup))
-
-    def test_parse_front_page(self):
-        moddb.front_page()
-
-    def test_parse_platforms(self):
-        urls = [
-            "https://www.moddb.com/platforms/pc",
-            "https://www.moddb.com/platforms/mac",
-            "https://www.moddb.com/platforms/mac",
-            "https://www.moddb.com/platforms/mac",
-            "https://www.moddb.com/platforms/ar"
-        ]
-
-        for url in urls:
-            print(url)
-            soup = moddb.utils.get_page(url)
-            moddb.pages.Platform(soup)
-
-    def test_parse_hardware(self):
-        urls = [
-            "https://www.moddb.com/hardware/htc-vive",
-            "https://www.moddb.com/hardware/jaunt-vr",
-            "https://www.moddb.com/hardware/the-free-space-omni",
-            "https://www.moddb.com/hardware/sprintr",
-            "https://www.moddb.com/hardware/osvr",
-            "https://www.moddb.com/hardware/oculus-rift"
-        ]
-
-        for url in urls:
-            print(url)
-            soup = moddb.utils.get_page(url)
-            moddb.pages.Hardware(soup)
-
-    def test_parse_software(self):
-        urls = [
-            "https://www.moddb.com/software/project-neptune-vr",
-            "https://www.moddb.com/software/kinecttovr",
-            "https://www.moddb.com/software/harfang-3d",
-            "https://www.moddb.com/software/tilt-brush",
-            "https://www.moddb.com/software/virtual-desktop",
-            "https://www.moddb.com/software/project-neptune-vr",
-            "https://www.moddb.com/software/geovisionary"
-        ]
-
-        for url in urls:
-            print(url)
-            soup = moddb.utils.get_page(url)
-            moddb.pages.Software(soup)
-
-    def test_parse_polls(self):
-        urls = [
-            "https://www.moddb.com/polls/total-conversions-vs-cosmetic-mods",
-            "https://www.moddb.com/polls/if-you-had-to-choose-which-feature-would-you-like-implemented-the-most",
-            "https://www.moddb.com/polls/default-v5-theme-should-be",
-            "https://www.moddb.com/polls/what-is-your-plan-for-2017",
-            "https://www.moddb.com/polls/should-moddb-focus-on-mods-only",
-            "https://www.moddb.com/polls/prices-in-game-profiles"
-        ]
-
-        for url in urls:
-            print(url)
-            soup = moddb.utils.get_page(url)
-            moddb.pages.Poll(soup)
-
-    def test_parse_reviews(self):
-        for mod in self.mods:
-            print(mod.url)
-            mod.get_reviews()
-
-        for game in self.games:
-            print(game.url)
-            game.get_reviews()
-
-        for engine in self.engines:
-            print(engine.url)
-            engine.get_reviews()
-
-    def test_parse_blogs(self):
-        for member in self.members:
-            print(member.url)
-            member.get_blogs()
diff --git a/test/test_poll.py b/test/test_poll.py
deleted file mode 100644
index 3d40584..0000000
--- a/test/test_poll.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import unittest
-import moddb
-
-class TestPoll(unittest.TestCase):
-    def setUp(self):
-        self.poll = moddb.pages.Poll(moddb.get_page(getattr(self, "url", "https://www.moddb.com/polls/total-conversions-vs-cosmetic-mods")))
-
-    def test_get_comments(self):
-        self.poll.get_comments()
-        self.poll.get_comments(4)
diff --git a/test/__init__.py b/tests/__init__.py
similarity index 100%
rename from test/__init__.py
rename to tests/__init__.py
diff --git a/tests/test_addon.py b/tests/test_addon.py
new file mode 100644
index 0000000..ac2c0d8
--- /dev/null
+++ b/tests/test_addon.py
@@ -0,0 +1,22 @@
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request
+
+import moddb
+
+DEFAULT = "https://www.moddb.com/games/grand-theft-auto-san-andreas/addons/superman-sa-beta-v10"
+
+@patch("moddb.utils.request", new=patched_request)
+class TestAddon:
+    @pytest.fixture(params=[DEFAULT], autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.addon = moddb.Addon(moddb.get_page(request.param))
+
+    def test_get_comments(self):
+        self.addon.get_comments()
+        self.addon.get_comments(5)
+
+    def test_get_mirrors(self):
+        self.addon.get_mirrors()
diff --git a/tests/test_article.py b/tests/test_article.py
new file mode 100644
index 0000000..d10c795
--- /dev/null
+++ b/tests/test_article.py
@@ -0,0 +1,19 @@
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request
+
+import moddb
+
+DEFAULT = "https://www.moddb.com/mods/edain-mod/news/edain-46-tower-defense"
+
+@patch("moddb.utils.request", new=patched_request)
+class TestArticle:
+    @pytest.fixture(params=[DEFAULT], autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.article = moddb.Article(moddb.get_page(request.param))
+    
+    def test_get_comments(self):
+        self.article.get_comments()
+        self.article.get_comments(4)
diff --git a/tests/test_base.py b/tests/test_base.py
new file mode 100644
index 0000000..e59c3f6
--- /dev/null
+++ b/tests/test_base.py
@@ -0,0 +1,64 @@
+from ast import Import
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request
+
+try:
+    from tests.test_config import username, password
+except ModuleNotFoundError:
+    import os
+    username = os.environ["USERNAME"]
+    password = os.environ["PASSWORD"]
+
+import moddb
+
+DEFAULT_SEARCH = ("edain mod", moddb.SearchCategory.mods)
+
+@patch("moddb.utils.request", new=patched_request)
+class TestFrontPage:
+    @pytest.fixture(autouse=True)
+    def _get_object(self, request):
+        self.fp = moddb.front_page()
+
+    def get_articles(self):
+        for article in self.fp.articles:
+            article.parse()
+
+    def get_games(self):
+        for game in self.fp.games:
+            game.parse()
+
+    def get_files(self):
+        for file in self.fp.files:
+            file.parse()
+
+@patch("moddb.utils.request", new=patched_request)
+class TestSearch:
+    @pytest.fixture(params=[DEFAULT_SEARCH], autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.search= moddb.search(request.param[1], query=request.param[0])
+
+    def test_resort(self):
+        results = self.search._results
+        search2 = self.search.resort(("visitstotal", "asc"))
+        assert results != search2._results
+
+    def test_next_page(self):
+        self.search.next_page()
+
+    def test_previous_pages(self):
+        search = self.search.next_page()
+        search.previous_page()
+
+class TestLogin:
+    def test_login(self):
+        moddb.login(username, password)
+
+    def test_bad_login(self):
+        with pytest.raises(ValueError) as e:
+            moddb.login("tico", "ticoisgod")
+
+    def tearDown(self):
+        moddb.SESSION.close()
diff --git a/tests/test_client.py b/tests/test_client.py
new file mode 100644
index 0000000..2133e43
--- /dev/null
+++ b/tests/test_client.py
@@ -0,0 +1,57 @@
+import pytest
+import random
+
+from tests.test_utils import mixed_urls
+
+try:
+    from tests.test_config import username, password, sender_username, sender_password
+except ModuleNotFoundError:
+    import os
+    username = os.environ["USERNAME"]
+    password = os.environ["PASSWORD"]
+    sender_username = os.environ["SENDER_USERNAME"]
+    sender_password = os.environ["SENDER_PASSWORD"]
+
+import moddb
+
+class TestClient:
+    @pytest.fixture(autouse=True)
+    def _get_object(self, request):
+        self.client = moddb.Client(username, password)
+        self.sender = moddb.Client(sender_username, sender_password)
+
+    def test_get_watched(self):
+        for e in moddb.WatchType:
+            self.client.get_watched(e)
+
+    def test_get_updates(self):
+        updates = self.client.get_updates()
+
+        if updates:
+            random.choice(updates).clear()
+
+    def test_posts(self):
+        for url in mixed_urls:
+            e = moddb.parse(url)
+            self.client.tracking(e) #follow
+            self.client.tracking(e) #unfollow
+
+            if e.comments:
+                comment = random.choice(e.comments.flatten())
+                self.client.like_comment(comment)
+                self.client.dislike_comment(comment)
+
+            if isinstance(e, moddb.Group):
+                self.client.membership(e) #join
+                self.client.membership(e) #leave
+
+    def test_friends(self):
+        self.sender.send_request(self.client.member)
+        request = moddb.utils.get(self.client.get_friend_requests(), name=self.sender.member.profile.name)
+        request.decline()
+
+        self.sender.send_request(self.client.member)
+        request = moddb.utils.get(self.client.get_friend_requests(), name=self.sender.member.profile.name)
+        request.accept()
+
+        self.client.unfriend(self.sender.member)
diff --git a/test/test_engine.py b/tests/test_engine.py
similarity index 68%
rename from test/test_engine.py
rename to tests/test_engine.py
index b8edc14..4256fd9 100644
--- a/test/test_engine.py
+++ b/tests/test_engine.py
@@ -1,17 +1,19 @@
-import unittest
-import moddb
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request
 
-class TestEngine(unittest.TestCase):
-    def setUp(self):
-        self.engine = moddb.pages.Engine(moddb.get_page(getattr(self, "url", "https://www.moddb.com/engines/sage-strategy-action-game-engine")))
+import moddb
 
-    def test_get_addons(self):
-        addons = self.engine.get_addons()
-        self.engine.get_addons(2)
-        self.engine.get_addons(licence=moddb.Licence.public_domain)
-        for addon in addons:
-            addon.parse()
+DEFAULT = "https://www.moddb.com/engines/sage-strategy-action-game-engine"
 
+@patch("moddb.utils.request", new=patched_request)
+class TestEngine:
+    @pytest.fixture(params=[DEFAULT], autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.engine = moddb.Engine(moddb.get_page(request.param))
+    
     def test_get_articles(self):
         articles = self.engine.get_articles()
         self.engine.get_articles(4)
@@ -54,3 +56,6 @@ def test_get_videos(self):
 
         for video in videos:
             video.parse()
+
+    def test_get_watchers(self):
+        self.engine.get_watchers()
diff --git a/tests/test_file.py b/tests/test_file.py
new file mode 100644
index 0000000..1a12386
--- /dev/null
+++ b/tests/test_file.py
@@ -0,0 +1,20 @@
+import pytest
+from unittest.mock import patch
+from tests.test_engine import DEFAULT
+
+from tests.test_utils import patched_request
+
+import moddb
+
+DEFAULT = "https://www.moddb.com/games/pokemon-generations/downloads/pokegen-v2-launcher"
+
+@patch("moddb.utils.request", new=patched_request)
+class TestFile:
+    @pytest.fixture(params=[DEFAULT], autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.file = moddb.File(moddb.get_page(request.param))
+
+    def test_get_comments(self):
+        self.file.get_comments()
+        self.file.get_comments(5)
diff --git a/test/test_game.py b/tests/test_game.py
similarity index 74%
rename from test/test_game.py
rename to tests/test_game.py
index af9ad51..909ec8e 100644
--- a/test/test_game.py
+++ b/tests/test_game.py
@@ -1,9 +1,18 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request
+
 import moddb
 
-class TestGame(unittest.TestCase):
-    def setUp(self):
-        self.game = moddb.pages.Game(moddb.get_page(getattr(self, "url", "https://www.moddb.com/games/battle-for-middle-earth-ii-rise-of-the-witch-king")))
+DEFAULT = "https://www.moddb.com/games/battle-for-middle-earth-ii-rise-of-the-witch-king"
+
+@patch("moddb.utils.request", new=patched_request)
+class TestGame:
+    @pytest.fixture(params=[DEFAULT], autouse=True)
+    def _get_mod(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.game = moddb.Game(moddb.get_page(request.param))
 
     def test_get_addons(self):
         addons = self.game.get_addons()
@@ -62,3 +71,6 @@ def test_get_videos(self):
 
         for video in videos:
             video.parse()
+
+    def test_get_watchers(self):
+        self.game.get_watchers()
diff --git a/test/test_group.py b/tests/test_group.py
similarity index 70%
rename from test/test_group.py
rename to tests/test_group.py
index a37ec60..d1693fd 100644
--- a/test/test_group.py
+++ b/tests/test_group.py
@@ -1,9 +1,18 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request
+
 import moddb
 
-class TestGroup(unittest.TestCase):
-    def setUp(self):
-        self.group = moddb.pages.Group(moddb.get_page(getattr(self, "url", "https://www.moddb.com/groups/humour-satire-parody")))
+DEFAULT = "https://www.moddb.com/groups/humour-satire-parody"
+
+@patch("moddb.utils.request", new=patched_request)
+class TestGroup:
+    @pytest.fixture(params=[DEFAULT], autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.group = moddb.Group(moddb.get_page(request.param))
 
     def test_get_addons(self):
         addons = self.group.get_addons()
@@ -50,4 +59,7 @@ def test_get_videos(self):
         videos = self.group.get_videos()
 
         for video in videos:
-            video.parse()
\ No newline at end of file
+            video.parse()
+
+    def test_get_watchers(self):
+        self.group.get_watchers()
diff --git a/test/test_hardware.py b/tests/test_hardware.py
similarity index 77%
rename from test/test_hardware.py
rename to tests/test_hardware.py
index 197a07a..6ca0990 100644
--- a/test/test_hardware.py
+++ b/tests/test_hardware.py
@@ -1,9 +1,18 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request
+
 import moddb
 
-class TestHardware(unittest.TestCase):
-    def setUp(self):
-        self.hardware = moddb.pages.Hardware(moddb.get_page(getattr(self, "url", "https://www.moddb.com/hardware/htc-vive")))
+DEFAULT = "https://www.moddb.com/hardware/htc-vive"
+
+@patch("moddb.utils.request", new=patched_request)
+class TestHardware:
+    @pytest.fixture(params=[DEFAULT], autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.hardware = moddb.Hardware(moddb.get_page(request.param))
 
     def test_get_articles(self):
         articles = self.hardware.get_articles()
@@ -69,3 +78,6 @@ def test_get_videos(self):
 
         for video in videos:
             video.parse()
+
+    def test_get_watchers(self):
+        self.hardware.get_watchers()
diff --git a/tests/test_job.py b/tests/test_job.py
new file mode 100644
index 0000000..7163b6f
--- /dev/null
+++ b/tests/test_job.py
@@ -0,0 +1,19 @@
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request
+
+import moddb
+
+DEFAULT = "https://www.moddb.com/jobs/programmer-enviro-artist-audio-specialist-needed-to-expand-our-launched-game"
+
+@patch("moddb.utils.request", new=patched_request)
+class TestJob:
+    @pytest.fixture(params=[DEFAULT], autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.job = moddb.Job(moddb.get_page(request.param))
+
+    def test_author_parse(self):
+        if self.job.author is not None:
+            self.job.author.parse()
diff --git a/tests/test_main.py b/tests/test_main.py
new file mode 100644
index 0000000..6d94aa4
--- /dev/null
+++ b/tests/test_main.py
@@ -0,0 +1,154 @@
+from unittest.mock import patch
+import logging
+import pytest
+
+from tests import (
+    test_mod,
+    test_game,
+    test_engine,
+    test_file,
+    test_addon,
+    test_media,
+    test_article,
+    test_group,
+    test_team,
+    test_job,
+    test_member,
+    test_platform,
+    test_software,
+    test_hardware,
+    test_poll,
+    test_base,
+
+    test_utils,
+    test_client,
+)
+
+import moddb
+
+logger = logging.getLogger('moddb')
+logger.setLevel(logging.DEBUG)
+handler = logging.FileHandler(filename='moddb.log', encoding='utf-8', mode='w')
+handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s'))
+logger.addHandler(handler)
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestMods(test_mod.TestMod):
+    @pytest.fixture(params=test_utils.mod_urls, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.mod = moddb.Mod(moddb.get_page(request.param))
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestGames(test_game.TestGame):
+    @pytest.fixture(params=test_utils.game_urls, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.game = moddb.Game(moddb.get_page(request.param))
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestEngines(test_engine.TestEngine):
+    @pytest.fixture(params=test_utils.engine_urls, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.engine = moddb.Engine(moddb.get_page(request.param))
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestFiles(test_file.TestFile):
+    @pytest.fixture(params=test_utils.file_urls, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.file = moddb.File(moddb.get_page(request.param))
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestAddons(test_addon.TestAddon):
+    @pytest.fixture(params=test_utils.addon_urls, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.addon = moddb.Addon(moddb.get_page(request.param))
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestMedias(test_media.TestMedia):
+    @pytest.fixture(params=test_utils.media_urls, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.media = moddb.Media(moddb.get_page(request.param))
+    
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestArticles(test_article.TestArticle):
+    @pytest.fixture(params=test_utils.article_urls, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.article = moddb.Article(moddb.get_page(request.param))
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestGroups(test_group.TestGroup):
+    @pytest.fixture(params=test_utils.group_urls, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.group = moddb.Group(moddb.get_page(request.param))
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestTeams(test_team.TestTeam):
+    @pytest.fixture(params=test_utils.team_urls, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.team = moddb.Team(moddb.get_page(request.param))
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestJobs(test_job.TestJob):
+    @pytest.fixture(params=test_utils.job_urls, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.job = moddb.Job(moddb.get_page(request.param))
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestMembers(test_member.TestMember):
+    @pytest.fixture(params=test_utils.member_urls, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.member = moddb.Member(moddb.get_page(request.param))
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestPlatforms(test_platform.TestPlatform):
+    @pytest.fixture(params=test_utils.platform_urls, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.platform = moddb.Platform(moddb.get_page(request.param))
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestHardwares(test_hardware.TestHardware):
+    @pytest.fixture(params=test_utils.hardware_urls, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.hardware = moddb.Hardware(moddb.get_page(request.param))
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestSoftwares(test_software.TestSoftware):
+    @pytest.fixture(params=test_utils.software_urls, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.software = moddb.Software(moddb.get_page(request.param))
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestPolls(test_poll.TestPoll):
+    @pytest.fixture(params=test_utils.poll_urls, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.poll = moddb.Poll(moddb.get_page(request.param))
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestSearches(test_base.TestSearch):
+    @pytest.fixture(params=test_utils.queries, autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=test_utils.patched_request) as f:
+            self.search= moddb.search(request.param[1], query=request.param[0])
+
+class TestLogin(test_base.TestLogin):
+    pass
+
+class TestFrontPage(test_base.TestFrontPage):
+    pass
+
+class TestClient(test_client.TestClient):
+    pass
diff --git a/tests/test_media.py b/tests/test_media.py
new file mode 100644
index 0000000..765f140
--- /dev/null
+++ b/tests/test_media.py
@@ -0,0 +1,19 @@
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request
+
+import moddb
+
+DEFAULT = "https://www.moddb.com/mods/third-age-total-war/videos/rohan7"
+
+@patch("moddb.utils.request", new=patched_request)
+class TestMedia:
+    @pytest.fixture(params=[DEFAULT], autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.media = moddb.Media(moddb.get_page(request.param))
+    
+    def test_get_comments(self):
+        self.media.get_comments()
+        self.media.get_comments(4)
diff --git a/test/test_member.py b/tests/test_member.py
similarity index 78%
rename from test/test_member.py
rename to tests/test_member.py
index 1d31e58..507d969 100644
--- a/test/test_member.py
+++ b/tests/test_member.py
@@ -1,10 +1,20 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request
+
 import moddb
+import requests
 
-class TestMember(unittest.TestCase):
-    def setUp(self):
-        self.member = moddb.pages.Member(moddb.get_page(getattr(self, "url", "https://www.moddb.com/members/mladen1996")))
+DEFAULT = "https://www.moddb.com/members/upstart"
 
+@patch("moddb.utils.request", new=patched_request)
+class TestMember:
+    @pytest.fixture(params=[DEFAULT], autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.member = moddb.Member(moddb.get_page(request.param))
+            
     def test_get_addons(self):
         addons = self.member.get_addons()
         self.member.get_addons(2)
@@ -42,7 +52,10 @@ def test_get_friends(self):
         self.member.get_friends(3)
 
         for friend in friends:
-            friend.parse()
+            try:
+                friend.parse()
+            except requests.exceptions.HTTPError:
+                pass
 
     def test_get_games(self):
         games = self.member.get_games()
@@ -93,4 +106,5 @@ def test_get_videos(self):
         for video in videos:
             video.parse()
 
-
+    def test_get_watchers(self):
+        self.member.get_watchers()
diff --git a/test/test_mod.py b/tests/test_mod.py
similarity index 73%
rename from test/test_mod.py
rename to tests/test_mod.py
index 3eab8aa..7b6ebae 100644
--- a/test/test_mod.py
+++ b/tests/test_mod.py
@@ -1,9 +1,18 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request
+
 import moddb
 
-class TestMod(unittest.TestCase):
-    def setUp(self):
-        self.mod = moddb.pages.Mod(moddb.get_page(getattr(self, "url", "https://www.moddb.com/mods/edain-mod")))
+DEFAULT = "https://www.moddb.com/mods/edain-mod"
+
+@patch("moddb.utils.request", new=patched_request)
+class TestMod:
+    @pytest.fixture(params=[DEFAULT], autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.mod = moddb.Mod(moddb.get_page(request.param))
 
     def test_get_addons(self):
         addons = self.mod.get_addons()
@@ -55,3 +64,6 @@ def test_get_videos(self):
 
         for video in videos:
             video.parse()
+
+    def test_get_watchers(self):
+        self.mod.get_watchers()
diff --git a/tests/test_parse.py b/tests/test_parse.py
new file mode 100644
index 0000000..58ba088
--- /dev/null
+++ b/tests/test_parse.py
@@ -0,0 +1,133 @@
+import logging
+from unittest.mock import patch
+
+from tests import test_utils
+
+import moddb
+
+logger = logging.getLogger('moddb')
+logger.setLevel(logging.DEBUG)
+handler = logging.FileHandler(filename='moddb.log', encoding='utf-8', mode='w')
+handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s'))
+logger.addHandler(handler)
+
+@patch("moddb.utils.request", new=test_utils.patched_request)
+class TestParsers:
+    mods = []
+    games = []
+    engines = []
+    members = []
+
+    def test_parse_mods(self):
+        for url in test_utils.mod_urls:
+            logger.debug(url)
+            soup = moddb.get_page(url)
+            self.mods.append(moddb.pages.Mod(soup))
+
+    def test_parse_games(self):
+        for url in test_utils.game_urls:
+            logger.debug(url)
+            soup = moddb.get_page(url)
+            self.games.append(moddb.pages.Game(soup))
+
+    def test_parse_engines(self):
+        for url in test_utils.engine_urls:
+            logger.debug(url)
+            soup = moddb.get_page(url)
+            self.engines.append(moddb.pages.Engine(soup))
+
+    def test_parse_files(self):
+        for url in test_utils.file_urls:
+            logger.debug(url)
+            soup = moddb.get_page(url)
+            try:
+                moddb.pages.File(soup)
+            except ValueError as e:
+                logger.debug(e)
+
+    def test_parse_addons(self):
+        for url in test_utils.addon_urls:
+            logger.debug(url)
+            soup = moddb.get_page(url)
+            moddb.pages.Addon(soup)
+
+    def test_parse_medias(self):
+        for url in test_utils.media_urls:
+            logger.debug(url)
+            soup = moddb.get_page(url)
+            moddb.pages.Media(soup)
+
+    def test_parse_articles(self):
+        for url in test_utils.article_urls:
+            logger.debug(url)
+            soup = moddb.get_page(url)
+            moddb.pages.Article(soup)  
+
+    def test_parse_groups(self):
+        for url in test_utils.group_urls:
+            logger.debug(url)
+            soup = moddb.get_page(url)
+            moddb.pages.Group(soup)
+
+    def test_parse_teams(self):
+        for url in test_utils.team_urls:
+            logger.debug(url)
+            soup = moddb.get_page(url)
+            moddb.pages.Team(soup) 
+
+    def test_parse_jobs(self):
+        for url in test_utils.job_urls:
+            logger.debug(url)
+            soup = moddb.get_page(url)
+            moddb.pages.Job(soup)
+
+    def test_parse_members(self):
+        for url in test_utils.member_urls:
+            logger.debug(url)
+            soup = moddb.get_page(url)
+            self.members.append(moddb.pages.Member(soup))
+
+    def test_parse_front_page(self):
+        moddb.front_page()
+
+    def test_parse_platforms(self):
+        for url in test_utils.platform_urls:
+            logger.debug(url)
+            soup = moddb.get_page(url)
+            moddb.pages.Platform(soup)
+
+    def test_parse_hardware(self):
+        for url in test_utils.hardware_urls:
+            logger.debug(url)
+            soup = moddb.get_page(url)
+            moddb.pages.Hardware(soup)
+
+    def test_parse_software(self):
+        for url in test_utils.software_urls:
+            logger.debug(url)
+            soup = moddb.get_page(url)
+            moddb.pages.Software(soup)
+
+    def test_parse_polls(self):
+        for url in test_utils.poll_urls:
+            logger.debug(url)
+            soup = moddb.get_page(url)
+            moddb.pages.Poll(soup)
+
+    def test_parse_reviews(self):
+        for mod in self.mods:
+            logger.debug(mod.url)
+            mod.get_reviews()
+
+        for game in self.games:
+            logger.debug(game.url)
+            game.get_reviews()
+
+        for engine in self.engines:
+            logger.debug(engine.url)
+            engine.get_reviews()
+
+    def test_parse_blogs(self):
+        for member in self.members:
+            logger.debug(member.url)
+            member.get_blogs()
diff --git a/test/test_platform.py b/tests/test_platform.py
similarity index 66%
rename from test/test_platform.py
rename to tests/test_platform.py
index 9c34718..12216f2 100644
--- a/test/test_platform.py
+++ b/tests/test_platform.py
@@ -1,9 +1,18 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request
+
 import moddb
 
-class TestPlatform(unittest.TestCase):
-    def setUp(self):
-        self.platform = moddb.pages.Platform(moddb.get_page(getattr(self, "url", "https://www.moddb.com/platforms/pc")))
+DEFAULT = "https://www.moddb.com/platforms/pc"
+
+@patch("moddb.utils.request", new=patched_request)
+class TestPlatform:
+    @pytest.fixture(params=[DEFAULT], autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.platform = moddb.Platform(moddb.get_page(request.param))
 
     def test_get_comments(self):
         self.platform.get_comments()
@@ -42,4 +51,4 @@ def test_get_software(self):
         self.platform.get_software(3)
 
         for software in softwares:
-            software.parse() 
+            software.parse()
diff --git a/tests/test_poll.py b/tests/test_poll.py
new file mode 100644
index 0000000..17f7f6d
--- /dev/null
+++ b/tests/test_poll.py
@@ -0,0 +1,19 @@
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request
+
+import moddb
+
+DEFAULT = "https://www.moddb.com/polls/total-conversions-vs-cosmetic-mods"
+
+@patch("moddb.utils.request", new=patched_request)
+class TestPoll:
+    @pytest.fixture(params=[DEFAULT], autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.poll = moddb.Poll(moddb.get_page(request.param))
+
+    def test_get_comments(self):
+        self.poll.get_comments()
+        self.poll.get_comments(4)
diff --git a/test/test_software.py b/tests/test_software.py
similarity index 70%
rename from test/test_software.py
rename to tests/test_software.py
index e5d2d26..3bf6978 100644
--- a/test/test_software.py
+++ b/tests/test_software.py
@@ -1,9 +1,18 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request
+
 import moddb
 
-class TestSoftware(unittest.TestCase):
-    def setUp(self):
-        self.software = moddb.pages.Software(moddb.get_page(getattr(self, "url", "https://www.moddb.com/software/project-neptune-vr")))
+DEFAULT = "https://www.moddb.com/software/project-neptune-vr"
+
+@patch("moddb.utils.request", new=patched_request)
+class TestSoftware:
+    @pytest.fixture(params=[DEFAULT], autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.software = moddb.Software(moddb.get_page(request.param))
 
     def test_get_articles(self):
         articles = self.software.get_articles()
@@ -48,3 +57,6 @@ def test_get_videos(self):
 
         for video in videos:
             video.parse()
+
+    def test_get_watchers(self):
+        self.software.get_watchers()
diff --git a/test/test_team.py b/tests/test_team.py
similarity index 80%
rename from test/test_team.py
rename to tests/test_team.py
index a1b231b..fcf9871 100644
--- a/test/test_team.py
+++ b/tests/test_team.py
@@ -1,9 +1,18 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request
+
 import moddb
 
-class TestTeam(unittest.TestCase):
-    def setUp(self):
-        self.team = moddb.pages.Team(moddb.get_page(getattr(self, "url", "https://www.moddb.com/company/edainmod-dev-team")))
+DEFAULT = "https://www.moddb.com/company/edainmod-dev-team"
+
+@patch("moddb.utils.request", new=patched_request)
+class TestTeam:
+    @pytest.fixture(params=[DEFAULT], autouse=True)
+    def _get_object(self, request):
+        with patch("moddb.utils.request", new=patched_request) as f:
+            self.team = moddb.Team(moddb.get_page(request.param))
 
     def test_get_addons(self):
         addons = self.team.get_addons()
@@ -86,3 +95,6 @@ def test_get_videos(self):
 
         for video in videos:
             video.parse()
+
+    def test_get_watchers(self):
+        self.team.get_watchers()
diff --git a/test/test_main.py b/tests/test_utils.py
similarity index 69%
rename from test/test_main.py
rename to tests/test_utils.py
index 92010f9..125f965 100644
--- a/test/test_main.py
+++ b/tests/test_utils.py
@@ -1,35 +1,32 @@
-import unittest
-import moddb
+import os
 import logging
+from unittest.mock import patch
+
+import moddb
 
-from test.test_mod import TestMod
-from test.test_game import TestGame
-from test.test_engine import TestEngine
-from test.test_file import TestFile
-from test.test_addon import TestAddon
-from test.test_media import TestMedia
-from test.test_article import TestArticle
-from test.test_group import TestGroup
-from test.test_team import TestTeam
-from test.test_job import TestJob
-from test.test_member import TestMember
-from test.test_platform import TestPlatform
-from test.test_software import TestSoftware
-from test.test_hardware import TestHardware
-from test.test_poll import TestPoll
-from test.test_base import TestFrontPage, TestSearch, TestParse, TestLogin
-from test.test_client import TestClient
+class FakeResponse:
+    def __init__(self, text):
+        self.text = text
+        self.content = text.encode("utf-8")
 
-logger = logging.getLogger('moddb')
-logger.setLevel(logging.DEBUG)
-handler = logging.FileHandler(filename='moddb.log', encoding='utf-8', mode='w')
-handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s'))
-logger.addHandler(handler)
+request = moddb.utils.request
+def patched_request(req):
+    filename = req.url.replace(moddb.BASE_URL, '')
+    if not filename:
+        filename = "/frontpage"
+    
+    path = f"tests/fixtures{filename}.html"
 
-loader = unittest.TestLoader()
-suite  = unittest.TestSuite()
+    # cache the file if it doesn't exist
+    if not os.path.exists(path):
+        r = request(req)
 
+        os.makedirs(os.path.dirname(path), exist_ok=True)
+        with open(path, "wb") as f:
+            f.write(r.content)
 
+    with open(path, "r") as f:
+        return FakeResponse(f.read())
 
 mod_urls = [
     "https://www.moddb.com/mods/edain-mod",
@@ -39,13 +36,8 @@
     "https://www.moddb.com/mods/armory-world-of-warcraft",
     "https://www.moddb.com/mods/perisno",
     "https://www.moddb.com/mods/middle-earth-at-war",
-    "https://www.moddb.com/mods/zeruel87-mod"
 ]
 
-for url in mod_urls:
-    TestMod.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestMod))
-
 game_urls = [
     "https://www.moddb.com/games/battle-for-middle-earth-ii-rise-of-the-witch-king",
     "https://www.moddb.com/games/battle-for-middle-earth-ii",
@@ -53,18 +45,12 @@
     "https://www.moddb.com/games/mount-blade-warband",
     "https://www.moddb.com/games/minecraft",
     "https://www.moddb.com/games/zombie-treat",
-    "https://www.moddb.com/games/spellhack",
     "https://www.moddb.com/games/half-life",
     "https://www.moddb.com/games/diablo-2-lod",
     "https://www.moddb.com/games/car-chase-simulator",
-    "https://www.moddb.com/games/project-harpy",
     "https://www.moddb.com/games/fated-kingdom"
 ]
 
-for url in game_urls:
-    TestGame.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestGame))
-
 engine_urls = [
     "https://www.moddb.com/engines/sage-strategy-action-game-engine",
     "https://www.moddb.com/engines/source",
@@ -77,10 +63,6 @@
     "https://www.moddb.com/engines/limon-engine"
 ]
 
-for url in engine_urls:
-    TestEngine.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestEngine))
-
 file_urls = [
     "https://www.moddb.com/games/pokemon-generations/downloads/pokegen-v2-launcher",
     "https://www.moddb.com/mods/hidden-source/downloads/hidden-source-beta-4b",
@@ -89,13 +71,8 @@
     "https://www.moddb.com/games/shifting-dungeons/downloads/shifting-dungeons-update-1-mac-osx",
     "https://www.moddb.com/games/grofast-industries/downloads/linux-offline-version",
     "https://www.moddb.com/mods/cleric-mod-for-legend-farewell-edition-83/downloads/legend-farewell-edition-833-ultimate",
-    "https://www.moddb.com/games/na1549814307/downloads/gdz-simulator"
 ]
 
-for url in file_urls:
-    TestFile.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestFile))
-
 addon_urls = [
     "https://www.moddb.com/games/grand-theft-auto-san-andreas/addons/superman-sa-beta-v10",
     "https://www.moddb.com/mods/brutal-doom/addons/project-brutality",
@@ -107,10 +84,6 @@
     "https://www.moddb.com/games/axis-allies/addons/simple-vanilla-sai-buff"
 ]
 
-for url in addon_urls:
-    TestAddon.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestAddon))
-
 media_urls = [
     "https://www.moddb.com/mods/third-age-total-war/videos/rohan7",
     "https://www.moddb.com/games/erie/videos/pewdiepie-approved",
@@ -128,30 +101,20 @@
     "https://www.moddb.com/mods/goldeneye-source/videos/podcast-17-v41-interview-with-ges",
     "https://www.moddb.com/mods/tiberium-essence/videos/vlado32-fields-of-green",
     "https://www.moddb.com/mods/goldeneye-source/videos/podcast-17-v41-interview-with-ges",
-    "https://www.moddb.com/mods/war-of-the-gods-wrath-of-egypt/videos/the-mummy-returns-complete-score-23-leaving-clues-sandcastles",
     "https://www.moddb.com/mods/recovered-operations/videos/recoil-tension"
 ]
 
-for url in media_urls:
-    TestMedia.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestMedia))
-
 article_urls = [
     "https://www.moddb.com/mods/the-consequence-of-infection/news/whats-happening-to-this-project-in-2019",
     "https://www.moddb.com/mods/project-zer0/news/new-intro-wip",
     "https://www.moddb.com/news/the-challenge-of-adblock",
     "https://www.moddb.com/groups/editors/news/d2multires",
-    "https://www.moddb.com/games/patient-2045/news/patient-2045-devlog-1-january-2019",
     "https://www.moddb.com/news/dungeon-siege-skinning-contest",
     "https://www.moddb.com/games/neon-knigh/tutorials/neon-knight-manual",
     "https://www.moddb.com/games/fear/tutorials/alma1",
     "https://www.moddb.com/games/katharsis/news/katharsis-devlog-11"
 ] 
 
-for url in article_urls:
-    TestArticle.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestArticle)) 
-
 group_urls = [
     "https://www.moddb.com/groups/humour-satire-parody",
     "https://www.moddb.com/groups/star-wars-empire-at-war-forces-of-corruption-mods",
@@ -164,10 +127,6 @@
     "https://www.moddb.com/groups/2013-mod-of-the-year-awards"
 ] 
 
-for url in group_urls:
-    TestGroup.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestGroup))
-
 team_urls = [
     "https://www.moddb.com/company/wallace2anjos",
     "https://www.moddb.com/company/valve",
@@ -178,10 +137,6 @@
     "https://www.moddb.com/company/v1-infinity"
 ]
 
-for url in team_urls:
-    TestTeam.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestTeam))
-
 job_urls = [
     "https://www.moddb.com/jobs/programmer-enviro-artist-audio-specialist-needed-to-expand-our-launched-game",
     "https://www.moddb.com/jobs/looking-for-3d-modeller-for-fast-past-shooting-game1",
@@ -191,13 +146,7 @@
     "https://www.moddb.com/jobs/player-modeler-modeler1"
 ] 
 
-for url in job_urls:
-    TestJob.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestJob))
-
 member_urls = [
-    "https://www.moddb.com/members/mladen1996",
-    "https://www.moddb.com/members/na3703168",
     "https://www.moddb.com/members/barneypan",
     "https://www.moddb.com/members/officialnecro",
     "https://www.moddb.com/members/yizhang",
@@ -207,10 +156,6 @@
     "https://www.moddb.com/members/upstart",
 ]
 
-for url in member_urls:
-    TestMember.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestMember))
-
 platform_urls = [
     "https://www.moddb.com/platforms/pc",
     "https://www.moddb.com/platforms/mac",
@@ -219,10 +164,6 @@
     "https://www.moddb.com/platforms/ar"
 ]
 
-for url in platform_urls:
-    TestPlatform.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestPlatform))
-
 hardware_urls = [
     "https://www.moddb.com/hardware/htc-vive",
     "https://www.moddb.com/hardware/jaunt-vr",
@@ -232,24 +173,15 @@
     "https://www.moddb.com/hardware/oculus-rift"
 ]
 
-for url in hardware_urls:
-    TestHardware.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestHardware))
-
 software_urls = [
     "https://www.moddb.com/software/project-neptune-vr",
     "https://www.moddb.com/software/kinecttovr",
     "https://www.moddb.com/software/harfang-3d",
     "https://www.moddb.com/software/tilt-brush",
     "https://www.moddb.com/software/virtual-desktop",
-    "https://www.moddb.com/software/project-neptune-vr",
     "https://www.moddb.com/software/geovisionary"
 ]
 
-for url in software_urls:
-    TestSoftware.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestSoftware))
-
 poll_urls = [
     "https://www.moddb.com/polls/total-conversions-vs-cosmetic-mods",
     "https://www.moddb.com/polls/if-you-had-to-choose-which-feature-would-you-like-implemented-the-most",
@@ -259,13 +191,6 @@
     "https://www.moddb.com/polls/prices-in-game-profiles"
 ]
 
-for url in poll_urls:
-    TestPoll.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestPoll))
-
-suite.addTests(loader.loadTestsFromTestCase(TestFrontPage))
-suite.addTests(loader.loadTestsFromTestCase(TestLogin))
-
 test_urls = [
     "https://www.moddb.com/polls/total-conversions-vs-cosmetic-mods",
     "https://www.moddb.com/software/harfang-3d",
@@ -284,24 +209,24 @@
     "https://www.moddb.com/mods/edain-mod",
 ]
 
-for url in test_urls:
-    TestParse.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestParse))
-
 queries = [
     ("edain mod", moddb.SearchCategory.mods),
     ("battle for middle earth", moddb.SearchCategory.games),
     ("tanks", moddb.SearchCategory.groups)
 ]
 
-for query in queries:
-    TestSearch.query = query[0]
-    TestSearch.category = query[1]
-    suite.addTests(loader.loadTestsFromTestCase(TestSearch))
-
-suite.addTests(loader.loadTestsFromTestCase(TestClient))
-
-runner = unittest.TextTestRunner(verbosity=3)
-result = runner.run(suite)
-# result = suite.debug()
-
+mixed_urls = [
+    "https://www.moddb.com/mods/edain-mod",
+    "https://www.moddb.com/mods/the-horse-lords-a-total-modification-for-bfme",
+    "https://www.moddb.com/mods/third-age-total-war",
+    "https://www.moddb.com/games/battle-for-middle-earth-ii-rise-of-the-witch-king",
+    "https://www.moddb.com/games/battle-for-middle-earth-ii",
+    "https://www.moddb.com/games/battle-for-middle-earth",
+    "https://www.moddb.com/engines/sage-strategy-action-game-engine",
+    "https://www.moddb.com/engines/source",
+    "https://www.moddb.com/engines/cryengine-3",
+    "https://www.moddb.com/groups/humour-satire-parody",
+    "https://www.moddb.com/groups/warhammer-40k-fans-group",
+    "https://www.moddb.com/members/barneypan",
+    "https://www.moddb.com/members/officialnecro"
+]