feat: Add image generation and Cloudflare R2 upload support for match reports
This commit is contained in:
parent
f24106bef1
commit
e2c6c9ea5b
@ -7,6 +7,7 @@ attrs==23.1.0
|
|||||||
backcall==0.2.0
|
backcall==0.2.0
|
||||||
beautifulsoup4==4.12.2
|
beautifulsoup4==4.12.2
|
||||||
blurhash==1.1.4
|
blurhash==1.1.4
|
||||||
|
boto3==1.34.143
|
||||||
bs4==0.0.1
|
bs4==0.0.1
|
||||||
certifi==2022.12.7
|
certifi==2022.12.7
|
||||||
charset-normalizer==3.0.1
|
charset-normalizer==3.0.1
|
||||||
|
|||||||
@ -33,7 +33,8 @@ channel_id = 1152167937852055552
|
|||||||
@tasks.loop(minutes=1)
|
@tasks.loop(minutes=1)
|
||||||
async def send_message(channel):
|
async def send_message(channel):
|
||||||
if utils.is_game_time():
|
if utils.is_game_time():
|
||||||
send_message.change_interval(minutes=3)
|
# send_message.change_interval(minutes=3)
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
send_message.change_interval(minutes=15)
|
send_message.change_interval(minutes=15)
|
||||||
|
|
||||||
|
|||||||
56
dota.py
56
dota.py
@ -2,15 +2,16 @@ import peewee
|
|||||||
import opendota
|
import opendota
|
||||||
import datetime
|
import datetime
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
import json
|
||||||
import players
|
import players
|
||||||
import utils
|
import utils
|
||||||
|
from image_generator import ImageGenerator
|
||||||
|
|
||||||
db = peewee.SqliteDatabase('dota.db')
|
db = peewee.SqliteDatabase('dota.db')
|
||||||
hero_client = opendota.HeroesApi()
|
hero_client = opendota.HeroesApi()
|
||||||
player_client = opendota.PlayersApi()
|
player_client = opendota.PlayersApi()
|
||||||
match_client = opendota.MatchesApi()
|
match_client = opendota.MatchesApi()
|
||||||
|
image_generator = ImageGenerator()
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(peewee.Model):
|
class BaseModel(peewee.Model):
|
||||||
@ -48,27 +49,38 @@ class Match(BaseModel):
|
|||||||
duration = peewee.IntegerField()
|
duration = peewee.IntegerField()
|
||||||
radiant_win = peewee.BooleanField()
|
radiant_win = peewee.BooleanField()
|
||||||
party_size = peewee.IntegerField(null=True)
|
party_size = peewee.IntegerField(null=True)
|
||||||
|
opendota_response = peewee.TextField(null=True)
|
||||||
|
|
||||||
def serialize_match(self):
|
def serialize_match(self):
|
||||||
try:
|
if not self.opendota_response:
|
||||||
match_ = match_client.get_matches_by_match_id(self.match_id)
|
try:
|
||||||
except Exception as e:
|
match_ = match_client.get_matches_by_match_id(self.match_id)
|
||||||
logger.error('fail to get match %s' % self.match_id)
|
m_dict = match_.to_dict()
|
||||||
raise e
|
for player in m_dict['players']:
|
||||||
|
if player['last_login']:
|
||||||
|
# datatime obj to timestamp
|
||||||
|
player['last_login'] = int(player['last_login'].timestamp())
|
||||||
|
self.opendota_response = json.dumps(m_dict)
|
||||||
|
self.save()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('fail to get match %s' % self.match_id)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
md = json.loads(self.opendota_response)
|
||||||
match_data = {
|
match_data = {
|
||||||
'players': [players.serialize_player(player) for player in match_.players],
|
'players': [players.serialize_player(player) for player in md['players']],
|
||||||
'dire_score': match_.dire_score,
|
'dire_score': md['dire_score'],
|
||||||
'radiant_score': match_.radiant_score,
|
'radiant_score': md['radiant_score'],
|
||||||
# isoformat utc+8
|
# isoformat utc+8
|
||||||
'start_time': datetime.datetime.fromtimestamp(match_.start_time).strftime('%Y-%m-%dT%H:%M:%S.000+08:00'),
|
'start_time': datetime.datetime.fromtimestamp(md['start_time']).strftime('%Y-%m-%dT%H:%M:%S.000+08:00'),
|
||||||
'end_time': datetime.datetime.fromtimestamp(match_.start_time + match_.duration).strftime('%Y-%m-%dT%H:%M:%S.000+08:00'),
|
'end_time': datetime.datetime.fromtimestamp(md['start_time'] + md['duration']).strftime('%Y-%m-%dT%H:%M:%S.000+08:00'),
|
||||||
'duration': '%d:%02d:%02d' % utils.convert_seconds_to_hms(match_.duration),
|
'duration': '%d:%02d:%02d' % utils.convert_seconds_to_hms(md['duration']),
|
||||||
'radiant_win': match_.radiant_win,
|
'radiant_win': md['radiant_win'],
|
||||||
'party_size': self.party_size,
|
'party_size': self.party_size,
|
||||||
'match_id': self.match_id,
|
'match_id': self.match_id,
|
||||||
}
|
}
|
||||||
if not self.party_size:
|
if not self.party_size:
|
||||||
player_account_ids = [player.account_id for player in match_.players if player.account_id]
|
player_account_ids = [player['account_id'] for player in md['players'] if player['account_id']]
|
||||||
match_data['party_size'] = Friend.select().where(Friend.steam_id.in_(player_account_ids)).count()
|
match_data['party_size'] = Friend.select().where(Friend.steam_id.in_(player_account_ids)).count()
|
||||||
return match_data
|
return match_data
|
||||||
|
|
||||||
@ -289,6 +301,13 @@ def serialize_match_for_discord(match_):
|
|||||||
else:
|
else:
|
||||||
dire_indicator = ' 🌟'
|
dire_indicator = ' 🌟'
|
||||||
|
|
||||||
|
# 生成比赛报告图片
|
||||||
|
try:
|
||||||
|
image_url = image_generator.generate_match_report(match_)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"生成比赛报告图片失败: {str(e)}")
|
||||||
|
image_url = None
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"content": content,
|
"content": content,
|
||||||
"tts": False,
|
"tts": False,
|
||||||
@ -319,4 +338,11 @@ def serialize_match_for_discord(match_):
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 如果成功生成了图片,添加到embeds中
|
||||||
|
if image_url:
|
||||||
|
data["embeds"][0]["image"] = {
|
||||||
|
"url": image_url
|
||||||
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|||||||
10
env.ini
Normal file
10
env.ini
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[cloudflare]
|
||||||
|
account_id = your_account_id
|
||||||
|
access_key_id = your_access_key_id
|
||||||
|
secret_access_key = your_secret_access_key
|
||||||
|
bucket_name = dotabot-images
|
||||||
|
region = auto
|
||||||
|
custom_domain =
|
||||||
|
|
||||||
|
[bot]
|
||||||
|
# 其他机器人配置可以放在这里
|
||||||
5026
heroes.json
Normal file
5026
heroes.json
Normal file
File diff suppressed because it is too large
Load Diff
135
image_generator.py
Normal file
135
image_generator.py
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
import utils
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class ImageGenerator:
|
||||||
|
def __init__(self):
|
||||||
|
self.env = Environment(loader=FileSystemLoader('templates'))
|
||||||
|
# 加载英雄数据
|
||||||
|
with open('heroes.json', 'r', encoding='utf-8') as f:
|
||||||
|
self.heroes_data = json.load(f)
|
||||||
|
|
||||||
|
def get_hero_image_url(self, hero_id):
|
||||||
|
"""根据英雄id获取图片URL"""
|
||||||
|
hero_info = self.heroes_data.get(str(hero_id))
|
||||||
|
if hero_info:
|
||||||
|
return f"https://cdn.cloudflare.steamstatic.com{hero_info['img']}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def generate_match_report(self, match_data):
|
||||||
|
"""
|
||||||
|
根据match_data生成比赛报告图片
|
||||||
|
match_data应该是dota.py中serialize_match方法返回的格式
|
||||||
|
"""
|
||||||
|
# 处理数据,标记最高经济和最高伤害的玩家
|
||||||
|
radiant_players = []
|
||||||
|
dire_players = []
|
||||||
|
|
||||||
|
# 分离天辉和夜魇玩家
|
||||||
|
for player in match_data['players']:
|
||||||
|
player_data = {
|
||||||
|
'name': player['personaname'] if player['personaname'] else '-',
|
||||||
|
'is_friend': bool(player.get('nickname', '')),
|
||||||
|
'hero': player['hero'],
|
||||||
|
'level': player['level'],
|
||||||
|
'kills': player['kills'],
|
||||||
|
'deaths': player['deaths'],
|
||||||
|
'assists': player['assists'],
|
||||||
|
'total_gold': player['total_gold'],
|
||||||
|
'hero_damage': player['hero_damage'],
|
||||||
|
'hero_img': self.get_hero_image_url(player['hero_id']),
|
||||||
|
'highest_gold': False,
|
||||||
|
'highest_damage': False,
|
||||||
|
'rank': player.get('rank'),
|
||||||
|
'rank_tier': player.get('rank_tier'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 如果是好友,使用昵称
|
||||||
|
if player.get('nickname'):
|
||||||
|
player_data['name'] = player['nickname']
|
||||||
|
|
||||||
|
# 转换英雄名称为中文
|
||||||
|
player_data['hero'] = utils.get_hero_chinese_name(player['hero'])
|
||||||
|
|
||||||
|
if player['is_radiant']:
|
||||||
|
radiant_players.append(player_data)
|
||||||
|
else:
|
||||||
|
dire_players.append(player_data)
|
||||||
|
|
||||||
|
# 找出天辉最高经济和伤害
|
||||||
|
if radiant_players:
|
||||||
|
max_gold_player = max(radiant_players, key=lambda p: int(p['total_gold']))
|
||||||
|
max_damage_player = max(radiant_players, key=lambda p: int(p['hero_damage']))
|
||||||
|
|
||||||
|
for player in radiant_players:
|
||||||
|
if player == max_gold_player:
|
||||||
|
player['highest_gold'] = True
|
||||||
|
if player == max_damage_player:
|
||||||
|
player['highest_damage'] = True
|
||||||
|
|
||||||
|
# 找出夜魇最高经济和伤害
|
||||||
|
if dire_players:
|
||||||
|
max_gold_player = max(dire_players, key=lambda p: int(p['total_gold']))
|
||||||
|
max_damage_player = max(dire_players, key=lambda p: int(p['hero_damage']))
|
||||||
|
|
||||||
|
for player in dire_players:
|
||||||
|
if player == max_gold_player:
|
||||||
|
player['highest_gold'] = True
|
||||||
|
if player == max_damage_player:
|
||||||
|
player['highest_damage'] = True
|
||||||
|
|
||||||
|
|
||||||
|
# 短数字
|
||||||
|
for player in radiant_players:
|
||||||
|
player['total_gold'] = utils.shorten_digits(player['total_gold'])
|
||||||
|
player['hero_damage'] = utils.shorten_digits(player['hero_damage'])
|
||||||
|
for player in dire_players:
|
||||||
|
player['total_gold'] = utils.shorten_digits(player['total_gold'])
|
||||||
|
player['hero_damage'] = utils.shorten_digits(player['hero_damage'])
|
||||||
|
|
||||||
|
end_time = match_data.get('end_time', '')
|
||||||
|
# '2025-03-05T01:04:30.000+08:00' get 01:04:30
|
||||||
|
end_time = end_time.split('T')[1].split('.')[0]
|
||||||
|
|
||||||
|
# 准备模板数据
|
||||||
|
template_data = {
|
||||||
|
'radiant_players': radiant_players,
|
||||||
|
'dire_players': dire_players,
|
||||||
|
'radiant_score': match_data.get('radiant_score', 0),
|
||||||
|
'dire_score': match_data.get('dire_score', 0),
|
||||||
|
'duration': match_data.get('duration', 0),
|
||||||
|
'radiant_win': match_data.get('radiant_win', False),
|
||||||
|
'start_time': match_data.get('start_time', ''),
|
||||||
|
'end_time': end_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 渲染模板
|
||||||
|
template = self.env.get_template('match_report.html')
|
||||||
|
html_content = template.render(**template_data)
|
||||||
|
|
||||||
|
# 使用Playwright生成图片
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch()
|
||||||
|
page = browser.new_page()
|
||||||
|
page.set_content(html_content)
|
||||||
|
page.set_viewport_size({"width": 800, "height": 800})
|
||||||
|
|
||||||
|
# 等待内容完全加载
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# 调整截图高度以适应内容
|
||||||
|
body_height = page.evaluate('document.body.scrollHeight')
|
||||||
|
body_width = page.evaluate('document.body.offsetWidth')
|
||||||
|
page.set_viewport_size({"width": body_width, "height": body_height})
|
||||||
|
|
||||||
|
# 截图
|
||||||
|
image_path = "match_report_%s.png" % match_data.get('match_id')
|
||||||
|
page.screenshot(path=image_path, full_page=True)
|
||||||
|
browser.close()
|
||||||
|
image_url = utils.upload_image(image_path, image_path)
|
||||||
|
os.remove(image_path)
|
||||||
|
return image_url
|
||||||
|
return None
|
||||||
32
players.py
32
players.py
@ -1,21 +1,25 @@
|
|||||||
import dota
|
import dota
|
||||||
|
import utils
|
||||||
|
|
||||||
def serialize_player(player):
|
def serialize_player(player):
|
||||||
friend = dota.Friend.get_or_none(steam_id=player.account_id)
|
friend = dota.Friend.get_or_none(steam_id=player['account_id'])
|
||||||
player_data = {
|
player_data = {
|
||||||
'personaname': player.personaname if player.personaname else '',
|
'personaname': player['personaname'] if player['personaname'] else '',
|
||||||
'nickname': friend.name if friend else '',
|
'nickname': friend.name if friend else '',
|
||||||
'kills': player.kills,
|
'kills': player['kills'],
|
||||||
'deaths': player.deaths,
|
'deaths': player['deaths'],
|
||||||
'assists': player.assists,
|
'assists': player['assists'],
|
||||||
'total_gold': player.total_gold,
|
'total_gold': player['total_gold'],
|
||||||
'last_hits': player.last_hits,
|
'last_hits': player['last_hits'],
|
||||||
'denies': player.denies,
|
'denies': player['denies'],
|
||||||
'hero_damage': player.hero_damage,
|
'hero_damage': player['hero_damage'],
|
||||||
'party_id': player.party_id,
|
'party_id': player['party_id'],
|
||||||
'win': player.win,
|
'win': player['win'],
|
||||||
'level': player.level,
|
'level': player['level'],
|
||||||
'is_radiant': player.is_radiant,
|
'is_radiant': player['is_radiant'],
|
||||||
'hero': dota.Hero.get(hero_id=player.hero_id).localized_name,
|
'hero': dota.Hero.get(hero_id=player['hero_id']).localized_name,
|
||||||
|
'hero_id': player['hero_id'],
|
||||||
|
'rank_tier': player['rank_tier'],
|
||||||
|
'rank': utils.get_ranking(player['rank_tier']),
|
||||||
}
|
}
|
||||||
return player_data
|
return player_data
|
||||||
|
|||||||
339
templates/match_report.html
Normal file
339
templates/match_report.html
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: "Segoe UI", Arial, sans-serif;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 15px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: fit-content; /* 让body宽度适应内容 */
|
||||||
|
max-width: 500px; /* 设置最大宽度 */
|
||||||
|
}
|
||||||
|
.match-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
width: calc(100% - 24px); /* 确保宽度计算正确 */
|
||||||
|
}
|
||||||
|
.match-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.match-result {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1em;
|
||||||
|
text-align: center;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.teams {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%; /* 确保宽度为100% */
|
||||||
|
}
|
||||||
|
.team {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 10px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
width: calc(100% - 20px); /* 确保宽度计算正确 */
|
||||||
|
}
|
||||||
|
/* 胜利/失败指示条 - 统一颜色 */
|
||||||
|
.team::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 5px;
|
||||||
|
}
|
||||||
|
.team.win::before {
|
||||||
|
background: linear-gradient(to right, #4caf50, #8bc34a);
|
||||||
|
}
|
||||||
|
.team.lose::before {
|
||||||
|
background: linear-gradient(to right, #f44336, #ff9800);
|
||||||
|
}
|
||||||
|
.team-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-bottom: 1px solid #3a3a3a;
|
||||||
|
}
|
||||||
|
.team-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
.player-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px;
|
||||||
|
margin: 4px 0;
|
||||||
|
background: #3a3a3a;
|
||||||
|
border-radius: 3px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.player-container {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.hero-img {
|
||||||
|
width: 45px;
|
||||||
|
height: 25px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.player-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.player-name {
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 120px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.player-rank {
|
||||||
|
font-size: 0.6em;
|
||||||
|
color: #aaaaaa;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.friend {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
.player-stats {
|
||||||
|
display: flex;
|
||||||
|
width: 180px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.stat-header {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
padding: 0 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.stat-header-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 53px;
|
||||||
|
}
|
||||||
|
.stat-header-values {
|
||||||
|
display: flex;
|
||||||
|
width: 180px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.stat-header-cell {
|
||||||
|
width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.stat-cell {
|
||||||
|
width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.highest-gold {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.highest-gold::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 1px;
|
||||||
|
right: 1px;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
rgba(255, 215, 0, 0.2),
|
||||||
|
rgba(255, 215, 0, 0.4)
|
||||||
|
);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.highest-damage {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.highest-damage::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 1px;
|
||||||
|
right: 1px;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
rgba(255, 0, 0, 0.2),
|
||||||
|
rgba(255, 0, 0, 0.4)
|
||||||
|
);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.radiant-win {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
.dire-win {
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="match-header">
|
||||||
|
<div class="match-info">
|
||||||
|
<div>比赛时长: {{ duration }}</div>
|
||||||
|
<div>结束时间: {{ end_time }}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="match-result {% if radiant_win %}radiant-win{% else %}dire-win{% endif %}"
|
||||||
|
>
|
||||||
|
{% if radiant_win %}天辉胜利{% else %}夜魇胜利{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="match-info">
|
||||||
|
<div>天辉: {{ radiant_score }}</div>
|
||||||
|
<div>夜魇: {{ dire_score }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="teams">
|
||||||
|
<div
|
||||||
|
class="team radiant {% if radiant_win %}win{% else %}lose{% endif %}"
|
||||||
|
>
|
||||||
|
<div class="team-header">
|
||||||
|
<h2>天辉</h2>
|
||||||
|
</div>
|
||||||
|
<div class="stat-header">
|
||||||
|
<div class="stat-header-name">玩家</div>
|
||||||
|
<div class="stat-header-values">
|
||||||
|
<div class="stat-header-cell">KDA</div>
|
||||||
|
<div class="stat-header-cell">经济</div>
|
||||||
|
<div class="stat-header-cell">伤害</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for player in radiant_players %}
|
||||||
|
<div class="player-row">
|
||||||
|
<div class="player-container">
|
||||||
|
<img
|
||||||
|
class="hero-img"
|
||||||
|
src="{{ player.hero_img }}"
|
||||||
|
alt="{{ player.hero }}"
|
||||||
|
/>
|
||||||
|
<div class="player-info">
|
||||||
|
<div
|
||||||
|
class="player-name {% if player.is_friend %}friend{% endif %}"
|
||||||
|
>
|
||||||
|
{{ player.name }}
|
||||||
|
</div>
|
||||||
|
{% if player.rank_tier %}
|
||||||
|
<div class="player-rank">{{ player.rank }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="player-stats">
|
||||||
|
<div class="stat-cell">
|
||||||
|
<span class="stat-value"
|
||||||
|
>{{ player.kills }}/{{ player.deaths }}/{{ player.assists
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="stat-cell {% if player.highest_gold %}highest-gold{% endif %}"
|
||||||
|
>
|
||||||
|
<span class="stat-value">{{ player.total_gold }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="stat-cell {% if player.highest_damage %}highest-damage{% endif %}"
|
||||||
|
>
|
||||||
|
<span class="stat-value">{{ player.hero_damage }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="team dire {% if not radiant_win %}win{% else %}lose{% endif %}"
|
||||||
|
>
|
||||||
|
<div class="team-header">
|
||||||
|
<h2>夜魇</h2>
|
||||||
|
</div>
|
||||||
|
<div class="stat-header">
|
||||||
|
<div class="stat-header-name">玩家</div>
|
||||||
|
<div class="stat-header-values">
|
||||||
|
<div class="stat-header-cell">KDA</div>
|
||||||
|
<div class="stat-header-cell">经济</div>
|
||||||
|
<div class="stat-header-cell">伤害</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for player in dire_players %}
|
||||||
|
<div class="player-row">
|
||||||
|
<div class="player-container">
|
||||||
|
<img
|
||||||
|
class="hero-img"
|
||||||
|
src="{{ player.hero_img }}"
|
||||||
|
alt="{{ player.hero }}"
|
||||||
|
/>
|
||||||
|
<div class="player-info">
|
||||||
|
<div
|
||||||
|
class="player-name {% if player.is_friend %}friend{% endif %}"
|
||||||
|
>
|
||||||
|
{{ player.name }}
|
||||||
|
</div>
|
||||||
|
{% if player.rank_tier %}
|
||||||
|
<div class="player-rank">{{ player.rank }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="player-stats">
|
||||||
|
<div class="stat-cell">
|
||||||
|
<span class="stat-value"
|
||||||
|
>{{ player.kills }}/{{ player.deaths }}/{{ player.assists
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="stat-cell {% if player.highest_gold %}highest-gold{% endif %}"
|
||||||
|
>
|
||||||
|
<span class="stat-value">{{ player.total_gold }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="stat-cell {% if player.highest_damage %}highest-damage{% endif %}"
|
||||||
|
>
|
||||||
|
<span class="stat-value">{{ player.hero_damage }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
106
utils.py
106
utils.py
@ -2,6 +2,12 @@ import datetime
|
|||||||
import requests
|
import requests
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
import os
|
||||||
|
from botocore.config import Config
|
||||||
|
import uuid
|
||||||
|
import configparser
|
||||||
|
|
||||||
# logger_file = '/root/develop/log/dotabot.log'
|
# logger_file = '/root/develop/log/dotabot.log'
|
||||||
logger_file = 'dotabot.log'
|
logger_file = 'dotabot.log'
|
||||||
logger.add(logger_file)
|
logger.add(logger_file)
|
||||||
@ -12,14 +18,15 @@ def convert_seconds_to_hms(total_seconds):
|
|||||||
return hours, minutes, seconds
|
return hours, minutes, seconds
|
||||||
|
|
||||||
def is_workday():
|
def is_workday():
|
||||||
return datetime.datetime.today().weekday() < 5
|
return datetime.datetime.today().weekday() < 4
|
||||||
|
|
||||||
def is_game_time():
|
def is_game_time():
|
||||||
# game time is workday 21:00 - 1:00, weekend 10:00 - 1:00
|
# game time is workday 21:00 - 1:00, weekend 10:00 - 1:00
|
||||||
if is_workday():
|
if is_workday():
|
||||||
return datetime.datetime.now().hour >= 21 or datetime.datetime.now().hour < 1
|
# return datetime.datetime.now().hour >= 21 or datetime.datetime.now().hour < 1
|
||||||
|
return False
|
||||||
else:
|
else:
|
||||||
return datetime.datetime.now().hour >= 10 or datetime.datetime.now().hour < 1
|
return datetime.datetime.now().hour >= 12 or datetime.datetime.now().hour < 1
|
||||||
|
|
||||||
|
|
||||||
def heartbeat():
|
def heartbeat():
|
||||||
@ -204,3 +211,96 @@ def get_hero_chinese_name(english_name):
|
|||||||
# 将英文名转换为小写并去除空格,用作字典键
|
# 将英文名转换为小写并去除空格,用作字典键
|
||||||
key = english_name.lower().replace(' ', '_')
|
key = english_name.lower().replace(' ', '_')
|
||||||
return hero_name_map.get(key, english_name)
|
return hero_name_map.get(key, english_name)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_image(image_path, file_name=None):
|
||||||
|
"""
|
||||||
|
将图片上传到 Cloudflare R2 存储桶并返回访问URL
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path (str): 本地图片文件路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 上传成功后的图片URL,失败则返回None
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 读取配置文件
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'env.ini')
|
||||||
|
if not os.path.exists(config_path):
|
||||||
|
logger.error(f"配置文件不存在: {config_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
config.read(config_path)
|
||||||
|
|
||||||
|
# 获取Cloudflare R2配置
|
||||||
|
if 'cloudflare' not in config:
|
||||||
|
logger.error("配置文件中缺少cloudflare部分")
|
||||||
|
return None
|
||||||
|
|
||||||
|
cf_config = config['cloudflare']
|
||||||
|
account_id = cf_config.get('account_id')
|
||||||
|
access_key_id = cf_config.get('access_key_id')
|
||||||
|
secret_access_key = cf_config.get('secret_access_key')
|
||||||
|
bucket_name = cf_config.get('bucket_name', 'dotabot-images')
|
||||||
|
region = cf_config.get('region', 'auto')
|
||||||
|
custom_domain = cf_config.get('custom_domain', '')
|
||||||
|
|
||||||
|
# 检查必要的配置
|
||||||
|
if not all([account_id, access_key_id, secret_access_key]):
|
||||||
|
logger.error("缺少Cloudflare R2必要配置")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 创建S3客户端连接到Cloudflare R2
|
||||||
|
s3_client = boto3.client(
|
||||||
|
's3',
|
||||||
|
endpoint_url=f'https://{account_id}.r2.cloudflarestorage.com',
|
||||||
|
aws_access_key_id=access_key_id,
|
||||||
|
aws_secret_access_key=secret_access_key,
|
||||||
|
region_name=region,
|
||||||
|
config=Config(
|
||||||
|
signature_version='s3v4',
|
||||||
|
retries={
|
||||||
|
'max_attempts': 3,
|
||||||
|
'mode': 'standard'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 生成唯一的文件名
|
||||||
|
if not file_name:
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||||
|
random_id = str(uuid.uuid4())[:8]
|
||||||
|
file_extension = os.path.splitext(image_path)[1]
|
||||||
|
object_key = f'dotabot/{timestamp}_{random_id}{file_extension}'
|
||||||
|
else:
|
||||||
|
object_key = f'dotabot/{file_name}'
|
||||||
|
|
||||||
|
# 设置文件的Content-Type
|
||||||
|
content_type = 'image/png' if file_extension.lower() == '.png' else 'image/jpeg'
|
||||||
|
|
||||||
|
# 上传文件
|
||||||
|
with open(image_path, 'rb') as file_data:
|
||||||
|
s3_client.upload_fileobj(
|
||||||
|
file_data,
|
||||||
|
bucket_name,
|
||||||
|
object_key,
|
||||||
|
ExtraArgs={
|
||||||
|
'ContentType': content_type,
|
||||||
|
'CacheControl': 'max-age=2592000' # 30天缓存
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建公共访问URL
|
||||||
|
if custom_domain:
|
||||||
|
image_url = f'https://{custom_domain}/{object_key}'
|
||||||
|
else:
|
||||||
|
image_url = f'https://{bucket_name}.{account_id}.r2.cloudflarestorage.com/{object_key}'
|
||||||
|
|
||||||
|
logger.info(f"图片上传成功: {image_url}")
|
||||||
|
return image_url
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"上传图片失败: {str(e)}")
|
||||||
|
return None
|
||||||
Loading…
x
Reference in New Issue
Block a user