python-library-ff14-the-hunt 0.0.8__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.
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/.cursor/skills/ff14_the_hunt-package-changelog/SKILL.md +4 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/PKG-INFO +1 -1
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/__init__.py +10 -4
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/client.py +78 -10
- python_library_ff14_the_hunt-0.0.10/ff14_the_hunt/common/http_request.py +3 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/common/urlopen_retry.py +40 -1
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/ff14_the_hunt.py +29 -2
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/cn.py +5 -4
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/en.py +5 -4
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/models.py +16 -8
- python_library_ff14_the_hunt-0.0.10/ff14_the_hunt/poll/spawn_dedup.py +41 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/region_fetch.py +25 -1
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/pyproject.toml +1 -1
- python_library_ff14_the_hunt-0.0.10/tests/test_bear_tracker_client.py +46 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_ff14_the_hunt_service.py +16 -0
- python_library_ff14_the_hunt-0.0.10/tests/test_spawn_dedup.py +112 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_urlopen_retry.py +40 -1
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tools/build_locale_data.py +8 -7
- python_library_ff14_the_hunt-0.0.8/ff14_the_hunt/poll/spawn_dedup.py +0 -29
- python_library_ff14_the_hunt-0.0.8/tests/test_spawn_dedup.py +0 -55
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/.cursor/skills/ff14_the_hunt-package-design/SKILL.md +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/.cursor/skills/ff14_the_hunt-package-preload/SKILL.md +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/.gitignore +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/README.md +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/example/__main__.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/example/spawn_map_io.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/example.bat +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/enrich.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/fate_timer.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/resources.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/spawn_points.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/spawn_window.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/timer_theme.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/common/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/data/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/data/hunts_zh.json +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/data/regions_zh.json +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/detect.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/display.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/names.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/spawn_map_display.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/tag.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/loop.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/monitor_mode.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/scheduler.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/sleep_plan.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/sleep_settings.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/window_remaining.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/attach.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/coordinates.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/layout.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/png_meta.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/points.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/region_image.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/test.bat +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_fate_timer.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_locale_cn.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_monitor_mode.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_poll_scheduler.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_poll_sleep.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_recent_spawn_detection.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_spawn_map_data.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_spawn_map_points.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_spawn_window.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_untimed_marks_filter.py +0 -0
- {python_library_ff14_the_hunt-0.0.8 → 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``。
|
{python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/__init__.py
RENAMED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
from ff14_the_hunt.ff14_the_hunt import FF14TheHunt
|
|
2
|
-
from ff14_the_hunt.bear_tracker.
|
|
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
|
-
"
|
|
39
|
+
"BEAR_TRACKER_BAR_HEX",
|
|
40
|
+
"BearTrackerBlockedError",
|
|
41
|
+
"BearTrackerRequestError",
|
|
42
|
+
"FF14TheHunt",
|
|
37
43
|
"HuntDisplayLocale",
|
|
38
44
|
"HuntCrawlPacket",
|
|
39
45
|
"HuntMarkRecord",
|
|
@@ -1,13 +1,39 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
4
6
|
import urllib.error
|
|
5
7
|
import urllib.request
|
|
6
8
|
from typing import Any
|
|
7
9
|
|
|
8
|
-
from ff14_the_hunt.common.urlopen_retry import
|
|
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
|
|
9
15
|
|
|
10
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:
|
|
@@ -18,9 +44,15 @@ class BearTrackerClient:
|
|
|
18
44
|
*,
|
|
19
45
|
base_url: str = DEFAULT_BASE_URL,
|
|
20
46
|
timeout_seconds: float = 120.0,
|
|
47
|
+
min_request_interval_seconds: float = 1.0,
|
|
48
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
21
49
|
) -> None:
|
|
22
50
|
self._base_url = base_url.rstrip("/")
|
|
23
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:
|
|
@@ -30,6 +62,14 @@ class BearTrackerClient:
|
|
|
30
62
|
def timeout_seconds(self) -> float:
|
|
31
63
|
return self._timeout_seconds
|
|
32
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
|
+
|
|
33
73
|
def sync_session(self) -> dict[str, Any]:
|
|
34
74
|
"""拉取会话与 ``resources``(含 DatabaseHunt、SpawnPoint、DataCenters)。"""
|
|
35
75
|
return self._post("/syncSession", {})
|
|
@@ -89,23 +129,51 @@ class BearTrackerClient:
|
|
|
89
129
|
request = urllib.request.Request(
|
|
90
130
|
url,
|
|
91
131
|
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
|
-
},
|
|
132
|
+
headers=self._headers(),
|
|
99
133
|
method="POST",
|
|
100
134
|
)
|
|
101
135
|
try:
|
|
136
|
+
self._pace_request()
|
|
102
137
|
raw = urlopen_read(request, timeout=self._timeout_seconds)
|
|
103
138
|
except urllib.error.HTTPError as exc:
|
|
104
139
|
detail = exc.read().decode("utf-8", errors="replace")
|
|
105
|
-
|
|
106
|
-
|
|
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,
|
|
107
151
|
) from exc
|
|
152
|
+
except urllib.error.URLError as exc:
|
|
153
|
+
raise BearTrackerRequestError(f"Bear Tracker API {path} failed: {exc}") from exc
|
|
108
154
|
parsed = json.loads(raw.decode("utf-8"))
|
|
109
155
|
if not isinstance(parsed, dict):
|
|
110
156
|
raise RuntimeError(f"unexpected response type from {path}")
|
|
111
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()
|
|
@@ -4,11 +4,36 @@ import ssl
|
|
|
4
4
|
import time
|
|
5
5
|
import urllib.error
|
|
6
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
12
|
_DEFAULT_MAX_ATTEMPTS = 4
|
|
10
13
|
_DEFAULT_INITIAL_BACKOFF_SECONDS = 1.0
|
|
11
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:
|
|
@@ -36,6 +61,9 @@ def urlopen_read(
|
|
|
36
61
|
max_attempts: int = _DEFAULT_MAX_ATTEMPTS,
|
|
37
62
|
initial_backoff_seconds: float = _DEFAULT_INITIAL_BACKOFF_SECONDS,
|
|
38
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,
|
|
39
67
|
) -> bytes:
|
|
40
68
|
"""``urlopen`` 读响应体;瞬时网络或 TLS 失败时指数退避重试。
|
|
41
69
|
|
|
@@ -56,6 +84,10 @@ def urlopen_read(
|
|
|
56
84
|
"""
|
|
57
85
|
if max_attempts < 1:
|
|
58
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
|
|
@@ -67,7 +99,14 @@ def urlopen_read(
|
|
|
67
99
|
last_exc = exc
|
|
68
100
|
if attempt + 1 >= max_attempts or not is_retryable_urlopen_error(exc):
|
|
69
101
|
raise
|
|
70
|
-
|
|
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)
|
|
71
110
|
backoff = min(backoff * 2.0, max_backoff_seconds)
|
|
72
111
|
|
|
73
112
|
if last_exc is not None:
|
|
@@ -5,9 +5,13 @@ import threading
|
|
|
5
5
|
import time
|
|
6
6
|
from collections.abc import Callable
|
|
7
7
|
|
|
8
|
-
from ff14_the_hunt.bear_tracker.client import
|
|
8
|
+
from ff14_the_hunt.bear_tracker.client import (
|
|
9
|
+
BearTrackerBlockedError,
|
|
10
|
+
BearTrackerClient,
|
|
11
|
+
)
|
|
9
12
|
from ff14_the_hunt.bear_tracker.enrich import build_hunt_record, mark_has_display_timer
|
|
10
13
|
from ff14_the_hunt.bear_tracker.resources import BearResources
|
|
14
|
+
from ff14_the_hunt.common.http_request import DEFAULT_USER_AGENT
|
|
11
15
|
from ff14_the_hunt.locale.cn import normalize_patch_codes
|
|
12
16
|
from ff14_the_hunt.models import (
|
|
13
17
|
HuntCrawlPacket,
|
|
@@ -24,6 +28,7 @@ HuntCrawlCallback = Callable[[HuntCrawlPacket], None]
|
|
|
24
28
|
|
|
25
29
|
_log = logging.getLogger(__name__)
|
|
26
30
|
_FETCH_FAILURE_RETRY_SECONDS = 60.0
|
|
31
|
+
_BLOCKED_RETRY_SECONDS = 900.0
|
|
27
32
|
|
|
28
33
|
|
|
29
34
|
class FF14TheHunt:
|
|
@@ -40,6 +45,8 @@ class FF14TheHunt:
|
|
|
40
45
|
regions: list[str] | None = None,
|
|
41
46
|
base_url: str | None = None,
|
|
42
47
|
timeout_seconds: float = 120.0,
|
|
48
|
+
min_request_interval_seconds: float = 1.0,
|
|
49
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
43
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,
|
|
@@ -71,7 +78,11 @@ class FF14TheHunt:
|
|
|
71
78
|
include_spawn_maps: 为刚刷新记录拉取站点区域原图 PNG base64。
|
|
72
79
|
include_untimed_marks: 是否保留无计时的占位行(SS 级噬灵王、维护占位等)。
|
|
73
80
|
"""
|
|
74
|
-
kwargs: dict[str, float | str] = {
|
|
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)
|
|
@@ -291,6 +302,8 @@ class FF14TheHunt:
|
|
|
291
302
|
self._map_fetcher = RegionMapFetcher(
|
|
292
303
|
site_root=site_root_from_api_base(self._client.base_url),
|
|
293
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,
|
|
294
307
|
)
|
|
295
308
|
return self._map_fetcher
|
|
296
309
|
|
|
@@ -340,6 +353,20 @@ class FF14TheHunt:
|
|
|
340
353
|
marks = self._scheduler.fetch()
|
|
341
354
|
except (KeyboardInterrupt, SystemExit):
|
|
342
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
|
|
343
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)
|
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
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:
|
{python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/models.py
RENAMED
|
@@ -137,11 +137,15 @@ class HuntMarkRecord(BaseModel):
|
|
|
137
137
|
default=None,
|
|
138
138
|
description="仅刚刷新且启用区域原图时填充",
|
|
139
139
|
)
|
|
140
|
-
recently_spawned: bool = False
|
|
141
|
-
|
|
142
|
-
default=False,
|
|
143
|
-
description="
|
|
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
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
3
5
|
import urllib.error
|
|
4
6
|
import urllib.parse
|
|
5
7
|
import urllib.request
|
|
6
8
|
|
|
9
|
+
from ff14_the_hunt.common.http_request import DEFAULT_USER_AGENT
|
|
7
10
|
from ff14_the_hunt.common.urlopen_retry import urlopen_read
|
|
8
11
|
|
|
9
12
|
|
|
@@ -30,9 +33,15 @@ class RegionMapFetcher:
|
|
|
30
33
|
*,
|
|
31
34
|
site_root: str,
|
|
32
35
|
timeout_seconds: float = 120.0,
|
|
36
|
+
min_request_interval_seconds: float = 1.0,
|
|
37
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
33
38
|
) -> None:
|
|
34
39
|
self._site_root = site_root.rstrip("/")
|
|
35
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()
|
|
36
45
|
self._cache: dict[str, bytes] = {}
|
|
37
46
|
|
|
38
47
|
@property
|
|
@@ -58,12 +67,15 @@ class RegionMapFetcher:
|
|
|
58
67
|
request = urllib.request.Request(
|
|
59
68
|
url,
|
|
60
69
|
headers={
|
|
61
|
-
"User-Agent":
|
|
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",
|
|
62
73
|
"Referer": f"{self._site_root}/timer",
|
|
63
74
|
},
|
|
64
75
|
method="GET",
|
|
65
76
|
)
|
|
66
77
|
try:
|
|
78
|
+
self._pace_request()
|
|
67
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")
|
|
@@ -74,3 +86,15 @@ class RegionMapFetcher:
|
|
|
74
86
|
raise RuntimeError(f"region map {region} failed: {exc}") from exc
|
|
75
87
|
self._cache[region] = data
|
|
76
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()
|
|
@@ -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)
|
|
@@ -4,6 +4,7 @@ import urllib.error
|
|
|
4
4
|
from unittest.mock import MagicMock, patch
|
|
5
5
|
|
|
6
6
|
from ff14_the_hunt import FF14TheHunt, HuntCrawlPacket, HuntQueryFilter, HuntRankKind
|
|
7
|
+
from ff14_the_hunt.bear_tracker.client import BearTrackerBlockedError
|
|
7
8
|
from ff14_the_hunt.bear_tracker.timer_theme import build_timer_display
|
|
8
9
|
from ff14_the_hunt.models import (
|
|
9
10
|
HuntMarkRecord,
|
|
@@ -136,6 +137,21 @@ def test_poll_loop_survives_fetch_failure() -> None:
|
|
|
136
137
|
assert len(seen) == 1
|
|
137
138
|
|
|
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)
|
|
153
|
+
|
|
154
|
+
|
|
139
155
|
def test_wait_or_stop_wakes_early_on_stop() -> None:
|
|
140
156
|
stop_event = threading.Event()
|
|
141
157
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from ff14_the_hunt.bear_tracker.timer_theme import build_timer_display
|
|
2
|
+
from ff14_the_hunt.models import (
|
|
3
|
+
HuntCrawlPacket,
|
|
4
|
+
HuntMarkRecord,
|
|
5
|
+
HuntQueryFilter,
|
|
6
|
+
SpawnWindowPhase,
|
|
7
|
+
TimerBarColor,
|
|
8
|
+
TimerKind,
|
|
9
|
+
)
|
|
10
|
+
from ff14_the_hunt.poll.spawn_dedup import SpawnReportTracker, spawn_cycle_key
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _mark(
|
|
14
|
+
*,
|
|
15
|
+
hunt_key: str = "Foo",
|
|
16
|
+
world: str = "Bar",
|
|
17
|
+
last_death: float = 1000.0,
|
|
18
|
+
recently: bool = True,
|
|
19
|
+
phase: SpawnWindowPhase | None = None,
|
|
20
|
+
) -> HuntMarkRecord:
|
|
21
|
+
trigger_timer = None
|
|
22
|
+
if phase is not None:
|
|
23
|
+
trigger_timer = build_timer_display(
|
|
24
|
+
kind=TimerKind.TRIGGER,
|
|
25
|
+
phase=phase,
|
|
26
|
+
bar_color=TimerBarColor.SUCCESS
|
|
27
|
+
if phase == SpawnWindowPhase.OPEN
|
|
28
|
+
else TimerBarColor.ERROR,
|
|
29
|
+
counts_up=phase == SpawnWindowPhase.OPEN,
|
|
30
|
+
summary="test",
|
|
31
|
+
)
|
|
32
|
+
return HuntMarkRecord(
|
|
33
|
+
hunt_key=hunt_key,
|
|
34
|
+
hunt_name=hunt_key,
|
|
35
|
+
world_name=world,
|
|
36
|
+
last_death_time=last_death,
|
|
37
|
+
recently_spawned=recently,
|
|
38
|
+
trigger_timer=trigger_timer,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_spawn_cycle_key_uses_last_death() -> None:
|
|
43
|
+
mark = _mark(last_death=42.0)
|
|
44
|
+
assert spawn_cycle_key(mark) == ("Foo", "Bar", 42.0)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_tracker_marks_first_recent_as_new() -> None:
|
|
48
|
+
tracker = SpawnReportTracker()
|
|
49
|
+
marks = [_mark()]
|
|
50
|
+
tracker.apply(marks)
|
|
51
|
+
assert marks[0].newly_spawned is True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_tracker_skips_repeat_within_same_cycle() -> None:
|
|
55
|
+
tracker = SpawnReportTracker()
|
|
56
|
+
first = [_mark()]
|
|
57
|
+
tracker.apply(first)
|
|
58
|
+
second = [_mark()]
|
|
59
|
+
tracker.apply(second)
|
|
60
|
+
assert second[0].newly_spawned is False
|
|
61
|
+
assert second[0].recently_spawned is True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_tracker_reports_again_after_new_death_cycle() -> None:
|
|
65
|
+
tracker = SpawnReportTracker()
|
|
66
|
+
tracker.apply([_mark(last_death=10.0)])
|
|
67
|
+
later = [_mark(last_death=20.0)]
|
|
68
|
+
tracker.apply(later)
|
|
69
|
+
assert later[0].newly_spawned is True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_tracker_clears_newly_when_not_recent() -> None:
|
|
73
|
+
tracker = SpawnReportTracker()
|
|
74
|
+
marks = [_mark(recently=False)]
|
|
75
|
+
tracker.apply(marks)
|
|
76
|
+
assert marks[0].newly_spawned is False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_tracker_marks_first_open_window_as_new() -> None:
|
|
80
|
+
tracker = SpawnReportTracker()
|
|
81
|
+
marks = [_mark(recently=False, phase=SpawnWindowPhase.OPEN)]
|
|
82
|
+
tracker.apply(marks)
|
|
83
|
+
assert marks[0].newly_opened is True
|
|
84
|
+
assert marks[0].newly_spawned is True
|
|
85
|
+
|
|
86
|
+
packet = HuntCrawlPacket(
|
|
87
|
+
crawled_at=1.0,
|
|
88
|
+
next_fetch_at=2.0,
|
|
89
|
+
marks=marks,
|
|
90
|
+
query=HuntQueryFilter(),
|
|
91
|
+
)
|
|
92
|
+
assert packet.newly_opened_marks == marks
|
|
93
|
+
assert packet.newly_spawned_marks == marks
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_tracker_skips_repeat_open_window_cycle() -> None:
|
|
97
|
+
tracker = SpawnReportTracker()
|
|
98
|
+
first = [_mark(recently=False, phase=SpawnWindowPhase.OPEN)]
|
|
99
|
+
tracker.apply(first)
|
|
100
|
+
second = [_mark(recently=False, phase=SpawnWindowPhase.OPEN)]
|
|
101
|
+
tracker.apply(second)
|
|
102
|
+
assert second[0].newly_opened is False
|
|
103
|
+
assert second[0].newly_spawned is False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_tracker_reports_spawn_after_open_window_cycle() -> None:
|
|
107
|
+
tracker = SpawnReportTracker()
|
|
108
|
+
tracker.apply([_mark(recently=False, phase=SpawnWindowPhase.OPEN)])
|
|
109
|
+
later = [_mark(recently=True, phase=SpawnWindowPhase.OPEN)]
|
|
110
|
+
tracker.apply(later)
|
|
111
|
+
assert later[0].newly_opened is False
|
|
112
|
+
assert later[0].newly_spawned is True
|
|
@@ -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
|
|
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:
|
|
@@ -45,6 +49,41 @@ def test_urlopen_read_retries_transient_failure() -> None:
|
|
|
45
49
|
assert mock_urlopen.call_count == 2
|
|
46
50
|
|
|
47
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)
|
|
85
|
+
|
|
86
|
+
|
|
48
87
|
def test_urlopen_read_raises_after_exhausted_retries() -> None:
|
|
49
88
|
request = MagicMock()
|
|
50
89
|
ssl_exc = ssl.SSLEOFError("EOF occurred in violation of protocol")
|
|
@@ -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":
|
|
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
|
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from ff14_the_hunt.models import HuntMarkRecord
|
|
4
|
-
|
|
5
|
-
SpawnCycleKey = tuple[str, str, float | None]
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def spawn_cycle_key(mark: HuntMarkRecord) -> SpawnCycleKey:
|
|
9
|
-
"""同一死亡周期内只应上报一次「新检出」。"""
|
|
10
|
-
return (mark.hunt_key, mark.world_name, mark.last_death_time)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class SpawnReportTracker:
|
|
14
|
-
"""跨轮询记住已上报的刷新周期,避免宽限期内重复当作新刷新。"""
|
|
15
|
-
|
|
16
|
-
def __init__(self) -> None:
|
|
17
|
-
self._reported_cycles: set[SpawnCycleKey] = set()
|
|
18
|
-
|
|
19
|
-
def apply(self, marks: list[HuntMarkRecord]) -> None:
|
|
20
|
-
for mark in marks:
|
|
21
|
-
if not mark.recently_spawned:
|
|
22
|
-
mark.newly_spawned = False
|
|
23
|
-
continue
|
|
24
|
-
cycle = spawn_cycle_key(mark)
|
|
25
|
-
if cycle in self._reported_cycles:
|
|
26
|
-
mark.newly_spawned = False
|
|
27
|
-
continue
|
|
28
|
-
mark.newly_spawned = True
|
|
29
|
-
self._reported_cycles.add(cycle)
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
from ff14_the_hunt.models import HuntMarkRecord
|
|
2
|
-
from ff14_the_hunt.poll.spawn_dedup import SpawnReportTracker, spawn_cycle_key
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
def _mark(
|
|
6
|
-
*,
|
|
7
|
-
hunt_key: str = "Foo",
|
|
8
|
-
world: str = "Bar",
|
|
9
|
-
last_death: float = 1000.0,
|
|
10
|
-
recently: bool = True,
|
|
11
|
-
) -> HuntMarkRecord:
|
|
12
|
-
return HuntMarkRecord(
|
|
13
|
-
hunt_key=hunt_key,
|
|
14
|
-
hunt_name=hunt_key,
|
|
15
|
-
world_name=world,
|
|
16
|
-
last_death_time=last_death,
|
|
17
|
-
recently_spawned=recently,
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def test_spawn_cycle_key_uses_last_death() -> None:
|
|
22
|
-
mark = _mark(last_death=42.0)
|
|
23
|
-
assert spawn_cycle_key(mark) == ("Foo", "Bar", 42.0)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def test_tracker_marks_first_recent_as_new() -> None:
|
|
27
|
-
tracker = SpawnReportTracker()
|
|
28
|
-
marks = [_mark()]
|
|
29
|
-
tracker.apply(marks)
|
|
30
|
-
assert marks[0].newly_spawned is True
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def test_tracker_skips_repeat_within_same_cycle() -> None:
|
|
34
|
-
tracker = SpawnReportTracker()
|
|
35
|
-
first = [_mark()]
|
|
36
|
-
tracker.apply(first)
|
|
37
|
-
second = [_mark()]
|
|
38
|
-
tracker.apply(second)
|
|
39
|
-
assert second[0].newly_spawned is False
|
|
40
|
-
assert second[0].recently_spawned is True
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def test_tracker_reports_again_after_new_death_cycle() -> None:
|
|
44
|
-
tracker = SpawnReportTracker()
|
|
45
|
-
tracker.apply([_mark(last_death=10.0)])
|
|
46
|
-
later = [_mark(last_death=20.0)]
|
|
47
|
-
tracker.apply(later)
|
|
48
|
-
assert later[0].newly_spawned is True
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def test_tracker_clears_newly_when_not_recent() -> None:
|
|
52
|
-
tracker = SpawnReportTracker()
|
|
53
|
-
marks = [_mark(recently=False)]
|
|
54
|
-
tracker.apply(marks)
|
|
55
|
-
assert marks[0].newly_spawned is False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/example/__main__.py
RENAMED
|
File without changes
|
{python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/example/spawn_map_io.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/__init__.py
RENAMED
|
File without changes
|
{python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_fate_timer.py
RENAMED
|
File without changes
|
{python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_locale_cn.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_poll_sleep.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|