diff --git a/.gitignore b/.gitignore index fdddf5f..913e4e8 100644 --- a/.gitignore +++ b/.gitignore @@ -208,3 +208,4 @@ __marimo__/ # Streamlit .streamlit/secrets.toml +.local/ diff --git a/funpaybotengine/client/bot.py b/funpaybotengine/client/bot.py index 8320267..ecf15a4 100644 --- a/funpaybotengine/client/bot.py +++ b/funpaybotengine/client/bot.py @@ -816,14 +816,101 @@ async def get_subcategory_page( self, subcategory_type: SubcategoryType, subcategory_id: int, + options: 'SubcategoryPageParsingOptions | None' = None, ) -> SubcategoryPage: + """ + Fetch a subcategory listing page. + + :param options: Optional parser options. Pass with + ``fallback_structure_from_chips_offers=True`` to synthesize a + ``SubcategoryStructure`` for CHIPS subcategories without a + ``div.lot-fields`` block. + """ return ( await GetSubcategoryPage( type=subcategory_type, subcategory_id=subcategory_id, + options=options, ).execute(self) ).response_obj + async def get_subcategory_structure( + self, + subcategory_type: SubcategoryType, + subcategory_id: int, + *, + synthesize_chips: bool = True, + seed_from_offer_fields: bool = True, + enrich_from_offer_sample: bool = True, + sample_size: int = 5, + ) -> 'SubcategoryStructure | None': + """ + High-level helper: fetch the structure for a subcategory and apply + every available enrichment in one call. + + Pipeline: + 1. ``get_subcategory_page`` (with ``fallback_structure_from_chips_offers`` + when *synthesize_chips* and the subcategory is CHIPS). + 2. If structure exists and *seed_from_offer_fields*, fetch + ``OfferFields`` for the first reachable offer in the listing and + apply :meth:`SubcategoryStructure.enrich_from_offer_fields`. This + seeds canonical localized labels (``Регион``, ``Логин Steam``, …) + for TEXT fields that have no ``options`` and therefore cannot be + auto-aliased from value matching alone. + 3. If structure exists and *enrich_from_offer_sample*, walk up to + *sample_size* offers in the listing and apply + :meth:`SubcategoryStructure.enrich_from_offer` (one OfferPage at + a time) until at least one succeeds. + + Returns ``None`` if the subcategory has no structure and synthesis is + disabled / unavailable. + """ + from funpayparsers.parsers.page_parsers.subcategory_page_parser import ( + SubcategoryPageParsingOptions, + ) + opts: SubcategoryPageParsingOptions | None = None + if synthesize_chips and subcategory_type is SubcategoryType.CHIPS: + opts = SubcategoryPageParsingOptions( + fallback_structure_from_chips_offers=True, + ) + page = await self.get_subcategory_page(subcategory_type, subcategory_id, opts) + struct = page.structure + if struct is None: + return None + + if seed_from_offer_fields: + # Use the (subcategory_type, subcategory_id) form so we don't need + # to own the offer — this fetches the canonical localized + # ``offerEdit`` field schema for this subcategory directly. + try: + of = await self.get_offer_fields( + subcategory_type=subcategory_type, + subcategory_id=subcategory_id, + ) + struct.enrich_from_offer_fields(of) + except Exception: + pass + + if page.offers: + # Free batch enrichment — we already have the offer previews from + # the listing call, so applying ``other_data_names`` aliases costs + # zero additional HTTP requests. + struct.enrich_from_offer_previews(page.offers) + + if enrich_from_offer_sample and page.offers: + for offer in page.offers[:sample_size]: + try: + op = await self.get_offer_page(offer.id) + struct.enrich_from_offer(op) + # Same OfferPage carries the order-form delivery spec — + # accumulate per-subcategory delivery labels for free. + struct.enrich_delivery_fields_from_offer(op) + break + except Exception: + continue + + return struct + async def get_offer_page( self, offer_id: int | str, diff --git a/funpaybotengine/methods/get_subcategory_page.py b/funpaybotengine/methods/get_subcategory_page.py index 1bb613f..b7c518c 100644 --- a/funpaybotengine/methods/get_subcategory_page.py +++ b/funpaybotengine/methods/get_subcategory_page.py @@ -7,6 +7,9 @@ from pydantic import BaseModel from funpayparsers.types import Language from funpayparsers.parsers.page_parsers import SubcategoryPageParser +from funpayparsers.parsers.page_parsers.subcategory_page_parser import ( + SubcategoryPageParsingOptions, +) from funpaybotengine.types.enums import SubcategoryType from funpaybotengine.types.pages import SubcategoryPage @@ -29,13 +32,20 @@ class GetSubcategoryPage(FunPayMethod[SubcategoryPage], BaseModel): __model_to_build__ = SubcategoryPage - def __init__(self, type: SubcategoryType, subcategory_id: int, locale: Language | None = None): + def __init__( + self, + type: SubcategoryType, + subcategory_id: int, + locale: Language | None = None, + options: SubcategoryPageParsingOptions | None = None, + ): super().__init__( url=f'{type.url_alias}/{subcategory_id}/', method=HTTPMethod.GET, allow_anonymous=True, allow_uninitialized=True, parser_cls=SubcategoryPageParser, + parser_options=options, locale=locale, type=type, subcategory_id=subcategory_id, 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..4c1c4cc 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,28 @@ 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``.""" + + delivery_fields_spec: dict[str, str] = Field(default_factory=dict) + """ + Map of ``input.name → label`` for per-order delivery-contract fields + rendered in the buyer's order form (``
``). + + Keys are FunPay form input names (e.g. ``'player'``, ``'login'``); + values are localized labels shown to the buyer (e.g. + ``'Telegram Username'``, ``'Логин Steam'``). Includes both visible and + conditionally-hidden form-groups. + + Use :meth:`SubcategoryStructure.enrich_delivery_fields_from_offer` to + accumulate these into a per-subcategory delivery-label index. + """ + + 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..6fce752 100644 --- a/funpaybotengine/types/pages/order_page.py +++ b/funpaybotengine/types/pages/order_page.py @@ -5,18 +5,20 @@ import re -from typing import Annotated +from typing import Annotated, Any from types import MappingProxyType +from datetime import datetime, timezone from collections.abc import Mapping from pydantic import BaseModel, BeforeValidator -from funpayparsers.parsers.utils import parse_money_value_string +from funpayparsers.parsers.utils import parse_date_string, parse_money_value_string from funpaybotengine.types.chat import Chat from funpaybotengine.types.enums import OrderStatus, SubcategoryType 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 +43,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 +56,200 @@ class OrderPage(FunPayPage, BaseModel): chat: Chat """Chat with counterparty.""" + 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` or :attr:`delivery_fields`. Keys are + casefolded labels, values are display strings. Input for + :meth:`get_structured_fields`. + """ + + delivery_fields: Annotated[ + Mapping[str, str], BeforeValidator(OrderPage._convert_to_immutable) + ] = MappingProxyType({}) + """ + Per-order delivery-contract data supplied by the buyer (Telegram username, + Steam login, character name, email, …). Classified via the static + blacklist in funpayparsers' ``ORDER_DELIVERY_LABELS`` at parse time; + callers can re-classify with higher precision via + :meth:`reclassify_with_structure` once a ``SubcategoryStructure`` with + populated :attr:`SubcategoryStructure.delivery_fields` is available. + """ + @staticmethod - def _convert_to_immutable(value: dict[str, str]) -> MappingProxyType[str, str]: - return MappingProxyType(value) + 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. When labels are shared by multiple structure fields, uses + the conditions of those fields against already-resolved entries to + disambiguate. + """ + result: dict[str, str] = {} + for label, val in self.lot_fields.items(): + fid = structure.lookup_field_id(label, context=result) + if fid is None: + ids = structure.lower_label_map.get(label.casefold()) + if ids: + fid = ids[0] + if fid is not None: + result[fid] = val + return result + + def get_structured_delivery( + self, structure: SubcategoryStructure | None = None, + ) -> dict[str, str]: + """ + Return ``delivery_fields`` keyed by canonical FunPay form input names. + + When *structure* is provided and its ``delivery_fields`` (label→name + accumulator collected from ``OfferPage.delivery_fields_spec``) is + populated, each delivery label is resolved to the original form + ``input.name`` (``'player'``, ``'login'``, …). Without a structure + — falls back to the raw casefolded labels FunPay rendered on the + OrderPage. + """ + if structure is None or not structure.delivery_fields: + return dict(self.delivery_fields) + # Reverse map: label_casefold → input_name + reverse: dict[str, str] = {} + for name, label in structure.delivery_fields.items(): + reverse[label.casefold()] = name + result: dict[str, str] = {} + for label, val in self.delivery_fields.items(): + key = reverse.get(label.casefold(), label) + result[key] = val + return result - 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] + @staticmethod + def _extract_lot_quantity(lot: Mapping[str, str]) -> int | None: + """ + Best-effort numeric quantity extraction from already-resolved lot + fields, as a fallback for orders where FunPay does not render the + buyer-chosen quantity in ``metadata['amount']`` (e.g. Telegram Stars + subcategory — quantity lives in ``lot['quantity'] = '50 звёзд'``). + + Looks at conventional FunPay quantity-bearing field ids in priority + order, returns the leading integer or ``None``. + """ + for fid in ('quantity', 'quantity2', 'amount'): + raw = lot.get(fid) + if raw is None: + continue + try: + return int(raw) + except (TypeError, ValueError): + pass + m = re.match(r'^\s*(\d+)', str(raw)) + if m: + return int(m.group(1)) return None + def get_structured_context( + self, structure: SubcategoryStructure | None = None, + ) -> dict[str, Any]: + """ + Unified structured view of the order combining lot, delivery and + normalized order metadata in a single dict. + + Returned shape:: + + { + 'lot': {field_id: value, ...}, # via get_structured_fields + 'delivery': {input_name|label: value, ...}, # via get_structured_delivery + 'amount': int | None, + 'opened_at': datetime | None, # UTC + 'closed_at': datetime | None, # UTC + 'total': MoneyValue | None, + 'category': str | None, # game name + 'subcategory': str | None, # subcategory name + 'recipient': str | None, # first non-empty delivery value + 'recipient_label': str | None, + } + + Invariants: + + * ``amount`` falls back from ``metadata['amount']`` to the leading + integer of ``lot['quantity']`` / ``lot['quantity2']`` when the + metadata value is missing — guarantees a numeric answer whenever + quantity is anywhere in the order, regardless of which surface + carried it. + * ``recipient`` is populated whenever the order has any delivery + input (Telegram username, Steam login, character name, email, …) + — accessible without consulting ``delivery`` keys directly. + * If *structure* is omitted, ``lot`` is empty and ``delivery`` keys + are raw labels (no input-name mapping). + """ + lot = self.get_structured_fields(structure) if structure else {} + amount = self.amount + if amount is None: + amount = self._extract_lot_quantity(lot) + return { + 'lot': lot, + 'delivery': self.get_structured_delivery(structure), + 'amount': amount, + 'opened_at': self.opened_at, + 'closed_at': self.closed_at, + 'total': self.order_total, + 'category': self.order_category_name, + 'subcategory': self.order_subcategory_name, + 'recipient': self.recipient, + 'recipient_label': self.recipient_label, + } + + def reclassify_with_structure( + self, structure: SubcategoryStructure + ) -> OrderPage: + """ + Re-split ``data`` using ``structure.delivery_fields`` for + high-precision delivery classification. Useful when the page was + originally parsed without structure context (only the static + blacklist applied), and a populated ``SubcategoryStructure`` has + since become available. + + Mutates ``self.lot_fields`` and ``self.delivery_fields``; returns + ``self`` for chaining. + """ + from funpayparsers.types.pages.order_page import _split_order_data + extra = frozenset( + label.casefold() for label in structure.delivery_fields.values() + ) + _, lot_fields, delivery_fields = _split_order_data( + dict(self.data), extra_delivery_labels=extra, + ) + object.__setattr__(self, 'lot_fields', MappingProxyType(lot_fields)) + object.__setattr__(self, 'delivery_fields', MappingProxyType(delivery_fields)) + return self + @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] @@ -81,39 +257,87 @@ def amount(self) -> int | None: @property def open_date_text(self) -> str | None: - """Order open date.""" + """Order open date as raw FunPay text (e.g. ``'23 апреля в 20:40 (...)'``). - date_str = self._first_found(['open', 'открыт', 'відкрито']) + Prefer :attr:`opened_at` for a parsed ``datetime``. + """ + 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', 'закрыт', 'закрито']) + """Order close date as raw FunPay text. Prefer :attr:`closed_at`.""" + date_str = self.metadata.get('closed') if not date_str: return None return date_str.split('\n')[0].strip() + @property + def opened_at(self) -> datetime | None: + """ + Order open timestamp parsed from ``metadata['open']``. + + Returns a UTC ``datetime`` (FunPay renders Europe/Moscow / UTC+3 + timestamps; :func:`funpayparsers.parsers.utils.parse_date_string` + normalizes them to UTC). Returns ``None`` if missing or unparseable. + """ + text = self.open_date_text + if not text: + return None + try: + return datetime.fromtimestamp(parse_date_string(text), tz=timezone.utc) + except (ValueError, OverflowError, OSError): + return None + + @property + def closed_at(self) -> datetime | None: + """Order close timestamp parsed from ``metadata['closed']``. UTC.""" + text = self.close_date_text + if not text: + return None + try: + return datetime.fromtimestamp(parse_date_string(text), tz=timezone.utc) + except (ValueError, OverflowError, OSError): + return None + + @property + def recipient(self) -> str | None: + """ + Best-effort buyer-supplied delivery target — returns the first + non-empty value from :attr:`delivery_fields`. + + Common cases: Telegram username, Steam login, in-game character name, + email. Returns ``None`` if no delivery fields were captured. + """ + for v in self.delivery_fields.values(): + if v: + return v + return None + + @property + def recipient_label(self) -> str | None: + """Label of the field whose value is returned by :attr:`recipient`.""" + for label, v in self.delivery_fields.items(): + if v: + return label + return 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 ``