Nyanpre commited on
Commit
ea169df
·
verified ·
1 Parent(s): fd5613d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +260 -81
app.py CHANGED
@@ -8,51 +8,179 @@ from collections import Counter
8
  import plotly.express as px
9
  import plotly.graph_objects as go
10
  import networkx as nx
 
 
 
 
 
 
 
 
 
 
11
 
12
  def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress()):
13
  try:
14
- # --- 1. ログイン & データ取得 ---
15
  client = Client()
16
- client.login(my_id.replace('@', '').strip(), my_pw.strip())
17
  target_handle = target_id.replace('@', '').strip()
 
 
18
  profile = client.get_profile(actor=target_handle)
19
 
20
  posts_data = []
21
- interactions = [] # (自分, 相手, 種類)
 
 
 
 
 
 
 
 
 
22
 
 
 
23
  cursor = None
24
- for _ in range(10): # 最大1000件
25
- response = client.get_author_feed(actor=profile.did, limit=100, cursor=cursor)
 
 
 
 
 
 
 
 
26
  for feed_view in response.feed:
 
27
  post = feed_view.post
28
- if post.author.handle == target_handle:
29
- # 投稿データ保存
30
- posts_data.append({
31
- 'text': getattr(post.record, 'text', ""),
32
- 'created_at': pd.to_datetime(post.record.created_at),
33
- })
 
 
 
 
 
 
 
 
 
34
 
35
- # ネットワーク用:リプライ
36
- if feed_view.reply and feed_view.reply.parent:
37
- parent_author = getattr(feed_view.reply.parent.author, 'handle', None)
38
- if parent_author and parent_author != target_handle:
39
- interactions.append((target_handle, parent_author, 'reply'))
40
-
41
- # ネットワーク用:リポスト
42
- if feed_view.reason and post.author.handle != target_handle:
43
- interactions.append((target_handle, post.author.handle, 'repost'))
44
-
45
- cursor = response.cursor
46
- if not cursor: break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  if not posts_data:
49
- return None, None, "データが見つかりませんでした"
50
 
51
  df = pd.DataFrame(posts_data)
52
- df['created_at'] = df['created_at'].dt.tz_localize(None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
- # --- 2. 投稿数の棒グラフ (Plotly) ---
55
- # freq_type: "日ごと" -> "D", "週ごと" -> "W", "月ごと" -> "M"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  freq_map = {"日ごと": "D", "週ごと": "W", "月ごと": "M"}
57
  df_counts = df.set_index('created_at').resample(freq_map[freq_type]).size().reset_index(name='count')
58
 
@@ -62,72 +190,123 @@ def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress(
62
  template="plotly_white")
63
  fig_bar.update_traces(marker_color='#0085ff')
64
 
65
- # --- 3. ユーザーネットワーク図 (NetworkX + Plotly) ---
66
  G = nx.Graph()
67
- # 出現回数をカウントしてエッジ太さ反映
68
- edge_counts = Counter([(u, v) for u, v, t in interactions])
69
- for (u, v), weight in edge_counts.items():
70
- G.add_edge(u, v, weight=weight)
 
 
 
 
 
 
 
71
 
72
- pos = nx.spring_layout(G, k=0.5, iterations=50)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
- edge_x, edge_y = [], []
75
- for edge in G.edges():
76
- x0, y0 = pos[edge[0]]
77
- x1, y1 = pos[edge[1]]
78
- edge_x.extend([x0, x1, None])
79
- edge_y.extend([y0, y1, None])
80
-
81
- edge_trace = go.Scatter(x=edge_x, y=edge_y, line=dict(width=0.5, color='#888'), hoverinfo='none', mode='lines')
82
-
83
- node_x, node_y, node_text, node_size = [], [], [], []
84
- for node in G.nodes():
85
- x, y = pos[node]
86
- node_x.append(x)
87
- node_y.append(y)
88
- node_text.append(f"@{node}<br>接続数: {len(list(G.neighbors(node)))}")
89
- # 中心ユーザー(本人)を大きく表示
90
- node_size.append(30 if node == target_handle else 15)
91
-
92
- node_trace = go.Scatter(
93
- x=node_x, y=node_y, mode='markers', hoverinfo='text', text=node_text,
94
- marker=dict(showscale=True, colorscale='YlGnBu', size=node_size,
95
- colorbar=dict(thickness=15, title='Node Connections'),
96
- line_width=2))
97
-
98
- fig_network = go.Figure(data=[edge_trace, node_trace],
99
- layout=go.Layout(title='関連ユーザーネットワーク (Reply & Repost)',
100
- showlegend=False, hovermode='closest',
101
- margin=dict(b=0,l=0,r=0,t=40),
102
- xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
103
- yaxis=dict(showgrid=False, zeroline=False, showticklabels=False)))
104
-
105
- return fig_bar, fig_network, "解析完了"
106
 
107
  except Exception as e:
108
- return None, None, f"エラー: {str(e)}"
 
109
 
110
  # --- Gradio UI ---
111
- with gr.Blocks() as demo:
112
- gr.Markdown("# 🦋 Bluesky Advanced Visualizer")
113
 
114
  with gr.Row():
115
  with gr.Column(scale=1):
116
- in_id = gr.Textbox(label="自分のID")
117
- in_pw = gr.Textbox(label="アプリパスワード", type="password")
118
- in_target = gr.Textbox(label="解析対象のID")
119
- in_freq = gr.Radio(["日ごと", "週ごと", "月ごと"], label="グラフの単位", value="日ごと")
120
- btn = gr.Button("分析実行", variant="primary")
121
- status = gr.Textbox(label="ステータス")
122
-
 
 
 
 
 
 
123
  with gr.Row():
124
  out_bar = gr.Plot(label="投稿数推移")
125
-
 
126
  with gr.Row():
127
- out_net = gr.Plot(label="関連ユーザーネットワーク")
128
 
129
- btn.click(analyze_and_output,
130
- inputs=[in_id, in_pw, in_target, in_freq],
131
- outputs=[out_bar, out_net, status])
 
 
132
 
133
- demo.launch()
 
 
8
  import plotly.express as px
9
  import plotly.graph_objects as go
10
  import networkx as nx
11
+ import io
12
+ from PIL import Image
13
+
14
+ # プロフィール画像を取得するヘルパー関数
15
+ def get_profile_image(client, did_or_handle):
16
+ try:
17
+ profile = client.get_profile(actor=did_or_handle)
18
+ return profile.avatar if profile and profile.avatar else None
19
+ except Exception:
20
+ return None
21
 
22
  def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress()):
