diff --git a/starbot/core/datasource.py b/starbot/core/datasource.py index 6bf7e40..d6eae86 100644 --- a/starbot/core/datasource.py +++ b/starbot/core/datasource.py @@ -294,7 +294,8 @@ class MySQLDataSource(DataSource): "`enabled`, `logo`, `logo_base64`, `time`, `fans_change`, `fans_medal_change`, `guard_change`, " "`danmu`, `box`, `gift`, `sc`, `guard`, " "`danmu_ranking`, `box_ranking`, `box_profit_ranking`, `gift_ranking`, `sc_ranking`, " - "`guard_list`, `danmu_cloud` " + "`guard_list`, `box_profit_diagram`, `danmu_diagram`, `box_diagram`, `gift_diagram`, " + "`sc_diagram`, `guard_diagram`, `danmu_cloud` " "FROM `groups` AS `g` LEFT JOIN `live_report` AS `l` " "ON g.`uid` = l.`uid` AND g.`index` = l.`index` " f"WHERE g.`uid` = {uid} " diff --git a/starbot/core/model.py b/starbot/core/model.py index 37b59e1..9692963 100644 --- a/starbot/core/model.py +++ b/starbot/core/model.py @@ -136,6 +136,24 @@ class LiveReport(BaseModel): guard_list = False """是否展示本场直播开通大航海观众列表。默认:False""" + box_profit_diagram = False + """是否展示本场直播的盲盒盈亏曲线图。默认:False""" + + danmu_diagram = False + """是否展示本场直播的弹幕互动曲线图。默认:False""" + + box_diagram = False + """是否展示本场直播的盲盒互动曲线图。默认:False""" + + gift_diagram = False + """是否展示本场直播的礼物互动曲线图。默认:False""" + + sc_diagram = False + """是否展示本场直播的 SC(醒目留言)互动曲线图。默认:False""" + + guard_diagram = False + """是否展示本场直播的开通大航海互动曲线图。默认:False""" + danmu_cloud: Optional[bool] = False """是否生成本场直播弹幕词云。默认:False。默认:False""" @@ -143,25 +161,38 @@ class LiveReport(BaseModel): def default(cls): """ 获取功能全部开启的默认 LiveReport 实例 - 默认配置:启用直播报告,生成弹幕词云 + 默认配置:启用直播报告,无主播立绘,展示直播时间段和直播时长,展示粉丝变动,展示粉丝团(粉丝勋章数)变动,展示大航海变动 + 展示收到弹幕数、发送弹幕人数,展示收到盲盒数、送出盲盒人数、盲盒盈亏,展示礼物收益、送礼物人数 + 展示 SC(醒目留言)收益、发送 SC(醒目留言)人数,展示开通大航海数 + 展示弹幕排行榜前 3 名,展示盲盒数量排行榜前 3 名,展示盲盒盈亏排行榜前 3 名,展示礼物排行榜前 3 名 + 展示 SC(醒目留言)排行榜前 3 名,展示开通大航海观众列表 + 展示盲盒盈亏曲线图,展示弹幕互动曲线图,展示盲盒互动曲线图,展示礼物互动曲线图, + 展示 SC(醒目留言)互动曲线图,展示开通大航海互动曲线图, + 生成弹幕词云 """ return LiveReport(enabled=True, logo=None, logo_base64=None, time=True, fans_change=True, fans_medal_change=True,guard_change=True, danmu=True, box=True, gift=True, sc=True, guard=True, danmu_ranking=3, box_ranking=3, box_profit_ranking=3, gift_ranking=3, sc_ranking=3, - guard_list=True, danmu_cloud=True) + guard_list=True, box_profit_diagram=True, + danmu_diagram=True, box_diagram=True, gift_diagram=True, sc_diagram=True, guard_diagram=True, + danmu_cloud=True) def __str__(self): return (f"启用: {self.enabled}\n直播时长数据: {self.time}\n粉丝变动数据: {self.fans_change}\n" f"粉丝团变动数据: {self.fans_medal_change}\n大航海变动数据: {self.guard_change}\n" f"弹幕相关数据: {self.danmu}\n盲盒相关数据: {self.box}\n礼物相关数据: {self.gift}\n" - f"SC相关数据: {self.sc}\n大航海相关数据: {self.guard}\n" + f"SC 相关数据: {self.sc}\n大航海相关数据: {self.guard}\n" f"弹幕排行榜: {f'前 {self.danmu_ranking} 名' if self.danmu_ranking else False}\n" f"盲盒数量排行榜: {f'前 {self.box_ranking} 名' if self.box_ranking else False}\n" f"盲盒盈亏排行榜: {f'前 {self.box_profit_ranking} 名' if self.box_profit_ranking else False}\n" f"礼物排行榜: {f'前 {self.gift_ranking} 名' if self.gift_ranking else False}\n" f"SC 排行榜: {f'前 {self.sc_ranking} 名' if self.sc_ranking else False}\n" - f"开通大航海观众列表: {self.guard_list}\n生成弹幕词云: {self.danmu_cloud}") + f"开通大航海观众列表: {self.guard_list}\n盲盒盈亏曲线图: {self.box_profit_diagram}\n" + f"弹幕互动曲线图: {self.danmu_diagram}\n盲盒互动曲线图: {self.box_diagram}\n" + f"礼物互动曲线图: {self.gift_diagram}\nSC 互动曲线图: {self.sc_diagram}\n" + f"开通大航海互动曲线图: {self.guard_diagram}\n" + f"生成弹幕词云: {self.danmu_cloud}") class DynamicUpdate(BaseModel): diff --git a/starbot/core/room.py b/starbot/core/room.py index 247077e..2831a4f 100644 --- a/starbot/core/room.py +++ b/starbot/core/room.py @@ -1,17 +1,11 @@ import asyncio -import base64 -import os import time import typing from asyncio import AbstractEventLoop -from collections import Counter -from io import BytesIO from typing import Optional, Any, Union, List -import jieba from loguru import logger from pydantic import BaseModel, PrivateAttr -from wordcloud import WordCloud from .live import LiveDanmaku, LiveRoom from .model import PushTarget @@ -24,8 +18,6 @@ from ..utils.utils import get_credential, timestamp_format, get_unames_and_faces if typing.TYPE_CHECKING: from .sender import Bot -jieba.setLogLevel(jieba.logging.INFO) - class Up(BaseModel): """ @@ -77,6 +69,10 @@ class Up(BaseModel): def dispatch(self, name, data): self.__room.dispatch(name, data) + async def accumulate_and_reset_data(self): + await self.__accumulate_data() + await self.__reset_data() + def is_connecting(self): return (self.__room is not None) and (self.__room.get_status() != 2) @@ -195,8 +191,7 @@ class Up(BaseModel): await redis.hset(f"FansMedalCount:{self.room_id}", live_start_time, fans_medal_count) await redis.hset(f"GuardCount:{self.room_id}", live_start_time, guard_count) - await self.__accumulate_data() - await self.__reset_data() + await self.accumulate_and_reset_data() # 推送开播消息 arg_base = room_info["room_info"] @@ -230,7 +225,7 @@ class Up(BaseModel): self.__bot.send_live_off(self, live_off_args) self.__bot.send_live_report(self, live_report_param) - danmu_items = ["danmu", "danmu_cloud"] + danmu_items = ["danmu", "danmu_ranking", "danmu_diagram", "danmu_cloud"] if not config.get("ONLY_HANDLE_NECESSARY_EVENT") or self.__any_live_report_item_enabled(danmu_items): @self.__room.on("DANMU_MSG") async def on_danmu(event): @@ -250,8 +245,12 @@ class Up(BaseModel): # 弹幕词云所需弹幕记录 if isinstance(base[0][13], str): await redis.rpush(f"RoomDanmu:{self.room_id}", content) + await redis.hincrby(f"RoomDanmuTime:{self.room_id}", int(time.time())) - gift_items = ["box", "gift"] + gift_items = [ + "box", "gift", "box_ranking", "box_profit_ranking", "gift_ranking", + "box_profit_diagram", "box_diagram", "gift_diagram" + ] if not config.get("ONLY_HANDLE_NECESSARY_EVENT") or self.__any_live_report_item_enabled(gift_items): @self.__room.on("SEND_GIFT") async def on_gift(event): @@ -270,6 +269,8 @@ class Up(BaseModel): await redis.hincrbyfloat("RoomGiftProfit", self.room_id, price) await redis.zincrby(f"UserGiftProfit:{self.room_id}", uid, price) + await redis.hincrbyfloat(f"RoomGiftTime:{self.room_id}", int(time.time()), price) + # 盲盒统计 if base["blind_gift"] is not None: box_price = base["total_coin"] / 1000 @@ -279,10 +280,14 @@ class Up(BaseModel): await redis.hincrby("RoomBoxCount", self.room_id, gift_num) await redis.zincrby(f"UserBoxCount:{self.room_id}", uid, gift_num) - await redis.hincrbyfloat("RoomBoxProfit", self.room_id, profit) + box_profit_after = await redis.hincrbyfloat("RoomBoxProfit", self.room_id, profit) await redis.zincrby(f"UserBoxProfit:{self.room_id}", uid, profit) - if not config.get("ONLY_HANDLE_NECESSARY_EVENT") or self.__any_live_report_item_enabled("sc"): + await redis.rpush(f"RoomBoxProfitRecord:{self.room_id}", box_profit_after) + await redis.hincrby(f"RoomBoxTime:{self.room_id}", int(time.time())) + + sc_items = ["sc", "sc_ranking", "sc_diagram"] + if not config.get("ONLY_HANDLE_NECESSARY_EVENT") or self.__any_live_report_item_enabled(sc_items): @self.__room.on("SUPER_CHAT_MESSAGE") async def on_sc(event): """ @@ -298,7 +303,10 @@ class Up(BaseModel): await redis.hincrby("RoomScProfit", self.room_id, price) await redis.zincrby(f"UserScProfit:{self.room_id}", uid, price) - if not config.get("ONLY_HANDLE_NECESSARY_EVENT") or self.__any_live_report_item_enabled("guard"): + await redis.hincrby(f"RoomScTime:{self.room_id}", int(time.time()), price) + + guard_items = ["guard", "guard_list", "guard_diagram"] + if not config.get("ONLY_HANDLE_NECESSARY_EVENT") or self.__any_live_report_item_enabled(guard_items): @self.__room.on("GUARD_BUY") async def on_guard(event): """ @@ -320,6 +328,8 @@ class Up(BaseModel): await redis.hincrby(f"Room{type_mapping[guard_type]}Count", self.room_id, month) await redis.zincrby(f"User{type_mapping[guard_type]}Count:{self.room_id}", uid, month) + await redis.hincrby(f"RoomGuardTime:{self.room_id}", int(time.time()), month) + if self.__any_dynamic_update_enabled(): @self.__room.on("DYNAMIC_UPDATE") async def dynamic_update(event): @@ -407,6 +417,13 @@ class Up(BaseModel): # 清空弹幕记录 await redis.delete(f"RoomDanmu:{self.room_id}") + # 清空数据分布 + await redis.delete(f"RoomDanmuTime:{self.room_id}") + await redis.delete(f"RoomBoxTime:{self.room_id}") + await redis.delete(f"RoomGiftTime:{self.room_id}") + await redis.delete(f"RoomScTime:{self.room_id}") + await redis.delete(f"RoomGuardTime:{self.room_id}") + # 重置弹幕数 await redis.hset(f"RoomDanmuCount", self.room_id, 0) await redis.delete(f"UserDanmuCount:{self.room_id}") @@ -419,6 +436,9 @@ class Up(BaseModel): await redis.hset(f"RoomBoxProfit", self.room_id, 0) await redis.delete(f"UserBoxProfit:{self.room_id}") + # 清空盲盒盈亏记录 + await redis.delete(f"RoomBoxProfitRecord:{self.room_id}") + # 重置礼物收益 await redis.hset(f"RoomGiftProfit", self.room_id, 0) await redis.delete(f"UserGiftProfit:{self.room_id}") @@ -459,6 +479,8 @@ class Up(BaseModel): hour, minute = divmod(minute, 60) live_report_param.update({ + "start_timestamp": start_time, + "end_timestamp": end_time, "start_time": timestamp_format(start_time, "%m/%d %H:%M:%S"), "end_time": timestamp_format(end_time, "%m/%d %H:%M:%S"), "hour": hour, @@ -470,17 +492,16 @@ class Up(BaseModel): if self.__any_live_report_item_enabled(["fans_change", "fans_medal_change", "guard_change"]): room_info = await self.__live_room.get_room_info() - live_start_time = await redis.hgeti("StartTime", self.room_id) - if await redis.hexists(f"FansCount:{self.room_id}", live_start_time): - fans_count = await redis.hgeti(f"FansCount:{self.room_id}", live_start_time) + if await redis.hexists(f"FansCount:{self.room_id}", start_time): + fans_count = await redis.hgeti(f"FansCount:{self.room_id}", start_time) else: fans_count = -1 - if await redis.hexists(f"FansMedalCount:{self.room_id}", live_start_time): - fans_medal_count = await redis.hgeti(f"FansMedalCount:{self.room_id}", live_start_time) + if await redis.hexists(f"FansMedalCount:{self.room_id}", start_time): + fans_medal_count = await redis.hgeti(f"FansMedalCount:{self.room_id}", start_time) else: fans_medal_count = -1 - if await redis.hexists(f"GuardCount:{self.room_id}", live_start_time): - guard_count = await redis.hgeti(f"GuardCount:{self.room_id}", live_start_time) + if await redis.hexists(f"GuardCount:{self.room_id}", start_time): + guard_count = await redis.hgeti(f"GuardCount:{self.room_id}", start_time) else: guard_count = -1 @@ -512,21 +533,27 @@ class Up(BaseModel): # 弹幕相关 "danmu_count": await redis.hgeti("RoomDanmuCount", self.room_id), "danmu_person_count": await redis.zcard(f"UserDanmuCount:{self.room_id}"), + "danmu_diagram": await redis.hgetalltuplei(f"RoomDanmuTime:{self.room_id}"), # 盲盒相关 "box_count": await redis.hgeti("RoomBoxCount", self.room_id), "box_person_count": await redis.zcard(f"UserBoxCount:{self.room_id}"), "box_profit": box_profit, "box_beat_percent": percent, + "box_profit_diagram": await redis.lrangef1(f"RoomBoxProfitRecord:{self.room_id}", 0, -1), + "box_diagram": await redis.hgetalltuplei(f"RoomBoxTime:{self.room_id}"), # 礼物相关 "gift_profit": await redis.hgetf1("RoomGiftProfit", self.room_id), "gift_person_count": await redis.zcard(f"UserGiftProfit:{self.room_id}"), + "gift_diagram": await redis.hgetalltuplef1(f"RoomGiftTime:{self.room_id}"), # SC(醒目留言)相关 "sc_profit": await redis.hgeti("RoomScProfit", self.room_id), "sc_person_count": await redis.zcard(f"UserScProfit:{self.room_id}"), + "sc_diagram": await redis.hgetalltuplei(f"RoomScTime:{self.room_id}"), # 大航海相关 "captain_count": await redis.hgeti("RoomCaptainCount", self.room_id), "commander_count": await redis.hgeti("RoomCommanderCount", self.room_id), - "governor_count": await redis.hgeti("RoomGovernorCount", self.room_id) + "governor_count": await redis.hgeti("RoomGovernorCount", self.room_id), + "guard_diagram": await redis.hgetalltuplei(f"RoomGuardTime:{self.room_id}") }) # 弹幕排行 @@ -649,31 +676,11 @@ class Up(BaseModel): # 弹幕词云 if self.__any_live_report_item_enabled("danmu_cloud"): - all_danmu = " ".join(await redis.lrange(f"RoomDanmu:{self.room_id}", 0, -1)) + all_danmu = await redis.lrange(f"RoomDanmu:{self.room_id}", 0, -1) - if len(all_danmu) == 0: - live_report_param.update({ - "danmu_cloud": "" - }) - else: - words = list(jieba.cut(all_danmu)) - counts = dict(Counter(words)) - - font_base_path = os.path.dirname(os.path.dirname(__file__)) - io = BytesIO() - word_cloud = WordCloud(width=900, - height=450, - font_path=f"{font_base_path}/resource/{config.get('DANMU_CLOUD_FONT')}", - background_color=config.get("DANMU_CLOUD_BACKGROUND_COLOR"), - max_font_size=config.get("DANMU_CLOUD_MAX_FONT_SIZE"), - max_words=config.get("DANMU_CLOUD_MAX_WORDS")) - word_cloud.generate_from_frequencies(counts) - word_cloud.to_image().save(io, format="png") - base64_str = base64.b64encode(io.getvalue()).decode() - - live_report_param.update({ - "danmu_cloud": base64_str - }) + live_report_param.update({ + "all_danmu": all_danmu + }) return live_report_param diff --git a/starbot/painter/LiveReportGenerator.py b/starbot/painter/LiveReportGenerator.py index 41c78db..d738eb7 100644 --- a/starbot/painter/LiveReportGenerator.py +++ b/starbot/painter/LiveReportGenerator.py @@ -1,13 +1,26 @@ import base64 +import bisect +import io +import math import os +from collections import Counter from io import BytesIO from typing import Union, Tuple, List, Dict, Any +import jieba +import numpy as np from PIL import Image, ImageDraw +from matplotlib import pyplot as plt +from mpl_toolkits import axisartist +from scipy.interpolate import make_interp_spline +from wordcloud import WordCloud from .PicGenerator import Color, PicGenerator from ..core.model import LiveReport -from ..utils.utils import split_list, limit_str_length, mask_round +from ..utils import config +from ..utils.utils import split_list, limit_str_length, mask_round, timestamp_format + +jieba.setLogLevel(jieba.logging.INFO) class LiveReportGenerator: @@ -261,14 +274,107 @@ class LiveReportGenerator: ) pic.draw_img_alpha(pic.auto_size_img_by_limit(guard_list_img, logo_limit)) + # 盲盒盈亏曲线图 + if model.box_profit_diagram: + profits = param.get("box_profit_diagram", []) + if profits: + profits.insert(0, 0.0) + + pic.draw_section("盲盒盈亏曲线图") + + diagram = cls.__get_box_profit_diagram(profits, pic.width - (margin * 2)) + pic.draw_img_alpha(pic.auto_size_img_by_limit(diagram, logo_limit)) + + # 弹幕互动曲线图 + if model.danmu_diagram: + start = param.get('start_timestamp', 0) + end = param.get('end_timestamp', 0) + + times = param.get("danmu_diagram", []) + + if end - start >= 100 and times: + pic.draw_section("弹幕互动曲线图") + pic.draw_tip("收获弹幕数量在本场直播中的分布情况") + + diagram = cls.__get_interaction_diagram(times, start, end, pic.width - (margin * 2)) + pic.draw_img_alpha(pic.auto_size_img_by_limit(diagram, logo_limit)) + + # 盲盒互动曲线图 + if model.box_diagram: + start = param.get('start_timestamp', 0) + end = param.get('end_timestamp', 0) + + times = param.get("box_diagram", []) + + if end - start >= 100 and times: + pic.draw_section("盲盒互动曲线图") + pic.draw_tip("收获盲盒数量在本场直播中的分布情况") + + diagram = cls.__get_interaction_diagram(times, start, end, pic.width - (margin * 2)) + pic.draw_img_alpha(pic.auto_size_img_by_limit(diagram, logo_limit)) + + # 礼物互动曲线图 + if model.gift_diagram: + start = param.get('start_timestamp', 0) + end = param.get('end_timestamp', 0) + + times = param.get("gift_diagram", []) + + if end - start >= 100 and times: + pic.draw_section("礼物互动曲线图") + pic.draw_tip("收获礼物价值在本场直播中的分布情况") + + diagram = cls.__get_interaction_diagram(times, start, end, pic.width - (margin * 2)) + pic.draw_img_alpha(pic.auto_size_img_by_limit(diagram, logo_limit)) + + # SC(醒目留言)互动曲线图 + if model.sc_diagram: + start = param.get('start_timestamp', 0) + end = param.get('end_timestamp', 0) + + times = param.get("sc_diagram", []) + + if end - start >= 100 and times: + pic.draw_section("SC(醒目留言)互动曲线图") + pic.draw_tip("收获SC(醒目留言)价值在本场直播中的分布情况") + + diagram = cls.__get_interaction_diagram(times, start, end, pic.width - (margin * 2)) + pic.draw_img_alpha(pic.auto_size_img_by_limit(diagram, logo_limit)) + + # 开通大航海互动曲线图 + if model.guard_diagram: + start = param.get('start_timestamp', 0) + end = param.get('end_timestamp', 0) + + times = param.get("guard_diagram", []) + + if end - start >= 100 and times: + pic.draw_section("开通大航海互动曲线图") + pic.draw_tip("收获大航海开通时长在本场直播中的分布情况") + + diagram = cls.__get_interaction_diagram(times, start, end, pic.width - (margin * 2)) + pic.draw_img_alpha(pic.auto_size_img_by_limit(diagram, logo_limit)) + # 弹幕词云 if model.danmu_cloud: - base64_str = param.get('danmu_cloud', "") - if base64_str != "": + all_danmu = param.get('all_danmu', []) + + if all_danmu: pic.draw_section("弹幕词云") - img_bytes = BytesIO(base64.b64decode(base64_str)) - img = pic.auto_size_img_by_limit(Image.open(img_bytes), logo_limit) + all_danmu_str = " ".join(all_danmu) + words = list(jieba.cut(all_danmu_str)) + counts = dict(Counter(words)) + + font_base_path = os.path.dirname(os.path.dirname(__file__)) + word_cloud = WordCloud(width=900, + height=450, + font_path=f"{font_base_path}/resource/{config.get('DANMU_CLOUD_FONT')}", + background_color=config.get("DANMU_CLOUD_BACKGROUND_COLOR"), + max_font_size=config.get("DANMU_CLOUD_MAX_FONT_SIZE"), + max_words=config.get("DANMU_CLOUD_MAX_WORDS")) + word_cloud.generate_from_frequencies(counts) + img = pic.auto_size_img_by_limit(word_cloud.to_image(), logo_limit) pic.draw_img_with_border(img) # 底部版权信息,请务必保留此处 @@ -577,4 +683,206 @@ class LiveReportGenerator: cls.__get_guard_line_pic(pic, width, face_size, faces, unames, counts, icon_map[i], color_map[i]) ).move_pos(0, -pic.row_space) - return img.img \ No newline at end of file + return img.img + + @classmethod + def __insert_zeros(cls, lst: List[float]) -> List[float]: + """ + 在列表正负变化处添加 0.0 值 + + Args: + lst: 源列表 + + Returns: + 在正负变化处添加 0.0 后的列表 + """ + result = [] + for i in range(len(lst)): + if i == 0: + result.append(lst[i]) + continue + if (lst[i - 1] < 0 < lst[i]) or (lst[i] < 0 < lst[i - 1]): + result.append(0.0) + result.append(lst[i]) + return result + + @classmethod + def __smooth_xy(cls, lx: List[Any], ly: List[Any]) -> Tuple[Any, Any]: + """ + 平滑处理折线图数据 + + Args: + lx: x 轴数据 + ly: y 轴数据 + + Returns: + 平滑处理后的 x 和 y 轴数据组成的元组 + """ + x = np.array(lx) + y = np.array(ly) + x_smooth = np.linspace(0, max(x), 300) + y_smooth = make_interp_spline(x, y)(x_smooth) + return x_smooth, y_smooth + + @classmethod + def __get_line_diagram(cls, + xs: List[Any], + ys: List[Any], + xticks: List[Any], + yticks: List[Any], + xlabels: List[Any], + ylabels: List[Any], + xlimits: Tuple[Any, Any], + ylimits: Tuple[Any, Any], + width: int) -> Image: + """ + 绘制折线图 + + Args: + xs: x 轴数据 + ys: y 轴数据 + xticks: x 轴标签点 + yticks: y 轴标签点 + xlabels: x 轴标签 + ylabels: y 轴标签 + xlimits: x 轴范围 + ylimits: y 轴范围 + width: 折线图图片宽度 + """ + fig = plt.figure(figsize=(width / 100, width / 100 * 0.75)) + ax = axisartist.Subplot(fig, 111) + fig.add_axes(ax) + ax.axis[:].set_visible(False) + ax.axis["x"] = ax.new_floating_axis(0, 0) + ax.axis["y"] = ax.new_floating_axis(1, 0) + ax.axis["x"].set_axis_direction('top') + ax.axis["y"].set_axis_direction('left') + ax.axis["x"].set_axisline_style("->", size=2.0) + ax.axis["y"].set_axisline_style("->", size=2.0) + + ax.plot(xs, ys, color='red') + ax.grid(True, linestyle='--', alpha=0.5) + for i in range(len(ys) - 1): + if ys[i] >= 0 and ys[i + 1] >= 0: + plt.fill_between(xs[i:i + 2], 0, ys[i:i + 2], facecolor='red', alpha=0.3) + else: + plt.fill_between(xs[i:i + 2], 0, ys[i:i + 2], facecolor='green', alpha=0.3) + + ax.set_xticks(xticks) + ax.set_yticks(yticks) + + if xlabels: + ax.set_xticklabels(xlabels) + if ylabels: + ax.set_yticklabels(ylabels) + + ax.set_xlim(xlimits[0], xlimits[1]) + ax.set_ylim(ylimits[0], ylimits[1]) + + buf = io.BytesIO() + fig.savefig(buf) + buf.seek(0) + return Image.open(buf) + + @classmethod + def __get_box_profit_diagram(cls, profits: List[float], width: int) -> Image: + """ + 绘制盲盒盈亏曲线图 + + Args: + profits: 盲盒盈亏记录 + width: 盲盒盈亏曲线图图片宽度 + """ + profits = cls.__insert_zeros(profits) + length = len(profits) + indexs = list(range(0, length)) + + abs_max = int(max(max(profits), abs(min(profits)))) + start = -abs_max - (-abs_max % 10) + end = abs_max + (-abs_max % 10) + step = int((end - start) / 10) + + yticks = list(range(start, end)[::step]) + yticks.append(end) + return cls.__get_line_diagram( + indexs, profits, [], yticks, [], [], (-1, length), (start, end), width + ) + + @classmethod + def __calc_interaction_diagram_xy(cls, + times: List[Tuple[str, Union[int, float]]], + start: int, + end: int) -> Tuple[List[int], List[int]]: + """ + 根据互动时间列表计算互动曲线图中 x 轴和 y 轴数据 + + Args: + times: 互动时间及权重列表 + start: 直播开始时间戳 + end: 直播结束时间戳 + + Returns: + x 轴和 y 轴数据组成的元组 + """ + count = 20 + step = (end - start) // count + + times = [(int(x[0]), x[1]) for x in times] + + divisions = [(start + i * step, start + (i + 1) * step - 1) for i in range(count)] + divisions[-1] = (divisions[-1][0], end) + results = [0] * count + + times.sort(key=lambda x: x[0]) + for i in range(len(times) - 1, -1, -1): + if times[i][0] <= end: + times = times[:i + 1] + break + + for data in times: + index = bisect.bisect_left([d[1] for d in divisions], data[0]) + results[index] += data[1] + + result = [d[0] for d in divisions], results + result[0].append(start + count * step) + result[1].append(0) + return result + + @classmethod + def __get_interaction_diagram(cls, + times: List[Tuple[str, Union[int, float]]], + start: int, + end: int, + width: int) -> Image: + """ + 绘制互动曲线图 + + Args: + times: 互动时间及权重列表 + start: 直播开始时间戳 + end: 直播结束时间戳 + width: 互动曲线图图片宽度 + """ + xs, ys = cls.__calc_interaction_diagram_xy(times, start, end) + + xs = [x - start for x in xs] + max_x = max(xs) + + xs, ys = cls.__smooth_xy(xs, ys) + max_y = math.ceil(max(ys)) + + xticks = [x * max_x // 4 for x in range(1, 4)] + xticks.insert(0, 0) + xticks.append(max_x) + + xlabels = [timestamp_format(start + x * (end - start) // 4, "%H:%M:%S") for x in range(1, 4)] + xlabels.insert(0, timestamp_format(start, "%H:%M:%S")) + xlabels.append(timestamp_format(end, "%H:%M:%S")) + + ystep = max(max_y // 10, 1) + yticks = list(range(0, max_y)[::ystep]) + yticks.append(yticks[-1] + ystep) + + return cls.__get_line_diagram( + xs, ys, xticks, yticks, xlabels, [], (0, max_x), (0, yticks[-1]), width + ) diff --git a/starbot/utils/redis.py b/starbot/utils/redis.py index 30d39db..e7e6b54 100644 --- a/starbot/utils/redis.py +++ b/starbot/utils/redis.py @@ -33,8 +33,16 @@ async def delete(key: str): # List -async def lrange(key: str, start: int, end: int) -> List: - return list(map(lambda x: x.decode(), await __redis.lrange(key, start, end))) +async def lrange(key: str, start: int, end: int) -> List[str]: + return [x.decode() for x in await __redis.lrange(key, start, end)] + + +async def lrangei(key: str, start: int, end: int) -> List[float]: + return [int(x) for x in await __redis.lrange(key, start, end)] + + +async def lrangef1(key: str, start: int, end: int) -> List[float]: + return [float("{:.1f}".format(float(x))) for x in await __redis.lrange(key, start, end)] async def rpush(key: str, value: Any): @@ -61,6 +69,16 @@ async def hgetf1(key: str, hkey: Union[str, int]) -> float: return float("{:.1f}".format(float(result))) +async def hgetalltuplei(key: str) -> List[Tuple[str, int]]: + result = await __redis.hgetall(key) + return [(x.decode(), int(result[x])) for x in result] + + +async def hgetalltuplef1(key: str) -> List[Tuple[str, float]]: + result = await __redis.hgetall(key) + return [(x.decode(), float("{:.1f}".format(float(result[x])))) for x in result] + + async def hset(key: str, hkey: Union[str, int], value: Any): await __redis.hset(key, hkey, value) @@ -87,13 +105,13 @@ async def zrank(key: str, member: str) -> int: async def zrevrangewithscoresi(key: str, start: int, end: int) -> List[Tuple[str, int]]: - return list(map(lambda x: (x[0].decode(), int(x[1])), await __redis.zrevrange(key, start, end, True))) + return [(x[0].decode(), int(x[1])) for x in await __redis.zrevrange(key, start, end, True)] async def zrevrangewithscoresf1(key: str, start: int, end: int) -> List[Tuple[str, float]]: - return list(map( - lambda x: (x[0].decode(), float("{:.1f}".format(float(x[1])))), await __redis.zrevrange(key, start, end, True) - )) + return [ + (x[0].decode(), float("{:.1f}".format(float(x[1])))) for x in await __redis.zrevrange(key, start, end, True) + ] async def zadd(key: str, member: str, score: Union[int, float]):