Skip to content

Commit 3da79ee

Browse files
Flummy1claude
andcommitted
feat: multi-source label index, parse_title_fields, OrderPage data split
Address structural-parsing recall problems found via live engine testing on funpaybotengine PR #35: structure built from data-fields JSON keeps English IDs while OfferPage / OrderPage render localized labels, leaving get_structured_fields with ~1/8 recall on real data. - SubcategoryFieldDef gains casefolded `aliases: set[str]`; OfferFieldsParser registers field id and form <label> as aliases. label_map / lower_label_map now index aliases. New `lookup_field_id`, `add_alias`, and heuristic `enrich_from_offer` (matches by SELECT/DROPDOWN option value). - OfferPreview / OrderPreview gain `parse_title_fields(structure)` (cherry- picked from the lost feature/subcategory-structure-parser commits and adapted to the dict-based fields layout). SELECT/DROPDOWN segments are validated against `options`; unmatched segments are dropped. - OrderPage data is split into `metadata` (canonical RU/EN/UA-aware order fields) and `lot_fields` (everything else). `get_structured_fields` now reads `lot_fields` only, eliminating false positives from order-metadata labels colliding with structural ones. `data` is preserved for back-compat. - FieldCondition and SubcategoryStructure now extend FunPayObject with `raw_source`, removing the need for the asdict() shim in engine pydantic validators. - Bump 0.8.0 -> 0.8.2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 73db69e commit 3da79ee

11 files changed

Lines changed: 501 additions & 53 deletions

