Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,6 @@ jobs:
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
- name: Echo env
run: |
echo ${{ github.event.pull_request.base.ref }}
echo ${{ github.event.pull_request.base.ref_name }}
- name: Black and flake
run: |
bash run_lint.sh
Expand Down Expand Up @@ -65,6 +61,3 @@ jobs:
name: Test result
path: report.html
if: ${{ always() }}



2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2023 Clement Julia
Copyright (c) 2025 Clement Julia

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
7 changes: 7 additions & 0 deletions docs/source/client.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ Client
:inherited-members:


TwoFactorAuthClient
--------------------
.. autoclass:: moddb.client.TwoFactorAuthClient
:members:
:inherited-members:


ThreadThumbnail
----------------
.. autoclass:: moddb.client.ThreadThumbnail
Expand Down
2 changes: 1 addition & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
# -- Project information -----------------------------------------------------

project = "moddb"
copyright = "2022, Clement Julia"
copyright = "2025, Clement Julia"
author = "Clement Julia"

# The short X.Y version
Expand Down
16 changes: 16 additions & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ that mod page you would be forced to completly parse the result page on your own
See more snippets there: :ref:`snippets-ref`.


Two Factor Authentication
--------------------------
Sometimes ModDB will ask you for a code sent to your email when logging from a new device. There is no
way to circuvement this. As such the library provides a class to do this handshake::

>> import moddb
>> e = moddb.TwoFactorAuthClient("MyUser", "*****")
>> e.login()
False
>> e.submit_2fa_code("AZADV")
< Member >


This class' `login` method returns false if 2FA is required instead of erroring, allowing you elegantly
check for the code and to send it in a second request.

Searching
----------

Expand Down
3 changes: 2 additions & 1 deletion moddb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import requests

from .base import front_page, login, logout, parse_page, parse_results, rss, search, search_tags
from .client import Client, Thread
from .client import Client, TwoFactorAuthClient, Thread
from .enums import *
from .pages import *
from .utils import BASE_URL, LOGGER, Object, get_page, request, soup
Expand All @@ -23,6 +23,7 @@
"search",
"search_tags",
"Client",
"TwoFactorAuthClient",
"Thread",
"BASE_URL",
"LOGGER",
Expand Down
89 changes: 89 additions & 0 deletions moddb/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
GLOBAL_THROTLE,
LOGGER,
concat_docs,
create_login_payload,
generate_hash,
generate_login_cookies,
get,
Expand All @@ -30,6 +31,7 @@
get_sitearea,
get_siteareaid,
join,
prepare_request,
raise_for_status,
ratelimit,
soup,
Expand Down Expand Up @@ -1176,3 +1178,90 @@ def downvote_tag(self, tag: Tag) -> bool:
Whether the downvote was successful
"""
return self._vote_tag(tag, 1)


class TwoFactorAuthClient(Client):
"""A subclass of client to be used when facing 2FA requirements."""

def __init__(self, username: str, password: str):
self.username = username
self.password = password

session = requests.Session()
session.mount("http://", CurlCffiAdapter())
session.mount("https://", CurlCffiAdapter())
self._session = session
self.member: Member = None

self._2fa_request: requests.Response = None

def __repr__(self):
return f"<Client username={self.username}>"

def login(self) -> bool:
"""Log the user in

Returns
--------
bool
True if the login was successful, false it the login requires 2FA
"""
data, resp = create_login_payload(self.username, self.password, self._session)

req = requests.Request("POST", f"{BASE_URL}/members/login", data=data, cookies=resp.cookies)
login = self._session.send(prepare_request(req, self._session), allow_redirects=False)

if "members2faemailhash" in login.text:
self._2fa_request = login
return False

if "freeman" not in login.cookies:
raise ValueError(f"Login failed for user {self.username}")

self._session.cookies = login.cookies

self.member = Member(
soup(self._request("GET", f"{BASE_URL}/members/{self.username.replace('_', '-')}").text)
)
return True

def submit_2fa_code(self, code: str) -> Member:
"""Submit the 2FA code sent to the user being logged in. Only works if called
after `login`

