From decda643d19ddf2a528bcf4fd20aa48aba53ca20 Mon Sep 17 00:00:00 2001 From: Nodehost Date: Wed, 15 Apr 2026 23:48:30 +0300 Subject: [PATCH 1/3] feat: bump funpayparsers to >=0.8.0,<0.9.0 Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cfba2c3..edda6be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "aiohttp>=3.13.3,<3.14", "aiohttp-socks>=0.11.0", "eventry>=0.3.6,<1.0.0", - "funpayparsers>=0.7.0,<0.8.0", + "funpayparsers>=0.8.0,<0.9.0", "pydantic>=2.12,<2.13", ] authors = [ From d0355f6f2c3f40797062f829ac6c2f2f5ff90bfe Mon Sep 17 00:00:00 2001 From: Nodehost Date: Wed, 15 Apr 2026 23:48:40 +0300 Subject: [PATCH 2/3] feat: add SubcategoryStructure, SubcategoryFieldDef, FieldCondition types Re-export FieldCondition, SubcategoryFieldDef, SubcategoryStructure from funpayparsers.types.subcategory_structure via a new types module. Add field_schema: list[SubcategoryFieldDef] field to OfferFields and a subcategory_structure property that builds a SubcategoryStructure on demand. Co-Authored-By: Claude Sonnet 4.6 --- funpaybotengine/types/__init__.py | 1 + funpaybotengine/types/offers.py | 30 ++++++++++++++++++- .../types/subcategory_structure.py | 11 +++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 funpaybotengine/types/subcategory_structure.py diff --git a/funpaybotengine/types/__init__.py b/funpaybotengine/types/__init__.py index 0f13264..add8eeb 100644 --- a/funpaybotengine/types/__init__.py +++ b/funpaybotengine/types/__init__.py @@ -15,3 +15,4 @@ from .settings import * from .categories import * from .common_page_elements import * +from .subcategory_structure import * diff --git a/funpaybotengine/types/offers.py b/funpaybotengine/types/offers.py index 4e5ba75..f6d3951 100644 --- a/funpaybotengine/types/offers.py +++ b/funpaybotengine/types/offers.py @@ -11,6 +11,7 @@ from pydantic import Field, BaseModel, BeforeValidator from typing_extensions import Self from funpayparsers.parsers.utils import parse_date_string +from funpayparsers.types.subcategory_structure import SubcategoryFieldDef, SubcategoryStructure from funpaybotengine.types.base import FunPayObject, FunPayMutableObject from funpaybotengine.types.common import MoneyValue @@ -84,7 +85,7 @@ class OfferPreview(FunPayObject, BaseModel): BeforeValidator(MappingProxyType), ] """ - Additional data related to the offer, such as server ID, side ID, etc., + Additional data related to the offer, such as server ID, side ID, etc., if applicable. """ @@ -171,6 +172,33 @@ class properties (e.g. ``title_ru``, ``active``, ``images``), fields_names: dict[str, str] = Field(default_factory=dict) """Field names.""" + field_schema: list[SubcategoryFieldDef] = Field(default_factory=list) + """ + Subcategory field schema parsed from the ``data-fields`` JSON attribute. + + Each entry describes one configurable field of the subcategory, including its + type, human-readable label, visibility conditions, and available options + (for select fields). + + Empty list for currency/chips offers or when the offer page does not include + a ``div.lot-fields[data-fields]`` element. + """ + + @property + def subcategory_structure(self) -> SubcategoryStructure: + """ + Build and return a ``SubcategoryStructure`` from ``field_schema``. + + Returns a structure with field definitions keyed by field ID, + plus label maps for forward and case-insensitive reverse lookups. + + The result is not cached — call once and store if repeated access is needed. + """ + return SubcategoryStructure( + subcategory_id=self.subcategory_id, + fields={f.id: f for f in self.field_schema}, + ) + def __post_init__(self) -> None: if 'csrf_token' in self.fields_dict: del self.fields_dict['csrf_token'] diff --git a/funpaybotengine/types/subcategory_structure.py b/funpaybotengine/types/subcategory_structure.py new file mode 100644 index 0000000..6e5f798 --- /dev/null +++ b/funpaybotengine/types/subcategory_structure.py @@ -0,0 +1,11 @@ +from __future__ import annotations + + +__all__ = ('FieldCondition', 'SubcategoryFieldDef', 'SubcategoryStructure') + + +from funpayparsers.types.subcategory_structure import ( + FieldCondition, + SubcategoryFieldDef, + SubcategoryStructure, +) From 95a55ee9835993db709c2bbbb86f1c568818d193 Mon Sep 17 00:00:00 2001 From: Nodehost Date: Mon, 20 Apr 2026 13:43:20 +0300 Subject: [PATCH 3/3] refactor: replace re-exports with native FunPayObject subclasses FieldCondition, SubcategoryFieldDef and SubcategoryStructure are now proper Pydantic models inheriting from FunPayObject instead of re-exporting the funpayparsers dataclasses directly. Each class owns its _add_raw_source validator: - FieldCondition/SubcategoryStructure: no raw_source on the parser side, generate a stable JSON identifier on construction - SubcategoryFieldDef: raw_source already present on the parser dataclass, let from_attributes carry it through; fallback for dict construction SubcategoryStructure.from_offer_fields is added as a classmethod and used by OfferFields.subcategory_structure property. OfferFields.field_schema now stores engine SubcategoryFieldDef instances. Co-Authored-By: Claude Sonnet 4.6 --- funpaybotengine/types/offers.py | 7 +- .../types/subcategory_structure.py | 145 +++++++++++++++++- 2 files changed, 142 insertions(+), 10 deletions(-) diff --git a/funpaybotengine/types/offers.py b/funpaybotengine/types/offers.py index f6d3951..e8dd5e1 100644 --- a/funpaybotengine/types/offers.py +++ b/funpaybotengine/types/offers.py @@ -11,10 +11,10 @@ from pydantic import Field, BaseModel, BeforeValidator from typing_extensions import Self from funpayparsers.parsers.utils import parse_date_string -from funpayparsers.types.subcategory_structure import SubcategoryFieldDef, SubcategoryStructure from funpaybotengine.types.base import FunPayObject, FunPayMutableObject from funpaybotengine.types.common import MoneyValue +from funpaybotengine.types.subcategory_structure import SubcategoryFieldDef, SubcategoryStructure class OfferSeller(FunPayObject, BaseModel): @@ -194,10 +194,7 @@ def subcategory_structure(self) -> SubcategoryStructure: The result is not cached — call once and store if repeated access is needed. """ - return SubcategoryStructure( - subcategory_id=self.subcategory_id, - fields={f.id: f for f in self.field_schema}, - ) + return SubcategoryStructure.from_offer_fields(self) def __post_init__(self) -> None: if 'csrf_token' in self.fields_dict: diff --git a/funpaybotengine/types/subcategory_structure.py b/funpaybotengine/types/subcategory_structure.py index 6e5f798..c8127c5 100644 --- a/funpaybotengine/types/subcategory_structure.py +++ b/funpaybotengine/types/subcategory_structure.py @@ -4,8 +4,143 @@ __all__ = ('FieldCondition', 'SubcategoryFieldDef', 'SubcategoryStructure') -from funpayparsers.types.subcategory_structure import ( - FieldCondition, - SubcategoryFieldDef, - SubcategoryStructure, -) +import json +from dataclasses import asdict +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, Field, model_validator +from funpayparsers.types.enums import SubcategoryFieldType + +from funpaybotengine.types.base import FunPayObject + + +if TYPE_CHECKING: + from funpaybotengine.types.offers import OfferFields + + +class FieldCondition(FunPayObject, BaseModel): + """ + Represents a visibility condition for a ``SubcategoryFieldDef``. + + The owning field is shown only when the field identified by ``field_id`` + has one of the values listed in ``values``. + """ + + field_id: str + """ID of the field whose value controls visibility of the owning field.""" + + values: set[str] + """ + Values of ``field_id`` that make the owning field visible. + + Stored as a ``set`` — duplicates are not possible by definition. + """ + + @model_validator(mode='before') + @classmethod + def _add_raw_source(cls, data: Any) -> Any: + # funpayparsers.FieldCondition has no raw_source — convert to dict first + if not isinstance(data, dict): + data = asdict(data) + if 'raw_source' not in data: + data['raw_source'] = json.dumps({'field_id': data.get('field_id')}) + return data + + def is_satisfied_by(self, value: Any) -> bool: + """Return ``True`` if ``str(value)`` is present in ``values``.""" + return str(value) in self.values + + +class SubcategoryFieldDef(FunPayObject, BaseModel): + """Represents a single field definition within a subcategory.""" + + id: str + """Field identifier as used by FunPay (e.g. ``'arena'``, ``'quantity'``).""" + + type: SubcategoryFieldType + """Field type.""" + + label: str + """Human-readable label from ``label.control-label`` in the form HTML.""" + + conditions: list[FieldCondition] + """ + Visibility conditions. + + Empty list means the field is always visible. + The field is shown only when all conditions are satisfied simultaneously. + """ + + options: list[str] | None + """ + Available option values for ``SELECT`` and ``DROPDOWN`` type fields. + + ``None`` for non-select fields (``NUMERIC_RANGE``, ``TEXT``, ``TEXTAREA``, ``IMAGES``). + """ + + @model_validator(mode='before') + @classmethod + def _add_raw_source(cls, data: Any) -> Any: + # funpayparsers.SubcategoryFieldDef has raw_source — let from_attributes handle it + if not isinstance(data, dict): + return data + if 'raw_source' not in data: + data['raw_source'] = json.dumps({ + 'id': data.get('id'), + 'label': data.get('label'), + }) + return data + + +class SubcategoryStructure(FunPayObject, BaseModel): + """ + Derived subcategory field structure for quick lookups. + + Not parsed directly from HTML — constructed from ``OfferFields.field_schema`` + via the ``OfferFields.subcategory_structure`` property. + """ + + subcategory_id: int | None + """Subcategory ID. ``None`` for currency (chips) offer fields.""" + + fields: dict[str, SubcategoryFieldDef] = Field(default_factory=dict) + """ + Field definitions keyed by field ID, in declaration order. + + Use ``fields[field_id]`` for O(1) lookup by ID, + or iterate over ``fields.values()`` to process fields in declaration order. + """ + + @model_validator(mode='before') + @classmethod + def _add_raw_source(cls, data: Any) -> Any: + # SubcategoryStructure is not parsed from HTML — generate a stable raw_source + if not isinstance(data, dict): + return data + if 'raw_source' not in data: + data['raw_source'] = json.dumps({'subcategory_id': data.get('subcategory_id')}) + return data + + @property + def label_map(self) -> dict[str, str]: + """Mapping from FunPay label to field ID for reverse lookup.""" + return {f.label: f.id for f in self.fields.values()} + + @property + def lower_label_map(self) -> dict[str, str]: + """Case-insensitive variant of ``label_map`` — keys are lowercased.""" + return {k.lower(): v for k, v in self.label_map.items()} + + @classmethod + def from_offer_fields(cls, offer_fields: OfferFields) -> SubcategoryStructure: + """ + Build a ``SubcategoryStructure`` from engine ``OfferFields``. + + :param offer_fields: An ``OfferFields`` instance returned by ``GetOfferFields``. + :return: A ``SubcategoryStructure`` with field map and label maps populated. + """ + return cls( + raw_source=json.dumps({'subcategory_id': offer_fields.subcategory_id}), + subcategory_id=offer_fields.subcategory_id, + fields={f.id: f for f in offer_fields.field_schema}, + )