‎funpayparsers/parsers/offer_fields_parser.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,19 @@ def _parse_field_def(
119119

120120
options = cls._parse_options(form_group, field_type)
121121

122+
# Index the field's own ID and the form ``<label>`` text as aliases
123+
# so cross-locale label lookups (data-fields JSON key vs. localized
124+
# ``<label>``) resolve to the same field via ``label_map``.
125+
aliases = {field_id, label}
126+
122127
return SubcategoryFieldDef(
123128
raw_source=raw_source,
124129
id=field_id,
125130
type=field_type,
126131
label=label,
127132
conditions=conditions,
128133
options=options,
134+
aliases=aliases,
129135
)
130136

131137
@staticmethod
@@ -141,7 +147,13 @@ def _parse_conditions(raw_conditions: list[dict[str, Any]]) -> list[FieldConditi
141147
f'Unrecognized condition format: {cond!r}. '
142148
f"Expected one of 'list' or 'value' keys."
143149
)
144-
result.append(FieldCondition(field_id=cond['id'], values=set(values)))
150+
result.append(
151+
FieldCondition(
152+
raw_source=json.dumps(cond, ensure_ascii=False),
153+
field_id=cond['id'],
154+
values=set(values),
155+
)
156+
)
145157
return result
146158

147159
@staticmethod

‎funpayparsers/parsers/page_parsers/order_page_parser.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from funpayparsers.parsers.chat_parser import ChatParser, ChatParsingOptions
1414
from funpayparsers.parsers.appdata_parser import AppDataParser, AppDataParsingOptions
1515
from funpayparsers.parsers.reviews_parser import ReviewsParser, ReviewsParsingOptions
16+
from funpayparsers.types.pages.order_page import _split_order_data
1617
from funpayparsers.parsers.page_header_parser import (
1718
PageHeaderParser,
1819
PageHeaderParsingOptions,
@@ -90,6 +91,8 @@ def _parse(self) -> OrderPage:
9091

9192
data[name[0].text().strip().lower()] = value[-1].text().strip()
9293

94+
metadata, lot_fields = _split_order_data(data)
95+
9396
subcategory_url: str = self.tree.css_first( # type: ignore[assignment,union-attr]
9497
'div.param-item:has(h5):not(:has(ul, ol)) a',
9598
strict=False,
@@ -127,4 +130,6 @@ def _parse(self) -> OrderPage:
127130
options=self.options.chat_parsing_options,
128131
).parse(),
129132
data=data,
133+
metadata=metadata,
134+
lot_fields=lot_fields,
130135
)

‎funpayparsers/parsers/page_parsers/subcategory_page_parser.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ def _parse(self) -> SubcategoryPage:
8888
if lot_fields_div is not None:
8989
field_schema = OfferFieldsParser.parse_field_schema(lot_fields_div)
9090
structure = SubcategoryStructure(
91+
raw_source=lot_fields_div.html or '',
9192
subcategory_id=int(subcategory_id_str.split('-')[-1]),
9293
fields={f.id: f for f in field_schema},
9394
)

‎funpayparsers/types/offers.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,23 @@ class OfferPreview(FunPayObject):
105105
subcategory_type: SubcategoryType = SubcategoryType.UNKNOWN
106106
"""Type of the subcategory (OFFERS/CHIPS), derived from the offer URL."""
107107

108+
def parse_title_fields(
109+
self, structure: SubcategoryStructure
110+
) -> dict[str, str | int]:
111+
"""
112+
Extract structural field values from this preview's ``title``.
113+
114+
FunPay appends ``NUMERIC_RANGE``, ``SELECT``, and ``DROPDOWN`` field
115+
values to the offer title in declaration order. This method matches
116+
the trailing comma-separated segments of ``title`` against
117+
*structure*'s field definitions and returns a mapping of
118+
``field_id`` → value.
119+
120+
Returns an empty dict if ``title`` is ``None`` or no segment matches.
121+
"""
122+
from funpayparsers.types.subcategory_structure import _parse_title_fields
123+
return _parse_title_fields(self.title, structure)
124+
108125

109126
T = TypeVar('T')
110127
P = ParamSpec('P')

‎funpayparsers/types/orders.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
if TYPE_CHECKING:
1616
from funpayparsers.types.common import UserPreview
17+
from funpayparsers.types.subcategory_structure import SubcategoryStructure
1718

1819

1920
@dataclass
@@ -41,6 +42,18 @@ class OrderPreview(FunPayObject):
4142
counterparty: UserPreview
4243
"""Associated counterparty info."""
4344

45+
def parse_title_fields(
46+
self, structure: SubcategoryStructure
47+
) -> dict[str, str | int]:
48+
"""
49+
Extract structural field values from this preview's ``title``.
50+
51+
See :meth:`OfferPreview.parse_title_fields` for details — order titles
52+
share the same comma-separated suffix layout as offer titles.
53+
"""
54+
from funpayparsers.types.subcategory_structure import _parse_title_fields
55+
return _parse_title_fields(self.title, structure)
56+
4457
@property
4558
def timestamp(self) -> int:
4659
"""

‎funpayparsers/types/pages/order_page.py‎

Lines changed: 82 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
from __future__ import annotations
22

33

4-
__all__ = ('OrderPage',)
4+
__all__ = ('OrderPage', 'ORDER_METADATA_LABELS')
55

66
import re
77
from typing import TYPE_CHECKING
8-
from dataclasses import dataclass
8+
from dataclasses import field, dataclass
99

1010
from funpayparsers.types.chat import Chat
1111
from funpayparsers.types.enums import OrderStatus, SubcategoryType
@@ -19,6 +19,55 @@
1919
from funpayparsers.types.subcategory_structure import SubcategoryStructure
2020

2121

22+
# Stable order-metadata labels rendered by FunPay regardless of the lot's
23+
# subcategory. Keys are the canonical metadata names, values are the set of
24+
# casefolded label variants that may appear in ``param-list`` (RU/EN/UA).
25+
# Anything not in this map is treated as a lot-specific field and routed to
26+
# ``OrderPage.lot_fields`` instead of ``OrderPage.metadata``.
27+
ORDER_METADATA_LABELS: dict[str, frozenset[str]] = {
28+
'game': frozenset({'game', 'игра', 'гра'}),
29+
'category': frozenset({'category', 'категория', 'категорія'}),
30+
'short_description': frozenset(
31+
{'short description', 'краткое описание', 'короткий опис'}
32+
),
33+
'detailed_description': frozenset(
34+
{'detailed description', 'подробное описание', 'докладний опис'}
35+
),
36+
'amount': frozenset({'amount', 'количество', 'кількість'}),
37+
'open': frozenset({'open', 'открыт', 'відкрито'}),
38+
'closed': frozenset({'closed', 'закрыт', 'закрито'}),
39+
'total': frozenset({'total', 'сумма', 'сума'}),
40+
}
41+
42+
_LABEL_TO_METADATA_KEY: dict[str, str] = {
43+
label: canonical
44+
for canonical, variants in ORDER_METADATA_LABELS.items()
45+
for label in variants
46+
}
47+
48+
49+
def _split_order_data(
50+
data: dict[str, str],
51+
) -> tuple[dict[str, str], dict[str, str]]:
52+
"""
53+
Split ``param-list`` data into ``(metadata, lot_fields)``.
54+
55+
*data* keys are casefolded labels as parsed by ``OrderPageParser``.
56+
Metadata labels (see :data:`ORDER_METADATA_LABELS`) are extracted under
57+
their canonical key; everything else is preserved verbatim in
58+
``lot_fields``. The two resulting dicts are disjoint.
59+
"""
60+
metadata: dict[str, str] = {}
61+
lot_fields: dict[str, str] = {}
62+
for label, value in data.items():
63+
canonical = _LABEL_TO_METADATA_KEY.get(label)
64+
if canonical is not None and canonical not in metadata:
65+
metadata[canonical] = value
66+
else:
67+
lot_fields[label] = value
68+
return metadata, lot_fields
69+
70+
2271
@dataclass
2372
class OrderPage(FunPayPage):
2473
"""Represents an order page (`https://funpay.com/orders/<order_id>/`)."""
@@ -42,43 +91,55 @@ class OrderPage(FunPayPage):
4291
"""Order subcategory type."""
4392

4493
data: dict[str, str]
45-
"""Order data (short description, full description, etc.)"""
94+
"""
95+
Raw, flat ``param-list`` data — keys are casefolded labels.
96+
97+
Kept for backwards compatibility. Prefer ``metadata`` for stable
98+
order-level fields and ``lot_fields`` for lot-specific fields.
99+
"""
46100

47101
review: Review | None
48102
"""Order review."""
49103

50104
chat: Chat
51105
"""Chat with counterparty."""
52106

107+
metadata: dict[str, str] = field(default_factory=dict)
108+
"""
109+
Stable order metadata, keyed by canonical name (see
110+
:data:`ORDER_METADATA_LABELS`). Possible keys: ``game``, ``category``,
111+
``short_description``, ``detailed_description``, ``amount``, ``open``,
112+
``closed``, ``total``. Only keys actually present on the page are stored.
113+
"""
114+
115+
lot_fields: dict[str, str] = field(default_factory=dict)
116+
"""
117+
Lot-specific fields from ``param-list`` — everything in ``data`` that is
118+
not part of ``metadata``. Keys are casefolded labels, values are display
119+
strings. This is the input for :meth:`get_structured_fields`.
120+
"""
121+
53122
def get_structured_fields(self, structure: SubcategoryStructure) -> dict[str, str]:
54-
"""Return ``data`` remapped to FunPay field IDs using *structure*'s label map."""
123+
"""Return ``lot_fields`` remapped to FunPay field IDs using *structure*'s label map."""
55124
return {
56125
structure.lower_label_map[label][0]: val
57-
for label, val in self.data.items()
126+
for label, val in self.lot_fields.items()
58127
if label in structure.lower_label_map
59128
}
60129

61-
def _first_found(self, names: list[str]) -> str | None:
62-
for i in names:
63-
if self.data.get(i) is not None:
64-
return self.data[i]
65-
return None
66-
67130
@property
68131
def short_description(self) -> str | None:
69132
"""Order short description (title)."""
70-
71-
return self._first_found(['short description', 'краткое описание', 'короткий опис'])
133+
return self.metadata.get('short_description')
72134

73135
@property
74136
def full_description(self) -> str | None:
75137
"""Order full description (detailed description)."""
76-
77-
return self._first_found(['detailed description', 'подробное описание', 'докладний опис'])
138+
return self.metadata.get('detailed_description')
78139

79140
@property
80141
def amount(self) -> int | None:
81-
amount_str = self._first_found(['amount', 'количество', 'кількість'])
142+
amount_str = self.metadata.get('amount')
82143
if not amount_str:
83144
return None
84145
return int(re.search(r'\d+', amount_str).group()) # type: ignore[union-attr]
@@ -87,39 +148,35 @@ def amount(self) -> int | None:
87148
@property
88149
def open_date_text(self) -> str | None:
89150
"""Order open date."""
90-
91-
date_str = self._first_found(['open', 'открыт', 'відкрито'])
151+
date_str = self.metadata.get('open')
92152
if not date_str:
93153
return None
94154
return date_str.split('\n')[0].strip()
95155

96156
@property
97157
def close_date_text(self) -> str | None:
98158
"""Order close date."""
99-
100-
date_str = self._first_found(['closed', 'закрыт', 'закрито'])
159+
date_str = self.metadata.get('closed')
101160
if not date_str:
102161
return None
103162
return date_str.split('\n')[0].strip()
104163

105164
@property
106165
def order_category_name(self) -> str | None:
107166
"""Order category name."""
108-
109-
return self._first_found(['game', 'игра', 'гра'])
167+
return self.metadata.get('game')
110168

111169
@property
112170
def order_subcategory_name(self) -> str | None:
113171
"""Order subcategory name."""
114-
115-
return self._first_found(['category', 'категория', 'категорія'])
172+
return self.metadata.get('category')
116173

117174
@property
118175
def order_total(self) -> MoneyValue | None:
119176
"""Order total."""
120177
from funpayparsers.parsers.utils import parse_money_value_string
121178

122-
value = self._first_found(['total', 'сумма', 'сума'])
179+
value = self.metadata.get('total')
123180
if not value:
124181
return None
125182
return parse_money_value_string(value)

0 commit comments

Comments
 (0)