Skip to content

Commit 6030a71

Browse files
committed
add statistics and plot
1 parent 44919c0 commit 6030a71

10 files changed

Lines changed: 937 additions & 27 deletions

File tree

nonebot_plugin_memes_api/config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ class MemeListImageConfig(BaseModel):
1111
text_template: str = "{keywords}"
1212
add_category_icon: bool = True
1313
label_new_timedelta: timedelta = timedelta(days=30)
14-
label_hot_frequency: int = 24
14+
label_hot_threshold: int = 21
15+
label_hot_days: int = 7
1516

1617

1718
class Config(BaseModel):

nonebot_plugin_memes_api/manager.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,6 @@ def search(
9999
meme_names = process.extract(
100100
meme_name, self.__meme_names.keys(), limit=limit, score_cutoff=score_cutoff
101101
)
102-
logger.debug(meme_names)
103102
result: dict[str, MemeInfo] = {}
104103
for name, _, _ in meme_names:
105104
for meme in self.__meme_names[name]:
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from . import info, manage, search, help, command # noqa
1+
from . import info, manage, search, help, command, statistics # noqa

nonebot_plugin_memes_api/matchers/help.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import hashlib
2-
from datetime import datetime, timedelta
2+
from datetime import datetime, timedelta, timezone
33
from itertools import chain
44

55
from nonebot_plugin_alconna import Image, Text, on_alconna
@@ -47,18 +47,21 @@ async def _(user_id: UserId, session: EventSession):
4747
memes = sorted(memes, key=lambda meme: meme.date_modified, reverse=sort_reverse)
4848

4949
label_new_timedelta = list_image_config.label_new_timedelta
50-
label_hot_frequency = list_image_config.label_hot_frequency
50+
label_hot_threshold = list_image_config.label_hot_threshold
51+
label_hot_days = list_image_config.label_hot_days
5152

5253
meme_generation_keys = await get_meme_generation_keys(
53-
session, SessionIdType.GLOBAL, timedelta(days=1)
54+
session,
55+
SessionIdType.GLOBAL,
56+
time_start=datetime.now(timezone.utc) - timedelta(days=label_hot_days),
5457
)
5558

5659
meme_list: list[MemeKeyWithProperties] = []
5760
for meme in memes:
5861
labels = []
5962
if datetime.now() - meme.date_created < label_new_timedelta:
6063
labels.append("new")
61-
if meme_generation_keys.count(meme.key) >= label_hot_frequency:
64+
if meme_generation_keys.count(meme.key) >= label_hot_threshold:
6265
labels.append("hot")
6366
disabled = not meme_manager.check(user_id, meme.key)
6467
meme_list.append(
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
from datetime import datetime, timedelta
2+
from typing import Any, Optional, Union
3+
4+
from dateutil.relativedelta import relativedelta
5+
from nonebot.matcher import Matcher
6+
from nonebot_plugin_alconna import (
7+
Alconna,
8+
AlconnaQuery,
9+
Args,
10+
Option,
11+
Query,
12+
UniMessage,
13+
on_alconna,
14+
store_true,
15+
)
16+
from nonebot_plugin_session import EventSession, SessionIdType
17+
18+
from ..plot import plot_duration_counts, plot_key_and_duration_counts
19+
from ..recorder import get_meme_generation_records, get_meme_generation_times
20+
from ..utils import add_timezone
21+
from .utils import find_meme
22+
23+
statistics_matcher = on_alconna(
24+
Alconna(
25+
"表情调用统计",
26+
Args["meme_name?", str],
27+
Option("-g|--global", default=False, action=store_true, help_text="全局统计"),
28+
Option("--my", default=False, action=store_true, help_text="我的"),
29+
Option(
30+
"-t|--type",
31+
Args["type", ["day", "week", "month", "year", "24h", "7d", "30d", "1y"]],
32+
help_text="统计类型",
33+
),
34+
),
35+
aliases={"表情使用统计"},
36+
block=True,
37+
priority=11,
38+
use_cmd_start=True,
39+
)
40+
41+
42+
def wrapper(
43+
slot: Union[int, str], content: Optional[str], context: dict[str, Any]
44+
) -> str:
45+
if slot == "my" and content:
46+
return "--my"
47+
elif slot == "global" and content:
48+
return "--global"
49+
elif slot == "type" and content:
50+
if content in ["日", "24小时", "1天"]:
51+
return "--type 24h"
52+
elif content in ["本日", "今日"]:
53+
return "--type day"
54+
elif content in ["周", "一周", "7天"]:
55+
return "--type 7d"
56+
elif content in ["本周"]:
57+
return "--type week"
58+
elif content in ["月", "30天"]:
59+
return "--type 30d"
60+
elif content in ["本月", "月度"]:
61+
return "--type month"
62+
elif content in ["年", "一年"]:
63+
return "--type 1y"
64+
elif content in ["本年", "年度"]:
65+
return "--type year"
66+
return ""
67+
68+
69+
pattern_my = r"(?P<my>我的)"
70+
pattern_type = r"(?P<type>日|24小时|1天|本日|今日|周|一周|7天|本周|月|30天|本月|月度|年|一年|本年|年度)" # noqa E501
71+
pattern_global = r"(?P<global>全局)"
72+
pattern_cmd = r"表情(?:调用|使用)统计"
73+
74+
statistics_matcher.shortcut(
75+
rf"{pattern_my}{pattern_cmd}",
76+
prefix=True,
77+
wrapper=wrapper,
78+
arguments=["{my}"],
79+
).shortcut(
80+
rf"{pattern_global}{pattern_cmd}",
81+
prefix=True,
82+
wrapper=wrapper,
83+
arguments=["{global}"],
84+
).shortcut(
85+
rf"{pattern_my}{pattern_global}{pattern_cmd}",
86+
prefix=True,
87+
wrapper=wrapper,
88+
arguments=["{my}", "{global}"],
89+
).shortcut(
90+
rf"{pattern_my}?{pattern_global}?{pattern_type}{pattern_cmd}",
91+
prefix=True,
92+
wrapper=wrapper,
93+
arguments=["{my}", "{global}", "{type}"],
94+
)
95+
96+
97+
@statistics_matcher.handle()
98+
async def _(
99+
matcher: Matcher,
100+
session: EventSession,
101+
meme_name: Optional[str] = None,
102+
query_global: Query[bool] = AlconnaQuery("global.value", False),
103+
query_my: Query[bool] = AlconnaQuery("my.value", False),
104+
query_type: Query[str] = AlconnaQuery("type", "24h"),
105+
):
106+
meme = await find_meme(matcher, meme_name) if meme_name else None
107+
108+
is_my = query_my.result
109+
is_global = query_global.result
110+
type = query_type.result
111+
112+
if is_my and is_global:
113+
id_type = SessionIdType.USER
114+
elif is_my:
115+
id_type = SessionIdType.GROUP_USER
116+
elif is_global:
117+
id_type = SessionIdType.GLOBAL
118+
else:
119+
id_type = SessionIdType.GROUP
120+
121+
now = datetime.now().astimezone()
122+
if type == "24h":
123+
start = now - timedelta(days=1)
124+
td = timedelta(hours=1)
125+
fmt = "%H:%M"
126+
humanized = "24小时"
127+
elif type == "day":
128+
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
129+
td = timedelta(hours=1)
130+
fmt = "%H:%M"
131+
humanized = "本日"
132+
elif type == "7d":
133+
start = now - timedelta(days=7)
134+
td = timedelta(days=1)
135+
fmt = "%m/%d"
136+
humanized = "7天"
137+
elif type == "week":
138+
start = now.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(
139+
days=now.weekday()
140+
)
141+
td = timedelta(days=1)
142+
fmt = "%a"
143+
humanized = "本周"
144+
elif type == "30d":
145+
start = now - timedelta(days=30)
146+
td = timedelta(days=1)
147+
fmt = "%m/%d"
148+
humanized = "30天"
149+
elif type == "month":
150+
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
151+
td = timedelta(days=1)
152+
fmt = "%m/%d"
153+
humanized = "本月"
154+
elif type == "1y":
155+
start = now - relativedelta(years=1)
156+
td = relativedelta(months=1)
157+
fmt = "%y/%m"
158+
humanized = "一年"
159+
else:
160+
start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
161+
td = relativedelta(months=1)
162+
fmt = "%b"
163+
humanized = "本年"
164+
165+
if meme:
166+
meme_times = await get_meme_generation_times(
167+
session, id_type, meme_key=meme.key, time_start=start
168+
)
169+
meme_keys = [meme.key] * len(meme_times)
170+
else:
171+
meme_records = await get_meme_generation_records(
172+
session, id_type, time_start=start
173+
)
174+
meme_times = [record.time for record in meme_records]
175+
meme_keys = [record.meme_key for record in meme_records]
176+
177+
if not meme_times:
178+
await matcher.finish("暂时没有表情调用记录")
179+
180+
meme_times = [add_timezone(time) for time in meme_times]
181+
meme_times.sort()
182+
183+
def fmt_time(time: datetime) -> str:
184+
if type in ["24h", "7d", "30d", "1y"]:
185+
return (time + td).strftime(fmt)
186+
return time.strftime(fmt)
187+
188+
duration_counts: dict[str, int] = {}
189+
stop = start + td
190+
count = 0
191+
key = fmt_time(start)
192+
for time in meme_times:
193+
while time >= stop:
194+
duration_counts[key] = count
195+
key = fmt_time(stop)
196+
stop += td
197+
count = 0
198+
count += 1
199+
duration_counts[key] = count
200+
while stop <= now:
201+
key = fmt_time(stop)
202+
stop += td
203+
duration_counts[key] = 0
204+
205+
key_counts: dict[str, int] = {}
206+
for key in meme_keys:
207+
key_counts[key] = key_counts.get(key, 0) + 1
208+
209+
if meme:
210+
title = (
211+
f"表情“{meme.key}{humanized}调用统计"
212+
f"(总调用次数为 {key_counts.get(meme.key, 0)})"
213+
)
214+
output = await plot_duration_counts(duration_counts, title)
215+
else:
216+
title = f"{humanized}表情调用统计"
217+
output = await plot_key_and_duration_counts(key_counts, duration_counts, title)
218+
await UniMessage.image(raw=output).send()

nonebot_plugin_memes_api/plot.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from io import BytesIO
2+
3+
import matplotlib
4+
from matplotlib import pyplot as plt
5+
from matplotlib.axes import Axes
6+
from matplotlib.font_manager import fontManager
7+
from matplotlib.ticker import MaxNLocator
8+
from nonebot.utils import run_sync
9+
10+
matplotlib.use("agg")
11+
fallback_fonts = [
12+
"PingFang SC",
13+
"Hiragino Sans GB",
14+
"Microsoft YaHei",
15+
"Source Han Sans SC",
16+
"Noto Sans SC",
17+
"Noto Sans CJK SC",
18+
"WenQuanYi Micro Hei",
19+
]
20+
for fontfamily in fallback_fonts.copy():
21+
try:
22+
fontManager.findfont(fontfamily, fallback_to_default=False)
23+
except ValueError:
24+
fallback_fonts.remove(fontfamily)
25+
matplotlib.rcParams["font.family"] = fallback_fonts
26+
27+
28+
@run_sync
29+
def plot_key_and_duration_counts(
30+
key_counts: dict[str, int], duration_counts: dict[str, int], title: str
31+
) -> BytesIO:
32+
up_x = list(key_counts.keys())
33+
up_y = list(key_counts.values())
34+
low_x = list(duration_counts.keys())
35+
low_y = list(duration_counts.values())
36+
up_height = len(up_x) * 0.3
37+
low_height = 3
38+
fig_width = 8
39+
fig, axs = plt.subplots(
40+
nrows=2,
41+
figsize=(fig_width, up_height + low_height),
42+
height_ratios=[up_height, low_height],
43+
)
44+
up: Axes = axs[0]
45+
up.barh(up_x, up_y, height=0.6)
46+
up.xaxis.set_major_locator(MaxNLocator(integer=True))
47+
low: Axes = axs[1]
48+
low.plot(low_x, low_y, marker="o")
49+
if len(low_x) > 24:
50+
low.set_xticks(low_x[::3])
51+
elif len(low_x) > 12:
52+
low.set_xticks(low_x[::2])
53+
low.yaxis.set_major_locator(MaxNLocator(integer=True))
54+
fig.suptitle(title)
55+
fig.tight_layout()
56+
output = BytesIO()
57+
fig.savefig(output)
58+
return output
59+
60+
61+
@run_sync
62+
def plot_duration_counts(duration_counts: dict[str, int], title: str) -> BytesIO:
63+
x = list(duration_counts.keys())
64+
y = list(duration_counts.values())
65+
fig, ax = plt.subplots(figsize=(6, 4))
66+
ax.plot(x, y, marker="o")
67+
if len(x) > 24:
68+
ax.set_xticks(x[::3])
69+
elif len(x) > 12:
70+
ax.set_xticks(x[::2])
71+
ax.yaxis.set_major_locator(MaxNLocator(integer=True))
72+
fig.suptitle(title)
73+
fig.tight_layout()
74+
output = BytesIO()
75+
fig.savefig(output)
76+
return output

0 commit comments

Comments
 (0)