hvbrowser 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/PKG-INFO +5 -5
  2. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/pyproject.toml +6 -6
  3. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/src/hvbrowser/equipment_repair.py +247 -37
  4. hvbrowser-0.2.2/tests/test_equipment_repair.py +773 -0
  5. hvbrowser-0.2.0/tests/test_equipment_repair.py +0 -314
  6. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/LICENSE +0 -0
  7. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/README.md +0 -0
  8. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/scripts/live_readonly_smoke.py +0 -0
  9. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/src/hvbrowser/__init__.py +0 -0
  10. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/src/hvbrowser/hv.py +0 -0
  11. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/src/hvbrowser/live_probe.py +0 -0
  12. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/src/hvbrowser/lottery.py +0 -0
  13. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/src/hvbrowser/maintenance_navigation.py +0 -0
  14. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/src/hvbrowser/market.py +0 -0
  15. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/src/hvbrowser/monster_lab.py +0 -0
  16. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/src/hvbrowser/player.py +0 -0
  17. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/src/hvbrowser/py.typed +0 -0
  18. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/src/hvbrowser/realm.py +0 -0
  19. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/src/hvbrowser/runtime.py +0 -0
  20. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/src/hvbrowser/session.py +0 -0
  21. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/src/hvbrowser/urls.py +0 -0
  22. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/tests/test_architecture.py +0 -0
  23. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/tests/test_hv.py +0 -0
  24. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/tests/test_live_probe.py +0 -0
  25. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/tests/test_lottery.py +0 -0
  26. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/tests/test_maintenance_navigation.py +0 -0
  27. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/tests/test_market.py +0 -0
  28. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/tests/test_monster_lab.py +0 -0
  29. {hvbrowser-0.2.0 → hvbrowser-0.2.2}/tests/test_player.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hvbrowser
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Browser automation APIs for HentaiVerse.
5
5
  Project-URL: Homepage, https://github.com/Kuan-Lun/hvbrowser
6
6
  Project-URL: Tracker, https://github.com/Kuan-Lun/hvbrowser/issues
@@ -13,13 +13,13 @@ Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or l
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python :: 3.14
15
15
  Requires-Python: <3.15,>=3.14
16
- Requires-Dist: hbrowser<0.37,>=0.36.3
16
+ Requires-Dist: hbrowser<0.37,>=0.36.4
17
17
  Provides-Extra: dev
18
18
  Requires-Dist: black>=26.5.1; extra == 'dev'
19
- Requires-Dist: mypy>=2.1.0; extra == 'dev'
20
- Requires-Dist: pymarkdownlnt>=0.9.38; extra == 'dev'
19
+ Requires-Dist: mypy>=2.3.0; extra == 'dev'
20
+ Requires-Dist: pymarkdownlnt>=0.9.39; extra == 'dev'
21
21
  Requires-Dist: pytest>=9.1.1; extra == 'dev'
22
- Requires-Dist: ruff>=0.15.20; extra == 'dev'
22
+ Requires-Dist: ruff>=0.16.3; extra == 'dev'
23
23
  Description-Content-Type: text/markdown
24
24
 
25
25
  # HVBrowser
@@ -1,17 +1,17 @@
1
1
  [build-system]
