Repository: ClementJ18/moddb (language: python)

Issue:
flake8 adding config file and flakeheaven tests



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 5320654..4239ac8 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,24 @@ jobs:
     - name: Echo env
       run: |
         echo ${{ github.event.pull_request.base.ref }}
+        echo ${{ github.event.pull_request.base.ref_name }}
+    - name: Black and flake
+      run: |
+        bash run_lint.sh
     - name: Test changes with light tests
       run: |
-        bash run_tests.sh
-      if: github.event.pull_request.base.ref_name == "develop"
-    - name: Test changes with full tests
+        bash run_tests.sh --reruns 3 --reruns-delay 15 
+      if: github.event.pull_request.base.ref == 'develop'
+    - name: Test changes with full tests 1/3
+      run: |
+        bash run_tests.sh full --test-group-count 3 --test-group=1 --reruns 3 --reruns-delay 15 
+      if: github.event.pull_request.base.ref == 'master'
+    - name: Test changes with full tests 2/3
+      run: |
+        bash run_tests.sh full --test-group-count 3 --test-group=2 --reruns 3 --reruns-delay 15 
+      if: github.event.pull_request.base.ref == 'master'
+    - name: Test changes with full tests 3/3
       run: |
-        bash run_tests.sh full
-      if: github.event.pull_request.base.ref_name == "master"
+        bash run_tests.sh full --test-group-count 3 --test-group=3 --reruns 3 --reruns-delay 15 
+      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..c80d4e2 100644
--- a/README.md
+++ b/README.md
@@ -7,13 +7,13 @@ The goal of the library is to be able to navigate ModDB purely programmatically
 The simplest way to use this library is to simply pass a ModDB url to the parse function and let the magic happen.
 ```py
 import moddb
-mod = moddb.parse("http://www.moddb.com/mods/edain-mod")
+mod = moddb.parse_page("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.
 ```py
 import moddb
