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 *
48 changes: 44 additions & 4 deletions funpaybotengine/types/offers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
"""

Expand All @@ -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')
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -561,17 +601,17 @@ 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')

@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:
Expand Down
21 changes: 18 additions & 3 deletions funpaybotengine/types/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""

Expand Down Expand Up @@ -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):
"""
Expand All @@ -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.
"""
Expand Down
21 changes: 20 additions & 1 deletion funpaybotengine/types/pages/offer_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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."""
Expand All @@ -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
}
70 changes: 46 additions & 24 deletions funpaybotengine/types/pages/order_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -41,39 +42,65 @@ 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."""

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]
Expand All @@ -82,38 +109,33 @@ 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()

@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()

@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)
Expand Down
13 changes: 13 additions & 0 deletions funpaybotengine/types/pages/subcategory_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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 ``<select>``.

``None`` when the page has no ``div.lot-fields`` block (e.g. chips).
"""
Loading