python-library-ff14-the-hunt 0.0.9__tar.gz → 0.0.11__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.
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/.cursor/skills/ff14_the_hunt-package-changelog/SKILL.md +4 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/PKG-INFO +1 -1
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/client.py +112 -112
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/spawn_window.py +19 -7
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/common/http_request.py +3 -3
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/common/urlopen_retry.py +61 -61
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/ff14_the_hunt.py +43 -43
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/cn.py +2 -1
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/en.py +1 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/models.py +11 -3
- python_library_ff14_the_hunt-0.0.11/ff14_the_hunt/poll/spawn_dedup.py +43 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/attach.py +17 -12
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/region_fetch.py +51 -51
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/pyproject.toml +1 -1
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_ff14_the_hunt_service.py +21 -21
- python_library_ff14_the_hunt-0.0.11/tests/test_spawn_dedup.py +211 -0
- python_library_ff14_the_hunt-0.0.11/tests/test_spawn_map_data.py +194 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_urlopen_retry.py +45 -45
- python_library_ff14_the_hunt-0.0.9/ff14_the_hunt/poll/spawn_dedup.py +0 -29
- python_library_ff14_the_hunt-0.0.9/tests/test_spawn_dedup.py +0 -55
- python_library_ff14_the_hunt-0.0.9/tests/test_spawn_map_data.py +0 -74
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/.cursor/skills/ff14_the_hunt-package-design/SKILL.md +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/.cursor/skills/ff14_the_hunt-package-preload/SKILL.md +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/.gitignore +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/README.md +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/example/__main__.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/example/spawn_map_io.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/example.bat +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/enrich.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/fate_timer.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/resources.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/spawn_points.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/timer_theme.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/common/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/data/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/data/hunts_zh.json +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/data/regions_zh.json +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/detect.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/display.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/names.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/spawn_map_display.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/tag.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/loop.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/monitor_mode.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/scheduler.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/sleep_plan.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/sleep_settings.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/window_remaining.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/coordinates.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/layout.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/png_meta.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/points.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/region_image.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/test.bat +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_bear_tracker_client.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_fate_timer.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_locale_cn.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_monitor_mode.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_poll_scheduler.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_poll_sleep.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_recent_spawn_detection.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_spawn_map_points.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_spawn_window.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_untimed_marks_filter.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tools/build_locale_data.py +0 -0
- {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/update.bat +0 -0
|
@@ -7,6 +7,10 @@ description: ff14_the_hunt 包:要求与决议;最新在上。
|
|
|
7
7
|
|
|
8
8
|
(规则见 `~/.cursor/skills/agent-project-changelog/SKILL.md`。)
|
|
9
9
|
|
|
10
|
+
## 2026-07-19
|
|
11
|
+
|
|
12
|
+
- **Decision**: polling reports the first observed trigger-window `OPEN` event once per hunt/world/death cycle. `HuntMarkRecord.newly_opened` distinguishes the open-window event, and `newly_spawned` remains the compatibility signal for report-worthy messages so existing ff14hub callbacks that read `newly_spawned_marks` do not miss the 100% open-window transition.
|
|
13
|
+
|
|
10
14
|
## 2026-06-10
|
|
11
15
|
|
|
12
16
|
- **决议**:自动轮询:爬取、回调或循环内任意异常均记录后等待 60 秒继续,仅 ``KeyboardInterrupt`` / ``SystemExit`` 可终止服务。发布 ``0.0.6``。
|
|
@@ -1,74 +1,74 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
|
-
import threading
|
|
5
|
-
import time
|
|
6
|
-
import urllib.error
|
|
7
|
-
import urllib.request
|
|
8
|
-
from typing import Any
|
|
9
|
-
|
|
10
|
-
from ff14_the_hunt.common.urlopen_retry import (
|
|
11
|
-
retry_after_seconds_from_headers,
|
|
12
|
-
urlopen_read,
|
|
13
|
-
)
|
|
14
|
-
from ff14_the_hunt.common.http_request import DEFAULT_USER_AGENT
|
|
15
|
-
|
|
16
|
-
DEFAULT_BASE_URL = "https://tracker.beartoolkit.com/api"
|
|
17
|
-
_BLOCKED_HTTP_CODES = frozenset({403, 429})
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class BearTrackerRequestError(RuntimeError):
|
|
21
|
-
"""Bear Tracker API request failed after HTTP retry handling."""
|
|
22
|
-
|
|
23
|
-
def __init__(
|
|
24
|
-
self,
|
|
25
|
-
message: str,
|
|
26
|
-
*,
|
|
27
|
-
status_code: int | None = None,
|
|
28
|
-
retry_after_seconds: float | None = None,
|
|
29
|
-
) -> None:
|
|
30
|
-
super().__init__(message)
|
|
31
|
-
self.status_code = status_code
|
|
32
|
-
self.retry_after_seconds = retry_after_seconds
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class BearTrackerBlockedError(BearTrackerRequestError):
|
|
36
|
-
"""Bear Tracker returned a block or rate-limit status."""
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ff14_the_hunt.common.urlopen_retry import (
|
|
11
|
+
retry_after_seconds_from_headers,
|
|
12
|
+
urlopen_read,
|
|
13
|
+
)
|
|
14
|
+
from ff14_the_hunt.common.http_request import DEFAULT_USER_AGENT
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://tracker.beartoolkit.com/api"
|
|
17
|
+
_BLOCKED_HTTP_CODES = frozenset({403, 429})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BearTrackerRequestError(RuntimeError):
|
|
21
|
+
"""Bear Tracker API request failed after HTTP retry handling."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
message: str,
|
|
26
|
+
*,
|
|
27
|
+
status_code: int | None = None,
|
|
28
|
+
retry_after_seconds: float | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.status_code = status_code
|
|
32
|
+
self.retry_after_seconds = retry_after_seconds
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BearTrackerBlockedError(BearTrackerRequestError):
|
|
36
|
+
"""Bear Tracker returned a block or rate-limit status."""
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
class BearTrackerClient:
|
|
40
40
|
"""Bear Tracker 站点同源 API 客户端(``/api/*``)。"""
|
|
41
41
|
|
|
42
|
-
def __init__(
|
|
43
|
-
self,
|
|
44
|
-
*,
|
|
45
|
-
base_url: str = DEFAULT_BASE_URL,
|
|
46
|
-
timeout_seconds: float = 120.0,
|
|
47
|
-
min_request_interval_seconds: float = 1.0,
|
|
48
|
-
user_agent: str = DEFAULT_USER_AGENT,
|
|
49
|
-
) -> None:
|
|
50
|
-
self._base_url = base_url.rstrip("/")
|
|
51
|
-
self._timeout_seconds = timeout_seconds
|
|
52
|
-
self._min_request_interval_seconds = min_request_interval_seconds
|
|
53
|
-
self._user_agent = user_agent
|
|
54
|
-
self._last_request_at = 0.0
|
|
55
|
-
self._request_lock = threading.Lock()
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
46
|
+
timeout_seconds: float = 120.0,
|
|
47
|
+
min_request_interval_seconds: float = 1.0,
|
|
48
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._base_url = base_url.rstrip("/")
|
|
51
|
+
self._timeout_seconds = timeout_seconds
|
|
52
|
+
self._min_request_interval_seconds = min_request_interval_seconds
|
|
53
|
+
self._user_agent = user_agent
|
|
54
|
+
self._last_request_at = 0.0
|
|
55
|
+
self._request_lock = threading.Lock()
|
|
56
56
|
|
|
57
57
|
@property
|
|
58
58
|
def base_url(self) -> str:
|
|
59
59
|
return self._base_url
|
|
60
60
|
|
|
61
61
|
@property
|
|
62
|
-
def timeout_seconds(self) -> float:
|
|
63
|
-
return self._timeout_seconds
|
|
64
|
-
|
|
65
|
-
@property
|
|
66
|
-
def min_request_interval_seconds(self) -> float:
|
|
67
|
-
return self._min_request_interval_seconds
|
|
68
|
-
|
|
69
|
-
@property
|
|
70
|
-
def user_agent(self) -> str:
|
|
71
|
-
return self._user_agent
|
|
62
|
+
def timeout_seconds(self) -> float:
|
|
63
|
+
return self._timeout_seconds
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def min_request_interval_seconds(self) -> float:
|
|
67
|
+
return self._min_request_interval_seconds
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def user_agent(self) -> str:
|
|
71
|
+
return self._user_agent
|
|
72
72
|
|
|
73
73
|
def sync_session(self) -> dict[str, Any]:
|
|
74
74
|
"""拉取会话与 ``resources``(含 DatabaseHunt、SpawnPoint、DataCenters)。"""
|
|
@@ -123,57 +123,57 @@ class BearTrackerClient:
|
|
|
123
123
|
return result.get("data", result)
|
|
124
124
|
return {}
|
|
125
125
|
|
|
126
|
-
def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
127
|
-
url = f"{self._base_url}{path}"
|
|
128
|
-
encoded = json.dumps(body).encode("utf-8")
|
|
129
|
-
request = urllib.request.Request(
|
|
130
|
-
url,
|
|
131
|
-
data=encoded,
|
|
132
|
-
headers=self._headers(),
|
|
133
|
-
method="POST",
|
|
134
|
-
)
|
|
135
|
-
try:
|
|
136
|
-
self._pace_request()
|
|
137
|
-
raw = urlopen_read(request, timeout=self._timeout_seconds)
|
|
138
|
-
except urllib.error.HTTPError as exc:
|
|
139
|
-
detail = exc.read().decode("utf-8", errors="replace")
|
|
140
|
-
retry_after = retry_after_seconds_from_headers(exc.headers)
|
|
141
|
-
message = f"Bear Tracker API {path} failed: HTTP {exc.code}: {detail}"
|
|
142
|
-
error_type = (
|
|
143
|
-
BearTrackerBlockedError
|
|
144
|
-
if exc.code in _BLOCKED_HTTP_CODES
|
|
145
|
-
else BearTrackerRequestError
|
|
146
|
-
)
|
|
147
|
-
raise error_type(
|
|
148
|
-
message,
|
|
149
|
-
status_code=exc.code,
|
|
150
|
-
retry_after_seconds=retry_after,
|
|
151
|
-
) from exc
|
|
152
|
-
except urllib.error.URLError as exc:
|
|
153
|
-
raise BearTrackerRequestError(f"Bear Tracker API {path} failed: {exc}") from exc
|
|
154
|
-
parsed = json.loads(raw.decode("utf-8"))
|
|
155
|
-
if not isinstance(parsed, dict):
|
|
156
|
-
raise RuntimeError(f"unexpected response type from {path}")
|
|
157
|
-
return parsed
|
|
158
|
-
|
|
159
|
-
def _headers(self) -> dict[str, str]:
|
|
160
|
-
return {
|
|
161
|
-
"User-Agent": self._user_agent,
|
|
162
|
-
"Content-Type": "application/json",
|
|
163
|
-
"Accept": "application/json, text/plain, */*",
|
|
164
|
-
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
165
|
-
"Origin": "https://tracker.beartoolkit.com",
|
|
166
|
-
"Referer": "https://tracker.beartoolkit.com/timer",
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
def _pace_request(self) -> None:
|
|
170
|
-
if self._min_request_interval_seconds <= 0:
|
|
171
|
-
return
|
|
172
|
-
with self._request_lock:
|
|
173
|
-
now = time.monotonic()
|
|
174
|
-
wait_seconds = self._min_request_interval_seconds - (
|
|
175
|
-
now - self._last_request_at
|
|
176
|
-
)
|
|
177
|
-
if wait_seconds > 0:
|
|
178
|
-
time.sleep(wait_seconds)
|
|
179
|
-
self._last_request_at = time.monotonic()
|
|
126
|
+
def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
127
|
+
url = f"{self._base_url}{path}"
|
|
128
|
+
encoded = json.dumps(body).encode("utf-8")
|
|
129
|
+
request = urllib.request.Request(
|
|
130
|
+
url,
|
|
131
|
+
data=encoded,
|
|
132
|
+
headers=self._headers(),
|
|
133
|
+
method="POST",
|
|
134
|
+
)
|
|
135
|
+
try:
|
|
136
|
+
self._pace_request()
|
|
137
|
+
raw = urlopen_read(request, timeout=self._timeout_seconds)
|
|
138
|
+
except urllib.error.HTTPError as exc:
|
|
139
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
140
|
+
retry_after = retry_after_seconds_from_headers(exc.headers)
|
|
141
|
+
message = f"Bear Tracker API {path} failed: HTTP {exc.code}: {detail}"
|
|
142
|
+
error_type = (
|
|
143
|
+
BearTrackerBlockedError
|
|
144
|
+
if exc.code in _BLOCKED_HTTP_CODES
|
|
145
|
+
else BearTrackerRequestError
|
|
146
|
+
)
|
|
147
|
+
raise error_type(
|
|
148
|
+
message,
|
|
149
|
+
status_code=exc.code,
|
|
150
|
+
retry_after_seconds=retry_after,
|
|
151
|
+
) from exc
|
|
152
|
+
except urllib.error.URLError as exc:
|
|
153
|
+
raise BearTrackerRequestError(f"Bear Tracker API {path} failed: {exc}") from exc
|
|
154
|
+
parsed = json.loads(raw.decode("utf-8"))
|
|
155
|
+
if not isinstance(parsed, dict):
|
|
156
|
+
raise RuntimeError(f"unexpected response type from {path}")
|
|
157
|
+
return parsed
|
|
158
|
+
|
|
159
|
+
def _headers(self) -> dict[str, str]:
|
|
160
|
+
return {
|
|
161
|
+
"User-Agent": self._user_agent,
|
|
162
|
+
"Content-Type": "application/json",
|
|
163
|
+
"Accept": "application/json, text/plain, */*",
|
|
164
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
165
|
+
"Origin": "https://tracker.beartoolkit.com",
|
|
166
|
+
"Referer": "https://tracker.beartoolkit.com/timer",
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
def _pace_request(self) -> None:
|
|
170
|
+
if self._min_request_interval_seconds <= 0:
|
|
171
|
+
return
|
|
172
|
+
with self._request_lock:
|
|
173
|
+
now = time.monotonic()
|
|
174
|
+
wait_seconds = self._min_request_interval_seconds - (
|
|
175
|
+
now - self._last_request_at
|
|
176
|
+
)
|
|
177
|
+
if wait_seconds > 0:
|
|
178
|
+
time.sleep(wait_seconds)
|
|
179
|
+
self._last_request_at = time.monotonic()
|
|
@@ -85,13 +85,25 @@ def compute_trigger_timer(
|
|
|
85
85
|
)
|
|
86
86
|
|
|
87
87
|
|
|
88
|
-
def is_window_open(timer: TimerDisplay | None) -> bool:
|
|
89
|
-
return timer is not None and timer.phase == SpawnWindowPhase.OPEN
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def
|
|
93
|
-
timer
|
|
94
|
-
|
|
88
|
+
def is_window_open(timer: TimerDisplay | None) -> bool:
|
|
89
|
+
return timer is not None and timer.phase == SpawnWindowPhase.OPEN
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_window_report_ready(timer: TimerDisplay | None) -> bool:
|
|
93
|
+
if timer is None:
|
|
94
|
+
return False
|
|
95
|
+
if timer.phase == SpawnWindowPhase.CAPPED:
|
|
96
|
+
return True
|
|
97
|
+
if timer.phase != SpawnWindowPhase.OPEN:
|
|
98
|
+
return False
|
|
99
|
+
if timer.progress_percent is None:
|
|
100
|
+
return False
|
|
101
|
+
return timer.progress_percent >= 100.0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_recently_in_window(
|
|
105
|
+
timer: TimerDisplay | None,
|
|
106
|
+
*,
|
|
95
107
|
grace_seconds: float = 900.0,
|
|
96
108
|
) -> bool:
|
|
97
109
|
if timer is None or timer.phase != SpawnWindowPhase.OPEN:
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
DEFAULT_USER_AGENT = "ff14-the-hunt/0.0.8"
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
DEFAULT_USER_AGENT = "ff14-the-hunt/0.0.8"
|
|
@@ -1,39 +1,39 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import ssl
|
|
4
|
-
import time
|
|
5
|
-
import urllib.error
|
|
6
|
-
import urllib.request
|
|
7
|
-
from collections.abc import Callable
|
|
8
|
-
from email.utils import parsedate_to_datetime
|
|
9
|
-
from random import uniform
|
|
3
|
+
import ssl
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from email.utils import parsedate_to_datetime
|
|
9
|
+
from random import uniform
|
|
10
10
|
|
|
11
11
|
_RETRYABLE_HTTP_CODES = frozenset({408, 429, 500, 502, 503, 504})
|
|
12
|
-
_DEFAULT_MAX_ATTEMPTS = 4
|
|
13
|
-
_DEFAULT_INITIAL_BACKOFF_SECONDS = 1.0
|
|
14
|
-
_DEFAULT_MAX_BACKOFF_SECONDS = 8.0
|
|
15
|
-
_DEFAULT_MAX_RETRY_AFTER_SECONDS = 300.0
|
|
16
|
-
_DEFAULT_JITTER_RATIO = 0.15
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def retry_after_seconds_from_headers(headers: object) -> float | None:
|
|
20
|
-
if headers is None or not hasattr(headers, "get"):
|
|
21
|
-
return None
|
|
22
|
-
raw = headers.get("Retry-After") # type: ignore[attr-defined]
|
|
23
|
-
if raw is None:
|
|
24
|
-
return None
|
|
25
|
-
text = str(raw).strip()
|
|
26
|
-
if not text:
|
|
27
|
-
return None
|
|
28
|
-
try:
|
|
29
|
-
seconds = float(text)
|
|
30
|
-
except ValueError:
|
|
31
|
-
try:
|
|
32
|
-
retry_at = parsedate_to_datetime(text)
|
|
33
|
-
except (TypeError, ValueError):
|
|
34
|
-
return None
|
|
35
|
-
seconds = retry_at.timestamp() - time.time()
|
|
36
|
-
return max(0.0, seconds)
|
|
12
|
+
_DEFAULT_MAX_ATTEMPTS = 4
|
|
13
|
+
_DEFAULT_INITIAL_BACKOFF_SECONDS = 1.0
|
|
14
|
+
_DEFAULT_MAX_BACKOFF_SECONDS = 8.0
|
|
15
|
+
_DEFAULT_MAX_RETRY_AFTER_SECONDS = 300.0
|
|
16
|
+
_DEFAULT_JITTER_RATIO = 0.15
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def retry_after_seconds_from_headers(headers: object) -> float | None:
|
|
20
|
+
if headers is None or not hasattr(headers, "get"):
|
|
21
|
+
return None
|
|
22
|
+
raw = headers.get("Retry-After") # type: ignore[attr-defined]
|
|
23
|
+
if raw is None:
|
|
24
|
+
return None
|
|
25
|
+
text = str(raw).strip()
|
|
26
|
+
if not text:
|
|
27
|
+
return None
|
|
28
|
+
try:
|
|
29
|
+
seconds = float(text)
|
|
30
|
+
except ValueError:
|
|
31
|
+
try:
|
|
32
|
+
retry_at = parsedate_to_datetime(text)
|
|
33
|
+
except (TypeError, ValueError):
|
|
34
|
+
return None
|
|
35
|
+
seconds = retry_at.timestamp() - time.time()
|
|
36
|
+
return max(0.0, seconds)
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def is_retryable_urlopen_error(exc: BaseException) -> bool:
|
|
@@ -54,17 +54,17 @@ def is_retryable_urlopen_error(exc: BaseException) -> bool:
|
|
|
54
54
|
return False
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
def urlopen_read(
|
|
58
|
-
request: urllib.request.Request,
|
|
59
|
-
*,
|
|
60
|
-
timeout: float,
|
|
61
|
-
max_attempts: int = _DEFAULT_MAX_ATTEMPTS,
|
|
62
|
-
initial_backoff_seconds: float = _DEFAULT_INITIAL_BACKOFF_SECONDS,
|
|
63
|
-
max_backoff_seconds: float = _DEFAULT_MAX_BACKOFF_SECONDS,
|
|
64
|
-
max_retry_after_seconds: float = _DEFAULT_MAX_RETRY_AFTER_SECONDS,
|
|
65
|
-
jitter_ratio: float = _DEFAULT_JITTER_RATIO,
|
|
66
|
-
sleep: Callable[[float], None] = time.sleep,
|
|
67
|
-
) -> bytes:
|
|
57
|
+
def urlopen_read(
|
|
58
|
+
request: urllib.request.Request,
|
|
59
|
+
*,
|
|
60
|
+
timeout: float,
|
|
61
|
+
max_attempts: int = _DEFAULT_MAX_ATTEMPTS,
|
|
62
|
+
initial_backoff_seconds: float = _DEFAULT_INITIAL_BACKOFF_SECONDS,
|
|
63
|
+
max_backoff_seconds: float = _DEFAULT_MAX_BACKOFF_SECONDS,
|
|
64
|
+
max_retry_after_seconds: float = _DEFAULT_MAX_RETRY_AFTER_SECONDS,
|
|
65
|
+
jitter_ratio: float = _DEFAULT_JITTER_RATIO,
|
|
66
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
67
|
+
) -> bytes:
|
|
68
68
|
"""``urlopen`` 读响应体;瞬时网络或 TLS 失败时指数退避重试。
|
|
69
69
|
|
|
70
70
|
Args:
|
|
@@ -82,12 +82,12 @@ def urlopen_read(
|
|
|
82
82
|
urllib.error.URLError: 重试耗尽后的网络或 TLS 错误。
|
|
83
83
|
TimeoutError: 重试耗尽后的超时。
|
|
84
84
|
"""
|
|
85
|
-
if max_attempts < 1:
|
|
86
|
-
raise ValueError("max_attempts must be >= 1")
|
|
87
|
-
if max_retry_after_seconds < 0:
|
|
88
|
-
raise ValueError("max_retry_after_seconds must be >= 0")
|
|
89
|
-
if jitter_ratio < 0:
|
|
90
|
-
raise ValueError("jitter_ratio must be >= 0")
|
|
85
|
+
if max_attempts < 1:
|
|
86
|
+
raise ValueError("max_attempts must be >= 1")
|
|
87
|
+
if max_retry_after_seconds < 0:
|
|
88
|
+
raise ValueError("max_retry_after_seconds must be >= 0")
|
|
89
|
+
if jitter_ratio < 0:
|
|
90
|
+
raise ValueError("jitter_ratio must be >= 0")
|
|
91
91
|
|
|
92
92
|
last_exc: BaseException | None = None
|
|
93
93
|
backoff = initial_backoff_seconds
|
|
@@ -96,18 +96,18 @@ def urlopen_read(
|
|
|
96
96
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
97
97
|
return response.read()
|
|
98
98
|
except BaseException as exc:
|
|
99
|
-
last_exc = exc
|
|
100
|
-
if attempt + 1 >= max_attempts or not is_retryable_urlopen_error(exc):
|
|
101
|
-
raise
|
|
102
|
-
wait_seconds = min(backoff, max_backoff_seconds)
|
|
103
|
-
if isinstance(exc, urllib.error.HTTPError):
|
|
104
|
-
retry_after = retry_after_seconds_from_headers(exc.headers)
|
|
105
|
-
if retry_after is not None:
|
|
106
|
-
wait_seconds = min(retry_after, max_retry_after_seconds)
|
|
107
|
-
if jitter_ratio > 0 and wait_seconds > 0:
|
|
108
|
-
wait_seconds *= uniform(1.0 - jitter_ratio, 1.0 + jitter_ratio)
|
|
109
|
-
sleep(wait_seconds)
|
|
110
|
-
backoff = min(backoff * 2.0, max_backoff_seconds)
|
|
99
|
+
last_exc = exc
|
|
100
|
+
if attempt + 1 >= max_attempts or not is_retryable_urlopen_error(exc):
|
|
101
|
+
raise
|
|
102
|
+
wait_seconds = min(backoff, max_backoff_seconds)
|
|
103
|
+
if isinstance(exc, urllib.error.HTTPError):
|
|
104
|
+
retry_after = retry_after_seconds_from_headers(exc.headers)
|
|
105
|
+
if retry_after is not None:
|
|
106
|
+
wait_seconds = min(retry_after, max_retry_after_seconds)
|
|
107
|
+
if jitter_ratio > 0 and wait_seconds > 0:
|
|
108
|
+
wait_seconds *= uniform(1.0 - jitter_ratio, 1.0 + jitter_ratio)
|
|
109
|
+
sleep(wait_seconds)
|
|
110
|
+
backoff = min(backoff * 2.0, max_backoff_seconds)
|
|
111
111
|
|
|
112
112
|
if last_exc is not None:
|
|
113
113
|
raise last_exc
|
|
@@ -5,14 +5,14 @@ import threading
|
|
|
5
5
|
import time
|
|
6
6
|
from collections.abc import Callable
|
|
7
7
|
|
|
8
|
-
from ff14_the_hunt.bear_tracker.client import (
|
|
9
|
-
BearTrackerBlockedError,
|
|
10
|
-
BearTrackerClient,
|
|
11
|
-
)
|
|
12
|
-
from ff14_the_hunt.bear_tracker.enrich import build_hunt_record, mark_has_display_timer
|
|
13
|
-
from ff14_the_hunt.bear_tracker.resources import BearResources
|
|
14
|
-
from ff14_the_hunt.common.http_request import DEFAULT_USER_AGENT
|
|
15
|
-
from ff14_the_hunt.locale.cn import normalize_patch_codes
|
|
8
|
+
from ff14_the_hunt.bear_tracker.client import (
|
|
9
|
+
BearTrackerBlockedError,
|
|
10
|
+
BearTrackerClient,
|
|
11
|
+
)
|
|
12
|
+
from ff14_the_hunt.bear_tracker.enrich import build_hunt_record, mark_has_display_timer
|
|
13
|
+
from ff14_the_hunt.bear_tracker.resources import BearResources
|
|
14
|
+
from ff14_the_hunt.common.http_request import DEFAULT_USER_AGENT
|
|
15
|
+
from ff14_the_hunt.locale.cn import normalize_patch_codes
|
|
16
16
|
from ff14_the_hunt.models import (
|
|
17
17
|
HuntCrawlPacket,
|
|
18
18
|
HuntMarkRecord,
|
|
@@ -26,9 +26,9 @@ from ff14_the_hunt.spawn_map.region_fetch import RegionMapFetcher, site_root_fro
|
|
|
26
26
|
|
|
27
27
|
HuntCrawlCallback = Callable[[HuntCrawlPacket], None]
|
|
28
28
|
|
|
29
|
-
_log = logging.getLogger(__name__)
|
|
30
|
-
_FETCH_FAILURE_RETRY_SECONDS = 60.0
|
|
31
|
-
_BLOCKED_RETRY_SECONDS = 900.0
|
|
29
|
+
_log = logging.getLogger(__name__)
|
|
30
|
+
_FETCH_FAILURE_RETRY_SECONDS = 60.0
|
|
31
|
+
_BLOCKED_RETRY_SECONDS = 900.0
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
class FF14TheHunt:
|
|
@@ -42,12 +42,12 @@ class FF14TheHunt:
|
|
|
42
42
|
rank_kinds: list[HuntRankKind] | None = None,
|
|
43
43
|
patches: list[str] | None = None,
|
|
44
44
|
hunt_keys: list[str] | None = None,
|
|
45
|
-
regions: list[str] | None = None,
|
|
46
|
-
base_url: str | None = None,
|
|
47
|
-
timeout_seconds: float = 120.0,
|
|
48
|
-
min_request_interval_seconds: float = 1.0,
|
|
49
|
-
user_agent: str = DEFAULT_USER_AGENT,
|
|
50
|
-
active_poll_interval_seconds: float = 600.0,
|
|
45
|
+
regions: list[str] | None = None,
|
|
46
|
+
base_url: str | None = None,
|
|
47
|
+
timeout_seconds: float = 120.0,
|
|
48
|
+
min_request_interval_seconds: float = 1.0,
|
|
49
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
50
|
+
active_poll_interval_seconds: float = 600.0,
|
|
51
51
|
recent_poll_interval_seconds: float = 300.0,
|
|
52
52
|
fallback_poll_interval_seconds: float = 1800.0,
|
|
53
53
|
min_wakeup_seconds: float = 120.0,
|
|
@@ -78,11 +78,11 @@ class FF14TheHunt:
|
|
|
78
78
|
include_spawn_maps: 为刚刷新记录拉取站点区域原图 PNG base64。
|
|
79
79
|
include_untimed_marks: 是否保留无计时的占位行(SS 级噬灵王、维护占位等)。
|
|
80
80
|
"""
|
|
81
|
-
kwargs: dict[str, float | str] = {
|
|
82
|
-
"timeout_seconds": timeout_seconds,
|
|
83
|
-
"min_request_interval_seconds": min_request_interval_seconds,
|
|
84
|
-
"user_agent": user_agent,
|
|
85
|
-
}
|
|
81
|
+
kwargs: dict[str, float | str] = {
|
|
82
|
+
"timeout_seconds": timeout_seconds,
|
|
83
|
+
"min_request_interval_seconds": min_request_interval_seconds,
|
|
84
|
+
"user_agent": user_agent,
|
|
85
|
+
}
|
|
86
86
|
if base_url is not None:
|
|
87
87
|
kwargs["base_url"] = base_url
|
|
88
88
|
self._client = BearTrackerClient(**kwargs)
|
|
@@ -299,12 +299,12 @@ class FF14TheHunt:
|
|
|
299
299
|
|
|
300
300
|
def _region_map_fetcher(self) -> RegionMapFetcher:
|
|
301
301
|
if self._map_fetcher is None:
|
|
302
|
-
self._map_fetcher = RegionMapFetcher(
|
|
303
|
-
site_root=site_root_from_api_base(self._client.base_url),
|
|
304
|
-
timeout_seconds=self._client.timeout_seconds,
|
|
305
|
-
min_request_interval_seconds=self._client.min_request_interval_seconds,
|
|
306
|
-
user_agent=self._client.user_agent,
|
|
307
|
-
)
|
|
302
|
+
self._map_fetcher = RegionMapFetcher(
|
|
303
|
+
site_root=site_root_from_api_base(self._client.base_url),
|
|
304
|
+
timeout_seconds=self._client.timeout_seconds,
|
|
305
|
+
min_request_interval_seconds=self._client.min_request_interval_seconds,
|
|
306
|
+
user_agent=self._client.user_agent,
|
|
307
|
+
)
|
|
308
308
|
return self._map_fetcher
|
|
309
309
|
|
|
310
310
|
def recently_spawned(
|
|
@@ -353,21 +353,21 @@ class FF14TheHunt:
|
|
|
353
353
|
marks = self._scheduler.fetch()
|
|
354
354
|
except (KeyboardInterrupt, SystemExit):
|
|
355
355
|
raise
|
|
356
|
-
except BearTrackerBlockedError as exc:
|
|
357
|
-
retry_seconds = (
|
|
358
|
-
exc.retry_after_seconds
|
|
359
|
-
if exc.retry_after_seconds is not None
|
|
360
|
-
else _BLOCKED_RETRY_SECONDS
|
|
361
|
-
)
|
|
362
|
-
retry_seconds = max(retry_seconds, _FETCH_FAILURE_RETRY_SECONDS)
|
|
363
|
-
_log.warning(
|
|
364
|
-
"Bear Tracker refused crawl; retry in %s seconds: %s",
|
|
365
|
-
retry_seconds,
|
|
366
|
-
exc,
|
|
367
|
-
)
|
|
368
|
-
wait_or_stop(stop_event, retry_seconds)
|
|
369
|
-
return
|
|
370
|
-
except Exception as exc:
|
|
356
|
+
except BearTrackerBlockedError as exc:
|
|
357
|
+
retry_seconds = (
|
|
358
|
+
exc.retry_after_seconds
|
|
359
|
+
if exc.retry_after_seconds is not None
|
|
360
|
+
else _BLOCKED_RETRY_SECONDS
|
|
361
|
+
)
|
|
362
|
+
retry_seconds = max(retry_seconds, _FETCH_FAILURE_RETRY_SECONDS)
|
|
363
|
+
_log.warning(
|
|
364
|
+
"Bear Tracker refused crawl; retry in %s seconds: %s",
|
|
365
|
+
retry_seconds,
|
|
366
|
+
exc,
|
|
367
|
+
)
|
|
368
|
+
wait_or_stop(stop_event, retry_seconds)
|
|
369
|
+
return
|
|
370
|
+
except Exception as exc:
|
|
371
371
|
_log.warning("狩猎爬取失败,%s 秒后重试:%s", _FETCH_FAILURE_RETRY_SECONDS, exc)
|
|
372
372
|
wait_or_stop(stop_event, _FETCH_FAILURE_RETRY_SECONDS)
|
|
373
373
|
return
|
|
@@ -123,6 +123,7 @@ def mark_to_display_dict(
|
|
|
123
123
|
"失踪计数": mark.missing_counter,
|
|
124
124
|
"维护计时": mark.is_maintenance,
|
|
125
125
|
"刚刷新": mark.recently_spawned,
|
|
126
|
+
"开窗100%": mark.newly_opened,
|
|
126
127
|
"新检出": mark.newly_spawned,
|
|
127
128
|
}
|
|
128
129
|
if mark.fate_last_seen is not None:
|
|
@@ -152,7 +153,7 @@ def mark_to_display_dict(
|
|
|
152
153
|
"像素Y": point.pixel_y,
|
|
153
154
|
"格点X": point.grid_x,
|
|
154
155
|
"格点Y": point.grid_y,
|
|
155
|
-
"可能触发": point.active,
|
|
156
|
+
"可能触发": point.active,
|
|
156
157
|
}
|
|
157
158
|
for point in mark.spawn_points
|
|
158
159
|
]
|
|
@@ -110,6 +110,7 @@ def mark_to_display_dict(
|
|
|
110
110
|
"missing_counter": mark.missing_counter,
|
|
111
111
|
"is_maintenance": mark.is_maintenance,
|
|
112
112
|
"recently_spawned": mark.recently_spawned,
|
|
113
|
+
"newly_opened": mark.newly_opened,
|
|
113
114
|
"newly_spawned": mark.newly_spawned,
|
|
114
115
|
}
|
|
115
116
|
if mark.fate_last_seen is not None:
|
{python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/models.py
RENAMED
|
@@ -58,7 +58,7 @@ class MapCoordinate(BaseModel):
|
|
|
58
58
|
default=None,
|
|
59
59
|
description="区域原图上的 Y 像素;norm_y * 图高",
|
|
60
60
|
)
|
|
61
|
-
active: bool | None = Field(default=None, description="是否仍是可能触发点位")
|
|
61
|
+
active: bool | None = Field(default=None, description="是否仍是可能触发点位")
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
class SpawnMapLayout(BaseModel):
|
|
@@ -127,7 +127,7 @@ class HuntMarkRecord(BaseModel):
|
|
|
127
127
|
fate_timer: TimerDisplay | None = None
|
|
128
128
|
spawn_points: list[MapCoordinate] = Field(
|
|
129
129
|
default_factory=list,
|
|
130
|
-
description="仅刚刷新记录填充;只保留可能触发点位",
|
|
130
|
+
description="仅刚刷新记录填充;只保留可能触发点位",
|
|
131
131
|
)
|
|
132
132
|
spawn_map_layout: SpawnMapLayout | None = Field(
|
|
133
133
|
default=None,
|
|
@@ -138,9 +138,13 @@ class HuntMarkRecord(BaseModel):
|
|
|
138
138
|
description="仅刚刷新且启用区域原图时填充",
|
|
139
139
|
)
|
|
140
140
|
recently_spawned: bool = False
|
|
141
|
+
newly_opened: bool = Field(
|
|
142
|
+
default=False,
|
|
143
|
+
description="First reported 100-percent trigger-window event in this polling session.",
|
|
144
|
+
)
|
|
141
145
|
newly_spawned: bool = Field(
|
|
142
146
|
default=False,
|
|
143
|
-
description="
|
|
147
|
+
description="First report-worthy event in this polling session.",
|
|
144
148
|
)
|
|
145
149
|
raw_timer: dict[str, Any] = Field(default_factory=dict)
|
|
146
150
|
|
|
@@ -196,3 +200,7 @@ class HuntCrawlPacket(BaseModel):
|
|
|
196
200
|
@property
|
|
197
201
|
def newly_spawned_marks(self) -> list[HuntMarkRecord]:
|
|
198
202
|
return [mark for mark in self.marks if mark.newly_spawned]
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def newly_opened_marks(self) -> list[HuntMarkRecord]:
|
|
206
|
+
return [mark for mark in self.marks if mark.newly_opened]
|