@sophiacyl/dsh-plugin-waves 0.0.0-stage → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/DSH-GO-VERIFICATION.md +57 -0
  2. package/ICON.png +0 -0
  3. package/LICENSE +674 -0
  4. package/README.md +251 -2
  5. package/cordis.patch.yml +8 -0
  6. package/install.ps1 +0 -0
  7. package/install.sh +209 -0
  8. package/lib/client.js +1511 -0
  9. package/lib/host/aliashint.js +70 -0
  10. package/lib/host/attachments.js +25 -0
  11. package/lib/host/command.js +34 -0
  12. package/lib/host/consts.js +16 -0
  13. package/lib/host/dataguide.js +35 -0
  14. package/lib/host/imagebridge.js +41 -0
  15. package/lib/host/native-result.js +91 -0
  16. package/lib/host/native.js +191 -0
  17. package/lib/host/proxy.js +59 -0
  18. package/lib/host/results.js +67 -0
  19. package/lib/host/service.js +263 -0
  20. package/lib/host/tool.js +185 -0
  21. package/lib/host/troubleshoot.js +31 -0
  22. package/lib/host/worldbook.js +80 -0
  23. package/lib/index.js +74 -0
  24. package/locale/en.json +6 -0
  25. package/locale/zh.json +6 -0
  26. package/package.json +136 -4
  27. package/service/ICON.png +0 -0
  28. package/service/ai/AI_DATA.md +221 -0
  29. package/service/knowledge/additional/manual.jsonl +5 -0
  30. package/service/knowledge/wiki/baike.jsonl +1231 -0
  31. package/service/knowledge/wiki/chengjiu.jsonl +36 -0
  32. package/service/knowledge/wiki/gonglve.jsonl +383 -0
  33. package/service/knowledge/wiki/huodong.jsonl +427 -0
  34. package/service/knowledge/wiki/renwu.jsonl +412 -0
  35. package/service/knowledge/wiki/tujian.jsonl +2889 -0
  36. package/service/knowledge/wiki/zixun.jsonl +47 -0
  37. package/service/plugins/__init__.py +0 -0
  38. package/service/pyproject.toml +40 -0
  39. package/service/rover/__init__.py +0 -0
  40. package/service/rover/ai_core/__init__.py +0 -0
  41. package/service/rover/ai_core/models.py +50 -0
  42. package/service/rover/ai_core/rag/__init__.py +5 -0
  43. package/service/rover/ai_core/rag/knowledge.py +23 -0
  44. package/service/rover/ai_core/register.py +86 -0
  45. package/service/rover/ai_core/resource.py +6 -0
  46. package/service/rover/ai_core/skills/__init__.py +0 -0
  47. package/service/rover/ai_core/skills/operations.py +8 -0
  48. package/service/rover/api.py +720 -0
  49. package/service/rover/api_render.py +161 -0
  50. package/service/rover/app_life.py +154 -0
  51. package/service/rover/aps.py +47 -0
  52. package/service/rover/avatar.py +85 -0
  53. package/service/rover/bot.py +133 -0
  54. package/service/rover/builtin.py +152 -0
  55. package/service/rover/cards/__init__.py +26 -0
  56. package/service/rover/cards/_shared.py +129 -0
  57. package/service/rover/cards/char_list.py +258 -0
  58. package/service/rover/cards/char_panel.py +1353 -0
  59. package/service/rover/cards/echo_list.py +206 -0
  60. package/service/rover/cards/matrix_roles.py +25 -0
  61. package/service/rover/cards/rank_boards.py +714 -0
  62. package/service/rover/cards/rank_total.py +664 -0
  63. package/service/rover/compat.py +215 -0
  64. package/service/rover/config.py +163 -0
  65. package/service/rover/custom_panel.py +220 -0
  66. package/service/rover/data_store.py +31 -0
  67. package/service/rover/gss.py +19 -0
  68. package/service/rover/handler.py +191 -0
  69. package/service/rover/help/__init__.py +0 -0
  70. package/service/rover/help/draw_new_plugin_help.py +332 -0
  71. package/service/rover/help/model.py +16 -0
  72. package/service/rover/help/utils.py +7 -0
  73. package/service/rover/hooks.py +420 -0
  74. package/service/rover/html_actions.py +236 -0
  75. package/service/rover/i18n.py +19 -0
  76. package/service/rover/knowledge.py +235 -0
  77. package/service/rover/logger.py +102 -0
  78. package/service/rover/main.py +33 -0
  79. package/service/rover/media.py +222 -0
  80. package/service/rover/message_models.py +23 -0
  81. package/service/rover/models.py +92 -0
  82. package/service/rover/native_media.py +64 -0
  83. package/service/rover/ocr.py +79 -0
  84. package/service/rover/pool.py +43 -0
  85. package/service/rover/restart.py +36 -0
  86. package/service/rover/segment.py +143 -0
  87. package/service/rover/server.py +131 -0
  88. package/service/rover/status/__init__.py +0 -0
  89. package/service/rover/status/models.py +10 -0
  90. package/service/rover/status/plugin_status.py +17 -0
  91. package/service/rover/subscribe.py +148 -0
  92. package/service/rover/sv.py +403 -0
  93. package/service/rover/templates/char_list.html +182 -0
  94. package/service/rover/templates/char_panel.html +465 -0
  95. package/service/rover/templates/echo_list.html +221 -0
  96. package/service/rover/templates/rank_all_total.html +193 -0
  97. package/service/rover/templates/rank_board.html +107 -0
  98. package/service/rover/templates/rank_level_total.html +57 -0
  99. package/service/rover/templates/rank_matrix_total.html +66 -0
  100. package/service/rover/templates/rank_phantom_total.html +180 -0
  101. package/service/rover/templates/rank_slash_total.html +38 -0
  102. package/service/rover/trigger.py +98 -0
  103. package/service/rover/utils/__init__.py +0 -0
  104. package/service/rover/utils/boardcast/__init__.py +0 -0
  105. package/service/rover/utils/boardcast/models.py +15 -0
  106. package/service/rover/utils/cookie_manager/__init__.py +0 -0
  107. package/service/rover/utils/cookie_manager/qrlogin.py +24 -0
  108. package/service/rover/utils/database/__init__.py +0 -0
  109. package/service/rover/utils/database/base_models.py +1771 -0
  110. package/service/rover/utils/database/models.py +80 -0
  111. package/service/rover/utils/database/startup.py +60 -0
  112. package/service/rover/utils/default_bg/bg.jpg +0 -0
  113. package/service/rover/utils/download_resource/__init__.py +0 -0
  114. package/service/rover/utils/download_resource/download_core.py +164 -0
  115. package/service/rover/utils/download_resource/download_file.py +46 -0
  116. package/service/rover/utils/fonts/__init__.py +0 -0
  117. package/service/rover/utils/fonts/fonts.py +33 -0
  118. package/service/rover/utils/image/__init__.py +0 -0
  119. package/service/rover/utils/image/convert.py +51 -0
  120. package/service/rover/utils/image/image_tools.py +30 -0
  121. package/service/rover/utils/image/utils.py +70 -0
  122. package/service/rover/utils/plugins_config/__init__.py +0 -0
  123. package/service/rover/utils/plugins_config/gs_config.py +506 -0
  124. package/service/rover/utils/plugins_config/models.py +182 -0
  125. package/service/rover/web_app.py +3 -0
  126. package/service/rover/webconsole/__init__.py +0 -0
  127. package/service/rover/webconsole/mount_app.py +26 -0
  128. package/service/tools/migrate.py +351 -0
  129. package/service/tools/regression.py +87 -0
  130. package/service/tools/smoke.py +63 -0
  131. package/service/tools/update_plugins.py +212 -0
  132. package/service/tools/verify_actions.py +104 -0
  133. package/service/tools/verify_card.py +82 -0
  134. package/service/tools/wwdata.py +665 -0
  135. package/tests/compat.test.js +181 -0
  136. package/tests/imagebridge.test.js +58 -0
  137. package/tests/media.test.js +68 -0
  138. package/tests/native.test.js +127 -0
  139. package/tests/runtime.test.js +69 -0
  140. package/tests/service.test.js +48 -0
