feat: add gear provenance models and browser fetcher
This commit is contained in:
@@ -310,6 +310,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
default=Path(os.environ.get("RF4_COMMUNITY_FETCH_STATE", DEFAULT_STATE_FILE)),
|
||||
help="Persistent per-source cooldown state",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reserve-only", action="store_true",
|
||||
help="Reserve the shared site cooldown and stop before making an HTTP request",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if args.source in DETAIL_SOURCES and not args.url:
|
||||
parser.error(f"--url is required for {args.source}")
|
||||
@@ -319,6 +323,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
site_key = fetch_site_key(url)
|
||||
# Single atomic check-and-reserve before network I/O: failed attempts count toward the limit too.
|
||||
check_and_reserve(site_key, state_file=args.state_file)
|
||||
if args.reserve_only:
|
||||
print(json.dumps({"reserved": True, "source": args.source, "site_key": site_key}, ensure_ascii=False))
|
||||
return 0
|
||||
html = fetch_html(url)
|
||||
parsed = (
|
||||
parse(html, source_url=url)
|
||||
|
||||
@@ -45,6 +45,51 @@ class EquipmentItem:
|
||||
external_id: str | None
|
||||
|
||||
|
||||
GEAR_CATEGORIES = frozenset({
|
||||
"bait", "lure", "rod", "reel", "line", "hook", "rig", "float", "sinker", "other",
|
||||
})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RF4DBGearItem:
|
||||
source_system: str
|
||||
source_external_id: str
|
||||
source_url: str
|
||||
name: str
|
||||
category: str
|
||||
subcategory: str | None
|
||||
brand: str | None
|
||||
family: str | None
|
||||
unlock_level: int | None
|
||||
image_url: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RF4DBGearAttribute:
|
||||
key: str
|
||||
state: str
|
||||
value: str | int | float | None
|
||||
source_text: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RF4DBGearDetail:
|
||||
source_system: str
|
||||
source_external_id: str
|
||||
source_url: str
|
||||
name: str
|
||||
category: str
|
||||
subcategory: str | None
|
||||
brand: str | None
|
||||
family: str | None
|
||||
unlock_level: int | None
|
||||
attributes: tuple[RF4DBGearAttribute, ...]
|
||||
variants: tuple[str, ...]
|
||||
compatible_with: tuple[str, ...]
|
||||
rig_types: tuple[str, ...]
|
||||
image_urls: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RF4DBCatchDetail:
|
||||
source_external_id: str
|
||||
@@ -209,6 +254,110 @@ def parse_rf4db_waterbodies(
|
||||
return result
|
||||
|
||||
|
||||
def _gear_level(value: str | None) -> int | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if not re.fullmatch(r"\d+", text):
|
||||
raise CommunityParseError(f"invalid gear unlock level: {text!r}")
|
||||
return int(text)
|
||||
|
||||
|
||||
def _gear_category(value: str | None) -> str:
|
||||
category = (value or "").strip().casefold()
|
||||
if category not in GEAR_CATEGORIES:
|
||||
raise CommunityParseError(f"invalid or missing gear category: {value!r}")
|
||||
return category
|
||||
|
||||
|
||||
def _gear_value(node: Tag) -> str | int | float | None:
|
||||
text = _text(node) or None
|
||||
if text is None:
|
||||
return None
|
||||
value_type = str(node.get("data-type") or "text").casefold()
|
||||
if value_type == "number":
|
||||
try:
|
||||
return float(text) if "." in text else int(text)
|
||||
except ValueError as exc:
|
||||
raise CommunityParseError(f"invalid numeric gear attribute: {text!r}") from exc
|
||||
return text
|
||||
|
||||
|
||||
def parse_rf4db_gear_catalog(
|
||||
html: str, *, source_url: str = "https://rf4db.com/ru/wiki/gear", expected_count: int | None = None,
|
||||
) -> list[RF4DBGearItem]:
|
||||
"""Parse a gear index while keeping an unknown total explicitly unknown."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
cards = soup.select("article.gear-card, [data-gear-card]")
|
||||
result: list[RF4DBGearItem] = []
|
||||
seen: set[str] = set()
|
||||
for card in cards:
|
||||
link = card.select_one("a[href]")
|
||||
external_id = str(card.get("data-gear-id") or _key(link.get("href") if link else None) or "")
|
||||
name = _text(card.select_one("[data-name], h2, h3, .gear-name"))
|
||||
category = _gear_category(card.get("data-category"))
|
||||
if not external_id or not name:
|
||||
raise CommunityParseError("RF4DB gear card is missing id or name")
|
||||
if external_id in seen:
|
||||
raise CommunityParseError(f"duplicate RF4DB gear id: {external_id}")
|
||||
seen.add(external_id)
|
||||
item_url = urljoin(source_url, str(link.get("href"))) if link and link.get("href") else source_url
|
||||
image = card.select_one("img[src], img[data-src]")
|
||||
image_url = urljoin(item_url, str(image.get("src") or image.get("data-src"))) if image else None
|
||||
result.append(RF4DBGearItem(
|
||||
source_system="rf4db", source_external_id=external_id, source_url=item_url,
|
||||
name=name, category=category,
|
||||
subcategory=_text(card.select_one("[data-subcategory], .gear-subcategory")) or None,
|
||||
brand=_text(card.select_one("[data-brand], .gear-brand")) or None,
|
||||
family=_text(card.select_one("[data-family], .gear-family")) or None,
|
||||
unlock_level=_gear_level(card.get("data-unlock-level")), image_url=image_url,
|
||||
))
|
||||
if not result:
|
||||
raise CommunityParseError("RF4DB gear cards not found")
|
||||
if expected_count is not None and len(result) != expected_count:
|
||||
raise CommunityParseError(f"RF4DB gear catalog count mismatch: expected {expected_count}, got {len(result)}")
|
||||
return result
|
||||
|
||||
|
||||
def parse_rf4db_gear_detail(
|
||||
html: str, *, source_url: str,
|
||||
) -> RF4DBGearDetail:
|
||||
"""Parse one gear detail page with explicit missing/not-applicable/value states."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
root = soup.select_one("main[data-gear-detail], article.gear-detail") or soup
|
||||
external_id = _key(source_url)
|
||||
name = _text(root.select_one("h1, [data-name]"))
|
||||
category = _gear_category(root.get("data-category"))
|
||||
if not external_id or not name:
|
||||
raise CommunityParseError("RF4DB gear detail is missing id or name")
|
||||
attributes: list[RF4DBGearAttribute] = []
|
||||
for node in root.select("[data-gear-attribute]"):
|
||||
key = str(node.get("data-gear-attribute") or "").strip()
|
||||
state = str(node.get("data-state") or "value").strip().casefold()
|
||||
if not key or state not in {"value", "not_applicable", "missing"}:
|
||||
raise CommunityParseError("invalid RF4DB gear attribute state")
|
||||
value = None if state != "value" else _gear_value(node)
|
||||
attributes.append(RF4DBGearAttribute(key=key, state=state, value=value, source_text=_text(node) or None))
|
||||
images = tuple(dict.fromkeys(
|
||||
urljoin(source_url, str(node.get("src") or node.get("data-src")))
|
||||
for node in root.select("img[src], img[data-src]")
|
||||
if node.get("src") or node.get("data-src")
|
||||
))
|
||||
return RF4DBGearDetail(
|
||||
source_system="rf4db", source_external_id=external_id, source_url=source_url,
|
||||
name=name, category=category,
|
||||
subcategory=_text(root.select_one("[data-subcategory], .gear-subcategory")) or None,
|
||||
brand=_text(root.select_one("[data-brand], .gear-brand")) or None,
|
||||
family=_text(root.select_one("[data-family], .gear-family")) or None,
|
||||
unlock_level=_gear_level(root.get("data-unlock-level")),
|
||||
attributes=tuple(attributes),
|
||||
variants=tuple(dict.fromkeys(_text(node) for node in root.select("[data-gear-variant]") if _text(node))),
|
||||
compatible_with=tuple(dict.fromkeys(_text(node) for node in root.select("[data-compatible-with]") if _text(node))),
|
||||
rig_types=tuple(dict.fromkeys(_text(node) for node in root.select("[data-rig-type]") if _text(node))),
|
||||
image_urls=images,
|
||||
)
|
||||
|
||||
|
||||
def parse_rf4db_waterbody_detail(
|
||||
html: str, *, source_url: str,
|
||||
) -> RF4DBWaterbodyDetail:
|
||||
|
||||
Reference in New Issue
Block a user