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.
Files changed (72) hide show
  1. {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
  2. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/PKG-INFO +1 -1
  3. {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
  4. {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
  5. {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
  6. {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
  7. {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
  8. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/cn.py +2 -1
  9. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/en.py +1 -0
  10. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/models.py +11 -3
  11. python_library_ff14_the_hunt-0.0.11/ff14_the_hunt/poll/spawn_dedup.py +43 -0
  12. {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
  13. {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
  14. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/pyproject.toml +1 -1
  15. {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
  16. python_library_ff14_the_hunt-0.0.11/tests/test_spawn_dedup.py +211 -0
  17. python_library_ff14_the_hunt-0.0.11/tests/test_spawn_map_data.py +194 -0
  18. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_urlopen_retry.py +45 -45
  19. python_library_ff14_the_hunt-0.0.9/ff14_the_hunt/poll/spawn_dedup.py +0 -29
  20. python_library_ff14_the_hunt-0.0.9/tests/test_spawn_dedup.py +0 -55
  21. python_library_ff14_the_hunt-0.0.9/tests/test_spawn_map_data.py +0 -74
  22. {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
  23. {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
  24. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/.gitignore +0 -0
  25. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/README.md +0 -0
  26. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/example/__main__.py +0 -0
  27. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/example/spawn_map_io.py +0 -0
  28. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/example.bat +0 -0
  29. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/__init__.py +0 -0
  30. {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
  31. {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
  32. {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
  33. {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
  34. {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
  35. {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
  36. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/common/__init__.py +0 -0
  37. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/__init__.py +0 -0
  38. {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
  39. {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
  40. {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
  41. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/detect.py +0 -0
  42. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/display.py +0 -0
  43. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/names.py +0 -0
  44. {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
  45. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/tag.py +0 -0
  46. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/__init__.py +0 -0
  47. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/loop.py +0 -0
  48. {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
  49. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/scheduler.py +0 -0
  50. {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
  51. {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
  52. {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
  53. {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
  54. {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
  55. {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
  56. {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
  57. {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
  58. {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
  59. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/test.bat +0 -0
  60. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/__init__.py +0 -0
  61. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_bear_tracker_client.py +0 -0
  62. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_fate_timer.py +0 -0
  63. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_locale_cn.py +0 -0
  64. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_monitor_mode.py +0 -0
  65. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_poll_scheduler.py +0 -0
  66. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_poll_sleep.py +0 -0
  67. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_recent_spawn_detection.py +0 -0
  68. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_spawn_map_points.py +0 -0
  69. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_spawn_window.py +0 -0
  70. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tests/test_untimed_marks_filter.py +0 -0
  71. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.11}/tools/build_locale_data.py +0 -0
  72. {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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-library-ff14-the-hunt
3
- Version: 0.0.9
3
+ Version: 0.0.11
4
4
  Requires-Python: >=3.10
5
5
  Requires-Dist: pydantic>=2.0
6
6
  Provides-Extra: dev
@@ -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 is_recently_in_window(
93
- timer: TimerDisplay | None,
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:
@@ -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="本轮会话内首次检出的刚刷新;同一死亡周期不重复为 True",
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]