Nyanpre commited on
Commit
5ca6f32
·
verified ·
1 Parent(s): d465416

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +119 -228
app.py CHANGED
@@ -13,269 +13,160 @@ from atproto_client.models.app.bsky.feed.defs import PostView
13
 
14
  # --- カスタムCSS ---
15
  CUSTOM_CSS = """
16
- body { background-color: #f0f7ff; }
17
- .gradio-container { font-family: 'Hiragino Kaku Gothic ProN', 'Meiryo', sans-serif; }
18
- .stat-card {
19
- background: white; padding: 20px; border-radius: 15px;
20
- box-shadow: 0 4px 6px rgba(0,0,0,0.05); text-align: center;
21
- border-bottom: 4px solid #0085ff;
22
  }
23
- .stat-value { font-size: 1.8em; font-weight: bold; color: #0085ff; }
24
- .rank-card {
25
- background: #ffffff; padding: 15px; border-radius: 15px;
26
- border: 1px solid #e1e8ed; font-size: 0.9em;
27
- }
28
- .rank-header {
29
- color: #111; font-weight: bold; margin-bottom: 10px;
30
- border-bottom: 1px solid #eee; padding-bottom: 5px;
31
- }
32
- .best-post-card {
33
- background: #f8f9fa; border-left: 5px solid #0085ff;
34
- padding: 10px; margin-bottom: 10px; border-radius: 5px;
35
- }
36
- .rank-item { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
37
- .rank-avatar { width: 24px; height: 24px; border-radius: 50%; object-fit: cover; }
38
- button.primary {
39
- background: linear-gradient(135deg, #0085ff 0%, #00bfff 100%) !important;
40
- border: none !important; color: white !important;
41
  }
 
 
 
 
 
 
 
 
 
 
 
42
  """
43
 
44
  def get_profile_info(client, did_or_handle):
45
  try:
46
  profile = client.get_profile(actor=did_or_handle)
47
- return {
48
- "avatar": profile.avatar if profile.avatar else "https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png",
49
- "handle": profile.handle,
50
- "display_name": profile.display_name or profile.handle
51
- }
52
- except Exception:
53
- return None
54
 
55
  def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress()):
56
  try:
57
  client = Client()
58
- my_id = my_id.replace('@', '').strip()
59
  target_handle = target_id.replace('@', '').strip()
60
- client.login(my_id, my_pw.strip())
61
  profile = client.get_profile(actor=target_handle)
62
 
63
- posts_data = []
64
- reply_users_list = []
65
- repost_users_list = []
66
- interaction_pairs = []
67
- ends_list = []
68
 
69
  total_posts_count = getattr(profile, 'posts_count', 0)
