hvbrowser 0.1.0__py3-none-any.whl

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.
hvbrowser/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ __all__ = [
2
+ "HVDriver",
3
+ "SellItems",
4
+ "MarketCategory",
5
+ "MarketClient",
6
+ "MarketItem",
7
+ "MarketPageError",
8
+ "MarketSale",
9
+ "MarketSalePlan",
10
+ "MarketSaleQuote",
11
+ "MarketSaleReport",
12
+ "MarketSnapshot",
13
+ "MarketSubmissionError",
14
+ "HENTAIVERSE_ISEKAI_ROOT_URL",
15
+ "HENTAIVERSE_ROOT_URL",
16
+ ]
17
+
18
+
19
+ from .hv import HVDriver, SellItems
20
+ from .market import (
21
+ MarketCategory,
22
+ MarketClient,
23
+ MarketItem,
24
+ MarketPageError,
25
+ MarketSale,
26
+ MarketSalePlan,
27
+ MarketSaleQuote,
28
+ MarketSaleReport,
29
+ MarketSnapshot,
30
+ MarketSubmissionError,
31
+ )
32
+ from .urls import HENTAIVERSE_ISEKAI_ROOT_URL, HENTAIVERSE_ROOT_URL
hvbrowser/hv.py ADDED
@@ -0,0 +1,410 @@
1
+ import re
2
+ from abc import ABC
3
+ from typing import Any
4
+
5
+ from hbrowser.gallery import EHDriver
6
+ from hbrowser.gallery.utils import setup_logger
7
+
8
+ from .market import (
9
+ MarketCategory,
10
+ MarketClient,
11
+ MarketSalePlan,
12
+ MarketSaleReport,
13
+ MarketSnapshot,
14
+ )
15
+ from .urls import HENTAIVERSE_ISEKAI_ROOT_URL, HENTAIVERSE_ROOT_URL
16
+
17
+ logger = setup_logger(__name__)
18
+
19
+
20
+ def genxpath(imagepath: str) -> str:
21
+ return f'//img[@src="{imagepath}"]'
22
+
23
+
24
+ class BSItems(ABC):
25
+ def __init__(
26
+ self,
27
+ consumables: list[str] | None = None,
28
+ materials: list[str] | None = None,
29
+ trophies: list[str] | None = None,
30
+ artifacts: list[str] | None = None,
31
+ figures: list[str] | None = None,
32
+ monster_items: list[str] | None = None,
33
+ ) -> None:
34
+ self.consumables = consumables or []
35
+ self.materials = materials or []
36
+ self.trophies = trophies or []
37
+ self.artifacts = artifacts or []
38
+ self.figures = figures or []
39
+ self.monster_items = monster_items or []
40
+
41
+ def market_names(self) -> dict[MarketCategory, tuple[str, ...]]:
42
+ return {
43
+ MarketCategory.CONSUMABLES: tuple(self.consumables),
44
+ MarketCategory.MATERIALS: tuple(self.materials),
45
+ MarketCategory.TROPHIES: tuple(self.trophies),
46
+ MarketCategory.ARTIFACTS: tuple(self.artifacts),
47
+ MarketCategory.FIGURES: tuple(self.figures),
48
+ MarketCategory.MONSTER_ITEMS: tuple(self.monster_items),
49
+ }
50
+
51
+
52
+ class SellItems(BSItems):
53
+ pass
54
+
55
+
56
+ class BuyItems(BSItems):
57
+ pass
58
+
59
+
60
+ class HVDriver(EHDriver):
61
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
62
+ super().__init__(*args, **kwargs)
63
+ self.url[self.name] = HENTAIVERSE_ROOT_URL
64
+ self.url["HentaiVerse isekai"] = HENTAIVERSE_ISEKAI_ROOT_URL
65
+
66
+ def _setname(self) -> str:
67
+ return "HentaiVerse"
68
+
69
+ @property
70
+ async def is_isekai(self) -> bool:
71
+ url = await self.page.evaluate("window.location.href")
72
+ return "isekai" in url
73
+
74
+ async def _get_path_prefix(self) -> str:
75
+ return "/isekai" if await self.is_isekai else ""
76
+
77
+ def searchxpath(self, srclist: list[Any] | tuple[Any, ...] | set[Any]) -> str:
78
+ return " | ".join([genxpath(imagepath) for imagepath in srclist])
79
+
80
+ async def goisekai(self) -> None:
81
+ logger.info("Navigating to HentaiVerse isekai page")
82
+ await self.get(HENTAIVERSE_ISEKAI_ROOT_URL)
83
+
84
+ async def inspect_market(self) -> MarketSnapshot:
85
+ """Return a read-only snapshot of the visible Market inventory."""
86
+ return await MarketClient(self).inspect(is_isekai=await self.is_isekai)
87
+
88
+ async def plan_market_sales(self, sellitems: SellItems) -> MarketSalePlan:
89
+ """Build a sale plan without clicking or submitting Market controls."""
90
+ return await MarketClient(self).plan_sales(
91
+ sellitems.market_names(), is_isekai=await self.is_isekai
92
+ )
93
+
94
+ async def validate_market_sales(self, plan: MarketSalePlan) -> None:
95
+ """Validate sale-form selectors without changing Market state."""
96
+ await MarketClient(self).validate_sale_forms(plan)
97
+
98
+ async def submit_market_sales(self, plan: MarketSalePlan) -> MarketSaleReport:
99
+ """Submit a previously reviewed sale plan."""
100
+ return await MarketClient(self).submit_sales(plan)
101
+
102
+ async def loetterycheck(self, num: int) -> None:
103
+ logger.info(f"Checking lottery tickets (target: {num})")
104
+ await self.gohomepage()
105
+
106
+ for lettory in ["Weapon Lottery", "Armor Lottery"]:
107
+ logger.debug(f"Processing {lettory}")
108
+ bazaar = await self.page.select("#parent_Bazaar")
109
+ lettory_xpath = f"//div[contains(text(), '{lettory}')]"
110
+ lettory_elements = await self.page.xpath(lettory_xpath, timeout=5)
111
+ if not lettory_elements:
112
+ continue
113
+ lettory_elem = lettory_elements[0]
114
+
115
+ # Hover bazaar then click lottery
116
+ await bazaar.mouse_move()
117
+ await lettory_elem.mouse_move()
118
+ await lettory_elem.mouse_click()
119
+ await self.page.wait(1)
120
+
121
+ currently_elements = await self.page.xpath(
122
+ "//*[contains(text(), 'You currently have')]", timeout=5
123
+ )
124
+ if not currently_elements:
125
+ continue
126
+ numbers: list[str] = re.findall(r"[\d,]+", currently_elements[0].text)
127
+ currently_number = numbers[0].replace(",", "")
128
+
129
+ hold_elements = await self.page.xpath(
130
+ "//*[contains(text(), 'You hold')]", timeout=5
131
+ )
132
+ if not hold_elements:
133
+ continue
134
+ numbers = re.findall(r"[\d,]+", hold_elements[0].text)
135
+ buy_number = numbers[0].replace(",", "")
136
+
137
+ logger.info(
138
+ f"{lettory}: Currently have {currently_number} credits, "
139
+ f"hold {buy_number} tickets"
140
+ )
141
+
142
+ if int(buy_number) < num and int(currently_number) > (num * 1000):
143
+ purchase_amount = num - int(buy_number)
144
+ logger.info(f"Purchasing {purchase_amount} tickets for {lettory}")
145
+ html_element = await self.page.select("#ticket_temp")
146
+ await html_element.clear_input()
147
+ await html_element.send_keys(str(purchase_amount))
148
+ await self.page.evaluate("submit_buy()")
149
+ else:
150
+ logger.debug(
151
+ f"No purchase needed for {lettory} "
152
+ f"(tickets: {buy_number}, credits: {currently_number})"
153
+ )
154
+
155
+ async def get_stamina(self) -> int:
156
+ stamina_elements = await self.page.xpath(
157
+ "//div[contains(text(), 'Stamina:')]", timeout=5
158
+ )
159
+ if not stamina_elements:
160
+ raise ValueError("Unable to find stamina readout")
161
+ match = re.search(r"Stamina:\s*(\d+)", stamina_elements[0].text)
162
+ if not match:
163
+ raise ValueError(
164
+ f"Unable to parse stamina from: {stamina_elements[0].text!r}"
165
+ )
166
+ return int(match.group(1))
167
+
168
+ async def recoverstamina(self) -> bool:
169
+ logger.info("Checking USR RESTORATIVE availability for stamina recovery")
170
+
171
+ stamina_readout = await self.page.select("#stamina_readout")
172
+ await stamina_readout.mouse_move()
173
+
174
+ restorative_elements = await self.page.xpath(
175
+ "//img[@onclick=\"document.getElementById('recoverform').submit()\"]",
176
+ timeout=5,
177
+ )
178
+ if not restorative_elements:
179
+ logger.debug("USR RESTORATIVE is not available")
180
+ return False
181
+
182
+ restorative_img = restorative_elements[0]
183
+ await restorative_img.mouse_move()
184
+ await restorative_img.mouse_click()
185
+ await self.page.wait(1)
186
+
187
+ error_elements = await self.page.xpath(
188
+ "//p[contains(@class, 'messagebox_error')]", timeout=2
189
+ )
190
+ if error_elements:
191
+ logger.warning(f"USR RESTORATIVE failed: {error_elements[0].text}")
192
+ await error_elements[0].click()
193
+ return False
194
+
195
+ logger.info("Used USR RESTORATIVE to recover stamina")
196
+ return True
197
+
198
+ async def _select_all_and_check_repair_submit(
199
+ self, equipcount_elements: list[Any]
200
+ ) -> tuple[bool | None, list[Any]]:
201
+ """全選裝備並回報 repair submit 按鈕狀態。
202
+
203
+ 回傳 (is_disabled, submit_elements):
204
+ is_disabled 為 None 代表找不到 submit 按鈕,呼叫端應視為無需處理。
205
+ """
206
+ logger.debug(f"Before select_all click: {equipcount_elements[0].text!r}")
207
+ await self.wait(equipcount_elements[0].mouse_click, ischangeurl=False)
208
+
209
+ equipcount_after = await self.page.xpath("//label[@id='equipcount']", timeout=5)
210
+ if equipcount_after:
211
+ logger.debug(f"After select_all click: {equipcount_after[0].text!r}")
212
+
213
+ submit_elements = await self.page.xpath("//input[@id='equipsubmit']", timeout=5)
214
+ if not submit_elements:
215
+ logger.warning("Unable to find equipment repair submit button")
216
+ return None, []
217
+
218
+ is_disabled = await self.page.evaluate(
219
+ "document.getElementById('equipsubmit').disabled"
220
+ )
221
+ if is_disabled:
222
+ debug_state = await self.page.evaluate("""
223
+ JSON.stringify({
224
+ selected_count: selected_count,
225
+ selectable_count: selectable_count,
226
+ block_submit: block_submit,
227
+ materials: (() => {
228
+ const totals = {};
229
+ for (const el of document.querySelectorAll('input[name="eqids[]"]')) {
230
+ if (el.checked && eqitems[el.value]) {
231
+ for (const m in eqitems[el.value].m) {
232
+ totals[m] = (totals[m] || 0) + eqitems[el.value].m[m];
233
+ }
234
+ }
235
+ }
236
+ return Object.entries(totals).map(([id, need]) => ({
237
+ id,
238
+ name: itemdata[id] ? itemdata[id].n : undefined,
239
+ need,
240
+ have: itemdata[id] ? itemdata[id].c : undefined,
241
+ }));
242
+ })(),
243
+ })
244
+ """)
245
+ logger.warning(f"Not enough materials to repair equipment: {debug_state}")
246
+
247
+ return bool(is_disabled), submit_elements
248
+
249
+ async def _goto_repair_tab(self) -> bool:
250
+ """導航到 Bazaar -> The Armory -> Repair 頁籤。成功回傳 True。"""
251
+ try:
252
+ bazaar = await self.page.select("#parent_Bazaar")
253
+ except TimeoutError:
254
+ logger.warning(
255
+ "Timed out waiting for #parent_Bazaar; homepage may not have "
256
+ "finished loading yet, reloading and retrying once"
257
+ )
258
+ await self.gohomepage(force=True)
259
+ bazaar = await self.page.select("#parent_Bazaar")
260
+ armory_elements = await self.page.xpath(
261
+ "//div[contains(text(), 'The Armory')]", timeout=5
262
+ )
263
+ if not armory_elements:
264
+ logger.warning("Unable to find The Armory entry")
265
+ return False
266
+
267
+ await bazaar.mouse_move()
268
+ await armory_elements[0].mouse_move()
269
+ await self.wait(armory_elements[0].mouse_click, ischangeurl=True)
270
+
271
+ repair_elements = await self.page.xpath(
272
+ "//div[contains(@class, 'armory_tab') and contains(text(), 'Repair')]",
273
+ timeout=5,
274
+ )
275
+ if not repair_elements:
276
+ logger.warning("Unable to find Repair tab")
277
+ return False
278
+ await self.wait(repair_elements[0].click, ischangeurl=True)
279
+ return True
280
+
281
+ async def repairequipment(self) -> bool:
282
+ logger.info("Checking equipped gear for repairs")
283
+ if not await self._goto_repair_tab():
284
+ return True
285
+
286
+ equipcount_elements = await self.page.xpath(
287
+ "//label[@id='equipcount']", timeout=5
288
+ )
289
+ if not equipcount_elements:
290
+ logger.debug("No equipment needs repair")
291
+ return True
292
+
293
+ match = re.search(
294
+ r"Selected \d+ of (\d+) matching", equipcount_elements[0].text
295
+ )
296
+ if not match or int(match.group(1)) == 0:
297
+ logger.debug("No equipment needs repair")
298
+ return True
299
+
300
+ is_disabled, submit_elements = await self._select_all_and_check_repair_submit(
301
+ equipcount_elements
302
+ )
303
+ if is_disabled is None:
304
+ return True
305
+
306
+ if is_disabled:
307
+ logger.debug("Re-entering Repair tab to verify against fresh server state")
308
+ if not await self._goto_repair_tab():
309
+ return True
310
+
311
+ equipcount_reentered = await self.page.xpath(
312
+ "//label[@id='equipcount']", timeout=5
313
+ )
314
+ if not equipcount_reentered:
315
+ logger.debug("No equipment needs repair after re-entering Repair tab")
316
+ return True
317
+
318
+ is_disabled, submit_elements = (
319
+ await self._select_all_and_check_repair_submit(equipcount_reentered)
320
+ )
321
+ if is_disabled is None:
322
+ return True
323
+ if is_disabled:
324
+ logger.error(
325
+ "Still not enough materials to repair equipment "
326
+ "after re-entering Repair tab"
327
+ )
328
+ return False
329
+ logger.info(
330
+ "Repair submit was enabled after re-entering Repair tab; "
331
+ "the earlier disabled check was stale"
332
+ )
333
+
334
+ await submit_elements[0].mouse_click()
335
+ await self.page.wait(2)
336
+
337
+ equipcount_after_submit = await self.page.xpath(
338
+ "//label[@id='equipcount']", timeout=5
339
+ )
340
+ remaining = 0
341
+ if equipcount_after_submit:
342
+ match_after_submit = re.search(
343
+ r"Selected \d+ of (\d+) matching", equipcount_after_submit[0].text
344
+ )
345
+ if match_after_submit:
346
+ remaining = int(match_after_submit.group(1))
347
+
348
+ if remaining:
349
+ logger.error(
350
+ f"Repair submitted but {remaining} pieces of equipment still need repair"
351
+ )
352
+ return False
353
+
354
+ logger.info("Repaired equipment")
355
+ return True
356
+
357
+ async def monstercheck(self) -> None:
358
+ logger.info("Starting monster check")
359
+ await self.gohomepage()
360
+
361
+ # 進入 Monster Lab
362
+ logger.debug("Navigating to Monster Lab")
363
+ bazaar = await self.page.select("#parent_Bazaar")
364
+ monster_lab_elements = await self.page.xpath(
365
+ "//div[contains(text(), 'Monster Lab')]", timeout=5
366
+ )
367
+ if not monster_lab_elements:
368
+ return
369
+ monster_lab = monster_lab_elements[0]
370
+
371
+ await bazaar.mouse_move()
372
+ await monster_lab.mouse_move()
373
+ await monster_lab.mouse_click()
374
+ await self.page.wait(1)
375
+
376
+ path_prefix = await self._get_path_prefix()
377
+ feed_actions = {"feed": "food", "drug": "drugs"}
378
+ for action, resource in feed_actions.items():
379
+ xpath = self.searchxpath(
380
+ [f"{path_prefix}/y/monster/{action}allmonsters.png"]
381
+ )
382
+ images = await self.page.xpath(xpath, timeout=2)
383
+ if images:
384
+ logger.info("Feeding all monsters with %s", resource)
385
+ await self.page.evaluate(f"do_feed_all('{resource}')")
386
+ await self.page.wait(10)
387
+ else:
388
+ logger.debug("No feed all option available for %s", resource)
389
+
390
+ async def marketcheck(
391
+ self, sellitems: SellItems, *, commit: bool = False
392
+ ) -> MarketSalePlan | MarketSaleReport:
393
+ """Plan requested Market sales and submit only with explicit consent.
394
+
395
+ The default is read-only. This workflow never deposits credits and
396
+ never re-lists unrelated existing orders.
397
+ """
398
+ plan = await self.plan_market_sales(sellitems)
399
+ logger.info(
400
+ "Market sale plan contains %d item types and %d units",
401
+ len(plan.items),
402
+ plan.total_units,
403
+ )
404
+ if not commit:
405
+ return plan
406
+
407
+ await self.validate_market_sales(plan)
408
+ report = await self.submit_market_sales(plan)
409
+ logger.info("Market submitted %d verified item sales", len(report.sales))
410
+ return report
@@ -0,0 +1,111 @@
1
+ """Credential-safe, read-only smoke checks for a configured account."""
2
+
3
+ import os
4
+ from collections import Counter
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass
7
+
8
+ from .hv import HVDriver
9
+ from .market import MarketCategory, MarketClient, MarketPageError
10
+
11
+
12
+ class LiveProbeRefused(RuntimeError):
13
+ """A live probe was stopped before browser construction."""
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class MarketCategorySummary:
18
+ category: MarketCategory
19
+ item_types: int
20
+ stocked_item_types: int
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class MarketQuoteSummary:
25
+ category: MarketCategory
26
+ item_id: int
27
+ stock: int
28
+ sell_order_id: int
29
+ order_text: str
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class LiveProbeResult:
34
+ stamina: int
35
+ is_isekai: bool
36
+ market: tuple[MarketCategorySummary, ...]
37
+ quote: MarketQuoteSummary | None
38
+
39
+
40
+ def validate_live_environment(
41
+ environment: Mapping[str, str] | None = None,
42
+ ) -> None:
43
+ """Require credential indirection without selecting an account profile."""
44
+ current = os.environ if environment is None else environment
45
+ missing = [name for name in ("EH_USERNAME", "EH_PASSWORD") if not current.get(name)]
46
+ if missing:
47
+ raise LiveProbeRefused(
48
+ "Missing credential environment variables: " + ", ".join(missing)
49
+ )
50
+
51
+
52
+ async def run_live_probe(
53
+ *,
54
+ inspect_market_form: bool = False,
55
+ driver_factory: Callable[[], HVDriver] | None = None,
56
+ environment: Mapping[str, str] | None = None,
57
+ ) -> LiveProbeResult:
58
+ """Log in and inspect non-battle state without performing any mutation."""
59
+ validate_live_environment(environment)
60
+ create_driver = driver_factory or (lambda: HVDriver(headless=True))
61
+
62
+ async with create_driver() as driver:
63
+ battle_markers = await driver.page.xpath("//*[@id='battle_main']", timeout=2)
64
+ if battle_markers:
65
+ raise LiveProbeRefused(
66
+ "An active battle was detected; the read-only probe stopped"
67
+ )
68
+
69
+ stamina = await driver.get_stamina()
70
+ is_isekai = await driver.is_isekai
71
+ market_client = MarketClient(driver)
72
+ snapshot = await market_client.inspect(is_isekai=is_isekai)
73
+
74
+ item_counts = Counter(item.category for item in snapshot.items)
75
+ stocked_counts = Counter(
76
+ item.category for item in snapshot.items if item.stock > 0
77
+ )
78
+ categories = tuple(
79
+ MarketCategorySummary(
80
+ category=category,
81
+ item_types=item_counts[category],
82
+ stocked_item_types=stocked_counts[category],
83
+ )
84
+ for category in MarketCategory
85
+ if item_counts[category]
86
+ )
87
+
88
+ quote_summary: MarketQuoteSummary | None = None
89
+ if inspect_market_form:
90
+ for item in (item for item in snapshot.items if item.stock > 0):
91
+ try:
92
+ quote = await market_client.inspect_sale_quote(
93
+ item, is_isekai=is_isekai
94
+ )
95
+ except MarketPageError:
96
+ continue
97
+ quote_summary = MarketQuoteSummary(
98
+ category=item.category,
99
+ item_id=item.item_id,
100
+ stock=quote.current_stock,
101
+ sell_order_id=quote.sell_order_id,
102
+ order_text=quote.order_text,
103
+ )
104
+ break
105
+
106
+ return LiveProbeResult(
107
+ stamina=stamina,
108
+ is_isekai=is_isekai,
109
+ market=categories,
110
+ quote=quote_summary,
111
+ )
hvbrowser/market.py ADDED
@@ -0,0 +1,353 @@
1
+ import re
2
+ from collections.abc import Collection, Mapping
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from typing import Any, Protocol
6
+
7
+ from .urls import HENTAIVERSE_ISEKAI_ROOT_URL, HENTAIVERSE_ROOT_URL
8
+
9
+ MARKET_ROOT_URL = f"{HENTAIVERSE_ROOT_URL}/"
10
+ ISEKAI_MARKET_ROOT_URL = HENTAIVERSE_ISEKAI_ROOT_URL
11
+
12
+
13
+ class MarketCategory(StrEnum):
14
+ CONSUMABLES = "co"
15
+ MATERIALS = "ma"
16
+ TROPHIES = "tr"
17
+ ARTIFACTS = "ar"
18
+ FIGURES = "fi"
19
+ MONSTER_ITEMS = "mo"
20
+
21
+
22
+ _CATEGORY_LABELS: dict[MarketCategory, str] = {
23
+ MarketCategory.CONSUMABLES: "Consumables",
24
+ MarketCategory.MATERIALS: "Materials",
25
+ MarketCategory.TROPHIES: "Trophies",
26
+ MarketCategory.ARTIFACTS: "Artifacts",
27
+ MarketCategory.FIGURES: "Figures",
28
+ MarketCategory.MONSTER_ITEMS: "Monster Items",
29
+ }
30
+
31
+ PERSISTENT_MARKET_CATEGORIES = tuple(MarketCategory)
32
+ ISEKAI_MARKET_CATEGORIES = (
33
+ MarketCategory.CONSUMABLES,
34
+ MarketCategory.MATERIALS,
35
+ MarketCategory.TROPHIES,
36
+ )
37
+
38
+ # The selectors and stock-confirmation path are covered offline, but the live
39
+ # meaning of the selected sell-side quote has not yet been confirmed against
40
+ # the current authenticated Market DOM. Keep every public submission entry
41
+ # point fail-closed until that read-only verification has happened.
42
+ _MARKET_SUBMISSION_VERIFIED = False
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class MarketItem:
47
+ category: MarketCategory
48
+ item_id: int
49
+ name: str
50
+ stock: int
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class MarketSnapshot:
55
+ is_isekai: bool
56
+ items: tuple[MarketItem, ...]
57
+
58
+ def items_in(self, category: MarketCategory) -> tuple[MarketItem, ...]:
59
+ return tuple(item for item in self.items if item.category is category)
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class MarketSalePlan:
64
+ """A read-only snapshot of inventory selected for sale."""
65
+
66
+ is_isekai: bool
67
+ items: tuple[MarketItem, ...]
68
+
69
+ @property
70
+ def total_units(self) -> int:
71
+ return sum(item.stock for item in self.items)
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class MarketSaleQuote:
76
+ """Read-only pricing evidence from the first visible sell-side order."""
77
+
78
+ item: MarketItem
79
+ sell_order_id: int
80
+ order_text: str
81
+ current_stock: int
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class MarketSale:
86
+ item: MarketItem
87
+ remaining_stock: int
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class MarketSaleReport:
92
+ is_isekai: bool
93
+ sales: tuple[MarketSale, ...]
94
+
95
+
96
+ class MarketPageError(RuntimeError):
97
+ """The Market page did not expose the expected read-only structure."""
98
+
99
+
100
+ class MarketSubmissionError(RuntimeError):
101
+ """The Market rejected a sale or did not confirm the inventory change."""
102
+
103
+
104
+ class _MarketDriver(Protocol):
105
+ page: Any
106
+
107
+ async def get(self, url: str) -> None: ...
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class _SaleForm:
112
+ sell_order_id: int
113
+ order_text: str
114
+ current_stock: int
115
+ stock_control: Any
116
+ update_button: Any
117
+
118
+
119
+ def market_browse_url(category: MarketCategory, *, is_isekai: bool) -> str:
120
+ if is_isekai and category not in ISEKAI_MARKET_CATEGORIES:
121
+ raise ValueError(f"{market_category_label(category)} is unavailable in Isekai")
122
+ root = ISEKAI_MARKET_ROOT_URL if is_isekai else MARKET_ROOT_URL
123
+ return f"{root}?s=Bazaar&ss=mk&screen=browseitems&filter={category.value}"
124
+
125
+
126
+ def market_item_url(category: MarketCategory, item_id: int, *, is_isekai: bool) -> str:
127
+ return f"{market_browse_url(category, is_isekai=is_isekai)}&itemid={item_id}"
128
+
129
+
130
+ def market_category_label(category: MarketCategory) -> str:
131
+ return _CATEGORY_LABELS[category]
132
+
133
+
134
+ def parse_market_item_id(onclick: str) -> int:
135
+ patterns = (
136
+ r"[?&]itemid=(\d+)",
137
+ r"\bitemid\D+(\d+)",
138
+ r"\bselect_market_item\(\s*(\d+)\s*\)",
139
+ )
140
+ for pattern in patterns:
141
+ match = re.search(pattern, onclick)
142
+ if match:
143
+ return int(match.group(1))
144
+ raise MarketPageError(
145
+ f"Unable to parse Market item id from row action: {onclick!r}"
146
+ )
147
+
148
+
149
+ def parse_market_stock(text: str) -> int:
150
+ normalized = text.strip().replace(",", "")
151
+ if not normalized:
152
+ return 0
153
+ match = re.search(r"\d+", normalized)
154
+ if not match:
155
+ raise MarketPageError(f"Unable to parse Market stock value: {text!r}")
156
+ return int(match.group(0))
157
+
158
+
159
+ def parse_market_sell_order_id(onclick: str) -> int:
160
+ match = re.search(
161
+ r"\bautofill_from_sell_order\(\s*(\d+)\s*,\s*0\s*,\s*0\s*\)",
162
+ onclick,
163
+ )
164
+ if not match:
165
+ raise MarketPageError(
166
+ f"Unable to parse Market sell-order id from action: {onclick!r}"
167
+ )
168
+ return int(match.group(1))
169
+
170
+
171
+ class MarketClient:
172
+ """Market inspection, explicit sale planning, and verified submission."""
173
+
174
+ def __init__(self, driver: _MarketDriver) -> None:
175
+ self._driver = driver
176
+
177
+ async def inspect(self, *, is_isekai: bool) -> MarketSnapshot:
178
+ categories = (
179
+ ISEKAI_MARKET_CATEGORIES if is_isekai else PERSISTENT_MARKET_CATEGORIES
180
+ )
181
+ items: list[MarketItem] = []
182
+ for category in categories:
183
+ items.extend(await self._inspect_category(category, is_isekai=is_isekai))
184
+ return MarketSnapshot(is_isekai=is_isekai, items=tuple(items))
185
+
186
+ async def plan_sales(
187
+ self,
188
+ requested_names: Mapping[MarketCategory, Collection[str]],
189
+ *,
190
+ is_isekai: bool,
191
+ ) -> MarketSalePlan:
192
+ """Select stocked inventory without changing Market state."""
193
+ snapshot = await self.inspect(is_isekai=is_isekai)
194
+ normalized_requests = {
195
+ category: {name.casefold() for name in names}
196
+ for category, names in requested_names.items()
197
+ }
198
+ selected = tuple(
199
+ item
200
+ for item in snapshot.items
201
+ if item.stock > 0
202
+ and item.name.casefold() in normalized_requests.get(item.category, set())
203
+ )
204
+ return MarketSalePlan(is_isekai=is_isekai, items=selected)
205
+
206
+ async def validate_sale_forms(self, plan: MarketSalePlan) -> None:
207
+ """Verify every planned item form without clicking or submitting it."""
208
+ for item in plan.items:
209
+ await self.inspect_sale_quote(item, is_isekai=plan.is_isekai)
210
+
211
+ async def inspect_sale_quote(
212
+ self, item: MarketItem, *, is_isekai: bool
213
+ ) -> MarketSaleQuote:
214
+ """Inspect the current sell-side pricing source without clicking it."""
215
+ sale_form = await self._open_sale_form(item, is_isekai=is_isekai)
216
+ return MarketSaleQuote(
217
+ item=item,
218
+ sell_order_id=sale_form.sell_order_id,
219
+ order_text=sale_form.order_text,
220
+ current_stock=sale_form.current_stock,
221
+ )
222
+
223
+ async def submit_sales(self, plan: MarketSalePlan) -> MarketSaleReport:
224
+ """Submit an explicitly prepared plan and verify each stock change.
225
+
226
+ This operation never deposits credits and never touches existing sell
227
+ orders that are not present in ``plan``.
228
+ """
229
+ if not _MARKET_SUBMISSION_VERIFIED:
230
+ raise MarketSubmissionError(
231
+ "Market submission is disabled until the current live quote "
232
+ "and pricing semantics have been verified read-only"
233
+ )
234
+
235
+ return await self._submit_verified_sales(plan)
236
+
237
+ async def _submit_verified_sales(self, plan: MarketSalePlan) -> MarketSaleReport:
238
+ """Execute the guarded submission algorithm after live verification."""
239
+ sales: list[MarketSale] = []
240
+ for item in plan.items:
241
+ sale_form = await self._open_sale_form(item, is_isekai=plan.is_isekai)
242
+ if sale_form.current_stock != item.stock:
243
+ raise MarketSubmissionError(
244
+ f"Market sale plan is stale for {item.name!r}: "
245
+ f"planned={item.stock}, current={sale_form.current_stock}"
246
+ )
247
+ await self._driver.page.evaluate(
248
+ f"autofill_from_sell_order({sale_form.sell_order_id},0,0);"
249
+ )
250
+ await sale_form.stock_control.click()
251
+ await sale_form.update_button.click()
252
+ await self._driver.page.wait(1)
253
+ await self._raise_for_submission_error(item)
254
+
255
+ remaining_stock = await self._read_item_stock(
256
+ item, is_isekai=plan.is_isekai
257
+ )
258
+ if remaining_stock != 0:
259
+ raise MarketSubmissionError(
260
+ f"Market did not sell all planned stock for {item.name!r}: "
261
+ f"before={item.stock}, after={remaining_stock}"
262
+ )
263
+ sales.append(MarketSale(item=item, remaining_stock=remaining_stock))
264
+ return MarketSaleReport(is_isekai=plan.is_isekai, sales=tuple(sales))
265
+
266
+ async def _open_sale_form(self, item: MarketItem, *, is_isekai: bool) -> _SaleForm:
267
+ await self._driver.get(
268
+ market_item_url(item.category, item.item_id, is_isekai=is_isekai)
269
+ )
270
+ try:
271
+ sell_order_cells = await self._driver.page.xpath(
272
+ "//*[@id='market_itemsell']"
273
+ "//td[contains(@onclick, 'autofill_from_sell_order')]",
274
+ timeout=5,
275
+ )
276
+ stock_control = await self._driver.page.select(
277
+ "#sell_order_stock_field > span", timeout=5
278
+ )
279
+ update_button = await self._driver.page.select(
280
+ "#sellorder_update", timeout=5
281
+ )
282
+ except TimeoutError as error:
283
+ raise MarketPageError(
284
+ f"Market sale form is missing for {item.name!r}"
285
+ ) from error
286
+ if not sell_order_cells:
287
+ raise MarketPageError(
288
+ f"Market has no existing sell order to price {item.name!r}"
289
+ )
290
+ onclick = str(sell_order_cells[0].attrs.get("onclick", ""))
291
+ return _SaleForm(
292
+ sell_order_id=parse_market_sell_order_id(onclick),
293
+ order_text=sell_order_cells[0].text.strip(),
294
+ current_stock=parse_market_stock(stock_control.text),
295
+ stock_control=stock_control,
296
+ update_button=update_button,
297
+ )
298
+
299
+ async def _read_item_stock(self, item: MarketItem, *, is_isekai: bool) -> int:
300
+ await self._driver.get(
301
+ market_item_url(item.category, item.item_id, is_isekai=is_isekai)
302
+ )
303
+ try:
304
+ stock_control = await self._driver.page.select(
305
+ "#sell_order_stock_field > span", timeout=5
306
+ )
307
+ except TimeoutError as error:
308
+ raise MarketSubmissionError(
309
+ f"Market stock confirmation is missing for {item.name!r}"
310
+ ) from error
311
+ return parse_market_stock(stock_control.text)
312
+
313
+ async def _raise_for_submission_error(self, item: MarketItem) -> None:
314
+ try:
315
+ error_message = await self._driver.page.select(
316
+ "#messagebox_inner p.messagebox_error", timeout=1
317
+ )
318
+ except TimeoutError:
319
+ return
320
+ message = error_message.text.strip() or "unknown Market error"
321
+ raise MarketSubmissionError(f"Market rejected {item.name!r}: {message}")
322
+
323
+ async def _inspect_category(
324
+ self, category: MarketCategory, *, is_isekai: bool
325
+ ) -> list[MarketItem]:
326
+ await self._driver.get(market_browse_url(category, is_isekai=is_isekai))
327
+ try:
328
+ item_list = await self._driver.page.select("#market_itemlist", timeout=5)
329
+ except TimeoutError as error:
330
+ raise MarketPageError(
331
+ f"Market item list is missing for {market_category_label(category)}"
332
+ ) from error
333
+
334
+ rows = await item_list.query_selector_all("table > tbody > tr[onclick]")
335
+ items: list[MarketItem] = []
336
+ for row in rows:
337
+ cells = await row.query_selector_all("td")
338
+ if len(cells) < 2:
339
+ raise MarketPageError(
340
+ f"Market row has fewer than two cells in "
341
+ f"{market_category_label(category)}"
342
+ )
343
+ name = cells[0].text.strip()
344
+ onclick = str(row.attrs.get("onclick", ""))
345
+ items.append(
346
+ MarketItem(
347
+ category=category,
348
+ item_id=parse_market_item_id(onclick),
349
+ name=name,
350
+ stock=parse_market_stock(cells[1].text),
351
+ )
352
+ )
353
+ return items
hvbrowser/py.typed ADDED
File without changes
hvbrowser/runtime.py ADDED
@@ -0,0 +1,17 @@
1
+ """Shared browser primitives used by HentaiVerse domain packages.
2
+
3
+ This module is the intentional boundary through which higher-level HV packages
4
+ access the underlying browser runtime. Keeping that boundary here avoids
5
+ requiring those packages to depend on ``hbrowser`` directly.
6
+ """
7
+
8
+ from hbrowser.gallery.element_action import ElementAction
9
+ from hbrowser.gallery.utils import is_connection_error, setup_logger
10
+ from hbrowser.notify import notify
11
+
12
+ __all__ = [
13
+ "ElementAction",
14
+ "is_connection_error",
15
+ "notify",
16
+ "setup_logger",
17
+ ]
hvbrowser/urls.py ADDED
@@ -0,0 +1,4 @@
1
+ """Canonical HentaiVerse realm roots owned by the domain package."""
2
+
3
+ HENTAIVERSE_ROOT_URL = "https://hentaiverse.org"
4
+ HENTAIVERSE_ISEKAI_ROOT_URL = f"{HENTAIVERSE_ROOT_URL}/isekai/"
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: hvbrowser
3
+ Version: 0.1.0
4
+ Summary: Browser automation APIs for HentaiVerse.
5
+ Project-URL: Homepage, https://github.com/Kuan-Lun/hvbrowser
6
+ Project-URL: Tracker, https://github.com/Kuan-Lun/hvbrowser/issues
7
+ Author: Kuan-Lun Wang
8
+ License: GNU Affero General Public License v3
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Python: <3.15,>=3.14
15
+ Requires-Dist: hbrowser<0.36,>=0.35.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: black>=26.5.1; extra == 'dev'
18
+ Requires-Dist: mypy>=2.1.0; extra == 'dev'
19
+ Requires-Dist: pymarkdownlnt>=0.9.38; extra == 'dev'
20
+ Requires-Dist: pytest>=9.1.1; extra == 'dev'
21
+ Requires-Dist: ruff>=0.15.20; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # HVBrowser
25
+
26
+ HVBrowser provides browser automation APIs for HentaiVerse. It builds on
27
+ `hbrowser` for the shared authenticated browser session.
28
+
29
+ The package was extracted from the historical combined `hbrowser`
30
+ distribution. Battle-domain APIs live in the separate `hvbattle` package.
31
+
32
+ ## Development
33
+
34
+ Build a clean environment backed by the PyPI release of `hbrowser`:
35
+
36
+ ```bash
37
+ bash scripts/rebuild-env.sh
38
+ ```
39
+
40
+ For coordinated local development, overlay the clean environment manually:
41
+
42
+ ```bash
43
+ uv pip install --python .venv/bin/python --reinstall --no-deps --editable \
44
+ /Users/kuanlun_wang/Desktop/git-repo/hbrowser.clone
45
+ ```
46
+
47
+ Commands that must preserve this editable overlay use `uv run --no-sync`.
48
+
49
+ ## Read-only Market inspection
50
+
51
+ Market inspection and sale planning are non-mutating by default:
52
+
53
+ ```python
54
+ import asyncio
55
+
56
+ from hvbrowser import HVDriver, SellItems
57
+
58
+
59
+ async def main() -> None:
60
+ async with HVDriver(headless=True) as driver:
61
+ snapshot = await driver.inspect_market()
62
+ plan = await driver.marketcheck(SellItems(materials=["Low-Grade Metals"]))
63
+ print(len(snapshot.items), plan.total_units)
64
+
65
+
66
+ asyncio.run(main())
67
+ ```
68
+
69
+ The compatibility `marketcheck()` call requires `commit=True` before it can
70
+ request submission. Submission is currently blocked by a fail-closed runtime
71
+ gate until the current quote selector and pricing semantics have been confirmed
72
+ with the read-only live probe. The guarded implementation never deposits
73
+ credits and never re-lists unrelated orders.
74
+
75
+ ## Guarded live smoke test
76
+
77
+ The surrounding `hentaiverse` workspace owns account selection. This smoke
78
+ probe only refuses to construct a browser unless credentials are supplied
79
+ indirectly through the environment. It stops when an active battle is detected
80
+ and contains no click, submit, repair, recovery, lottery, monster-lab, or battle
81
+ call:
82
+
83
+ ```bash
84
+ uv run --no-sync python scripts/live_readonly_smoke.py \
85
+ --inspect-market-form
86
+ ```
@@ -0,0 +1,10 @@
1
+ hvbrowser/__init__.py,sha256=V2lb5K_qmdfxLuU2qijsII7BR1irhy5-qubcq_VSX1c,662
2
+ hvbrowser/hv.py,sha256=3xrXIJWqrgxIi9cAfZbQ8OfmJT3te6m1lD69dq4YEhQ,15615
3
+ hvbrowser/live_probe.py,sha256=OvIF935NWQbGfldoSJSPWdmhoWmzjOuiC0X0XdX5MiU,3589
4
+ hvbrowser/market.py,sha256=0lQVoRaCBMy2yQlTJ0h9x3_e_TzCdRLZhCbLaSv82SA,12348
5
+ hvbrowser/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ hvbrowser/runtime.py,sha256=9Doe3KbiXAEu2QH3pQUUii8ERti0J5efiaevpcvSsy8,544
7
+ hvbrowser/urls.py,sha256=fFwITwW-3iOgfcnUi1YKI67Orqgf3DqSpetrVXHxeSw,183
8
+ hvbrowser-0.1.0.dist-info/METADATA,sha256=Vlk3rh145bZBEPj5HkISUH9tf1vFfUxZe5GtUGUmAYQ,2890
9
+ hvbrowser-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
10
+ hvbrowser-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any