Skip to content
Open
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
1 change: 1 addition & 0 deletions funpaybotengine/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
from .settings import *
from .categories import *
from .common_page_elements import *
from .subcategory_structure import *
27 changes: 26 additions & 1 deletion funpaybotengine/types/offers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

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):
Expand Down Expand Up @@ -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.
"""

Expand Down Expand Up @@ -171,6 +172,30 @@ 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.from_offer_fields(self)

def __post_init__(self) -> None:
if 'csrf_token' in self.fields_dict:
del self.fields_dict['csrf_token']
Expand Down
146 changes: 146 additions & 0 deletions funpaybotengine/types/subcategory_structure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
from __future__ import annotations


__all__ = ('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},
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down