70
- max_iterations = (total_posts_count // 100) + 1
71
 
72
- progress(0, desc=f"全 {total_posts_count} ポストを解析中...")
73
  cursor = None
74
-
75
  for i in range(max_iterations):
76
- try:
77
- response = client.get_author_feed(actor=profile.did, limit=100, cursor=cursor)
78
- except Exception: break
79
- if not response or not response.feed: break
80
-
81
- for feed_view in response.feed:
82
- post = feed_view.post
83
- if not isinstance(post, PostView): continue
84
 
85
- created_at_raw = getattr(post.record, 'created_at', None)
86
- if created_at_raw:
87
- dt_jst = pd.to_datetime(created_at_raw) + timedelta(hours=9)
88
- if post.author.handle == target_handle:
89
- text = getattr(post.record, 'text', "")
90
- clean_text = re.sub(r'[!\?!?。\n\s]+$', '', text)
91
- if len(clean_text) >= 2:
92
- ending = clean_text[-3:]
93
- if re.search(r'[ぁ-んーwWwW]$', ending):
94
- ends_list.append(ending)
95
-
96
- posts_data.append({
97
- 'text': text,
98
- 'created_at': dt_jst,
99
- 'hour': dt_jst.hour,
100
- 'weekday': dt_jst.day_name(),
101
- 'likes': getattr(post, 'like_count', 0),
102
- 'reposts': getattr(post, 'repost_count', 0),
103
- 'score': getattr(post, 'like_count', 0) + getattr(post, 'repost_count', 0)
104
- })
105
-
106
- u_author = post.author.handle
107
- if feed_view.reason:
108
- interaction_pairs.append((target_handle, u_author))
109
- if u_author != target_handle: repost_users_list.append(u_author)
110
 
111
- if getattr(feed_view, 'reply', None) and feed_view.reply.parent:
112
- parent_post = feed_view.reply.parent
113
- if isinstance(parent_post, PostView):
114
- u_parent = parent_post.author.handle
115
- interaction_pairs.append((u_author, u_parent))
116
- if u_author == target_handle and u_parent != target_handle:
117
- reply_users_list.append(u_parent)
 
 
 
 
 
118
 
119
  cursor = response.cursor
120
- progress((i + 1) / max_iterations)
121
  if not cursor: break
122
 
123
- if not posts_data: return "データなし", "", "", None, None, None, "失敗"
124
-
125
- df = pd.DataFrame(posts_data)
126
- # --- 重複ポストの排除 ---
127
- df = df.drop_duplicates(subset=['text']).reset_index(drop=True)
128
-
129
- first_post_date = df['created_at'].min().strftime('%Y/%m/%d')
130
- total_posts = getattr(profile, 'posts_count', 0)
131
-
132
- stats_html = f"""
133
- <div style='display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; margin-bottom: 20px;'>
134
- <div class='stat-card'><small>総ポスト数</small><div class='stat-value'>{total_posts}</div></div>
135
- <div class='stat-card'><small>観測開始日</small><div class='stat-value' style='font-size:1.2em; padding: 6px 0;'>{first_post_date}</div></div>
136
- <div class='stat-card'><small>サンプル</small><div class='stat-value'>{len(df)}</div></div>
137
- </div>
 
 
 
 
 
 
 
 
138
  """
139
-
140
- def rank_box_styled(title, items, icon=""):
141
- top = Counter(items).most_common(3)
142
- res = f"<div class='rank-card'><div class='rank-header'>{icon} {title}</div>"
143
- if top:
144
- for n, c in top:
145
- info = get_profile_info(client, n) if "@" not in str(n) and "." in str(n) else None
146
- avatar_html = f"<img src='{info['avatar']}' class='rank-avatar'>" if info else ""
147
- res += f"<div class='rank-item'>{avatar_html} <b>{n}</b> <span style='margin-left:auto; color:#666;'>{c}回</span></div>"
148
- else:
149
- res += "<div style='color:#ccc;'>未検出</div>"
150
- res += "</div>"
151
- return res
152
 
153
- rank_html = f"""
154
- <div style='display: flex; flex-direction: column; gap: 10px;'>
155
- {rank_box_styled('リプライ相手', reply_users_list, '💬')}
156
- {rank_box_styled('リポスト相手', repost_users_list, '🔄')}
157
- {rank_box_styled('よく使う語尾', ends_list, '🗣️')}
158
- </div>
159
- """
160
 
161
- top_posts = df.sort_values('score', ascending=False).head(3)
162
- posts_html = "<div class='rank-card'><div class='rank-header'>🏆 ベストポスト</div>"
163
- for _, row in top_posts.iterrows():
164
- posts_html += f"<div class='best-post-card'>{row['text'][:80]}...<br><small style='color:#0085ff;'>❤️ {row['likes']} &nbsp; 🔄 {row['reposts']}</small></div>"
165
- posts_html += "</div>"
166
-
167
- freq_map = {"日ごと": "D", "週ごと": "W", "月ごと": "M"}
168
- df_counts = df.set_index('created_at').resample(freq_map[freq_type]).size().reset_index(name='count')
169
- fig_bar = px.bar(df_counts, x='created_at', y='count', title="投稿推移", color_discrete_sequence=['#0085ff'], template="plotly_white")
170
-
171
- week_order = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
172
- heat_pt = df.groupby(['weekday', 'hour']).size().reset_index(name='count').pivot(index='weekday', columns='hour', values='count').reindex(week_order).fillna(0)
173
- heat_pt.index = ['月', '火', '水', '木', '金', '土', '日']
174
- fig_heat = px.imshow(heat_pt, x=list(range(24)), y=heat_pt.index, color_continuous_scale='Blues', title="時間帯別アクティビティ")
175
-
176
- # --- 相関図の修正 ---
177
- all_interactors = [p for pair in interaction_pairs for p in pair if p != target_handle]
178
- top_10 = [u for u, c in Counter(all_interactors).most_common(10)]
179
- nodes = list(set([target_handle] + top_10))
180
- RELATIONSHIPS = ["家族", "恋人", "相棒", "ライバル", "師匠", "弟子", "癒やし", "腐れ縁", "憧れ", "守護霊"]
181
-
182
- # 各ノードにランダムな関係性を割り当て
183
- node_labels = {}
184
- for n in nodes:
185
- rel = random.choice(RELATIONSHIPS) if n != target_handle else "(本人)"
186
- node_labels[n] = f"<b>{n}</b><br>{rel}"
187
-
188
  G = nx.Graph()
189
- for (u1, u2) in interaction_pairs:
190
- if u1 in nodes and u2 in nodes and u1 != u2:
191
- if G.has_edge(u1, u2): G[u1][u2]['weight'] += 1
192
- else: G.add_edge(u1, u2, weight=1)
193
-
194
- pos = nx.spring_layout(G, k=2.0, seed=42)
195
  fig_net = go.Figure()
196
-
197
- # エッジの描画
198
  for edge in G.edges():
199
- fig_net.add_trace(go.Scatter(
200
- x=[pos[edge[0]][0], pos[edge[1]][0], None],
201
- y=[pos[edge[0]][1], pos[edge[1]][1], None],
202
- mode='lines',
203
- line=dict(color='#cfd8dc', width=1.5),
204
- hoverinfo='none'
205
- ))
206
-
207
- # ノード画像の設定
208
- node_images = []
209
- for node in G.nodes():
210
- info = get_profile_info(client, node)
211
- if info:
212
- node_images.append(dict(
213
- source=info['avatar'], xref="x", yref="y",
214
- x=pos[node][0], y=pos[node][1],
215
- sizex=0.18, sizey=0.18,
216
- xanchor="center", yanchor="middle", layer="above"
217
- ))
218
-
219
- # --- ラベルを確実に表示するための修正 ---
220
- fig_net.add_trace(go.Scatter(
221
- x=[pos[n][0] for n in G.nodes()],
222
- y=[pos[n][1] for n in G.nodes()],
223
- mode='markers+text', # markersも有効化
224
- marker=dict(size=25, color='rgba(0,0,0,0)'), # 透明なマーカーで当たり判定を確保
225
- text=[node_labels[n] for n in G.nodes()],
226
- textposition="bottom center", # 画像の下に配置
227
- textfont=dict(color="black", size=12),
228
- hoverinfo='text'
229
- ))
230
-
231
- fig_net.update_layout(
232
- images=node_images,
233
- showlegend=False,
234
- plot_bgcolor='white',
235
- height=700,
236
- xaxis=dict(visible=False, range=[-1.5, 1.5]),
237
- yaxis=dict(visible=False, range=[-1.5, 1.5]),
238
- margin=dict(l=0, r=0, t=40, b=0)
239
- )
240
 
241
- return stats_html, rank_html, posts_html, fig_bar, fig_heat, fig_net, "解析完了しました!"
242
-
243
  except Exception as e:
244
- import traceback
245
- print(traceback.format_exc())
246
- return f"エラー: {str(e)}", "", "", None, None, None, "失敗"
247
 
248
- # --- UI (そのまま) ---
249
- with gr.Blocks(css=CUSTOM_CSS, title="Bluesky Analysis Dashboard") as demo:
250
- gr.Markdown("# <p style='text-align:center; color:#0085ff;'>🦋 Bluesky Ultimate Dashboard</p>")
251
-
252
  with gr.Row():
253
- with gr.Column(scale=1):
254
- with gr.Group():
255
- my_id = gr.Textbox(label="自分のハン", placeholder="user.bsky.social")
256
- my_pw = gr.Textbox(label="アプリパスワード", type="password")
257
- target_id = gr.Textbox(label="解析対象のハンドル")
258
- freq = gr.Radio(["日ごと", "週ごと", "月ごと"], label="グラフ単位", value="日ごと")
259
- btn = gr.Button("解析を開始する", variant="primary")
260
- status = gr.Markdown("待機中...")
261
-
262
- with gr.Column(scale=2):
263
- out_stats = gr.HTML()
264
- with gr.Row():
265
- out_rank = gr.HTML()
266
- out_posts = gr.HTML()
267
-
268
  with gr.Tabs():
269
- with gr.TabItem("📊 活動データ"):
270
- with gr.Row():
271
- out_bar = gr.Plot()
272
- out_heat = gr.Plot()
273
- with gr.TabItem("🤝 マップ"):
274
- out_net = gr.Plot()
275
 
276
- btn.click(analyze_and_output,
277
- inputs=[my_id, my_pw, target_id, freq],
278
- outputs=[out_stats, out_rank, out_posts, out_bar, out_heat, out_net, status])
279
 
280
- if __name__ == "__main__":
281
- demo.launch()
 
13
 
14
  # --- カスタムCSS ---
15
  CUSTOM_CSS = """
16
+ .gradio-container { max-width: 100% !important; padding: 10px !important; background-color: #f0f7ff; }
17
+ .dashboard-container {
18
+ display: grid;
19
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
20
+ gap: 20px;
21
+ width: 100%;
22
  }
23
+ .card {
24
+ background: white; border-radius: 16px; padding: 20px;
25
+ box-shadow: 0 4px 12px rgba(0,0,0,0.08); width: 100%; box-sizing: border-box;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  }
27
+ .kanji-card { background: linear-gradient(135deg, #0085ff 0%, #00bfff 100%); color: white; text-align: center; }
28
+ .kanji-value { font-size: 5rem; font-weight: 900; line-height: 1; margin: 15px 0; }
29
+ .stats-subgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 15px; }
30
+ .stat-item { background: rgba(255,255,255,0.2); padding: 10px; border-radius: 10px; text-align: center; }
31
+ .stat-num { font-size: 1.5rem; font-weight: bold; }
32
+ .rank-section { margin-bottom: 20px; }
33
+ .rank-header { font-size: 1.1rem; font-weight: bold; border-left: 4px solid #0085ff; padding-left: 10px; margin-bottom: 15px; color: #333; }
34
+ .rank-entry { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #eee; }
35
+ .best-post-item { background: #f9f9f9; border-radius: 12px; padding: 15px; margin-bottom: 15px; border: 1px solid #eef; line-height: 1.6; }
36
+ .post-meta { font-size: 0.85rem; color: #0085ff; margin-top: 10px; font-weight: bold; }
37
+ button.primary { background: #0085ff !important; height: 60px !important; font-size: 1.2rem !important; color: white !important; }
38
  """
39
 
40
  def get_profile_info(client, did_or_handle):
41
  try:
42
  profile = client.get_profile(actor=did_or_handle)
43
+ return {"avatar": profile.avatar or "https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png", "handle": profile.handle}
44
+ except: return None
 
 
 
 
 
45
 
46
  def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress()):