23
  try:
24
+ # --- 1. ログイン ---
25
  client = Client()
26
+ my_id = my_id.replace('@', '').strip()
27
  target_handle = target_id.replace('@', '').strip()
28
+ client.login(my_id, my_pw.strip())
29
+
30
  profile = client.get_profile(actor=target_handle)
31
 
32
  posts_data = []
33
+ hashtags = []
34
+ reply_users_list = []
35
+ repost_users_list = []
36
+ like_users_list = []
37
+
38
+ # ネットワーク図作成のための相互作用リスト
39
+ interactions_for_network = [] # (source_handle, target_handle, type)
40
+ user_avatars = {target_handle: getattr(profile, 'avatar', None)} # 顔アイコンURLを保存
41
+
42
+ max_limit = 500
43
 
44
+ # --- 2. 投稿フィードの分析 ---
45
+ progress(0, desc="フィードと相互作用データを取得中...")
46
  cursor = None
47
+
48
+ for _ in range(10): # 最大1000件取得を想定しつつ無限ループ防止
49
+ try:
50
+ response = client.get_author_feed(actor=profile.did, limit=100, cursor=cursor)
51
+ except Exception:
52
+ break
53
+
54
+ if not response or not hasattr(response, 'feed') or not response.feed:
55
+ break
56
+
57
  for feed_view in response.feed:
58
+ if not getattr(feed_view, 'post', None): continue
59
  post = feed_view.post
60
+
61
+ # リポスト分析
62
+ if feed_view.reason:
63
+ if hasattr(post, 'author') and post.author:
64
+ orig_author_handle = post.author.handle
65
+ if orig_author_handle != target_handle:
66
+ repost_users_list.append(orig_author_handle)
67
+ interactions_for_network.append((target_handle, orig_author_handle, 'repost'))
68
+ if orig_author_handle not in user_avatars:
69
+ user_avatars[orig_author_handle] = get_profile_image(client, orig_author_handle)
70
+ continue
71
+
72
+ # 本人投稿分析
73
+ if hasattr(post, 'author') and post.author and post.author.handle == target_handle:
74
+ if not hasattr(post, 'record'): continue
75
 
