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.
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/PKG-INFO +1 -1
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/__init__.py +10 -4
- {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
- python_library_ff14_the_hunt-0.0.9/ff14_the_hunt/common/http_request.py +3 -0
- {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
- {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
- {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
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/pyproject.toml +1 -1
- python_library_ff14_the_hunt-0.0.9/tests/test_bear_tracker_client.py +46 -0
- {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
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_urlopen_retry.py +44 -5
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tools/build_locale_data.py +8 -7
- {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
- {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
- {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
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/.gitignore +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/README.md +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/example/__main__.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/example/spawn_map_io.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/example.bat +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/common/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/cn.py +0 -0
- {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
- {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
- {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
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/detect.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/display.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/en.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/names.py +0 -0
- {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
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/tag.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/models.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/poll/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/poll/loop.py +0 -0
- {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
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/poll/scheduler.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/test.bat +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_fate_timer.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_locale_cn.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_monitor_mode.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_poll_scheduler.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_poll_sleep.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_recent_spawn_detection.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_spawn_dedup.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_spawn_map_data.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_spawn_map_points.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_spawn_window.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_untimed_marks_filter.py +0 -0
- {python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/update.bat +0 -0
{python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/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,34 +1,74 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
from
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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()
|
|
@@ -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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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] = {
|
|
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
|
|
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
|
|
4
|
-
import
|
|
5
|
-
import urllib.
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
self.
|
|
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":
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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()
|
|
@@ -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.
|
|
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
|
|
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:
|
{python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tools/build_locale_data.py
RENAMED
|
@@ -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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/example/__main__.py
RENAMED
|
File without changes
|
{python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/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
|
{python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/cn.py
RENAMED
|
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.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/locale/en.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/models.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/ff14_the_hunt/poll/loop.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
|
{python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_fate_timer.py
RENAMED
|
File without changes
|
{python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_locale_cn.py
RENAMED
|
File without changes
|
{python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_monitor_mode.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_poll_sleep.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_spawn_dedup.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_library_ff14_the_hunt-0.0.7 → python_library_ff14_the_hunt-0.0.9}/tests/test_spawn_window.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|