feat: Add image generation and Cloudflare R2 upload support for match reports
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
e2d1aabe99
commit
269f47fc6c
@ -7,6 +7,7 @@ attrs==23.1.0
|
||||
backcall==0.2.0
|
||||
beautifulsoup4==4.12.2
|
||||
blurhash==1.1.4
|
||||
boto3==1.34.143
|
||||
bs4==0.0.1
|
||||
certifi==2022.12.7
|
||||
charset-normalizer==3.0.1
|
||||
|
||||
@ -33,7 +33,8 @@ channel_id = 1152167937852055552
|
||||
@tasks.loop(minutes=1)
|
||||
async def send_message(channel):
|
||||
if utils.is_game_time():
|
||||
send_message.change_interval(minutes=3)
|
||||
# send_message.change_interval(minutes=3)
|
||||
pass
|
||||
else:
|
||||
send_message.change_interval(minutes=15)
|
||||
|
||||
|
||||
56
dota.py
56
dota.py
@ -2,15 +2,16 @@ import peewee
|
||||
import opendota
|
||||
import datetime
|
||||
from loguru import logger
|
||||
|
||||
import json
|
||||
import players
|
||||
import utils
|
||||
|
||||
from image_generator import ImageGenerator
|
||||
|
||||
db = peewee.SqliteDatabase('dota.db')
|
||||
hero_client = opendota.HeroesApi()
|
||||
player_client = opendota.PlayersApi()
|
||||
match_client = opendota.MatchesApi()
|
||||
image_generator = ImageGenerator()
|
||||
|
||||
|
||||
class BaseModel(peewee.Model):
|
||||
@ -48,27 +49,38 @@ class Match(BaseModel):
|
||||
duration = peewee.IntegerField()
|
||||
radiant_win = peewee.BooleanField()
|
||||
party_size = peewee.IntegerField(null=True)
|
||||
opendota_response = peewee.TextField(null=True)
|
||||
|
||||
def serialize_match(self):
|
||||
try:
|
||||
match_ = match_client.get_matches_by_match_id(self.match_id)
|
||||
except Exception as e:
|
||||
logger.error('fail to get match %s' % self.match_id)
|
||||
raise e
|
||||
if not self.opendota_response:
|
||||
try:
|
||||
match_ = match_client.get_matches_by_match_id(self.match_id)
|
||||
m_dict = match_.to_dict()
|
||||
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 = {
|
||||
'players': [players.serialize_player(player) for player in match_.players],
|
||||
'dire_score': match_.dire_score,
|
||||
'radiant_score': match_.radiant_score,
|
||||
'players': [players.serialize_player(player) for player in md['players']],
|
||||
'dire_score': md['dire_score'],
|
||||
'radiant_score': md['radiant_score'],
|
||||
# isoformat utc+8
|
||||
'start_time': datetime.datetime.fromtimestamp(match_.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'),
|
||||
'duration': '%d:%02d:%02d' % utils.convert_seconds_to_hms(match_.duration),
|
||||
'radiant_win': match_.radiant_win,
|
||||
'start_time': datetime.datetime.fromtimestamp(md['start_time']).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(md['duration']),
|
||||
'radiant_win': md['radiant_win'],
|
||||
'party_size': self.party_size,
|
||||
'match_id': self.match_id,
|
||||
}
|
||||
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()
|
||||
return match_data
|
||||
|
||||
@ -289,6 +301,13 @@ def serialize_match_for_discord(match_):
|
||||
else:
|
||||
dire_indicator = ' 🌟'
|
||||
|
||||
# 生成比赛报告图片
|
||||
try:
|
||||
image_url = image_generator.generate_match_report(match_)
|
||||
except Exception as e:
|
||||
logger.error(f"生成比赛报告图片失败: {str(e)}")
|
||||
image_url = None
|
||||
|
||||
data = {
|
||||
"content": content,
|
||||
"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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 441 KiB |
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
|
||||
BIN
match_report.png
Normal file
BIN
match_report.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
32
players.py
32
players.py
@ -1,21 +1,25 @@
|
||||
import dota
|
||||
import utils
|
||||
|
||||
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 = {
|
||||
'personaname': player.personaname if player.personaname else '',
|
||||
'personaname': player['personaname'] if player['personaname'] else '',
|
||||
'nickname': friend.name if friend else '',
|
||||
'kills': player.kills,
|
||||
'deaths': player.deaths,
|
||||
'assists': player.assists,
|
||||
'total_gold': player.total_gold,
|
||||
'last_hits': player.last_hits,
|
||||
'denies': player.denies,
|
||||
'hero_damage': player.hero_damage,
|
||||
'party_id': player.party_id,
|
||||
'win': player.win,
|
||||
'level': player.level,
|
||||
'is_radiant': player.is_radiant,
|
||||
'hero': dota.Hero.get(hero_id=player.hero_id).localized_name,
|
||||
'kills': player['kills'],
|
||||
'deaths': player['deaths'],
|
||||
'assists': player['assists'],
|
||||
'total_gold': player['total_gold'],
|
||||
'last_hits': player['last_hits'],
|
||||
'denies': player['denies'],
|
||||
'hero_damage': player['hero_damage'],
|
||||
'party_id': player['party_id'],
|
||||
'win': player['win'],
|
||||
'level': player['level'],
|
||||
'is_radiant': player['is_radiant'],
|
||||
'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
|
||||
|
||||
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>
|
||||
108
utils.py
108
utils.py
@ -2,6 +2,12 @@ import datetime
|
||||
import requests
|
||||
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 = 'dotabot.log'
|
||||
logger.add(logger_file)
|
||||
@ -12,14 +18,15 @@ def convert_seconds_to_hms(total_seconds):
|
||||
return hours, minutes, seconds
|
||||
|
||||
def is_workday():
|
||||
return datetime.datetime.today().weekday() < 5
|
||||
return datetime.datetime.today().weekday() < 4
|
||||
|
||||
def is_game_time():
|
||||
# game time is workday 21:00 - 1:00, weekend 10:00 - 1:00
|
||||
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:
|
||||
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():
|
||||
@ -203,4 +210,97 @@ def get_hero_chinese_name(english_name):
|
||||
}
|
||||
# 将英文名转换为小写并去除空格,用作字典键
|
||||
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