2
- requires = ["hatchling"]
2
+ requires = ["hatchling>=1.32.0"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hvbrowser"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Browser automation APIs for HentaiVerse."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Kuan-Lun Wang" }]
11
11
  license = "AGPL-3.0-or-later"
12
12
  license-files = ["LICENSE"]
13
13
  requires-python = ">=3.14,<3.15"
14
- dependencies = ["hbrowser>=0.36.3,<0.37"]
14
+ dependencies = ["hbrowser>=0.36.4,<0.37"]
15
15
 
16
16
  classifiers = [
17
17
  "Development Status :: 3 - Alpha",
@@ -24,9 +24,9 @@ classifiers = [
24
24
  [project.optional-dependencies]
25
25
  dev = [
26
26
  "black>=26.5.1",
27
- "ruff>=0.15.20",
28
- "mypy>=2.1.0",
29
- "pymarkdownlnt>=0.9.38",
27
+ "ruff>=0.16.3",
28
+ "mypy>=2.3.0",
29
+ "pymarkdownlnt>=0.9.39",
30
30
  "pytest>=9.1.1",
31
31
  ]
32
32
 
@@ -1,27 +1,81 @@
1
1
  """Typed inspection and repair operations for equipped gear."""
2
2
 
3
3
  import logging
4
- import re
5
4
  from dataclasses import dataclass
6
5
  from enum import StrEnum
7
6
  from typing import Any, Protocol
7
+ from urllib.parse import parse_qs, urlsplit
8
8
 
9
9
  from .maintenance_navigation import (
10
10
  MaintenanceNavigationBlockedError,
11
11
  MaintenanceNavigator,
12
+ classify_maintenance_navigation_blocker,
12
13
  )
13
- from .realm import Realm, RealmNavigator
14
+ from .realm import Realm, RealmNavigator, realm_from_url
14
15
  from .runtime import setup_logger
16
+ from .urls import HENTAIVERSE_ISEKAI_ROOT_URL, HENTAIVERSE_ROOT_URL
15
17
 
16
18
  logger = setup_logger(__name__)
17
19
 
18
- _EQUIPMENT_COUNT_XPATH = "//label[@id='equipcount']"
19
- _REPAIR_SUBMIT_XPATH = "//input[@id='equipsubmit']"
20
+ _EQUIPMENT_COUNT_XPATH = "//*[@id='equipform']//label[@id='equipcount']"
21
+ _REPAIR_SUBMIT_XPATH = "//*[@id='equipform']//input[@id='equipsubmit']"
22
+ _ARMORY_MENU_XPATH = (
23
+ "//*[@id='child_Bazaar']"
24
+ "//*[@onclick and contains(@onclick, 's=Bazaar') "
25
+ "and contains(@onclick, 'ss=am')]"
26
+ " | //*[@id='child_Bazaar']//a[contains(@href, 's=Bazaar') "
27
+ "and contains(@href, 'ss=am')]"
28
+ )
20
29
  _REPAIR_TAB_XPATH = (
21
- "//div[contains(@class, 'armory_tab') "
22
- "and contains(normalize-space(.), 'Repair')]"
30
+ "//*[@id='armory_left']/a[contains(@href, 'screen=repair') "
31
+ "and ./*[contains(concat(' ', normalize-space(@class), ' '), "
32
+ "' armory_tab ')]]"
33
+ )
34
+ _REPAIR_SELECTED_PAGE_XPATH = (
35
+ "//*[@id='armory_outer']["
36
+ ".//*[@id='armory_left']/a[contains(@href, 'screen=repair')]"
37
+ "/*[contains(concat(' ', normalize-space(@class), ' '), ' armory_tab ') "
38
+ "and contains(concat(' ', normalize-space(@class), ' '), ' armory_cur ')] "
39
+ "and .//*[@id='filterbar']/a[contains(@href, 'filter=equipped')]"
40
+ "/*[contains(concat(' ', normalize-space(@class), ' '), ' cfbs ')]"
41
+ "]"
23
42
  )
24
- _EQUIPMENT_COUNT_PATTERN = re.compile(r"Selected [0-9]+ of ([0-9]+) matching")
43
+ _EQUIPMENT_STATE_SCRIPT = """
44
+ (() => {
45
+ const equipForm = document.getElementById("equipform");
46
+ const equipList = document.getElementById("equiplist");
47
+ const selectableCount = (
48
+ typeof selectable_count !== "number"
49
+ || !Number.isInteger(selectable_count)
50
+ || selectable_count < 0
51
+ ) ? null : selectable_count;
52
+ return {
53
+ hasEquipForm: Boolean(equipForm),
54
+ hasEquipList: Boolean(equipList),
55
+ selectableCount,
56
+ empty: Boolean(
57
+ equipList
58
+ && (
59
+ equipList.querySelector(".eqempty")
60
+ || equipList.querySelector("tr.eqselall > th > p")
61
+ )
62
+ ),
63
+ rowCount: equipList
64
+ ? equipList.querySelectorAll('tr[onmouseover*="hover_equip"]').length
65
+ : null,
66
+ };
67
+ })()
68
+ """
69
+ _ARMORY_MENU_VISIBILITY_ATTEMPTS = 20
70
+ _ARMORY_MENU_VISIBILITY_INTERVAL_SECONDS = 0.1
71
+ _ARMORY_URLS = {
72
+ Realm.PERSISTENT: (
73
+ f"{HENTAIVERSE_ROOT_URL}/" "?s=Bazaar&ss=am&screen=repair&filter=equipped"
74
+ ),
75
+ Realm.ISEKAI: (
76
+ f"{HENTAIVERSE_ISEKAI_ROOT_URL}" "?s=Bazaar&ss=am&screen=repair&filter=equipped"
77
+ ),
78
+ }
25
79
 
26
80
 
27
81
  class EquipmentRepairOutcome(StrEnum):
@@ -60,6 +114,10 @@ class EquipmentRepairPageError(RuntimeError):
60
114
  """The Armory Repair page did not expose the expected structure."""
61
115
 
62
116
 
117
+ class _EquipmentRepairNavigationSafetyError(EquipmentRepairPageError):
118
+ """The current battle state, origin, or realm could not be trusted."""
119
+
120
+
63
121
  class EquipmentRepairSubmissionError(RuntimeError):
64
122
  """A repair was submitted but its outcome is unknown or unconfirmed."""
65
123
 
@@ -71,6 +129,8 @@ class EquipmentRepairStateChangedError(RuntimeError):
71
129
  class _EquipmentRepairDriver(Protocol):
72
130
  page: Any
73
131
 
132
+ async def get(self, url: str) -> None: ...
133
+
74
134
  async def wait(
75
135
  self,
76
136
  fun: Any,
@@ -193,6 +253,44 @@ class EquipmentRepairClient:
193
253
  )
194
254
 
195
255
  async def _navigate(self, realm: Realm) -> None:
256
+ try:
257
+ await self._open_repair_from_menu(realm)
258
+ return
259
+ except MaintenanceNavigationBlockedError:
260
+ raise
261
+ except _EquipmentRepairNavigationSafetyError:
262
+ raise
263
+ except EquipmentRepairPageError as error:
264
+ logger.warning(
265
+ "Armory menu navigation did not open Repair; retrying through "
266
+ "the realm-scoped direct URL: realm=%s error_type=%s",
267
+ realm.value,
268
+ type(error).__name__,
269
+ )
270
+
271
+ await self._open_repair_directly(realm)
272
+
273
+ async def _open_repair_from_menu(self, realm: Realm) -> None:
274
+ await self._open_armory_from_menu(realm)
275
+ repair = await self._find_repair_tab()
276
+
277
+ try:
278
+ await self.driver.wait(
279
+ repair.click,
280
+ ischangeurl=True,
281
+ )
282
+ except Exception as error:
283
+ try:
284
+ await self._ensure_navigation_is_safe("after opening Repair")
285
+ except MaintenanceNavigationBlockedError as blocked:
286
+ raise blocked from error
287
+ except _EquipmentRepairNavigationSafetyError as safety_error:
288
+ raise safety_error from error
289
+ raise EquipmentRepairPageError("Unable to open Repair tab") from error
290
+
291
+ await self._verify_repair_destination(realm)
292
+
293
+ async def _open_armory_from_menu(self, realm: Realm) -> None:
196
294
  try:
197
295
  bazaar = await self.maintenance.select_bazaar(
198
296
  realm,
@@ -204,9 +302,7 @@ class EquipmentRepairClient:
204
302
  raise EquipmentRepairPageError("Unable to open Bazaar") from error
205
303
 
206
304
  try:
207
- armory_elements = await self.page.xpath(
208
- "//div[contains(text(), 'The Armory')]", timeout=5
209
- )
305
+ armory_elements = await self.page.xpath(_ARMORY_MENU_XPATH, timeout=5)
210
306
  except Exception as error:
211
307
  raise EquipmentRepairPageError(
212
308
  "Unable to find The Armory menu entry"
@@ -214,16 +310,112 @@ class EquipmentRepairClient:
214
310
  if not armory_elements:
215
311
  raise EquipmentRepairPageError("Unable to find The Armory menu entry")
216
312
 
313
+ armory = armory_elements[0]
217
314
  try:
218
315
  await bazaar.mouse_move()
219
- await armory_elements[0].mouse_move()
316
+ await self._wait_for_armory_menu(armory)
317
+ await armory.mouse_move()
220
318
  await self.driver.wait(
221
- armory_elements[0].mouse_click,
319
+ armory.mouse_click,
222
320
  ischangeurl=True,
223
321
  )
322
+ except EquipmentRepairPageError:
323
+ raise
224
324
  except Exception as error:
225
325
  raise EquipmentRepairPageError("Unable to open The Armory") from error
226
326
 
327
+ async def _wait_for_armory_menu(self, armory: Any) -> None:
328
+ last_error: Exception | None = None
329
+ for attempt in range(_ARMORY_MENU_VISIBILITY_ATTEMPTS):
330
+ try:
331
+ position = await armory.get_position()
332
+ except Exception as error:
333
+ last_error = error
334
+ else:
335
+ if position is not None:
336
+ return
337
+
338
+ if attempt + 1 < _ARMORY_MENU_VISIBILITY_ATTEMPTS:
339
+ try:
340
+ await self.page.wait(_ARMORY_MENU_VISIBILITY_INTERVAL_SECONDS)
341
+ except Exception as error:
342
+ raise EquipmentRepairPageError(
343
+ "Unable to wait for The Armory menu entry"
344
+ ) from error
345
+
346
+ page_error = EquipmentRepairPageError(
347
+ "The Armory menu entry did not become visible"
348
+ )
349
+ if last_error is None:
350
+ raise page_error
351
+ raise page_error from last_error
352
+
353
+ async def _open_repair_directly(self, realm: Realm) -> None:
354
+ await self._ensure_navigation_is_safe("before direct Armory navigation")
355
+ try:
356
+ await self.driver.get(_ARMORY_URLS[realm])
357
+ except Exception as error:
358
+ try:
359
+ await self._ensure_navigation_is_safe("after direct Armory navigation")
360
+ except MaintenanceNavigationBlockedError as blocked:
361
+ raise blocked from error
362
+ except _EquipmentRepairNavigationSafetyError as safety_error:
363
+ raise safety_error from error
364
+ raise EquipmentRepairPageError(
365
+ "Unable to open The Armory through its direct URL"
366
+ ) from error
367
+
368
+ await self._verify_repair_destination(realm)
369
+
370
+ async def _ensure_navigation_is_safe(self, context: str) -> None:
371
+ try:
372
+ blocker = await classify_maintenance_navigation_blocker(self.page)
373
+ except Exception as error:
374
+ raise _EquipmentRepairNavigationSafetyError(
375
+ f"Unable to verify battle state {context}"
376
+ ) from error
377
+ if blocker is not None:
378
+ raise MaintenanceNavigationBlockedError(blocker)
379
+
380
+ async def _verify_repair_destination(self, realm: Realm) -> None:
381
+ await self._ensure_navigation_is_safe("after opening Repair")
382
+ try:
383
+ current_url = await self.page.evaluate("window.location.href")
384
+ landed_realm = realm_from_url(current_url)
385
+ except Exception as error:
386
+ raise _EquipmentRepairNavigationSafetyError(
387
+ "Unable to verify the Repair URL"
388
+ ) from error
389
+ if landed_realm is not realm:
390
+ raise _EquipmentRepairNavigationSafetyError(
391
+ "Repair navigation landed in the wrong realm"
392
+ )
393
+ if not isinstance(current_url, str):
394
+ raise _EquipmentRepairNavigationSafetyError("Repair URL is invalid")
395
+ parsed_url = urlsplit(current_url)
396
+ expected_path = "/isekai/" if realm is Realm.ISEKAI else "/"
397
+ if parsed_url.path != expected_path:
398
+ raise _EquipmentRepairNavigationSafetyError(
399
+ "Repair navigation landed on an unexpected path"
400
+ )
401
+ query = parse_qs(parsed_url.query, keep_blank_values=True)
402
+ expected_query = {
403
+ "s": ["Bazaar"],
404
+ "ss": ["am"],
405
+ "screen": ["repair"],
406
+ }
407
+ if any(query.get(key) != value for key, value in expected_query.items()):
408
+ raise EquipmentRepairPageError(
409
+ "Repair navigation did not land on the Repair route"
410
+ )
411
+ filter_values = query.get("filter")
412
+ if filter_values is not None and filter_values != ["equipped"]:
413
+ raise EquipmentRepairPageError(
414
+ "Repair navigation did not land on the Equipped filter"
415
+ )
416
+ await self._verify_repair_selected()
417
+
418
+ async def _find_repair_tab(self) -> Any:
227
419
  try:
228
420
  repair_elements = await self.page.xpath(
229
421
  _REPAIR_TAB_XPATH,
@@ -233,53 +425,71 @@ class EquipmentRepairClient:
233
425
  raise EquipmentRepairPageError("Unable to find Repair tab") from error
234
426
  if not repair_elements:
235
427
  raise EquipmentRepairPageError("Unable to find Repair tab")
428
+ return repair_elements[0]
236
429
 
430
+ async def _verify_repair_selected(self) -> None:
237
431
  try:
238
- await self.driver.wait(
239
- repair_elements[0].click,
240
- ischangeurl=True,
432
+ selected_tabs = await self.page.xpath(
433
+ _REPAIR_SELECTED_PAGE_XPATH,
434
+ timeout=5,
241
435
  )
242
436
  except Exception as error:
243
- raise EquipmentRepairPageError("Unable to open Repair tab") from error
437
+ raise EquipmentRepairPageError(
438
+ "Unable to verify the Armory Repair page"
439
+ ) from error
440
+ if not selected_tabs:
441
+ raise EquipmentRepairPageError(
442
+ "Armory Repair selected-tab marker or Equipped filter marker is missing"
443
+ )
244
444
 
245
445
  async def _inspect_current(
246
446
  self,
247
447
  realm: Realm,
248
448
  ) -> tuple[EquipmentRepairSnapshot, Any | None]:
449
+ await self._verify_repair_destination(realm)
450
+
249
451
  try:
250
- repair_page_markers = await self.page.xpath(
251
- _REPAIR_TAB_XPATH,
452
+ equipcount_elements = await self.page.xpath(
453
+ _EQUIPMENT_COUNT_XPATH,
252
454
  timeout=5,
253
455
  )
254
456
  except Exception as error:
255
457
  raise EquipmentRepairPageError(
256
- "Unable to verify the Armory Repair page"
458
+ "Unable to inspect equipment repair count"
257
459
  ) from error
258
- if not repair_page_markers:
259
- raise EquipmentRepairPageError("Armory Repair page marker is missing")
260
460
 
261
461
  try:
262
- equipcount_elements = await self.page.xpath(
263
- _EQUIPMENT_COUNT_XPATH,
264
- timeout=5,
265
- )
462
+ raw_state = await self.page.evaluate(_EQUIPMENT_STATE_SCRIPT)
266
463
  except Exception as error:
267
464
  raise EquipmentRepairPageError(
268
465
  "Unable to inspect equipment repair count"
269
466
  ) from error
270
- if not equipcount_elements:
467
+ if not isinstance(raw_state, dict):
468
+ raise EquipmentRepairPageError("Equipment repair count state is invalid")
469
+ repair_count = raw_state.get("selectableCount")
470
+ empty = raw_state.get("empty")
471
+ row_count = raw_state.get("rowCount")
472
+ has_equip_form = raw_state.get("hasEquipForm")
473
+ has_equip_list = raw_state.get("hasEquipList")
474
+ if (
475
+ (repair_count is not None and type(repair_count) is not int)
476
+ or type(empty) is not bool
477
+ or (row_count is not None and (type(row_count) is not int or row_count < 0))
478
+ or has_equip_form is not True
479
+ or has_equip_list is not True
480
+ ):
481
+ raise EquipmentRepairPageError("Equipment repair count state is invalid")
482
+ if equipcount_elements:
483
+ if repair_count is None or repair_count < 0:
484
+ raise EquipmentRepairPageError(
485
+ "Equipment repair count state is invalid"
486
+ )
487
+ return EquipmentRepairSnapshot(realm, repair_count), equipcount_elements[0]
488
+ if empty and row_count == 0 and repair_count in {None, 0}:
271
489
  return EquipmentRepairSnapshot(realm, 0), None
272
-
273
- equipcount = equipcount_elements[0]
274
- text = getattr(equipcount, "text", None)
275
- if not isinstance(text, str):
276
- raise EquipmentRepairPageError("Equipment repair count text is missing")
277
- match = _EQUIPMENT_COUNT_PATTERN.search(text)
278
- if match is None:
279
- raise EquipmentRepairPageError(
280
- f"Unable to parse equipment repair count from: {text!r}"
281
- )
282
- return EquipmentRepairSnapshot(realm, int(match.group(1))), equipcount
490
+ raise EquipmentRepairPageError(
491
+ "Equipment repair count is missing without an empty equipment list"
492
+ )
283
493
 
284
494
  async def _select_all_and_inspect_submit(self, equipcount: Any) -> tuple[bool, Any]:
285
495
  logger.debug("Before select-all click: %r", getattr(equipcount, "text", None))