76
+ rkey = post.uri.split('/')[-1]
77
+ post_url = f"https://bsky.app/profile/{target_handle}/post/{rkey}"
78
+
79
+ likes = getattr(post, 'like_count', 0) or 0
80
+ reposts = getattr(post, 'repost_count', 0) or 0
81
+ created_at_raw = getattr(post.record, 'created_at', None)
82
+ text = getattr(post.record, 'text', "") or ""
83
+
84
+ if created_at_raw:
85
+ posts_data.append({
86
+ 'text': text,
87
+ 'created_at': pd.to_datetime(created_at_raw),
88
+ 'likes': likes,
89
+ 'reposts': reposts,
90
+ 'score': likes + reposts,
91
+ 'url': post_url
92
+ })
93
+ if text:
94
+ hashtags.extend(re.findall(r'#(\w+)', text))
95
+
96
+ # リプライ相手
97
+ if getattr(feed_view, 'reply', None) and feed_view.reply.parent:
98
+ parent = feed_view.reply.parent
99
+ if hasattr(parent, 'author') and parent.author:
100
+ p_handle = parent.author.handle
101
+ if p_handle != target_handle:
102
+ reply_users_list.append(p_handle)
103
+ interactions_for_network.append((target_handle, p_handle, 'reply'))
104
+ if p_handle not in user_avatars:
105
+ user_avatars[p_handle] = get_profile_image(client, p_handle)
106
 
107
+ cursor = getattr(response, 'cursor', None)
108
+ if not cursor or len(posts_data) >= max_limit: break
109
+ progress(min(len(posts_data)/max_limit * 0.5, 0.5), desc=f"{len(posts_data)}件取得中...")
110
+
111
+ # --- 3. いいね一覧の分析 ---
112
+ progress(0.6, desc="いいねを分析中...")
113
+ try:
114
+ likes_resp = client.get_actor_likes(actor=profile.did, limit=50) # いいねの取得は少し少なめに
115
+ if likes_resp and hasattr(likes_resp, 'feed'):
116
+ for like_item in likes_resp.feed:
117
+ if like_item.post and hasattr(like_item.post, 'author') and like_item.post.author:
118
+ l_handle = like_item.post.author.handle
119
+ if l_handle != target_handle:
120
+ like_users_list.append(l_handle)
121
+ # ネットワーク図には追加しないが、ユーザー情報を取得するならここで
122
+ if l_handle not in user_avatars:
123
+ user_avatars[l_handle] = get_profile_image(client, l_handle)
124
+ except Exception:
125
+ pass # いいね取得でエラーが出ても続行
126
+
127
+ # --- 4. 解析結果のHTML作成 ---
128
  if not posts_data:
129
+ return "有効な投稿データが見つかりませんでした", "", "", None, None, "完了(データ不足)"
130
 
131
  df = pd.DataFrame(posts_data)
132
+ df['created_at'] = df['created_at'].dt.tz_localize(None) # タイムゾーンを削除
133
+ df = df.sort_values('created_at', ascending=True)
134
+
135
+ first_post_time = df.iloc[0]['created_at'].replace(tzinfo=None)
136
+ days_active = max((datetime.now().replace(tzinfo=None) - first_post_time).days, 1)
137
+
138
+ stats_html = f"""
139
+ <div style='display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 20px;'>
140
+ <div style='background: #e3f2fd; padding: 15px; border-radius: 10px; text-align: center; border: 1px solid #90caf9;'>
141
+ <div style='color: #1565c0; font-size: 0.8em;'>総ポスト</div><div style='font-size: 1.5em; font-weight: bold;'>{getattr(profile, 'posts_count', 0)}</div>
142
+ </div>
143
+ <div style='background: #f1f8e9; padding: 15px; border-radius: 10px; text-align: center; border: 1px solid #c5e1a5;'>
144
+ <div style='color: #33691e; font-size: 0.8em;'>継続日数</div><div style='font-size: 1.5em; font-weight: bold;'>{days_active}日</div>
145
+ </div>
146
+ <div style='background: #fff3e0; padding: 15px; border-radius: 10px; text-align: center; border: 1px solid #ffcc80;'>
147
+ <div style='color: #e65100; font-size: 0.8em;'>1日平均</div><div style='font-size: 1.5em; font-weight: bold;'>{len(df)/days_active:.2f}</div>
148
+ </div>
149
+ </div>
150
+ """
151
 
