From 5294b6a4393ea451ca53d694b419f049ec853b59 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Sun, 8 Nov 2026 10:06:16 +0100 Subject: [PATCH 01/14] parse:parse_user_header - fix parsing of join date and user title Fix #37 --- faapi/parse.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/faapi/parse.py b/faapi/parse.py index 570078f..6a47875 100644 --- a/faapi/parse.py +++ b/faapi/parse.py @@ -632,22 +632,27 @@ 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]") + + assert tag_join_date is not None, _raise_exception(ParsingError("Missing join date 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), 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() 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])}" From 96c7a12ead6907a38d9f4371c4f349d6d46bc500 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Sun, 8 Nov 2026 10:06:28 +0100 Subject: [PATCH 02/14] parse:parse_user_header - parse unique ID --- faapi/parse.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/faapi/parse.py b/faapi/parse.py index 6a47875..9c7f7ec 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 @@ -641,8 +642,10 @@ def parse_user_header(user_header: Tag) -> dict[str, Any]: 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), NavigableString) else None @@ -655,6 +658,7 @@ def parse_user_header(user_header: Tag) -> dict[str, Any]: 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, @@ -663,6 +667,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, } From bf62556d1d21fc5eb14e07d3b9393d01f379f831 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Sun, 8 Nov 2026 10:06:32 +0100 Subject: [PATCH 03/14] user:User - add `id: UUID` field --- faapi/user.py | 4 ++++ 1 file changed, 4 insertions(+) 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"] From 021387dcf138f7ac29f11e3f90cf87506bd0e812 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Sun, 8 Nov 2026 10:06:38 +0100 Subject: [PATCH 04/14] readme - update User fields --- README.md | 1 + 1 file changed, 1 insertion(+) 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 From 656c0127c420a89d68f6ab3afd07e52db1a66e06 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Sun, 8 Nov 2026 10:06:43 +0100 Subject: [PATCH 05/14] version - minor 3.12.7 > 3.13.0 --- faapi/__version__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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" From c5b477b6a8342618d516e86b7904f23e5ad892a5 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Sun, 8 Nov 2026 10:07:06 +0100 Subject: [PATCH 06/14] changelog:3.13.0 - add new features and fixes --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 From a72c51f4d185ef26149d5b7aae9be9ac54610c3f Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Sun, 8 Nov 2026 10:18:41 +0100 Subject: [PATCH 07/14] github.workflows.test - update Python version and Poetry version, use only minimum Python version for tests --- .github/workflows/test.yml | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 64abdfe..98a144a 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" ] 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 @@ -35,14 +33,11 @@ jobs: if: github.event_name != 'schedule' name: mypy runs-on: ubuntu-latest - strategy: - matrix: - python: [ "3.11", "3.10", "3.9" ] 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 From 95353e0243679031b7dce2a90be27c7a60f794bb Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Sun, 8 Nov 2026 10:23:36 +0100 Subject: [PATCH 08/14] github.workflows.test - use ubuntu-24.04 fir flake8 and mypy tests --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 98a144a..3593e76 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: flake8: if: github.event_name != 'schedule' name: Flake8 - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v7 @@ -32,7 +32,7 @@ jobs: mypy: if: github.event_name != 'schedule' name: mypy - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v7 From 9b3c965e18a26e075172f1a1c7726275a32e39ab Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Sun, 8 Nov 2026 10:35:12 +0100 Subject: [PATCH 09/14] parse:parse_user_header - fix incorrectly stripped user title --- faapi/parse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/faapi/parse.py b/faapi/parse.py index 9c7f7ec..cacdb3c 100644 --- a/faapi/parse.py +++ b/faapi/parse.py @@ -654,7 +654,7 @@ def parse_user_header(user_header: Tag) -> dict[str, Any]: name: str = tag_user_name.text.strip().removeprefix(status).strip() display_name: str = tag_user_display_name.text.strip() - title: str = tag_title.text.strip() if tag_title else "" + 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])}" From 7c3c47ff569a4924c7ea63b82857cba0b6aa0052 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Sun, 8 Nov 2026 10:35:49 +0100 Subject: [PATCH 10/14] tests - do not check for default entry in robots.txt, instead, ensure that there is at least one entry --- tests/test_connection.py | 2 +- tests/test_faapi.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_connection.py b/tests/test_connection.py index f19da7d..5812734 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 def test_get(cookies: RequestsCookieJar): diff --git a/tests/test_faapi.py b/tests/test_faapi.py index c1f3d71..9b3c70f 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 assert api.crawl_delay >= 1 assert api.check_path("/") assert api.check_path("/view") From 3f23e327383590c86aa32073e80ad3e29df5c945 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Sun, 8 Nov 2026 10:43:59 +0100 Subject: [PATCH 11/14] parse - fix line too long --- faapi/parse.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/faapi/parse.py b/faapi/parse.py index cacdb3c..8fa30a4 100644 --- a/faapi/parse.py +++ b/faapi/parse.py @@ -648,7 +648,10 @@ def parse_user_header(user_header: Tag) -> dict[str, Any]: 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), NavigableString) else None + tag_title: Optional[NavigableString] = ns if isinstance( + ns := next(tag_user_title.children, None), + 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() From 054c4864246002050b612e3f26c1862b63694cd3 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Sun, 8 Nov 2026 10:47:15 +0100 Subject: [PATCH 12/14] parse - ignore line with mypy that it cannot parse correctly --- faapi/parse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/faapi/parse.py b/faapi/parse.py index 8fa30a4..a37c42b 100644 --- a/faapi/parse.py +++ b/faapi/parse.py @@ -651,7 +651,7 @@ def parse_user_header(user_header: Tag) -> dict[str, Any]: tag_title: Optional[NavigableString] = ns if isinstance( ns := next(tag_user_title.children, None), NavigableString - ) else None + ) else None # type: ignore status: str = tag_user_symbol.text.strip() if tag_user_symbol else "" name: str = tag_user_name.text.strip().removeprefix(status).strip() From 1a276bf883fa03f7e58d80c6e67ba453d935b8a6 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Sun, 8 Nov 2026 10:48:13 +0100 Subject: [PATCH 13/14] parse - ignore line with mypy that it cannot parse correctly --- faapi/parse.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/faapi/parse.py b/faapi/parse.py index a37c42b..e0f5e7d 100644 --- a/faapi/parse.py +++ b/faapi/parse.py @@ -649,9 +649,9 @@ def parse_user_header(user_header: Tag) -> dict[str, Any]: 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), + ns := next(tag_user_title.children, None), # type: ignore NavigableString - ) else None # type: ignore + ) else None status: str = tag_user_symbol.text.strip() if tag_user_symbol else "" name: str = tag_user_name.text.strip().removeprefix(status).strip() From 5b97a4522abda2399146e234abd454a2f8f0bbff Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Sun, 8 Nov 2026 10:53:52 +0100 Subject: [PATCH 14/14] tests - ignore line with mypy that it cannot parse correctly --- tests/test_connection.py | 2 +- tests/test_faapi.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_connection.py b/tests/test_connection.py index 5812734..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 result.entries + assert result.entries # type: ignore def test_get(cookies: RequestsCookieJar): diff --git a/tests/test_faapi.py b/tests/test_faapi.py index 9b3c70f..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 api.robots.entries + assert api.robots.entries # type: ignore assert api.crawl_delay >= 1 assert api.check_path("/") assert api.check_path("/view")