From 8708c931c8ff40712d377f7402079b2762e6660d Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Sat, 7 Feb 2026 03:44:09 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=AF=8F=E5=91=A8?= =?UTF-8?q?=E6=80=BB=E7=BB=93=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 get_weekly_stats_for_discord() 获取过去7天统计数据 - 新增 generate_weekly_summary_for_discord() 生成周报 - 新增 generate_weekly_summary_image() 生成周报图片 - 新增 weekly_summary.html 模板 - 添加每周日21:00定时推送 - 添加 /weekly_summary 命令手动触发 --- discord_bot.py | 48 +++++++ dota.py | 126 ++++++++++++++++++ image_generator.py | 186 +++++++++++++++++++++----- templates/weekly_summary.html | 244 ++++++++++++++++++++++++++++++++++ 4 files changed, 570 insertions(+), 34 deletions(-) create mode 100644 templates/weekly_summary.html diff --git a/discord_bot.py b/discord_bot.py index bc45a6c..d7da26a 100644 --- a/discord_bot.py +++ b/discord_bot.py @@ -140,6 +140,22 @@ async def deactivate_friend(ctx, steam_id): else: await ctx.respond(content=f'找不到 {steam_id}') +@bot.command(description='获取本周统计总结', name='weekly_summary') +async def get_weekly_summary(ctx): + """手动触发每周总结""" + await ctx.defer() + logger.info("Manual weekly summary requested") + try: + data = await dota.generate_weekly_summary_for_discord() + if data: + await ctx.respond(content=data['content'], embeds=[discord.Embed.from_dict(embed) for embed in data['embeds']]) + else: + await ctx.respond(content='本周暂无数据') + except Exception as e: + logger.error(f"Error generating weekly summary: {e}") + await ctx.respond(content=f'生成周报失败: {str(e)}') + + @bot.command(description='启用朋友', name='activate_friend') async def activate_friend(ctx, steam_id): logger.info(f'activate_friend {steam_id}') @@ -178,6 +194,35 @@ async def before_check_rank_changes(): seconds_until_target = (target_time - now).total_seconds() await asyncio.sleep(seconds_until_target) + +@tasks.loop(hours=168) # 每7天运行一次 +async def weekly_summary(channel): + """每周总结任务""" + logger.info("Generating weekly summary") + try: + data = await dota.generate_weekly_summary_for_discord() + if data: + logger.info(f"Sending weekly summary") + await channel.send(content=data['content'], embeds=[discord.Embed.from_dict(embed) for embed in data['embeds']]) + except Exception as e: + logger.error(f"Error generating weekly summary: {e}") + sentry_sdk.capture_exception(e) + + +@weekly_summary.before_loop +async def before_weekly_summary(): + # 等待到周日晚上9点再开始第一次运行 + now = datetime.datetime.now() + # 计算下一个周日的21:00 + days_until_sunday = (6 - now.weekday()) % 7 + if days_until_sunday == 0 and now.hour >= 21: + days_until_sunday = 7 + target_time = (now + datetime.timedelta(days=days_until_sunday)).replace(hour=21, minute=0, second=0, microsecond=0) + + seconds_until_target = (target_time - now).total_seconds() + logger.info(f"Weekly summary will start in {seconds_until_target} seconds") + await asyncio.sleep(seconds_until_target) + @bot.event async def on_ready(): logger.info(f"We have logged in as {bot.user}") @@ -188,5 +233,8 @@ async def on_ready(): # 启动天梯检查任务 check_rank_changes.start(channel) + + # 启动每周总结任务 + weekly_summary.start(channel) bot.run('MTE1MjE2NTc3NDMwNDIyMzI2Mg.GEi-17.VvuIkRy_cFD9XF6wtTagY95LKEbTxKaxy-FxGw') # 这里替换成你自己的 token diff --git a/dota.py b/dota.py index 2747d14..1b0d22b 100644 --- a/dota.py +++ b/dota.py @@ -604,3 +604,129 @@ def check_streaks(): streak_updates = [] return notifications + + +def get_weekly_stats_for_discord(): + """ + 获取过去7天的统计数据,返回Discord格式的消息 + """ + import json + + # 获取7天前的日期 + week_ago = datetime.datetime.now() - datetime.timedelta(days=7) + + weekly_data = [] + + for friend in Friend.filter(active=True): + # 获取该玩家最近20场比赛(从中筛选过去7天的) + recent_matches = friend.get_recent_matches(limit=20) + + # 筛选过去7天的比赛 + matches_this_week = [] + for match in recent_matches: + match_time = datetime.datetime.fromtimestamp(match.start_time) + if match_time >= week_ago: + matches_this_week.append(match) + + if not matches_this_week: + continue + + # 统计数据 + wins = 0 + losses = 0 + total_kills = 0 + total_deaths = 0 + total_assists = 0 + hero_stats = {} # {hero_id: {'wins': 0, 'losses': 0, 'games': 0}} + + for match in matches_this_week: + player_won = match.radiant_win == (match.player_slot < 128) + if player_won: + wins += 1 + else: + losses += 1 + + total_kills += match.kills + total_deaths += match.deaths + total_assists += match.assists + + # 英雄统计 + hero_id = match.hero_id + if hero_id not in hero_stats: + hero_stats[hero_id] = {'wins': 0, 'losses': 0, 'games': 0, 'hero_id': hero_id} + hero_stats[hero_id]['games'] += 1 + if player_won: + hero_stats[hero_id]['wins'] += 1 + else: + hero_stats[hero_id]['losses'] += 1 + + total_games = wins + losses + win_rate = (wins / total_games * 100) if total_games > 0 else 0 + kda = (total_kills + total_assists) / total_deaths if total_deaths > 0 else (total_kills + total_assists) + + # 找出最常用英雄和最佳英雄 + most_played = max(hero_stats.values(), key=lambda x: x['games']) if hero_stats else None + best_hero = max( + [h for h in hero_stats.values() if h['games'] >= 2], + key=lambda x: x['wins'] / x['games'] if x['games'] > 0 else 0, + default=None + ) + + weekly_data.append({ + 'name': friend.name, + 'steam_id': friend.steam_id, + 'total_games': total_games, + 'wins': wins, + 'losses': losses, + 'win_rate': win_rate, + 'kda': kda, + 'total_kills': total_kills, + 'total_deaths': total_deaths, + 'total_assists': total_assists, + 'most_played_hero': most_played, + 'best_hero': best_hero, + 'hero_stats': hero_stats + }) + + return weekly_data + + +async def generate_weekly_summary_for_discord(): + """ + 生成每周总结报告,返回Discord格式的消息 + """ + weekly_data = get_weekly_stats_for_discord() + + if not weekly_data: + return None + + # 生成图片 + image_url = await image_generator.generate_weekly_summary_image(weekly_data) + + content = "## 📊 本周 Dota 战报\n\n" + + # 整体统计 + total_games = sum(d['total_games'] for d in weekly_data) + total_wins = sum(d['wins'] for d in weekly_data) + total_losses = sum(d['losses'] for d in weekly_data) + + content += f"本周共打了 **{total_games}** 场比赛,**{total_wins}** 胜 **{total_losses}** 败\n\n" + + # 个人统计 + for data in weekly_data: + win_rate_str = f"{data['win_rate']:.1f}%" + kda_str = f"{data['kda']:.2f}" + content += f"**{data['name']}**: {data['wins']}胜{data['losses']}败 ({win_rate_str}) KDA {kda_str}\n" + + result = { + 'content': content, + 'embeds': [] + } + + if image_url: + result['embeds'].append({ + 'image': {'url': image_url}, + 'color': 3447003 + }) + + return result diff --git a/image_generator.py b/image_generator.py index ebac7a1..cd36899 100644 --- a/image_generator.py +++ b/image_generator.py @@ -13,7 +13,7 @@ class ImageGenerator: # 加载英雄数据 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)) @@ -29,7 +29,7 @@ class ImageGenerator: # 处理数据,标记最高经济和最高伤害的玩家 radiant_players = [] dire_players = [] - + # 分离天辉和夜魇玩家 for player in match_data['players']: player_data = { @@ -48,42 +48,42 @@ class ImageGenerator: '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']) @@ -107,77 +107,77 @@ class ImageGenerator: '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生成图片 async with async_playwright() as playwright: browser = await playwright.chromium.launch() page = await browser.new_page() await page.set_content(html_content) await page.set_viewport_size({"width": 800, "height": 800}) - + # 等待内容完全加载 await page.wait_for_timeout(1000) - + # 调整截图高度以适应内容 body_height = await page.evaluate('document.body.scrollHeight') body_width = await page.evaluate('document.body.offsetWidth') await page.set_viewport_size({"width": body_width, "height": body_height}) - + # 设置更高的设备像素比以获得更清晰的图像 await page.evaluate('''() => { window.devicePixelRatio = 2; }''') - + # 截图 image_path = f"match_report_{match_data.get('match_id')}.png" await page.screenshot(path=image_path, full_page=True) await browser.close() - + # 上传图片 image_url = utils.upload_image(image_path, image_path) - + # 删除本地文件 try: os.remove(image_path) except Exception as e: logger.warning(f"删除本地图片文件失败: {str(e)}") - + return image_url - return None + return None async def generate_recent_matches_image(self, player_name, matches): """ 生成玩家最近比赛的图片报告 - + Args: player_name: 玩家名称 matches: 比赛数据列表 - + Returns: str: 上传后的图片URL """ # 处理比赛数据 processed_matches = [] - + for match in matches: # 获取英雄图片 hero_id = str(match['hero_id']) hero_img = f"https://cdn.dota2.com/apps/dota2/images/heroes/{self.heroes_data[hero_id]['name'].replace('npc_dota_hero_', '')}_full.png" - + # 格式化时间 end_time = datetime.datetime.fromtimestamp(match['end_time']).strftime('%Y-%m-%d %H:%M') - + # 格式化时长 duration_hms = utils.convert_seconds_to_hms(match['duration']) duration_formatted = f"{duration_hms[0]}:{duration_hms[1]:02d}:{duration_hms[2]:02d}" - + # 获取英雄中文名 hero_name = utils.get_hero_chinese_name(self.heroes_data[hero_id]['name']) - + processed_match = { 'win': match['win'], 'kills': match['kills'], @@ -190,15 +190,134 @@ class ImageGenerator: 'party_size': match['party_size'], 'average_rank': match['average_rank'] } - + processed_matches.append(processed_match) - + # 渲染模板 template = self.env.get_template('recent_matches.html') html_content = template.render( player_name=player_name, matches=processed_matches ) + + # 使用Playwright生成图片 + async with async_playwright() as playwright: + browser = await playwright.chromium.launch() + page = await browser.new_page() + await page.set_content(html_content) + await page.set_viewport_size({"width": 800, "height": 800}) + + # 等待内容完全加载 + await page.wait_for_timeout(1000) + + # 调整截图高度以适应内容 + body_height = await page.evaluate('document.body.scrollHeight') + body_width = await page.evaluate('document.body.offsetWidth') + await page.set_viewport_size({"width": body_width, "height": body_height}) + + # 设置更高的设备像素比以获得更清晰的图像 + await page.evaluate('''() => { + window.devicePixelRatio = 2; + }''') + + # 截图 + image_path = f"recent_matches_{player_name.replace(' ', '_')}_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}.png" + await page.screenshot(path=image_path, full_page=True) + await browser.close() + + # 上传图片 + image_url = utils.upload_image(image_path, image_path) + + # 删除本地文件 + try: + os.remove(image_path) + except Exception as e: + logger.warning(f"删除本地图片文件失败: {str(e)}") + + return image_url + + async def generate_weekly_summary_image(self, weekly_data): + """ + 生成每周总结报告图片 + + Args: + weekly_data: dota.py 中 get_weekly_stats_for_discord() 返回的数据列表 + + Returns: + str: 上传后的图片URL + """ + import datetime + + # 计算日期范围 + today = datetime.datetime.now() + week_ago = today - datetime.timedelta(days=7) + date_range = f"{week_ago.strftime('%m/%d')} - {today.strftime('%m/%d')}" + + # 整体统计 + total_games = sum(d['total_games'] for d in weekly_data) + total_wins = sum(d['wins'] for d in weekly_data) + total_losses = sum(d['losses'] for d in weekly_data) + overall_win_rate = round((total_wins / total_games * 100), 1) if total_games > 0 else 0 + + # 处理每个玩家的数据 + players = [] + for data in weekly_data: + player_data = { + 'name': data['name'], + 'wins': data['wins'], + 'losses': data['losses'], + 'win_rate': round(data['win_rate'], 1), + 'kda': round(data['kda'], 2), + 'avg_kills': round(data['total_kills'] / data['total_games'], 1) if data['total_games'] > 0 else 0, + 'avg_deaths': round(data['total_deaths'] / data['total_games'], 1) if data['total_games'] > 0 else 0, + 'avg_assists': round(data['total_assists'] / data['total_games'], 1) if data['total_games'] > 0 else 0, + } + + # 处理最常用英雄 + if data['most_played_hero']: + hero_id = str(data['most_played_hero']['hero_id']) + hero_info = self.heroes_data.get(hero_id, {}) + hero_name = hero_info.get('localized_name', 'Unknown') + hero_img_name = hero_info.get('name', '').replace('npc_dota_hero_', '') + player_data['most_played_hero'] = { + 'name': utils.get_hero_chinese_name(hero_name), + 'img': f"https://cdn.dota2.com/apps/dota2/images/heroes/{hero_img_name}_full.png", + 'wins': data['most_played_hero']['wins'], + 'losses': data['most_played_hero']['losses'], + 'games': data['most_played_hero']['games'] + } + else: + player_data['most_played_hero'] = None + + # 处理最佳英雄 + if data['best_hero']: + hero_id = str(data['best_hero']['hero_id']) + hero_info = self.heroes_data.get(hero_id, {}) + hero_name = hero_info.get('localized_name', 'Unknown') + hero_img_name = hero_info.get('name', '').replace('npc_dota_hero_', '') + win_rate = round(data['best_hero']['wins'] / data['best_hero']['games'] * 100, 1) if data['best_hero']['games'] > 0 else 0 + player_data['best_hero'] = { + 'name': utils.get_hero_chinese_name(hero_name), + 'img': f"https://cdn.dota2.com/apps/dota2/images/heroes/{hero_img_name}_full.png", + 'wins': data['best_hero']['wins'], + 'losses': data['best_hero']['losses'], + 'win_rate': win_rate + } + else: + player_data['best_hero'] = None + + players.append(player_data) + + # 渲染模板 + template = self.env.get_template('weekly_summary.html') + html_content = template.render( + date_range=date_range, + total_games=total_games, + total_wins=total_wins, + total_losses=total_losses, + overall_win_rate=overall_win_rate, + players=players + ) # 使用Playwright生成图片 async with async_playwright() as playwright: @@ -221,7 +340,7 @@ class ImageGenerator: }''') # 截图 - image_path = f"recent_matches_{player_name.replace(' ', '_')}_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}.png" + image_path = f"weekly_summary_{today.strftime('%Y%m%d')}.png" await page.screenshot(path=image_path, full_page=True) await browser.close() @@ -233,6 +352,5 @@ class ImageGenerator: os.remove(image_path) except Exception as e: logger.warning(f"删除本地图片文件失败: {str(e)}") - - return image_url - \ No newline at end of file + + return image_url \ No newline at end of file diff --git a/templates/weekly_summary.html b/templates/weekly_summary.html new file mode 100644 index 0000000..a69a050 --- /dev/null +++ b/templates/weekly_summary.html @@ -0,0 +1,244 @@ + + + + + + + + +
+

