python-library-ff14-the-hunt 0.0.9__tar.gz → 0.0.10__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.10}/.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.10}/PKG-INFO +1 -1
  3. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/client.py +112 -112
  4. python_library_ff14_the_hunt-0.0.10/ff14_the_hunt/common/http_request.py +3 -0
  5. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/common/urlopen_retry.py +61 -61
  6. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/ff14_the_hunt.py +43 -43
  7. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/cn.py +5 -4
  8. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/en.py +5 -4
  9. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/models.py +16 -8
  10. python_library_ff14_the_hunt-0.0.10/ff14_the_hunt/poll/spawn_dedup.py +41 -0
  11. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/region_fetch.py +51 -51
  12. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/pyproject.toml +1 -1
  13. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/tests/test_ff14_the_hunt_service.py +21 -21
  14. python_library_ff14_the_hunt-0.0.10/tests/test_spawn_dedup.py +112 -0
  15. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/tests/test_urlopen_retry.py +45 -45
  16. python_library_ff14_the_hunt-0.0.9/ff14_the_hunt/common/http_request.py +0 -3
  17. python_library_ff14_the_hunt-0.0.9/ff14_the_hunt/poll/spawn_dedup.py +0 -29
  18. python_library_ff14_the_hunt-0.0.9/tests/test_spawn_dedup.py +0 -55
  19. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/.cursor/skills/ff14_the_hunt-package-design/SKILL.md +0 -0
  20. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/.cursor/skills/ff14_the_hunt-package-preload/SKILL.md +0 -0
  21. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/.gitignore +0 -0
  22. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/README.md +0 -0
  23. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/example/__main__.py +0 -0
  24. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/example/spawn_map_io.py +0 -0
  25. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/example.bat +0 -0
  26. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/__init__.py +0 -0
  27. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/__init__.py +0 -0
  28. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/enrich.py +0 -0
  29. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/fate_timer.py +0 -0
  30. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/resources.py +0 -0
  31. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/spawn_points.py +0 -0
  32. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/spawn_window.py +0 -0
  33. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/timer_theme.py +0 -0
  34. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/common/__init__.py +0 -0
  35. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/__init__.py +0 -0
  36. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/data/__init__.py +0 -0
  37. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/data/hunts_zh.json +0 -0
  38. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/data/regions_zh.json +0 -0
  39. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/detect.py +0 -0
  40. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/display.py +0 -0
  41. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/names.py +0 -0
  42. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/spawn_map_display.py +0 -0
  43. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/tag.py +0 -0
  44. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/__init__.py +0 -0
  45. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/loop.py +0 -0
  46. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/monitor_mode.py +0 -0
  47. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/scheduler.py +0 -0
  48. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/sleep_plan.py +0 -0
  49. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/sleep_settings.py +0 -0
  50. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/window_remaining.py +0 -0
  51. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/__init__.py +0 -0
  52. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/attach.py +0 -0
  53. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/coordinates.py +0 -0
  54. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/layout.py +0 -0
  55. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/png_meta.py +0 -0
  56. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/points.py +0 -0
  57. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/region_image.py +0 -0
  58. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/test.bat +0 -0
  59. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/tests/__init__.py +0 -0
  60. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/tests/test_bear_tracker_client.py +0 -0
  61. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/tests/test_fate_timer.py +0 -0
  62. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/tests/test_locale_cn.py +0 -0
  63. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/tests/test_monitor_mode.py +0 -0
  64. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/tests/test_poll_scheduler.py +0 -0
  65. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/tests/test_poll_sleep.py +0 -0
  66. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/tests/test_recent_spawn_detection.py +0 -0
  67. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/tests/test_spawn_map_data.py +0 -0
  68. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/tests/test_spawn_map_points.py +0 -0
  69. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/tests/test_spawn_window.py +0 -0
  70. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/tests/test_untimed_marks_filter.py +0 -0
  71. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/tools/build_locale_data.py +0 -0
  72. {python_library_ff14_the_hunt-0.0.9 → python_library_ff14_the_hunt-0.0.10}/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.10
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()
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ DEFAULT_USER_AGENT = "ff14-the-hunt/0.0.9"
@@ -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
@@ -121,10 +121,11 @@ def mark_to_display_dict(
121
121
  "上次死亡时间": mark.last_death_time,
122
122
  "上次标记时间": mark.last_mark_time,
123
123
  "失踪计数": mark.missing_counter,
124
- "维护计时": mark.is_maintenance,
125
- "刚刷新": mark.recently_spawned,
126
- "新检出": mark.newly_spawned,
127
- }
124
+ "维护计时": mark.is_maintenance,
125
+ "刚刷新": mark.recently_spawned,
126
+ "刚开窗": mark.newly_opened,
127
+ "新检出": mark.newly_spawned,
128
+ }
128
129
  if mark.fate_last_seen is not None:
129
130
  item["FATE最近发现"] = mark.fate_last_seen
130
131
  if mark.fate_last_death is not None:
@@ -108,10 +108,11 @@ def mark_to_display_dict(
108
108
  "last_death_time": mark.last_death_time,
109
109
  "last_mark_time": mark.last_mark_time,
110
110
  "missing_counter": mark.missing_counter,
111
- "is_maintenance": mark.is_maintenance,
112
- "recently_spawned": mark.recently_spawned,
113
- "newly_spawned": mark.newly_spawned,
114
- }
111
+ "is_maintenance": mark.is_maintenance,
112
+ "recently_spawned": mark.recently_spawned,
113
+ "newly_opened": mark.newly_opened,
114
+ "newly_spawned": mark.newly_spawned,
115
+ }
115
116
  if mark.fate_last_seen is not None:
116
117
  item["fate_last_seen"] = mark.fate_last_seen
117
118
  if mark.fate_last_death is not None:
@@ -137,11 +137,15 @@ class HuntMarkRecord(BaseModel):
137
137
  default=None,
138
138
  description="仅刚刷新且启用区域原图时填充",
139
139
  )
140
- recently_spawned: bool = False
141
- newly_spawned: bool = Field(
142
- default=False,
143
- description="本轮会话内首次检出的刚刷新;同一死亡周期不重复为 True",
144
- )
140
+ recently_spawned: bool = False
141
+ newly_opened: bool = Field(
142
+ default=False,
143
+ description="First reported trigger-window open event in this polling session.",
144
+ )
145
+ newly_spawned: bool = Field(
146
+ default=False,
147
+ description="First report-worthy event in this polling session.",
148
+ )
145
149
  raw_timer: dict[str, Any] = Field(default_factory=dict)
146
150
 
147
151
 
@@ -193,6 +197,10 @@ class HuntCrawlPacket(BaseModel):
193
197
  marks: list[HuntMarkRecord] = Field(default_factory=list, description="解析后的狩猎记录")
194
198
  query: HuntQueryFilter = Field(description="本次爬取使用的筛选条件")
195
199
 
196
- @property
197
- def newly_spawned_marks(self) -> list[HuntMarkRecord]:
198
- return [mark for mark in self.marks if mark.newly_spawned]
200
+ @property
201
+ def newly_spawned_marks(self) -> list[HuntMarkRecord]:
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]
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from ff14_the_hunt.models import HuntMarkRecord, SpawnWindowPhase
4
+
5
+ SpawnCycleKey = tuple[str, str, float | None]
6
+
7
+
8
+ def spawn_cycle_key(mark: HuntMarkRecord) -> SpawnCycleKey:
9
+ """Return the per-hunt death cycle used for one-shot reports."""
10
+ return (mark.hunt_key, mark.world_name, mark.last_death_time)
11
+
12
+
13
+ class SpawnReportTracker:
14
+ """Track one-shot report events across polling rounds."""
15
+
16
+ def __init__(self) -> None:
17
+ self._reported_spawn_cycles: set[SpawnCycleKey] = set()
18
+ self._reported_open_cycles: set[SpawnCycleKey] = set()
19
+
20
+ def apply(self, marks: list[HuntMarkRecord]) -> None:
21
+ for mark in marks:
22
+ cycle = spawn_cycle_key(mark)
23
+ mark.newly_opened = self._should_report_open_window(mark, cycle)
24
+ mark.newly_spawned = self._should_report_spawn(mark, cycle) or mark.newly_opened
25
+
26
+ def _should_report_spawn(self, mark: HuntMarkRecord, cycle: SpawnCycleKey) -> bool:
27
+ if not mark.recently_spawned:
28
+ return False
29
+ if cycle in self._reported_spawn_cycles:
30
+ return False
31
+ self._reported_spawn_cycles.add(cycle)
32
+ return True
33
+
34
+ def _should_report_open_window(self, mark: HuntMarkRecord, cycle: SpawnCycleKey) -> bool:
35
+ trigger = mark.trigger_timer
36
+ if trigger is None or trigger.phase != SpawnWindowPhase.OPEN:
37
+ return False
38
+ if cycle in self._reported_open_cycles:
39
+ return False
40
+ self._reported_open_cycles.add(cycle)
41
+ return True