152
+ def make_rank_list(title, icon, counter_list):
153
+ html = f"<b>{icon} {title}</b><div style='font-size: 0.85em; color: #444; margin: 5px 0 12px 0;'>"
154
+ if not counter_list: return html + "データなし</div>"
155
+ items = Counter(counter_list).most_common(3)
156
+ for name, count in items:
157
+ html += f"<div>@{name} ({count}回)</div>"
158
+ return html + "</div>"
159
+
160
+ rank_html = f"""
161
+ <div style='background: #fafafa; padding: 15px; border-radius: 10px; border: 1px solid #eee;'>
162
+ {make_rank_list("よくリプライする相手", "💬", reply_users_list)}
163
+ {make_rank_list("よくリポストする相手", "🔄", repost_users_list)}
164
+ {make_rank_list("よくいいねする相手", "❤️", like_users_list)}
165
+ <b>#️⃣ よく使うタグ</b><div style='display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px;'>
166
+ {"".join([f"<span style='background: #eee; padding: 3px 8px; border-radius: 10px; font-size: 0.75em;'>#{t}</span>" for t, _ in Counter(hashtags).most_common(3)]) or "なし"}
167
+ </div>
168
+ </div>
169
+ """
170
+
171
+ top_3 = df.sort_values('score', ascending=False).head(3)
172
+ posts_html = f"<h4>🌱 初投稿 ({df.iloc[0]['created_at'].strftime('%Y/%m/%d')})</h4><p style='font-size: 0.9em; background:#f9f9f9; padding:10px; border-radius:5px;'>{df.iloc[0]['text']}</p><h4>🏆 ベストポスト Top 3</h4>"
173
+
174
+ for i, (_, row) in enumerate(top_3.iterrows(), 1):
175
+ posts_html += f"""
176
+ <div style='border: 1px solid #eee; padding: 10px; margin-bottom: 8px; border-radius: 8px; background: white;'>
177
+ <b>第{i}位</b> (❤️{row['likes']} 🔄{row['reposts']})<br>
178
+ <p style='margin:5px 0;'>{row['text'][:80]}...</p>
179
+ <a href='{row['url']}' target='_blank' style='color:#d81b60; font-size:0.8em;'>表示</a>
180
+ </div>
181
+ """
182
+
183
+ # --- 5. 投稿数の棒グラフ (Plotly) ---
184
  freq_map = {"日ごと": "D", "週ごと": "W", "月ごと": "M"}
185
  df_counts = df.set_index('created_at').resample(freq_map[freq_type]).size().reset_index(name='count')
186
 
 
190
  template="plotly_white")
191
  fig_bar.update_traces(marker_color='#0085ff')
192
 
193
+ # --- 6. ユーザーネットワーク図 (NetworkX + Plotly + 顔アイコン) ---
194
  G = nx.Graph()
195
+ # 相互作用多い順上位10ユーザーを抽出
196
+ all_interactions_nodes = [item[1] for item in interactions_for_network] # 相手側のユーザー
197
+ top_10_users_counter = Counter(all_interactions_nodes).most_common(9) # 本人を除いた9人
198
+ top_10_handles = [target_handle] + [user for user, _ in top_10_users_counter]
199
+
200
+ for source, target, _type in interactions_for_network:
201
+ if source in top_10_handles and target in top_10_handles:
202
+ if not G.has_edge(source, target):
203
+ G.add_edge(source, target, weight=1) # 初回
204
+ else:
205
+ G[source][target]['weight'] += 1 # 相互作用回数を重み付け
206
 
