diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 64abdfe..3593e76 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,22 +8,20 @@ on: workflow_dispatch: env: - PYTHON_VERSION: 3.11.0 - POETRY_VERSION: 1.3.1 + MIN_PYTHON_VERSION: 3.9.25 + PYTHON_VERSION: 3.13.17 + POETRY_VERSION: 2.5.1 jobs: flake8: if: github.event_name != 'schedule' name: Flake8 - runs-on: ubuntu-latest - strategy: - matrix: - python: [ "3.11", "3.10", "3.9" ] + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: - python-version: ${{ matrix.python }} + python-version: ${{ env.MIN_PYTHON_VERSION }} - name: Run pip install run: python -m pip install 'flake8>=5.0.4' - name: Style Test @@ -34,15 +32,12 @@ jobs: mypy: if: github.event_name != 'schedule' name: mypy - runs-on: ubuntu-latest - strategy: - matrix: - python: [ "3.11", "3.10", "3.9" ] + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: - python-version: ${{ matrix.python }} + python-version: ${{ env.MIN_PYTHON_VERSION }} - name: Run pip install run: python -m pip install 'mypy>=0.991' 'pytest>=7.2.0' 'types-beautifulsoup4>=4.11.6' - name: Types test @@ -56,7 +51,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: ${{ env.PYTHON_VERSION }} - uses: abatilo/actions-poetry@v2.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c32dff..0dce4e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## v3.13.0 + +### New Features + +* Add `User.id: UUID` property + +### Fixes + +* Fix parsing of user pages + * Fix issue [#37](https://github.com/FurryCoders/FAAPI/issues/37) + ## v3.12.7 ### Fixes diff --git a/README.md b/README.md index e04446b..a4f0541 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ If no `user_tag` is passed then the object fields will remain at their default - The main class storing all of a user's metadata. +* `id: UUID` unique Fur Affinity user ID * `name: str` display name with capital letters and extra characters such as "_" * `status: str` user status (~, !, etc.) * `title: str` the user title as it appears on their userpage diff --git a/faapi/__version__.py b/faapi/__version__.py index 1980816..62ee17b 100644 --- a/faapi/__version__.py +++ b/faapi/__version__.py @@ -1 +1 @@ -__version__ = "3.12.7" +__version__ = "3.13.0" diff --git a/faapi/parse.py b/faapi/parse.py index 570078f..e0f5e7d 100644 --- a/faapi/parse.py +++ b/faapi/parse.py @@ -10,6 +10,7 @@ from typing import Optional from typing import Union from urllib.parse import quote +from uuid import UUID from bbcode import Parser as BBCodeParser # type:ignore from bs4 import BeautifulSoup @@ -632,24 +633,35 @@ def parse_submission_page(sub_page: BeautifulSoup) -> dict[str, Any]: def parse_user_header(user_header: Tag) -> dict[str, Any]: tag_user_name: Optional[Tag] = user_header.select_one("a.c-usernameBlock__userName") tag_user_display_name: Optional[Tag] = user_header.select_one("a.c-usernameBlock__displayName") - tag_title_join_date: Optional[Tag] = user_header.select_one("userpage-nav-user-details span.user-title") + tag_user_title: Optional[Tag] = user_header.select_one("userpage-nav-user-details span.user-title") tag_avatar: Optional[Tag] = user_header.select_one("userpage-nav-avatar img") assert tag_user_name is not None, _raise_exception(ParsingError("Missing user name tag")) assert tag_user_display_name is not None, _raise_exception(ParsingError("Missing user display name tag")) - assert tag_title_join_date is not None, _raise_exception(ParsingError("Missing join date tag")) + assert tag_user_title is not None, _raise_exception(ParsingError("Missing user title tag")) assert tag_avatar is not None, _raise_exception(ParsingError("Missing user icon tag")) + tag_join_date: Optional[Tag] = tag_user_title.select_one("span.popup_date[data-time]") + tag_unique_id: Optional[Tag] = tag_user_title.select_one('a[href^="/user/id:"]') + + assert tag_join_date is not None, _raise_exception(ParsingError("Missing join date tag")) + assert tag_unique_id is not None, _raise_exception(ParsingError("Missing unique id tag")) + tag_user_symbol: Optional[Tag] = tag_user_name.select_one("span.c-usernameBlock__symbol") + tag_title: Optional[NavigableString] = ns if isinstance( + ns := next(tag_user_title.children, None), # type: ignore + NavigableString + ) else None status: str = tag_user_symbol.text.strip() if tag_user_symbol else "" name: str = tag_user_name.text.strip().removeprefix(status).strip() display_name: str = tag_user_display_name.text.strip() - title: str = ttd[0].strip() if len(ttd := tag_title_join_date.text.rsplit("|", 1)) > 1 else "" - join_date: datetime = parse_date(ttd[-1].strip().split(":", 1)[1]) + title: str = tag_title.text.strip("| \n\r") if tag_title else "" + join_date: datetime = datetime.fromtimestamp(int(get_attr(tag_join_date, "data-time"))) avatar_url: str = "https:" + get_attr(tag_avatar, "src") avatar_url = f"{avatar_url.rsplit('/', 1)[0]}/{quote(avatar_url.rsplit('/', 1)[1])}" + unique_id: UUID = UUID(get_attr(tag_unique_id, "href").split(":")[-1]) return { "status": status, @@ -658,6 +670,7 @@ def parse_user_header(user_header: Tag) -> dict[str, Any]: "title": title, "join_date": join_date, "avatar_url": avatar_url, + "unique_id": unique_id, } diff --git a/faapi/user.py b/faapi/user.py index 79947b0..4b37a39 100644 --- a/faapi/user.py +++ b/faapi/user.py @@ -2,6 +2,7 @@ from datetime import datetime from typing import Optional from urllib.parse import quote +from uuid import UUID from .connection import join_url from .connection import root @@ -184,6 +185,7 @@ def __init__(self, user_page: Optional[BeautifulSoup] = None): super().__init__() + self.id: UUID = UUID(int=0) self.user_page: Optional[BeautifulSoup] = user_page self.title: str = "" self.join_date: datetime = datetime.fromtimestamp(0) @@ -201,6 +203,7 @@ def __init__(self, user_page: Optional[BeautifulSoup] = None): self.parse() def __iter__(self): + yield "id", self.id yield "name", self.name yield "display_name", self.display_name yield "status", self.status @@ -243,6 +246,7 @@ def parse(self, user_page: Optional[BeautifulSoup] = None): parsed: dict = parse_user_page(self.user_page) + self.id = parsed["unique_id"] self.name = parsed["name"] self.display_name = parsed["display_name"] self.status = parsed["status"] diff --git a/pyproject.toml b/pyproject.toml index ce4e5c6..4bbe518 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "faapi" -version = "3.12.7" +version = "3.13.0" description = "Python module to implement API-like functionality for the FurAffinity.net website." authors = ["Matteo Campinoti "] license = "EUPL-1.2" diff --git a/tests/test_connection.py b/tests/test_connection.py index f19da7d..759772e 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -46,7 +46,7 @@ def test_make_session_error(): def test_get_robots(cookies: RequestsCookieJar): result = get_robots(make_session(cookies, Session)) assert isinstance(result, RobotFileParser) - assert getattr(result, "default_entry", None) is not None + assert result.entries # type: ignore def test_get(cookies: RequestsCookieJar): diff --git a/tests/test_faapi.py b/tests/test_faapi.py index c1f3d71..b57d684 100644 --- a/tests/test_faapi.py +++ b/tests/test_faapi.py @@ -58,7 +58,7 @@ def remove_user_icons(html: str) -> str: def test_robots(cookies: RequestsCookieJar): api: FAAPI = FAAPI(cookies) - assert getattr(api.robots, "default_entry") is not None + assert api.robots.entries # type: ignore assert api.crawl_delay >= 1 assert api.check_path("/") assert api.check_path("/view")