-mod = moddb.parse("http://www.moddb.com/mods/edain-mod", page_type=moddb.ThumbnailType.mod)
+mod = moddb.parse_page("http://www.moddb.com/mods/edain-mod", page_type=moddb.ThumbnailType.mod)
 print(mod.name) #Edain Mod
 ```
 
@@ -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/base.rst b/docs/source/base.rst
index c3b918d..0d007be 100644
--- a/docs/source/base.rst
+++ b/docs/source/base.rst
@@ -1,7 +1,7 @@
 .. currentmodule:: moddb.base
 
-Key Functions & Objects
-========================
+Key Functions
+===============
 This page documents the functions and objects you will interact with at a high level. They are
 the most reliable source for obtaining pages and should preffered over passing the html to the
 models yourself.
@@ -10,19 +10,17 @@ models yourself.
    :local:
    :backlinks: none
 
-Search
--------
-.. autoclass:: moddb.base.Search
-    :members:
-    :inherited-members:
-
 search
 --------
 .. autofunction:: moddb.base.search
 
-parse
+parse_page
 -------
-.. autofunction:: moddb.base.parse
+.. autofunction:: moddb.base.parse_page
+
+parse_results
+--------------
+.. autofunction:: moddb.base.parse_results
 
 login
 ------
diff --git a/docs/source/boxes.rst b/docs/source/boxes.rst
index 3cfa3ea..9cea737 100644
--- a/docs/source/boxes.rst
+++ b/docs/source/boxes.rst
@@ -22,7 +22,12 @@ ResultList
 .. autoclass:: moddb.boxes.ResultList
     :members:
     :inherited-members:
-    
+
+ResultList
+------------
+.. autoclass:: moddb.pages.ReviewList
+    :members:
+    :inherited-members:    
 
 Statistics
 -----------
@@ -52,20 +57,6 @@ Thumbnail
     :inherited-members:
     
 
-Update
------------
-.. autoclass:: moddb.boxes.Update
-    :members:
-    :inherited-members:
-    
-
-Request
------------
-.. autoclass:: moddb.boxes.Request
-    :members:
-    :inherited-members:
-    
-
 Comment
 --------
 .. autoclass:: moddb.boxes.Comment
@@ -113,4 +104,11 @@ Option
 .. autoclass:: moddb.boxes.Option
     :members:
     :inherited-members:
+
+
+Mirror
+-------
+.. autoclass:: moddb.boxes.Mirror
+    :members:
+    :inherited-members:
     
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index f07aa05..ea422bf 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -8,6 +8,40 @@ The page attempt to keep a clear list of breaking/non-breaking changes and new f
    :local:
    :backlinks: none
 
+v0.8.0
+-------
+Bug Fixes
+##########
+* Improved parsing stability accross the board
+* Fixed :func:`Addon.save`
+* Fixed large memory usage of saving addons
+* 
+
+New Features
+#############
+* Ratelimits implemented: 1 request every 5 seconds up to 30 every 5 minutes.
+* New error :class:`AwaitingAuthorisation`
+* Monolithic pages.py split into submodule
+* `parse` renamed to `parse_page`
+* Added :func:`parse_results` to allow parsing any result list regardless of native support (such as tags)
+* New support for mirrors as with the class :class:`Mirror` and `mirror` keyword for :func:`Addon.save` and :func:`File.save`
+* New :func:`Comment.is_stale` method to make it easier to detect if a comment can still be used
+* Renamed `ResultList.page` to `ResultList.current_page`
+* Renamed `ResultList.max_page` to `ResultList.total_pages`
+* Added `ResultList.total_results` to make it easier to access the total number of results
+* Updated enums and included script for future updates
+* Added `get_watcher` method to several page objects
+
+
+Removed Features
+#################
+* Remove :class:`Search`, fused together back with :class:`ResultList`
+* Removed `feedparser` dependency, RSS feed methods now only retun the URL
+* Removed under the hood `robobrowser` dependency
+* Removed obsolete `Engine.get_addons` method
+* Removed `page_type` parameter in :func:`parse_page`
+
+
 v0.7
 -----
 Bug Fixes
diff --git a/docs/source/client.rst b/docs/source/client.rst
index 2afe347..0516e81 100644
--- a/docs/source/client.rst
+++ b/docs/source/client.rst
@@ -1,12 +1,27 @@
 .. currentmodule:: moddb.enums
 
-Client
-=======
+Authenticating 
+===============
 The client object allows you to manipulate a user object that is logged in. Each client object represents a logged in user on which you can do a set of operations and requests. This gives you more interactivity with the website beyond simply getting random page. You can track, untrack pages and get the updates to those pages.
 
 The client also supports __enter__ and __exit__ as means to temporarily replace the module's session with the client's. See snippets for an example.
 
+Client
+---------
 .. autoclass:: moddb.client.Client
     :members:
     :inherited-members:
+
+Update
+-----------
+.. autoclass:: moddb.client.Update
+    :members:
+    :inherited-members:
+    
+
+Request
+-----------
+.. autoclass:: moddb.client.Request
+    :members:
+    :inherited-members:
     
\ No newline at end of file
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 2e60dbf..48b6959 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -14,19 +14,20 @@
 #
 import os
 import sys
-sys.path.insert(0, os.path.abspath('../..'))
+
+sys.path.insert(0, os.path.abspath("../.."))
 
 
 # -- Project information -----------------------------------------------------
 
-project = 'moddb'
-copyright = '2018, Clement Julia'
-author = 'Clement Julia'
+project = "moddb"
+copyright = "2022, Clement Julia"
+author = "Clement Julia"
 
 # The short X.Y version
-version = ''
+version = ""
 # The full version, including alpha/beta/rc tags
-release = '1.0'
+release = "0.8.0"
 
 
 # -- General configuration ---------------------------------------------------
@@ -39,28 +40,28 @@
 # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
 # ones.
 extensions = [
-    'sphinx.ext.autodoc',
-    'sphinx.ext.extlinks',
-    'sphinx.ext.intersphinx',
-    'sphinx.ext.coverage',
-    'sphinx.ext.githubpages',
-    'sphinx.ext.viewcode',
-    'sphinx.ext.napoleon',
-    'sphinx.ext.autosummary',
-    'sphinx_autodoc_typehints',
+    "sphinx.ext.autodoc",
+    "sphinx.ext.extlinks",
+    "sphinx.ext.intersphinx",
+    "sphinx.ext.coverage",
+    "sphinx.ext.githubpages",
+    "sphinx.ext.viewcode",
+    "sphinx.ext.napoleon",
+    "sphinx.ext.autosummary",
+    "sphinx_autodoc_typehints",
 ]
 
 # Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
 
 # The suffix(es) of source filenames.
 # You can specify multiple suffix as a list of string:
 #
 # source_suffix = ['.rst', '.md']
-source_suffix = '.rst'
+source_suffix = ".rst"
 
 # The master toctree document.
-master_doc = 'index'
+master_doc = "index"
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
@@ -83,7 +84,7 @@
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
 #
-html_theme = 'alabaster'
+html_theme = "alabaster"
 
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
@@ -94,7 +95,7 @@
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = ["_static"]
 
 # Custom sidebar templates, must be a dictionary that maps document names
 # to template names.
@@ -110,7 +111,7 @@
 # -- Options for HTMLHelp output ---------------------------------------------
 
 # Output file base name for HTML help builder.
-htmlhelp_basename = 'moddbdoc'
+htmlhelp_basename = "moddbdoc"
 
 
 # -- Options for LaTeX output ------------------------------------------------
@@ -119,15 +120,12 @@
     # The paper size ('letterpaper' or 'a4paper').
     #
     # 'papersize': 'letterpaper',
-
     # The font size ('10pt', '11pt' or '12pt').
     #
     # 'pointsize': '10pt',
-
     # Additional stuff for the LaTeX preamble.
     #
     # 'preamble': '',
-
     # Latex figure (float) alignment
     #
     # 'figure_align': 'htbp',
@@ -137,8 +135,7 @@
 # (source start file, target name, title,
 #  author, documentclass [howto, manual, or own class]).
 latex_documents = [
-    (master_doc, 'moddb.tex', 'moddb Documentation',
-     'Clement Julia', 'manual'),
+    (master_doc, "moddb.tex", "moddb Documentation", "Clement Julia", "manual"),
 ]
 
 
@@ -146,10 +143,7 @@
 
 # One entry per manual page. List of tuples
 # (source start file, name, description, authors, manual section).
-man_pages = [
-    (master_doc, 'moddb', 'moddb Documentation',
-     [author], 1)
-]
+man_pages = [(master_doc, "moddb", "moddb Documentation", [author], 1)]
 
 
 # -- Options for Texinfo output ----------------------------------------------
@@ -158,9 +152,15 @@
 # (source start file, target name, title, author,
 #  dir menu entry, description, category)
 texinfo_documents = [
-    (master_doc, 'moddb', 'moddb Documentation',
-     author, 'moddb', 'One line description of project.',
-     'Miscellaneous'),
+    (
+        master_doc,
+        "moddb",
+        "moddb Documentation",
+        author,
+        "moddb",
+        "One line description of project.",
+        "Miscellaneous",
+    ),
 ]
 
 
@@ -179,7 +179,7 @@
 # epub_uid = ''
 
 # A list of files that should not be packed into the epub file.
-epub_exclude_files = ['search.html']
+epub_exclude_files = ["search.html"]
 
 
 # -- Extension configuration -------------------------------------------------
@@ -189,7 +189,7 @@
 # -- Options for intersphinx extension ---------------------------------------
 
 # Example configuration for intersphinx: refer to the Python standard library.
-intersphinx_mapping = {'https://docs.python.org/': None}
+intersphinx_mapping = {"https://docs.python.org/": None}
 
-autodoc_default_flags = ['members']
+autodoc_default_flags = ["members"]
 autosummary_generate = True
diff --git a/docs/source/index.rst b/docs/source/index.rst
index ae3f049..d3f51e0 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.
 
@@ -54,13 +54,14 @@ Basic Usage
 The simplest way to use this library is to simply pass a ModDB url to the parse function and let the magic happen:: 
 
    import moddb
-   mod = moddb.parse("http://www.moddb.com/mods/edain-mod")
+   mod = moddb.parse_page("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(
+   mod = moddb.parse_page(
                "http://www.moddb.com/mods/edain-mod", 
                page_type=moddb.ThumbnailType.mod
             )
@@ -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/docs/source/snippets.rst b/docs/source/snippets.rst
index ee983d3..97b2001 100644
--- a/docs/source/snippets.rst
+++ b/docs/source/snippets.rst
@@ -5,23 +5,6 @@ Examples and Snippets
 =======================
 Here are presented some up to date snippets of things that can be done with the library.
 
-Getting every page of a search
---------------------------------
-This has now been fully implemented with the :class: `.Search.get_all_results()` method.
-
-Reusing filters
-----------------
-Filters are a cool thing, they allow you to limit the amount of result you return to be more tailored to what you're actually looking for. But what if you want to do multiple searches with the same filters? You could save them as a dict which you then unpack into the search function and keep saved somewhere sure, but why bother, the Search object already stores the filters so just piggy off that and you'll be fine.::
-
-    import moddb
-    #I swear I don't have a LOTR bias. Also, this is a simple example, assume we have dozens of filters.
-    fake_game = moddb.Object(id=10009)
-    search = moddb.search(moddb.SearchCategory.mods, game=fake_game)
-    #so now we have a set of filters, now let's say we wanna fine tune the search a bit more, let's says we also only want
-    #the mods that have been released in the last year
-    search = moddb.search(moddb.SearchCategory.mods, timeframe=moddb.TimeFrame.year, **search.filters)
-
-
 The Client
 ------------
 Clients represents an independtly logged in user. This allows you to make certain requests more easily, they don't need to be logged in to the library's session but rather those independent sessions can be used to make requests. They can be used to make requests with the methods provided by Client but can also be used to overwrite the module wide session to make any requests with any user. Firt, a simple example of using client:: 
@@ -47,7 +30,7 @@ A simple little example, users have updates which happen when pages upload new a
 
     #now we want to get something using that user's account, like their profile
     with client:
-        member = moddb.parse("https://www.moddb.com/members/regularuser")
+        member = moddb.parse_page("https://www.moddb.com/members/regularuser")
 
     #now we can see this profile from the persepctive of RegularUser.
 
diff --git a/moddb/__init__.py b/moddb/__init__.py
index 8bcf51d..1f710ed 100644
--- a/moddb/__init__.py
+++ b/moddb/__init__.py
@@ -1,11 +1,11 @@
-from .base import search, parse, login, logout, front_page, rss
+from .base import search, parse_page, parse_results, login, logout, front_page, rss
 from .utils import LOGGER, BASE_URL, soup, request, get_page, Object
 from .enums import *
 from .pages import *
 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..bf19dee 100644
--- a/moddb/base.py
+++ b/moddb/base.py
@@ -1,184 +1,31 @@
-from .enums import SearchCategory, ThumbnailType, RSSType
-from .boxes import Thumbnail
-from .utils import get_page, LOGGER, normalize, get_type_from, BASE_URL, request
+import requests
+from .enums import SearchCategory, RSSType
+from .boxes import ResultList, _parse_results
+from .utils import (
+    get_page,
+    get_page_type,
+    BASE_URL,
+    generate_login_cookies,
+    request,
+    soup,
+)
 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"]
+__all__ = ["search", "parse_page", "login", "logout", "front_page", "parse_results"]
 
-class Search(collections.abc.MutableSequence):
-    """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
-    -----------
-    results : List[Thumbnail]
-        The list of results the search returned
-    category : ThumbnailType
-        The type results
-    filters : Dict[str : Enum]
-        The dict of filters that was used to search for the results
-    max_page : int
-        The number of pages
-    page : int
-        The current page, range is 1-max_page included
-    query : str
-        The text query that was used in the search
-    results_max : int
-        The total number of results for this search
-    filters : dict
-        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")
-        self.filters = kwargs.get("filters")
-        self.max_page = kwargs.get("max_page")
-        self.page = kwargs.get("page")
-        self.query = kwargs.get("query")
-        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 
-        if the last page is the current one
-
-        Returns
-        --------
-        Search
-            The new search objects containing a new set of results.
-
-        Raises
-        -------
-        ValueError
-            There is no next page
-        """
-        if self.page == self.max_page:
-            raise ValueError("Reached last page already")
-
-        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 
-        ValueError if the first page is the current one
-
-        Returns
-        --------
-        Search
-            The new search objects containing a new set of results.
-
-        Raises
-        -------
-        ValueError
-            There is no previous page
-        """
-        if self.page == 1:
-            raise ValueError("Reached first page already")
-
-        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 
-        of bounds.
-    
-        Parameters
-        -----------
-        page : int
-            A page number within the range 1 - max_page inclusive
-
-        Returns
-        --------
-        Search
-            The new search objects containing a new set of results.
-
-        Raises
-        -------
-        ValueError
-            This page does not exist
-        """
-        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)
-
-    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
-        -----------
-        new_sort : Tuple[str, str]
-            The new sorting tuple to check by
-
-        Returns
-        -------
-        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)
-
-    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. 
-
-        Returns
-        --------
-        List[Thumbnail]
-            The list of things you were searching for
-        """
-        search = self.to_page(1)
-        results = list(search)
-
-        while True:
-            try:
-                search = search.next_page()
-            except ValueError:
-                break
-            else:
-                results.extend(search)
-                LOGGER.info("Parsed page %s/%s", search.page, search.max_page)
-
-        return list(toolz.unique(results, key=lambda element: element.name))
-
-    def __repr__(self):
-        return f"<Search category={self.category.name} pages={self.page}/{self.max_page}, results={self._results}>"
-
-    def __getitem__(self, element):
-        return self._results.__getitem__(element)
-
-    def __delitem__(self, element):
-        self._results.__delitem__(element)
-
-    def __len__(self):
-        return self._results.__len__()
-
-    def __setitem__(self, key, value):
-        self._results.__setitem__(key, value)
-
-    def insert(self, index, value):
-        self._results.insert(index, value)
-
-    def __add__(self, sequence):
-        if not isinstance(sequence, 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,
+) -> ResultList:
+    """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 +41,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
@@ -203,7 +50,7 @@ def search(category : SearchCategory, *, query : str = None, sort : Tuple[str, s
 
     Returns
     --------
-    Search
+    ResultList
         The search object containing the current query settings (so at to be able to redo the search easily),
         pagination metadata and helper methods to navigate the list of results.
     """
@@ -213,44 +60,41 @@ 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")}
-    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
-    })
-
-    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)
-    except AttributeError:
-        LOGGER.info("Search query %s has less than 30 results (only one page)", url)
-        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: 
+    filter_parsed = {key: value.value for key, value in filters.items() if hasattr(value, "value")}
+
+    params = {
+        "filter": "t",
+        "kw": query,
+        "sort": sort_ready,
+        "game": game,
+        "year": filters.get("years", None),
+        **filter_parsed,
+    }
+
+    html = get_page(
+        url,
+        params=params,
+    )
+
+    results, current_page, total_pages, total_results = _parse_results(html)
+
+    return ResultList(
+        results=results,
+        total_pages=total_pages,
+        current_page=current_page,
+        params=params,
+        url=f"{BASE_URL}/{category.name}",
+        total_results=total_results,
+    )
+
+
+def parse_page(url: str) -> Any:
     """Parse a url and return the appropriate object.
 
     Parameters
     ------------
     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 
-        something to happen out of the regular behavior the option is there.
 
     Returns
     --------
@@ -260,13 +104,54 @@ def parse(url : str, *, page_type : ThumbnailType = None) -> Any:
     """
 
     html = get_page(url)
-    page_type = page_type or get_type_from(url)
+    page_type = get_page_type(url)
 
     model = getattr(sys.modules["moddb"], page_type.name.title())(html)
     return model
 
 
-def login(username : str, password : str) -> Member: 
+def parse_results(url: str, *, params: dict = {}) -> ResultList:
+    """Parse a list of results and return them as a
+    list of thumbnails.
+
+    Parameters
+    -----------
+    url : str
+        The url of the result list to parse
+
+    Returns
+    --------
+    ResultList
+        The list of thumbnails, wrapped in a ResultList
+        so as to benefit from the helper methods that
+        help with navigation
+    """
+
+    resp = request(requests.Request("GET", url, params=params))
+    html = soup(resp.text)
+
+    results, current_page, total_pages, total_results = _parse_results(html)
+
+    parts = resp.url.split("?")
+    url = parts[0].split("/page")[0]
+
+    filters = {}
+    if len(parts) > 1:
+        for filter in parts[1].split("&"):
+            key, value = filter.split("=")
+            filters[key] = value
+
+    return ResultList(
+        results=results,
+        total_pages=total_pages,
+        current_page=current_page,
+        url=url,
+        total_results=total_results,
+        params=filters,
+    )
+
+
+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 +173,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 +193,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..e7f8bf7 100644
--- a/moddb/boxes.py
+++ b/moddb/boxes.py
@@ -1,18 +1,58 @@
-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_list_stats,
+    get_page,
+    get_views,
+    join,
+    normalize,
+    LOGGER,
+    time_mapping,
+    get_page_type,
+    get,
+)
 
 import re
 import sys
 import toolz
+import datetime
 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",
+    "MissingComment",
+    "MemberProfile",
+    "MemberStatistics",
+    "PlatformStatistics",
+    "PartialArticle",
+    "Option",
+    "Mirror",
+    "ResultList",
+    "CommentList",
+]
+
 
 class Statistics:
     """The stats box, on pages that have one. This represents total stats and daily stats in one
@@ -45,9 +85,21 @@ 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)
@@ -64,11 +116,12 @@ def __init__(self, html):
     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 +147,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 +168,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,24 +181,38 @@ 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)
@@ -161,12 +229,43 @@ 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
@@ -186,11 +285,13 @@ def __init__(self, html):
 
             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]
+                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 +309,25 @@ 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 +342,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,13 +364,14 @@ 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]))
@@ -275,10 +386,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 +407,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,112 +431,100 @@ 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 
-    methods.
-
-    Attributes
-    -----------
-    updates : List[Thumbnail]
-        A list of thumbnail objects of the things thave have been posted (new files, new images)
-    """
-
-    def __init__(self, **attrs):
-        super().__init__(**attrs)
-
-        self.updates = attrs.get("updates")
-        self._unfollow_url = join(attrs.get("unfollow"))
-        self._clear_url = join(attrs.get("clear"))
-        self._client = attrs.get("client")
-
-    def __repr__(self):
-        return f"<Update name={self.name} type={self.type.name} updates={len(self.updates)}>"
-
-    def clear(self):
-        """Clears all updates
-
-        Raises
-        -------
-        ModdbException
-            An error has occured while trying to clear the updates for this page
 
-        Returns
-        --------
-        bool
-            True if the updates were successfully cleared
-        """
-        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
-            An error has occured while trying to unfollow this page
-
-        Returns
-        --------
-        bool
-            True if the page was successfully unfollowed
-        """
-        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)
-
-        self._decline = join(attrs.get("decline"))
-        self._accept = join(attrs.get("accept"))
-        self._client = attrs.get("client")
-
-    def accept(self):
-        """Accept the friend request.
-
-        Raises
-        -------
-        ModdbException
-            An error has occured while trying to accept the request
-
-        Returns
-        --------
-        bool
-            True if the request was successfully accepted
-        """
-        r = self._client._request("post", self._accept, data = {"ajax": "t"})
-
-        return "now friends with" in r.json()["text"]
-
-    def decline(self):
-        """Decline the friend request
-
-        Raises
-        -------
-        ModdbException
-            An error has occured while trying to decline the request
-
-        Returns
-        --------
-        bool
-            True if the page was successfully declined
-        """
-        r = self._client._request("post", self._decline, data = {"ajax": "t"})
+def _parse_results(html):
+    result_box = html.find("div", class_="normalbox browsebox")
+    try:
+        search_raws = (
+            result_box.find("div", class_="inner")
+            .find("div", class_="table")
+            .find_all("div", class_=["rowcontent"])
+        )
+    except AttributeError:
+        return [], 1, 1, 0
+
+    results = []
+    try:
+        for obj in search_raws:
+            date = obj.find("time")
+            summary = obj.find("p")
+            results.append(
+                Thumbnail(
+                    name=obj.a["title"],
+                    url=obj.a["href"],
+                    image=obj.a.img["src"],
+                    type=get_page_type(join(obj.a["href"])),
+                    summary=summary.string if summary else None,
+                    date=get_date(date["datetime"]) if date else None,
+                )
+            )
+    except (TypeError, KeyError):
+        # parse as a title-content pair of articles
+        LOGGER.info("Parsing articles as key-pair list")
+        for title, content in zip(search_raws[::2], search_raws[1::2]):
+            date = title.find("time")
+            url = title.find("h4").a
+            results.append(
+                Thumbnail(
+                    name=url.text,
+                    url=url["href"],
+                    image=None,
+                    type=get_page_type(join(url["href"])),
+                    summary=content.text,
+                    date=get_date(date["datetime"]) if date else None,
+                )
+            )
+
+    current_page, total_page, total_results = get_list_stats(result_box)
+    if total_results is None:
+        total_results = len(results)
+
+    return results, current_page, total_page, total_results
+
+
+def _parse_comments(html):
+    comments = []
+    comment_box = html.find("div", id="comments")
+    if comment_box is None:
+        return [], 1, 1, 0
+
+    current_page, total_page, total_results = get_list_stats(comment_box)
+
+    try:
+        url = html.find("meta", property="og:url")["content"]
+    except TypeError:
+        url = join(html.find("a", itemprop="mainEntityOfPage")["href"])
+
+    comments_raw = comment_box.find("div", class_=["tablecomments"]).find_all(
+        "div", class_="row", recursive=False
+    )
+    if total_results is None:
+        total_results = len(comments_raw)
+
+    for raw in comments_raw:
+        comment = Comment(raw)
+        comment._url = f"{url}/page/{current_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 "successfully removed" in r.json()["text"]
+    return comments, current_page, total_page, total_results
 
-    def __repr__(self):
-        return f"<Request from={self.name}>"
 
 class Comment:
     """A moddb comment object.
-    
+
     Parameters
     -----------
     html : bs4.Tag
@@ -468,14 +569,21 @@ 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")
+        self._fetch_time = datetime.datetime.utcnow()
 
-        position = html["class"] 
+        position = html["class"]
         if "reply1" in position:
             self.position = 1
         elif "reply2" in position:
@@ -490,7 +598,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:
@@ -518,20 +630,40 @@ def __init__(self, html):
         self.location = html.find("a", class_="related")
         if self.location is not None:
             url = join(self.location["href"])
-            page_type = get_type_from(url)
+            page_type = get_page_type(url)
             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:
+        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 self._fetch_time + datetime.timedelta(minute=30) > datetime.datetime.utcnow()
+
     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 +684,15 @@ 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}>"
 
+    def is_stale(self):
+        return True
+
+
 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,8 +727,11 @@ 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"]
 
@@ -609,13 +748,13 @@ def __init__(self, html):
             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()
 
@@ -628,8 +767,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,19 +800,25 @@ 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)
+        self.time = time_mapping[mapping.replace("s", "")] * int(time)
 
         try:
             rank = normalize(html.find("h5", string="Rank").parent.span.string).split("of")
@@ -686,9 +832,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,13 +854,20 @@ 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>"
+        return "<PlatformStatistics>"
+
 
 class PartialArticle:
     """A partial article is an article object missing attributes due to being parsed from the front page
@@ -737,9 +891,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 +902,9 @@ 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 +915,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 +925,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 +935,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,44 +951,48 @@ def __init__(self, **kwargs):
     def __repr__(self):
         return f"<Option text={self.text}>"
 
+
 class ModDBList(collections.abc.MutableSequence):
     """Base List type for the lib
 
     Attributes
     -----------
-    page : int
-        The page of results this page represents
-    max_page : int
-        The maximum amount of result pages available
-    _results : list
-        The list of results this object emulates
-    _params : dict
-        the dict of pre-processed params (filters, query & sort) that lead to this
-        result list, can be unprocessed params in some occasions
-    _url : str
-        The url to getting this list of results, also contains what page the list of
-        results is from
-    _action : callable
-        The callable that is used when methods such as next_page or previous_page are
-        used to get another set of results. Must be able to take a positional argument `url`
-        and a keyword argument `params`.
+    current_page : int
+        The page of results this objects represents
+    total_pages : int
+        The total amount of result pages available
+    total_results : int
+        The total amount of results available
     """
-    def __init__(self, **kwargs):
-        self._results = kwargs.get("results")
-        self._params = kwargs.get("params", {})
-        self._url = kwargs.get("url")
-        self._action = kwargs.get("action")
-        self.max_page = kwargs.get("max_page")
-        self.page = kwargs.get("page")
-
-    def _do_action(self, url, params={}):
-        if params:
-            return self._action(url, params=params)
-
-        return self._action(url)
 
-
-    def next_page(self) -> Union['ResultList', 'CommentList']:
+    def __init__(self, **kwargs):
+        self._results = kwargs.pop("results")
+        self._params = kwargs.pop("params", {})
+        self._url = kwargs.pop("url")
+        self.total_pages = kwargs.pop("total_pages")
+        self.current_page = kwargs.pop("current_page")
+        self.total_results = kwargs.pop("total_results")
+
+    def _parse_method(self, html):
+        raise NotImplementedError
+
+    def _do_request(self, **kwargs):
+        page = kwargs.pop("page", self.current_page)
+        params = {**self._params, **kwargs}
+
+        html = get_page(f"{self._url}/page/{page}", params=params)
+        results, current_page, total_pages, total_results = self._parse_method(html)
+
+        return self.__class__(
+            results=results,
+            params=params,
+            url=self._url,
+            total_pages=total_pages,
+            current_page=current_page,
+            total_results=total_results,
+        )
+
+    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.
 
@@ -843,12 +1006,12 @@ def next_page(self) -> Union['ResultList', 'CommentList']:
         ValueError
             There is no next page
         """
-        if self.page == self.max_page:
+        if self.current_page == self.total_pages:
             raise ValueError("Reached last page already")
 
-        return self.to_page(self.page+1)
+        return self.to_page(self.current_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.
 
@@ -862,15 +1025,15 @@ def previous_page(self) -> Union['ResultList', 'CommentList']:
         ValueError
             There is no previous page
         """
-        if self.page == 1:
+        if self.current_page == 1:
             raise ValueError("Reached first page already")
 
-        return self.to_page(self.page-1)
+        return self.to_page(self.current_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
@@ -886,16 +1049,15 @@ def to_page(self, page : int) -> Union['ResultList', 'CommentList']:
         ValueError
             This page does not exist
         """
-        if page < 1 or page > self.max_page:
-            raise ValueError(f"Please pick a page between 1 and {self.max_page}")
+        if page < 1 or page > self.total_pages:
+            raise ValueError(f"Please pick a page between 1 and {self.total_pages}")
 
-        new_url = self._url.replace(self._url.split("/")[-1], str(page))
-        return self._do_action(new_url, params=self._params)
+        return self._do_request(page=page)
 
     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
         --------
@@ -912,7 +1074,7 @@ def get_all_results(self):
                 break
             else:
                 results.extend(search)
-                LOGGER.info("Parsed page %s/%s", search.page, search.max_page)
+                LOGGER.info("Parsed page %s/%s", search.current_page, search.total_pages)
 
         def key_check(element):
             if isinstance(element, Comment):
@@ -924,7 +1086,7 @@ def key_check(element):
         return search
 
     def __repr__(self):
-        return f"<{self.__class__.__name__} pages={self.page}/{self.max_page}, results={self._results}>"
+        return f"<{self.__class__.__name__} pages={self.current_page}/{self.total_pages}, results={self._results}>"
 
     def __getitem__(self, element):
         return self._results.__getitem__(element)
@@ -941,23 +1103,29 @@ 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
 
     Attributes
     -----------
-    page : int
-        The page of results this page represents
-    max_page : int
-        The maximum amount of result pages available
+    current_page : int
+        The page of results this objects represents
+    total_pages : int
+        The total amount of result pages available
+    total_results : int
+        The total amount of results available
     """
 
-    def resort(self, new_sort : Tuple[str, str]) -> 'ResultList': 
+    def _parse_method(self, html):
+        return _parse_results(html)
+
+    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
@@ -970,8 +1138,7 @@ def resort(self, new_sort : Tuple[str, str]) -> 'ResultList':
         ResultList
             The new set of results with the updated sort order
         """
-        self._params["sort"] = f"{new_sort[0]}-{new_sort[1]}"
-        return self._do_action(self._url, params=self._params)
+        return self._do_request(sort=f"{new_sort[0]}-{new_sort[1]}")
 
     def __contains__(self, element):
         return get(self._results, name=element.name) is not None
@@ -980,32 +1147,37 @@ 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
-        The page of comments this page represents
-    max_page : int
-        The maximum amount of comment pages available
+    current_page : int
+        The page of results this objects represents
+    total_pages : int
+        The total amount of result pages available
+    total_results : int
+        The total amount of results available
     """
 
+    def _parse_method(self, html):
+        return _parse_comments(html)
+
     def __contains__(self, element):
         return get(self._results, name=element.name) is not None
 
     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 +1191,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..a584fda 100644
--- a/moddb/client.py
+++ b/moddb/client.py
@@ -1,20 +1,145 @@
 import sys
-import json
-import time
 import random
+from pyrate_limiter import Duration, Limiter, RequestRate
 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 .boxes import Update, Thumbnail, Request, Comment, ResultList
-from .pages import Member, Group, Mod, Game, Engine, Team
-from .enums import ThumbnailType, WatchType, Status
+from .utils import (
+    concat_docs,
+    generate_login_cookies,
+    join,
+    soup,
+    get_page_type,
+    get_date,
+    BASE_URL,
+    generate_hash,
+    get,
+    LOGGER,
+    user_agent_list,
+    raise_for_status,
+    LIMITER,
+)
+from .boxes import Thumbnail, Comment, ResultList, _parse_results
+from .pages import Member
+from .enums import WatchType, Status
 from .errors import ModdbException
-from .base import parse
+from .base import parse_page
+
+COMMENT_LIMITER = Limiter(RequestRate(1, Duration.MINUTE))
+
+
+@concat_docs
+class Update(Thumbnail):
+    """An update object. Which is basically just a fancy thumbnail with a couple extra attributes and
+    methods.
+
+    Attributes
+    -----------
+    updates : List[Thumbnail]
+        A list of thumbnail objects of the things thave have been posted (new files, new images)
+    """
+
+    def __init__(self, **attrs):
+        super().__init__(**attrs)
+
+        self.updates = attrs.get("updates")
+        self._unfollow_url = join(attrs.get("unfollow"))
+        self._clear_url = join(attrs.get("clear"))
+        self._client = attrs.get("client")
+
+    def __repr__(self):
+        return f"<Update name={self.name} type={self.type.name} updates={len(self.updates)}>"
+
+    def clear(self):
+        """Clears all updates
+
+        Raises
+        -------
+        ModdbException
+            An error has occured while trying to clear the updates for this page
+
+        Returns
+        --------
+        bool
+            True if the updates were successfully cleared
+        """
+        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
+            An error has occured while trying to unfollow this page
+
+        Returns
+        --------
+        bool
+            True if the page was successfully unfollowed
+        """
+        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.
+
+    Attributes
+    -----------
+    """
+
+    def __init__(self, **attrs):
+        super().__init__(**attrs)
+
+        self._decline = join(attrs.get("decline"))
+        self._accept = join(attrs.get("accept"))
+        self._client = attrs.get("client")
+
+    def accept(self):
+        """Accept the friend request.
+
+        Raises
+        -------
+        ModdbException
+            An error has occured while trying to accept the request
+
+        Returns
+        --------
+        bool
+            True if the request was successfully accepted
+        """
+        r = self._client._request("POST", self._accept, data={"ajax": "t"})
+
+        return "now friends with" in r.json()["text"]
+
+    def decline(self):
+        """Decline the friend request
+
+        Raises
+        -------
+        ModdbException
+            An error has occured while trying to decline the request
+
+        Returns
+        --------
+        bool
+            True if the page was successfully declined
+        """
+        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 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,28 +160,15 @@ 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._last_comment_time = 0
+        self.member = Member(
+            soup(self._request("GET", f"{BASE_URL}/members/{username.replace('_', '-')}").text)
+        )
 
     def __repr__(self):
         return f"<Client username={self.member.name} level={self.member.profile.level}>"
@@ -69,42 +181,47 @@ 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)
-
-    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()
+        headers = {
+            **kwargs.pop("headers", {}),
+            "User-Agent": random.choice(user_agent_list),
+        }
+
+        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,24 +230,34 @@ 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_page_type(thumbnail["href"]),
+                    image=thumbnail.img["src"],
+                    client=self,
+                    unfollow=unfollow,
+                    clear=clear,
+                    updates=[
+                        Thumbnail(name=x.string, url=x["href"], type=get_page_type(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")
@@ -141,17 +268,24 @@ def get_friend_requests(self):
             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_page_type(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
         -----------
@@ -166,25 +300,21 @@ def get_watched(self, category :  WatchType, page : int = 1):
             List of watched things
 
         """
-        url = f"{BASE_URL}/messages/watching/{category.name}s/page/{page}"
-        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]
-
-        page, max_page = get_page_number(html)
+        url = f"{BASE_URL}/messages/watching/{category.name}s"
+        html = soup(self._request("GET", f"{url}/page/{page}").text)
+        results, current_page, total_pages, total_results = _parse_results(html)
 
         return ResultList(
             results=results,
             url=url,
-            action=self.get_watched,
-            page=page,
-            max_page=max_page
+            current_page=current_page,
+            total_pages=total_pages,
+            total_results=total_results,
         )
 
     def tracking(self, page):
         """Follow/unfollow this page.
-        
+
         Parameters
         -----------
         page : Union[Mod, Game, Engine, Group, Member]
@@ -200,19 +330,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 +362,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 +397,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 +431,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 +458,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 +490,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 +518,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,46 +536,48 @@ 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")
-
-        r = self._request("post", page.url,
-            data = {
+        COMMENT_LIMITER.try_acquire(self.member.name_id, delay=True)
+        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()
-
         return page.__class__(soup(r.text))
 
     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
@@ -459,7 +605,7 @@ def delete_comment(self, comment):
             True if the comment was successfully deleted
         """
         with self:
-            page = parse(comment._url)
+            page = parse_page(comment._url)
             updated_comment = get(page.comments, id=comment.id)
 
         r = self._comment_state_update(updated_comment)
@@ -489,17 +635,20 @@ def undelete_comment(self, comment):
             True if the comment was successfully undeleted
         """
         with self:
-            page = parse(comment._url)
-            updated_comment = get(page._get_comments_from_url(comment._url, show_deleted=True), id=comment.id)
+            page = parse_page(comment._url)
+            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 +664,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.
@@ -554,27 +701,29 @@ def add_review(self, page, rating, *, text = None, has_spoilers = False):
             True of the review was successfuly submitted.
 
         """
-        if not (2 < rating < 9) and review is None:
-            raise ModdbException("Please include a review to justify such a low/high rating.") 
+        if not (2 < rating < 9) and text is None:
+            raise ModdbException("Please include a review to justify such a low/high rating.")
 
         with self:
-            page = parse(page.url)    
+            page = parse_page(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 +747,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..cfec1dc 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,126 @@ 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 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 +245,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..54cea67 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(Exception):
+    """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..771132a
--- /dev/null
+++ b/moddb/pages/__init__.py
@@ -0,0 +1,33 @@
+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, ReviewList
+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",
+    "ReviewList",
+]
diff --git a/moddb/pages/article.py b/moddb/pages/article.py
new file mode 100644
index 0000000..4e409c1
--- /dev/null
+++ b/moddb/pages/article.py
@@ -0,0 +1,170 @@
+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..3093f99
--- /dev/null
+++ b/moddb/pages/base.py
@@ -0,0 +1,619 @@
+import re
+import bs4
+from typing import List
+
+from ..utils import (
+    join,
+    LOGGER,
+    get_page,
+    get_media_type,
+    get_page_type,
+)
+from ..boxes import (
+    CommentList,
+    Thumbnail,
+    ResultList,
+    Profile,
+    Statistics,
+    PartialArticle,
+    Style,
+    _parse_comments,
+    _parse_results,
+)
+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 entity, 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.
+
+        Parameters
+        -----------
+        html : bs4.BeautifulSoup
+            The html containing the comments
+
+        Returns
+        --------
+        CommentList
+            The list of parsed comments
+        """
+        results, current_page, total_pages, total_results = _parse_comments(html)
+
+        return CommentList(
+            results=results,
+            current_page=current_page,
+            total_pages=total_pages,
+            url=self.url,
+            total_results=total_results,
+        )
+
+    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
+        -------
+        ResultList[Thumbnail]
+            The list of objects present on the page as a list of thumbnails.
+        """
+        html = get_page(url, params=params)
+        results, current_page, total_pages, total_results = _parse_results(html)
+
+        return ResultList(
+            results=results,
+            params=params,
+            url=url,
+            current_page=current_page,
+            total_pages=total_pages,
+            total_results=total_results,
+        )
+
+    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(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_media_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_page_type(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..23f7b42
--- /dev/null
+++ b/moddb/pages/entity.py
@@ -0,0 +1,495 @@
+import bs4
+from typing import List, Tuple
+
+from ..utils import concat_docs, LOGGER, join, get_page
+from .base import PageMetaClass, BaseMetaClass
+from .mixins import (
+    GetAddonsMixin,
+    GetEnginesMixin,
+    GetGamesMixin,
+    GetModsMixin,
+    GetWaresMixin,
+)
+from ..boxes import (
+    Profile,
+    Statistics,
+    Thumbnail,
+    MemberProfile,
+    MemberStatistics,
+    ResultList,
+    CommentList,
+    _parse_results,
+)
+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,
+        }
+
+        url = f"{self.url}/blogs"
+        html = get_page(f"{url}/page/{index}", params=params)
+        results, current_page, total_pages, total_results = _parse_results(html)
+
+        return ResultList(
+            results=results,
+            params=params,
+            url=url,
+            current_page=current_page,
+            total_pages=total_pages,
+            total_results=total_results,
+        )
+
+    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..5d56da6
--- /dev/null
+++ b/moddb/pages/file.py
@@ -0,0 +1,349 @@
+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..23068a5
--- /dev/null
+++ b/moddb/pages/fp.py
@@ -0,0 +1,148 @@
+import bs4
+import re
+
+from ..boxes import Thumbnail
+from ..enums import ThumbnailType
+from ..utils import get_page_type, join, get_page
+from . import opinion
+
+
+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_page_type(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 = opinion.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..da87f08
--- /dev/null
+++ b/moddb/pages/job.py
@@ -0,0 +1,106 @@
+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..02a5c57
--- /dev/null
+++ b/moddb/pages/mixins.py
@@ -0,0 +1,566 @@
+from typing import Tuple, Union, List
+
+from . import opinion
+
+from ..utils import get_page, Object
+from ..boxes import ResultList, Thumbnail
+from ..enums import (
+    Status,
+    Genre,
+    Theme,
+    Scope,
+    PlayerStyle,
+    TimeFrame,
+    Licence,
+    ArticleCategory,
+    FileCategory,
+    AddonCategory,
+    Difficulty,
+    TutorialCategory,
+    HardwareCategory,
+    SoftwareCategory,
+    RSSType,
+)
+
+
+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,
+        }
+
+        base_url = f"{self.url}/reviews"
+        url = f"{base_url}/page/{index}"
+        html = get_page(url, params=params)
+        results, current_page, total_pages, total_results = opinion.parse_reviews(html)
+
+        return opinion.ReviewList(
+            results=results,
+            url=base_url,
+            total_results=total_results,
+            params=params,
+            current_page=current_page,
+            total_pages=total_pages,
+        )
+
+    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..467065e
--- /dev/null
+++ b/moddb/pages/opinion.py
@@ -0,0 +1,188 @@
+import re
+
+from ..utils import concat_docs, get_date, get_list_stats, join
+from ..boxes import ModDBList, Thumbnail, Option
+from ..enums import ThumbnailType
+from .base import BaseMetaClass
+
+
+class ReviewList(ModDBList):
+    """Represents a list of reviews
+
+    Attributes
+    -----------
+    current_page : int
+        The page of results this objects represents
+    total_pages : int
+        The total amount of result pages available
+    total_results : int
+        The total amount of results available
+    """
+
+    def _parse_method(self, html):
+        return parse_reviews(html)
+
+
+def parse_reviews(html):
+    review_box = html.find("div", class_="normalbox browsebox")
+
+    try:
+        raw_ratings = (
+            review_box.find("div", class_="inner")
+            .find("div", class_="table")
+            .find_all("div", class_=["rowrating"], recursive=False)
+        )
+    except AttributeError:
+        return [], 1, 1, 0
+
+    reviews = []
+    for rating in raw_ratings:
+        next_sibling = rating.find_next_sibling("div", class_=["rowcontent"])
+        if next_sibling is not None and "rowcontentnext" in next_sibling["class"]:
+            reviews.append(Review(rating=rating, text=next_sibling))
+        else:
+            reviews.append(Review(rating=rating))
+
+    return reviews, *get_list_stats(review_box, 10)
+
+
+@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("rating")
+        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..1cd2302
--- /dev/null
+++ b/moddb/pages/platform.py
@@ -0,0 +1,187 @@
+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..64fec8a
--- /dev/null
+++ b/moddb/pages/ware.py
@@ -0,0 +1,178 @@
+from ..utils import concat_docs, LOGGER
+from ..enums import SearchCategory, ThumbnailType
+from ..boxes import Thumbnail
+from .base import HardwareSoftwareMetaClass
+from .mixins import GetGamesMixin, GetWaresMixin
+
+
+@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..e54ddc4 100644
--- a/moddb/utils.py
+++ b/moddb/utils.py
@@ -1,8 +1,11 @@
+import bs4
 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 +14,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 +91,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 +112,93 @@ 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):
+    """Prepared a request with the appropriate cookies"""
+    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 raise_for_status(response):
+    """Raise any error that could have occured"""
+    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):
+    """Log a user in and return the `freeman` cookie containing the login hash"""
+    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}")
 
-def request(url, *, params = {}, post = False):
+    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 +207,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(" ", "+")
-
-    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)})
+    prepped = prepare_request(req, SESSION)
+    r = SESSION.send(prepped)
 
-    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 +238,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 +262,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,28 +277,34 @@ def join(path : str) -> str:
         The full url.
 
     """
-    return urljoin(BASE_URL, path)
+    if not path.startswith(BASE_URL):
+        return urljoin(BASE_URL, path)
 
-def normalize(string : str) -> str:
+    return path
+
+
+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_media_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):
+
+def get_page_type(url: str) -> "ThumbnailType":
     """Get the page type based on a url.
 
     Parameters
     -----------
     url : str
-        The url to get 
+        The url to get
 
     Return
     -------
@@ -241,11 +319,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,38 +333,41 @@ 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
 
-    Parameters
-    -----------
-    html : bs4.BeautifulSoup
-        The html to get the page numbers from
+def ceildiv(a: int, b: int) -> int:
+    "Like a // b but rounded up instead of down."
+    return -(a // -b)
 
-    Returns
-    --------
-    Tuple[int, int]
-        The page and max_page
-    """
-    try:
-        max_page = int(html.find("div", class_="pages").find_all()[-1].string)
-    except AttributeError:
-        LOGGER.info("Has less than 30 comments (only one page)")
-        max_page = 1
 
-    try:
-        page = int(html.find("span", class_="current").string)
-    except AttributeError:
-        LOGGER.info("Has less than 30 results (only one page)")
-        page = 1
+def get_list_stats(result_box: bs4.BeautifulSoup, per_page: int = 30) -> Tuple[int, int, int]:
+    """Get the current page, total pages and total results from
+    a result list"""
+    stats = re.match(
+        r".*\(([0-9,]*) - ([0-9,]*) of ([0-9,]*)\)",
+        result_box.find("div", class_="normalcorner")
+        .find("div", class_="title")
+        .find("span", class_="heading")
+        .string,
+    )
+
+    if not stats:  # less than a page
+        return 1, 1, None
+
+    max_results = int(stats.group(2).replace(",", ""))
+    all_results = int(stats.group(3).replace(",", ""))
+    max_page = ceildiv(all_results, per_page)
+    current_page = ceildiv(max_results, per_page)
+
+    return current_page, max_page, all_results
+
 
-    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 +395,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 +442,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 +453,6 @@ def predicate(elem):
 
     return find(predicate, iterable)
 
+
 def generate_hash():
     return uuid.uuid4().hex
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..4e5354c
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,10 @@
+[tool.flakeheaven]
+max_line_length = 110
+exclude = ["__init__.py"]
+
+[tool.black]
+line-length = 110
+
+[tool.flakeheaven.plugins]
+pyflakes = ["+*", "-F821"]
+"flake8-*" = ["+*"]
diff --git a/requirements.txt b/requirements.txt
index 2e949b0..cea6863 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,15 +1,14 @@
-# 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
+pytest-test-groups==1.0.3
+flakeheaven==1.0.1
+black==22.3.0
diff --git a/run_lint.sh b/run_lint.sh
new file mode 100644
index 0000000..9c04964
--- /dev/null
+++ b/run_lint.sh
@@ -0,0 +1,2 @@
+black --diff  moddb/ tests/
+flakeheaven lint moddb/
diff --git a/scripts/data/enums_base.txt b/scripts/data/enums_base.txt
new file mode 100644
index 0000000..05d9a6f
--- /dev/null
+++ b/scripts/data/enums_base.txt
@@ -0,0 +1,166 @@
+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..9f663c9
--- /dev/null
+++ b/scripts/generate_enums.py
@@ -0,0 +1,144 @@
+import bs4
+import requests
+import re
+import time
+import logging
+
+logging.basicConfig(level=logging.INFO)
+
+enum_dict = [
+    {
+        "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..fbbe7a1
--- /dev/null
+++ b/run_tests.sh
@@ -0,0 +1,6 @@
+if [ ${1:-not_full} == "full" ]
+then
+    pytest tests/test_main.py --log-level="INFO" --html=report.html --self-contained-html "${@:2}"
+else
+    pytest -k "not test_main and not test_client" --log-level="INFO" --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..4376e00
--- /dev/null
+++ b/tests/test_addon.py
@@ -0,0 +1,23 @@
+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..c8fcef7
--- /dev/null
+++ b/tests/test_article.py
@@ -0,0 +1,20 @@
+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..d0f5220
--- /dev/null
+++ b/tests/test_base.py
@@ -0,0 +1,68 @@
+from ast import Import
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request, sample_list
+
+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 sample_list(self.fp.articles, 3):
+            article.parse()
+
+    def get_games(self):
+        for game in sample_list(self.fp.games, 3):
+            game.parse()
+
+    def get_files(self):
+        for file in sample_list(self.fp.files, 3):
+            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..8622b1f
--- /dev/null
+++ b/tests/test_client.py
@@ -0,0 +1,59 @@
+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_page(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 55%
rename from test/test_engine.py
rename to tests/test_engine.py
index b8edc14..9aec58d 100644
--- a/test/test_engine.py
+++ b/tests/test_engine.py
@@ -1,23 +1,26 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request, sample_list
+
 import moddb
 
-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")))
+DEFAULT = "https://www.moddb.com/engines/sage-strategy-action-game-engine"
 
-    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()
+
+@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)
         self.engine.get_articles(category=moddb.ArticleCategory.news)
 
-        for article in articles:
+        for article in sample_list(articles, 3):
             article.parse()
 
     def test_get_comments(self):
@@ -28,13 +31,13 @@ def test_get_games(self):
         games = self.engine.get_games()
         self.engine.get_games(3)
 
-        for game in games:
+        for game in sample_list(games, 3):
             game.parse()
 
     def test_get_images(self):
         images = self.engine.get_images()
 
-        for image in images[:10]:
+        for image in sample_list(images, 3):
             image.parse()
 
     def test_get_reviews(self):
@@ -46,11 +49,14 @@ def test_get_tutorials(self):
         self.engine.get_tutorials(3)
         self.engine.get_tutorials(difficulty=moddb.Difficulty.basic)
 
-        for tutorial in tutorials:
+        for tutorial in sample_list(tutorials, 3):
             tutorial.parse()
 
     def test_get_videos(self):
         videos = self.engine.get_videos()
 
-        for video in videos:
+        for video in sample_list(videos, 3):
             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..28a6a25
--- /dev/null
+++ b/tests/test_file.py
@@ -0,0 +1,21 @@
+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 60%
rename from test/test_game.py
rename to tests/test_game.py
index af9ad51..ef9718d 100644
--- a/test/test_game.py
+++ b/tests/test_game.py
@@ -1,15 +1,26 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request, sample_list
+
 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()
         self.game.get_addons(2)
         self.game.get_addons(licence=moddb.Licence.public_domain)
-        for addon in addons:
+
+        for addon in sample_list(addons, 3):
             addon.parse()
 
     def test_get_articles(self):
@@ -17,7 +28,7 @@ def test_get_articles(self):
         self.game.get_articles(4)
         self.game.get_articles(category=moddb.ArticleCategory.news)
 
-        for article in articles:
+        for article in sample_list(articles, 3):
             article.parse()
 
     def test_get_comments(self):
@@ -29,20 +40,20 @@ def test_get_files(self):
         self.game.get_files(4)
         self.game.get_files(category=moddb.FileCategory.demo)
 
-        for file in files:
+        for file in sample_list(files, 3):
             file.parse()
 
     def test_get_images(self):
         images = self.game.get_images()
 
-        for image in images[:10]:
+        for image in sample_list(images, 3):
             image.parse()
 
     def test_get_mods(self):
         mods = self.game.get_mods()
         self.game.get_mods(3)
 
-        for mod in mods:
+        for mod in sample_list(mods, 3):
             mod.parse()
 
     def test_get_reviews(self):
@@ -54,11 +65,14 @@ def test_get_tutorials(self):
         self.game.get_tutorials(3)
         self.game.get_tutorials(difficulty=moddb.Difficulty.basic)
 
-        for tutorial in tutorials:
+        for tutorial in sample_list(tutorials, 3):
             tutorial.parse()
 
     def test_get_videos(self):
         videos = self.game.get_videos()
 
-        for video in videos:
+        for video in sample_list(videos, 3):
             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 57%
rename from test/test_group.py
rename to tests/test_group.py
index a37ec60..c596704 100644
--- a/test/test_group.py
+++ b/tests/test_group.py
@@ -1,15 +1,25 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request, sample_list
+
 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()
         self.group.get_addons(2)
         self.group.get_addons(licence=moddb.Licence.public_domain)
-        for addon in addons:
+        for addon in sample_list(addons, 3):
             addon.parse()
 
     def test_get_articles(self):
@@ -17,7 +27,7 @@ def test_get_articles(self):
         self.group.get_articles(4)
         self.group.get_articles(category=moddb.ArticleCategory.news)
 
-        for article in articles:
+        for article in sample_list(articles, 3):
             article.parse()
 
     def test_get_comments(self):
@@ -29,13 +39,13 @@ def test_get_files(self):
         self.group.get_files(4)
         self.group.get_files(category=moddb.FileCategory.demo)
 
-        for file in files:
+        for file in sample_list(files, 3):
             file.parse()
 
     def test_get_images(self):
         images = self.group.get_images()
 
-        for image in images[:10]:
+        for image in sample_list(images, 3):
             image.parse()
 
     def test_get_tutorials(self):
@@ -43,11 +53,14 @@ def test_get_tutorials(self):
         self.group.get_tutorials(3)
         self.group.get_tutorials(difficulty=moddb.Difficulty.basic)
 
-        for tutorial in tutorials:
+        for tutorial in sample_list(tutorials, 3):
             tutorial.parse()
 
     def test_get_videos(self):
         videos = self.group.get_videos()
 
-        for video in videos:
-            video.parse()
\ No newline at end of file
+        for video in sample_list(videos, 3):
+            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 61%
rename from test/test_hardware.py
rename to tests/test_hardware.py
index 197a07a..304598d 100644
--- a/test/test_hardware.py
+++ b/tests/test_hardware.py
@@ -1,16 +1,26 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request, sample_list
+
 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()
         self.hardware.get_articles(4)
         self.hardware.get_articles(category=moddb.ArticleCategory.news)
 
-        for article in articles:
+        for article in sample_list(articles, 3):
             article.parse()
 
     def test_get_comments(self):
@@ -22,27 +32,27 @@ def test_get_files(self):
         self.hardware.get_files(4)
         self.hardware.get_files(category=moddb.FileCategory.demo)
 
-        for file in files:
+        for file in sample_list(files, 3):
             file.parse()
 
     def test_get_games(self):
         games = self.hardware.get_games()
         self.hardware.get_games(3)
 
-        for game in games:
+        for game in sample_list(games, 3):
             game.parse()
 
     def test_get_hardware(self):
         hardwares = self.hardware.get_hardware()
         self.hardware.get_hardware(3)
 
-        for hardware in hardwares:
+        for hardware in sample_list(hardwares, 3):
             hardware.parse()
 
     def test_get_images(self):
         images = self.hardware.get_images()
 
-        for image in images[:10]:
+        for image in sample_list(images, 3):
             image.parse()
 
     def test_get_reviews(self):
@@ -53,19 +63,22 @@ def test_get_software(self):
         softwares = self.hardware.get_software()
         self.hardware.get_software(3)
 
-        for software in softwares:
-            software.parse()  
+        for software in sample_list(softwares, 3):
+            software.parse()
 
     def test_get_tutorials(self):
         tutorials = self.hardware.get_tutorials()
         self.hardware.get_tutorials(3)
         self.hardware.get_tutorials(difficulty=moddb.Difficulty.basic)
 
-        for tutorial in tutorials:
+        for tutorial in sample_list(tutorials, 3):
             tutorial.parse()
 
     def test_get_videos(self):
         videos = self.hardware.get_videos()
 
-        for video in videos:
+        for video in sample_list(videos, 3):
             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..7c13e0d
--- /dev/null
+++ b/tests/test_job.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/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..a9907e3
--- /dev/null
+++ b/tests/test_main.py
@@ -0,0 +1,172 @@
+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..7250323
--- /dev/null
+++ b/tests/test_media.py
@@ -0,0 +1,20 @@
+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 64%
rename from test/test_member.py
rename to tests/test_member.py
index 1d31e58..4449e3d 100644
--- a/test/test_member.py
+++ b/tests/test_member.py
@@ -1,15 +1,27 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request, sample_list
+
 import moddb
+import requests
+import random
+
+DEFAULT = "https://www.moddb.com/members/upstart"
 
-class TestMember(unittest.TestCase):
-    def setUp(self):
-        self.member = moddb.pages.Member(moddb.get_page(getattr(self, "url", "https://www.moddb.com/members/mladen1996")))
+
+@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)
         self.member.get_addons(licence=moddb.Licence.public_domain)
-        for addon in addons:
+        for addon in sample_list(addons, 3):
             addon.parse()
 
     def test_get_articles(self):
@@ -17,7 +29,7 @@ def test_get_articles(self):
         self.member.get_articles(4)
         self.member.get_articles(category=moddb.ArticleCategory.news)
 
-        for article in articles:
+        for article in sample_list(articles, 3):
             article.parse()
 
     def test_get_blogs(self):
@@ -34,41 +46,44 @@ def test_get_files(self):
         self.member.get_files(4)
         self.member.get_files(category=moddb.FileCategory.demo)
 
-        for file in files:
+        for file in sample_list(files, 3):
             file.parse()
 
     def test_get_friends(self):
         friends = self.member.get_friends()
         self.member.get_friends(3)
 
-        for friend in friends:
-            friend.parse()
+        for friend in sample_list(friends, 3):
+            try:
+                friend.parse()
+            except requests.exceptions.HTTPError:
+                pass
 
     def test_get_games(self):
         games = self.member.get_games()
         self.member.get_games(3)
 
-        for game in games:
+        for game in sample_list(games, 3):
             game.parse()
 
     def test_get_groups(self):
         groups = self.member.get_groups()
         self.member.get_groups(3)
 
-        for group in groups:
+        for group in sample_list(groups, 3):
             group.parse()
 
     def test_get_images(self):
         images = self.member.get_images()
 
-        for image in images[:10]:
+        for image in sample_list(images, 5):
             image.parse()
 
     def test_get_mods(self):
         mods = self.member.get_mods()
         self.member.get_mods(3)
 
-        for mod in mods:
+        for mod in sample_list(mods, 3):
             mod.parse()
 
     def test_get_reviews(self):
@@ -80,7 +95,7 @@ def test_get_tutorials(self):
         self.member.get_tutorials(3)
         self.member.get_tutorials(difficulty=moddb.Difficulty.basic)
 
-        for tutorial in tutorials:
+        for tutorial in sample_list(tutorials, 3):
             tutorial.parse()
 
     def test_get_comments(self):
@@ -90,7 +105,8 @@ def test_get_comments(self):
     def test_get_videos(self):
         videos = self.member.get_videos()
 
-        for video in videos:
+        for video in sample_list(videos, 3):
             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 60%
rename from test/test_mod.py
rename to tests/test_mod.py
index 3eab8aa..aa20196 100644
--- a/test/test_mod.py
+++ b/tests/test_mod.py
@@ -1,15 +1,25 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request, sample_list
+
 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()
         self.mod.get_addons(2)
         self.mod.get_addons(licence=moddb.Licence.public_domain)
-        for addon in addons:
+        for addon in sample_list(addons, 3):
             addon.parse()
 
     def test_get_articles(self):
@@ -17,7 +27,7 @@ def test_get_articles(self):
         self.mod.get_articles(4)
         self.mod.get_articles(category=moddb.ArticleCategory.news)
 
-        for article in articles:
+        for article in sample_list(articles, 3):
             article.parse()
 
     def test_get_comments(self):
@@ -29,13 +39,13 @@ def test_get_files(self):
         self.mod.get_files(4)
         self.mod.get_files(category=moddb.FileCategory.demo)
 
-        for file in files:
+        for file in sample_list(files, 3):
             file.parse()
 
     def test_get_images(self):
         images = self.mod.get_images()
 
-        for image in images[:10]:
+        for image in sample_list(images, 3):
             image.parse()
 
     def test_get_reviews(self):
@@ -47,11 +57,14 @@ def test_get_tutorials(self):
         self.mod.get_tutorials(3)
         self.mod.get_tutorials(difficulty=moddb.Difficulty.basic)
 
-        for tutorial in tutorials:
+        for tutorial in sample_list(tutorials, 3):
             tutorial.parse()
 
     def test_get_videos(self):
         videos = self.mod.get_videos()
 
-        for video in videos:
+        for video in sample_list(videos, 3):
             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..0049c48
--- /dev/null
+++ b/tests/test_parse.py
@@ -0,0 +1,134 @@
+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 52%
rename from test/test_platform.py
rename to tests/test_platform.py
index 9c34718..0734565 100644
--- a/test/test_platform.py
+++ b/tests/test_platform.py
@@ -1,9 +1,19 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request, sample_list
+
 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()
@@ -13,33 +23,33 @@ def test_get_engines(self):
         engines = self.platform.get_engines()
         self.platform.get_engines(3)
 
-        for engine in engines:
+        for engine in sample_list(engines, 3):
             engine.parse()
 
     def test_get_games(self):
         games = self.platform.get_games()
         self.platform.get_games(3)
 
-        for game in games:
+        for game in sample_list(games, 3):
             game.parse()
 
     def test_get_hardware(self):
         hardwares = self.platform.get_hardware()
         self.platform.get_hardware(3)
 
-        for hardware in hardwares:
+        for hardware in sample_list(hardwares, 3):
             hardware.parse()
 
     def test_get_mods(self):
         mods = self.platform.get_mods()
         self.platform.get_mods(3)
 
-        for mod in mods:
+        for mod in sample_list(mods, 3):
             mod.parse()
 
     def test_get_software(self):
         softwares = self.platform.get_software()
         self.platform.get_software(3)
 
-        for software in softwares:
-            software.parse() 
+        for software in sample_list(softwares, 3):
+            software.parse()
diff --git a/tests/test_poll.py b/tests/test_poll.py
new file mode 100644
index 0000000..1c920fd
--- /dev/null
+++ b/tests/test_poll.py
@@ -0,0 +1,20 @@
+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 58%
rename from test/test_software.py
rename to tests/test_software.py
index e5d2d26..60a3a85 100644
--- a/test/test_software.py
+++ b/tests/test_software.py
@@ -1,16 +1,26 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request, sample_list
+
 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()
         self.software.get_articles(4)
         self.software.get_articles(category=moddb.ArticleCategory.news)
 
-        for article in articles:
+        for article in sample_list(articles, 3):
             article.parse()
 
     def test_get_comments(self):
@@ -22,13 +32,13 @@ def test_get_files(self):
         self.software.get_files(4)
         self.software.get_files(category=moddb.FileCategory.demo)
 
-        for file in files:
+        for file in sample_list(files, 3):
             file.parse()
 
     def test_get_images(self):
         images = self.software.get_images()
 
-        for image in images[:10]:
+        for image in sample_list(images, 3):
             image.parse()
 
     def test_get_reviews(self):
@@ -40,11 +50,14 @@ def test_get_tutorials(self):
         self.software.get_tutorials(3)
         self.software.get_tutorials(difficulty=moddb.Difficulty.basic)
 
-        for tutorial in tutorials:
+        for tutorial in sample_list(tutorials, 3):
             tutorial.parse()
 
     def test_get_videos(self):
         videos = self.software.get_videos()
 
-        for video in videos:
+        for video in sample_list(videos, 3):
             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 61%
rename from test/test_team.py
rename to tests/test_team.py
index a1b231b..bf55c4c 100644
--- a/test/test_team.py
+++ b/tests/test_team.py
@@ -1,15 +1,25 @@
-import unittest
+import pytest
+from unittest.mock import patch
+
+from tests.test_utils import patched_request, sample_list
+
 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()
         self.team.get_addons(2)
         self.team.get_addons(licence=moddb.Licence.public_domain)
-        for addon in addons:
+        for addon in sample_list(addons, 3):
             addon.parse()
 
     def test_get_articles(self):
@@ -17,7 +27,7 @@ def test_get_articles(self):
         self.team.get_articles(4)
         self.team.get_articles(category=moddb.ArticleCategory.news)
 
-        for article in articles:
+        for article in sample_list(articles, 3):
             article.parse()
 
     def test_get_comments(self):
@@ -28,7 +38,7 @@ def test_get_engines(self):
         engines = self.team.get_engines()
         self.team.get_engines(3)
 
-        for engine in engines:
+        for engine in sample_list(engines, 3):
             engine.parse()
 
     def test_get_files(self):
@@ -36,53 +46,56 @@ def test_get_files(self):
         self.team.get_files(4)
         self.team.get_files(category=moddb.FileCategory.demo)
 
-        for file in files:
+        for file in sample_list(files, 3):
             file.parse()
 
     def test_get_games(self):
         games = self.team.get_games()
         self.team.get_games(3)
 
-        for game in games:
+        for game in sample_list(games, 3):
             game.parse()
 
     def test_get_hardware(self):
         hardwares = self.team.get_hardware()
         self.team.get_hardware(3)
 
-        for hardware in hardwares:
+        for hardware in sample_list(hardwares, 3):
             hardware.parse()
 
     def test_get_images(self):
         images = self.team.get_images()
 
-        for image in images[:10]:
+        for image in sample_list(images, 3):
             image.parse()
 
     def test_get_mods(self):
         mods = self.team.get_mods()
         self.team.get_mods(3)
 
-        for mod in mods:
+        for mod in sample_list(mods, 3):
             mod.parse()
 
     def test_get_software(self):
         softwares = self.team.get_software()
         self.team.get_software(3)
 
-        for software in softwares:
-            software.parse() 
+        for software in sample_list(softwares, 3):
+            software.parse()
 
     def test_get_tutorials(self):
         tutorials = self.team.get_tutorials()
         self.team.get_tutorials(3)
         self.team.get_tutorials(difficulty=moddb.Difficulty.basic)
 
-        for tutorial in tutorials:
-            tutorial.parse()   
+        for tutorial in sample_list(tutorials, 3):
+            tutorial.parse()
 
     def test_get_videos(self):
         videos = self.team.get_videos()
 
-        for video in videos:
+        for video in sample_list(videos, 3):
             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 65%
rename from test/test_main.py
rename to tests/test_utils.py
index 92010f9..7bc1864 100644
--- a/test/test_main.py
+++ b/tests/test_utils.py
@@ -1,35 +1,45 @@
-import unittest
+import os
+import random
+from unittest.mock import patch
+
 import moddb
-import logging
-
-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
-
-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)
-
-loader = unittest.TestLoader()
-suite  = unittest.TestSuite()
 
 
+class FakeResponse:
+    def __init__(self, text):
+        self.text = text
+        self.content = text.encode("utf-8")
+
+
+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"
+
+    # 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())
+
+
+def sample_list(l, k):
+    sample = min(k, len(l))
+    if l:
+        return random.sample(l, sample)
+
+    return []
+
 
 mod_urls = [
     "https://www.moddb.com/mods/edain-mod",
@@ -39,13 +49,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 +58,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"
+    "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",
@@ -74,13 +73,9 @@
     "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"
+    "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 +84,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",
@@ -104,13 +94,9 @@
     "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"
+    "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,29 +114,19 @@
     "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"
+    "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)) 
+    "https://www.moddb.com/games/katharsis/news/katharsis-devlog-11",
+]
 
 group_urls = [
     "https://www.moddb.com/groups/humour-satire-parody",
@@ -161,12 +137,8 @@
     "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 group_urls:
-    TestGroup.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestGroup))
+    "https://www.moddb.com/groups/2013-mod-of-the-year-awards",
+]
 
 team_urls = [
     "https://www.moddb.com/company/wallace2anjos",
@@ -175,29 +147,19 @@
     "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"
+    "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",
     "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 job_urls:
-    TestJob.url = url
-    suite.addTests(loader.loadTestsFromTestCase(TestJob))
+    "https://www.moddb.com/jobs/player-modeler-modeler1",
+]
 
 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,65 +169,41 @@
     "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",
     "https://www.moddb.com/platforms/linux",
     "https://www.moddb.com/platforms/vr",
-    "https://www.moddb.com/platforms/ar"
+    "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",
     "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"
+    "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"
+    "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",
     "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"
+    "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 +222,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)
+    ("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",
+]