@@ -0,0 +1,80 @@
1
+ from typing import List, Union, Optional
2
+
3
+ from sqlmodel import Field, Index
4
+
5
+ from rover.logger import logger
6
+ from rover.models import Message
7
+ from rover.message_models import ButtonType
8
+
9
+ from .base_models import BaseModel
10
+
11
+
12
+ class Subscribe(BaseModel, table=True):
13
+ __table_args__ = (
14
+ Index(
15
+ "ix_subscribe_task_name_uid",
16
+ "task_name",
17
+ "uid",
18
+ ),
19
+ {"extend_existing": True},
20
+ )
21
+
22
+ WS_BOT_ID: Optional[str] = Field(title="WS机器人ID", default=None)
23
+ group_id: Optional[str] = Field(title="群ID", default=None, index=True)
24
+ task_name: str = Field(title="任务名称", default=None, index=True)
25
+ bot_self_id: str = Field(title="机器人自身ID", default=None)
26
+ user_type: str = Field(title="发送类型", default=None)
27
+ extra_message: Optional[str] = Field(title="额外消息", default=None)
28
+ uid: Optional[str] = Field(title="账户ID", default=None, index=True)
29
+ extra_data: Optional[str] = Field(title="额外消息2", default=None)
30
+ msg_id: Optional[str] = Field(title="消息ID", default=None)
31
+
32
+ async def send(
33
+ self,
34
+ reply: Optional[
35
+ Union[
36
+ Message,
37
+ List[Message],
38
+ List[str],
39
+ str,
40
+ bytes,
41
+ ]
42
+ ] = None,
43
+ option_list: Optional[ButtonType] = None,
44
+ unsuported_platform: bool = False,
45
+ sep: str = "\n",
46
+ command_tips: str = "请输入以下命令之一:",
47
+ command_start_text: str = "",
48
+ force_direct: bool = False,
49
+ ):
50
+ from rover.gss import gss
51
+
52
+ if reply is None:
53
+ return -1
54
+
55
+ user_type = "direct" if force_direct else self.user_type
56
+ target_id = self.group_id if user_type == "group" else self.user_id
57
+
58
+ bot = None
59
+ if self.WS_BOT_ID:
60
+ bot = gss.active_bot.get(self.WS_BOT_ID)
61
+ if bot is None:
62
+ for _bot in gss.active_bot.values():
63
+ if getattr(_bot, "bot_id", None) == self.bot_id:
64
+ bot = _bot
65
+ break
66
+ if bot is None:
67
+ bot = next(iter(gss.active_bot.values()), None)
68
+
69
+ if bot is None:
70
+ logger.error(f"[订阅] 无可用连接, 该消息无法发送! task_name={self.task_name}")
71
+ return -1
72
+
73
+ await bot.target_send(
74
+ reply,
75
+ user_type,
76
+ target_id,
77
+ self.bot_id,
78
+ self.bot_self_id,
79
+ "",
80
+ )
@@ -0,0 +1,60 @@
1
+ import importlib
2
+ from typing import List
3
+
4
+ from sqlmodel import SQLModel
5
+ from sqlalchemy.sql import text
6
+
7
+ from rover.logger import logger
8
+
9
+ from . import base_models
10
+ from .base_models import init_database
11
+
12
+ CORE_DATABASE_MODEL_MODULES = ("rover.utils.database.models",)
13
+
14
+ # 插件在导入时 extend 本列表, 因此必须是模块级可变 list
15
+ exec_list: List[str] = [
16
+ "ALTER TABLE Subscribe ADD COLUMN uid TEXT DEFAULT NULL;",
17
+ "ALTER TABLE Subscribe ADD COLUMN WS_BOT_ID TEXT DEFAULT NULL;",
18
+ "ALTER TABLE Subscribe ADD COLUMN extra_data TEXT DEFAULT NULL;",
19
+ "ALTER TABLE Subscribe ADD COLUMN msg_id TEXT DEFAULT NULL;",
20
+ "CREATE INDEX ix_subscribe_task_name ON Subscribe (task_name);",
21
+ "CREATE INDEX ix_subscribe_uid ON Subscribe (uid);",
22
+ "CREATE INDEX ix_subscribe_task_name_uid ON Subscribe (task_name, uid);",
23
+ ]
24
+
25
+
26
+ def import_database_models() -> None:
27
+ """导入表模型, 确保 create_all 能看到完整 metadata。"""
28
+ for module_name in CORE_DATABASE_MODEL_MODULES:
29
+ try:
30
+ importlib.import_module(module_name)
31
+ except Exception as e:
32
+ logger.warning(f"[数据库] 导入模型模块失败: {module_name}, 跳过对应表创建: {e}")
33
+
34
+
35
+ async def create_core_tables() -> None:
36
+ import_database_models()
37
+ async with base_models.engine.begin() as conn:
38
+ await conn.run_sync(SQLModel.metadata.create_all)
39
+ logger.info("[数据库] 核心数据库表创建成功!")
40
+
41
+
42
+ async def trans_adapter() -> None:
43
+ async with base_models.async_maker() as session:
44
+ for _t in exec_list:
45
+ try:
46
+ await session.execute(text(_t))
47
+ await session.commit()
48
+ except Exception: # noqa: E722
49
+ pass
50
+
51
+
52
+ async def init_all() -> None:
53
+ """建库建表并补齐历史列。
54
+
55
+ 插件模块在导入时会绑定 base_models.engine 并 extend exec_list,
56
+ 因此需要先 await init_database() 再导入插件, 最后调用本函数。
57
+ """
58
+ await init_database()
59
+ await create_core_tables()
60
+ await trans_adapter()
@@ -0,0 +1,164 @@
1
+ """资源批量下载。目录索引解析 + 按大小比对增量下载。"""
2
+
3
+ import asyncio
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Dict, Optional
7
+ from urllib.parse import unquote
8
+
9
+ import httpx
10
+ from bs4 import BeautifulSoup, Tag
11
+
12
+ from rover.logger import logger
13
+
14
+ from .download_file import download
15
+
16
+
17
+ async def _get_url(url: str, client: httpx.AsyncClient) -> bytes:
18
+ try:
19
+ response = await client.get(url)
20
+ response.raise_for_status()
21
+ return response.read()
22
+ except httpx.HTTPStatusError as exc:
23
+ logger.warning(f"[资源下载] HTTP 错误: {url} {exc}")
24
+ return b""
25
+ except httpx.ConnectError as exc:
26
+ logger.warning(f"[资源下载] 连接错误: {url} {exc}")
27
+ return b""
28
+ except httpx.UnsupportedProtocol as exc:
29
+ logger.warning(f"[资源下载] 协议不支持: {url} {exc}")
30
+ return b""
31
+ except httpx.RequestError as exc:
32
+ logger.warning(f"[资源下载] 请求错误: {url} {exc}")
33
+ return b""
34
+
35
+
36
+ async def download_atag_file(
37
+ PLUGIN_RES: str,
38
+ endpoint: str,
39
+ EPATH_MAP: Dict[str, Path],
40
+ client: httpx.AsyncClient,
41
+ TAG: str,
42
+ plugin_name: str,
43
+ ):
44
+ TASKS = []
45
+ url = f"{PLUGIN_RES}/{endpoint}"
46
+ if not url.endswith("/"):
47
+ url += "/"
48
+
49
+ if endpoint not in EPATH_MAP:
50
+ # 仅当用户填了尾斜杠时才裁掉, 避免 EPATH_MAP 误匹
51
+ _endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
52
+ _et = _endpoint.rsplit("/", 1)
53
+ _e = _et[0]
54
+ _t = _et[1]
55
+ if _e in EPATH_MAP:
56
+ path = EPATH_MAP[_e] / _t
57
+ else:
58
+ return
59
+ else:
60
+ path = EPATH_MAP[endpoint]
61
+
62
+ if not path.exists():
63
+ path.mkdir(parents=True)
64
+
65
+ base_data = await _get_url(url, client)
66
+ content_bs = BeautifulSoup(base_data, "lxml")
67
+ pre_data_list = content_bs.find_all("pre")
68
+ if not pre_data_list:
69
+ logger.warning(f"{TAG} {endpoint} 目录索引解析失败")
70
+ return
71
+ pre_data = pre_data_list[0]
72
+
73
+ if not isinstance(pre_data, Tag):
74
+ logger.warning(f"{TAG} {endpoint} 目录索引解析失败")
75
+ return
76
+ data_list = pre_data.find_all("a")
77
+ size_list = [i for i in content_bs.strings]
78
+
79
+ logger.trace(f"{TAG} {endpoint} 共 {len(data_list)} 个条目")
80
+
81
+ temp_num = 0
82
+ size_temp = 0
83
+ for index, data in enumerate(data_list):
84
+ if data["href"] == "../": # type: ignore
85
+ continue
86
+ file_url = f"{url}{data['href']}" # type: ignore
87
+ name: str = unquote(file_url.split("/")[-1])
88
+ size = size_list[index * 2 + 6].split(" ")[-1]
89
+ _size: str = size.replace("\r", "")
90
+ _size = _size.replace("\n", "")
91
+
92
+ if _size == "-" or _size == "-\n":
93
+ await download_atag_file(
94
+ PLUGIN_RES,
95
+ f"{endpoint}/{data['href']}", # type: ignore
96
+ EPATH_MAP,
97
+ client,
98
+ TAG,
99
+ plugin_name,
100
+ )
101
+ continue
102
+
103
+ if _size.isdigit():
104
+ size = int(_size)
105
+ else:
106
+ continue
107
+
108
+ file_path = path / name
109
+
110
+ if file_path.exists():
111
+ is_diff = size == os.stat(file_path).st_size
112
+ else:
113
+ is_diff = True
114
+
115
+ if not file_path.exists() or not os.stat(file_path).st_size or not is_diff:
116
+ logger.info(f"{TAG} {plugin_name} {endpoint} 下载 {name}")
117
+ temp_num += 1
118
+ size_temp += size
119
+ TASK = asyncio.create_task(download(file_url, path, name, client, TAG))
120
+ TASKS.append(TASK)
121
+ if size_temp >= 1500000 or len(TASKS) >= 4:
122
+ await asyncio.gather(*TASKS)
123
+ TASKS.clear()
124
+ size_temp = 0
125
+ else:
126
+ await asyncio.gather(*TASKS)
127
+ TASKS.clear()
128
+
129
+ if temp_num == 0:
130
+ logger.trace(f"{TAG} {endpoint} 无需更新")
131
+ else:
132
+ logger.success(f"{TAG} {endpoint} 下载 {temp_num} 个文件完成")
133
+ temp_num = 0
134
+
135
+
136
+ async def download_all_file(
137
+ plugin_name: str,
138
+ EPATH_MAP: Dict[str, Path],
139
+ URL: Optional[str] = None,
140
+ TAG: Optional[str] = None,
141
+ ):
142
+ # 资源站由调用方测速后给出, 缺失时直接跳过
143
+ if not URL:
144
+ logger.warning(f"[资源下载] {plugin_name} 无可用资源站, 跳过")
145
+ return
146
+
147
+ TAG = TAG or "[Unknown]"
148
+ PLUGIN_RES = f"{URL}/{plugin_name}"
149
+
150
+ async with httpx.AsyncClient(timeout=httpx.Timeout(200.0)) as client:
151
+ n = 0
152
+ for endpoint in EPATH_MAP:
153
+ await download_atag_file(
154
+ PLUGIN_RES,
155
+ endpoint,
156
+ EPATH_MAP,
157
+ client,
158
+ TAG,
159
+ plugin_name,
160
+ )
161
+ n += 1
162
+
163
+ if n == len(EPATH_MAP):
164
+ logger.success(f"[资源下载] {plugin_name} 资源下载完成")
@@ -0,0 +1,46 @@
1
+ """资源文件下载。"""
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Union
5
+
6
+ import aiofiles
7
+ import httpx
8
+
9
+ from rover.logger import logger
10
+
11
+ if TYPE_CHECKING:
12
+ from aiohttp.client import ClientSession
13
+
14
+
15
+ async def download(
16
+ url: str,
17
+ path: Path,
18
+ name: str,
19
+ sess: Union["ClientSession", httpx.AsyncClient, None] = None,
20
+ tag: str = "",
21
+ ):
22
+ logger.info(f"{tag} 开始下载: {name}")
23
+ logger.info(f"{tag} 下载地址: {url}")
24
+ if sess is None:
25
+ sess = httpx.AsyncClient()
26
+
27
+ try:
28
+ if isinstance(sess, httpx.AsyncClient):
29
+ res = await sess.get(url)
30
+ content = res.read()
31
+ retcode = res.status_code
32
+ else:
33
+ async with sess.get(url) as resp:
34
+ content = await resp.read()
35
+ retcode = resp.status
36
+
37
+ if retcode == 200:
38
+ async with aiofiles.open(path / name, "wb") as f:
39
+ await f.write(content)
40
+ logger.success(f"{tag} 下载完成: {name}")
41
+ else:
42
+ logger.warning(f"{tag} 下载失败: {name}, 状态码 {retcode}")
43
+ return retcode
44
+ except Exception as e:
45
+ logger.error(e)
46
+ logger.warning(f"{tag} 下载出错: {name}")
File without changes
@@ -0,0 +1,33 @@
1
+ """内置字体。优先用系统中文字体, 缺失时退回 PIL 默认字体。"""
2
+
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from PIL import ImageFont
8
+
9
+ FONT_PATH = Path(__file__).parent
10
+
11
+ _CANDIDATES = (
12
+ FONT_PATH / "rover_font.ttf",
13
+ Path("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"),
14
+ Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"),
15
+ Path("/usr/share/fonts/truetype/arphic/gbsn00lp.ttf"),
16
+ Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
17
+ )
18
+
19
+
20
+ @lru_cache(maxsize=1)
21
+ def _font_file() -> Optional[Path]:
22
+ for path in _CANDIDATES:
23
+ if path.exists():
24
+ return path
25
+ return None
26
+
27
+
28
+ @lru_cache(maxsize=64)
29
+ def core_font(size: int) -> ImageFont.FreeTypeFont:
30
+ path = _font_file()
31
+ if path is None:
32
+ return ImageFont.load_default(size) # type: ignore[return-value]
33
+ return ImageFont.truetype(str(path), size)
File without changes
@@ -0,0 +1,51 @@
1
+ """图片格式转换。"""
2
+
3
+ import os
4
+ from base64 import b64encode
5
+ from io import BytesIO
6
+ from pathlib import Path
7
+ from typing import Union
8
+
9
+ from PIL import Image
10
+
11
+ from rover.logger import logger
12
+ from rover.pool import to_thread
13
+
14
+ pic_quality: int = int(os.getenv("ROVER_PIC_QUALITY", "92"))
15
+
16
+
17
+ async def convert_img(
18
+ img: Union[Image.Image, str, Path, bytes],
19
+ is_base64: bool = False,
20
+ ):
21
+ """图片转 bytes 或 base64。is_base64 只对 PIL 对象生效, 其余一律返回 base64 串。"""
22
+ return await _convert_img(img, is_base64)
23
+
24
+
25
+ @to_thread
26
+ def _convert_img(
27
+ img: Union[Image.Image, str, Path, bytes],
28
+ is_base64: bool = False,
29
+ ):
30
+ logger.info("[图片] 开始处理图片...")
31
+
32
+ if isinstance(img, Image.Image):
33
+ result_buffer = BytesIO()
34
+ if img.format == "GIF":
35
+ img.save(result_buffer, format="GIF")
36
+ else:
37
+ img.convert("RGB").save(result_buffer, format="JPEG", quality=pic_quality)
38
+
39
+ res = result_buffer.getvalue()
40
+ if is_base64:
41
+ res = "base64://" + b64encode(res).decode()
42
+ return res
43
+ elif isinstance(img, bytes):
44
+ pass
45
+ else:
46
+ with open(Path(img), "rb") as fp:
47
+ img = fp.read()
48
+
49
+ logger.success("[图片] 处理完成!")
50
+
51
+ return f"base64://{b64encode(img).decode()}"
@@ -0,0 +1,30 @@
1
+ """图片处理工具。"""
2
+
3
+ import math
4
+
5
+ from PIL import Image
6
+
7
+ __all__ = ["crop_center_img"]
8
+
9
+
10
+ def crop_center_img(img: Image.Image, based_w: int, based_h: int) -> Image.Image:
11
+ # 确定图片的长宽
12
+ based_scale = "%.3f" % (based_w / based_h)
13
+ w, h = img.size
14
+ scale_f = "%.3f" % (w / h)
15
+ new_w = math.ceil(based_h * float(scale_f))
16
+ new_h = math.ceil(based_w / float(scale_f))
17
+ if scale_f > based_scale:
18
+ resize_img = img.resize((new_w, based_h), Image.Resampling.LANCZOS)
19
+ x1 = int(new_w / 2 - based_w / 2)
20
+ y1 = 0
21
+ x2 = int(new_w / 2 + based_w / 2)
22
+ y2 = based_h
23
+ else:
24
+ resize_img = img.resize((based_w, new_h), Image.Resampling.LANCZOS)
25
+ x1 = 0
26
+ y1 = int(new_h / 2 - based_h / 2)
27
+ x2 = based_w
28
+ y2 = int(new_h / 2 + based_h / 2)
29
+ crop_img = resize_img.crop((x1, y1, x2, y2))
30
+ return crop_img
@@ -0,0 +1,70 @@
1
+ """图片网络请求。"""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import httpx
11
+
12
+ from rover.data_store import get_res_path
13
+ from rover.logger import logger
14
+
15
+ _SGET_CONNECT_TIMEOUT: float = 3.0
16
+ _SGET_READ_TIMEOUT: float = 8.0
17
+ _SGET_WRITE_TIMEOUT: float = 3.0
18
+ _SGET_POOL_TIMEOUT: float = 3.0
19
+ _SGET_TOTAL_TIMEOUT: float = 12.0
20
+ _SGET_CACHE_TTL: float = 172800.0 # 2 天
21
+
22
+ _URL_CACHE_DIR: Path = get_res_path(["IMAGE_CACHE", "sget"])
23
+
24
+
25
+ def _url_cache_path(url: str) -> Path:
26
+ key = hashlib.sha256(url.encode("utf-8")).hexdigest()
27
+ return _URL_CACHE_DIR / f"{key}.bin"
28
+
29
+
30
+ async def _read_cached(url: str) -> Optional[httpx.Response]:
31
+ path = _url_cache_path(url)
32
+ if not path.exists():
33
+ return None
34
+ if time.time() - path.stat().st_mtime > _SGET_CACHE_TTL:
35
+ return None
36
+ content = await asyncio.to_thread(path.read_bytes)
37
+ request = httpx.Request("GET", url)
38
+ return httpx.Response(200, content=content, request=request)
39
+
40
+
41
+ async def _write_cache(url: str, content: bytes) -> None:
42
+ path = _url_cache_path(url)
43
+ tmp_path = path.with_suffix(".tmp")
44
+ await asyncio.to_thread(tmp_path.write_bytes, content)
45
+ await asyncio.to_thread(os.replace, tmp_path, path)
46
+
47
+
48
+ async def sget(url: str, use_cache: bool = False) -> httpx.Response:
49
+ if use_cache:
50
+ cached = await _read_cached(url)
51
+ if cached is not None:
52
+ return cached
53
+
54
+ logger.info(f"[图片] 开始下载: {url}")
55
+ timeout = httpx.Timeout(
56
+ connect=_SGET_CONNECT_TIMEOUT,
57
+ read=_SGET_READ_TIMEOUT,
58
+ write=_SGET_WRITE_TIMEOUT,
59
+ pool=_SGET_POOL_TIMEOUT,
60
+ )
61
+ async with asyncio.timeout(_SGET_TOTAL_TIMEOUT):
62
+ async with httpx.AsyncClient(
63
+ timeout=timeout,
64
+ follow_redirects=True,
65
+ ) as client:
66
+ resp = await client.get(url=url)
67
+ resp.raise_for_status()
68
+ if use_cache:
69
+ await _write_cache(url, resp.content)
70
+ return resp
File without changes