Repository: biglocalnews/court-scraper (language: python)

Issue:
Remove defunct base classes?
After refactoring OK (#91) and WI (#61), decide whether we can remove potentially defunct base classes. 

Below is a running list of modules to delete:

- [x] court_scraper/base/requests_base_page.py
- [x] court-scraper/court_scraper/base/parse.py
- [x] court_scraper/base/last_date.py - this might be useful long-term, but could use test coverage and possibly a refactor


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 1379474..cb9de9b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,7 @@ __pycache__/
 config.json
 .python-version
 .vscode/settings.json
+.tox
+build/
+dist/
+docs/_build/
diff --git a/.readthedocs.yml b/.readthedocs.yml
new file mode 100644
index 0000000..401a2de
--- /dev/null
+++ b/.readthedocs.yml
@@ -0,0 +1,9 @@
+version: 2
+build:
+  image: latest
+python:
+  version: 3.7
+  install:
+    - requirements: requirements-dev.txt
+    - requirements: requirements.txt
+
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..3c8ca29
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,20 @@
+# Config file for automatic testing at travis-ci.org
+language: python
+matrix:
+  include:
+  - python: "3.6"
+    env: TOXENV=py36
+  - python: "3.7"
+    env: TOXENV=py37
+  - python: "3.8"
+    env: TOXENV=py38
+  - python: "3.9"
+    env: TOXENV=py39
+
+# command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors
+install: pip install -U tox
+
+# command to run tests, e.g. python setup.py test
+script:
+  - tox
+
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
new file mode 100644
index 0000000..9ccb048
--- /dev/null
+++ b/CONTRIBUTING.rst
@@ -0,0 +1,149 @@
+.. highlight:: shell
+
+.. _contributing:
+
+============
+Contributing
+============
+
+Contributions are welcome, and they are greatly appreciated! Every
+little bit helps, and credit will always be given. There are plenty of ways
+to get involved besides writing code. We've listed a few options below, so 
+take a gander and reach out on Gitter_ if you have questions.
+
+.. _Gitter: https://gitter.im/court-scraper/general?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge
+.. _reach out: https://gitter.im/court-scraper/general?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge
+
+Ways to Contribute
+-------------------
+
+Write a scraper
+~~~~~~~~~~~~~~~
+
+Don't see a scraper for your state or county? We'd love to have you
+write a scraper to help us expand coverage! 
+
+Our :ref:`Writing a scraper <writing a scraper>` page is the best place to get started.
+It's also a good idea to file a new ticket for the work on our `Issue tracker`_, or claim
+the ticket if one already exists. We're happy to talk through strategies for scraping and 
+integration with the framework, so please do `reach out`_!
+
+Report Bugs
+~~~~~~~~~~~
+
+Report bugs on our `Issue tracker`_.
+
+.. _Issue tracker: https://github.com/biglocalnews/court-scraper/issues
+
+If you are reporting a bug, please include:
+
+* Your operating system name and version.
+* Any details about your local setup that might be helpful in troubleshooting.
+* Detailed steps to reproduce the bug.
+
+Fix Bugs
+~~~~~~~~
+
+Look through the GitHub `Issue tracker`_ for bugs. Anything tagged with "bug"
+and "help wanted" is open to whoever wants to implement it.
+
+Do Research
+~~~~~~~~~~~
+
+This project involves a fair bit of research, especially with respect to locating
+platforms and sites to scrape. Research jobs are great ways to get involved if
+you don't write code but still want to pitch in. Anything tagged
+with the "research" and "help wanted" labels on GitHub is fair game.
+
+Write Documentation
+~~~~~~~~~~~~~~~~~~~
+
+court-scraper could always use more documentation, whether as part of the
+official court-scraper docs, in docstrings, or even on the web in blog posts,
+articles, and such.
+
+Our `official docs`_ use restructuredText and Sphinx. To contribute documentation:
+
+* Fork and clone this repo
+* Create a virtual environment and perform the next steps inside an active venv
+* ``pip install -r requirements.txt`` and ``pip install -r requirements-dev.txt``
+* Create a branch for your doc updates and start writing!
+* Use :code:`make docs` to build docs and/or use :code:`make servedocs` commands to run a
+  Sphinx server that displays doc pages and allows easier reloading of pages in browser
+* Create a GitHub Pull Request once you're ready to send us your changes
+
+.. _official docs: https://court-scraper.readthedocs.io/en/latest/?badge=latest
+
+Submit Feedback
+~~~~~~~~~~~~~~~
+
+The best way to send feedback is to file an issue at https://github.com/biglocalnews/court-scraper/issues.
+
+If you are proposing a feature:
+
+* Explain in detail how it would work.
+* Keep the scope as narrow as possible, to make it easier to implement.
+* Remember that this is a volunteer-driven project, and that contributions
+  are welcome :)
+
+
+.. _code contribution bootstrap:
+
+Get Started!
+------------
+
+Ready to contribute? 
+Check out our docs on :ref:`Writing a scraper` and :ref:`Testing`, and see
+below steps on setting up `court-scraper` for local development.
+
+1. Fork the `court-scraper` repo on GitHub.
+2. Clone your fork locally::
+
+    $ git clone git@github.com:your_name_here/court-scraper.git
+
+3. Set up a local virtual environment and install dev dependencies 
+   for local development with Pipenv_::
+    
+    # Install dependencies and activate the virtual env
+    cd court-scraper/
+    pipenv install --dev
+    pipenv shell
+
+4. Create a branch for local development::
+
+    $ git checkout -b name-of-your-bugfix-or-feature
+
+   Now you can make your changes locally.
+
+5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox::
+
+    # Lint and test for current Python version
+    pytest
+    flake8 court_scraper
+
+    # Run tests for all Python versions
+    # (requires setting up and installing all supported Python versions)
+    $ tox
+
+6. Commit your changes and push your branch to GitHub::
+
+    $ git add .
+    $ git commit -m "Your detailed description of your changes."
+    $ git push origin name-of-your-bugfix-or-feature
+
+7. Submit a pull request through the GitHub website.
+
+.. _Pipenv: https://pipenv.pypa.io/en/latest/
+
+Pull Request Guidelines
+-----------------------
+
+Before you submit a pull request, check that it meets these guidelines:
+
+1. The pull request should include tests.
+2. If the pull request adds functionality, please be sure to review the docs 
+   and include necessary updates. For example, new classes, methods
+   and functions should be documented.
+3. The pull request should work for Python version 3.6 or higher. Check
+   https://travis-ci.com/github/biglocalnews/court-scraper/pull_requests
+   and make sure that the tests pass for all supported Python versions.
diff --git a/HISTORY.rst b/HISTORY.rst
new file mode 100644
index 0000000..4c1593f
--- /dev/null
+++ b/HISTORY.rst
@@ -0,0 +1,10 @@
+=======
+History
+=======
+
+0.1.0 (2021-08-05)
+------------------
+
+* Initial release with CLI
+* Support for Odyssey sites (partial coverage of CA, GA, WA)
+* Full state coverage for OK and WI
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..292d6dd
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,10 @@
+include CONTRIBUTING.rst
+include HISTORY.rst
+include LICENSE
+include README.rst
+
+recursive-include tests *
+recursive-exclude * __pycache__
+recursive-exclude * *.py[co]
+
+recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..f242673
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,91 @@
+.PHONY: clean clean-test clean-pyc clean-build docs help
+.DEFAULT_GOAL := help
+define BROWSER_PYSCRIPT
+import os, webbrowser, sys
+try:
+	from urllib import pathname2url
+except:
+	from urllib.request import pathname2url
+
+webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1])))
+endef
+export BROWSER_PYSCRIPT
+
+define PRINT_HELP_PYSCRIPT
+import re, sys
+
+for line in sys.stdin:
+	match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line)
+	if match:
+		target, help = match.groups()
+		print("%-20s %s" % (target, help))
+endef
+export PRINT_HELP_PYSCRIPT
+BROWSER := python -c "$$BROWSER_PYSCRIPT"
+
+help:
+	@python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)
+
+clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts
+
+
+clean-build: ## remove build artifacts
+	rm -fr build/
+	rm -fr dist/
+	rm -fr .eggs/
+	find . -name '*.egg-info' -exec rm -fr {} +
+	find . -name '*.egg' -exec rm -f {} +
+
+clean-pyc: ## remove Python file artifacts
+	find . -name '*.pyc' -exec rm -f {} +
+	find . -name '*.pyo' -exec rm -f {} +
+	find . -name '*~' -exec rm -f {} +
+	find . -name '__pycache__' -exec rm -fr {} +
+
+clean-test: ## remove test and coverage artifacts
+	rm -fr .tox/
+	rm -f .coverage
+	rm -fr htmlcov/
+
+lint: ## check style with flake8
+	flake8 court_scraper tests
+
+test: ## run tests quickly with the default Python
+		py.test
+
+test-all: ## run tests on every Python version with tox
+	tox -p auto
+
+coverage: ## check code coverage quickly with the default Python
+		coverage run --source court_scraper -m pytest
+		coverage report -m
+		coverage html
+		$(BROWSER) htmlcov/index.html
+
+docs: ## generate Sphinx HTML documentation, including API docs
+	rm -f docs/court_scraper.rst
+	rm -f docs/modules.rst
+	sphinx-apidoc -o docs/ court_scraper
+	$(MAKE) -C docs clean
+	$(MAKE) -C docs html
+	$(BROWSER) docs/_build/html/index.html
+
+servedocs: docs ## compile the docs watching for changes
+	watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D .
+
+check-release: ## check release for potential errors
+	twine check dist/*
+
+test-release: clean dist ## release distros to test.pypi.org
+	twine upload -r testpypi dist/*
+
+release: clean dist ## package and upload a release
+	twine upload -r pypi dist/*
+
+dist: clean ## builds source and wheel package
+	python setup.py sdist
+	python setup.py bdist_wheel
+	ls -l dist
+
+install: clean ## install the package to the active Python's site-packages
+	python setup.py install
diff --git a/Pipfile b/Pipfile
index 60deccd..73bfa9e 100644
--- a/Pipfile
+++ b/Pipfile
@@ -4,12 +4,23 @@ url = "https://pypi.org/simple"
 verify_ssl = true
 
 [dev-packages]
+argh = "*"
+bumpversion = "*"
+coverage = "*"
+docutils = "0.16"
 flake8 = "*"
 pytest = "*"
 pylint = "*"
 pytest-vcr = "*"
+Sphinx = "*"
+sphinx-rtd-theme = "*"
+tox = "*"
+twine = "*"
+watchdog = "*"
+wheel = "*"
 
 [packages]
+bs4 = "*"
 selenium = "*"
 my-fake-useragent = "*"
 retrying = "*"
@@ -19,7 +30,4 @@ pyyaml = "*"
 click-option-group = "*"
 sqlalchemy = "*"
 anticaptchaofficial = "*"
-bs4 = "*"
-
-[requires]
-python_version = "3.7"
+typing-extensions = "*"
diff --git a/Pipfile.lock b/Pipfile.lock
index eacf57c..da4be5a 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,12 +1,10 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "13db769155d3f37f5eb839bc9551a08479b5d37fdd134ba233a6ebe74311e2ce"
+            "sha256": "2e777ded3c413a290ba0451bbd117a39b3750318dcdfe240c8361b90971a940d"
         },
         "pipfile-spec": 6,
-        "requires": {
-            "python_version": "3.7"
-        },
+        "requires": {},
         "sources": [
             {
                 "name": "pypi",
@@ -324,7 +322,7 @@
                 "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342",
                 "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"
             ],
-            "markers": "python_version < '3.8'",
+            "index": "pypi",
             "version": "==3.10.0.0"
         },
         "urllib3": {
@@ -345,6 +343,21 @@
         }
     },
     "develop": {
+        "alabaster": {
+            "hashes": [
+                "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359",
+                "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"
+            ],
+            "version": "==0.7.12"
+        },
+        "argh": {
+            "hashes": [
+                "sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3",
+                "sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65"
+            ],
+            "index": "pypi",
+            "version": "==0.26.2"
+        },
         "astroid": {
             "hashes": [
                 "sha256:3975a0bd5373bdce166e60c851cfcbaf21ee96de80ec518c1f4cb3e94c3fb334",
@@ -361,6 +374,149 @@
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
             "version": "==21.2.0"
         },
+        "babel": {
+            "hashes": [
+                "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9",
+                "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==2.9.1"
+        },
+        "backports.entry-points-selectable": {
+            "hashes": [
+                "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a",
+                "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"
+            ],
+            "markers": "python_version >= '2.7'",
+            "version": "==1.1.0"
+        },
+        "bleach": {
+            "hashes": [
+                "sha256:c1685a132e6a9a38bf93752e5faab33a9517a6c0bb2f37b785e47bf253bdb51d",
+                "sha256:ffa9221c6ac29399cc50fcc33473366edd0cf8d5e2cbbbb63296dc327fb67cc8"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==4.0.0"
+        },
+        "bump2version": {
+            "hashes": [
+                "sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410",
+                "sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==1.0.1"
+        },
+        "bumpversion": {
+            "hashes": [
+                "sha256:4ba55e4080d373f80177b4dabef146c07ce73c7d1377aabf9d3c3ae1f94584a6",
+                "sha256:4eb3267a38194d09f048a2179980bb4803701969bff2c85fa8f6d1ce050be15e"
+            ],
+            "index": "pypi",
+            "version": "==0.6.0"
+        },
+        "certifi": {
+            "hashes": [
+                "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee",
+                "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"
+            ],
+            "version": "==2021.5.30"
+        },
+        "charset-normalizer": {
+            "hashes": [
+                "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b",
+                "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"
+            ],
+            "markers": "python_version >= '3'",
+            "version": "==2.0.4"
+        },
+        "colorama": {
+            "hashes": [
+                "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",
+                "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==0.4.4"
+        },
+        "coverage": {
+            "hashes": [
+                "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c",
+                "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6",
+                "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45",
+                "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a",
+                "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03",
+                "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529",
+                "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a",
+                "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a",
+                "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2",
+                "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6",
+                "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759",
+                "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53",
+                "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a",
+                "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4",
+                "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff",
+                "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502",
+                "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793",
+                "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb",
+                "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905",
+                "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821",
+                "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b",
+                "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81",
+                "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0",
+                "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b",
+                "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3",
+                "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184",
+                "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701",
+                "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a",
+                "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82",
+                "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638",
+                "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5",
+                "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083",
+                "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6",
+                "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90",
+                "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465",
+                "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a",
+                "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3",
+                "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e",
+                "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066",
+                "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf",
+                "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b",
+                "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae",
+                "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669",
+                "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873",
+                "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b",
+                "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6",
+                "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb",
+                "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160",
+                "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c",
+                "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079",
+                "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d",
+                "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"
+            ],
+            "index": "pypi",
+            "version": "==5.5"
+        },
+        "distlib": {
+            "hashes": [
+                "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736",
+                "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"
+            ],
+            "version": "==0.3.2"
+        },
+        "docutils": {
+            "hashes": [
+                "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af",
+                "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"
+            ],
+            "index": "pypi",
+            "version": "==0.16"
+        },
+        "filelock": {
+            "hashes": [
+                "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59",
+                "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"
+            ],
+            "version": "==3.0.12"
+        },
         "flake8": {
             "hashes": [
                 "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b",
@@ -377,6 +533,14 @@
             "markers": "python_version >= '3'",
             "version": "==3.2"
         },
+        "imagesize": {
+            "hashes": [
+                "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1",
+                "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==1.2.0"
+        },
         "importlib-metadata": {
             "hashes": [
                 "sha256:0645585859e9a6689c523927a5032f2ba5919f1f7d0e84bd4533312320de1ff9",
@@ -400,6 +564,22 @@
             "markers": "python_version < '4.0' and python_full_version >= '3.6.1'",
             "version": "==5.9.3"
         },
+        "jinja2": {
+            "hashes": [
+                "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4",
+                "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==3.0.1"
+        },
+        "keyring": {
+            "hashes": [
+                "sha256:045703609dd3fccfcdb27da201684278823b72af515aedec1a8515719a038cb8",
+                "sha256:8f607d7d1cc502c43a932a275a56fe47db50271904513a379d39df1af277ac48"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==23.0.1"
+        },
         "lazy-object-proxy": {
             "hashes": [
                 "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653",
@@ -428,6 +608,46 @@
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
             "version": "==1.6.0"
         },
+        "markupsafe": {
+            "hashes": [
+                "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298",
+                "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64",
+                "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b",
+                "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567",
+                "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff",
+                "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74",
+                "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35",
+                "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26",
+                "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7",
+                "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75",
+                "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f",
+                "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135",
+                "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8",
+                "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a",
+                "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914",
+                "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18",
+                "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8",
+                "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2",
+                "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d",
+                "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b",
+                "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f",
+                "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb",
+                "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833",
+                "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415",
+                "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902",
+                "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9",
+                "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d",
+                "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066",
+                "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f",
+                "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5",
+                "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94",
+                "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509",
+                "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51",
+                "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==2.0.1"
+        },
         "mccabe": {
             "hashes": [
                 "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
@@ -486,6 +706,21 @@
             "markers": "python_version >= '3.6'",
             "version": "==21.0"
         },
+        "pkginfo": {
+            "hashes": [
+                "sha256:37ecd857b47e5f55949c41ed061eb51a0bee97a87c969219d144c0e023982779",
+                "sha256:e7432f81d08adec7297633191bbf0bd47faf13cd8724c3a13250e51d542635bd"
+            ],
+            "version": "==1.7.1"
+        },
+        "platformdirs": {
+            "hashes": [
+                "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c",
+                "sha256:632daad3ab546bd8e6af0537d09805cec458dce201bccfe23012df73332e181e"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==2.2.0"
+        },
         "pluggy": {
             "hashes": [
                 "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
@@ -518,6 +753,14 @@
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
             "version": "==2.3.1"
         },
+        "pygments": {
+            "hashes": [
+                "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f",
+                "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==2.9.0"
+        },
         "pylint": {
             "hashes": [
                 "sha256:2e1a0eb2e8ab41d6b5dbada87f066492bb1557b12b76c47c2ee8aa8a11186594",
@@ -550,6 +793,13 @@
             "index": "pypi",
             "version": "==1.0.2"
         },
+        "pytz": {
+            "hashes": [
+                "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
+                "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"
+            ],
+            "version": "==2021.1"
+        },
         "pyyaml": {
             "hashes": [
                 "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf",
@@ -585,6 +835,35 @@
             "index": "pypi",
             "version": "==5.4.1"
         },
+        "readme-renderer": {
+            "hashes": [
+                "sha256:63b4075c6698fcfa78e584930f07f39e05d46f3ec97f65006e430b595ca6348c",
+                "sha256:92fd5ac2bf8677f310f3303aa4bce5b9d5f9f2094ab98c29f13791d7b805a3db"
+            ],
+            "version": "==29.0"
+        },
+        "requests": {
+            "hashes": [
+                "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24",
+                "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
+            "version": "==2.26.0"
+        },
+        "requests-toolbelt": {
+            "hashes": [
+                "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f",
+                "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"
+            ],
+            "version": "==0.9.1"
+        },
+        "rfc3986": {
+            "hashes": [
+                "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835",
+                "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"
+            ],
+            "version": "==1.5.0"
+        },
         "six": {
             "hashes": [
                 "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
@@ -593,6 +872,77 @@
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
             "version": "==1.16.0"
         },
+        "snowballstemmer": {
+            "hashes": [
+                "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2",
+                "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"
+            ],
+            "version": "==2.1.0"
+        },
+        "sphinx": {
+            "hashes": [
+                "sha256:3092d929cd807926d846018f2ace47ba2f3b671b309c7a89cd3306e80c826b13",
+                "sha256:46d52c6cee13fec44744b8c01ed692c18a640f6910a725cbb938bc36e8d64544"
+            ],
+            "index": "pypi",
+            "version": "==4.1.2"
+        },
+        "sphinx-rtd-theme": {
+            "hashes": [
+                "sha256:32bd3b5d13dc8186d7a42fc816a23d32e83a4827d7d9882948e7b837c232da5a",
+                "sha256:4a05bdbe8b1446d77a01e20a23ebc6777c74f43237035e76be89699308987d6f"
+            ],
+            "index": "pypi",
+            "version": "==0.5.2"
+        },
+        "sphinxcontrib-applehelp": {
+            "hashes": [
+                "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a",
+                "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==1.0.2"
+        },
+        "sphinxcontrib-devhelp": {
+            "hashes": [
+                "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e",
+                "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==1.0.2"
+        },
+        "sphinxcontrib-htmlhelp": {
+            "hashes": [
+                "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07",
+                "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==2.0.0"
+        },
+        "sphinxcontrib-jsmath": {
+            "hashes": [
+                "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178",
+                "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==1.0.1"
+        },
+        "sphinxcontrib-qthelp": {
+            "hashes": [
+                "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72",
+                "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==1.0.3"
+        },
+        "sphinxcontrib-serializinghtml": {
+            "hashes": [
+                "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd",
+                "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==1.1.5"
+        },
         "toml": {
             "hashes": [
                 "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
@@ -601,6 +951,30 @@
             "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
             "version": "==0.10.2"
         },
+        "tox": {
+            "hashes": [
+                "sha256:60eda26fa47b7130e6fc1145620b1fd897963af521093c3685c3f63d1c394029",
+                "sha256:9850daeb96d21b4abf049bc5f197426123039e383ebfed201764e9355fc5a880"
+            ],
+            "index": "pypi",
+            "version": "==3.24.1"
+        },
+        "tqdm": {
+            "hashes": [
+                "sha256:3642d483b558eec80d3c831e23953582c34d7e4540db86d9e5ed9dad238dabc6",
+                "sha256:706dea48ee05ba16e936ee91cb3791cd2ea6da348a0e50b46863ff4363ff4340"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==4.62.0"
+        },
+        "twine": {
+            "hashes": [
+                "sha256:087328e9bb405e7ce18527a2dca4042a84c7918658f951110b38bc135acab218",
+                "sha256:4caec0f1ed78dc4c9b83ad537e453d03ce485725f2aea57f1bb3fdde78dae936"
+            ],
+            "index": "pypi",
+            "version": "==3.4.2"
+        },
         "typed-ast": {
             "hashes": [
                 "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace",
@@ -643,9 +1017,17 @@
                 "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342",
                 "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"
             ],
-            "markers": "python_version < '3.8'",
+            "index": "pypi",
             "version": "==3.10.0.0"
         },
+        "urllib3": {
+            "hashes": [
+                "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4",
+                "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
+            "version": "==1.26.6"
+        },
         "vcrpy": {
             "hashes": [
                 "sha256:12c3fcdae7b88ecf11fc0d3e6d77586549d4575a2ceee18e82eee75c1f626162",
@@ -654,6 +1036,56 @@
             "markers": "python_version >= '3.5'",
             "version": "==4.1.1"
         },
+        "virtualenv": {
+            "hashes": [
+                "sha256:97066a978431ec096d163e72771df5357c5c898ffdd587048f45e0aecc228094",
+                "sha256:fdfdaaf0979ac03ae7f76d5224a05b58165f3c804f8aa633f3dd6f22fbd435d5"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==20.7.0"
+        },
+        "watchdog": {
+            "hashes": [
+                "sha256:0bcdf7b99b56a3ae069866c33d247c9994ffde91b620eaf0306b27e099bd1ae0",
+                "sha256:0bcfe904c7d404eb6905f7106c54873503b442e8e918cc226e1828f498bdc0ca",
+                "sha256:201cadf0b8c11922f54ec97482f95b2aafca429c4c3a4bb869a14f3c20c32686",
+                "sha256:3a7d242a7963174684206093846537220ee37ba9986b824a326a8bb4ef329a33",
+                "sha256:3e305ea2757f81d8ebd8559d1a944ed83e3ab1bdf68bcf16ec851b97c08dc035",
+                "sha256:431a3ea70b20962e6dee65f0eeecd768cd3085ea613ccb9b53c8969de9f6ebd2",
+                "sha256:44acad6f642996a2b50bb9ce4fb3730dde08f23e79e20cd3d8e2a2076b730381",
+                "sha256:54e057727dd18bd01a3060dbf5104eb5a495ca26316487e0f32a394fd5fe725a",
+                "sha256:6fe9c8533e955c6589cfea6f3f0a1a95fb16867a211125236c82e1815932b5d7",
+                "sha256:85b851237cf3533fabbc034ffcd84d0fa52014b3121454e5f8b86974b531560c",
+                "sha256:8805a5f468862daf1e4f4447b0ccf3acaff626eaa57fbb46d7960d1cf09f2e6d",
+                "sha256:9628f3f85375a17614a2ab5eac7665f7f7be8b6b0a2a228e6f6a2e91dd4bfe26",
+                "sha256:a12539ecf2478a94e4ba4d13476bb2c7a2e0a2080af2bb37df84d88b1b01358a",
+                "sha256:acc4e2d5be6f140f02ee8590e51c002829e2c33ee199036fcd61311d558d89f4",
+                "sha256:b5fc5c127bad6983eecf1ad117ab3418949f18af9c8758bd10158be3647298a9",
+                "sha256:b8ddb2c9f92e0c686ea77341dcb58216fa5ff7d5f992c7278ee8a392a06e86bb",
+                "sha256:bf84bd94cbaad8f6b9cbaeef43080920f4cb0e61ad90af7106b3de402f5fe127",
+                "sha256:d9456f0433845e7153b102fffeb767bde2406b76042f2216838af3b21707894e",
+                "sha256:e4929ac2aaa2e4f1a30a36751160be391911da463a8799460340901517298b13",
+                "sha256:e5236a8e8602ab6db4b873664c2d356c365ab3cac96fbdec4970ad616415dd45",
+                "sha256:fd8c595d5a93abd441ee7c5bb3ff0d7170e79031520d113d6f401d0cf49d7c8f"
+            ],
+            "index": "pypi",
+            "version": "==2.1.3"
+        },
+        "webencodings": {
+            "hashes": [
+                "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
+                "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
+            ],
+            "version": "==0.5.1"
+        },
+        "wheel": {
+            "hashes": [
+                "sha256:78b5b185f0e5763c26ca1e324373aadd49182ca90e825f7853f4b2509215dc0e",
+                "sha256:e11eefd162658ea59a60a0f6c7d493a7190ea4b9a85e335b33489d9f17e0245e"
+            ],
+            "index": "pypi",
+            "version": "==0.36.2"
+        },
         "wrapt": {
             "hashes": [
                 "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"
diff --git a/README.md b/README.md
index 0102abe..a2ec580 100644
--- a/README.md
+++ b/README.md
@@ -1,379 +1,12 @@
 # court-scraper
 
-[![Join the chat at https://gitter.im/court-scraper/general](https://badges.gitter.im/court-scraper/general.svg)](https://gitter.im/court-scraper/general?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
-
-- [Overview](#overview)
-- [Setup](#setup)
-  - [Pre-reqs](#pre-reqs)
-  - [Install](#install)
-  - [Configuration](#configuration)
-- [Usage](#usage)
-- [Contributing](#contributing)
-- [Testing](#testing)
-
-## Overview
-
 The `court-scraper` package provides a Python library and command-line
 tool that help search and download case information from county courts
 in the United States.
 
-## Setup
-
-### Pre-reqs
-
-* Python >= 3.7
-* (optional) [Pipenv](https://pipenv.pypa.io/en/latest/) is not required, but useful for contributing code
-
-Additionally, scrapers that rely on Selenium require:
-
-* [Google Chrome](https://www.google.com/chrome/) web browser
-* [Chromedriver](https://sites.google.com/a/chromium.org/chromedriver/downloads)
-
-Important notes:
-
-- You must ensure that the version of `chromedriver` matches the version of Google Chrome installed on the machine. To check the version on a Desktop installation, open the Chrome browser and enter the following in the address bar: `chrome://settings/help`
-- Make sure the `chromedriver` executable is on your system `PATH`.
-
-### Install
-
-```
-pip install git+https://github.com/biglocalnews/court-scraper#egg=court-scraper
-```
-
-The `court-scraper` command-line tool will store scraped pages,
-logs and other files in the `.court-scraper` folder in the
-user's home directory. On Linux/Mac, this defaults to `~/.court-scraper`.
-
-You can override the default location by setting the below environment
-variable.
-
-```
-# E.g., you might place this in ~/.profile on Mac
-export COURT_SCRAPER_DIR=/some/other/path/
-```
-
-### Configuration
-
-User credentials to log into court sites should
-be stored in a YAML configuration file.
-
-This file is expected to live inside the storage
-location for scraped files, logs, etc.
-
-On Mac/Linux, it would live at `~/.court-scraper/config.yaml`.
-
-The YAML configuration must contain credentials for each
-location based on a place ID, which is a combination
-of state and county (e.g. `ga_dekalb` for Dekalb County, GA).
-
-Courts with a common software platform that allow sharing
-of credentials can inherit credentials from a single entry.
-
-Here's an example configuration file:
-
-```
-# ~/.court-scraper/config.yaml
-platforms:
-  # Mark a platform pass for reuse in multiple sites
-  odyssey_site: &ODYSSEY_SITE
-    username: 'user@example.com'
-    password: 'SECRET_PASS'
-# Inherit platform credentials across multiple courts
-ga_chatham: *ODYSSEY_SITE
-ga_dekalb: *ODYSSEY_SITE
-ga_fulton: *ODYSSEY_SITE
-
-# Or simply set site-specific attributes
-ny_westchester:
-  username: 'user2@example.com'
-  password: 'GREAT_PASSWORD'
-```
-
-#### CAPTCHA-protected sites
-
-`court-scraper` uses the [Anti-captcha][] service to handle sites protected by [CAPTCHAs](https://en.wikipedia.org/wiki/CAPTCHA).
-
-[Anti-captcha]: https://anti-captcha.com
-
-If you plan to scrape a CAPTCHA-protected site, register with the
-[Anti-captcha][] service and obtain an API key.
-
-Then, add your API key to your local court-scraper configuration file as shown below:
-
-```
-# ~/.court-scraper/config.yaml
-captcha_service_api_key: 'YOUR_API_KEY'
-```
-
-Once configured, you should be able to query captcha-protected sites currently supported by court-scraper.
-
-> This configuration currently works only for sites that use the
-> Odyssey platform (e.g. Sonoma County, CA).
-
-## Usage
-
-### Info
-
-The `court-scraper` command-line tool provides information
-on the available court sites via the `info` subcommand.
-
-```
-> court-scraper info
-
-Available scrapers:
-
- * GA - Chatham (ga_chatham)
- * GA - Dekalb (ga_dekalb)
- * GA - Fulton (ga_fulton)
-```
-
-In the above output, the items in parentheses are
-IDs for the state and county.
-
-> You will need these IDs to run a search on a particular court site.
-
-### Search
-
-To search for cases, use the `search` subcommand.
-
-> Note the use of the place ID acquired from the [`info` subcommand](#info).
-
-```
-# Search Dekalb County, GA for CASE ID 123
-court-scraper search --place-id ga_dekalb --search-term 123
-
-```
-
-You can also supply a text file containing a list of search terms
--- one term per line -- to the `search` command.
-
-```
-# Search a series of terms in Dekalb County, GA
-court-scraper search \
-  --place-id ga_dekalb \
-  --search-terms-file ga_dekalb_terms.txt
-```
-
-If a scraper uses Selenium, you can choose to launch a browser with the
-`--with-browser` flag:
-
-```
-court-scraper search \
-  --place-id ga_dekalb \
-  --search-term 123 \
-  --with-browser
-```
-
-### Data and files
-
-Scrapers will generally store raw file assets (e.g. HTML pages, CSV file) for
-case details in a local cache. They will also store basic case metadata such
-as court location, case number, and filing date in a SQLite database.
-
-```
-# A search for two case IDs for Dekalb County, GA
-
-> court-scraper search -p ga_dekalb -f ga_dekalb_search_terms.txt
-Executing search for ga_dekalb
-Caching file: /Users/someuser/.court-scraper/cache/ga_dekalb/19D67383.html
-Caching file: /Users/someuser/.court-scraper/cache/ga_dekalb/19D102716.html
-Adding 2 results to /Users/someuser/.court-scraper/cases.db
-```
-
-## Contributing
-
-### Overview
-
-We welcome contributions of new court scrapers as well as bug fixes.
-
-It's generally a good idea to review our [Issues][] and start a conversation
-with our team before implementing code, to ensure the code meets our standards
-and to avoid duplication of effort.
-
-[Issues]: https://github.com/biglocalnews/court-scraper/issues
-
-To send us code:
-
-* Fork this repo
-* Add a site scraper using the [steps outlined below](#adding-a-scraper)
-* Send us a pull request
-
-> Please try to include test coverage for contributions, and
-> ensure that existing test coverage passes before sending a pull
-> request. Pull requests will not be accepted for code that breaks
-> existing functionality.
-
-### Adding a scraper
-
-The main task involved in contributing a scraper involves creating a
-a "site" class that provides generic search functionality.
-
-In concrete terms, this means adding a site class with a `search`
-method and a `login` method, if users are required to log into a site.
-
-The code for a given court will vary widely in terms of complexity, so
-we don't place stringent requirements on the overall structure of code
-contributions. However, we do require that you provide a standard public
-interface to a given site through a `search` method.
-
-Here's an incredibly simplified example of a potential scraper for
-Westchester County, NY:
-
-```
-# court_scraper/scrapers/ny_westchester.py
-
-class NyWestchester:
-
-    def __init__(self, url, download_dir):
-        self.site_url = url
-        self.download_dir = download_dir
-
-    def search(self, search_terms=[]):
-        # Do things of varying complexity with search terms.
-        # This method should return a list of CaseInfo
-        # instances containing case metadata and, if available,
-        # HTML for case detail
-        pass
-```
-
-A scraper specific to a single county/court should live in
-the `court_scraper.scrapers` namespace in modules named by state and county.
-
-For example, the example class for Westchester County would live in
- `court_scraper/scrapers/ny_westchester.py`.
-
-> Note that we use CamelCase names for the classes!!
-
-Many counties use common software platforms, such as Odyssey by
-Tyler Technologies, to provide case information.
-
-To add a platform-based scraper for use in more than one jurisdiction,
-add a site class to the `court_scraper.platforms` namespace.
-
-For example, `court_scraper/platforms/odyssey_site.py`.
-
-The final step to adding a site scraper involves adding some
-metadata to [sites_meta.csv][]. This file contains
-the following fields:
-
-[sites_meta.csv]: https://github.com/biglocalnews/court-scraper/blob/master/court_scraper/data/sites_meta.csv
-
-* `state` - 2-letter state abbreviation, lower cased
-* `county` - Name of county (without the word "County"), lower cased
-* `site_type` - Base name of the Python module where the scraper lives.
-   For example, `ny_westchester` for single-location sites; `odyssey_site` for platform-based sites.
-* `site_version` - Platform based sites often have multiple versions.
-  Use this field to denote a new version of a platform-based site.
-* `captcha_service_required` - Mark an "x" in this field if a site class must handle Captchas
-* `home_url` - Starting page for a site scraper
-
-It's important to note that *every jurisdiction covered* by a site scraper
-must be entered in `sites_meta.csv`, even if the sites share a common
-platform. For example, there are separate entries in [sites_meta.csv][]
-for Chatham, Dekalb and Fulton counties in Georgia, even though all
-three sites use the same version of the Odyssey courts platform.
-
-## Testing
-
-This code base was developed on Python 3.7 and uses the
-[pytest](https://docs.pytest.org/en/latest/contents.html) library for unit testing.
-
-Assuming you've cloned this repo locally, you can run
-tests by using Pipenv to install dependencies and
-then executing pytest in an active virtual environment.
-
-```
-cd court-scraper/
-pipenv install
-
-# Execute tests
-pipenv run pytest
-
-# or
-
-pipenv shell
-pytest
-```
-
-### Slow tests
-
-Slow-running tests should be [marked][] as such:
-
-
-```
-@pytest.mark.slow
-def test_something_slow():
-    ...
-
-```
-
-Slow tests are skipped by default. To run them, pass the `--runslow` flag
-when invoking pytest:
-
-```
-pytest --runslow
-```
-
-### Live tests
-
-Tests that hit live web sites should be [marked][] as `webtest`,
-allowing them to be [executed selectively][]:
-
-```
-@pytest.mark.webtest
-def test_that_hits_live_website():
-    ...
-
-# On the command line, run only tests marked as "webtest"
-pytest -m webtest
-```
-
-In many cases, tests that hit live websites should be marked
-as both `webtest` and `slow`:
-
-```
-@pytest.mark.webtest
-@pytest.mark.slow
-def test_that_hits_live_website():
-    ...
-
-# On the command line, you'll need two flags
-pytest --runslow -m webtest
-```
-
-Live web tests of Selenium-based scrapers will open a
-web browser by default. All tests of Selenium scrapers
-should use the `headless` fixture in order to provide
-the ability to disable running tests in browser.
-
-> These tests should typically be marked as `slow` and `webtest`
-> as well.
-
-```
-@pytest.mark.webtest
-@pytest.mark.slow
-def test_selenium_scrape(headless):
-```
-
-You can then activate headless mode when running
-pytest by using the `--headless` flag:
-
-
-```
-pytest --headless --runslow
-```
-
-
-### Test login credentials
-
-Tests that hit [live web sites](#live-tests) may require authentication,
-as in the case of some Odyssey sites such as Dekalb and Chatham counties
-in Georgia.
-
-Such tests require creating user accounts and adding login credentials
-to a local YAML configuration file, as described above in
-the [Configuration section](#configuration).
-
+Learn more or reach out using the below resources:
 
-[marked]: https://docs.pytest.org/en/stable/example/markers.html
-[executed selectively]: https://docs.pytest.org/en/stable/example/markers.html#marking-test-functions-and-selecting-them-for-a-run
+- Gitter for chat: https://gitter.im/court-scraper/general
+- Documentation: http://court-scraper.readthedocs.io/en/latest/
+- GitHub: https://github.com/biglocalnews/court-scraper
+- PyPI: https://pypi.python.org/pypi/court-scraper
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..ea07417
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,85 @@
+
+.. image:: https://img.shields.io/pypi/v/court-scraper.svg
+        :target: https://pypi.python.org/pypi/court-scraper
+
+.. image:: https://img.shields.io/pypi/pyversions/court-scraper.svg
+        :target: https://pypi.python.org/pypi/court-scraper
+
+.. image:: https://img.shields.io/travis/biglocalnews/court-scraper.svg
+        :target: https://travis-ci.com/biglocalnews/court-scraper
+
+.. image:: https://readthedocs.org/projects/court-scraper/badge/?version=latest
+        :target: https://court-scraper.readthedocs.io/en/latest/?badge=latest
+        :alt: Documentation Status
+
+.. image:: https://badges.gitter.im/court-scraper/general.svg
+        :target: https://gitter.im/court-scraper/general?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge
+        :alt: Gitter
+
+=============
+Court Scraper
+=============
+
+Overview
+========
+
+The `court-scraper` package provides a Python library and command-line
+tool that help search for and download case information from local court system
+websites in the United States. This library primarily focuses on supporting the ability
+to scrape county-level court data by case number and date range.
+
+* Documentation: http://court-scraper.readthedocs.io/en/latest/
+* GitHub: https://github.com/biglocalnews/court-scraper
+* PyPI: https://pypi.python.org/pypi/court-scraper
+* Free and open source software: `Apache license`_
+
+.. _Apache license: https://github.com/biglocalnews/court-scraper/blob/master/LICENSE
+
+Pre-reqs
+========
+
+* Python >= 3.6
+* Scrapers that rely on Selenium require:
+
+  * `Google Chrome <https://www.google.com/chrome/>`_ web browser
+  * `Chromedriver <https://sites.google.com/a/chromium.org/chromedriver/downloads>`_
+
+.. _Pipenv: https://pipenv.pypa.io/en/latest/
+
+Quickstart
+==========
+
+Install court-scraper_::
+
+   pip install court-scraper
+
+Scrape using the command line tool::
+
+   # Scrape data for a case in Tulsak, Oklahoma
+   $ court-scraper search -p ok_tulsa -s CJ-2021-2045
+
+Or in a script::
+
+  # awesome-scraper-script.py
+
+  # Scrape case details from Oklahoma State Courts Network (OSCN) platform
+
+  from court_scraper import Site
+
+  site = Site('ok_tulsa')
+
+  # Scrape details for one or more case numbers
+  results = site.search(case_numbers=['CJ-2021-2045'])
+
+  # Scrape details for cases in January 2021
+  results = site.search_by_date(
+    start_date='2021-01-01,
+    end_date='2021-01-31',
+    case_details=True
+  )
+
+.. note:: Check out the :ref:`usage` docs for more detailed information on scraping.
+
+
+.. _court-scraper: https://github.com/biglocalnews/court-scraper
+.. _court-scraper docs: https://court-scraper.readthedocs.io/en/latest/
diff --git a/court_scraper/__init__.py b/court_scraper/__init__.py
index 2c458be..252c620 100644
--- a/court_scraper/__init__.py
+++ b/court_scraper/__init__.py
@@ -2,4 +2,4 @@
 
 
 __version__ = '0.1.0'
-__all__ = (Site,)
+__all__ = ('Site',)
diff --git a/court_scraper/base/last_date.py b/court_scraper/base/last_date.py
deleted file mode 100644
index eef283a..0000000
--- a/court_scraper/base/last_date.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from datetime import timedelta, datetime
-
-
-class LastDate:
-
-    def __init__(self):
-        now = datetime.today()
-        self.current_year = now.year
-        self.current_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
-
-    def _most_recent_workday(self, last_day):
-        self.holidays = self.cal.holidays(
-            datetime(self.input_year, 1, 1),
-            datetime(self.input_year, 12, 31)
-        ).to_pydatetime()
-        if self.search_date.weekday() < 5 and self.search_date not in self.holidays:
-            pass
-        elif self.search_date.weekday() < 5 and self.search_date in self.holidays:
-            self.search_date = (self.search_date - timedelta(days=1))
-            if self.search_date.weekday() < 5:
-                pass
-            else:
-                difference = (self.search_date.weekday() - 4)
-                self.search_date = (self.search_date - timedelta(days=difference))
-        else:
-            difference = (self.search_date.weekday() - 4)
-            self.search_date = (self.search_date - timedelta(days=difference))
-        return self.search_date
-
-    # function returns most recent weekday with an option to manually override
-    def date_to_search(self, year, subtract_days=1):
-        if len(str(year)) != 4:
-            return Exception('year format must conform to XXXX')
-        else:
-            pass
-        self.input_year = year
-        if self.input_year == self.current_year:
-            self.search_date = (self.current_day - timedelta(days=subtract_days))
-            return self._most_recent_workday(self.search_date)
-        else:
-            self.search_date = (datetime(self.input_year, 12, 31) - timedelta(days=(subtract_days - 1)))
-            return self._most_recent_workday(self.search_date)
diff --git a/court_scraper/base/parse.py b/court_scraper/base/parse.py
deleted file mode 100644
index 80e475b..0000000
--- a/court_scraper/base/parse.py
+++ /dev/null
@@ -1,232 +0,0 @@
-import os
-import csv
-import json
-from lxml import etree
-from io import StringIO
-
-
-class ElementException(Exception):
-    """
-    When using this class inside of court scraper's automation, the cache_directory from runner should be
-    passed to all file_directories.
-    """
-    pass
-
-
-class Parser:
-
-    def __init__(self):
-        self.lxml_parser = etree.HTMLParser()
-
-    # opens html file
-    def open_html(self, html_file):
-        self.html_file = html_file
-        self.html = open(self.html_file, "r")
-        self.html = self.html.read()
-        # lxml will not access text after <br> tags and xpaths to their location return nothing,
-        # thus removal solves the problem with no known drawbacks
-        self.html = self.html.replace('<br>', '')
-        return etree.parse(StringIO(self.html), self.lxml_parser)
-
-    def clean(self, dirty_str):
-        """
-        Base function for cleaning string return from xpath text.
-        """
-        self.dirty_str = dirty_str
-        self.clean_str = self.dirty_str.lower().strip().replace('\n', ' ')
-        return self.clean_str
-
-    """
-    lxml and Selenium do not return information from xpaths the same.
-
-    The following functions modify base lxml to return information using the same logic as Selenium:
-        - find_text_by_xpath will always return a string from the first element found with the provided xpath
-            - or it will return a string of 'none'
-        - find_elements_by_xpath will return a list of all elements found with with the provided xpath
-            - or it will return a string of 'none'
-        - get_length_by_xpath returns the length of the list of elements found
-            - this is often used for counting rows in a party table
-    """
-
-    # base return of lxml, other functions build on it
-    def _get_info(self, input_xpath):
-        self.input_xpath = input_xpath
-        return self.tree.xpath(self.input_xpath)
-
-    # equivalent of Selenium's driver.find_element_by_xpath('xpath').text
-    def find_text_by_xpath(self, input_xpath):
-        self.input_xpath = input_xpath
-        self.info = self._get_info(input_xpath)
-        if isinstance(self.info, list):
-            if len(self.info) == 0:
-                # length is 0
-                return 'none'
-            # test ensures the return isn't junk
-            elif self.info[0].text is not None and self.clean(self.info[0].text) != '':
-                # position 1 is not none
-                return self.clean(self.info[0].text)
-            else:
-                # none
-                return 'none'
-        elif self.info is None:
-            # not a list, is none
-            return 'none'
-        else:
-            # not a list, not none
-            return self.info
-
-    # equivalent of Selenium's driver.find_elements_by_xpath('xpath')
-    def find_elements_by_xpath(self, location):
-        self.info = self._get_info(location)
-        return self.info
-        if isinstance(self.info, list):
-            if len(self.info) == 1:
-                raise ElementException('Element only returned one element. Try find_text_by_xpath')
-            else:
-                return self.info
-        else:
-            raise ElementException('Element only returned one element. Try find_text_by_xpath.')
-
-    # equivalent of Selenium's len(driver.find_elements_by_xpath('xpath'))
-    def get_length_by_xpath(self, location):
-        self.info = self.find_elements_by_xpath
-        return len(self.info)
-
-    # this function writes multiples 'none's to a given list until that list is the desired length
-    def expand_list(self, list, length):
-        self.list = list
-        self.length = length
-        while len(self.list) < self.length:
-            try:
-                self.list.append('none')
-            except Exception:
-                pass
-        return self.list
-
-    # this function returns a string 'yes' or 'no' if the length of a list is greater than the input
-    def more_than(self, list, count):
-        self.list = list
-        self.count = count
-        if len(self.list) > self.count:
-            return 'yes'
-        else:
-            return 'no'
-
-    # this function parses string text to return the text between two given strings
-    def text_between(self, text, before, after):
-        self.text = text
-        self.before = before
-        self.after = after
-        try:
-            self.parse = self.text.split(self.before)
-            self.parse = self.parse[1].split(self.after)
-            self.parse = self.clean(self.parse[0])
-        except Exception:
-            self.parse = 'none'
-        return self.parse
-
-    # this function returns a string 'yes' or 'no' if provided string appears in the provided text
-    def does_string_appear(self, text, string):
-        self.text = text
-        self.string = string
-        self.test = self.text.find(self.string)
-        if self.test != -1:
-            return 'yes'
-        else:
-            return 'no'
-
-    """
-    string_search provides an entry_point to the best xpath logic for new users
-    it follows this logic
-    {div/table item}/{div/html element text is in}/{text to search}/{xpath to follow to getto relative field}
-    """
-    def string_search(self, text, table='', route=None, text_location=None):
-        self.text = text
-        self.table = table
-        self.route = route
-        self.text_location = text_location
-        if self.text_location is None and self.route is None:
-            # ('no location and no route')
-            return self.find_text_by_xpath(f'{self.table}//*[contains(text(), "{text}")]')
-        elif self.text_location is None and self.route is not None:
-            # ('no location')
-            return self.find_text_by_xpath(f'{self.table}//*[contains(text(), "{text}")]/{self.route}')
-        elif self.text_location is not None and self.route is None:
-            # ('no route')
-            return self.find_text_by_xpath(f'{self.table}//{self.text_location}[contains(text(), "{text}")]')
-        elif self.text_location is not None and self.route is not None:
-            # ('location and route')
-            return self.find_text_by_xpath(
-                f'{self.table}//{self.text_location}[contains(text(), "{text}")]/{self.route}'
-            )
-
-    """
-    These functions parse keys and values to dictionaries destined for json files
-    """
-    # this function creates dicts of a lists of keys and values
-    def lists_to_dict(self, keys, values):
-        self.keys = self.keys
-        self.values = self.values
-        self.output_dictionary = dict(zip(self.keys, self.values))
-        if self.output_dictionary == {}:
-            return 'none'
-        else:
-            return self.output_dictionary
-
-    # this function write dicts to json files
-    def write_to_json(self, input_data, save_dir, file_name):
-        self.input_data = input_data
-        self.save_dir = save_dir
-        self.file_name = file_name
-        with open(f'{self.save_dir}/{self.file_name}.json', 'w') as self.outfile:
-            json.dump(self.json_data, self.outfile)
-
-    # this function creates a list of given files in a given file directory
-    def get_files(self, input_file_dir, file_type):
-        self.input_file_dir = input_file_dir
-        self.file_type = file_type
-        self.file_list = []
-        for self.entry in os.scandir(self.file_dir):
-            if (self.entry.path.endswith(self.file_type)):
-                self.file_list.append(self.entry.path)
-        return self.file_list
-
-    # this function opens a json file
-    def open_json(self, file):
-        self.file = file
-        with open(self.file) as self.temp_load:
-            self.json_data = json.load(self.temp_load)
-        return self.json_data
-
-    # this function creates a new database and failure log but wont overwrite existing one
-    def create_csv(self, database_name, file_path, header):
-        self.database_name = database_name
-        self.header = header
-        self.file_path = file_path
-        self.file = f'{self.file_path}/{self.database_name}.csv'
-        if os.path.exists(self.file):
-            print('CSV already exists.')
-        else:
-            with open(self.file, 'w', newline='') as self.outfile:
-                self.writer = csv.writer(self.outfile)
-                self.writer.writerow(self.header)
-
-    # write data to csv
-    def write_to_csv(self, data_out, database_name, file_path):
-        self.data_out = data_out
-        self.database_name = database_name
-        self.file_path = file_path
-        self.file = f'{file_path}/{self.database_name}'
-        with open(self.file, 'a', newline='') as self.outfile:
-            self.writer = csv.writer(self.outfile, delimiter=',')
-            self.writer.writerow(self.data_out)
-
-    def json_to_csv(self, json_list, database_name, file_path, header, row):
-        self.json_list = json_list
-        self.database_name = database_name
-        self.file_path = file_path
-        self.header = header
-        self.row = row
-        self.create_csv(self.database_name, self.file_path, self.header)
-        for self.json in self.json_list:
-            self.write_to_csv(self.row, self.database_name, self.file_path)
diff --git a/court_scraper/base/requests_base_page.py b/court_scraper/base/requests_base_page.py
deleted file mode 100644
index f2477c2..0000000
--- a/court_scraper/base/requests_base_page.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import requests
-from fake_useragent import UserAgent
-
-
-class RequestsBasePage:
-
-    def get_html(self, url, payload=None, fakeuser=None):
-        self.url = url
-        if fakeuser:
-            fakeuser = UserAgent()
-            headers = {'user-agent': fakeuser.chrome}
-            data = requests.get(url, headers=headers, params=payload)
-        else:
-            data = requests.get(url, params=payload)
-        return data
-
-    def save_html(self, data, filename):
-        self.file = open(filename, "a")
-        self.file.write(data)
-        self.file.close()
diff --git a/court_scraper/base/search_page_mixin.py b/court_scraper/base/search_page_mixin.py
deleted file mode 100644
index 16cc7ea..0000000
--- a/court_scraper/base/search_page_mixin.py
+++ /dev/null
@@ -1,98 +0,0 @@
-from .last_date import LastDate
-from lxml import etree
-from io import StringIO
-
-
-class CountyNotSupported(Exception):
-    pass
-
-
-class SearchPageMixIn():
-
-    lastdate = LastDate()
-
-    def _county_specific_selenium_search(self):
-        raise CountyNotSupported('Did you forget to provide county-specific steps?')
-
-    def _get_last_workday(self, subtract_days=1):
-        self.subtract_days = subtract_days
-        self.url_date = self.lastdate.date_to_search(
-            self.year,
-            subtract_days=subtract_days
-        ).strftime("%m/%d/%y")
-
-    def _subtract_another_day(self, subtract_days=1):
-        self.subtract_days = self.subtract_days + subtract_days
-
-    def _parse_case_table(self):
-        if self.driver is not None:
-            self.single_case_result = None
-            self.case_number = None
-            try:
-                self.single_case_result = self.driver.find_element(*self.single_case_locator).text
-            except Exception:
-                try:
-                    self.case_rows = self.driver.find_elements(*self.row_locator)
-                except Exception:
-                    pass
-        else:
-            self.parser = etree.HTMLParser()
-            self.tree = etree.parse(StringIO(self.output.text), self.parser)
-            self.case_rows = self.tree.xpath(f'//*[contains(text(), "{self.case_prefix}-")]')
-        self.case_number = self.case_rows[len(self.case_rows)-1].text
-        # this is intended to check for the first three characters of "No cases found" or similar test
-        # that might appear at the provided location
-        if self.case_number[0:3] != self.empty_return_text and self.case_number is not None:
-            return self.case_number
-        elif self.single_case_result is not None:
-            return self.single_case_result
-        else:
-            raise IndexError()
-
-    def _search_previous_day_until_success(self):
-        result = None
-        while result is None:
-            try:
-                print('subtracting another day')
-                self._subtract_another_day()
-                print('getting next workday')
-                self._get_last_workday(subtract_days=self.subtract_days)
-                print('calling new html search')
-                if self.driver is not None:
-                    self._county_specific_selenium_steps()
-                else:
-                    self.output = self.get_html(self.url, payload=self.payload)
-                print(f'attempting to parse case table for {self.url_date}')
-                result = self._parse_case_table()
-            except IndexError:
-                pass
-        return result
-
-    def most_recent_case(
-        self,
-        county,
-        year,
-        case_prefix,
-        session=None,
-        driver=None,
-        row_locator=None,
-        single_case_locator=None,
-        empty_return_text=None
-    ):
-        self.county = county
-        self.year = year
-        self.case_prefix = case_prefix
-        self.driver = driver
-        self.row_locator = row_locator
-        self.single_case_locator = single_case_locator
-        self.session = session
-        self.empty_return_text = empty_return_text
-        self._get_last_workday()
-        if self.driver is not None:
-            self._county_specific_selenium_steps()
-        else:
-            self.output = self.get_html(self.url, payload=self.payload)
-        try:
-            return self._parse_case_table()
-        except Exception:
-            return self._search_previous_day_until_success()
diff --git a/court_scraper/captcha/invisible_recaptcha_v2.py b/court_scraper/captcha/invisible_recaptcha_v2.py
deleted file mode 100644
index e3372be..0000000
--- a/court_scraper/captcha/invisible_recaptcha_v2.py
+++ /dev/null
@@ -1,59 +0,0 @@
-import time
-from .recaptcha import Recaptcha
-from anticaptchaofficial.recaptchav2proxyless import InjectionError
-
-
-class InvisibleRecaptchaV2(Recaptcha):
-
-    def solve(self, driver, sitekey, apikey, captcha_test_xpath, callback_function):
-        """Main entry point"""
-        self.driver = driver
-        self.sitekey = sitekey
-        self.apikey = apikey
-        self.captcha_test_xpath = captcha_test_xpath
-        self.callback_function = callback_function
-        self.result = False
-        self.captcha_test_xpath = captcha_test_xpath
-        self._captcha_solver()
-
-    def _inject_response(self):
-        """Get captcha response and inject via callback function"""
-        self.driver.execute_script(f'{self.callback_function}("{self.g_response}")')
-
-    def _captcha_solver(self):
-        """Create a true false test to see if the captcha has been solved"""
-        if self.test(self.driver, self.captcha_test_xpath) is True:
-            self._recaptcha_V2_solver()
-            self._inject_response()
-            time.sleep(2)
-            return False
-        else:
-            return True
-
-    def _captcha_solver_loop(self):
-        """Creates a while loop to keep trying to solve a captcha until the _captcha_solver returns a success
-
-        Necessary in cases when:
-         - captcha solving service returns an error
-         - website throws multiple captchas at the bot
-        """
-        while self.result is False:
-            try:
-                self.result = self.captcha_solver()
-            except Exception:
-                raise InjectionError('An error occured while trying to inject captcha solution into webpage.')
-
-    def test(self, driver, captcha_test_xpath):
-        """Test if captcha is visible
-
-        User has to make the recaptcha test appear
-        """
-        self.driver = driver
-        self.captcha_test_xpath = captcha_test_xpath
-        self.captcha_test = self.driver.find_element_by_xpath(
-            self.captcha_test_xpath
-        ).get_attribute('style')[12:19]
-        if self.captcha_test == 'visible':
-            return True
-        else:
-            return False
diff --git a/court_scraper/captcha/recaptcha.py b/court_scraper/captcha/recaptcha.py
deleted file mode 100644
index 305f683..0000000
--- a/court_scraper/captcha/recaptcha.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from anticaptchaofficial.recaptchav2proxyless import recaptchaV2Proxyless
-
-
-class Recaptcha:
-
-    def _recaptcha_V2_solver(self):
-        self.solver = recaptchaV2Proxyless()
-        self.solver.set_verbose(0)
-        self.solver.set_key(self.apikey)
-        self.solver.set_website_url(self.driver.current_url)
-        self.solver.set_website_key(self.sitekey)
-        self.g_response = self.solver.solve_and_return_solution()
diff --git a/court_scraper/captcha/recaptcha_v2.py b/court_scraper/captcha/recaptcha_v2.py
deleted file mode 100644
index 48e85ee..0000000
--- a/court_scraper/captcha/recaptcha_v2.py
+++ /dev/null
@@ -1,46 +0,0 @@
-from anticaptchaofficial.recaptchav2proxyless import (
-    AntiCaptchaError,
-    InjectionError,
-    SubmitError,
-    SubmitException
-)
-from .recaptcha import Recaptcha
-
-
-class RecaptchaV2(Recaptcha):
-
-    def solve(self, driver, sitekey, apikey, script_submit=None, xpath_submit=None):
-        # this function is the entry point
-        self.driver = driver
-        self.sitekey = sitekey
-        self.apikey = apikey
-        self.script_submit = script_submit
-        self.xpath_submit = xpath_submit
-        self._solve()
-
-    def _solve(self):
-        try:
-            self._recaptcha_V2_solver()
-        except Exception:
-            raise AntiCaptchaError('AntiCaptcha returned an error')
-        try:
-            self._inject_response()
-        except Exception:
-            raise InjectionError('cannot find g-recaptcha-response ID')
-        try:
-            self._submit()
-        except Exception:
-            raise SubmitError('provided submit option is not working')
-
-    def _inject_response(self):
-        self.driver.execute_script(
-            f'document.getElementById("g-recaptcha-response").innerHTML = "{self.g_response}"'
-        )
-
-    def _submit(self):
-        if self.script_submit is not None:
-            self.driver.execute_script(self.script_submit)
-        elif self.xpath_submit is not None:
-            self.driver.find_element_by_xpath(self.xpath_submit).click()
-        else:
-            raise SubmitException('no way to submit provided')
diff --git a/court_scraper/case_info.py b/court_scraper/case_info.py
index 0833d22..afc8325 100644
--- a/court_scraper/case_info.py
+++ b/court_scraper/case_info.py
@@ -1,24 +1,31 @@
 class CaseInfo:
     """
     Encapsulates data from web scraping search results,
-    and provides the ability to map fields to
-    standardized names.
+    and provides the ability to map fields to standardized names.
 
-    USAGE:
+    Args:
+        data (dict): Dict used to create instance attributes
 
-        from court_scraper.case_info import CaseInfo
-        # Provide  a mapping of raw field -> standard field
-        CaseInfo._map = { 'case_num': 'number' }
+    Usage:
+        Configure CaseInfo class with a dictionary that maps
+        raw field name (key) to standardized field name (value):
 
-        # Supply data to class
-        data = { 'foo': 'bar', 'case_num': 1 }
-        ci = CaseInfo(data)
+        >>> from court_scraper.case_info import CaseInfo
+        >>> CaseInfo._map = { 'case_num': 'number' }
 
-        # Mapped fields will appear under standard name
-        assert ci.number = 1
+        Instantiate the class with data:
 
-        # Unmapped fields will appear under raw field name
-        assert ci.foo == bar
+        >>> data = { 'case_num': 1, 'foo': 'bar' }
+        >>> case_info = CaseInfo(data)
+
+        Mapped fields will be accessible via dotted-attribute notation
+        using the standardized name:
+
+        >>> assert case_info.number = 1
+
+        Unmapped fields will be accessible using raw field name:
+
+        >>> assert case_info.foo == bar
 
     """
 
diff --git a/court_scraper/platforms/odyssey/__init__.py b/court_scraper/platforms/odyssey/__init__.py
index 0f24375..bfbc180 100644
--- a/court_scraper/platforms/odyssey/__init__.py
+++ b/court_scraper/platforms/odyssey/__init__.py
@@ -1,4 +1,4 @@
 from .site import Site
 
 
-__all__ = (Site,)
+__all__ = ('Site',)
diff --git a/court_scraper/platforms/odyssey/site.py b/court_scraper/platforms/odyssey/site.py
index abbb5d8..903a62c 100644
--- a/court_scraper/platforms/odyssey/site.py
+++ b/court_scraper/platforms/odyssey/site.py
@@ -34,7 +34,7 @@ def login(self, username, password):
     def go_to_home(self):
         self.driver.get(self.site_url)
 
-    def search(self, case_numbers=[], case_details=True, timeout=60):
+    def search(self, case_numbers=[], case_details=False, timeout=60):
         """Search for cases by number.
 
         Supports search by precise case number or wild-card case number searches.
diff --git a/court_scraper/platforms/oscn/__init__.py b/court_scraper/platforms/oscn/__init__.py
index 0f24375..bfbc180 100644
--- a/court_scraper/platforms/oscn/__init__.py
+++ b/court_scraper/platforms/oscn/__init__.py
@@ -1,4 +1,4 @@
 from .site import Site
 
 
-__all__ = (Site,)
+__all__ = ('Site',)
diff --git a/court_scraper/platforms/oscn/pages/search.py b/court_scraper/platforms/oscn/pages/search.py
index dda6917..57465ba 100644
--- a/court_scraper/platforms/oscn/pages/search.py
+++ b/court_scraper/platforms/oscn/pages/search.py
@@ -21,7 +21,8 @@ class Search(BaseSearch):
     use DailyFilings search class.
 
     Args:
-        - place_id (str): Standard place id (e.g. ok_alfalfa)
+        place_id (str): Standard place id (e.g. ok_alfalfa)
+
     """
 
     def __init__(self, place_id):
diff --git a/court_scraper/platforms/oscn/site.py b/court_scraper/platforms/oscn/site.py
index 0caa6ff..01369c7 100644
--- a/court_scraper/platforms/oscn/site.py
+++ b/court_scraper/platforms/oscn/site.py
@@ -1,5 +1,8 @@
 from datetime import date
+from typing import List
 
+from court_scraper.case_info import CaseInfo
+from .search_results_wrapper import SearchResultsWrapper
 from .pages.case_number_lookup import CaseNumberLookup
 from .pages.daily_filings import DailyFilings
 from .pages.search import Search
@@ -34,7 +37,7 @@ def __init__(self, place_id):
     def __repr__(self):
         return f'Oscn ({self.place_id})'
 
-    def search(self, case_numbers=[], **kwargs):
+    def search(self, case_numbers=[], **kwargs) -> List[CaseInfo]:
         """Search one or more case IDs in a given county.
 
         Args:
@@ -42,7 +45,7 @@ def search(self, case_numbers=[], **kwargs):
 
         Returns:
 
-            List of CaseInfo classes
+            List of :py:class:`CaseInfo <court_scraper.case_info.CaseInfo>` instances
 
         """
         # NOTE: place_id will be a county typically, but we could at some
@@ -50,7 +53,7 @@ def search(self, case_numbers=[], **kwargs):
         lookup = CaseNumberLookup(self.place_id)
         return lookup.search(case_numbers=case_numbers)
 
-    def search_by_date(self, start_date=None, end_date=None, case_details=False):
+    def search_by_date(self, start_date=None, end_date=None, case_details=False) -> SearchResultsWrapper:
         """Search for cases by date range
 
         Searches current day by default. Optionally scrapes case detail pages.
@@ -66,8 +69,11 @@ def search_by_date(self, start_date=None, end_date=None, case_details=False):
             case_details (boolean): Scrape data from case detail pages. Default: False
 
         Returns:
-            SearchResults object with date-based keys (YYYY-MM-DD) and a dict value
-            containing HTML and list of CaseInfo objects: {'html': '<etc>', 'cases': [CaseInfo, etc.]}
+            A dict-like :py:class:`SearchResultsWrapper
+            <court_scraper.platforms.oscn.search_results_wrapper.SearchResultsWrapper>`
+            instance with date-based keys (YYYY-MM-DD) and dict values
+            containing `html` and `cases` as keys. The latter contains a list of
+            :py:class:`CaseInfo <court_scraper.case_info.CaseInfo>` instances.
 
         """
         # Use Daily Filings where available, and fall back to generic
diff --git a/court_scraper/platforms/wicourts/__init__.py b/court_scraper/platforms/wicourts/__init__.py
index 0f24375..bfbc180 100644
--- a/court_scraper/platforms/wicourts/__init__.py
+++ b/court_scraper/platforms/wicourts/__init__.py
@@ -1,4 +1,4 @@
 from .site import Site
 
 
-__all__ = (Site,)
+__all__ = ('Site',)
diff --git a/court_scraper/platforms/wicourts/captcha.py b/court_scraper/platforms/wicourts/captcha.py
deleted file mode 100644
index 2b2b121..0000000
--- a/court_scraper/platforms/wicourts/captcha.py
+++ /dev/null
@@ -1,34 +0,0 @@
-import pickle
-from selenium.webdriver.common.by import By
-from court_scraper.captcha.invisible_recaptcha_v2 import InvisibleRecaptchaV2
-
-
-class CaptchaVariables:
-    SITEKEY = '6LeTUtAUAAAAALyw0QQWmOALx9f2eDS2Y23m6eVp'
-    APIKEY = ''
-    CALLBACK_FUNCTION = '___grecaptcha_cfg.clients[0].O.O.callback'
-    CAPTCHA_TYPE = 'recaptcha_V2'
-    CAPTCHA = (By.XPATH, '/html/body/div[2]')
-
-
-class CaptchaHelpers():
-
-    captcha = InvisibleRecaptchaV2()
-    variables = CaptchaVariables()
-
-    def test_captcha(self):
-        return self.captcha.test(self.driver, self.variables.CAPTCHA[1])
-
-    def solve_captcha(self):
-        self.captcha.solve(
-            self.driver, self.variables.SITEKEY, self.variables.APIKEY,
-            self.variables.CAPTCHA[1],  self.variables.CALLBACK_FUNCTION
-        )
-
-    def save_cookies(self):
-        pickle.dump(self.driver.get_cookies(), open(f'{cache_dir}/cookies.pkl', 'wb'))
-
-    def load_cookies(self):
-        cookies = pickle.load(open(f'{cache_dir}/cookies.pkl', 'rb'))
-        for cookie in cookies:
-            self.driver.add_cookie(cookie)
diff --git a/court_scraper/platforms/wicourts/pages/search.py b/court_scraper/platforms/wicourts/pages/search.py
index f389959..fa04b6e 100644
--- a/court_scraper/platforms/wicourts/pages/search.py
+++ b/court_scraper/platforms/wicourts/pages/search.py
@@ -8,7 +8,6 @@
 from court_scraper.base.selenium_helpers import SeleniumHelpers
 from court_scraper.utils import dates_for_range
 from .search_results import SearchResultsPage
-from ..captcha import CaptchaHelpers
 from ..search_api import SearchApi
 
 
@@ -42,7 +41,7 @@ class SearchLocators:
     RESET_BUTTON = (By.XPATH, '//*[@id="home-container"]/main/div/form/div[11]/div/button[2]')
 
 
-class SearchPage(CaptchaHelpers, SeleniumHelpers):
+class SearchPage(SeleniumHelpers):
 
     locators = SearchLocators
 
diff --git a/court_scraper/site.py b/court_scraper/site.py
index 075ef93..0504665 100644
--- a/court_scraper/site.py
+++ b/court_scraper/site.py
@@ -33,13 +33,14 @@ def get_site_meta(cls, place_id):
     def get_site_class(cls, place_id, site_type):
         """Look up Site class by place ID and site type.
 
-        Site types for one-off scrapers should live in the 'scrapers'
-        namespace in a module named by state and county, e.g. ny_westchester.
+        Site classes for one-off scrapers should live in the *scrapers*
+        namespace in a module named by state and county, e.g. *court_scraper.scrapers.ny_westchester*.
 
-        Platform site classes should live in platforms namespace (e.g. odyssey).
+        Platform site classes should live in the *platforms* namespace
+        (e.g. *court_scraper.platforms.odyssey*).
 
-        In both cases, sites_meta.csv should specify the module name
-        in the site_type field as a snake_case value (ny_westchester).
+        In both cases, *sites_meta.csv* should specify the package name
+        in the :code:`site_type` field as a snake_case value (e.g. *odyssey* or *wicourts*).
 
         """
         if place_id == site_type:
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..143106f
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,177 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS    =
+SPHINXBUILD   = sphinx-build
+PAPER         =
+BUILDDIR      = _build
+
+# User-friendly check for sphinx-build
+ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
+$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
+endif
+
+# Internal variables.
+PAPEROPT_a4     = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+# the i18n builder cannot share the environment and doctrees with the others
+I18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+
+.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
+
+help:
+	@echo "Please use \`make <target>' where <target> is one of"
+	@echo "  html       to make standalone HTML files"
+	@echo "  dirhtml    to make HTML files named index.html in directories"
+	@echo "  singlehtml to make a single large HTML file"
+	@echo "  pickle     to make pickle files"
+	@echo "  json       to make JSON files"
+	@echo "  htmlhelp   to make HTML files and a HTML help project"
+	@echo "  qthelp     to make HTML files and a qthelp project"
+	@echo "  devhelp    to make HTML files and a Devhelp project"
+	@echo "  epub       to make an epub"
+	@echo "  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+	@echo "  latexpdf   to make LaTeX files and run them through pdflatex"
+	@echo "  latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
+	@echo "  text       to make text files"
+	@echo "  man        to make manual pages"
+	@echo "  texinfo    to make Texinfo files"
+	@echo "  info       to make Texinfo files and run them through makeinfo"
+	@echo "  gettext    to make PO message catalogs"
+	@echo "  changes    to make an overview of all changed/added/deprecated items"
+	@echo "  xml        to make Docutils-native XML files"
+	@echo "  pseudoxml  to make pseudoxml-XML files for display purposes"
+	@echo "  linkcheck  to check all external links for integrity"
+	@echo "  doctest    to run all doctests embedded in the documentation (if enabled)"
+
+clean:
+	rm -rf $(BUILDDIR)/*
+
+html:
+	$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+	@echo
+	@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+	$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+	@echo
+	@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+singlehtml:
+	$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+	@echo
+	@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+pickle:
+	$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+	@echo
+	@echo "Build finished; now you can process the pickle files."
+
+json:
+	$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+	@echo
+	@echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+	$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+	@echo
+	@echo "Build finished; now you can run HTML Help Workshop with the" \
+	      ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+	$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+	@echo
+	@echo "Build finished; now you can run "qcollectiongenerator" with the" \
+	      ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+	@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/court-scrape.qhcp"
+	@echo "To view the help file:"
+	@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/court-scrape.qhc"
+
+devhelp:
+	$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+	@echo
+	@echo "Build finished."
+	@echo "To view the help file:"
+	@echo "# mkdir -p $$HOME/.local/share/devhelp/court-scrape"
+	@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/court-scrape"
+	@echo "# devhelp"
+
+epub:
+	$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+	@echo
+	@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+latex:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo
+	@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+	@echo "Run \`make' in that directory to run these through (pdf)latex" \
+	      "(use \`make latexpdf' here to do that automatically)."
+
+latexpdf:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo "Running LaTeX files through pdflatex..."
+	$(MAKE) -C $(BUILDDIR)/latex all-pdf
+	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+latexpdfja:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo "Running LaTeX files through platex and dvipdfmx..."
+	$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
+	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+text:
+	$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+	@echo
+	@echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+man:
+	$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+	@echo
+	@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+texinfo:
+	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+	@echo
+	@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
+	@echo "Run \`make' in that directory to run these through makeinfo" \
+	      "(use \`make info' here to do that automatically)."
+
+info:
+	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+	@echo "Running Texinfo files through makeinfo..."
+	make -C $(BUILDDIR)/texinfo info
+	@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
+
+gettext:
+	$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
+	@echo
+	@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
+
+changes:
+	$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+	@echo
+	@echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+	$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+	@echo
+	@echo "Link check complete; look for any errors in the above output " \
+	      "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+	$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+	@echo "Testing of doctests in the sources finished, look at the " \
+	      "results in $(BUILDDIR)/doctest/output.txt."
+
+xml:
+	$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
+	@echo
+	@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
+
+pseudoxml:
+	$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
+	@echo
+	@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100755
index 0000000..d6f6cdf
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,291 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# court-scraper documentation build configuration file, created by
+# sphinx-quickstart on Tue Jul  9 22:26:36 2013.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+import sys
+import os
+import sphinx_rtd_theme
+
+# If extensions (or modules to document with autodoc) are in another
+# directory, add these directories to sys.path here. If the directory is
+# relative to the documentation root, use os.path.abspath to make it
+# absolute, like shown here.
+#sys.path.insert(0, os.path.abspath('.'))
+
+# Get the project root dir, which is the parent dir of this
+cwd = os.getcwd()
+project_root = os.path.dirname(cwd)
+
+# Insert the project root dir as the first element in the PYTHONPATH.
+# This lets us ensure that the source package is imported, and that its
+# version is used.
+sys.path.insert(0, project_root)
+
+import court_scraper
+
+# -- General configuration ---------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
+extensions = [
+    'sphinx.ext.autodoc',
+    'sphinx.ext.viewcode',
+    'sphinx.ext.napoleon',
+    "sphinx_rtd_theme",
+]
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix of source filenames.
+source_suffix = '.rst'
+
+# The encoding of source files.
+source_encoding = 'utf-8-sig'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = u'court-scraper'
+copyright = u"2021, Big Local News"
+
+# The version info for the project you're documenting, acts as replacement
+# for |version| and |release|, also used in various other places throughout
+# the built documents.
+#
+# The short X.Y version.
+version = court_scraper.__version__
+# The full version, including alpha/beta/rc tags.
+release = court_scraper.__version__
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#language = None
+
+# There are two options for replacing |today|: either, you set today to
+# some non-false value, then it is used:
+#today = ''
+# Else, today_fmt is used as the format for a strftime call.
+#today_fmt = '%B %d, %Y'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+exclude_patterns = ['_build']
+
+# The reST default role (used for this markup: `text`) to use for all
+# documents.
+#default_role = None
+
+# If true, '()' will be appended to :func: etc. cross-reference text.
+#add_function_parentheses = True
+
+# If true, the current module name will be prepended to all description
+# unit titles (such as .. function::).
+#add_module_names = True
+
+# If true, sectionauthor and moduleauthor directives will be shown in the
+# output. They are ignored by default.
+#show_authors = False
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# A list of ignored prefixes for module index sorting.
+#modindex_common_prefix = []
+
+# If true, keep warnings as "system message" paragraphs in the built
+# documents.
+#keep_warnings = False
+
+
+# -- Options for HTML output -------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+#html_theme = 'alabaster'
+html_theme = "sphinx_rtd_theme"
+
+# 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
+# documentation.
+html_theme_options = {
+    'collapse_navigation': True,
+}
+
+# Add any paths that contain custom themes here, relative to this directory.
+#html_theme_path = []
+
+# The name for this set of Sphinx documents.  If None, it defaults to
+# "<project> v<release> documentation".
+#html_title = None
+
+# A shorter title for the navigation bar.  Default is the same as
+# html_title.
+#html_short_title = None
+
+# The name of an image file (relative to this directory) to place at the
+# top of the sidebar.
+#html_logo = None
+
+# The name of an image file (within the static path) to use as favicon
+# of the docs.  This file should be a Windows icon file (.ico) being
+# 16x16 or 32x32 pixels large.
+#html_favicon = None
+
+# 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']
+
+# If not '', a 'Last updated on:' timestamp is inserted at every page
+# bottom, using the given strftime format.
+#html_last_updated_fmt = '%b %d, %Y'
+
+# If true, SmartyPants will be used to convert quotes and dashes to
+# typographically correct entities.
+#html_use_smartypants = True
+
+# Custom sidebar templates, maps document names to template names.
+html_sidebars = {
+    '**': [
+        'about.html',
+        'navigation.html',
+        'relations.html',
+        'searchbox.html',
+    ]
+}
+
+# Additional templates that should be rendered to pages, maps page names
+# to template names.
+#html_additional_pages = {}
+
+# If false, no module index is generated.
+#html_domain_indices = True
+
+# If false, no index is generated.
+#html_use_index = True
+
+# If true, the index is split into individual pages for each letter.
+#html_split_index = False
+
+# If true, links to the reST sources are added to the pages.
+#html_show_sourcelink = True
+
+# If true, "Created using Sphinx" is shown in the HTML footer.
+# Default is True.
+#html_show_sphinx = True
+
+# If true, "(C) Copyright ..." is shown in the HTML footer.
+# Default is True.
+#html_show_copyright = True
+
+# If true, an OpenSearch description file will be output, and all pages
+# will contain a <link> tag referring to it.  The value of this option
+# must be the base URL from which the finished HTML is served.
+#html_use_opensearch = ''
+
+# This is the file name suffix for HTML files (e.g. ".xhtml").
+#html_file_suffix = None
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'court-scraperdoc'
+
+
+# -- Options for LaTeX output ------------------------------------------
+
+latex_elements = {
+    # The paper size ('letterpaper' or 'a4paper').
+    #'papersize': 'letterpaper',
+
+    # The font size ('10pt', '11pt' or '12pt').
+    #'pointsize': '10pt',
+
+    # Additional stuff for the LaTeX preamble.
+    #'preamble': '',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title, author, documentclass
+# [howto/manual]).
+latex_documents = [
+    ('index', 'court-scraper.tex',
+     u'court-scraper Documentation',
+     u'Serdar Tumgoren', 'manual'),
+]
+
+# The name of an image file (relative to this directory) to place at
+# the top of the title page.
+#latex_logo = None
+
+# For "manual" documents, if this is true, then toplevel headings
+# are parts, not chapters.
+#latex_use_parts = False
+
+# If true, show page references after internal links.
+#latex_show_pagerefs = False
+
+# If true, show URL addresses after external links.
+#latex_show_urls = False
+
+# Documents to append as an appendix to all manuals.
+#latex_appendices = []
+
+# If false, no module index is generated.
+#latex_domain_indices = True
+
+
+# -- Options for manual page output ------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+    ('index', 'court-scraper',
+     u'court-scraper Documentation',
+     [u'Serdar Tumgoren'], 1)
+]
+
+# If true, show URL addresses after external links.
+#man_show_urls = False
+
+
+# -- Options for Texinfo output ----------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+#  dir menu entry, description, category)
+texinfo_documents = [
+    ('index', 'court-scraper',
+     u'court-scraper Documentation',
+     u'Serdar Tumgoren',
+     'court-scraper',
+     'One line description of project.',
+     'Miscellaneous'),
+]
+
+# Documents to append as an appendix to all manuals.
+#texinfo_appendices = []
+
+# If false, no module index is generated.
+#texinfo_domain_indices = True
+
+# How to display URL addresses: 'footnote', 'no', or 'inline'.
+#texinfo_show_urls = 'footnote'
+
+# If true, do not generate a @detailmenu in the "Top" node's menu.
+#texinfo_no_detailmenu = False
diff --git a/docs/contributing.rst b/docs/contributing.rst
new file mode 100644
index 0000000..e582053
--- /dev/null
+++ b/docs/contributing.rst
@@ -0,0 +1,1 @@
+.. include:: ../CONTRIBUTING.rst
diff --git a/docs/court_scraper.base.rst b/docs/court_scraper.base.rst
new file mode 100644
index 0000000..23befb0
--- /dev/null
+++ b/docs/court_scraper.base.rst
@@ -0,0 +1,37 @@
+court\_scraper.base package
+===========================
+
+Submodules
+----------
+
+court\_scraper.base.runner module
+---------------------------------
+
+.. automodule:: court_scraper.base.runner
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.base.selenium\_helpers module
+--------------------------------------------
+
+.. automodule:: court_scraper.base.selenium_helpers
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.base.selenium\_site module
+-----------------------------------------
+
+.. automodule:: court_scraper.base.selenium_site
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: court_scraper.base
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/court_scraper.captcha.rst b/docs/court_scraper.captcha.rst
new file mode 100644
index 0000000..8e47f32
--- /dev/null
+++ b/docs/court_scraper.captcha.rst
@@ -0,0 +1,10 @@
+court\_scraper.captcha package
+==============================
+
+Module contents
+---------------
+
+.. automodule:: court_scraper.captcha
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/court_scraper.platforms.odyssey.pages.rst b/docs/court_scraper.platforms.odyssey.pages.rst
new file mode 100644
index 0000000..d5abd36
--- /dev/null
+++ b/docs/court_scraper.platforms.odyssey.pages.rst
@@ -0,0 +1,61 @@
+court\_scraper.platforms.odyssey.pages package
+==============================================
+
+Submodules
+----------
+
+court\_scraper.platforms.odyssey.pages.base module
+--------------------------------------------------
+
+.. automodule:: court_scraper.platforms.odyssey.pages.base
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.odyssey.pages.case\_detail module
+----------------------------------------------------------
+
+.. automodule:: court_scraper.platforms.odyssey.pages.case_detail
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.odyssey.pages.login module
+---------------------------------------------------
+
+.. automodule:: court_scraper.platforms.odyssey.pages.login
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.odyssey.pages.portal module
+----------------------------------------------------
+
+.. automodule:: court_scraper.platforms.odyssey.pages.portal
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.odyssey.pages.search module
+----------------------------------------------------
+
+.. automodule:: court_scraper.platforms.odyssey.pages.search
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.odyssey.pages.search\_results module
+-------------------------------------------------------------
+
+.. automodule:: court_scraper.platforms.odyssey.pages.search_results
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: court_scraper.platforms.odyssey.pages
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/court_scraper.platforms.odyssey.rst b/docs/court_scraper.platforms.odyssey.rst
new file mode 100644
index 0000000..43c262a
--- /dev/null
+++ b/docs/court_scraper.platforms.odyssey.rst
@@ -0,0 +1,37 @@
+court\_scraper.platforms.odyssey package
+========================================
+
+Subpackages
+-----------
+
+.. toctree::
+   :maxdepth: 4
+
+   court_scraper.platforms.odyssey.pages
+
+Submodules
+----------
+
+court\_scraper.platforms.odyssey.runner module
+----------------------------------------------
+
+.. automodule:: court_scraper.platforms.odyssey.runner
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.odyssey.site module
+--------------------------------------------
+
+.. automodule:: court_scraper.platforms.odyssey.site
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: court_scraper.platforms.odyssey
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/court_scraper.platforms.oscn.pages.rst b/docs/court_scraper.platforms.oscn.pages.rst
new file mode 100644
index 0000000..ca9ac42
--- /dev/null
+++ b/docs/court_scraper.platforms.oscn.pages.rst
@@ -0,0 +1,69 @@
+court\_scraper.platforms.oscn.pages package
+===========================================
+
+Submodules
+----------
+
+court\_scraper.platforms.oscn.pages.base\_search module
+-------------------------------------------------------
+
+.. automodule:: court_scraper.platforms.oscn.pages.base_search
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.oscn.pages.case\_detail module
+-------------------------------------------------------
+
+.. automodule:: court_scraper.platforms.oscn.pages.case_detail
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.oscn.pages.case\_number\_lookup module
+---------------------------------------------------------------
+
+.. automodule:: court_scraper.platforms.oscn.pages.case_number_lookup
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.oscn.pages.daily\_filings module
+---------------------------------------------------------
+
+.. automodule:: court_scraper.platforms.oscn.pages.daily_filings
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.oscn.pages.daily\_filings\_results module
+------------------------------------------------------------------
+
+.. automodule:: court_scraper.platforms.oscn.pages.daily_filings_results
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.oscn.pages.search module
+-------------------------------------------------
+
+.. automodule:: court_scraper.platforms.oscn.pages.search
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.oscn.pages.search\_results module
+----------------------------------------------------------
+
+.. automodule:: court_scraper.platforms.oscn.pages.search_results
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: court_scraper.platforms.oscn.pages
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/court_scraper.platforms.oscn.parsers.rst b/docs/court_scraper.platforms.oscn.parsers.rst
new file mode 100644
index 0000000..b2d51b8
--- /dev/null
+++ b/docs/court_scraper.platforms.oscn.parsers.rst
@@ -0,0 +1,21 @@
+court\_scraper.platforms.oscn.parsers package
+=============================================
+
+Submodules
+----------
+
+court\_scraper.platforms.oscn.parsers.case\_detail module
+---------------------------------------------------------
+
+.. automodule:: court_scraper.platforms.oscn.parsers.case_detail
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: court_scraper.platforms.oscn.parsers
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/court_scraper.platforms.oscn.rst b/docs/court_scraper.platforms.oscn.rst
new file mode 100644
index 0000000..90c9a70
--- /dev/null
+++ b/docs/court_scraper.platforms.oscn.rst
@@ -0,0 +1,46 @@
+court\_scraper.platforms.oscn package
+=====================================
+
+Subpackages
+-----------
+
+.. toctree::
+   :maxdepth: 4
+
+   court_scraper.platforms.oscn.pages
+   court_scraper.platforms.oscn.parsers
+
+Submodules
+----------
+
+court\_scraper.platforms.oscn.runner module
+-------------------------------------------
+
+.. automodule:: court_scraper.platforms.oscn.runner
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.oscn.search\_results\_wrapper module
+-------------------------------------------------------------
+
+.. automodule:: court_scraper.platforms.oscn.search_results_wrapper
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.oscn.site module
+-----------------------------------------
+
+.. automodule:: court_scraper.platforms.oscn.site
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: court_scraper.platforms.oscn
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/court_scraper.platforms.rst b/docs/court_scraper.platforms.rst
new file mode 100644
index 0000000..f941982
--- /dev/null
+++ b/docs/court_scraper.platforms.rst
@@ -0,0 +1,20 @@
+court\_scraper.platforms package
+================================
+
+Subpackages
+-----------
+
+.. toctree::
+   :maxdepth: 4
+
+   court_scraper.platforms.odyssey
+   court_scraper.platforms.oscn
+   court_scraper.platforms.wicourts
+
+Module contents
+---------------
+
+.. automodule:: court_scraper.platforms
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/court_scraper.platforms.wicourts.pages.rst b/docs/court_scraper.platforms.wicourts.pages.rst
new file mode 100644
index 0000000..f8da745
--- /dev/null
+++ b/docs/court_scraper.platforms.wicourts.pages.rst
@@ -0,0 +1,29 @@
+court\_scraper.platforms.wicourts.pages package
+===============================================
+
+Submodules
+----------
+
+court\_scraper.platforms.wicourts.pages.search module
+-----------------------------------------------------
+
+.. automodule:: court_scraper.platforms.wicourts.pages.search
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.wicourts.pages.search\_results module
+--------------------------------------------------------------
+
+.. automodule:: court_scraper.platforms.wicourts.pages.search_results
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: court_scraper.platforms.wicourts.pages
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/court_scraper.platforms.wicourts.parsers.rst b/docs/court_scraper.platforms.wicourts.parsers.rst
new file mode 100644
index 0000000..1cae626
--- /dev/null
+++ b/docs/court_scraper.platforms.wicourts.parsers.rst
@@ -0,0 +1,10 @@
+court\_scraper.platforms.wicourts.parsers package
+=================================================
+
+Module contents
+---------------
+
+.. automodule:: court_scraper.platforms.wicourts.parsers
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/court_scraper.platforms.wicourts.rst b/docs/court_scraper.platforms.wicourts.rst
new file mode 100644
index 0000000..9b117d6
--- /dev/null
+++ b/docs/court_scraper.platforms.wicourts.rst
@@ -0,0 +1,46 @@
+court\_scraper.platforms.wicourts package
+=========================================
+
+Subpackages
+-----------
+
+.. toctree::
+   :maxdepth: 4
+
+   court_scraper.platforms.wicourts.pages
+   court_scraper.platforms.wicourts.parsers
+
+Submodules
+----------
+
+court\_scraper.platforms.wicourts.runner module
+-----------------------------------------------
+
+.. automodule:: court_scraper.platforms.wicourts.runner
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.wicourts.search\_api module
+----------------------------------------------------
+
+.. automodule:: court_scraper.platforms.wicourts.search_api
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.platforms.wicourts.site module
+---------------------------------------------
+
+.. automodule:: court_scraper.platforms.wicourts.site
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: court_scraper.platforms.wicourts
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/court_scraper.rst b/docs/court_scraper.rst
new file mode 100644
index 0000000..57ea2b8
--- /dev/null
+++ b/docs/court_scraper.rst
@@ -0,0 +1,87 @@
+court\_scraper package
+======================
+
+Subpackages
+-----------
+
+.. toctree::
+   :maxdepth: 4
+
+   court_scraper.base
+   court_scraper.captcha
+   court_scraper.platforms
+
+Submodules
+----------
+
+court\_scraper.case\_info module
+--------------------------------
+
+.. automodule:: court_scraper.case_info
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.cli module
+-------------------------
+
+.. automodule:: court_scraper.cli
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.configs module
+-----------------------------
+
+.. automodule:: court_scraper.configs
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.datastore module
+-------------------------------
+
+.. automodule:: court_scraper.datastore
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.db module
+------------------------
+
+.. automodule:: court_scraper.db
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.site module
+--------------------------
+
+.. automodule:: court_scraper.site
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.sites\_meta module
+---------------------------------
+
+.. automodule:: court_scraper.sites_meta
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+court\_scraper.utils module
+---------------------------
+
+.. automodule:: court_scraper.utils
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: court_scraper
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/history.rst b/docs/history.rst
new file mode 100644
index 0000000..2506499
--- /dev/null
+++ b/docs/history.rst
@@ -0,0 +1,1 @@
+.. include:: ../HISTORY.rst
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..fd1b8cb
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,22 @@
+.. court-scraper documentation master file
+
+.. include:: ../README.rst
+
+Documentation
+=============
+
+.. toctree::
+
+   install
+   usage
+   contributing
+   writing_a_scraper
+   testing
+   history
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/docs/install.rst b/docs/install.rst
new file mode 100644
index 0000000..da8cc95
--- /dev/null
+++ b/docs/install.rst
@@ -0,0 +1,115 @@
+.. _install:
+
+Installation
+============
+
+Install
+-------
+
+.. code::
+
+   pip install court-scraper
+
+Upon installation, you should have access to the :code:`court-scraper` tool on the command line. Use the
+:code:`--help` flag to view available sub-commands::
+
+  court-scraper --help
+
+
+You should also be able to import the :code:`court_scraper` package from a Python script.::
+
+
+  import court_scraper
+  print(court_scraper.__version__)
+
+.. note:: See :ref:`the usage docs <usage>` for details on using *court-scraper* on
+  the command line and in custom scripts.
+
+
+.. _default cache dir:
+
+Default cache directory
+-------------------------
+
+By default, files downloaded by the command-line tool will be saved to the :code:`.court-scraper` folder
+in the user's home directory.
+
+On Linux/Mac systems, this will be :code:`~/.court-scraper/`.
+
+
+.. _customize cache dir:
+
+Customize cache directory
+-------------------------
+
+To use an alternate cache directory, set the below environment variable
+(e.g. in a :code:`~/.bashrc` or :code:`~/.bash_profile` configuration file)::
+
+   export COURT_SCRAPER_DIR=/tmp/some_other_dir
+
+
+.. _configuration: 
+
+Configuration
+-------------
+
+Many court sites require user credentials to log in or present
+:ref:`CAPTCHAs <captcha sites>` that must be handled
+using a paid, third-party service (`court-scraper` uses `Anti-captcha`_).
+
+Sensitive information such as user logins and the API key
+for a CAPTCHA service should be stored in a YAML configuration file called `config.yaml`.
+
+This file is expected to live inside the :ref:`default storage location <default cache dir>`
+for scraped files, logs, etc.
+
+On Linux/Mac, the default location is :code:`~/.court-scraper/config.yaml`.
+
+This configuration file must contain credentials for each
+location based on a :ref:`Place ID <place id>`, which is a `snake_case <https://en.wikipedia.org/wiki/Snake_case>`_
+combination of state and county (e.g. `ga_dekalb` for Dekalb County, GA).
+
+Courts with a common software platform that allow sharing
+of credentials can inherit credentials from a single entry.
+
+Here's an example configuration file::
+
+  # ~/.court-scraper/config.yaml
+  captcha_service_api_key: 'YOUR_ANTICAPTCHA_KEY'
+  platforms:
+    # Mark a platform user/pass for reuse in multiple sites
+    odyssey_site: &ODYSSEY_SITE
+      username: 'user@example.com'
+      password: 'SECRET_PASS'
+  # Inherit platform credentials across multiple courts
+  ga_chatham: *ODYSSEY_SITE
+  ga_dekalb: *ODYSSEY_SITE
+  ga_fulton: *ODYSSEY_SITE
+
+  # Or simply set site-specific attributes
+  ny_westchester:
+    username: 'user2@example.com'
+    password: 'GREAT_PASSWORD'
+
+
+.. _captcha sites:
+
+CAPTCHA-protected sites
+~~~~~~~~~~~~~~~~~~~~~~~
+
+`court-scraper` uses the `Anti-captcha`_ service to handle sites
+protected by CAPTCHAs_.
+
+.. _Anti-captcha: https://anti-captcha.com
+.. _CAPTCHAS: https://en.wikipedia.org/wiki/CAPTCHA
+
+If you plan to scrape a CAPTCHA-protected site, register with the
+`Anti-captcha`_ service and obtain an API key.
+
+Then, add your API key to your local court-scraper configuration file as shown below::
+
+
+  # ~/.court-scraper/config.yaml
+  captcha_service_api_key: 'YOUR_API_KEY'
+
+Once configured, you should be able to query CAPTCHA-protected sites currently supported by `court-scraper`.
diff --git a/docs/modules.rst b/docs/modules.rst
new file mode 100644
index 0000000..c4051a5
--- /dev/null
+++ b/docs/modules.rst
@@ -0,0 +1,7 @@
+court_scraper
+=============
+
+.. toctree::
+   :maxdepth: 4
+
+   court_scraper
diff --git a/docs/usage.rst b/docs/usage.rst
new file mode 100644
index 0000000..6ad0f43
--- /dev/null
+++ b/docs/usage.rst
@@ -0,0 +1,287 @@
+.. _usage:
+
+Usage
+=====
+
+Overview
+--------
+
+*court-scraper* provides a command-line tool and underlying Python library
+that can be used to scrape data about court cases.  The command-line tool supports
+basic search by case numbers. The Python library offers a wider range of options
+for more advanced use cases (e.g. filtering search results by case type).
+
+Our project focuses on scraping data from platforms used by county-level courts. 
+These platforms vary in features. Some only offer basic search by party or case number,
+whereas others support advanced search by one or more parameters such as date range and case type.
+
+Wherever possible, *court-scraper* attempts to provide support for search by:
+
+- **date range** - to enable automated discovery of new cases and backfilling of previous cases
+- **case type** - to enable more targeted scrapes in combination with date range
+- **case number** - to enable ongoing updates of open cases
+
+The library is currently focused on acquring raw file artifacts (e.g. HTML and JSON files containing case data).
+*court-scraper* does not automate the extraction and standardization of data from these raw files.
+
+.. note:: We hope to eventually provide tools to help with data extraction and standardization. However,
+          due to the wide variability of case types even within a single platform, this effort remains
+          on our long-term roadmap. We welcome :ref:`contributions <contributing>` on this front!
+
+
+.. _find a site:
+
+Find a court to scrape
+-----------------------
+
+Before you can start scraping court records, you must first pinpoint
+a county of interest and check whether we currently support it.
+
+Use the command-line tool's :ref:`info sub-command <cli info>` to list currently supported counties.
+
+If you don't see the state or county you're targeting, it's worth checking out our `Issue tracker`_ to
+see if it's on the roadmap. In some cases, we may be actively working on adding support for your jurisdiction. We also
+have a stable of scrapers that were written by others for project-specific purposes and contributed
+to our project for integration into our more general framework. We can provide access to these
+"one-off" scrapers for your customization, even if we have not yet integrated them into `court-scraper`.
+
+.. _Issue tracker: https://github.com/biglocalnews/court-scraper/issues
+
+
+.. _place id:
+
+Place IDs
+---------
+
+*court-scraper* requires searches to target courts/jurisdictions in specific counties. Every jurisdiction supported
+by the framework has a so-called *Place ID*. These unique identifiers are in "snake case" format
+(i.e. lower case with underscores): :code:`<state_postal>_<county_name>`.
+
+For example, the *Place ID* for Tulsa, Oklahoma is :code:`ok_tulsa`.
+
+Whether working with the :ref:`CLI` or :ref:`Custom scripts`, you'll need to identify the `Place ID` for the
+target jurisdiction. You can use the command-line tool's :ref:`info sub-command <cli info>` to
+find the `Place ID` for your jurisdiction.
+
+
+.. _cli:
+
+Command line
+------------
+
+.. note:: Before using the command-line tool, check out the :ref:`install docs <install>` 
+   and read up on :ref:`finding a court site to scrape <find a site>`.
+
+The command-line tool helps pinpoint counties currently supported by *court-scraper* 
+and enables scraping case files by number.
+
+Use the :code:`--help` flag to view available sub-commands::
+
+  court-scraper --help
+
+.. _cli info:
+
+Info command
+~~~~~~~~~~~~
+
+The :code:`info` sub-command lists the currently supported counties::
+
+  court-scraper info
+
+.. note:: See :ref:`find a site` for advice if your jurisdiction is not among those listed.
+
+
+Case number search
+~~~~~~~~~~~~~~~~~~
+
+The *court-scraper* CLI's :code:`search` sub-command is the primary way to gather
+case details from a county court site. You can use the tool's :code:`--help`
+flag to get details on available options::
+
+  court-scraper search --help
+
+The :code:`search` sub-command supports scraping by case number. It requires two parameters:
+
+- :code:`--place-id` or :code:`-p` - A combination of state postal and county name in "snake case" (e.g. `ok_tulsa`). The :ref:`Place ID <place id>` can be obtained by using the :ref:`info sub-command <cli info>`.
+
+- :code:`--case-number` or :code:`-c` - A single case number to scrape.
+
+Here's an example search for Tulsa, Oklahoma::
+
+  # Scrape case details by place ID and case number
+  court-scraper search --place-id ok_tulsa --case-number CJ-2021-2045
+
+To search for more than one case at a time, use the :code:`--case-numbers-file` (or :code:`-f`) flag
+with a text file containing case numbers on separate lines.
+
+For example, if you create a *case_numbers.txt* file with the below case numbers::
+
+  # case_numbers.txt
+  CJ-2021-2045
+  CJ-2021-2046
+
+You can then search using the *case_numbers.txt* file::
+
+  court-scraper search --place-id ok_tulsa --case-numbers-file case_numbers.txt
+
+
+Browser mode
+~~~~~~~~~~~~
+
+Scrapers that use `Selenium <https://selenium-python.readthedocs.io/>`_ to drive a web browser
+by default run in "headless" mode (i.e. the browser will not run visibly). In order
+to run a Selenium-based scraper with the browser, which can be helpful for debugging, use
+the :code:`--with-browser` flag::
+
+  court-scraper search --with-browser --place-id ok_tulsa --case-number CJ-2021-2045
+
+
+File storage
+~~~~~~~~~~~~~
+
+Files scraped by the :code:`search` sub-command are saved to a :ref:`standard <default cache dir>`  -- but :ref:`configurable <customize cache dir>` -- location 
+in the user's home directory, based on the court's :ref:`Place ID <place id>` (:code:`~/.court-scraper/cache/<place_id>` on Linux/Mac).
+
+For example, HTML files scraped for Tulsa, Oklahoma are stored in :code:`~/.court-scraper/cache/ok_tulsa`.
+
+Metadata db
+~~~~~~~~~~~~
+
+The :code:`search` sub-command stores basic metadata about scraped cases in a SQLite database
+located in the :ref:`standard cache directory <default cache dir>`: :code:`~/.court-scraper/cases.db`.
+
+The database can be helpful for quickly checking which cases have been scraped.
+
+It stores the following fields:
+
+* ``created`` (*datetime*) - The date and time of the case was initially scraped.
+* ``udpated`` (*datetime*) - The date and time of last scrape for the case.
+* ``place_id`` (*str*) - The state postal and county name in "snake case" (e.g. *ok_tulsa*).
+* ``number`` (*str*) - The case number.
+* ``filing_date`` (*date*) - The filing date of the case (if available).
+* ``status`` (*str*) - Case status (if available).
+
+.. _custom scripts:
+
+Custom scripts
+--------------
+
+*court-scraper* provides an importable Python package for users who are comfortable creating their 
+own scripts. The Python package provides access to a wider variety of features for
+added flexibility and more advanced scenarios such as searching by date and filtering by case type.
+
+.. note:: Court websites offer different search functionality, so it's important to review
+   the site and its corresponding Site class (and search methods) in this library to get a sense 
+   of supported features.
+
+
+Scrape case details by number
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Once you :ref:`install <install>` *court-scraper* and
+:ref:`find a site to scrape <find a site>`, you're ready to begin
+using the ``court_scraper`` Python package.
+
+Create an instance of :code:`Site` by passing it the :ref:`Place ID <place id>` for
+the jurisdiction. Then call the :code:`search` method with one or more case numbers::
+
+  from court_scraper import Site
+  site = Site('ok_tulsa')
+  case_numbers=['CJ-2021-1904', 'CJ-2021-1905']
+  results = site.search(case_numbers=case_numbers)
+
+.. note:: :py:class:`Site <court_scraper.site.Site>` provides a generic interface to simplify import and configuration
+   of platform-specific Site classes, such as :py:class:`court_scraper.platforms.oscn.site.Site`.
+   Platform Site classes typically have varying options for initialization and search, so it's a good
+   idea to review their options when using this generic Site class.
+
+.. _scrape by date:
+
+Scrape by date
+~~~~~~~~~~~~~~
+
+Some court sites support date-based search. In such cases, you can use the platform's :code:`Site.search_by_date` method 
+to scrape data for one or more days.
+
+By default, :code:`search_by_date` only gathers case metadata (e.g. case numbers, filing dates, status, etc.) that 
+typically appear on a results page after performing a search.
+
+.. note:: See :ref:`below <scrape details>` for details on scraping case detail file artifacts (e.g. HTML, JSON, etc.).
+
+To scrape case metadata for the current day::
+
+  from court_scraper import Site
+  site = Site('ok_tulsa')
+  results = site.search_by_date()
+
+To search a range of dates, use the :code:`start_date` and :code:`end_date` arguments.
+Their values must be strings of the form :code:`YYYY-MM-DD`. The below code scrapes metadata
+for cases filed in Tulsa, Oklahoma during January 2021::
+
+  from court_scraper import Site
+  site = Site('ok_tulsa')
+  results = site.search_by_date(start_date='2021-01-01', end_date='2021-01-31')
+
+.. _scrape details:
+
+Scrape case details
+~~~~~~~~~~~~~~~~~~~
+
+Court sites typically provide more detailed case information on separate pages devoted to a case.
+Depending on the site, these pages can include:
+
+- Case type
+- Case status
+- Litigant information (i.e. names and addresses)
+- Judge name(s)
+- Events related to the case (e.g. filings and decisions)
+
+Links to case detail pages are listed on a results page after conducting a search.
+These are typically HTML, but may be JSON or other file formats depending on the site.
+ 
+By default, :code:`search_by_date` only scrapes metadata from search results pages (as described in :ref:`Scrape by date`).
+
+To scrape case detail files, pass the :code:`case_details=True` keyword argument::
+
+  from court_scraper import Site
+  site = Site('ok_tulsa')
+  results = site.search_by_date(
+      start_date='2021-01-01',
+      end_date='2021-01-31',
+      case_details=True # Fetches case detail files
+  )
+
+.. filter case type:
+
+Filter by case type
+~~~~~~~~~~~~~~~~~~~
+
+Some court sites support a variety of parameters for more targeted filtering of
+search results. These filters can be useful for more surgical scrapes, and in scenarios
+where a site truncates results. If a site limits search results to 500 records, for example, 
+scraping in a more targeted way with filters can help stay under that cap.
+
+
+To determine if a site supports case-type filtering, you should review the court's website
+and the corresponding :code:`Site` class in *court-scraper*.
+
+For example, the Wisconsin court system's `Advanced Search`_ page
+offers a variety of additional search parameters. In *court-scraper*, the site's
+corresponding :py:meth:`search_by_date <court_scraper.platforms.wicourts.site.Site.search_by_date>` method
+supports a :code:`case_types` argument that accepts a list of one or more case types.
+
+.. note:: For Wisconsin, these case types are two-letter, upper-case codes that can be found by 
+   examining the source code for the *Case types* select menu on the `Advanced Search`_ page.
+
+Here's a sample usage that searches for civil (CV) and small claims (SC) cases
+on July 1, 2021 in Milwaukee, WI::
+
+  from court_scraper import Site
+  site = Site('wi_milwaukee')
+  results = site.search_by_date(
+      start_date='2021-07-01',
+      end_date='2021-07-01',
+      case_types=['CV', 'SC'] # Civil and Small Claims case types
+  )
+
+.. _Advanced Search: https://wcca.wicourts.gov/advanced.html
\ No newline at end of file
diff --git a/docs/writing_a_scraper.rst b/docs/writing_a_scraper.rst
new file mode 100644
index 0000000..5ad12fd
--- /dev/null
+++ b/docs/writing_a_scraper.rst
@@ -0,0 +1,189 @@
+.. _writing a scraper:
+
+Writing a scraper
+=================
+
+Overview
+--------
+
+*court-scraper*'s main goal is to serve as a framework for acquiring basic 
+court case metadata and raw file artifacts (e.g. HTML, JSON, PDFs, etc.) for county-level
+courts.
+
+These files can then be further processed in separate scripts that perform data extraction and standardization to
+support the needs of a given project. 
+
+We're especially focused on platforms used by a large number of county court sites, 
+although we expect to create "one-off" scrapers for bespoke sites when necessary.
+
+We also anticipate situations where county-level data simply isn't available online. 
+Such cases will require requesting data on a regular basis (and possibly paying for it).
+
+If you're thinking of scraping a court site, it's important to conduct some research to determine what 
+data a court site provides. Some jurisdictions provide a simple search with case details readily accessible
+online (and easily scrapable). Others provide multiple ways of accessing case data, such as a free and open
+site that allows searching for case metadata, while hiding more detailed case information behind logins, CAPTCHAs,
+paywalls, or some combination of these barriers.
+
+Additionally, sites typically include legal restrictions on access and use of data.
+
+When embarking on scraping a court site, it's important to understand their offerings and
+the legal restrictions surrounding use of the site and its data. Please perform your due diligence
+and reach out to discuss a particular site if you have questions about strategy!
+
+Devise a scraping strategy
+---------------------------
+
+Before coding a new scraper, take some time to determine the best scraping strategy by interacting
+with and `dissecting the site <https://github.com/stanfordjournalism/stanford-progj-2021/blob/main/docs/web_scraping/101.md>`_.
+
+Whenever possible, we favor scrapers that gather data using basic HTTP GET or POST calls using
+the Python requests_ library. Sites that are heavy on dynamically generated content and pose other 
+challenges may require browser automation via Selenium_. Such cases are unavoidable, although 
+often you may find it's possible to use a combination of both scraping strategies to optimize
+the speed of the scraper. For example, the :py:class:`Wisconsin scraper <court_scraper.platforms.wicourts.site.Site>`
+uses both libraries to achieve faster scrapes while handling (and minimizing the cost) of CAPTCHAs.
+
+.. _requests: https://docs.python-requests.org/en/master/index.html
+.. _Selenium: https://selenium-python.readthedocs.io/
+
+Code a scraper
+---------------
+
+Add a Site class
+~~~~~~~~~~~~~~~~
+
+.. note:: Check out the docs for :ref:`getting started on code contributions <code contribution bootstrap>`
+  for details on setting up a fork for local development. 
+
+The main task involved in contributing a scraper is creating a :code:`Site` class that 
+provides a :code:`search` method capable of scraping one or more case numbers.
+
+For courts that offer date-based search, :code:`Site` should also have a :code:`search_by_date` method. 
+If the date search can be filtered by one or more case types, the method should include support for this
+filter as well.
+
+Lastly, sites that require login should have a :code:`login` method.
+
+These methods should have `standard signatures <https://en.wikipedia.org/wiki/Type_signature#Method_signature>`_
+in order to support automated scraping and for integration with *court-scraper*'s :ref:`commnand-line tool <cli>`.
+
+Below is a simplified example of a scraper for an imaginary platform called *Court Whiz*. Each
+method notes its expectations, and we use type annotations to signal expected return values::
+
+    # court_scraper/platforms/court_whiz/site.py
+
+    from typing import List
+    from court_scraper.case_info import CaseInfo
+
+    class Site:
+
+        def __init__(self, place_id):
+            self.place_id = place_id
+            self.url = "https://court-whiz.com"
+
+        def search(self, case_numbers=[]) -> List[CaseInfo]:
+            # Perform a place-specific search (using self.place_id)
+            # for one or more case numbers.
+            # Return a list of CaseInfo instances containing case metadata and, 
+            # if available, HTML for case detail page
+            pass
+
+        def search_by_date(self, start_date=None, end_date=None, case_details=False, case_types=[]) -> List[CaseInfo]:
+            # Perform a place-specific, date-based search. 
+            # Defaut to current day if start_date and end_date not supplied.
+            # Only scrape case metadata from search results pages by default.
+            # If case_details set to True, scrape detailed case info
+            # Apply case type filter if supported by site.
+            # Return a list of CaseInfo instances
+            pass
+
+        def login(self, username, password):
+            # Perform login with username and password
+            pass
+
+
+Site classes for scrapers specific to a single county should live in
+the :code:`court_scraper.scrapers` namespace under a package based on the jurisdiction's
+:ref:`Place ID <place id>`.
+
+For example, the Site class for Westchester County would live in :code:`court_scraper.scrapers.ny_westchester.site.py`.
+
+Many counties use common software platforms, such as Odyssey by Tyler Technologies, to provide case information.
+
+To add a platform-based scraper for use in more than one jurisdiction, add a site class to the :code:`court_scraper.platforms` namespace.
+For example, :py:class:`court_scraper.platforms.odyssey.site.Site`.
+
+.. note:: We've provided some base classes and helper functions to help with common 
+   scenarios (e.g. see :py:class:`SeleniumHelpers <court_scraper.base.selenium_helpers.SeleniumHelpers>` and functions 
+   in :py:mod:`court_scraper.utils`).
+
+Add tests
+~~~~~~~~~
+
+New site classes should include test coverage for the :code:`search` and :code:`search_by_date` methods. 
+
+Check out our :ref:`Testing docs <testing>` and review test modules for the Odyssey, Oklahoma (oscn) or Wisconsin (wicourts) 
+site clasess for examples that can help you get started.
+
+Update *court_scraper.site.Site*
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The :py:class:`court_scraper.site.Site` class provides a simpler interface for looking up and working 
+with a jurisdiction's Site class.
+
+If your new Site class has some initialization needs beyond simply providing a :ref:`Place ID <place id>`,
+you may need to update :py:class:`court_scraper.site.Site` with special handling for your new Site class.
+
+Even if you don't update :py:class:`court_scraper.site.Site`, it's a good idea to add at least one 
+high-level integration test in :code:`tests/test_site.py` for your new Site class to ensure it's
+handled correctly.
+
+CLI Integration
+---------------
+
+Integration with *court-scraper*'s :ref:`command-line tool <cli>` requires several steps, as detailed
+below.
+
+Create a Runner
+~~~~~~~~~~~~~~~~
+
+First, you must create a :code:`Runner` class capable of driving the newly implemented :code:`Site` 
+class. Runners generally perform the following taks:
+
+- Instantiate the :code:`Site` class
+- Call :code:`Site.search` with values passed in by :py:mod:`court_scraper.cli`
+- Set sensible defaults, as needed
+- Perform caching of scraped file artifacts
+- Log information to the command-line, as needed
+
+See the runners for :py:class:`Oklahoma <court_scraper.platforms.oscn.runner.Runner>` 
+or :py:class:`Odyssey <court_scraper.platforms.odyssey.runner.Runner>` for reference implementations.
+
+
+Sites Meta CSV
+~~~~~~~~~~~~~~~
+
+In order for our CLI tool to execute scrapes for a given jurisdiction, the jurisdiction
+must be added to `sites_meta.csv`_. This file contains the following fields:
+
+- :code:`state` - 2-letter state abbreviation, lower cased
+- :code:`county` - lower-case name of county (without the word "County")
+- :code:`site_type` - Base name of the Python package where the Site class lives (e.g. `odyssey` or `wicourts`)
+- :code:`site_version` - Platform based sites may have multiple versions. Use this field to denote a new version of a platform-based site.
+- :code:`captcha_service_required` - Mark as True if a site presents CAPTCHAs
+- :code:`home_url` - Starting page for a platform used by many jurisdictions at separate domains (e.g. `odyssey`) 
+
+
+It's important to note that *every jurisdiction covered* by a scraper
+must be entered in `sites_meta.csv`_, even if the sites share a common
+platform. 
+
+For example, there are separate entries in `sites_meta.csv`_ for most counties in Washington State. These
+jurisdictions use the Odyssey platform, but they live at different domains. `sites_meta.csv`_ provides a single
+place to store the home URL and other metadata for each of these counties.
+
+
+.. _sites_meta.csv: https://github.com/biglocalnews/court-scraper/blob/master/court_scraper/data/sites_meta.csv
+
+
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..fd4c55a
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,20 @@
+bumpversion
+wheel
+watchdog
+flake8
+tox
+coverage
+# Peg docutils until below bug is resolved
+# https://github.com/sphinx-doc/sphinx/issues/3212
+#docutils==0.12
+# Docutils < 0.17 is required for sphinx_rtd_theme == 0.5.2
+docutils==0.16
+#Sphinx==1.4.8
+Sphinx
+sphinx-rtd-theme
+pylint
+pytest
+pytest-vcr
+vcrpy
+argh
+twine
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..6783d50
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,11 @@
+anticaptchaofficial
+bs4
+click
+click-option-group
+lxml
+my-fake-useragent
+pyyaml
+retrying
+selenium
+sqlalchemy
+typing-extensions
diff --git a/setup.py b/setup.py
index 131dae0..cb33033 100644
--- a/setup.py
+++ b/setup.py
@@ -1,10 +1,47 @@
 #!/usr/bin/env python
 # -*- coding: utf-8 -*-
+"""
+.. image:: https://img.shields.io/pypi/v/court-scraper.svg
+        :target: https://pypi.python.org/pypi/court-scraper
 
+.. image:: https://img.shields.io/pypi/pyversions/court-scraper.svg
+        :target: https://pypi.python.org/pypi/court-scraper
+
+.. image:: https://img.shields.io/travis/biglocalnews/court-scraper.svg
+        :target: https://travis-ci.com/biglocalnews/court-scraper
+
+.. image:: https://readthedocs.org/projects/court-scraper/badge/?version=latest
+        :target: https://court-scraper.readthedocs.io/en/latest/?badge=latest
+        :alt: Documentation Status
+
+.. image:: https://badges.gitter.im/court-scraper/general.svg
+        :target: https://gitter.im/court-scraper/general?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge
+        :alt: Gitter
+
+Court Scraper
+-------------
+
+The `court-scraper` package provides a Python library and command-line
+tool that help search for and download case information from local court system
+websites in the United States. This library primarily focuses on supporting the ability
+to scrape county-level court data by case number and date range.
+
+* Documentation: http://court-scraper.readthedocs.io/en/latest/
+* GitHub: https://github.com/biglocalnews/court-scraper
+* PyPI: https://pypi.python.org/pypi/court-scraper
+* Gitter: https://gitter.im/court-scraper/general
+* Free and open source software: `Apache license`_
+
+.. _Apache license: https://github.com/biglocalnews/court-scraper/blob/master/LICENSE
+
+"""
 from setuptools import setup, find_packages
 
+
+
 requirements = [
     'anticaptchaofficial',
+    'bs4',
     'click',
     'click-option-group',
     'lxml',
@@ -12,20 +49,24 @@
     'pyyaml',
     'retrying',
     'selenium',
-    'sqlalchemy'
+    'sqlalchemy',
+    'typing-extensions'
 ]
 
+
 test_requirements = [
     'flake8',
     'pytest',
     'pytest-vcr'
 ]
 
+
 setup(
     name='court-scraper',
     version='0.1.0',
-    description="Command-line tool for scraping data from U.S. county courts.",
+    description="Command-line tool for scraping data from U.S. county courts",
     long_description=__doc__,
+    long_description_content_type='text/x-rst',
     author="Serdar Tumgoren",
     author_email='zstumgoren@gmail.com',
     url='https://github.com/biglocalnews/court-scraper',
@@ -45,7 +86,16 @@
         'License :: OSI Approved :: ISC License (ISCL)',
         'Natural Language :: English',
         'Programming Language :: Python :: 3',
+        'Programming Language :: Python :: 3.6',
+        'Programming Language :: Python :: 3.7',
+        'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python :: 3.9',
     ],
     test_suite='tests',
-    tests_require=test_requirements
+    tests_require=test_requirements,
+    project_urls={
+        'Maintainer': 'https://github.com/biglocalnews',
+        'Source': 'https://github.com/biglocalnews/court-scraper',
+        'Tracker': 'https://github.com/biglocalnews/court-scraper/issues'
+    },
 )
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..01a57fa
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,34 @@
+[tox]
+envlist =
+  py36,
+  py37,
+  py38,
+  py39,
+;  docs,
+  flake8
+
+[base]
+basepython =
+  py36: python3.6
+  py37: python3.7
+  py38: python3.8
+  py39: python3.9
+
+[testenv:flake8]
+basepython=python
+deps=flake8
+commands=flake8 court_scraper
+
+[testenv]
+deps =
+    -r{toxinidir}/requirements-dev.txt
+commands =
+    pip install -U pip
+    py.test --vcr-record=none --basetemp={envtmpdir}
+passenv =
+  HOME
+
+; If you want to make tox run the tests with the same versions, create a
+; requirements.txt with the pinned versions and uncomment the following lines:
+; deps =
+;     -r{toxinidir}/requirements.txt


Test patch:
diff --git a/docs/testing.rst b/docs/testing.rst
new file mode 100644
index 0000000..5cdc158
--- /dev/null
+++ b/docs/testing.rst
@@ -0,0 +1,102 @@
+.. _testing:
+
+Testing
+=======
+
+Overview
+--------
+
+*court-scraper* is developed primarily on Python 3.7 and uses the pytest_ library for unit testing.
+We use pytest-vcr_ for scrapers that use the `requests` library 
+(e.g. :py:class:`oscn.Site <court_scraper.platforms.oscn.site.Site>`). Scrapers that
+use Selenium should include a minimal set of :ref:`live webtests <live tests>` to ensure
+correct functionality and guard against regressions.
+
+.. note:: Selenium and other long-running tests should be marked as :ref:`slow <slow tests>` 
+    to enable optional running of these tests.
+
+.. _pytest: https://docs.pytest.org/en/latest/contents.html
+.. _pytest-vcr: https://github.com/ktosiek/pytest-vcr
+
+Install and run tests
+----------------------
+
+Assuming you've cloned this repo locally and installed test and application dependencies, 
+you can run tests by executing pytest in an active virtual environment::
+
+    cd court-scraper/
+    pipenv install --dev
+
+    # Execute tests
+    pipenv run pytest
+
+.. _slow tests:
+
+Slow tests
+----------
+
+Slow-running tests should be marked_ as such::
+
+    @pytest.mark.slow
+    def test_something_slow():
+        ...
+
+
+Slow tests are skipped by default. To run them, pass the :code:`--runslow` flag
+when invoking pytest::
+
+    pytest --runslow
+
+
+.. _live tests:
+
+Live tests
+-----------
+
+Tests that hit live web sites should be marked_ as `webtest`, allowing them to be `executed selectively`_::
+
+    @pytest.mark.webtest
+    def test_that_hits_live_website():
+        ...
+
+    # On the command line, run only tests marked as "webtest"
+    pytest -m webtest
+
+In many cases, tests that hit live websites should be marked as both `webtest` and `slow`::
+
+    @pytest.mark.webtest
+    @pytest.mark.slow
+    def test_that_hits_live_website():
+        ...
+
+    # On the command line, use both flags to target long-running webtests
+    pytest --runslow -m webtest
+
+Live web tests of Selenium-based scrapers will open a web browser by default. 
+All tests of Selenium scrapers should use the :code:`headless` fixture in order to provide
+the ability to disable running tests in browser.
+
+These tests should typically be marked as `slow` and `webtest` as well.::
+
+    @pytest.mark.webtest
+    @pytest.mark.slow
+    def test_selenium_scrape(headless):
+
+You can activate headless mode when running pytest by using the :code:`--headless` flag::
+
+    pytest --headless --runslow
+
+
+Test login credentials
+-----------------------
+
+Tests that hit :ref:`live web sites <live tests>` may require authentication,
+as in the case of some Odyssey sites such as Dekalb and Chatham counties
+in Georgia.
+
+Such tests require creating user accounts and adding login credentials
+to a local YAML :ref:`configuration <configuration>` file.
+
+
+.. _marked: https://docs.pytest.org/en/stable/example/markers.html
+.. _executed selectively: https://docs.pytest.org/en/stable/example/markers.html#marking-test-functions-and-selecting-them-for-a-run
\ No newline at end of file
diff --git a/tests/platforms/test_odyssey_site.py b/tests/platforms/test_odyssey_site.py
index b699aeb..4bb2821 100644
--- a/tests/platforms/test_odyssey_site.py
+++ b/tests/platforms/test_odyssey_site.py
@@ -49,8 +49,8 @@ def test_search(test_input, headless, live_configs, court_scraper_dir):
     site.login(username, password)
     results = site.search(case_numbers=case_ids)
     assert len(results) == 1
-    # Scrapes Case Detail page HTML by default
-    assert 'page_source' in results[0].data.keys()
+    # Does *not* scrape Case Detail page (HTML) by default
+    assert 'page_source' not in results[0].data.keys()
 
 
 @pytest.mark.parametrize(
@@ -70,8 +70,6 @@ def test_search_nologin_no_captcha(test_input, headless, court_scraper_dir):
     site = OdysseySite(place_id, url=url, download_dir=court_scraper_dir, headless=headless)
     results = site.search(case_numbers=case_ids)
     assert len(results) == 1
-    # Scrapes Case Detail page HTML by default
-    assert 'page_source' in results[0].data.keys()
 
 
 @pytest.mark.parametrize(
@@ -105,18 +103,18 @@ def test_search_nologin_no_captcha_noresults(test_input, headless, court_scraper
 )
 @pytest.mark.slow
 @pytest.mark.webtest
-def test_skip_case_details(test_input, headless, live_configs, court_scraper_dir):
-    "should support option to skip scraping of case details"
+def test_scrape_case_details(test_input, headless, live_configs, court_scraper_dir):
+    "should support optional scraping of case details"
     auth = live_configs['ga_dekalb']
     username = auth['username']
     password = auth['password']
     place_id, url, case_ids = test_input
     site = OdysseySite(place_id, url=url, download_dir=court_scraper_dir, headless=headless)
     site.login(username, password)
-    results = site.search(case_numbers=case_ids, case_details=False)
-    # Should *NOT* have case detail HTML stored on return object
+    results = site.search(case_numbers=case_ids, case_details=True)
+    # Should have case detail HTML stored on return object
     assert len(results) == 1
-    assert 'page_source' not in results[0].data.keys()
+    assert 'page_source' in results[0].data.keys()
 
 
 @pytest.mark.parametrize(
@@ -135,7 +133,7 @@ def test_maximize_displayed_results(test_input, headless, court_scraper_dir):
     "should automatically maximize the number of results displayed on results page"
     place_id, url, case_ids = test_input
     site = OdysseySite(place_id, url=url, download_dir=court_scraper_dir, headless=headless)
-    results = site.search(case_numbers=case_ids, case_details=False)
+    results = site.search(case_numbers=case_ids)
     assert len(results) == 91
 
 
@@ -159,6 +157,6 @@ def test_malformed_result_listing(test_input, headless, live_configs, court_scra
     place_id, url, case_ids = test_input
     site = OdysseySite(place_id, url=url, download_dir=court_scraper_dir, headless=headless)
     site.login(username, password)
-    results = site.search(case_numbers=case_ids, case_details=False)
+    results = site.search(case_numbers=case_ids)
     assert len(results) == 1
     assert results[0].data['File Date'] == '10/02/2019'