python-library-ff14-the-hunt 0.0.7__tar.gz → 0.0.9__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 (69) hide show
  1. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/PKG-INFO +1 -1
  2. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/__init__.py +10 -4
  3. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/bear_tracker/client.py +112 -44
  4. python_library_ff14_the_hunt-0.0.9/ff14_the_hunt/common/http_request.py +3 -0
  5. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/common/urlopen_retry.py +61 -22
  6. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/ff14_the_hunt.py +44 -17
  7. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/spawn_map/region_fetch.py +51 -27
  8. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/pyproject.toml +1 -1
  9. python_library_ff14_the_hunt-0.0.9/tests/test_bear_tracker_client.py +46 -0
  10. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_ff14_the_hunt_service.py +21 -5
  11. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_urlopen_retry.py +44 -5
  12. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tools/build_locale_data.py +8 -7
  13. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/.cursor/skills/ff14_the_hunt-package-changelog/SKILL.md +0 -0
  14. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/.cursor/skills/ff14_the_hunt-package-design/SKILL.md +0 -0
  15. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/.cursor/skills/ff14_the_hunt-package-preload/SKILL.md +0 -0
  16. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/.gitignore +0 -0
  17. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/README.md +0 -0
  18. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/example/__main__.py +0 -0
  19. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/example/spawn_map_io.py +0 -0
  20. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/example.bat +0 -0
  21. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/bear_tracker/__init__.py +0 -0
  22. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/bear_tracker/enrich.py +0 -0
  23. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/bear_tracker/fate_timer.py +0 -0
  24. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/bear_tracker/resources.py +0 -0
  25. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/bear_tracker/spawn_points.py +0 -0
  26. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/bear_tracker/spawn_window.py +0 -0
  27. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/bear_tracker/timer_theme.py +0 -0
  28. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/common/__init__.py +0 -0
  29. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/__init__.py +0 -0
  30. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/cn.py +0 -0
  31. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/data/__init__.py +0 -0
  32. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/data/hunts_zh.json +0 -0
  33. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/data/regions_zh.json +0 -0
  34. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/detect.py +0 -0
  35. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/display.py +0 -0
  36. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/en.py +0 -0
  37. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/names.py +0 -0
  38. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/spawn_map_display.py +0 -0
  39. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/tag.py +0 -0
  40. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/models.py +0 -0
  41. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/poll/__init__.py +0 -0
  42. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/poll/loop.py +0 -0
  43. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/poll/monitor_mode.py +0 -0
  44. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/poll/scheduler.py +0 -0
  45. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/poll/sleep_plan.py +0 -0
  46. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/poll/sleep_settings.py +0 -0
  47. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/poll/spawn_dedup.py +0 -0
  48. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/poll/window_remaining.py +0 -0
  49. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/spawn_map/__init__.py +0 -0
  50. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/spawn_map/attach.py +0 -0
  51. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/spawn_map/coordinates.py +0 -0
  52. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/spawn_map/layout.py +0 -0
  53. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/spawn_map/png_meta.py +0 -0
  54. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/spawn_map/points.py +0 -0
  55. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/spawn_map/region_image.py +0 -0
  56. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/test.bat +0 -0
  57. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/__init__.py +0 -0
  58. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_fate_timer.py +0 -0
  59. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_locale_cn.py +0 -0
  60. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_monitor_mode.py +0 -0
  61. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_poll_scheduler.py +0 -0
  62. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_poll_sleep.py +0 -0
  63. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_recent_spawn_detection.py +0 -0
  64. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_spawn_dedup.py +0 -0
  65. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_spawn_map_data.py +0 -0
  66. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_spawn_map_points.py +0 -0
  67. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_spawn_window.py +0 -0
  68. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_untimed_marks_filter.py +0 -0
  69. {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/update.bat +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-library-ff14-the-hunt
3
- Version: 0.0.7
3
+ Version: 0.0.9
4
4
  Requires-Python: >=3.10
5
5
  Requires-Dist: pydantic>=2.0
6
6
  Provides-Extra: dev
@@ -1,5 +1,9 @@
1
- from ff14_the_hunt.ff14_the_hunt import FF14TheHunt
2
- from ff14_the_hunt.bear_tracker.timer_theme import BEAR_TRACKER_BAR_HEX, bar_hex
1
+ from ff14_the_hunt.ff14_the_hunt import FF14TheHunt
2
+ from ff14_the_hunt.bear_tracker.client import (
3
+ BearTrackerBlockedError,
4
+ BearTrackerRequestError,
5
+ )
6
+ from ff14_the_hunt.bear_tracker.timer_theme import BEAR_TRACKER_BAR_HEX, bar_hex
3
7
  from ff14_the_hunt.models import (
4
8
  HuntCrawlPacket,
5
9
  HuntMarkRecord,
@@ -32,8 +36,10 @@ from ff14_the_hunt.poll import (
32
36
  )
33
37
 
34
38
  __all__ = [
35
- "BEAR_TRACKER_BAR_HEX",
36
- "FF14TheHunt",
39
+ "BEAR_TRACKER_BAR_HEX",
40
+ "BearTrackerBlockedError",
41
+ "BearTrackerRequestError",
42
+ "FF14TheHunt",
37
43
  "HuntDisplayLocale",
38
44
  "HuntCrawlPacket",
39
45
  "HuntMarkRecord",
@@ -1,34 +1,74 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
4
- import urllib.error
5
- import urllib.request
6
- from typing import Any
7
-
8
- from ff14_the_hunt.common.urlopen_retry import urlopen_read
9
-
10
- DEFAULT_BASE_URL = "https://tracker.beartoolkit.com/api"
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."""
11
37
 
12
38
 
13
39
  class BearTrackerClient:
14
40
  """Bear Tracker 站点同源 API 客户端(``/api/*``)。"""
15
41
 
16
- def __init__(
17
- self,
18
- *,
19
- base_url: str = DEFAULT_BASE_URL,
20
- timeout_seconds: float = 120.0,
21
- ) -> None:
22
- self._base_url = base_url.rstrip("/")
23
- self._timeout_seconds = timeout_seconds
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()
24
56
 
25
57
  @property
26
58
  def base_url(self) -> str:
27
59
  return self._base_url
28
60
 
29
61
  @property
30
- def timeout_seconds(self) -> float:
31
- return self._timeout_seconds
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
32
72
 
33
73
  def sync_session(self) -> dict[str, Any]:
34
74
  """拉取会话与 ``resources``(含 DatabaseHunt、SpawnPoint、DataCenters)。"""
@@ -83,29 +123,57 @@ class BearTrackerClient:
83
123
  return result.get("data", result)
84
124
  return {}
85
125
 
86
- def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
87
- url = f"{self._base_url}{path}"
88
- encoded = json.dumps(body).encode("utf-8")
89
- request = urllib.request.Request(
90
- url,
91
- data=encoded,
92
- headers={
93
- "User-Agent": "python-library-ff14-the-hunt",
94
- "Content-Type": "application/json",
95
- "Accept": "application/json",
96
- "Origin": "https://tracker.beartoolkit.com",
97
- "Referer": "https://tracker.beartoolkit.com/timer",
98
- },
99
- method="POST",
100
- )
101
- try:
102
- raw = urlopen_read(request, timeout=self._timeout_seconds)
103
- except urllib.error.HTTPError as exc:
104
- detail = exc.read().decode("utf-8", errors="replace")
105
- raise RuntimeError(
106
- f"Bear Tracker API {path} failed: HTTP {exc.code}: {detail}"
107
- ) from exc
108
- parsed = json.loads(raw.decode("utf-8"))
109
- if not isinstance(parsed, dict):
110
- raise RuntimeError(f"unexpected response type from {path}")
111
- return parsed
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.8"
@@ -1,14 +1,39 @@
1
1
  from __future__ import annotations
2
2
 
3
- import ssl
4
- import time
5
- import urllib.error
6
- import urllib.request
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
7
10
 
8
11
  _RETRYABLE_HTTP_CODES = frozenset({408, 429, 500, 502, 503, 504})
9
- _DEFAULT_MAX_ATTEMPTS = 4
10
- _DEFAULT_INITIAL_BACKOFF_SECONDS = 1.0
11
- _DEFAULT_MAX_BACKOFF_SECONDS = 8.0
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
37
 
13
38
 
14
39
  def is_retryable_urlopen_error(exc: BaseException) -> bool:
@@ -29,14 +54,17 @@ def is_retryable_urlopen_error(exc: BaseException) -> bool:
29
54
  return False
30
55
 
31
56
 
32
- def urlopen_read(
33
- request: urllib.request.Request,
34
- *,
35
- timeout: float,
36
- max_attempts: int = _DEFAULT_MAX_ATTEMPTS,
37
- initial_backoff_seconds: float = _DEFAULT_INITIAL_BACKOFF_SECONDS,
38
- max_backoff_seconds: float = _DEFAULT_MAX_BACKOFF_SECONDS,
39
- ) -> 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:
40
68
  """``urlopen`` 读响应体;瞬时网络或 TLS 失败时指数退避重试。
41
69
 
42
70
  Args:
@@ -54,8 +82,12 @@ def urlopen_read(
54
82
  urllib.error.URLError: 重试耗尽后的网络或 TLS 错误。
55
83
  TimeoutError: 重试耗尽后的超时。
56
84
  """
57
- if max_attempts < 1:
58
- raise ValueError("max_attempts must be >= 1")
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")
59
91
 
60
92
  last_exc: BaseException | None = None
61
93
  backoff = initial_backoff_seconds
@@ -64,11 +96,18 @@ def urlopen_read(
64
96
  with urllib.request.urlopen(request, timeout=timeout) as response:
65
97
  return response.read()
66
98
  except BaseException as exc:
67
- last_exc = exc
68
- if attempt + 1 >= max_attempts or not is_retryable_urlopen_error(exc):
69
- raise
70
- time.sleep(min(backoff, max_backoff_seconds))
71
- 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)
72
111
 
73
112
  if last_exc is not None:
74
113
  raise last_exc
@@ -5,10 +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 BearTrackerClient
9
- from ff14_the_hunt.bear_tracker.enrich import build_hunt_record, mark_has_display_timer
10
- from ff14_the_hunt.bear_tracker.resources import BearResources
11
- 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
12
16
  from ff14_the_hunt.models import (
13
17
  HuntCrawlPacket,
14
18
  HuntMarkRecord,
@@ -22,8 +26,9 @@ from ff14_the_hunt.spawn_map.region_fetch import RegionMapFetcher, site_root_fro
22
26
 
23
27
  HuntCrawlCallback = Callable[[HuntCrawlPacket], None]
24
28
 
25
- _log = logging.getLogger(__name__)
26
- _FETCH_FAILURE_RETRY_SECONDS = 60.0
29
+ _log = logging.getLogger(__name__)
30
+ _FETCH_FAILURE_RETRY_SECONDS = 60.0
31
+ _BLOCKED_RETRY_SECONDS = 900.0
27
32
 
28
33
 
29
34
  class FF14TheHunt:
@@ -37,10 +42,12 @@ class FF14TheHunt:
37
42
  rank_kinds: list[HuntRankKind] | None = None,
38
43
  patches: list[str] | None = None,
39
44
  hunt_keys: list[str] | None = None,
40
- regions: list[str] | None = None,
41
- base_url: str | None = None,
42
- timeout_seconds: float = 120.0,
43
- 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,
44
51
  recent_poll_interval_seconds: float = 300.0,
45
52
  fallback_poll_interval_seconds: float = 1800.0,
46
53
  min_wakeup_seconds: float = 120.0,
@@ -67,11 +74,15 @@ class FF14TheHunt:
67
74
  min_wakeup_seconds: 全未开窗时睡到最近开窗的下限(秒),默认 2 分钟。
68
75
  poll_interval_seconds: 已废弃;等同 ``fallback_poll_interval_seconds``。
69
76
  recent_grace_seconds: 「刚刷新」宽限秒数。
70
- include_spawn_states: 已废弃;刚刷新记录的候选点会在补全刷点时自动查询。
77
+ include_spawn_states: 已废弃;刚刷新记录的候选点会在补全刷点时自动查询。
71
78
  include_spawn_maps: 为刚刷新记录拉取站点区域原图 PNG base64。
72
79
  include_untimed_marks: 是否保留无计时的占位行(SS 级噬灵王、维护占位等)。
73
80
  """
74
- kwargs: dict[str, float | str] = {"timeout_seconds": timeout_seconds}
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
+ }
75
86
  if base_url is not None:
76
87
  kwargs["base_url"] = base_url
77
88
  self._client = BearTrackerClient(**kwargs)
@@ -288,10 +299,12 @@ class FF14TheHunt:
288
299
 
289
300
  def _region_map_fetcher(self) -> RegionMapFetcher:
290
301
  if self._map_fetcher is None:
291
- self._map_fetcher = RegionMapFetcher(
292
- site_root=site_root_from_api_base(self._client.base_url),
293
- timeout_seconds=self._client.timeout_seconds,
294
- )
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
+ )
295
308
  return self._map_fetcher
296
309
 
297
310
  def recently_spawned(
@@ -340,7 +353,21 @@ class FF14TheHunt:
340
353
  marks = self._scheduler.fetch()
341
354
  except (KeyboardInterrupt, SystemExit):
342
355
  raise
343
- 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:
344
371
  _log.warning("狩猎爬取失败,%s 秒后重试:%s", _FETCH_FAILURE_RETRY_SECONDS, exc)
345
372
  wait_or_stop(stop_event, _FETCH_FAILURE_RETRY_SECONDS)
346
373
  return
@@ -1,10 +1,13 @@
1
- from __future__ import annotations
2
-
3
- import urllib.error
4
- import urllib.parse
5
- import urllib.request
6
-
7
- from ff14_the_hunt.common.urlopen_retry import urlopen_read
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+
9
+ from ff14_the_hunt.common.http_request import DEFAULT_USER_AGENT
10
+ from ff14_the_hunt.common.urlopen_retry import urlopen_read
8
11
 
9
12
 
10
13
  def site_root_from_api_base(base_url: str) -> str:
@@ -25,15 +28,21 @@ def region_map_image_url(site_root: str, region: str) -> str:
25
28
  class RegionMapFetcher:
26
29
  """按区域名拉取并缓存狩猎地图原图字节。"""
27
30
 
28
- def __init__(
29
- self,
30
- *,
31
- site_root: str,
32
- timeout_seconds: float = 120.0,
33
- ) -> None:
34
- self._site_root = site_root.rstrip("/")
35
- self._timeout_seconds = timeout_seconds
36
- self._cache: dict[str, bytes] = {}
31
+ def __init__(
32
+ self,
33
+ *,
34
+ site_root: str,
35
+ timeout_seconds: float = 120.0,
36
+ min_request_interval_seconds: float = 1.0,
37
+ user_agent: str = DEFAULT_USER_AGENT,
38
+ ) -> None:
39
+ self._site_root = site_root.rstrip("/")
40
+ self._timeout_seconds = timeout_seconds
41
+ self._min_request_interval_seconds = min_request_interval_seconds
42
+ self._user_agent = user_agent
43
+ self._last_request_at = 0.0
44
+ self._request_lock = threading.Lock()
45
+ self._cache: dict[str, bytes] = {}
37
46
 
38
47
  @property
39
48
  def site_root(self) -> str:
@@ -56,15 +65,18 @@ class RegionMapFetcher:
56
65
  return cached
57
66
  url = region_map_image_url(self._site_root, region)
58
67
  request = urllib.request.Request(
59
- url,
60
- headers={
61
- "User-Agent": "python-library-ff14-the-hunt",
62
- "Referer": f"{self._site_root}/timer",
63
- },
64
- method="GET",
65
- )
66
- try:
67
- data = urlopen_read(request, timeout=self._timeout_seconds)
68
+ url,
69
+ headers={
70
+ "User-Agent": self._user_agent,
71
+ "Accept": "image/avif,image/webp,image/apng,image/png,image/*,*/*;q=0.8",
72
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
73
+ "Referer": f"{self._site_root}/timer",
74
+ },
75
+ method="GET",
76
+ )
77
+ try:
78
+ self._pace_request()
79
+ data = urlopen_read(request, timeout=self._timeout_seconds)
68
80
  except urllib.error.HTTPError as exc:
69
81
  detail = exc.read().decode("utf-8", errors="replace")
70
82
  raise RuntimeError(
@@ -72,5 +84,17 @@ class RegionMapFetcher:
72
84
  ) from exc
73
85
  except urllib.error.URLError as exc:
74
86
  raise RuntimeError(f"region map {region} failed: {exc}") from exc
75
- self._cache[region] = data
76
- return data
87
+ self._cache[region] = data
88
+ return data
89
+
90
+ def _pace_request(self) -> None:
91
+ if self._min_request_interval_seconds <= 0:
92
+ return
93
+ with self._request_lock:
94
+ now = time.monotonic()
95
+ wait_seconds = self._min_request_interval_seconds - (
96
+ now - self._last_request_at
97
+ )
98
+ if wait_seconds > 0:
99
+ time.sleep(wait_seconds)
100
+ self._last_request_at = time.monotonic()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-library-ff14-the-hunt"
7
- version = "0.0.7"
7
+ version = "0.0.9"
8
8
  requires-python = ">=3.10"
9
9
  dependencies = [
10
10
  "pydantic>=2.0",
@@ -0,0 +1,46 @@
1
+ import urllib.error
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ import pytest
5
+
6
+ from ff14_the_hunt.bear_tracker.client import (
7
+ BearTrackerBlockedError,
8
+ BearTrackerClient,
9
+ )
10
+
11
+
12
+ def test_post_raises_blocked_error_for_forbidden() -> None:
13
+ client = BearTrackerClient(min_request_interval_seconds=0.0)
14
+ http_error = urllib.error.HTTPError(
15
+ url="https://tracker.beartoolkit.com/api/syncSession",
16
+ code=403,
17
+ msg="Forbidden",
18
+ hdrs={"Retry-After": "60"},
19
+ fp=None,
20
+ )
21
+
22
+ with patch(
23
+ "ff14_the_hunt.bear_tracker.client.urlopen_read",
24
+ side_effect=http_error,
25
+ ):
26
+ with pytest.raises(BearTrackerBlockedError) as raised:
27
+ client.sync_session()
28
+
29
+ assert raised.value.status_code == 403
30
+ assert raised.value.retry_after_seconds == 60.0
31
+
32
+
33
+ def test_client_paces_consecutive_requests() -> None:
34
+ client = BearTrackerClient(min_request_interval_seconds=1.0)
35
+ sleep = MagicMock()
36
+
37
+ with patch("ff14_the_hunt.bear_tracker.client.time.sleep", sleep):
38
+ with patch(
39
+ "ff14_the_hunt.bear_tracker.client.time.monotonic",
40
+ side_effect=[10.0, 10.0, 10.2, 11.0],
41
+ ):
42
+ client._pace_request() # type: ignore[attr-defined]
43
+ client._pace_request() # type: ignore[attr-defined]
44
+
45
+ sleep.assert_called_once()
46
+ assert sleep.call_args.args[0] == pytest.approx(0.8)
@@ -3,8 +3,9 @@ import time
3
3
  import urllib.error
4
4
  from unittest.mock import MagicMock, patch
5
5
 
6
- from ff14_the_hunt import FF14TheHunt, HuntCrawlPacket, HuntQueryFilter, HuntRankKind
7
- from ff14_the_hunt.bear_tracker.timer_theme import build_timer_display
6
+ from ff14_the_hunt import FF14TheHunt, HuntCrawlPacket, HuntQueryFilter, HuntRankKind
7
+ from ff14_the_hunt.bear_tracker.client import BearTrackerBlockedError
8
+ from ff14_the_hunt.bear_tracker.timer_theme import build_timer_display
8
9
  from ff14_the_hunt.models import (
9
10
  HuntMarkRecord,
10
11
  SpawnWindowPhase,
@@ -118,7 +119,7 @@ def test_poll_loop_survives_callback_failure() -> None:
118
119
  hunt._poll_once_or_wait(stop_event) # type: ignore[attr-defined]
119
120
 
120
121
 
121
- def test_poll_loop_survives_fetch_failure() -> None:
122
+ def test_poll_loop_survives_fetch_failure() -> None:
122
123
  hunt, mock_query = _hunt_with_mock_query()
123
124
  seen: list[HuntCrawlPacket] = []
124
125
  hunt.on_crawl(lambda packet: seen.append(packet))
@@ -132,8 +133,23 @@ def test_poll_loop_survives_fetch_failure() -> None:
132
133
  hunt._poll_once_or_wait(stop_event) # type: ignore[attr-defined]
133
134
  hunt._poll_once_or_wait(stop_event) # type: ignore[attr-defined]
134
135
 
135
- assert mock_query.call_count == 2
136
- assert len(seen) == 1
136
+ assert mock_query.call_count == 2
137
+ assert len(seen) == 1
138
+
139
+
140
+ def test_poll_loop_uses_blocked_retry_after() -> None:
141
+ hunt, mock_query = _hunt_with_mock_query()
142
+ mock_query.side_effect = BearTrackerBlockedError(
143
+ "blocked",
144
+ status_code=429,
145
+ retry_after_seconds=180.0,
146
+ )
147
+ stop_event = threading.Event()
148
+
149
+ with patch("ff14_the_hunt.ff14_the_hunt.wait_or_stop", return_value=False) as wait:
150
+ hunt._poll_once_or_wait(stop_event) # type: ignore[attr-defined]
151
+
152
+ wait.assert_called_once_with(stop_event, 180.0)
137
153
 
138
154
 
139
155
  def test_wait_or_stop_wakes_early_on_stop() -> None:
@@ -4,7 +4,11 @@ from unittest.mock import MagicMock, patch
4
4
 
5
5
  import pytest
6
6
 
7
- from ff14_the_hunt.common.urlopen_retry import is_retryable_urlopen_error, urlopen_read
7
+ from ff14_the_hunt.common.urlopen_retry import (
8
+ is_retryable_urlopen_error,
9
+ retry_after_seconds_from_headers,
10
+ urlopen_read,
11
+ )
8
12
 
9
13
 
10
14
  def test_is_retryable_urlopen_error_for_ssl_eof() -> None:
@@ -24,7 +28,7 @@ def test_is_retryable_urlopen_error_for_http_404() -> None:
24
28
  assert is_retryable_urlopen_error(http_error) is False
25
29
 
26
30
 
27
- def test_urlopen_read_retries_transient_failure() -> None:
31
+ def test_urlopen_read_retries_transient_failure() -> None:
28
32
  request = MagicMock()
29
33
  response = MagicMock()
30
34
  response.read.return_value = b"ok"
@@ -39,10 +43,45 @@ def test_urlopen_read_retries_transient_failure() -> None:
39
43
  "ff14_the_hunt.common.urlopen_retry.urllib.request.urlopen",
40
44
  side_effect=[url_error, response],
41
45
  ) as mock_urlopen:
42
- raw = urlopen_read(request, timeout=5.0, max_attempts=3)
46
+ raw = urlopen_read(request, timeout=5.0, max_attempts=3)
43
47
 
44
- assert raw == b"ok"
45
- assert mock_urlopen.call_count == 2
48
+ assert raw == b"ok"
49
+ assert mock_urlopen.call_count == 2
50
+
51
+
52
+ def test_retry_after_seconds_from_headers_delta_seconds() -> None:
53
+ assert retry_after_seconds_from_headers({"Retry-After": "12"}) == 12.0
54
+
55
+
56
+ def test_urlopen_read_respects_retry_after_header() -> None:
57
+ request = MagicMock()
58
+ response = MagicMock()
59
+ response.read.return_value = b"ok"
60
+ response.__enter__.return_value = response
61
+ response.__exit__.return_value = False
62
+ http_error = urllib.error.HTTPError(
63
+ url="https://example.test",
64
+ code=429,
65
+ msg="Too Many Requests",
66
+ hdrs={"Retry-After": "7"},
67
+ fp=None,
68
+ )
69
+ sleep = MagicMock()
70
+
71
+ with patch(
72
+ "ff14_the_hunt.common.urlopen_retry.urllib.request.urlopen",
73
+ side_effect=[http_error, response],
74
+ ):
75
+ raw = urlopen_read(
76
+ request,
77
+ timeout=5.0,
78
+ max_attempts=2,
79
+ jitter_ratio=0.0,
80
+ sleep=sleep,
81
+ )
82
+
83
+ assert raw == b"ok"
84
+ sleep.assert_called_once_with(7.0)
46
85
 
47
86
 
48
87
  def test_urlopen_read_raises_after_exhausted_retries() -> None:
@@ -9,9 +9,10 @@ import re
9
9
  import sys
10
10
  import urllib.error
11
11
  import urllib.request
12
- from pathlib import Path
13
-
14
- from ff14_the_hunt import FF14TheHunt
12
+ from pathlib import Path
13
+
14
+ from ff14_the_hunt import FF14TheHunt
15
+ from ff14_the_hunt.common.http_request import DEFAULT_USER_AGENT
15
16
 
16
17
  DATAMINING_BASE = (
17
18
  "https://cdn.jsdelivr.net/gh/InfSein/ffxiv-datamining-mixed@master"
@@ -26,10 +27,10 @@ HUNT_PREFIX_ZH: dict[str, str] = {
26
27
 
27
28
 
28
29
  def _download_text(url: str, *, timeout: float = 180.0) -> str:
29
- request = urllib.request.Request(
30
- url,
31
- headers={"User-Agent": "python-library-ff14-the-hunt"},
32
- )
30
+ request = urllib.request.Request(
31
+ url,
32
+ headers={"User-Agent": DEFAULT_USER_AGENT},
33
+ )
33
34
  with urllib.request.urlopen(request, timeout=timeout) as response:
34
35
  return response.read().decode("utf-8-sig")
35
36