Returns
--------
Member
The logged in member
"""
if self._2fa_request is None:
raise ValueError("Call login first")

html = soup(self._2fa_request.text)
form = html.find("form", action="https://www.moddb.com/members/login2fa/#membersform")

data = {
"rememberme": "1",
"referer": "/",
"2faemaildomain": form.find("input", id="members2faemaildomain")["value"],
"2faemailhash": form.find("input", id="members2faemailhash")["value"],
"2faemailcode": code,
"members": "Verify",
}

req = requests.Request(
"POST", f"{BASE_URL}/members/login2fa", data=data, cookies=self._2fa_request.cookies
)
login = self._session.send(prepare_request(req, self._session), allow_redirects=False)

if "freeman" not in login.cookies:
raise ValueError(f"Login failed for user {self.username}")

self._session.cookies = login.cookies
self._2fa_request = None

self.member = Member(
soup(self._request("GET", f"{BASE_URL}/members/{self.username.replace('_', '-')}").text)
)

return self.member
8 changes: 8 additions & 0 deletions moddb/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,11 @@ def __init__(self, message, remaining) -> None:
super().__init__(message)

self.remaining = remaining


class AuthError(ModdbException):
"""The user you are trying to login with requires 2FA to login. Use
the TwoFactorAuthClient object to do so.
"""

pass
10 changes: 6 additions & 4 deletions moddb/pages/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,19 @@ def __init__(self, html: BeautifulSoup):
),
lambda: int(html.find("input", attrs={"name": "siteareaid"})["value"]),
lambda: int(html.find("meta", property="og:image")["content"].split("/")[-2]),
lambda: re.match(
lambda: re.findall(
r"https:\/\/www\.moddb\.com\/html\/scripts\/autocomplete\.php\?a=mentions&p=home&l=6&u=(\d*)",
str(html),
).group(1),
)[0],
]
):
try:
self.id = func()
break
except (AttributeError, TypeError):
LOGGER.warning("Failed to get id from method %s for member %s", index, self.name)
except (AttributeError, TypeError) as e:
LOGGER.warning(
"Failed to get id from method %s for member %s: %s", index, self.name, e
)
else:
raise AttributeError(f"Failed to get id from member {self.name}")

Expand Down
25 changes: 17 additions & 8 deletions moddb/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from requests import utils

from .enums import MediaCategory, ThumbnailType
from .errors import AwaitingAuthorisation, ModdbException, Ratelimited
from .errors import AuthError, AwaitingAuthorisation, ModdbException, Ratelimited

LOGGER = logging.getLogger("moddb")
BASE_URL = "https://www.moddb.com"
Expand Down Expand Up @@ -216,6 +216,21 @@ def generate_login_cookies(username: str, password: str, session: requests.Sessi
if session is None:
session = sys.modules["moddb"].SESSION

data, resp = create_login_payload(username, password, session)

req = requests.Request("POST", f"{BASE_URL}/members/login", data=data, cookies=resp.cookies)
login = session.send(prepare_request(req, session), allow_redirects=False)

if "members2faemailhash" in login.text:
raise AuthError("2FA required, use TwoFactorAuthClient")

if "freeman" not in login.cookies:
raise ValueError(f"Login failed for user {username}")

return login.cookies


def create_login_payload(username: str, password: str, session: requests.Session):
req = requests.Request("GET", f"{BASE_URL}/members/login")
resp = session.send(prepare_request(req, session))
resp.raise_for_status()
Expand All @@ -235,13 +250,7 @@ def generate_login_cookies(username: str, password: str, session: requests.Sessi
"members": "Sign in",
}

req = requests.Request("POST", f"{BASE_URL}/members/login", data=data, cookies=resp.cookies)
login = session.send(prepare_request(req, session), allow_redirects=False)

if "freeman" not in login.cookies:
raise ValueError(f"Login failed for user {username}")

return login.cookies
return data, resp


@ratelimit(GLOBAL_THROTLE, GLOBAL_LIMITER)
Expand Down
4 changes: 2 additions & 2 deletions run_tests.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
if [ ${1:-not_full} == "full" ]
then
python -m pytest -k "main" --log-level="INFO" --html=report.html --self-contained-html --record-mode=new_episodes -vv "${@:2}"
python -m pytest -k "main and not test_client and not TestLogin" --log-level="INFO" --html=report.html --self-contained-html --record-mode=new_episodes -vv "${@:2}"
else
python -m pytest -k "not main and not test_client" --log-level="INFO" --html=report.html --self-contained-html --record-mode=new_episodes -vv "$@"
python -m pytest -k "not main and not test_client and not TestLogin" --log-level="INFO" --html=report.html --self-contained-html --record-mode=new_episodes -vv "$@"
fi