koishi-plugin-steam-info-check 1.0.10 → 1.1.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 (37) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.js +75 -56
  3. package/dist/locales/zh-CN.d.ts +3 -0
  4. package/dist/locales/zh-CN.js +3 -0
  5. package/nonebot-plugin-steam-info-main/.github/actions/setup-python/action.yml +21 -0
  6. package/nonebot-plugin-steam-info-main/.github/workflows/release.yml +37 -0
  7. package/nonebot-plugin-steam-info-main/LICENSE +21 -0
  8. package/nonebot-plugin-steam-info-main/README.md +117 -0
  9. package/nonebot-plugin-steam-info-main/fonts/MiSans-Bold.ttf +0 -0
  10. package/nonebot-plugin-steam-info-main/fonts/MiSans-Light.ttf +0 -0
  11. package/nonebot-plugin-steam-info-main/fonts/MiSans-Regular.ttf +0 -0
  12. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/__init__.py +487 -0
  13. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/config.py +19 -0
  14. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/data_source.py +264 -0
  15. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/draw.py +921 -0
  16. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/models.py +82 -0
  17. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/bg_dots.png +0 -0
  18. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/busy.png +0 -0
  19. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/default_achievement_image.png +0 -0
  20. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/default_header_image.jpg +0 -0
  21. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/friends_search.png +0 -0
  22. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/gaming.png +0 -0
  23. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/parent_status.png +0 -0
  24. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/unknown_avatar.jpg +0 -0
  25. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/zzz_gaming.png +0 -0
  26. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/zzz_online.png +0 -0
  27. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/steam.py +259 -0
  28. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/utils.py +112 -0
  29. package/nonebot-plugin-steam-info-main/pdm.lock +966 -0
  30. package/nonebot-plugin-steam-info-main/preview.png +0 -0
  31. package/nonebot-plugin-steam-info-main/preview_1.png +0 -0
  32. package/nonebot-plugin-steam-info-main/preview_2.png +0 -0
  33. package/nonebot-plugin-steam-info-main/pyproject.toml +29 -0
  34. package/package.json +1 -1
  35. package/src/index.ts +58 -58
  36. package/src/locales/zh-CN.ts +3 -0
  37. package/src/locales/zh-CN.yml +2 -1
