Skip to content

Commit 765ed5e

Browse files
authored
ネットワーク機能の追加 (#7)
* Add network analysis cog and dependencies for conversation network generation - Implemented a new cog `ConversationNetwork` for generating conversation networks based on message interactions. - Added `networkx` and `scipy` as dependencies in `uv.lock` for network analysis and visualization. - Updated `main.py` to load the new `network` cog. * fix: 修正されたエッジの重複を防ぐために、ユーザーIDのタプルをソートして使用
1 parent acaf857 commit 765ed5e

5 files changed

Lines changed: 338 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ dependencies = [
88
"discord-py>=2.7.1",
99
"janome>=0.5.0",
1010
"matplotlib>=3.10.8",
11+
"networkx>=3.6.1",
1112
"pymongo>=4.16.0",
13+
"scipy>=1.17.1",
1214
"wordcloud>=1.9.6",
1315
]
1416

src/cogs/network.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import discord
2+
from discord import app_commands
3+
from discord.ext import commands
4+
from typing import Optional
5+
from datetime import timedelta
6+
from collections import defaultdict
7+
8+
from libs.visualize import generate_conversation_network
9+
from libs.embed import EmbedHelper
10+
11+
12+
class ConversationNetwork(commands.Cog):
13+
14+
network_group = app_commands.Group(
15+
name="network",
16+
description="会話ネットワーク分析",
17+
)
18+
19+
def __init__(self, bot: commands.Bot):
20+
self.bot = bot
21+
22+
@network_group.command(
23+
name="generate",
24+
description="会話ネットワークを生成します",
25+
)
26+
@app_commands.describe(
27+
period="解析する期間(日)",
28+
user="特定ユーザーのみ解析",
29+
channel="特定チャンネルのみ解析",
30+
)
31+
async def generate_network(
32+
self,
33+
interaction: discord.Interaction,
34+
period: Optional[str] = None,
35+
user: Optional[discord.User] = None,
36+
channel: Optional[discord.TextChannel] = None,
37+
):
38+
embed_helper = EmbedHelper(function_name="ConversationNetwork")
39+
40+
if interaction.guild_id is None:
41+
embed = embed_helper.create_error_embed(
42+
title="エラー",
43+
description="このコマンドはサーバー内で使ってね。",
44+
)
45+
await interaction.response.send_message(embed=embed, ephemeral=True)
46+
return
47+
48+
# period
49+
period_filter = {}
50+
if period:
51+
try:
52+
period_filter = {
53+
"timestamp": {
54+
"$gte": (
55+
discord.utils.utcnow() - timedelta(days=int(period))
56+
).isoformat()
57+
}
58+
}
59+
except ValueError:
60+
embed = embed_helper.create_error_embed(
61+
title="エラー",
62+
description="期間は数値で指定してね。",
63+
)
64+
await interaction.response.send_message(embed=embed, ephemeral=True)
65+
return
66+
67+
user_filter = {}
68+
if user:
69+
user_filter = {"user_id": str(user.id)}
70+
71+
channel_filter = {}
72+
if channel:
73+
channel_filter = {"channel_id": str(channel.id)}
74+
75+
await interaction.response.defer(thinking=True)
76+
77+
try:
78+
docs = list(
79+
self.bot.db.messages.find(
80+
{
81+
"guild_id": str(interaction.guild_id),
82+
**period_filter,
83+
**user_filter,
84+
**channel_filter,
85+
}
86+
)
87+
.sort("timestamp", -1)
88+
.limit(5000)
89+
)
90+
except Exception as e:
91+
embed = embed_helper.create_error_embed(
92+
title="DBエラー",
93+
description="データ取得中にエラーが発生しました",
94+
)
95+
await interaction.followup.send(embed=embed)
96+
print(e)
97+
return
98+
99+
if not docs:
100+
embed = embed_helper.create_warning_embed(
101+
title="データ不足",
102+
description="解析対象メッセージがありません。",
103+
)
104+
await interaction.followup.send(embed=embed)
105+
return
106+
107+
# message map
108+
msg_map = {doc["message_id"]: doc for doc in docs}
109+
110+
edges = defaultdict(int)
111+
112+
for msg in docs:
113+
114+
author = msg.get("user_id")
115+
116+
# reply
117+
reply_to = msg.get("reply_to")
118+
if reply_to and reply_to in msg_map:
119+
other = msg_map[reply_to]["user_id"]
120+
if author != other:
121+
edges[tuple(sorted([author, other]))] += 1
122+
123+
# mention
124+
for mentioned in msg.get("mentions", []):
125+
if mentioned != author:
126+
edges[tuple(sorted([author, mentioned]))] += 1
127+
128+
if not edges:
129+
embed = embed_helper.create_warning_embed(
130+
title="会話不足",
131+
description="会話ネットワークを作れるデータがありません。",
132+
)
133+
await interaction.followup.send(embed=embed)
134+
return
135+
136+
# user id → name
137+
user_map = {}
138+
139+
for a, b in edges.keys():
140+
if a not in user_map:
141+
member = interaction.guild.get_member(int(a))
142+
user_map[a] = member.display_name if member else a
143+
144+
if b not in user_map:
145+
member = interaction.guild.get_member(int(b))
146+
user_map[b] = member.display_name if member else b
147+
148+
named_edges = {
149+
(user_map[a], user_map[b]): count for (a, b), count in edges.items()
150+
}
151+
152+
try:
153+
image_buffer = generate_conversation_network(named_edges)
154+
except Exception as e:
155+
embed = embed_helper.create_error_embed(
156+
title="生成エラー",
157+
description="ネットワーク生成中にエラーが発生しました",
158+
)
159+
await interaction.followup.send(embed=embed)
160+
print(e)
161+
return
162+
163+
embed = embed_helper.create_success_embed(
164+
title="会話ネットワーク生成",
165+
description=f"{len(docs)}件のメッセージを解析しました",
166+
binary_data=image_buffer.getvalue(),
167+
binary_filename="network.png",
168+
)
169+
170+
await interaction.followup.send(
171+
embed=embed,
172+
file=discord.File(image_buffer, filename="network.png"),
173+
)
174+
175+
176+
async def setup(bot: commands.Bot):
177+
await bot.add_cog(ConversationNetwork(bot))

src/libs/visualize.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
from janome.tokenizer import Tokenizer
88
from wordcloud import WordCloud
99

10+
import networkx as nx
11+
import matplotlib.pyplot as plt
12+
import matplotlib.font_manager as fm
13+
1014
matplotlib.use("Agg")
1115
STOP_WORDS = {
1216
"ので",
@@ -159,5 +163,75 @@ def generate_wordcloud_from_file(
159163
f.write(image_buffer.getvalue())
160164

161165

166+
def generate_conversation_network(edges: dict) -> io.BytesIO:
167+
168+
font_path = resolve_font_path()
169+
font_prop = None
170+
171+
if font_path:
172+
font_prop = fm.FontProperties(fname=font_path)
173+
174+
G = nx.Graph()
175+
176+
# ユーザー名 → ノードID
177+
node_map = {}
178+
labels = {}
179+
node_index = 0
180+
181+
for (a, b), weight in edges.items():
182+
183+
if weight < 2:
184+
continue
185+
186+
if a not in node_map:
187+
node_map[a] = node_index
188+
labels[node_index] = a
189+
node_index += 1
190+
191+
if b not in node_map:
192+
node_map[b] = node_index
193+
labels[node_index] = b
194+
node_index += 1
195+
196+
G.add_edge(node_map[a], node_map[b], weight=weight)
197+
198+
pos = nx.kamada_kawai_layout(G)
199+
200+
plt.figure(figsize=(24, 24))
201+
202+
weights = [G[u][v]["weight"] for u, v in G.edges()]
203+
204+
nx.draw(
205+
G,
206+
pos,
207+
node_color="#A0CBE2",
208+
node_size=5000,
209+
width=[w * 0.8 for w in weights],
210+
with_labels=False,
211+
)
212+
213+
texts = nx.draw_networkx_labels(
214+
G,
215+
pos,
216+
labels,
217+
font_size=30,
218+
)
219+
220+
# 日本語フォント適用
221+
if font_prop:
222+
for t in texts.values():
223+
t.set_fontproperties(font_prop)
224+
225+
buffer = io.BytesIO()
226+
227+
plt.axis("off")
228+
plt.savefig(buffer, format="png", bbox_inches="tight")
229+
plt.close()
230+
231+
buffer.seek(0)
232+
233+
return buffer
234+
235+
162236
if __name__ == "__main__":
163237
generate_wordcloud_from_file("sample.txt")

src/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ async def main():
247247
await bot.load_extension("cogs.wordcloud")
248248
await bot.load_extension("cogs.about")
249249
await bot.load_extension("cogs.optout")
250+
await bot.load_extension("cogs.network")
250251

251252
async with bot:
252253
await bot.start(TOKEN)

0 commit comments

Comments
 (0)