python-library-ff14-the-hunt 0.0.4__tar.gz → 0.0.7__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.4 → python_library_ff14_the_hunt-0.0.7}/.cursor/skills/ff14_the_hunt-package-changelog/SKILL.md +5 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/.cursor/skills/ff14_the_hunt-package-preload/SKILL.md +25 -25
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/PKG-INFO +1 -1
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/README.md +2 -2
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/bear_tracker/client.py +3 -5
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/bear_tracker/enrich.py +7 -11
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/bear_tracker/spawn_points.py +8 -12
- python_library_ff14_the_hunt-0.0.7/ff14_the_hunt/common/urlopen_retry.py +75 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/ff14_the_hunt.py +29 -9
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/locale/cn.py +1 -1
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/models.py +2 -2
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/spawn_map/points.py +3 -5
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/spawn_map/region_fetch.py +3 -5
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/pyproject.toml +1 -1
- python_library_ff14_the_hunt-0.0.7/tests/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_ff14_the_hunt_service.py +27 -1
- python_library_ff14_the_hunt-0.0.7/tests/test_recent_spawn_detection.py +59 -0
- python_library_ff14_the_hunt-0.0.7/tests/test_spawn_map_points.py +44 -0
- python_library_ff14_the_hunt-0.0.7/tests/test_urlopen_retry.py +59 -0
- python_library_ff14_the_hunt-0.0.4/tests/test_spawn_map_points.py +0 -21
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/.cursor/skills/ff14_the_hunt-package-design/SKILL.md +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/.gitignore +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/example/__main__.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/example/spawn_map_io.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/example.bat +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/bear_tracker/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/bear_tracker/fate_timer.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/bear_tracker/resources.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/bear_tracker/spawn_window.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/bear_tracker/timer_theme.py +0 -0
- {python_library_ff14_the_hunt-0.0.4/ff14_the_hunt/locale/data → python_library_ff14_the_hunt-0.0.7/ff14_the_hunt/common}/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/locale/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.4/tests → python_library_ff14_the_hunt-0.0.7/ff14_the_hunt/locale/data}/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/locale/data/hunts_zh.json +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/locale/data/regions_zh.json +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/locale/detect.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/locale/display.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/locale/en.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/locale/names.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/locale/spawn_map_display.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/locale/tag.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/poll/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/poll/loop.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/poll/monitor_mode.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/poll/scheduler.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/poll/sleep_plan.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/poll/sleep_settings.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/poll/spawn_dedup.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/poll/window_remaining.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/spawn_map/__init__.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/spawn_map/attach.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/spawn_map/coordinates.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/spawn_map/layout.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/spawn_map/png_meta.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/spawn_map/region_image.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/test.bat +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_fate_timer.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_locale_cn.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_monitor_mode.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_poll_scheduler.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_poll_sleep.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_spawn_dedup.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_spawn_map_data.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_spawn_window.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_untimed_marks_filter.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tools/build_locale_data.py +0 -0
- {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/update.bat +0 -0
|
@@ -7,6 +7,11 @@ description: ff14_the_hunt 包:要求与决议;最新在上。
|
|
|
7
7
|
|
|
8
8
|
(规则见 `~/.cursor/skills/agent-project-changelog/SKILL.md`。)
|
|
9
9
|
|
|
10
|
+
## 2026-06-10
|
|
11
|
+
|
|
12
|
+
- **决议**:自动轮询:爬取、回调或循环内任意异常均记录后等待 60 秒继续,仅 ``KeyboardInterrupt`` / ``SystemExit`` 可终止服务。发布 ``0.0.6``。
|
|
13
|
+
- **决议**:Bear Tracker / 区域地图 HTTP 经 ``common/urlopen_retry`` 对瞬时 TLS/网络错误指数退避重试。发布 ``0.0.5``。
|
|
14
|
+
|
|
10
15
|
## 2026-06-09
|
|
11
16
|
|
|
12
17
|
- **决议**:``region_map_image_url`` 对区域名做 URL 编码,修复含空格等区域名(如 ``Yak T'el``)拉取狩猎地图时 ``InvalidURL`` 导致 ff14hub 崩溃。发布 ``0.0.4``。
|
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: ff14-the-hunt-package-preload
|
|
3
|
-
description: ff14_the_hunt 包:会话预加载;改本包源码前 Read。
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# ff14_the_hunt · 会话预加载
|
|
7
|
-
|
|
8
|
-
## 初始化加载(Session preload)
|
|
9
|
-
|
|
10
|
-
在 **`packages/ff14_the_hunt/`** 下改源码、测试或 `pyproject.toml` 时,按序 **Read**:
|
|
11
|
-
|
|
12
|
-
1. `~/.cursor/skills/project-skill-manifest-policy/SKILL.md`
|
|
13
|
-
2. `~/.cursor/skills/forbidden-doc-comment-vocabulary/SKILL.md`
|
|
14
|
-
3. `~/.cursor/skills/markdown-authoring-zh/SKILL.md`
|
|
15
|
-
4. `~/.cursor/skills/python-project-ai/SKILL.md`
|
|
16
|
-
5. `~/.cursor/skills/python-doc-comments/SKILL.md`
|
|
17
|
-
6. `~/.cursor/skills/agent-codegen-self-review/SKILL.md`
|
|
18
|
-
7. `.cursor/skills/ff14_the_hunt-package-design/SKILL.md`
|
|
19
|
-
8. `.cursor/skills/ff14_the_hunt-package-changelog/SKILL.md`
|
|
20
|
-
|
|
21
|
-
整库约定见仓库根 `.cursor/skills/python-library-session-manifest/SKILL.md`。
|
|
22
|
-
|
|
23
|
-
## 用过的 skill(追加记录)
|
|
24
|
-
|
|
25
|
-
(初始为空。)
|
|
1
|
+
---
|
|
2
|
+
name: ff14-the-hunt-package-preload
|
|
3
|
+
description: ff14_the_hunt 包:会话预加载;改本包源码前 Read。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ff14_the_hunt · 会话预加载
|
|
7
|
+
|
|
8
|
+
## 初始化加载(Session preload)
|
|
9
|
+
|
|
10
|
+
在 **`packages/ff14_the_hunt/`** 下改源码、测试或 `pyproject.toml` 时,按序 **Read**:
|
|
11
|
+
|
|
12
|
+
1. `~/.cursor/skills/project-skill-manifest-policy/SKILL.md`
|
|
13
|
+
2. `~/.cursor/skills/forbidden-doc-comment-vocabulary/SKILL.md`
|
|
14
|
+
3. `~/.cursor/skills/markdown-authoring-zh/SKILL.md`
|
|
15
|
+
4. `~/.cursor/skills/python-project-ai/SKILL.md`
|
|
16
|
+
5. `~/.cursor/skills/python-doc-comments/SKILL.md`
|
|
17
|
+
6. `~/.cursor/skills/agent-codegen-self-review/SKILL.md`
|
|
18
|
+
7. `.cursor/skills/ff14_the_hunt-package-design/SKILL.md`
|
|
19
|
+
8. `.cursor/skills/ff14_the_hunt-package-changelog/SKILL.md`
|
|
20
|
+
|
|
21
|
+
整库约定见仓库根 `.cursor/skills/python-library-session-manifest/SKILL.md`。
|
|
22
|
+
|
|
23
|
+
## 用过的 skill(追加记录)
|
|
24
|
+
|
|
25
|
+
(初始为空。)
|
|
@@ -66,9 +66,9 @@ example.bat
|
|
|
66
66
|
| --- | --- |
|
|
67
67
|
| `POST /api/syncSession` | 狩猎库、刷点坐标、数据中心/世界列表 |
|
|
68
68
|
| `POST /api/lastDeathTimers` | 计时行;`RankType` 为 `aRank` / `sRank` / `fate` |
|
|
69
|
-
| `POST /api/querySpawnPoints` |
|
|
69
|
+
| `POST /api/querySpawnPoints` | 刚刷新记录的非触发点认证状态(需 `LastDeath`) |
|
|
70
70
|
|
|
71
|
-
刚刷新记录才填充 ``spawn_points``、``spawn_map_layout`` 与 ``region_map``。包内不标点、不裁剪;``region_map`` 为站点 ``HuntRegions`` 原图 base64
|
|
71
|
+
刚刷新记录才填充 ``spawn_points``、``spawn_map_layout`` 与 ``region_map``。包内不标点、不裁剪;``region_map`` 为站点 ``HuntRegions`` 原图 base64。刷点仅保留未被 Sonar 或玩家认证为非触发的绿色候选点,含 ``地图X/Y``、``格点X/Y``、``像素X/Y``(按原图尺寸算好);``地图布局`` 含格点尺度与归一化公式。默认 ``include_spawn_maps=True`` 拉取原图。示例 ``python -m example`` 将 PNG 写入 ``example/output/maps/``,JSON 用 ``区域地图.地图文件`` 引用。
|
|
72
72
|
|
|
73
73
|
`QueryDeathTimers` 为世界名列表(由所选数据中心展开)。
|
|
74
74
|
|
|
@@ -5,6 +5,8 @@ import urllib.error
|
|
|
5
5
|
import urllib.request
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
|
+
from ff14_the_hunt.common.urlopen_retry import urlopen_read
|
|
9
|
+
|
|
8
10
|
DEFAULT_BASE_URL = "https://tracker.beartoolkit.com/api"
|
|
9
11
|
|
|
10
12
|
|
|
@@ -97,11 +99,7 @@ class BearTrackerClient:
|
|
|
97
99
|
method="POST",
|
|
98
100
|
)
|
|
99
101
|
try:
|
|
100
|
-
|
|
101
|
-
request,
|
|
102
|
-
timeout=self._timeout_seconds,
|
|
103
|
-
) as response:
|
|
104
|
-
raw = response.read()
|
|
102
|
+
raw = urlopen_read(request, timeout=self._timeout_seconds)
|
|
105
103
|
except urllib.error.HTTPError as exc:
|
|
106
104
|
detail = exc.read().decode("utf-8", errors="replace")
|
|
107
105
|
raise RuntimeError(
|
|
@@ -7,10 +7,9 @@ from ff14_the_hunt.models import HuntMarkRecord, HuntQueryFilter
|
|
|
7
7
|
|
|
8
8
|
from ff14_the_hunt.bear_tracker.fate_timer import compute_fate_timer
|
|
9
9
|
from ff14_the_hunt.bear_tracker.resources import BearResources
|
|
10
|
-
from ff14_the_hunt.bear_tracker.spawn_window import (
|
|
11
|
-
compute_trigger_timer,
|
|
12
|
-
|
|
13
|
-
)
|
|
10
|
+
from ff14_the_hunt.bear_tracker.spawn_window import (
|
|
11
|
+
compute_trigger_timer,
|
|
12
|
+
)
|
|
14
13
|
|
|
15
14
|
|
|
16
15
|
def mark_has_display_timer(record: HuntMarkRecord) -> bool:
|
|
@@ -88,13 +87,10 @@ def build_hunt_record(
|
|
|
88
87
|
now=now,
|
|
89
88
|
)
|
|
90
89
|
|
|
91
|
-
recently =
|
|
92
|
-
if last_mark
|
|
93
|
-
if now
|
|
94
|
-
|
|
95
|
-
elif last_mark:
|
|
96
|
-
if time.time() - float(last_mark) <= recent_grace_seconds:
|
|
97
|
-
recently = True
|
|
90
|
+
recently = False
|
|
91
|
+
if last_mark:
|
|
92
|
+
reference_now = time.time() if now is None else now
|
|
93
|
+
recently = reference_now - float(last_mark) <= recent_grace_seconds
|
|
98
94
|
|
|
99
95
|
region = meta.get("Region", "")
|
|
100
96
|
return HuntMarkRecord(
|
|
@@ -40,21 +40,17 @@ def list_map_coordinates(
|
|
|
40
40
|
grid_y = float(raw[1])
|
|
41
41
|
norm_x = (grid_x - 1.0) / scale
|
|
42
42
|
norm_y = (grid_y - 1.0) / scale
|
|
43
|
-
state = None
|
|
44
|
-
if api_states
|
|
45
|
-
|
|
46
|
-
if isinstance(entry, dict):
|
|
47
|
-
state_val = entry.get("State")
|
|
48
|
-
if state_val is not None:
|
|
49
|
-
state = bool(state_val)
|
|
43
|
+
state = None
|
|
44
|
+
if api_states is not None:
|
|
45
|
+
state = key not in api_states
|
|
50
46
|
coordinates.append(
|
|
51
47
|
MapCoordinate(
|
|
52
48
|
point_key=key,
|
|
53
49
|
x=norm_x,
|
|
54
50
|
y=norm_y,
|
|
55
|
-
grid_x=grid_x,
|
|
56
|
-
grid_y=grid_y,
|
|
57
|
-
active=state,
|
|
58
|
-
)
|
|
59
|
-
)
|
|
51
|
+
grid_x=grid_x,
|
|
52
|
+
grid_y=grid_y,
|
|
53
|
+
active=state,
|
|
54
|
+
)
|
|
55
|
+
)
|
|
60
56
|
return coordinates
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ssl
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
_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
|
+
|
|
13
|
+
|
|
14
|
+
def is_retryable_urlopen_error(exc: BaseException) -> bool:
|
|
15
|
+
"""判断 ``urlopen`` 相关异常是否宜短暂等待后重试。"""
|
|
16
|
+
if isinstance(exc, urllib.error.HTTPError):
|
|
17
|
+
return exc.code in _RETRYABLE_HTTP_CODES
|
|
18
|
+
if isinstance(exc, TimeoutError):
|
|
19
|
+
return True
|
|
20
|
+
if isinstance(exc, urllib.error.URLError):
|
|
21
|
+
reason = exc.reason
|
|
22
|
+
if isinstance(reason, TimeoutError):
|
|
23
|
+
return True
|
|
24
|
+
if isinstance(reason, (ssl.SSLError, ConnectionResetError, OSError)):
|
|
25
|
+
return True
|
|
26
|
+
return True
|
|
27
|
+
if isinstance(exc, (ssl.SSLError, ConnectionResetError, OSError)):
|
|
28
|
+
return True
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
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:
|
|
40
|
+
"""``urlopen`` 读响应体;瞬时网络或 TLS 失败时指数退避重试。
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
request: 待发送请求。
|
|
44
|
+
timeout: 单次 ``urlopen`` 超时秒数。
|
|
45
|
+
max_attempts: 最多尝试次数(含首次)。
|
|
46
|
+
initial_backoff_seconds: 首次重试前等待秒数。
|
|
47
|
+
max_backoff_seconds: 退避等待上限秒数。
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
响应体原始字节。
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
urllib.error.HTTPError: 不可重试的 HTTP 状态,或重试耗尽后的 HTTP 错误。
|
|
54
|
+
urllib.error.URLError: 重试耗尽后的网络或 TLS 错误。
|
|
55
|
+
TimeoutError: 重试耗尽后的超时。
|
|
56
|
+
"""
|
|
57
|
+
if max_attempts < 1:
|
|
58
|
+
raise ValueError("max_attempts must be >= 1")
|
|
59
|
+
|
|
60
|
+
last_exc: BaseException | None = None
|
|
61
|
+
backoff = initial_backoff_seconds
|
|
62
|
+
for attempt in range(max_attempts):
|
|
63
|
+
try:
|
|
64
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
65
|
+
return response.read()
|
|
66
|
+
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)
|
|
72
|
+
|
|
73
|
+
if last_exc is not None:
|
|
74
|
+
raise last_exc
|
|
75
|
+
raise RuntimeError("urlopen_read ended without response or error")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
import threading
|
|
4
5
|
import time
|
|
5
6
|
from collections.abc import Callable
|
|
@@ -21,6 +22,9 @@ from ff14_the_hunt.spawn_map.region_fetch import RegionMapFetcher, site_root_fro
|
|
|
21
22
|
|
|
22
23
|
HuntCrawlCallback = Callable[[HuntCrawlPacket], None]
|
|
23
24
|
|
|
25
|
+
_log = logging.getLogger(__name__)
|
|
26
|
+
_FETCH_FAILURE_RETRY_SECONDS = 60.0
|
|
27
|
+
|
|
24
28
|
|
|
25
29
|
class FF14TheHunt:
|
|
26
30
|
"""FF14 狩猎追踪门面:当前对接 [Bear Tracker](https://tracker.beartoolkit.com/timer)。"""
|
|
@@ -63,7 +67,7 @@ class FF14TheHunt:
|
|
|
63
67
|
min_wakeup_seconds: 全未开窗时睡到最近开窗的下限(秒),默认 2 分钟。
|
|
64
68
|
poll_interval_seconds: 已废弃;等同 ``fallback_poll_interval_seconds``。
|
|
65
69
|
recent_grace_seconds: 「刚刷新」宽限秒数。
|
|
66
|
-
include_spawn_states:
|
|
70
|
+
include_spawn_states: 已废弃;刚刷新记录的候选点会在补全刷点时自动查询。
|
|
67
71
|
include_spawn_maps: 为刚刷新记录拉取站点区域原图 PNG base64。
|
|
68
72
|
include_untimed_marks: 是否保留无计时的占位行(SS 级噬灵王、维护占位等)。
|
|
69
73
|
"""
|
|
@@ -317,17 +321,33 @@ class FF14TheHunt:
|
|
|
317
321
|
if stop_event is None:
|
|
318
322
|
return
|
|
319
323
|
|
|
320
|
-
marks = self._scheduler.fetch()
|
|
321
|
-
self._emit_crawl(self._make_crawl_packet(marks))
|
|
322
|
-
|
|
323
324
|
while not stop_event.is_set():
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
325
|
+
try:
|
|
326
|
+
self._poll_once_or_wait(stop_event)
|
|
327
|
+
if stop_event.is_set():
|
|
328
|
+
break
|
|
329
|
+
wait_seconds = self._scheduler.seconds_until_next_fetch()
|
|
330
|
+
if wait_seconds > 0 and wait_or_stop(stop_event, wait_seconds):
|
|
331
|
+
break
|
|
332
|
+
except KeyboardInterrupt:
|
|
333
|
+
raise
|
|
334
|
+
except Exception as exc:
|
|
335
|
+
_log.exception("狩猎轮询意外错误,%s 秒后继续:%s", _FETCH_FAILURE_RETRY_SECONDS, exc)
|
|
336
|
+
wait_or_stop(stop_event, _FETCH_FAILURE_RETRY_SECONDS)
|
|
337
|
+
|
|
338
|
+
def _poll_once_or_wait(self, stop_event: threading.Event) -> None:
|
|
339
|
+
try:
|
|
329
340
|
marks = self._scheduler.fetch()
|
|
341
|
+
except (KeyboardInterrupt, SystemExit):
|
|
342
|
+
raise
|
|
343
|
+
except Exception as exc:
|
|
344
|
+
_log.warning("狩猎爬取失败,%s 秒后重试:%s", _FETCH_FAILURE_RETRY_SECONDS, exc)
|
|
345
|
+
wait_or_stop(stop_event, _FETCH_FAILURE_RETRY_SECONDS)
|
|
346
|
+
return
|
|
347
|
+
try:
|
|
330
348
|
self._emit_crawl(self._make_crawl_packet(marks))
|
|
349
|
+
except Exception as exc:
|
|
350
|
+
_log.exception("狩猎爬取回调失败,已跳过本轮展示:%s", exc)
|
|
331
351
|
|
|
332
352
|
def _make_crawl_packet(self, marks: list[HuntMarkRecord]) -> HuntCrawlPacket:
|
|
333
353
|
crawled_at = self._scheduler.last_crawl_at
|
{python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/models.py
RENAMED
|
@@ -58,7 +58,7 @@ class MapCoordinate(BaseModel):
|
|
|
58
58
|
default=None,
|
|
59
59
|
description="区域原图上的 Y 像素;norm_y * 图高",
|
|
60
60
|
)
|
|
61
|
-
active: bool | None = Field(default=None, description="
|
|
61
|
+
active: bool | None = Field(default=None, description="是否仍是可能触发点位")
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
class SpawnMapLayout(BaseModel):
|
|
@@ -127,7 +127,7 @@ class HuntMarkRecord(BaseModel):
|
|
|
127
127
|
fate_timer: TimerDisplay | None = None
|
|
128
128
|
spawn_points: list[MapCoordinate] = Field(
|
|
129
129
|
default_factory=list,
|
|
130
|
-
description="
|
|
130
|
+
description="仅刚刷新记录填充;只保留可能触发点位",
|
|
131
131
|
)
|
|
132
132
|
spawn_map_layout: SpawnMapLayout | None = Field(
|
|
133
133
|
default=None,
|
|
@@ -3,12 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
from ff14_the_hunt.models import MapCoordinate
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
def select_display_spawn_points(points: list[MapCoordinate]) -> list[MapCoordinate]:
|
|
7
|
-
"""
|
|
6
|
+
def select_display_spawn_points(points: list[MapCoordinate]) -> list[MapCoordinate]:
|
|
7
|
+
"""刚刷新记录上只展示仍可能触发的绿色候选点。"""
|
|
8
8
|
active = [point for point in points if point.active is True]
|
|
9
|
-
|
|
10
|
-
return active
|
|
11
|
-
return list(points)
|
|
9
|
+
return active
|
|
12
10
|
|
|
13
11
|
|
|
14
12
|
def resolve_region_name(region: str | list[str]) -> str:
|
|
@@ -4,6 +4,8 @@ import urllib.error
|
|
|
4
4
|
import urllib.parse
|
|
5
5
|
import urllib.request
|
|
6
6
|
|
|
7
|
+
from ff14_the_hunt.common.urlopen_retry import urlopen_read
|
|
8
|
+
|
|
7
9
|
|
|
8
10
|
def site_root_from_api_base(base_url: str) -> str:
|
|
9
11
|
"""由 Bear Tracker ``/api`` 根地址推导站点静态资源根。"""
|
|
@@ -62,11 +64,7 @@ class RegionMapFetcher:
|
|
|
62
64
|
method="GET",
|
|
63
65
|
)
|
|
64
66
|
try:
|
|
65
|
-
|
|
66
|
-
request,
|
|
67
|
-
timeout=self._timeout_seconds,
|
|
68
|
-
) as response:
|
|
69
|
-
data = response.read()
|
|
67
|
+
data = urlopen_read(request, timeout=self._timeout_seconds)
|
|
70
68
|
except urllib.error.HTTPError as exc:
|
|
71
69
|
detail = exc.read().decode("utf-8", errors="replace")
|
|
72
70
|
raise RuntimeError(
|
|
File without changes
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import threading
|
|
2
2
|
import time
|
|
3
|
-
|
|
3
|
+
import urllib.error
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
4
5
|
|
|
5
6
|
from ff14_the_hunt import FF14TheHunt, HuntCrawlPacket, HuntQueryFilter, HuntRankKind
|
|
6
7
|
from ff14_the_hunt.bear_tracker.timer_theme import build_timer_display
|
|
@@ -110,6 +111,31 @@ def test_wait_or_stop() -> None:
|
|
|
110
111
|
assert wait_or_stop(stop_event, 1.0) is True
|
|
111
112
|
|
|
112
113
|
|
|
114
|
+
def test_poll_loop_survives_callback_failure() -> None:
|
|
115
|
+
hunt, _mock_query = _hunt_with_mock_query()
|
|
116
|
+
hunt.on_crawl(lambda _packet: (_ for _ in ()).throw(RuntimeError("sink")))
|
|
117
|
+
stop_event = threading.Event()
|
|
118
|
+
hunt._poll_once_or_wait(stop_event) # type: ignore[attr-defined]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_poll_loop_survives_fetch_failure() -> None:
|
|
122
|
+
hunt, mock_query = _hunt_with_mock_query()
|
|
123
|
+
seen: list[HuntCrawlPacket] = []
|
|
124
|
+
hunt.on_crawl(lambda packet: seen.append(packet))
|
|
125
|
+
mock_query.side_effect = [
|
|
126
|
+
urllib.error.URLError("transient"),
|
|
127
|
+
[_sample_mark()],
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
stop_event = threading.Event()
|
|
131
|
+
with patch("ff14_the_hunt.ff14_the_hunt.wait_or_stop", return_value=False):
|
|
132
|
+
hunt._poll_once_or_wait(stop_event) # type: ignore[attr-defined]
|
|
133
|
+
hunt._poll_once_or_wait(stop_event) # type: ignore[attr-defined]
|
|
134
|
+
|
|
135
|
+
assert mock_query.call_count == 2
|
|
136
|
+
assert len(seen) == 1
|
|
137
|
+
|
|
138
|
+
|
|
113
139
|
def test_wait_or_stop_wakes_early_on_stop() -> None:
|
|
114
140
|
stop_event = threading.Event()
|
|
115
141
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from ff14_the_hunt.bear_tracker.enrich import build_hunt_record
|
|
2
|
+
from ff14_the_hunt.bear_tracker.resources import BearResources
|
|
3
|
+
from ff14_the_hunt.models import HuntQueryFilter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
_RESOURCES = BearResources(
|
|
7
|
+
{
|
|
8
|
+
"DatabaseHunt": {
|
|
9
|
+
"Sansheya": {
|
|
10
|
+
"Patch": "DT",
|
|
11
|
+
"Region": "Shaaloani",
|
|
12
|
+
"Rank": 3,
|
|
13
|
+
"RespawnTimer": [46.0, 2.0],
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
"SpawnPoint": {},
|
|
17
|
+
"DataCenters": {},
|
|
18
|
+
},
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _row(**overrides) -> dict:
|
|
23
|
+
row = {
|
|
24
|
+
"huntKey": "Sansheya",
|
|
25
|
+
"huntName": "Sansheya",
|
|
26
|
+
"worldName": "Test",
|
|
27
|
+
"isMaint": False,
|
|
28
|
+
"lastDeathTime": 1_000_000.0,
|
|
29
|
+
}
|
|
30
|
+
row.update(overrides)
|
|
31
|
+
return row
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_open_window_without_last_mark_is_not_recently_spawned() -> None:
|
|
35
|
+
record = build_hunt_record(
|
|
36
|
+
timer_row=_row(),
|
|
37
|
+
resources=_RESOURCES,
|
|
38
|
+
query=HuntQueryFilter(),
|
|
39
|
+
now=1_000_000.0 + 46 * 3600 + 60,
|
|
40
|
+
recent_grace_seconds=900.0,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
assert record is not None
|
|
44
|
+
assert record.trigger_timer is not None
|
|
45
|
+
assert record.recently_spawned is False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_recent_last_mark_is_recently_spawned() -> None:
|
|
49
|
+
now = 1_000_000.0 + 46 * 3600 + 60
|
|
50
|
+
record = build_hunt_record(
|
|
51
|
+
timer_row=_row(lastMarkTime=now - 30),
|
|
52
|
+
resources=_RESOURCES,
|
|
53
|
+
query=HuntQueryFilter(),
|
|
54
|
+
now=now,
|
|
55
|
+
recent_grace_seconds=900.0,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
assert record is not None
|
|
59
|
+
assert record.recently_spawned is True
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from ff14_the_hunt.models import MapCoordinate
|
|
2
|
+
from ff14_the_hunt.bear_tracker.spawn_points import list_map_coordinates
|
|
3
|
+
from ff14_the_hunt.spawn_map.points import select_display_spawn_points
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_select_display_spawn_points_keeps_only_possible_points() -> None:
|
|
7
|
+
points = [
|
|
8
|
+
MapCoordinate(point_key="SpawnPoint01", x=0.1, y=0.2, active=False),
|
|
9
|
+
MapCoordinate(point_key="SpawnPoint02", x=0.3, y=0.4, active=True),
|
|
10
|
+
]
|
|
11
|
+
selected = select_display_spawn_points(points)
|
|
12
|
+
assert len(selected) == 1
|
|
13
|
+
assert selected[0].point_key == "SpawnPoint02"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_select_display_spawn_points_does_not_fallback_to_excluded_points() -> None:
|
|
17
|
+
points = [
|
|
18
|
+
MapCoordinate(point_key="SpawnPoint01", x=0.1, y=0.2, active=None),
|
|
19
|
+
MapCoordinate(point_key="SpawnPoint02", x=0.3, y=0.4, active=False),
|
|
20
|
+
]
|
|
21
|
+
selected = select_display_spawn_points(points)
|
|
22
|
+
assert selected == []
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_list_map_coordinates_marks_unreported_points_as_possible() -> None:
|
|
26
|
+
points = list_map_coordinates(
|
|
27
|
+
{
|
|
28
|
+
"Dimensions": [41, 41],
|
|
29
|
+
"DisplayPoints": 3,
|
|
30
|
+
"SpawnPoint01": [1, 1],
|
|
31
|
+
"SpawnPoint02": [2, 2],
|
|
32
|
+
"SpawnPoint03": [3, 3],
|
|
33
|
+
},
|
|
34
|
+
api_states={
|
|
35
|
+
"SpawnPoint01": {"State": True, "Verified": True},
|
|
36
|
+
"SpawnPoint02": {"State": False, "Verified": True},
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
assert [(point.point_key, point.active) for point in points] == [
|
|
41
|
+
("SpawnPoint01", False),
|
|
42
|
+
("SpawnPoint02", False),
|
|
43
|
+
("SpawnPoint03", True),
|
|
44
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import ssl
|
|
2
|
+
import urllib.error
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from ff14_the_hunt.common.urlopen_retry import is_retryable_urlopen_error, urlopen_read
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_is_retryable_urlopen_error_for_ssl_eof() -> None:
|
|
11
|
+
ssl_exc = ssl.SSLEOFError("EOF occurred in violation of protocol")
|
|
12
|
+
url_error = urllib.error.URLError(ssl_exc)
|
|
13
|
+
assert is_retryable_urlopen_error(url_error) is True
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_is_retryable_urlopen_error_for_http_404() -> None:
|
|
17
|
+
http_error = urllib.error.HTTPError(
|
|
18
|
+
url="https://example.test",
|
|
19
|
+
code=404,
|
|
20
|
+
msg="Not Found",
|
|
21
|
+
hdrs=None,
|
|
22
|
+
fp=None,
|
|
23
|
+
)
|
|
24
|
+
assert is_retryable_urlopen_error(http_error) is False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_urlopen_read_retries_transient_failure() -> None:
|
|
28
|
+
request = MagicMock()
|
|
29
|
+
response = MagicMock()
|
|
30
|
+
response.read.return_value = b"ok"
|
|
31
|
+
response.__enter__.return_value = response
|
|
32
|
+
response.__exit__.return_value = False
|
|
33
|
+
|
|
34
|
+
ssl_exc = ssl.SSLEOFError("EOF occurred in violation of protocol")
|
|
35
|
+
url_error = urllib.error.URLError(ssl_exc)
|
|
36
|
+
|
|
37
|
+
with patch("ff14_the_hunt.common.urlopen_retry.time.sleep"):
|
|
38
|
+
with patch(
|
|
39
|
+
"ff14_the_hunt.common.urlopen_retry.urllib.request.urlopen",
|
|
40
|
+
side_effect=[url_error, response],
|
|
41
|
+
) as mock_urlopen:
|
|
42
|
+
raw = urlopen_read(request, timeout=5.0, max_attempts=3)
|
|
43
|
+
|
|
44
|
+
assert raw == b"ok"
|
|
45
|
+
assert mock_urlopen.call_count == 2
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_urlopen_read_raises_after_exhausted_retries() -> None:
|
|
49
|
+
request = MagicMock()
|
|
50
|
+
ssl_exc = ssl.SSLEOFError("EOF occurred in violation of protocol")
|
|
51
|
+
url_error = urllib.error.URLError(ssl_exc)
|
|
52
|
+
|
|
53
|
+
with patch("ff14_the_hunt.common.urlopen_retry.time.sleep"):
|
|
54
|
+
with patch(
|
|
55
|
+
"ff14_the_hunt.common.urlopen_retry.urllib.request.urlopen",
|
|
56
|
+
side_effect=url_error,
|
|
57
|
+
):
|
|
58
|
+
with pytest.raises(urllib.error.URLError):
|
|
59
|
+
urlopen_read(request, timeout=5.0, max_attempts=2)
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
from ff14_the_hunt.models import MapCoordinate
|
|
2
|
-
from ff14_the_hunt.spawn_map.points import select_display_spawn_points
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
def test_select_display_spawn_points_prefers_active() -> None:
|
|
6
|
-
points = [
|
|
7
|
-
MapCoordinate(point_key="SpawnPoint01", x=0.1, y=0.2, active=False),
|
|
8
|
-
MapCoordinate(point_key="SpawnPoint02", x=0.3, y=0.4, active=True),
|
|
9
|
-
]
|
|
10
|
-
selected = select_display_spawn_points(points)
|
|
11
|
-
assert len(selected) == 1
|
|
12
|
-
assert selected[0].point_key == "SpawnPoint02"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def test_select_display_spawn_points_keeps_all_when_no_active() -> None:
|
|
16
|
-
points = [
|
|
17
|
-
MapCoordinate(point_key="SpawnPoint01", x=0.1, y=0.2, active=None),
|
|
18
|
-
MapCoordinate(point_key="SpawnPoint02", x=0.3, y=0.4, active=False),
|
|
19
|
-
]
|
|
20
|
-
selected = select_display_spawn_points(points)
|
|
21
|
-
assert len(selected) == 2
|
|
File without changes
|
|
File without changes
|
{python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/example/__main__.py
RENAMED
|
File without changes
|
{python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/example/spawn_map_io.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/__init__.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
|
{python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/ff14_the_hunt/locale/en.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/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
|
{python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_fate_timer.py
RENAMED
|
File without changes
|
{python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_locale_cn.py
RENAMED
|
File without changes
|
{python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_monitor_mode.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_poll_sleep.py
RENAMED
|
File without changes
|
{python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_spawn_dedup.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tests/test_spawn_window.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.7}/tools/build_locale_data.py
RENAMED
|
File without changes
|
|
File without changes
|