@@ -0,0 +1,82 @@
1
+ from typing import TypedDict, List
2
+
3
+
4
+ class Player(TypedDict):
5
+ steamid: str
6
+ communityvisibilitystate: int
7
+ profilestate: int
8
+ personaname: str
9
+ profileurl: str
10
+ avatar: str
11
+ avatarmedium: str
12
+ avatarfull: str
13
+ avatarhash: str
14
+ lastlogoff: int
15
+ personastate: int
16
+ realname: str
17
+ primaryclanid: str
18
+ timecreated: int
19
+ personastateflags: int
20
+ # gameextrainfo: str
21
+ # gameid: str
22
+
23
+
24
+ class PlayerSummariesResponse(TypedDict):
25
+ players: List[Player]
26
+
27
+
28
+ class PlayerSummaries(TypedDict):
29
+ response: PlayerSummariesResponse
30
+
31
+
32
+ class ProcessedPlayer(Player):
33
+ game_start_time: int # Unix timestamp
34
+
35
+
36
+ class PlayerSummariesProcessedResponse(TypedDict):
37
+ players: List[ProcessedPlayer]
38
+
39
+
40
+ class Achievements(TypedDict):
41
+ name: str
42
+ image: bytes
43
+
44
+
45
+ class GameData(TypedDict):
46
+ game_name: str
47
+ play_time: str # e.g. 10.2
48
+ last_played: str # e.g. 10 月 2 日
49
+ game_image: bytes
50
+ achievements: List[Achievements]
51
+ completed_achievement_number: int
52
+ total_achievement_number: int
53
+
54
+
55
+ class PlayerData(TypedDict):
56
+ steamid: str
57
+ player_name: str
58
+ background: bytes
59
+ avatar: bytes
60
+ description: str
61
+ recent_2_week_play_time: str
62
+ game_data: List[GameData]
63
+
64
+
65
+ class DrawPlayerStatusData(TypedDict):
66
+ game_name: str
67
+ game_time: str # e.g. 10.2 小时(过去 2 周)
68
+ last_play_time: str # e.g. 10 月 2 日
69
+ game_header: bytes
70
+ achievements: List[Achievements]
71
+ completed_achievement_number: int
72
+ total_achievement_number: int
73
+
74
+
75
+ __all__ = [
76
+ "Player",
77
+ "PlayerSummaries",
78
+ "PlayerSummariesResponse",
79
+ "ProcessedPlayer",
80
+ "PlayerSummariesProcessedResponse",
81
+ "DrawPlayerStatusData",
82
+ ]
@@ -0,0 +1,259 @@
1
+ import re
2
+ import httpx
3
+ from pathlib import Path
4
+ from bs4 import BeautifulSoup
5
+ from nonebot.log import logger
6
+ from typing import List, Optional
7
+ from datetime import datetime, timezone
8
+
9
+ from .models import PlayerSummaries, PlayerData
10
+
11
+
12
+ STEAM_ID_OFFSET = 76561197960265728
13
+
14
+
15
+ def get_steam_id(steam_id_or_steam_friends_code: str) -> str:
16
+ if not steam_id_or_steam_friends_code.isdigit():
17
+ return None
18
+
19
+ id_ = int(steam_id_or_steam_friends_code)
20
+
21
+ if id_ < STEAM_ID_OFFSET:
22
+ return str(id_ + STEAM_ID_OFFSET)
23
+
24
+ return steam_id_or_steam_friends_code
25
+
26
+
27
+ async def get_steam_users_info(
28
+ steam_ids: List[str], steam_api_key: List[str], proxy: str = None
29
+ ) -> PlayerSummaries:
30
+ if len(steam_ids) == 0:
31
+ return {"response": {"players": []}}
32
+
33
+ if len(steam_ids) > 100:
34
+ # 分批获取
35
+ result = {"response": {"players": []}}
36
+ for i in range(0, len(steam_ids), 100):
37
+ batch_result = await get_steam_users_info(
38
+ steam_ids[i : i + 100], steam_api_key, proxy
39
+ )
40
+ result["response"]["players"].extend(batch_result["response"]["players"])
41
+ return result
42
+
43
+ for api_key in steam_api_key:
44
+ try:
45
+ async with httpx.AsyncClient(proxy=proxy) as client:
46
+ response = await client.get(
47
+ f'http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key={api_key}&steamids={",".join(steam_ids)}'
48
+ )
49
+ if response.status_code == 200:
50
+ return response.json()
51
+ else:
52
+ logger.warning(f"API key {api_key} failed to get steam users info.")
53
+ except httpx.RequestError as exc:
54
+ logger.warning(f"API key {api_key} encountered an error: {exc}")
55
+
56
+ logger.error("All API keys failed to get steam users info.")
57
+ return {"response": {"players": []}}
58
+
59
+
60
+ async def _fetch(
61
+ url: str, default: bytes, cache_file: Optional[Path] = None, proxy: str = None
62
+ ) -> bytes:
63
+ if cache_file is not None and cache_file.exists():
64
+ return cache_file.read_bytes()
65
+ try:
66
+ async with httpx.AsyncClient(proxy=proxy) as client:
67
+ response = await client.get(url)
68
+ if response.status_code == 200:
69
+ if cache_file is not None:
70
+ cache_file.write_bytes(response.content)
71
+ return response.content
72
+ else:
73
+ response.raise_for_status()
74
+ except Exception as exc:
75
+ logger.error(f"Failed to get image: {exc}")
76
+ return default
77
+
78
+
79
+ async def get_user_data(
80
+ steam_id: int, cache_path: Path, proxy: str = None
81
+ ) -> PlayerData:
82
+ url = f"https://steamcommunity.com/profiles/{steam_id}"
83
+ default_background = (Path(__file__).parent / "res/bg_dots.png").read_bytes()
84
+ default_avatar = (Path(__file__).parent / "res/unknown_avatar.jpg").read_bytes()
85
+ default_achievement_image = (
86
+ Path(__file__).parent / "res/default_achievement_image.png"
87
+ ).read_bytes()
88
+ default_header_image = (
89
+ Path(__file__).parent / "res/default_header_image.jpg"
90
+ ).read_bytes()
91
+
92
+ result = {
93
+ "description": "No information given.",
94
+ "background": default_background,
95
+ "avatar": default_avatar,
96
+ "player_name": "Unknown",
97
+ "recent_2_week_play_time": None,
98
+ "game_data": [],
99
+ }
100
+
101
+ local_time = datetime.now(timezone.utc).astimezone()
102
+ utc_offset_minutes = int(local_time.utcoffset().total_seconds())
103
+ timezone_cookie_value = f"{utc_offset_minutes},0"
104
+
105
+ try:
106
+ async with httpx.AsyncClient(
107
+ proxy=proxy,
108
+ headers={
109
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"
110
+ },
111
+ cookies={"timezoneOffset": timezone_cookie_value},
112
+ ) as client:
113
+ response = await client.get(url)
114
+ if response.status_code == 200:
115
+ html = response.text
116
+ elif response.status_code == 302:
117
+ url = response.headers["Location"]
118
+ response = await client.get(url)
119
+ if response.status_code == 200:
120
+ html = response.text
121
+ else:
122
+ response.raise_for_status()
123
+ except httpx.RequestError as exc:
124
+ logger.error(f"Failed to get user data: {exc}")
125
+ return result
126
+
127
+ # player name
128
+ player_name = re.search(r"<title>Steam 社区 :: (.*?)</title>", html)
129
+ if player_name:
130
+ result["player_name"] = player_name.group(1)
131
+
132
+ # description t<div class="profile_summary">\r\n\t\t\t\t\t\t\t\t風が雨が激しくても<br>思いだすんだ 僕らを照らす光があるよ<br>今日もいっぱい<br>明日もいっぱい 力を出しきってみるよ\t\t\t\t\t\t\t</div>
133
+ description = re.search(
134
+ r'<div class="profile_summary">(.*?)</div>', html, re.DOTALL | re.MULTILINE
135
+ )
136
+ if description:
137
+ description = description.group(1)
138
+ description = re.sub(r"<br>", "\n", description)
139
+ description = re.sub(r"\t", "", description)
140
+ result["description"] = description.strip()
141
+
142
+ # remove emoji
143
+ result["description"] = re.sub(r"ː.*?ː", "", result["description"])
144
+
145
+ # remove xml
146
+ result["description"] = re.sub(r"<.*?>", "", result["description"])
147
+
148
+ # background
149
+ background_url = re.search(r"background-image: url\( \'(.*?)\' \)", html)
150
+ if background_url:
151
+ background_url = background_url.group(1)
152
+ result["background"] = await _fetch(
153
+ background_url, default_background, proxy=proxy
154
+ )
155
+
156
+ # avatar
157
+ # \t<link rel="image_src" href="https://avatars.akamai.steamstatic.com/3ade30f61c3d2cc0b8c80aaf567b573cd022c405_full.jpg">
158
+ avatar_url = re.search(r'<link rel="image_src" href="(.*?)"', html)
159
+ if avatar_url:
160
+ avatar_url = avatar_url.group(1)
161
+ # https://avatars.akamai.steamstatic.com/3ade30f61c3d2cc0b8c80aaf567b573cd022c405_full.jpg
162
+ avatar_url_split = avatar_url.split("/")
163
+ avatar_file = cache_path / f"avatar_{avatar_url_split[-1].split('_')[0]}.jpg"
164
+ result["avatar"] = await _fetch(
165
+ avatar_url, default_avatar, cache_file=avatar_file, proxy=proxy
166
+ )
167
+
168
+ # recent 2 week play time
169
+ # \t<div class="recentgame_quicklinks recentgame_recentplaytime">\r\n\t\t\t\t\t\t\t\t\t<div>15.5 小时(过去 2 周)</div>
170
+ play_time_text = re.search(
171
+ r'<div class="recentgame_quicklinks recentgame_recentplaytime">\s*<div>(.*?)</div>',
172
+ html,
173
+ )
174
+ if play_time_text:
175
+ play_time_text = play_time_text.group(1)
176
+ result["recent_2_week_play_time"] = play_time_text
177
+
178
+ # game data
179
+ soup = BeautifulSoup(html, "html.parser")
180
+ game_data = []
181
+ recent_games = soup.find_all("div", class_="recent_game")
182
+
183
+ for game in recent_games:
184
+ game_info = {}
185
+ game_info["game_name"] = game.find("div", class_="game_name").text.strip()
186
+ game_info["game_image_url"] = game.find("img", class_="game_capsule")["src"]
187
+ game_info_split = game_info["game_image_url"].split("/")
188
+ # https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1144400/capsule_184x69_schinese.jpg?t=1724440433
189
+
190
+ game_info["game_image"] = await _fetch(
191
+ game_info["game_image_url"],
192
+ default_header_image,
193
+ cache_file=cache_path / f"header_{game_info_split[-2]}.jpg",
194
+ proxy=proxy,
195
+ )
196
+
197
+ play_time_text = game.find("div", class_="game_info_details").text.strip()
198
+ play_time = re.search(r"总时数\s*(.*?)\s*小时", play_time_text)
199
+ if play_time is None:
200
+ game_info["play_time"] = ""
201
+ else:
202
+ game_info["play_time"] = play_time.group(1)
203
+
204
+ last_played = re.search(r"最后运行日期:(.*) 日", play_time_text)
205
+ if last_played is not None:
206
+ game_info["last_played"] = "最后运行日期:" + last_played.group(1) + " 日"
207
+ else:
208
+ game_info["last_played"] = "当前正在游戏"
209
+ achievements = []
210
+ achievement_elements = game.find_all("div", class_="game_info_achievement")
211
+ for achievement in achievement_elements:
212
+ if "plus_more" in achievement["class"]:
213
+ continue
214
+ achievement_info = {}
215
+ achievement_info["name"] = achievement["data-tooltip-text"]
216
+ achievement_info["image_url"] = achievement.find("img")["src"]
217
+ achievement_info_split = achievement_info["image_url"].split("/")
218
+
219
+ achievement_info["image"] = await _fetch(
220
+ achievement_info["image_url"],
221
+ default_achievement_image,
222
+ cache_file=cache_path
223
+ / f"achievement_{achievement_info_split[-2]}_{achievement_info_split[-1]}",
224
+ proxy=proxy,
225
+ )
226
+ achievements.append(achievement_info)
227
+ game_info["achievements"] = achievements
228
+ game_info_achievement_summary = game.find(
229
+ "span", class_="game_info_achievement_summary"
230
+ )
231
+ if game_info_achievement_summary is None:
232
+ game_data.append(game_info)
233
+ continue
234
+ remain_achievement_text = game_info_achievement_summary.find(
235
+ "span", class_="ellipsis"
236
+ ).text
237
+ game_info["completed_achievement_number"] = int(
238
+ remain_achievement_text.split("/")[0].strip()
239
+ )
240
+ game_info["total_achievement_number"] = int(
241
+ remain_achievement_text.split("/")[1].strip()
242
+ )
243
+
244
+ game_data.append(game_info)
245
+
246
+ result["game_data"] = game_data
247
+
248
+ return result
249
+
250
+
251
+ if __name__ == "__main__":
252
+ from nonebot.log import logger
253
+ import asyncio
254
+
255
+ data = asyncio.run(get_user_data(76561199135038179, None))
256
+
257
+ with open("bg.jpg", "wb") as f:
258
+ f.write(data["background"])
259
+ logger.info(data["description"])
@@ -0,0 +1,112 @@
1
+ import time
2
+ import pytz
3
+ import httpx
4
+ import datetime
5
+ import calendar
6
+ from PIL import Image
7
+ from io import BytesIO
8
+ from pathlib import Path
9
+ from typing import Dict, Optional
10
+
11
+ from .models import Player
12
+ from .data_source import BindData
13
+
14
+
15
+ async def _fetch_avatar(avatar_url: str, proxy: str = None) -> Image.Image:
16
+ async with httpx.AsyncClient(proxy=proxy) as client:
17
+ response = await client.get(avatar_url)
18
+ if response.status_code != 200:
19
+ return Image.open(Path(__file__).parent / "res/unknown_avatar.jpg")
20
+ return Image.open(BytesIO(response.content))
21
+
22
+
23
+ async def fetch_avatar(
24
+ player: Player, avatar_dir: Optional[Path], proxy: str = None
25
+ ) -> Image.Image:
26
+ if avatar_dir is not None:
27
+ avatar_path = (
28
+ avatar_dir / f"avatar_{player['steamid']}_{player['avatarhash']}.png"
29
+ )
30
+
31
+ if avatar_path.exists():
32
+ avatar = Image.open(avatar_path)
33
+ else:
34
+ avatar = await _fetch_avatar(player["avatarfull"], proxy)
35
+
36
+ avatar.save(avatar_path)
37
+ else:
38
+ avatar = await _fetch_avatar(player["avatarfull"], proxy)
39
+
40
+ return avatar
41
+
42
+
43
+ def convert_player_name_to_nickname(
44
+ data: Dict[str, str], parent_id: str, bind_data: BindData
45
+ ) -> Dict[str, str]:
46
+ data["nickname"] = bind_data.get_by_steam_id(parent_id, data["steamid"])["nickname"]
47
+ return data
48
+
49
+
50
+ async def simplize_steam_player_data(
51
+ player: Player, proxy: str = None, avatar_dir: Path = None
52
+ ) -> Dict[str, str]:
53
+ avatar = await fetch_avatar(player, avatar_dir, proxy)
54
+
55
+ if player["personastate"] == 0:
56
+ if not player.get("lastlogoff"):
57
+ status = "离线"
58
+ else:
59
+ time_logged_off = player["lastlogoff"] # Unix timestamp
60
+ time_to_now = calendar.timegm(time.gmtime()) - time_logged_off
61
+
62
+ # 将时间转换为自然语言
63
+ if time_to_now < 60:
64
+ status = "上次在线 刚刚"
65
+ elif time_to_now < 3600:
66
+ status = f"上次在线 {time_to_now // 60} 分钟前"
67
+ elif time_to_now < 86400:
68
+ status = f"上次在线 {time_to_now // 3600} 小时前"
69
+ elif time_to_now < 2592000:
70
+ status = f"上次在线 {time_to_now // 86400} 天前"
71
+ elif time_to_now < 31536000:
72
+ status = f"上次在线 {time_to_now // 2592000} 个月前"
73
+ else:
74
+ status = f"上次在线 {time_to_now // 31536000} 年前"
75
+ elif player["personastate"] in [1, 2, 4]:
76
+ status = (
77
+ "在线" if player.get("gameextrainfo") is None else player["gameextrainfo"]
78
+ )
79
+ elif player["personastate"] == 3:
80
+ status = (
81
+ "离开" if player.get("gameextrainfo") is None else player["gameextrainfo"]
82
+ )
83
+ elif player["personastate"] in [5, 6]:
84
+ status = "在线"
85
+ else:
86
+ status = "未知"
87
+
88
+ return {
89
+ "steamid": player["steamid"],
90
+ "avatar": avatar,
91
+ "name": player["personaname"],
92
+ "status": status,
93
+ "personastate": player["personastate"],
94
+ }
95
+
96
+
97
+ def image_to_bytes(image: Image.Image) -> bytes:
98
+ with BytesIO() as bio:
99
+ image.save(bio, format="PNG")
100
+ return bio.getvalue()
101
+
102
+
103
+ def hex_to_rgb(hex_color: str):
104
+ return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
105
+
106
+
107
+ def convert_timestamp_to_beijing_time(timestamp: int) -> str:
108
+ beijing_timezone = pytz.timezone("Asia/Shanghai")
109
+ date_utc = datetime.datetime.fromtimestamp(timestamp, pytz.utc)
110
+ date_beijing = date_utc.astimezone(beijing_timezone)
111
+ return date_beijing.strftime("%Y-%m-%d %H:%M:%S")
112
+ # example: 2021-09-06 21:00:00