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 +32 -0
- hvbrowser/hv.py +410 -0
- hvbrowser/live_probe.py +111 -0
- hvbrowser/market.py +353 -0
- hvbrowser/py.typed +0 -0
- hvbrowser/runtime.py +17 -0
- hvbrowser/urls.py +4 -0
- hvbrowser-0.1.0.dist-info/METADATA +86 -0
- hvbrowser-0.1.0.dist-info/RECORD +10 -0
- hvbrowser-0.1.0.dist-info/WHEEL +4 -0
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
|
hvbrowser/live_probe.py
ADDED
|
@@ -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,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,,
|