This commit is contained in:
sansenhoshi
2026-01-04 17:15:40 +08:00
commit 275f05ee4a
207 changed files with 12305 additions and 0 deletions
@@ -0,0 +1,524 @@
import time
import httpx
import nonebot
from io import BytesIO
from pathlib import Path
from nonebot.log import logger
from PIL import Image as PILImage
from nonebot.params import Depends
from nonebot.params import CommandArg
from nonebot import on_command, require
from typing import Union, Optional, List, Dict
from nonebot.adapters import Message, Event, Bot
from nonebot.plugin import PluginMetadata, inherit_supported_adapters
require("nonebot_plugin_alconna")
require("nonebot_plugin_localstore")
require("nonebot_plugin_apscheduler")
import nonebot_plugin_localstore as store
from nonebot_plugin_apscheduler import scheduler
from nonebot_plugin_alconna import Text, Image, UniMessage, Target, At
from nonebot.adapters.onebot.v11 import MessageEvent, Bot, MessageSegment
from .config import Config
from .models import ProcessedPlayer
from .data_source import BindData, SteamInfoData, ParentData, DisableParentData
from .steam import (
get_steam_id,
get_user_data,
STEAM_ID_OFFSET,
get_steam_users_info,
)
from .draw import (
check_font,
set_font_paths,
draw_start_gaming,
draw_player_status,
draw_friends_status,
vertically_concatenate_images,
)
from .utils import (
fetch_avatar,
image_to_bytes,
simplize_steam_player_data,
convert_player_name_to_nickname,
)
__plugin_meta__ = PluginMetadata(
name="Steam Info",
description="播报绑定的 Steam 好友状态",
usage="""
steamhelp: 查看帮助
steambind [Steam ID 或 Steam 好友代码]: 绑定 Steam ID
steamunbind: 解绑 Steam ID
steaminfo (可选)[@某人 或 Steam ID 或 Steam好友代码]: 查看 Steam 主页
steamcheck: 查看 Steam 好友状态
steamenable: 启用 Steam 播报
steamdisable: 禁用 Steam 播报
steamupdate [名称] [图片]: 更新群信息
steamnickname [昵称]: 设置玩家昵称
""".strip(),
type="application",
homepage="https://github.com/zhaomaoniu/nonebot-plugin-steam-info",
config=Config,
supported_adapters=inherit_supported_adapters("nonebot_plugin_alconna"),
)
help = on_command("steamhelp", aliases={"steam帮助"}, priority=10)
bind = on_command("steambind", aliases={"绑定steam"}, priority=10)
unbind = on_command("steamunbind", aliases={"解绑steam"}, priority=10)
info = on_command("steaminfo", aliases={"steam信息"}, priority=10)
check = on_command("steamcheck", aliases={"查看steam", "查steam", "谁在玩游戏"}, priority=10)
enable = on_command("steamenable", aliases={"启用steam"}, priority=10)
disable = on_command("steamdisable", aliases={"禁用steam"}, priority=10)
update_parent_info = on_command("steamupdate", aliases={"更新群信息"}, priority=10)
set_nickname = on_command("steamnickname", aliases={"steam昵称"}, priority=10)
get_play_time = on_command("steamplaytime", aliases={"游玩时长表"}, priority=10)
if hasattr(nonebot, "get_plugin_config"):
config = nonebot.get_plugin_config(Config)
else:
from nonebot import get_driver
config = Config.parse_obj(get_driver().config)
set_font_paths(
config.steam_font_regular_path,
config.steam_font_light_path,
config.steam_font_bold_path,
)
bind_data_path = store.get_data_file("nonebot_plugin_steam_info", "bind_data.json")
steam_info_data_path = store.get_data_file(
"nonebot_plugin_steam_info", "steam_info.json"
)
parent_data_path = store.get_data_file("nonebot_plugin_steam_info", "parent_data.json")
disable_parent_data_path = store.get_data_file(
"nonebot_plugin_steam_info", "disable_parent_data.json"
)
avatar_path = store.get_cache_dir("nonebot_plugin_steam_info")
cache_path = avatar_path
bind_data = BindData(bind_data_path)
steam_info_data = SteamInfoData(steam_info_data_path)
parent_data = ParentData(parent_data_path)
disable_parent_data = DisableParentData(disable_parent_data_path)
try:
check_font()
except FileNotFoundError as e:
logger.error(
f"{e}, nonebot_plugin_steam_info 无法使用,请参照 `https://github.com/zhaomaoniu/nonebot-plugin-steam-info` 配置字体文件"
)
async def get_target(event: Event, bot: Bot) -> Optional[Target]:
target = UniMessage.get_target(event, bot, bot.adapter.get_name())
if target.private:
# 不支持私聊消息
return None
return target
async def to_image_data(image: Image) -> Union[BytesIO, bytes]:
if image.raw is not None:
return image.raw
if image.path is not None:
return Path(image.path).read_bytes()
if image.url is not None:
async with httpx.AsyncClient() as client:
response = await client.get(image.url)
if response.status_code != 200:
raise ValueError(f"无法获取图片数据: {response.status_code}")
return response.content
raise ValueError("无法获取图片数据")
async def broadcast_steam_info(
parent_id: str,
old_players: List[ProcessedPlayer],
new_players: List[ProcessedPlayer],
):
if disable_parent_data.is_disabled(parent_id):
return None
bot = nonebot.get_bot()
play_data = steam_info_data.compare(old_players, new_players)
msg = []
for entry in play_data:
player: ProcessedPlayer = entry["player"]
old_player: ProcessedPlayer = entry.get("old_player")
if entry["type"] == "start":
msg.append(f"{player['personaname']} 开始玩 {player['gameextrainfo']}")
elif entry["type"] == "stop":
time_start = old_player["game_start_time"]
time_stop = time.time()
hours = int((time_stop - time_start) / 3600)
minutes = int((time_stop - time_start) % 3600 / 60)
time_str = (
f"{hours} 小时 {minutes} 分钟" if hours > 0 else f"{minutes} 分钟"
)
msg.append(
f"{player['personaname']} 玩了 {time_str} {old_player['gameextrainfo']} 后不玩了"
)
elif entry["type"] == "change":
msg.append(
f"{player['personaname']} 停止玩 {old_player['gameextrainfo']},开始玩 {player['gameextrainfo']}"
)
elif entry["type"] == "error":
f"出现错误!{player['personaname']}\nNew: {player.get('gameextrainfo')}\nOld: {old_player.get('gameextrainfo')}"
else:
logger.error(f"未知的播报类型: {entry['type']}")
if msg == []:
return None
if config.steam_broadcast_type == "all":
steam_status_data = [
convert_player_name_to_nickname(
(await simplize_steam_player_data(player, config.proxy, avatar_path)),
parent_id,
bind_data,
)
for player in new_players
]
parent_avatar, parent_name = parent_data.get(parent_id)
image = draw_friends_status(parent_avatar, parent_name, steam_status_data)
uni_msg = UniMessage([Text("\n".join(msg)), Image(raw=image_to_bytes(image))])
elif config.steam_broadcast_type == "part":
images = [
draw_start_gaming(
(await fetch_avatar(entry["player"], avatar_path, config.proxy)),
entry["player"]["personaname"],
entry["player"]["gameextrainfo"],
bind_data.get_by_steam_id(parent_id, entry["player"]["steamid"])[
"nickname"
],
)
for entry in play_data
if entry["type"] == "start"
]
if images == []:
uni_msg = UniMessage([Text("\n".join(msg))])
else:
image = (
vertically_concatenate_images(images) if len(images) > 1 else images[0]
)
uni_msg = UniMessage(
[Text("\n".join(msg)), Image(raw=image_to_bytes(image))]
)
elif config.steam_broadcast_type == "none":
uni_msg = UniMessage([Text("\n".join(msg))])
else:
logger.error(f"未知的播报类型: {config.steam_broadcast_type}")
return None
try:
logger.info(f"主动消息触发:{uni_msg}")
await uni_msg.send(
Target(parent_id, parent_id, True, False, "", bot.adapter.get_name()), bot
)
except Exception as e:
logger.error(f"UniMessage异常:{e}")
# 初始化计数器
current_key_index = 0
def key_select_2():
global current_key_index
# 获取key列表
keys = config.steam_api_key
# 获取当前使用的 key
current_key: str = keys[current_key_index]
# 更新计数器
current_key_index = (current_key_index + 1) % len(keys)
return [current_key]
async def update_steam_info():
steam_ids = bind_data.get_all_steam_id()
api_keys = config.steam_api_key
if not api_keys:
logger.warning("没有可用的 key。")
current_key = key_select_2()
logger.info(f"当前调用的key{current_key}")
steam_info = await get_steam_users_info(
steam_ids, current_key, config.proxy
)
old_players_dict: Dict[str, List[ProcessedPlayer]] = {}
for parent_id in bind_data.content.keys():
steam_ids = bind_data.get_all(parent_id)
old_players_dict[parent_id] = steam_info_data.get_players(steam_ids)
steam_info_data.update_by_players(steam_info["response"]["players"])
steam_info_data.save()
return bind_data, old_players_dict
@scheduler.scheduled_job(
"interval", minutes=config.steam_request_interval / 60, id="update_steam_info"
)
async def fetch_and_broadcast_steam_info():
bind_data, old_players_dict = await update_steam_info()
for parent_id in bind_data.content.keys():
old_players = old_players_dict[parent_id]
new_players = steam_info_data.get_players(bind_data.get_all(parent_id))
await broadcast_steam_info(parent_id, old_players, new_players)
if not config.steam_disable_broadcast_on_startup:
nonebot.get_driver().on_bot_connect(update_steam_info)
else:
logger.info("已禁用启动时的 Steam 播报")
@help.handle()
async def help_handle():
await help.finish(__plugin_meta__.usage)
@bind.handle()
async def bind_handle(
event: Event, target: Target = Depends(get_target), cmd_arg: Message = CommandArg()
):
parent_id = target.parent_id or target.id
arg = cmd_arg.extract_plain_text()
if not arg.isdigit():
await bind.finish(
"请输入正确的 Steam ID 或 Steam好友代码,格式: steambind [Steam ID 或 Steam好友代码]"
)
steam_id = get_steam_id(arg)
if user_data := bind_data.get(parent_id, event.get_user_id()):
user_data["steam_id"] = steam_id
bind_data.save()
await bind.finish(f"已更新你的 Steam ID 为 {steam_id}")
else:
bind_data.add(
parent_id,
{"user_id": event.get_user_id(), "steam_id": steam_id, "nickname": None},
)
bind_data.save()
await bind.finish(f"已绑定你的 Steam ID 为 {steam_id}")
@unbind.handle()
async def unbind_handle(event: Event, target: Target = Depends(get_target)):
parent_id = target.parent_id or target.id
user_id = event.get_user_id()
if bind_data.get(parent_id, user_id) is not None:
bind_data.remove(parent_id, user_id)
bind_data.save()
await unbind.finish("已解绑 Steam ID")
else:
await unbind.finish("未绑定 Steam ID")
@info.handle()
async def info_handle(
bot: Bot,
event: Event,
target: Target = Depends(get_target),
arg: Message = CommandArg(),
):
parent_id = target.parent_id or target.id
uni_arg = await UniMessage.generate(message=arg, event=event, bot=bot)
at = uni_arg[At]
if len(at) != 0:
user_id: str = at[0].target
user_data = bind_data.get(parent_id, user_id)
if user_data is None:
await info.finish("该用户未绑定 Steam ID")
steam_id = user_data["steam_id"]
steam_friend_code = str(int(steam_id) - STEAM_ID_OFFSET)
elif arg.extract_plain_text().strip() != "":
steam_id = int(arg.extract_plain_text().strip())
if steam_id < STEAM_ID_OFFSET:
steam_friend_code = steam_id
steam_id += STEAM_ID_OFFSET
else:
steam_friend_code = steam_id - STEAM_ID_OFFSET
else:
user_data = bind_data.get(parent_id, event.get_user_id())
if user_data is None:
await info.finish(
"未绑定 Steam ID, 请使用 “steambind [Steam ID 或 Steam好友代码]” 绑定 Steam ID"
)
steam_id = user_data["steam_id"]
steam_friend_code = str(int(steam_id) - STEAM_ID_OFFSET)
player_data = await get_user_data(steam_id, cache_path, config.proxy)
draw_data = [
{
"game_header": game["game_image"],
"game_name": game["game_name"],
"game_time": f"{game['play_time']} 小时",
"last_play_time": game["last_played"],
"achievements": game["achievements"],
"completed_achievement_number": game.get("completed_achievement_number"),
"total_achievement_number": game.get("total_achievement_number"),
}
for game in player_data["game_data"]
]
image = draw_player_status(
player_data["background"],
player_data["avatar"],
player_data["player_name"],
str(steam_friend_code),
player_data["description"],
player_data["recent_2_week_play_time"],
draw_data,
)
await info.finish(
await UniMessage(
Image(raw=image_to_bytes(image)),
).export(bot)
)
# 初始化计数器
current_key_index_1 = 0
def key_select():
global current_key_index_1
# 获取key列表
keys = config.steam_api_key
# 获取当前使用的 key
current_key: str = keys[current_key_index_1]
# 更新计数器
current_key_index_1 = (current_key_index_1 + 1) % len(keys)
return [current_key]
@check.handle()
async def check_handle(
target: Target = Depends(get_target), arg: Message = CommandArg()
):
if arg.extract_plain_text().strip() != "":
return None
parent_id = target.parent_id or target.id
steam_ids = bind_data.get_all(parent_id)
current_key = key_select()
logger.info(f"当前调用的key{current_key}")
steam_info = await get_steam_users_info(
steam_ids, current_key, config.proxy
)
logger.debug(f"{parent_id} Players info: {steam_info}")
parent_avatar, parent_name = parent_data.get(parent_id)
steam_status_data = [
convert_player_name_to_nickname(
(await simplize_steam_player_data(player, config.proxy, avatar_path)),
parent_id,
bind_data,
)
for player in steam_info["response"]["players"]
]
image = draw_friends_status(parent_avatar, parent_name, steam_status_data)
await target.send(UniMessage(Image(raw=image_to_bytes(image))))
@update_parent_info.handle()
async def update_parent_info_handle(
bot: Bot,
event: Event,
target: Target = Depends(get_target),
arg: Message = CommandArg(),
):
msg = await UniMessage.generate(message=arg, event=event, bot=bot)
info = {}
for seg in msg:
if isinstance(seg, Image):
info["avatar"] = PILImage.open(BytesIO(await to_image_data(seg)))
elif isinstance(seg, Text) and seg.text != "":
info["name"] = seg.text
if "avatar" not in info or "name" not in info:
await update_parent_info.finish("文本中应包含图片和文字")
parent_data.update(target.parent_id or target.id, info["avatar"], info["name"])
await update_parent_info.finish("更新成功")
@enable.handle()
async def enable_handle(target: Target = Depends(get_target)):
parent_id = target.parent_id or target.id
disable_parent_data.remove(parent_id)
disable_parent_data.save()
await enable.finish("已启用 Steam 播报")
@disable.handle()
async def disable_handle(target: Target = Depends(get_target)):
parent_id = target.parent_id or target.id
disable_parent_data.add(parent_id)
disable_parent_data.save()
await disable.finish("已禁用 Steam 播报")
@set_nickname.handle()
async def set_nickname_handle(
event: Event, target: Target = Depends(get_target), cmd_arg: Message = CommandArg()
):
parent_id = target.parent_id or target.id
nickname = cmd_arg.extract_plain_text().strip()
if nickname == "":
await set_nickname.finish("请输入昵称,格式: steamnickname [昵称]")
user_data = bind_data.get(parent_id, event.get_user_id())
if user_data is None:
await set_nickname.finish(
"未绑定 Steam ID,请先使用 steambind 绑定 Steam ID 后再设置昵称"
)
user_data["nickname"] = nickname
bind_data.save()
await set_nickname.finish(f"已设置你的昵称为 {nickname},将在 Steam 播报中显示")
@@ -0,0 +1,19 @@
from typing import Optional, Union, List
from pydantic import BaseModel, validator
class Config(BaseModel):
steam_api_key: Union[str, List[str]]
proxy: Optional[str] = None
steam_request_interval: int = 300 # seconds
steam_broadcast_type: str = "part" # all, part, none
steam_disable_broadcast_on_startup: bool = False
steam_font_regular_path: Optional[str] = "fonts/MiSans-Regular.ttf"
steam_font_light_path: Optional[str] = "fonts/MiSans-Light.ttf"
steam_font_bold_path: Optional[str] = "fonts/MiSans-Bold.ttf"
@validator("steam_api_key", pre=True)
def ensure_list(cls, v):
if isinstance(v, str):
return [v]
return v
@@ -0,0 +1,260 @@
import json
import time
from PIL import Image
from pathlib import Path
from typing import Any, List, Dict, Optional, Tuple
from .models import Player, ProcessedPlayer
class BindData:
def __init__(self, save_path: Path) -> None:
self.content: Dict[str, List[Dict[str, str]]] = {}
self._save_path = save_path
if save_path.exists():
self.content = json.loads(Path(save_path).read_text("utf-8"))
else:
self.save()
def save(self) -> None:
with open(self._save_path, "w", encoding="utf-8") as f:
json.dump(self.content, f, indent=4)
def add(self, parent_id: str, content: Dict[str, str]) -> None:
if parent_id not in self.content:
self.content[parent_id] = [content]
else:
self.content[parent_id].append(content)
def remove(self, parent_id: str, user_id: str) -> None:
if parent_id not in self.content:
return
for data in self.content[parent_id]:
if data["user_id"] == user_id:
self.content[parent_id].remove(data)
break
def update(self, parent_id: str, content: Dict[str, str]) -> None:
self.content[parent_id] = content
def get(self, parent_id: str, user_id: str) -> Optional[Dict[str, str]]:
if parent_id not in self.content:
return None
for data in self.content[parent_id]:
if data["user_id"] == user_id:
if not data.get("nickname"):
data["nickname"] = None
return data
return None
def get_by_steam_id(
self, parent_id: str, steam_id: str
) -> Optional[Dict[str, str]]:
if parent_id not in self.content:
return None
for data in self.content[parent_id]:
if data["steam_id"] == steam_id:
if not data.get("nickname"):
data["nickname"] = None
return data
return None
def get_all(self, parent_id: str) -> List[str]:
if parent_id not in self.content:
return []
result = []
for data in self.content[parent_id]:
if not data["steam_id"] in result:
result.append(data["steam_id"])
return result
def get_all_steam_id(self) -> List[str]:
result = []
for parent_id in self.content:
for data in self.content[parent_id]:
if not data["steam_id"] in result:
result.append(data["steam_id"])
return result
class SteamInfoData:
def __init__(self, save_path: Path) -> None:
self.content: List[ProcessedPlayer] = []
self._save_path = save_path
if save_path.exists():
self.content = json.loads(save_path.read_text("utf-8"))
if isinstance(self.content, dict):
self.content = []
self.save()
else:
self.save()
def save(self) -> None:
with open(self._save_path, "w", encoding="utf-8") as f:
json.dump(self.content, f, indent=4)
def update(self, player: ProcessedPlayer) -> None:
self.content.append(player)
def update_by_players(self, players: List[Player]):
# 将 Player 转换为 ProcessedPlayer
processed_players = []
for player in players:
old_player = self.get_player(player["steamid"])
if old_player is None:
if player.get("gameextrainfo") is not None:
player["game_start_time"] = int(time.time())
else:
player["game_start_time"] = None
processed_players.append(player)
else:
if (
player.get("gameextrainfo") is not None
and old_player.get("gameextrainfo") is None
):
# 开始游戏
player["game_start_time"] = int(time.time())
elif (
player.get("gameextrainfo") is None
and old_player.get("gameextrainfo") is not None
):
# 结束游戏
player["game_start_time"] = None
elif (
player.get("gameextrainfo") is not None
and old_player.get("gameextrainfo") is not None
):
# 继续游戏
player["game_start_time"] = old_player["game_start_time"]
else:
player["game_start_time"] = None
processed_players.append(player)
self.content = processed_players
def get_player(self, steam_id: str) -> Optional[Player]:
for player in self.content:
if player["steamid"] == steam_id:
return player
return None
def get_players(self, steam_ids: List[str]) -> List[Player]:
result = []
for player in self.content:
if player["steamid"] in steam_ids:
result.append(player)
return result
def compare(
self, old_players: List[Player], new_players: List[Player]
) -> List[Dict[str, Any]]:
result = []
for player in new_players:
for old_player in old_players:
if player["steamid"] == old_player["steamid"]:
if player.get("gameextrainfo") != old_player.get("gameextrainfo"):
if player.get("gameextrainfo") is not None:
result.append(
{
"type": "start",
"player": player,
"old_player": old_player,
}
)
elif old_player.get("gameextrainfo") is not None:
result.append(
{
"type": "stop",
"player": player,
"old_player": old_player,
}
)
elif (
player.get("gameextrainfo") is not None
and old_player.get("gameextrainfo") is not None
):
result.append(
{
"type": "change",
"player": player,
"old_player": old_player,
}
)
else:
result.append(
{
"type": "error",
"player": player,
"old_player": old_player,
}
)
return result
class ParentData:
def __init__(self, save_path: Path) -> None:
self.content: Dict[str, str] = {} # parent_id: name
self._save_path = save_path
if not save_path.exists():
save_path.parent.mkdir(parents=True, exist_ok=True)
self.save()
else:
self.content = json.loads(save_path.read_text("utf-8"))
def save(self) -> None:
with open(self._save_path, "w", encoding="utf-8") as f:
json.dump(self.content, f, indent=4)
def update(self, parent_id: str, avatar: Image.Image, name: str) -> None:
self.content[parent_id] = name
self.save()
# 保存图片
avatar_path = self._save_path.parent / f"{parent_id}.png"
avatar.save(avatar_path)
def get(self, parent_id: str) -> Tuple[Image.Image, str]:
if parent_id not in self.content:
return (
Image.open(Path(__file__).parent / "res/unknown_avatar.jpg"),
parent_id,
)
avatar_path = self._save_path.parent / f"{parent_id}.png"
return Image.open(avatar_path), self.content[parent_id]
class DisableParentData:
"""储存禁用 Steam 通知的 parent"""
def __init__(self, save_path: Path) -> None:
self.content: List[str] = []
self._save_path = save_path
if save_path.exists():
self.content = json.loads(save_path.read_text("utf-8"))
else:
self.save()
def save(self) -> None:
with open(self._save_path, "w", encoding="utf-8") as f:
json.dump(self.content, f, indent=4)
def add(self, parent_id: str) -> None:
if parent_id not in self.content:
self.content.append(parent_id)
self.save()
def remove(self, parent_id: str) -> None:
if parent_id in self.content:
self.content.remove(parent_id)
self.save()
def is_disabled(self, parent_id: str) -> bool:
return parent_id in self.content
@@ -0,0 +1,921 @@
import numpy as np
from io import BytesIO
from pathlib import Path
from typing import List, Dict, Tuple
from colorsys import rgb_to_hsv, hsv_to_rgb
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance
from .utils import hex_to_rgb
from .models import DrawPlayerStatusData, Achievements
WIDTH = 400
PARENT_AVATAR_SIZE = 72
MEMBER_AVATAR_SIZE = 50
unknown_avatar_path = Path(__file__).parent / "res/unknown_avatar.jpg"
parent_status_path = Path(__file__).parent / "res/parent_status.png"
friends_search_path = Path(__file__).parent / "res/friends_search.png"
busy_path = Path(__file__).parent / "res/busy.png"
zzz_online_path = Path(__file__).parent / "res/zzz_online.png"
zzz_gaming_path = Path(__file__).parent / "res/zzz_gaming.png"
gaming_path = Path(__file__).parent / "res/gaming.png"
font_regular_path = None
font_light_path = None
font_bold_path = None
def set_font_paths(regular_path, light_path, bold_path):
global font_regular_path, font_light_path, font_bold_path
base_dir = Path().cwd()
font_regular_path = str((base_dir / regular_path).resolve())
font_light_path = str((base_dir / light_path).resolve())
font_bold_path = str((base_dir / bold_path).resolve())
def check_font():
if not Path(font_regular_path).exists():
raise FileNotFoundError(f"Font file {font_regular_path} not found.")
if not Path(font_light_path).exists():
raise FileNotFoundError(f"Font file {font_light_path} not found.")
if not Path(font_bold_path).exists():
raise FileNotFoundError(f"Font file {font_bold_path} not found.")
personastate_colors = {
0: (hex_to_rgb("969697"), hex_to_rgb("656565")),
1: (hex_to_rgb("6dcef5"), hex_to_rgb("4c91ac")),
2: (hex_to_rgb("6dcef5"), hex_to_rgb("4c91ac")),
3: (hex_to_rgb("45778e"), hex_to_rgb("365969")),
4: (hex_to_rgb("6dcef5"), hex_to_rgb("4c91ac")),
5: (hex_to_rgb("6dcef5"), hex_to_rgb("4c91ac")),
6: (hex_to_rgb("6dcef5"), hex_to_rgb("4c91ac")),
}
def vertically_concatenate_images(images: List[Image.Image]) -> Image.Image:
widths, heights = zip(*(i.size for i in images))
total_width = max(widths)
total_height = sum(heights)
new_image = Image.new("RGB", (total_width, total_height))
y_offset = 0
for image in images:
new_image.paste(image, (0, y_offset))
y_offset += image.size[1]
return new_image
def draw_start_gaming(
avatar: Image.Image, friend_name: str, game_name: str, nickname: str = None
):
canvas = Image.open(gaming_path)
canvas.paste(avatar.resize((66, 66), Image.BICUBIC), (15, 20))
# 绘制名称
draw = ImageDraw.Draw(canvas)
draw.text(
(104, 14),
f"{friend_name} ({nickname})" if nickname is not None else friend_name,
font=ImageFont.truetype(font_regular_path, 19),
fill=hex_to_rgb("e3ffc2"),
)
# 绘制"正在玩"
draw.text(
(103, 42),
"正在玩",
font=ImageFont.truetype(font_regular_path, 17),
fill=hex_to_rgb("969696"),
)
# 绘制游戏名称
draw.text(
(104, 66),
game_name,
font=ImageFont.truetype(font_bold_path, 14),
fill=hex_to_rgb("91c257"),
)
return canvas
def draw_parent_status(parent_avatar: Image.Image, parent_name: str) -> Image.Image:
parent_avatar = parent_avatar.resize(
(PARENT_AVATAR_SIZE, PARENT_AVATAR_SIZE), Image.BICUBIC
)
canvas = Image.open(parent_status_path).resize((WIDTH, 120), Image.BICUBIC)
draw = ImageDraw.Draw(canvas)
# 在左下角 (16, 16) 处绘制头像
avatar_height = 120 - 16 - PARENT_AVATAR_SIZE
canvas.paste(parent_avatar, (16, avatar_height))
# 绘制名称
draw.text(
(16 + PARENT_AVATAR_SIZE + 16, avatar_height + 12),
parent_name,
font=ImageFont.truetype(font_bold_path, 20),
fill=hex_to_rgb("6dcff6"),
)
# 绘制状态
draw.text(
(16 + PARENT_AVATAR_SIZE + 16, avatar_height + 20 + 16),
"在线",
font=ImageFont.truetype(font_light_path, 18),
fill=hex_to_rgb("4c91ac"),
)
return canvas
def draw_friends_search() -> Image.Image:
canvas = Image.new("RGB", (WIDTH, 50), hex_to_rgb("434953"))
friends_search = Image.open(friends_search_path)
canvas.paste(friends_search, (WIDTH - friends_search.width, 0))
draw = ImageDraw.Draw(canvas)
draw.text(
(24, 10),
"好友",
hex_to_rgb("b7ccd5"),
font=ImageFont.truetype(font_regular_path, 20),
)
return canvas
def draw_friend_status(
friend_avatar: Image.Image,
friend_name: str,
status: str,
personastate: int,
nickname: str = None,
) -> Image.Image:
friend_avatar = friend_avatar.resize(
(MEMBER_AVATAR_SIZE, MEMBER_AVATAR_SIZE), Image.BICUBIC
)
canvas = Image.new("RGB", (WIDTH, 64), hex_to_rgb("1e2024"))
draw = ImageDraw.Draw(canvas)
display_name = (
f"{friend_name} ({nickname})" if nickname is not None else friend_name
)
if personastate == 2:
# 忙碌 加上一个忙碌图标
canvas = draw_friend_status(friend_avatar, friend_name, status, 1, nickname)
draw = ImageDraw.Draw(canvas)
busy = Image.open(busy_path)
name_width = int(
draw.textlength(display_name, font=ImageFont.truetype(font_bold_path, 20))
)
canvas.paste(busy, (22 + MEMBER_AVATAR_SIZE + 16 + name_width + 4, 18))
return canvas
if personastate == 4:
# 打盹 加上一个 ZZZ
canvas = draw_friend_status(friend_avatar, friend_name, status, 1, nickname)
draw = ImageDraw.Draw(canvas)
zzz = Image.open(zzz_online_path if status == "在线" else zzz_gaming_path)
name_width = int(
draw.textlength(display_name, font=ImageFont.truetype(font_bold_path, 20))
)
canvas.paste(zzz, (22 + MEMBER_AVATAR_SIZE + 16 + name_width + 8, 18))
return canvas
# 绘制头像
canvas.paste(friend_avatar, (22, 8))
if status != "在线" and personastate == 1:
fill = (hex_to_rgb("e3ffc2"), hex_to_rgb("8ebe56"))
elif status != "离开" and personastate == 3:
fill = (hex_to_rgb("e3ffc2"), hex_to_rgb("8ebe56"))
else:
fill = personastate_colors[personastate]
# 绘制名称
draw.text(
(22 + MEMBER_AVATAR_SIZE + 18, 12),
display_name,
font=ImageFont.truetype(font_bold_path, 20),
fill=fill[0],
)
# 绘制状态
draw.text(
(22 + MEMBER_AVATAR_SIZE + 16, 36),
status,
font=ImageFont.truetype(font_regular_path, 18),
fill=fill[1],
)
return canvas
def draw_gaming_friends_status(data: List[Dict[str, str]]) -> Image.Image:
# 排序数据,按照游戏名称字母表顺序排序
data.sort(key=lambda x: x["status"])
canvas = Image.new(
"RGB",
(WIDTH, 64 + (MEMBER_AVATAR_SIZE + 16) * len(data) + 16),
hex_to_rgb("1e2024"),
)
draw = ImageDraw.Draw(canvas)
# 绘制标题
draw.text(
(22, 22),
"游戏中",
hex_to_rgb("c5d6d4"),
font=ImageFont.truetype(font_regular_path, 22),
)
# 绘制好友头像和名称
friends_status_list = [
draw_friend_status(
d["avatar"], d["name"], d["status"], d["personastate"], d["nickname"]
)
for d in data
]
# 拼接好友头像和名称
for i, friend_status in enumerate(friends_status_list):
canvas.paste(friend_status, (0, 64 + (MEMBER_AVATAR_SIZE + 16) * i))
return canvas
def draw_online_friends_status(data: List[Dict[str, str]]) -> Image.Image:
canvas = Image.new(
"RGB",
(WIDTH, 64 + (MEMBER_AVATAR_SIZE + 16) * len(data) + 16),
hex_to_rgb("1e2024"),
)
draw = ImageDraw.Draw(canvas)
# 绘制标题
draw.text(
(22, 22),
"在线好友",
hex_to_rgb("c5d6d4"),
font=ImageFont.truetype(font_regular_path, 22),
)
# 绘制在线人数
draw.text(
(115, 25),
f"({len(data)})",
hex_to_rgb("67665c"),
font=ImageFont.truetype(font_regular_path, 18),
)
# 绘制好友头像和名称
friends_status_list = [
draw_friend_status(
d["avatar"], d["name"], d["status"], d["personastate"], d["nickname"]
)
for d in data
]
# 拼接好友头像和名称
for i, friend_status in enumerate(friends_status_list):
canvas.paste(friend_status, (0, 64 + (MEMBER_AVATAR_SIZE + 16) * i))
return canvas
def draw_offline_friends_status(data: List[Dict[str, str]]) -> Image.Image:
canvas = Image.new(
"RGB",
(WIDTH, 64 + (MEMBER_AVATAR_SIZE + 16) * len(data) + 16),
hex_to_rgb("1e2024"),
)
draw = ImageDraw.Draw(canvas)
# 绘制标题
draw.text(
(22, 22),
"离线",
hex_to_rgb("c5d6d4"),
font=ImageFont.truetype(font_regular_path, 22),
)
# 绘制离线人数
draw.text(
(72, 25),
f"({len(data)})",
hex_to_rgb("67665c"),
font=ImageFont.truetype(font_regular_path, 18),
)
# 绘制好友头像和名称
friends_status_list = [
draw_friend_status(
d["avatar"], d["name"], d["status"], d["personastate"], d["nickname"]
)
for d in data
]
# 拼接好友头像和名称
for i, friend_status in enumerate(friends_status_list):
canvas.paste(friend_status, (0, 64 + (MEMBER_AVATAR_SIZE + 16) * i))
return canvas
def draw_friends_status(
parent_avatar: Image.Image, parent_name: str, data: List[Dict[str, str]]
):
data.sort(key=lambda x: x["personastate"])
parent_status = draw_parent_status(parent_avatar, parent_name)
friends_search = draw_friends_search()
status_images: List[Image.Image] = []
height = parent_status.height + friends_search.height
gaming_data = [
d
for d in data
if (d["personastate"] == 1 and d["status"] != "在线")
or (d["personastate"] == 3 and d["status"] != "离开")
or (d["personastate"] == 4 and d["status"] != "在线")
]
if gaming_data:
status_images.append(draw_gaming_friends_status(gaming_data))
height += status_images[-1].height
online_data = [
d
for d in data
if (d["personastate"] == 1 and d["status"] == "在线")
or (d["personastate"] == 3 and d["status"] == "离开")
or (d["personastate"] == 4 and d["status"] == "在线")
or (d["personastate"] in [2, 5, 6])
]
# 按 1, 2, 4, 5, 6, 3 的顺序排序
online_data.sort(key=lambda x: (7 if x["personastate"] == 3 else x["personastate"]))
if online_data:
status_images.append(draw_online_friends_status(online_data))
height += status_images[-1].height
offline_data = [d for d in data if d["personastate"] == 0]
if offline_data:
status_images.append(draw_offline_friends_status(offline_data))
height += status_images[-1].height
# 拼合图片
canvas = Image.new("RGB", (WIDTH, height), hex_to_rgb("1e2024"))
draw = ImageDraw.Draw(canvas)
canvas.paste(parent_status, (0, 0))
canvas.paste(friends_search, (0, parent_status.height))
y = parent_status.height + friends_search.height
for i, status_image in enumerate(status_images):
canvas.paste(status_image, (0, y))
y += status_image.height
# 绘制分割线
if i != len(status_images) - 1:
draw.rectangle([0, y - 1, WIDTH, y], fill=hex_to_rgb("333439"))
return canvas
def get_average_color(image: Image.Image) -> tuple[int, int, int]:
"""获取图片的平均颜色"""
image_np = np.array(image)
average_color = image_np.mean(axis=(0, 1)).astype(int)
return tuple(average_color)
def split_image(
image: Image.Image, rows: int, cols: int
) -> tuple[list[Image.Image], int, int]:
"""将图片分割为rows * cols份"""
width, height = image.size
piece_width = width // cols
piece_height = height // rows
pieces = []
for r in range(rows):
for c in range(cols):
box = (
c * piece_width,
r * piece_height,
(c + 1) * piece_width,
(r + 1) * piece_height,
)
piece = image.crop(box)
pieces.append(piece)
return pieces, piece_width, piece_height
def recolor_image(image: Image.Image, rows: int, cols: int) -> Image.Image:
"""分片图片,提取平均颜色后拼接"""
total_average_color = get_average_color(image) # 获取整体平均颜色
pieces, piece_width, piece_height = split_image(image, rows, cols)
diameter = min(pieces[0].size) # 以最小边为直径
radius = diameter // 2
new_image = Image.new("RGB", image.size, total_average_color)
for i, piece in enumerate(pieces):
average_color = get_average_color(piece) # 获取每片的平均颜色
# 计算放置的位置
row, col = divmod(i, cols)
x = col * piece_width + piece_width // 2
y = row * piece_height + piece_height // 2
# 画圆
circle = Image.new("RGBA", (piece_width, piece_height), (0, 0, 0, 0))
draw = ImageDraw.Draw(circle)
draw.ellipse((0, 0, piece_width, piece_height), fill=average_color)
# 将圆形图片粘贴到新图片上
new_image.paste(circle, (x - radius, y - radius), circle)
new_image = new_image.filter(ImageFilter.SMOOTH)
new_image = new_image.filter(ImageFilter.GaussianBlur(50))
return new_image
def create_gradient_image(
size: Tuple[int, int], color1: Tuple[int, int, int], color2: Tuple[int, int, int]
) -> Image.Image:
"""创建渐变图片"""
# 确保颜色值在 0-255 范围内
color1 = tuple(max(0, min(255, c)) for c in color1)
color2 = tuple(max(0, min(255, c)) for c in color2)
# 创建一个渐变的线性空间
gradient_array = np.linspace(color1, color2, size[0])
# 将渐变数组的形状调整为 (height, width, 3)
gradient_image = np.tile(gradient_array, (size[1], 1, 1)).astype(np.uint8)
return Image.fromarray(gradient_image, "RGBA")
def create_vertical_gradient_rect(width, height, start_color, end_color):
"""
创建一个在竖直方向上渐变的矩形图像.
Args:
width (int): 矩形的宽度 (以像素为单位).
height (int): 矩形的高度 (以像素为单位).
start_color (tuple): 起始颜色,格式为 (R, G, B),每个值范围为 0-255.
end_color (tuple): 结束颜色,格式为 (R, G, B),每个值范围为 0-255.
Returns:
Image: PIL Image 对象,表示生成的渐变矩形.
"""
if width <= 0 or height <= 0:
return Image.new("RGBA", (1, 1), (0, 0, 0, 0))
# 确保颜色不超过 0-255 的范围
start_color = tuple(max(0, min(255, c)) for c in start_color)
end_color = tuple(max(0, min(255, c)) for c in end_color)
# 使用 NumPy 创建一个线性渐变数组
gradient_array = np.linspace(start_color, end_color, num=height, dtype=np.uint8)
gradient_array = np.tile(gradient_array[:, np.newaxis, :], (1, width, 1))
# 使用 Pillow 创建图像并填充颜色
image = Image.fromarray(gradient_array)
return image
def random_color_offset(
color: Tuple[int, int, int], offset: int
) -> Tuple[int, int, int]:
return tuple(
min(255, max(0, c + np.random.randint(-offset, offset + 1))) for c in color
)
def get_brightest_and_darkest_color(
image: Image.Image,
saturation_threshold: int = 100,
hue_difference_threshold: int = 30,
) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]:
"""获取图片最亮和最暗的颜色"""
# 将RGB图像转换为HSV
img_hsv = np.array(image.convert("HSV"))
# 设定一个阈值来定义“鲜艳的颜色”,例如饱和度大于150
vivid_mask = img_hsv[..., 1] > saturation_threshold
# 获取饱和度较高(鲜艳)的像素索引
vivid_pixels = img_hsv[vivid_mask]
if len(vivid_pixels) < 10:
return get_brightest_and_darkest_color(image, saturation_threshold - 10)
# 在鲜艳的像素中,根据亮度(V通道)找到最亮和最暗的颜色
brightest_pixel = vivid_pixels[np.argmax(vivid_pixels[..., 2])]
darkest_pixel = vivid_pixels[np.argmin(vivid_pixels[..., 2])]
# 获取最亮和最暗的颜色的色相差异
hue_difference = abs(int(brightest_pixel[0]) - int(darkest_pixel[0]))
# 如果色相差异过小,则尝试寻找新的最暗颜色,直到色相差异大于设定阈值
if hue_difference < hue_difference_threshold:
possible_dark_pixels = vivid_pixels[vivid_pixels[..., 0] != brightest_pixel[0]]
if len(possible_dark_pixels) > 0:
darkest_pixel = possible_dark_pixels[
np.argmin(possible_dark_pixels[..., 2])
]
# 将最亮和最暗的像素从HSV转回RGB
brightest_color = (
Image.fromarray(np.uint8([[brightest_pixel]]), "HSV")
.convert("RGB")
.getpixel((0, 0))
)
darkest_color = (
Image.fromarray(np.uint8([[darkest_pixel]]), "HSV")
.convert("RGB")
.getpixel((0, 0))
)
return brightest_color, darkest_color
def draw_game_info(
header: Image.Image,
game_name: str,
game_time: str,
last_play_time: str,
achievements: List[Achievements],
completed_achievement_number: int,
total_achievement_number: int,
achievement_color: Tuple[int, int, int],
) -> Image.Image:
bg = Image.new("RGBA", (880, 110 + 64 + 10), (0, 0, 0, 110))
header = header.resize((229, 86), Image.BICUBIC)
bg.paste(header, (10, 110 // 2 - header.height // 2))
draw = ImageDraw.Draw(bg)
# 画游戏名
draw.text(
(260, 10),
game_name,
font=ImageFont.truetype(font_regular_path, 26),
fill=(255, 255, 255),
)
# 画最后游玩时间
font = ImageFont.truetype(font_light_path, 22)
display_text = last_play_time
draw.text(
(int(bg.width - font.getlength(display_text)) - 10, 75),
display_text,
font=font,
fill=(150, 150, 150),
)
# 画游戏时间
font = ImageFont.truetype(font_light_path, 22)
display_text = f"总时数 {game_time}"
draw.text(
(int(bg.width - font.getlength(display_text)) - 10, 50),
display_text,
font=font,
fill=(150, 150, 150),
)
if completed_achievement_number is None or total_achievement_number is None:
return bg.crop((0, 0, bg.width, 110))
# 画成就 + 64 + 10
achievement_bg = Image.new("RGBA", (860, 64), achievement_color)
draw_achievement = ImageDraw.Draw(achievement_bg)
# 画成就进度
font = ImageFont.truetype(font_light_path, 18)
x = 14
draw_achievement.text(
(x, 20),
"成就进度",
font=font,
fill=(255, 255, 255, 255),
)
x += font.getlength("成就进度") + 10
draw_achievement.text(
(int(x), 20),
f"{completed_achievement_number} / {total_achievement_number}",
font=font,
fill=(130, 130, 130),
)
x += (
font.getlength(f"{completed_achievement_number} / {total_achievement_number}")
+ 10
)
progress_bar = create_progress_bar(
completed_achievement_number / total_achievement_number, achievement_color
)
achievement_bg.paste(progress_bar, (int(x), 24), progress_bar)
# 画成就图标
x = 860 - 48 * 6 - 10 * 6
for achievement in achievements:
achievement_image = Image.open(BytesIO(achievement["image"])).resize((48, 48))
achievement_bg.paste(achievement_image, (x, 8))
x += 48 + 10
if completed_achievement_number > 6:
font = ImageFont.truetype(font_regular_path, 22)
display_text = f"+{completed_achievement_number - 5}"
draw_achievement.rectangle((x, 8, x + 48, 56), fill=(34, 34, 34))
draw_achievement.text(
(x + 24 - font.getlength(display_text) // 2, 18),
display_text,
font=font,
fill=(255, 255, 255),
)
bg.paste(achievement_bg, (10, 110), achievement_bg)
return bg
def draw_player_status(
player_bg: Image.Image,
player_avatar: Image.Image,
player_name: str,
player_id: str,
player_description: str,
player_last_two_weeks_time: str, # e.g. 10.2 小时
player_games: List[DrawPlayerStatusData],
):
if isinstance(player_bg, bytes):
player_bg = Image.open(BytesIO(player_bg))
if isinstance(player_avatar, bytes):
player_avatar = Image.open(BytesIO(player_avatar))
bg = recolor_image(
player_bg.crop(
(
(player_bg.width - 960) // 2,
0,
(player_bg.width + 960) // 2,
player_bg.height,
)
),
10,
10,
)
# 调暗背景
enhancer = ImageEnhance.Brightness(bg)
bg = enhancer.enhance(0.7)
# bg.size = (960, 1020)
player_avatar = player_avatar.resize((200, 200))
bg.paste(player_avatar, (40, 40))
draw = ImageDraw.Draw(bg)
# 画头像外框
draw.rectangle((40, 40, 240, 240), outline=(83, 164, 196), width=3)
# 画昵称
draw.text(
(280, 48),
player_name,
font=ImageFont.truetype(font_light_path, 40),
fill=(255, 255, 255),
)
# 画ID
draw.text(
(280, 100),
f"好友代码: {player_id}",
font=ImageFont.truetype(font_regular_path, 19),
fill=(191, 191, 191),
)
# 画简介
line_width = 0
offset = 0
line = ""
for idx, char in enumerate(player_description):
line += char
line_width += ImageFont.truetype(font_light_path, 22).getlength(char)
if line_width > 640 or idx == len(player_description) - 1 or char == "\n":
draw.text(
(280, 132 + offset),
line,
font=ImageFont.truetype(font_light_path, 22),
fill=(255, 255, 255),
)
line = ""
offset += 25
line_width = 0
if offset >= 25 * 4:
break
# 画游戏
brightest_color, darkest_color = get_brightest_and_darkest_color(player_bg)
brightest_color = tuple(map(lambda x: x - 30 if x >= 30 else 0, brightest_color))
darkest_color = tuple(
map(lambda x: x + 30 if x <= 255 - 30 else 255, darkest_color)
)
brightest_color = (brightest_color[0], brightest_color[1], brightest_color[2], 128)
brightest_color = random_color_offset(brightest_color, 20)
darkest_color = (darkest_color[0], darkest_color[1], darkest_color[2], 128)
darkest_color = random_color_offset(darkest_color, 20)
# 画游戏信息
hsv_achievement_color = rgb_to_hsv(*brightest_color[:3])
achievement_color = tuple(
map(
int,
hsv_to_rgb(
hsv_achievement_color[0],
hsv_achievement_color[1] * 0.85,
hsv_achievement_color[2] * 0.6,
),
)
)
game_images: List[Image.Image] = []
for idx, game in enumerate(player_games):
game_image = Image.open(BytesIO(game["game_header"]))
game_info = draw_game_info(
game_image,
game["game_name"],
game["game_time"],
game["last_play_time"],
game["achievements"],
game["completed_achievement_number"],
game["total_achievement_number"],
achievement_color,
)
game_images.append(game_info)
# 画半透明黑色背景
bg_game = Image.new(
"RGBA", (920, 106 + sum([game_image.height + 26 for game_image in game_images]))
)
draw_game = ImageDraw.Draw(bg_game)
draw_game.rectangle(
(
0,
0,
920,
bg_game.height,
),
fill=(0, 0, 0, 120),
)
bg.paste(bg_game, (20, 272), bg_game)
# 画渐变条
gradient = create_gradient_image((920, 50), brightest_color, darkest_color)
bg.paste(gradient, (20, 272), gradient)
# 画渐变条的文字:最新动态,最近游戏
draw.text(
(34, 279),
"最新动态",
font=ImageFont.truetype(font_light_path, 26),
fill=(255, 255, 255),
)
if player_last_two_weeks_time is not None:
width = ImageFont.truetype(font_light_path, 26).getlength(
player_last_two_weeks_time
)
draw.text(
(960 - width - 34, 279),
player_last_two_weeks_time,
font=ImageFont.truetype(font_light_path, 26),
fill=(255, 255, 255),
)
y = 350
for idx, game_image in enumerate(game_images):
bg.paste(
game_image,
((920 - game_image.width) // 2 + 20, y),
game_image.convert("RGBA"),
)
y += game_image.height + 26
player_bg.paste(bg, ((player_bg.width - 960) // 2, 0), bg.convert("RGBA"))
return player_bg
def rounded_rectangle(
image: Image.Image,
radius: int,
border=False,
border_width=0,
border_color=(0, 0, 0),
):
"""
将给定的Image.Image对象切割为圆角矩形。
Args:
image: 一个PIL Image对象。
radius: 圆角半径,单位为像素。
border: 是否需要边框,默认为False。
border_width: 边框宽度,单位为像素,默认为0。
border_color: 边框颜色,RGB元组,默认为黑色(0, 0, 0)。
Returns:
一个PIL Image对象,表示切割后的圆角矩形图像。
"""
width, height = image.size
image_ = Image.new("RGBA", (width + 1, height + 1), (0, 0, 0, 0))
image_.paste(image, (0, 0), image.convert("RGBA"))
# 创建一个圆角矩形的遮罩
result = Image.new("RGBA", (width + 1, height + 1), (0, 0, 0, 0))
mask = Image.new("L", (width + 1, height + 1), 0)
draw = ImageDraw.Draw(mask)
image_draw = ImageDraw.Draw(result)
# 绘制圆角矩形
draw.rounded_rectangle((0, 0, width, height), radius=radius, fill=255)
# 应用遮罩到原始图像
result.paste(image_, (0, 0), mask)
# 添加边框 (如果需要)
if border:
image_draw.rounded_rectangle(
(0, 0, width, height),
radius=radius,
outline=border_color,
width=border_width,
)
return result
def create_progress_bar(
progress: float, color: Tuple[int, int, int], width=186, height=16
):
color_hsv = rgb_to_hsv(*color)
# 外条
bar_color = tuple(
map(int, hsv_to_rgb(color_hsv[0], color_hsv[1], color_hsv[2] * 0.8))
)
border_color = tuple(map(lambda x: max(x - 20, 0), color))
border_image = rounded_rectangle(
Image.new("RGBA", (width, height), bar_color),
8,
border=True,
border_width=1,
border_color=border_color,
)
# 内条
bar_color_top = tuple(
map(int, hsv_to_rgb(color_hsv[0], color_hsv[1] / 2, color_hsv[2] * 5 / 2))
)
bar_color_bottem = tuple(
map(int, hsv_to_rgb(color_hsv[0], color_hsv[1] / 2, color_hsv[2]))
)
bar_image = create_vertical_gradient_rect(
int(width * progress) - 6, height - 4, bar_color_top, bar_color_bottem
)
bar_image = rounded_rectangle(bar_image, 6)
# 合并
border_image.paste(bar_image, (3, 2), bar_image)
return border_image
@@ -0,0 +1,82 @@
from typing import TypedDict, List
class Player(TypedDict):
steamid: str
communityvisibilitystate: int
profilestate: int
personaname: str
profileurl: str
avatar: str
avatarmedium: str
avatarfull: str
avatarhash: str
lastlogoff: int
personastate: int
realname: str
primaryclanid: str
timecreated: int
personastateflags: int
# gameextrainfo: str
# gameid: str
class PlayerSummariesResponse(TypedDict):
players: List[Player]
class PlayerSummaries(TypedDict):
response: PlayerSummariesResponse
class ProcessedPlayer(Player):
game_start_time: int # Unix timestamp
class PlayerSummariesProcessedResponse(TypedDict):
players: List[ProcessedPlayer]
class Achievements(TypedDict):
name: str
image: bytes
class GameData(TypedDict):
game_name: str
play_time: str # e.g. 10.2
last_played: str # e.g. 10 月 2 日
game_image: bytes
achievements: List[Achievements]
completed_achievement_number: int
total_achievement_number: int
class PlayerData(TypedDict):
steamid: str
player_name: str
background: bytes
avatar: bytes
description: str
recent_2_week_play_time: str
game_data: List[GameData]
class DrawPlayerStatusData(TypedDict):
game_name: str
game_time: str # e.g. 10.2 小时(过去 2 周)
last_play_time: str # e.g. 10 月 2 日
game_header: bytes
achievements: List[Achievements]
completed_achievement_number: int
total_achievement_number: int
__all__ = [
"Player",
"PlayerSummaries",
"PlayerSummariesResponse",
"ProcessedPlayer",
"PlayerSummariesProcessedResponse",
"DrawPlayerStatusData",
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

@@ -0,0 +1,321 @@
import re
import httpx
from pathlib import Path
from bs4 import BeautifulSoup
from nonebot.log import logger
from typing import List, Optional
from datetime import datetime, timezone
import asyncio
import requests
import csv
from .models import PlayerSummaries, PlayerData
STEAM_ID_OFFSET = 76561197960265728
# 全局变量,用于记录当前使用的 API Key 的索引
current_api_key_index = 0
def get_steam_id(steam_id_or_steam_friends_code: str) -> str:
if not steam_id_or_steam_friends_code.isdigit():
return None
id_ = int(steam_id_or_steam_friends_code)
if id_ < STEAM_ID_OFFSET:
return str(id_ + STEAM_ID_OFFSET)
return steam_id_or_steam_friends_code
async def get_steam_users_info(
steam_ids: List[str], steam_api_key: List[str], proxy: str = None
) -> PlayerSummaries:
if len(steam_ids) == 0:
return {"response": {"players": []}}
if len(steam_ids) > 100:
# 分批获取
result = {"response": {"players": []}}
for i in range(0, len(steam_ids), 100):
batch_result = await get_steam_users_info(
steam_ids[i: i + 100], steam_api_key, proxy
)
result["response"]["players"].extend(batch_result["response"]["players"])
return result
for api_key in steam_api_key:
try:
async with httpx.AsyncClient(proxy=proxy) as client:
response = await client.get(
f'https://community.steam-api.com/ISteamUser/GetPlayerSummaries/v0002/?key={api_key}&steamids={",".join(steam_ids)}'
)
logger.info(f"本次请求的响应码:{response.status_code}")
if response.status_code == 200:
return response.json()
else:
logger.warning(f"API key {api_key} failed to get steam users info.")
except httpx.RequestError as exc:
async with httpx.AsyncClient(proxy=proxy) as client:
response = await client.get(
f'https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key={api_key}&steamids={",".join(steam_ids)}'
)
logger.info(f"本次请求的响应码:{response.status_code}")
if response.status_code == 200:
return response.json()
else:
logger.warning(f"API key {api_key} failed to get steam users info.")
logger.warning(f"API key {api_key} encountered an error: {exc}")
logger.error("All API keys failed to get steam users info.")
return {"response": {"players": []}}
async def _fetch(
url: str, default: bytes, cache_file: Optional[Path] = None, proxy: str = None
) -> bytes:
if cache_file is not None and cache_file.exists():
return cache_file.read_bytes()
try:
async with httpx.AsyncClient(proxy=proxy) as client:
response = await client.get(url)
if response.status_code == 200:
if cache_file is not None:
cache_file.write_bytes(response.content)
return response.content
else:
response.raise_for_status()
except Exception as exc:
logger.error(f"Failed to get image: {exc}")
return default
async def get_user_data(
steam_id: int, cache_path: Path, proxy: str = None
) -> PlayerData:
url = f"https://steamcommunity.com/profiles/{steam_id}"
default_background = (Path(__file__).parent / "res/bg_dots.png").read_bytes()
default_avatar = (Path(__file__).parent / "res/unknown_avatar.jpg").read_bytes()
default_achievement_image = (
Path(__file__).parent / "res/default_achievement_image.png"
).read_bytes()
default_header_image = (
Path(__file__).parent / "res/default_header_image.jpg"
).read_bytes()
result = {
"description": "No information given.",
"background": default_background,
"avatar": default_avatar,
"player_name": "Unknown",
"recent_2_week_play_time": None,
"game_data": [],
}
local_time = datetime.now(timezone.utc).astimezone()
utc_offset_minutes = int(local_time.utcoffset().total_seconds())
timezone_cookie_value = f"{utc_offset_minutes},0"
try:
async with httpx.AsyncClient(
proxy=proxy,
headers={
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"
},
cookies={"timezoneOffset": timezone_cookie_value},
) as client:
response = await client.get(url)
if response.status_code == 200:
html = response.text
elif response.status_code == 302:
url = response.headers["Location"]
response = await client.get(url)
if response.status_code == 200:
html = response.text
else:
response.raise_for_status()
except httpx.RequestError as exc:
logger.error(f"Failed to get user data: {exc}")
return result
# player name
player_name = re.search(r"<title>Steam 社区 :: (.*?)</title>", html)
if player_name:
result["player_name"] = player_name.group(1)
# 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>
description = re.search(
r'<div class="profile_summary">(.*?)</div>', html, re.DOTALL | re.MULTILINE
)
if description:
description = description.group(1)
description = re.sub(r"<br>", "\n", description)
description = re.sub(r"\t", "", description)
result["description"] = description.strip()
# remove emoji
result["description"] = re.sub(r"ː.*?ː", "", result["description"])
# remove xml
result["description"] = re.sub(r"<.*?>", "", result["description"])
# background
background_url = re.search(r"background-image: url\( \'(.*?)\' \)", html)
if background_url:
background_url = background_url.group(1)
result["background"] = await _fetch(
background_url, default_background, proxy=proxy
)
# avatar
# \t<link rel="image_src" href="https://avatars.akamai.steamstatic.com/3ade30f61c3d2cc0b8c80aaf567b573cd022c405_full.jpg">
avatar_url = re.search(r'<link rel="image_src" href="(.*?)"', html)
if avatar_url:
avatar_url = avatar_url.group(1)
# https://avatars.akamai.steamstatic.com/3ade30f61c3d2cc0b8c80aaf567b573cd022c405_full.jpg
avatar_url_split = avatar_url.split("/")
avatar_file = cache_path / f"avatar_{avatar_url_split[-1].split('_')[0]}.jpg"
result["avatar"] = await _fetch(
avatar_url, default_avatar, cache_file=avatar_file, proxy=proxy
)
# recent 2 week play time
# \t<div class="recentgame_quicklinks recentgame_recentplaytime">\r\n\t\t\t\t\t\t\t\t\t<div>15.5 小时(过去 2 周)</div>
play_time_text = re.search(
r'<div class="recentgame_quicklinks recentgame_recentplaytime">\s*<div>(.*?)</div>',
html,
)
if play_time_text:
play_time_text = play_time_text.group(1)
result["recent_2_week_play_time"] = play_time_text
# game data
soup = BeautifulSoup(html, "html.parser")
game_data = []
recent_games = soup.find_all("div", class_="recent_game")
for game in recent_games:
game_info = {}
game_info["game_name"] = game.find("div", class_="game_name").text.strip()
game_info["game_image_url"] = game.find("img", class_="game_capsule")["src"]
game_info_split = game_info["game_image_url"].split("/")
# https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1144400/capsule_184x69_schinese.jpg?t=1724440433
game_info["game_image"] = await _fetch(
game_info["game_image_url"],
default_header_image,
cache_file=cache_path / f"header_{game_info_split[-2]}.jpg",
proxy=proxy,
)
play_time_text = game.find("div", class_="game_info_details").text.strip()
play_time = re.search(r"总时数\s*(.*?)\s*小时", play_time_text)
if play_time is None:
game_info["play_time"] = ""
else:
game_info["play_time"] = play_time.group(1)
last_played = re.search(r"最后运行日期:(.*) 日", play_time_text)
if last_played is not None:
game_info["last_played"] = "最后运行日期:" + last_played.group(1) + ""
else:
game_info["last_played"] = "当前正在游戏"
achievements = []
achievement_elements = game.find_all("div", class_="game_info_achievement")
for achievement in achievement_elements:
if "plus_more" in achievement["class"]:
continue
achievement_info = {}
achievement_info["name"] = achievement["data-tooltip-text"]
achievement_info["image_url"] = achievement.find("img")["src"]
achievement_info_split = achievement_info["image_url"].split("/")
achievement_info["image"] = await _fetch(
achievement_info["image_url"],
default_achievement_image,
cache_file=cache_path
/ f"achievement_{achievement_info_split[-2]}_{achievement_info_split[-1]}",
proxy=proxy,
)
achievements.append(achievement_info)
game_info["achievements"] = achievements
game_info_achievement_summary = game.find(
"span", class_="game_info_achievement_summary"
)
if game_info_achievement_summary is None:
game_data.append(game_info)
continue
remain_achievement_text = game_info_achievement_summary.find(
"span", class_="ellipsis"
).text
game_info["completed_achievement_number"] = int(
remain_achievement_text.split("/")[0].strip()
)
game_info["total_achievement_number"] = int(
remain_achievement_text.split("/")[1].strip()
)
game_data.append(game_info)
result["game_data"] = game_data
return result
async def get_play_time(steam_id):
# 配置
API_KEY = "53A64539C4E11B2480AC47F2D8251728"
STEAM_ID = steam_id
API_URL = f"https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?key={API_KEY}&steamid={STEAM_ID}&include_appinfo=true"
try:
# 发送API请求
response = requests.get(API_URL)
if response.status_code != 200:
print(f"API请求失败,状态码:{response.status_code}")
exit()
data = response.json()
games = data.get("response", {}).get("games", [])
if not games:
print("没有找到游戏数据")
exit()
# 处理游戏数据
game_list = []
for game in games:
name = game.get("name", "Unknown")
playtime = int(game.get("playtime_forever", 0))
game_list.append({
"name": name,
"playtime_hours": round(playtime / 60, 2)
})
# 按游玩时长排序
game_list_sorted = sorted(game_list, key=lambda x: x["playtime_hours"], reverse=True)
# 添加序号
for index, game in enumerate(game_list_sorted, start=1):
game["rank"] = index
# 写入CSV文件
fieldnames = ["编号", "名称", "游玩时长(H)"]
with open(f"{STEAM_ID}_steam_games.csv", "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(game_list_sorted)
print(f"文件已生成:{STEAM_ID}_steam_games.csv")
except Exception as e:
print(f"程序运行时出错:{e}")
if __name__ == "__main__":
from nonebot.log import logger
import asyncio
data = asyncio.run(get_user_data(76561199135038179, None))
with open("bg.jpg", "wb") as f:
f.write(data["background"])
logger.info(data["description"])
@@ -0,0 +1,112 @@
import time
import pytz
import httpx
import datetime
import calendar
from PIL import Image
from io import BytesIO
from pathlib import Path
from typing import Dict, Optional
from .models import Player
from .data_source import BindData
async def _fetch_avatar(avatar_url: str, proxy: str = None) -> Image.Image:
async with httpx.AsyncClient(proxy=proxy) as client:
response = await client.get(avatar_url)
if response.status_code != 200:
return Image.open(Path(__file__).parent / "res/unknown_avatar.jpg")
return Image.open(BytesIO(response.content))
async def fetch_avatar(
player: Player, avatar_dir: Optional[Path], proxy: str = None
) -> Image.Image:
if avatar_dir is not None:
avatar_path = (
avatar_dir / f"avatar_{player['steamid']}_{player['avatarhash']}.png"
)
if avatar_path.exists():
avatar = Image.open(avatar_path)
else:
avatar = await _fetch_avatar(player["avatarfull"], proxy)
avatar.save(avatar_path)
else:
avatar = await _fetch_avatar(player["avatarfull"], proxy)
return avatar
def convert_player_name_to_nickname(
data: Dict[str, str], parent_id: str, bind_data: BindData
) -> Dict[str, str]:
data["nickname"] = bind_data.get_by_steam_id(parent_id, data["steamid"])["nickname"]
return data
async def simplize_steam_player_data(
player: Player, proxy: str = None, avatar_dir: Path = None
) -> Dict[str, str]:
avatar = await fetch_avatar(player, avatar_dir, proxy)
if player["personastate"] == 0:
if not player.get("lastlogoff"):
status = "离线"
else:
time_logged_off = player["lastlogoff"] # Unix timestamp
time_to_now = calendar.timegm(time.gmtime()) - time_logged_off
# 将时间转换为自然语言
if time_to_now < 60:
status = "上次在线 刚刚"
elif time_to_now < 3600:
status = f"上次在线 {time_to_now // 60} 分钟前"
elif time_to_now < 86400:
status = f"上次在线 {time_to_now // 3600} 小时前"
elif time_to_now < 2592000:
status = f"上次在线 {time_to_now // 86400} 天前"
elif time_to_now < 31536000:
status = f"上次在线 {time_to_now // 2592000} 个月前"
else:
status = f"上次在线 {time_to_now // 31536000} 年前"
elif player["personastate"] in [1, 2, 4]:
status = (
"在线" if player.get("gameextrainfo") is None else player["gameextrainfo"]
)
elif player["personastate"] == 3:
status = (
"离开" if player.get("gameextrainfo") is None else player["gameextrainfo"]
)
elif player["personastate"] in [5, 6]:
status = "在线"
else:
status = "未知"
return {
"steamid": player["steamid"],
"avatar": avatar,
"name": player["personaname"],
"status": status,
"personastate": player["personastate"],
}
def image_to_bytes(image: Image.Image) -> bytes:
with BytesIO() as bio:
image.save(bio, format="PNG")
return bio.getvalue()
def hex_to_rgb(hex_color: str):
return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
def convert_timestamp_to_beijing_time(timestamp: int) -> str:
beijing_timezone = pytz.timezone("Asia/Shanghai")
date_utc = datetime.datetime.fromtimestamp(timestamp, pytz.utc)
date_beijing = date_utc.astimezone(beijing_timezone)
return date_beijing.strftime("%Y-%m-%d %H:%M:%S")
# example: 2021-09-06 21:00:00