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..6975158 100644 --- a/funpaybotengine/types/offers.py +++ b/funpaybotengine/types/offers.py @@ -13,7 +13,9 @@ from funpayparsers.parsers.utils import parse_date_string from funpaybotengine.types.base import FunPayObject, FunPayMutableObject +from funpaybotengine.types.enums import SubcategoryType from funpaybotengine.types.common import MoneyValue +from funpaybotengine.types.subcategory_structure import SubcategoryFieldDef, SubcategoryStructure class OfferSeller(FunPayObject, BaseModel): @@ -84,7 +86,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. """ @@ -102,6 +104,20 @@ class OfferPreview(FunPayObject, BaseModel): disabled: bool = False """Whether the offer is disabled (defaults to ``False``).""" + subcategory_type: SubcategoryType = SubcategoryType.UNKNOWN + """Type of the subcategory (OFFERS/CHIPS), derived from the offer URL.""" + + def parse_title_fields( + self, structure: SubcategoryStructure + ) -> dict[str, str | int]: + """ + Extract structured field values from the comma-separated title suffix. + + Delegates to :func:`funpayparsers.types.subcategory_structure._parse_title_fields`. + """ + from funpayparsers.types.subcategory_structure import _parse_title_fields + return _parse_title_fields(self.title, structure) + T = TypeVar('T') P = ParamSpec('P') @@ -171,6 +187,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'] @@ -561,9 +601,9 @@ def secrets(self) -> list[str] | None: Applicable for common offers only. - Field name: ``fields[secrets]`` + Field name: ``secrets`` """ - goods = self.fields_dict.get('fields[secrets]') + goods = self.fields_dict.get('secrets') if goods is None: return None return goods.split('\n') @@ -571,7 +611,7 @@ def secrets(self) -> list[str] | None: @secrets.setter @common_only def secrets(self, value: list[str] | None) -> None: - self.set_field('fields[secrets]', '\n'.join(value) if value is not None else None) + self.set_field('secrets', '\n'.join(value) if value is not None else None) @property def active(self) -> bool: diff --git a/funpaybotengine/types/orders.py b/funpaybotengine/types/orders.py index 9e44afc..5f30c46 100644 --- a/funpaybotengine/types/orders.py +++ b/funpaybotengine/types/orders.py @@ -4,7 +4,7 @@ __all__ = ('OrderPreview', 'OrderPreviewsBatch') -from typing import Any +from typing import TYPE_CHECKING, Any from pydantic import BaseModel, PrivateAttr from funpayparsers.parsers.utils import parse_date_string @@ -14,6 +14,10 @@ from funpaybotengine.types.common import MoneyValue, UserPreview +if TYPE_CHECKING: + from funpaybotengine.types.subcategory_structure import SubcategoryStructure + + class OrderPreview(FunPayObject, BaseModel): """Represents an order preview.""" @@ -62,6 +66,17 @@ def timestamp(self) -> int: except ValueError: return 0 + def parse_title_fields( + self, structure: SubcategoryStructure + ) -> dict[str, str | int]: + """ + Extract structured field values from the comma-separated title suffix. + + Delegates to :func:`funpayparsers.types.subcategory_structure._parse_title_fields`. + """ + from funpayparsers.types.subcategory_structure import _parse_title_fields + return _parse_title_fields(self.title, structure) + class OrderPreviewsBatch(FunPayObject): """ @@ -78,8 +93,8 @@ class OrderPreviewsBatch(FunPayObject): """ ID of the next order to use as a cursor for pagination. - If present, this value should be included in the next request to fetch the - following batch of order previews. + If present, this value should be included in the next request to fetch the + following batch of order previews. If ``None``, there are no more orders to load. """ diff --git a/funpaybotengine/types/pages/offer_page.py b/funpaybotengine/types/pages/offer_page.py index df120bb..24471bc 100644 --- a/funpaybotengine/types/pages/offer_page.py +++ b/funpaybotengine/types/pages/offer_page.py @@ -4,9 +4,12 @@ __all__ = ('OfferPage',) +from pydantic import Field + from funpaybotengine.types.chat import Chat from funpaybotengine.types.common import PaymentOption, DetailedUserBalance from funpaybotengine.types.pages.base import FunPayPage +from funpaybotengine.types.subcategory_structure import SubcategoryStructure class OfferPage(FunPayPage): @@ -17,7 +20,12 @@ class OfferPage(FunPayPage): """Whether auto-delivery is on or off.""" fields: dict[str, str] - """Offer fields.""" + """ + Offer fields from ``div.param-list``. + + Keys are human-readable FunPay labels (e.g. ``'Арена'``), + values are display strings (e.g. ``'15'``). + """ chat: Chat """Chat with seller.""" @@ -27,3 +35,14 @@ class OfferPage(FunPayPage): user_balance: DetailedUserBalance # user_balance available even on anonymous pages """User balance.""" + + images: list[str] = Field(default_factory=list) + """Full-size image URLs extracted from attachment items in ``div.param-list``.""" + + def get_structured_fields(self, structure: SubcategoryStructure) -> dict[str, str]: + """Return ``fields`` remapped to FunPay field IDs using *structure*'s label map.""" + return { + structure.lower_label_map[label.lower()][0]: val + for label, val in self.fields.items() + if label.lower() in structure.lower_label_map + } diff --git a/funpaybotengine/types/pages/order_page.py b/funpaybotengine/types/pages/order_page.py index 9a8c84a..f7c66db 100644 --- a/funpaybotengine/types/pages/order_page.py +++ b/funpaybotengine/types/pages/order_page.py @@ -17,6 +17,7 @@ from funpaybotengine.types.common import MoneyValue from funpaybotengine.types.reviews import Review from funpaybotengine.types.pages.base import FunPayPage +from funpaybotengine.types.subcategory_structure import SubcategoryStructure class OrderPage(FunPayPage, BaseModel): @@ -41,7 +42,12 @@ class OrderPage(FunPayPage, BaseModel): """Order subcategory type.""" data: Annotated[Mapping[str, str], BeforeValidator(OrderPage._convert_to_immutable)] - """Order data (short description, full description, etc.)""" + """ + Raw, flat ``param-list`` data — keys are casefolded labels. + + Kept for backwards compatibility. Prefer :attr:`metadata` for stable + order-level fields and :attr:`lot_fields` for lot-specific fields. + """ review: Review | None """Order review.""" @@ -49,31 +55,52 @@ class OrderPage(FunPayPage, BaseModel): chat: Chat """Chat with counterparty.""" - @staticmethod - def _convert_to_immutable(value: dict[str, str]) -> MappingProxyType[str, str]: - return MappingProxyType(value) + metadata: Annotated[ + Mapping[str, str], BeforeValidator(OrderPage._convert_to_immutable) + ] = MappingProxyType({}) + """ + Stable order metadata keyed by canonical name. Possible keys: ``game``, + ``category``, ``short_description``, ``detailed_description``, ``amount``, + ``open``, ``closed``, ``total``. Only keys actually present on the page + are stored. + """ + + lot_fields: Annotated[ + Mapping[str, str], BeforeValidator(OrderPage._convert_to_immutable) + ] = MappingProxyType({}) + """ + Lot-specific fields from ``param-list`` — everything in :attr:`data` that + is not part of :attr:`metadata`. Keys are casefolded labels, values are + display strings. This is the input for :meth:`get_structured_fields`. + """ - def _first_found(self, names: list[str]) -> str | None: - for i in names: - if self.data.get(i) is not None: - return self.data[i] - return None + @staticmethod + def _convert_to_immutable(value: Mapping[str, str]) -> MappingProxyType[str, str]: + if isinstance(value, MappingProxyType): + return value + return MappingProxyType(dict(value)) + + def get_structured_fields(self, structure: SubcategoryStructure) -> dict[str, str]: + """Return ``lot_fields`` remapped to FunPay field IDs using *structure*'s label map.""" + return { + structure.lower_label_map[label.casefold()][0]: val + for label, val in self.lot_fields.items() + if label.casefold() in structure.lower_label_map + } @property def short_description(self) -> str | None: """Order short description (title).""" - - return self._first_found(['short description', 'краткое описание', 'короткий опис']) + return self.metadata.get('short_description') @property def full_description(self) -> str | None: """Order full description (detailed description).""" - - return self._first_found(['detailed description', 'подробное описание', 'докладний опис']) + return self.metadata.get('detailed_description') @property def amount(self) -> int | None: - amount_str = self._first_found(['amount', 'количество', 'кількість']) + amount_str = self.metadata.get('amount') if not amount_str: return None return int(re.search(r'\d+', amount_str).group()) # type: ignore[union-attr] @@ -82,8 +109,7 @@ def amount(self) -> int | None: @property def open_date_text(self) -> str | None: """Order open date.""" - - date_str = self._first_found(['open', 'открыт', 'відкрито']) + date_str = self.metadata.get('open') if not date_str: return None return date_str.split('\n')[0].strip() @@ -91,8 +117,7 @@ def open_date_text(self) -> str | None: @property def close_date_text(self) -> str | None: """Order close date.""" - - date_str = self._first_found(['closed', 'закрыт', 'закрито']) + date_str = self.metadata.get('closed') if not date_str: return None return date_str.split('\n')[0].strip() @@ -100,20 +125,17 @@ def close_date_text(self) -> str | None: @property def order_category_name(self) -> str | None: """Order category name.""" - - return self._first_found(['game', 'игра', 'гра']) + return self.metadata.get('game') @property def order_subcategory_name(self) -> str | None: """Order subcategory name.""" - - return self._first_found(['category', 'категория', 'категорія']) + return self.metadata.get('category') @property def order_total(self) -> MoneyValue | None: """Order total.""" - - value = self._first_found(['total', 'сумма', 'сума']) + value = self.metadata.get('total') if not value: return None money_value_string = parse_money_value_string(value) diff --git a/funpaybotengine/types/pages/subcategory_page.py b/funpaybotengine/types/pages/subcategory_page.py index cb55cb0..24bef38 100644 --- a/funpaybotengine/types/pages/subcategory_page.py +++ b/funpaybotengine/types/pages/subcategory_page.py @@ -10,6 +10,7 @@ from funpaybotengine.types.offers import OfferPreview from funpaybotengine.types.categories import Subcategory from funpaybotengine.types.pages.base import FunPayPage +from funpaybotengine.types.subcategory_structure import SubcategoryStructure class SubcategoryPage(FunPayPage, BaseModel): @@ -32,3 +33,15 @@ class SubcategoryPage(FunPayPage, BaseModel): offers: tuple[OfferPreview, ...] | None """Subcategory offers list.""" + + structure: SubcategoryStructure | None = None + """ + Partial subcategory field structure, derived from the listing page's + ``data-fields`` JSON and per-field form groups. + + This is a strict subset of the authenticated ``offerEdit`` schema: + listing pages omit non-filterable fields (e.g. ``TEXTAREA``, ``IMAGES``) + and may render option lists as button groups rather than ``