207
+ # ノードが1つ以下ならネットワーク図は作らない
208
+ if G.number_of_nodes() < 2:
209
+ fig_network = go.Figure().update_layout(title="ネットワーク図:相互作用が不足しています")
210
+ else:
211
+ pos = nx.spring_layout(G, k=0.5, iterations=50) # レイアウト計算
212
+
213
+ edge_x, edge_y, edge_weights = [], [], []
214
+ for u, v, data in G.edges(data=True):
215
+ x0, y0 = pos[u]
216
+ x1, y1 = pos[v]
217
+ edge_x.extend([x0, x1, None])
218
+ edge_y.extend([y0, y1, None])
219
+ edge_weights.append(data['weight'])
220
+
221
+ edge_trace = go.Scatter(x=edge_x, y=edge_y, line=dict(width=[w*0.5 for w in edge_weights], color='#888'),
222
+ hoverinfo='none', mode='lines', opacity=0.7)
223
+
224
+ node_x, node_y, node_text, node_images = [], [], [], []
225
+ for node in G.nodes():
226
+ x, y = pos[node]
227
+ node_x.append(x)
228
+ node_y.append(y)
229
+ node_text.append(f"@{node}<br>つながり: {len(list(G.neighbors(node)))}")
230
+
231
+ avatar_url = user_avatars.get(node, None)
232
+ node_images.append(avatar_url if avatar_url else 'https://img.icons8.com/ios-filled/50/000000/user--v1.png') # デフォルトアイコン
233
+
234
+ node_trace = go.Scatter(
235
+ x=node_x, y=node_y, mode='markers', hoverinfo='text', text=node_text,
236
+ marker=dict(
237
+ symbol='circle', # ダミー
238
+ size=30,
239
+ color='rgba(0,0,0,0)', # 透明にして画像を表示
240
+ line_width=0,
241
+ opacity=0 # 透明
242
+ )
243
+ )
244
+
245
+ # アイコンを追加するためのアノテーション
246
+ annotations = []
247
+ for i, (x, y) in enumerate(zip(node_x, node_y)):
248
+ annotations.append(
249
+ go.layout.Annotation(
250
+ x=x, y=y,
251
+ xref="x", yref="y",
252
+ text="",
253
+ showarrow=False,
254
+ images=[dict(
255
+ source=node_images[i],
256
+ xref="x", yref="y",
257
+ x=x, y=y,
258
+ sizex=0.08, sizey=0.08, # アイコンのサイズ調��
259
+ xanchor="center", yanchor="middle"
260
+ )]
261
+ )
262
+ )
263
+
264
+ fig_network = go.Figure(data=[edge_trace, node_trace],
265
+ layout=go.Layout(title=f'{target_handle}の関連ユーザーネットワーク (Top {len(top_10_handles)} users)',
266
+ showlegend=False, hovermode='closest',
267
+ margin=dict(b=0,l=0,r=0,t=40),
268
+ xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
269
+ yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
270
+ annotations=annotations # アイコンをアノテーションとして追加
271
+ ))
272
 
273
+ return stats_html, rank_html, posts_html, fig_bar, fig_network, "解析が完了しました!"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
 
275
  except Exception as e:
276
+ import traceback
277
+ return f"エラーが発生しました: {str(e)}", "", "", None, None, "失敗"
278
 
279
  # --- Gradio UI ---
280
+ with gr.Blocks(title="Bluesky Analysis") as demo:
281
+ gr.Markdown("# 🦋 Bluesky アクティビティ&ネットワーク分析")
282
 
283
  with gr.Row():
284
  with gr.Column(scale=1):
285
+ in_id = gr.Textbox(label="自分のBluesky ID (例: example.bsky.social)")
286
+ in_pw = gr.Textbox(label="Bluesky アプリパスワード", type="password")
287
+ in_target = gr.Textbox(label="解析したいユーザーBluesky ID (例: target.bsky.social)")
288
+ in_freq = gr.Radio(["日ごと", "週ごと", "月ごと"], label="投稿数グラフの単位", value="日ごと")
289
+ btn = gr.Button("分析開始", variant="primary")
290
+ status = gr.Textbox(label="ステータス", interactive=False)
291
+
292
+ with gr.Column(scale=2):
293
+ out_stats = gr.HTML(label="概要統計")
294
+ out_rank = gr.HTML(label="関連ユーザーとハッシュタグ")
295
+ out_posts = gr.HTML(label="ベスト投稿 Top 3")
296
+
297
+ gr.Markdown("## グラフで見るアクティビティ")
298
  with gr.Row():
299
  out_bar = gr.Plot(label="投稿数推移")
300
+
301
+ gr.Markdown("## 関連ユーザーネットワーク")
302
  with gr.Row():
303
+ out_net = gr.Plot(label="主要関連ユーザーネットワーク")
304
 
305
+ btn.click(
306
+ fn=analyze_and_output,
307
+ inputs=[in_id, in_pw, in_target, in_freq],
308
+ outputs=[out_stats, out_rank, out_posts, out_bar, out_net, status]
309
+ )
310
 
311
+ demo.launch()
312
+ ```http://googleusercontent.com/image_generation_content/1