📊 本周 Dota 战报

+
{{ date_range }}
+
+ +
+
+
+
{{ total_games }}
+
总局数
+
+
+
{{ total_wins }}
+
胜场
+
+
+
{{ total_losses }}
+
败场
+
+
+
{{ overall_win_rate }}%
+
总胜率
+
+
+
+ + {% for player in players %} +
+
+ {{ player.name }} + {{ player.wins }}胜 {{ player.losses }}败 +
+
+
+ 胜率: + {{ player.win_rate }}% +
+
+ KDA: + {{ player.kda }} +
+
+ 场均: + {{ player.avg_kills }}/{{ player.avg_deaths }}/{{ player.avg_assists + }} +
+
+
+
+
最常用英雄
+ {% if player.most_played_hero %} +
+ {{ player.most_played_hero.name }} +
+
{{ player.most_played_hero.name }}
+
+ {{ player.most_played_hero.wins }}胜 {{ + player.most_played_hero.losses }}败 ({{ player.most_played_hero.games + }}场) +
+
+
+ {% else %} +
暂无数据
+ {% endif %} +
+
+
最佳英雄 (≥2场)
+ {% if player.best_hero %} +
+ {{ player.best_hero.name }} +
+
{{ player.best_hero.name }}
+
+ {{ player.best_hero.wins }}胜 {{ player.best_hero.losses }}败 + ({{ player.best_hero.win_rate }}%胜率) +
+
+
+ {% else %} +
暂无数据
+ {% endif %} +
+
+
+ {% endfor %} + +