47
  try:
48
  client = Client()
49
+ client.login(my_id.replace('@', '').strip(), my_pw.strip())
50
  target_handle = target_id.replace('@', '').strip()
 
51
  profile = client.get_profile(actor=target_handle)
52
 
53
+ posts_data, reply_users, repost_users, interaction_pairs, ends_list = [], [], [], [], []
54
+ all_text = ""
 
 
 
55
 
56
  total_posts_count = getattr(profile, 'posts_count', 0)
57
+ max_iterations = min((total_posts_count // 100) + 1, 30)
58
 
 
59
  cursor = None
 
60
  for i in range(max_iterations):
61
+ response = client.get_author_feed(actor=profile.did, limit=100, cursor=cursor)
62
+ if not response.feed: break
63
+
64
+ for f in response.feed:
65
+ p = f.post
66
+ # --- 修正ポイント:PostViewではない(削除済みなど)場合はスキップ ---
67
+ if not isinstance(p, PostView): continue
 
68
 
69
+ # 投稿内容の解析
70
+ if p.author.handle == target_handle:
71
+ txt = getattr(p.record, 'text', "")
72
+ all_text += txt
73
+ clean = re.sub(r'[!\?!?。\n\s]+$', '', txt)
74
+ if len(clean) >= 2:
75
+ end = clean[-3:];
76
+ if re.search(r'[-んーwWwW]$', end): ends_list.append(end)
77
+
78
+ posts_data.append({
79
+ 'text': txt, 'likes': getattr(p, 'like_count', 0),
80
+ 'reposts': getattr(p, 'repost_count', 0),
81
+ 'created_at': pd.to_datetime(getattr(p.record, 'created_at')) + timedelta(hours=9),
82
+ 'score': getattr(p, 'like_count', 0) + getattr(p, 'repost_count', 0)
83
+ })
 
 
 
 
 
 
 
 
 
 
84
 
85
+ # 交流の解析
86
+ u_auth = p.author.handle
87
+ if f.reason: interaction_pairs.append((target_handle, u_auth))
88
+
89
+ # リプライ先の安全な確認
90
+ if getattr(f, 'reply', None) and f.reply.parent:
91
+ parent = f.reply.parent
92
+ # リプライ先が削除されていないか確認
93
+ if isinstance(parent, PostView):
94
+ u_parent = parent.author.handle
95
+ interaction_pairs.append((u_auth, u_parent))
96
+ if u_auth == target_handle: reply_users.append(u_parent)
97
 
98
  cursor = response.cursor
99
+ progress((i+1)/max_iterations, desc="データを解析中...")
100
  if not cursor: break
101
 
102
+ if not posts_data: return "有効ポストが見つかりませんでた。", None, None, "失敗"
103
+
104
+ df = pd.DataFrame(posts_data).drop_duplicates(subset=['text']).reset_index(drop=True)
105
+ kanjis = re.findall(r'[一-龠]', all_text)
106
+ rep_kanji = Counter(kanjis).most_common(1)[0][0] if kanjis else "魂"
107
+
108
+ # HTML構成(レスポンシブ)
109
+ html = f"""
110
+ <div class="dashboard-container">
111
+ <div class="card kanji-card">
112
+ <small>象徴する一文字</small><div class="kanji-value">{rep_kanji}</div>
113
+ <div class="stats-subgrid">
114
+ <div class="stat-item"><small>総投稿</small><br><span class="stat-num">{total_posts_count}</span></div>
115
+ <div class="stat-item"><small></small><br><span class="stat-num">{len(df)}</span></div>
116
+ </div>
117
+ </div>
118
+ <div class="card">
119
+ <div class="rank-section"><div class="rank-header">👥 主な交流相手</div>
120
+ {"".join([f"<div class='rank-entry'><span>{n}</span><b>{c}回</b></div>" for n,c in Counter(reply_users).most_common(3)])}</div>
121
+ <div class="rank-section"><div class="rank-header">🗣️ 特徴的な語尾</div>
122
+ {"".join([f"<div class='rank-entry'><span>{n}</span><b>{c}回</b></div>" for n,c in Counter(ends_list).most_common(3)])}</div>
123
+ </div>
124
+ <div class="card" style="grid-column: 1 / -1;"><div class="rank-header">🏆 ベストポスト</div>
125
  """
126
+ for _, r in df.sort_values('score', ascending=False).head(3).iterrows():
127
+ html += f"<div class='best-post-item'>{r['text']}<div class='post-meta'>❤️ {r['likes']} 🔄 {r['reposts']} — {r['created_at'].strftime('%Y/%m/%d')}</div></div>"
128
+ html += "</div></div>"
 
 
 
 
 
 
 
 
 
 
129
 
130
+ # グラフ生成
131
+ df_counts = df.set_index('created_at').resample(freq_map := {"日ごと": "D", "週ごと": "W", "月ごと": "M"}[freq_type]).size().reset_index(name='count')
132
+ fig_bar = px.bar(df_counts, x='created_at', y='count', color_discrete_sequence=['#0085ff'], template="plotly_white", title="投稿頻度")
 
 
 
 
133
 
134
+ # 相関図
135
+ top_users = [u for u, c in Counter([p for pair in interaction_pairs for p in pair if p != target_handle]).most_common(10)]
136
+ nodes = list(set([target_handle] + top_users))
137
+ RELATIONSHIPS = ["運命の人", "相棒", "ライバル", "師匠", "���子", "癒やし", "犬猿の仲", "腐れ縁", "守護霊"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  G = nx.Graph()
139
+ for u1, u2 in interaction_pairs:
140
+ if u1 in nodes and u2 in nodes and u1 != u2: G.add_edge(u1, u2)
141
+ pos = nx.spring_layout(G, k=1.5, seed=42)
 
 
 
142
  fig_net = go.Figure()
 
 
143
  for edge in G.edges():
144
+ fig_net.add_trace(go.Scatter(x=[pos[edge[0]][0], pos[edge[1]][0], None], y=[pos[edge[0]][1], pos[edge[1]][1], None], mode='lines', line=dict(color='#ddd', width=1), hoverinfo='none'))
145
+ fig_net.add_trace(go.Scatter(x=[pos[n][0] for n in nodes], y=[pos[n][1] for n in nodes], mode='markers+text',
146
+ text=[f"<b>{n}</b><br>{random.choice(RELATIONSHIPS) if n != target_handle else '(本人)'}" for n in nodes],
147
+ textposition="bottom center", marker=dict(size=12, color='#0085ff'), textfont=dict(size=11)))
148
+ fig_net.update_layout(showlegend=False, xaxis=dict(visible=False), yaxis=dict(visible=False), margin=dict(l=0, r=0, t=40, b=0), plot_bgcolor='white', height=500)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
+ return html, fig_bar, fig_net, "解析完了!"
 
151
  except Exception as e:
152
+ return f"エラーが発生しました: {str(e)}", None, None, "失敗"
 
 
153
 
154
+ with gr.Blocks(css=CUSTOM_CSS, title="Bluesky Analysis") as demo:
155
+ gr.Markdown("# <p style='text-align:center; color:#0085ff;'>🦋 Bluesky Analysis Dashboard</p>")
 
 
156
  with gr.Row():
157
+ with gr.Column():
158
+ m_id = gr.Textbox(label="自分のハンドル", placeholder="user.bsky.social")
159
+ m_pw = gr.Textbox(label="アプリパスワード", type="password")
160
+ t_id = gr.Textbox(label="解析したい相手")
161
+ frq = gr.Radio(["日ごと", "週ごと", "月ごと"], label="グラフ単位", value="日ごと")
162
+ btn = gr.Button("解析���開始", variant="primary")
163
+ st = gr.Markdown("準備完了")
164
+
165
+ out_h = gr.HTML()
 
 
 
 
 
 
166
  with gr.Tabs():
167
+ with gr.TabItem("📊 活動"): out_b = gr.Plot()
168
+ with gr.TabItem("🤝 相関図"): out_n = gr.Plot()
 
 
 
 
169
 
170
+ btn.click(analyze_and_output, inputs=[m_id, m_pw, t_id, frq], outputs=[out_h, out_b, out_n, st])
 
 
171
 
172
+ demo.launch()