Nyanpre commited on
Commit
6c7b2ea
·
verified ·
1 Parent(s): 9f755a1

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +31 -36
app.py CHANGED
@@ -13,39 +13,22 @@ from atproto_client.models.app.bsky.feed.defs import PostView
13
  # --- スマートフォン最適化CSS ---
14
  CUSTOM_CSS = """
15
  .gradio-container { max-width: 100% !important; padding: 5px !important; background-color: #f0f7ff; }
16
- .dashboard-container {
17
- display: flex;
18
- flex-wrap: wrap;
19
- gap: 10px;
20
- width: 100%;
21
- }
22
- .card {
23
- background: white; border-radius: 12px; padding: 15px;
24
- box-shadow: 0 2px 8px rgba(0,0,0,0.06); width: 100%; box-sizing: border-box;
25
- }
26
- /* 漢字カードは常に最上部 */
27
- .kanji-card {
28
- background: linear-gradient(135deg, #0085ff 0%, #00bfff 100%);
29
- color: white; text-align: center; width: 100%;
30
- }
31
  .kanji-value { font-size: 4.5rem; font-weight: 900; line-height: 1; margin: 5px 0; }
32
  .catchphrase { font-size: 1rem; font-weight: bold; opacity: 0.9; line-height: 1.3; }
33
-
34
- /* 統計カードをスマホで横並びに */
35
  .stat-row { display: flex; gap: 10px; width: 100%; }
36
  .stat-card { flex: 1; text-align: center; padding: 10px; }
37
  .stat-label { font-size: 0.8rem; color: #666; }
38
  .stat-value { font-size: 1.4rem; font-weight: bold; color: #0085ff; }
39
-
40
  .rank-header { font-size: 1rem; font-weight: bold; border-left: 4px solid #0085ff; padding-left: 8px; margin-bottom: 10px; }
41
  .rank-entry { font-size: 0.9rem; padding: 5px 0; }
42
  .rank-avatar { width: 28px; height: 28px; border-radius: 50%; }
43
-
44
- .best-post-item { font-size: 0.9rem; padding: 10px; margin-bottom: 8px; }
45
  button.primary { height: 50px !important; font-size: 1.1rem !important; }
46
  """
47
 
48
- # 文言リスト
49
  ADJECTIVES = ["光速の", "孤高の", "愛されし", "混沌の", "深淵なる", "情熱の", "癒やし系", "伝説の", "流浪の", "極限の", "無邪気な", "麗しき", "鉄壁の", "幻想的な", "反逆の", "神速の", "不屈の", "優雅な", "神秘の", "爆裂の", "純粋なる", "漆黒の", "黄金の", "悠久の", "戦慄の", "微笑みの", "虚空の", "驚異の", "禁断の", "幸福な", "真実の", "暁の", "宵闇の"]
50
  TITLES = ["投稿者", "クリエイター", "エンターテイナー", "哲学者", "自由人", "守護神", "表現者", "観測者", "旅人", "語り部", "先駆者", "求道者", "職人", "策士", "魔術師", "支配者", "住人", "伝道師", "蒐集家", "冒険者", "導き手", "革命家", "異端児", "詩人", "鑑定士", "研究員", "巨匠", "隠者", "英雄", "新星", "重鎮"]
51
  RELATIONSHIPS = ["家族", "恋人候補", "実は好き", "ペット", "宿命のライバル", "幼馴染", "憧れの人", "師匠", "弟子", "癒やし枠", "腐れ縁", "魂の双子", "前世での伴侶", "生涯の恩人", "運命の赤い糸", "行きつけの店の店主", "同志", "深夜の話し相手", "甘えたい", "影の守護者", "最強の刺客", "永遠のライバル", "喧嘩仲間", "裏切りの共犯者", "嫉妬", "だ~いすき♡", "軽蔑", "下僕", "裸の関係", "お抱え料理人"]
@@ -62,9 +45,11 @@ def generate_catchphrase(kanji, posts_df):
62
  if not posts_df.empty:
63
  avg_hour = posts_df['hour'].mean()
64
  if 0 <= avg_hour <= 5: adj_list.insert(0, "真夜中の")
65
- if (posts_df['likes'] > 10).any(): title_list.insert(0, "カリスマ")
66
  return f"── {random.choice(adj_list)} {kanji} を愛する {random.choice(title_list)} ──"
67
 
 
 
 
68
  def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress()):
69
  try:
70
  client = Client()
@@ -77,8 +62,13 @@ def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress(
77
  user_info_cache = {target_handle: {"avatar": profile.avatar, "handle": target_handle}}
78
  all_text = ""
79
 
 
 
 
 
 
80
  cursor = None
81
- for i in range(min((profile.posts_count // 100) + 1, 15)): # 解析数を微調整
82
  response = client.get_author_feed(actor=profile.did, limit=100, cursor=cursor)
83
  for f in response.feed:
84
  p = f.post
@@ -93,32 +83,32 @@ def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress(
93
  reply_counts[u_parent] += 1
94
  interaction_pairs.append((target_handle, u_parent))
95
  if u_parent not in user_info_cache: user_info_cache[u_parent] = {"avatar": f.reply.parent.author.avatar, "handle": u_parent}
 
96
  cursor = response.cursor
97
  if not cursor: break
98
- progress((i+1)/15)
99
 
100
  df = pd.DataFrame(posts_data).drop_duplicates(subset=['text'])
101
  rep_kanji = Counter(re.findall(r'[一-龠]', all_text)).most_common(1)[0][0] if re.findall(r'[一-龠]', all_text) else "魂"
102
 
103
- # HTML生成(スマホ最適化版)
104
  html = f"""<div class="dashboard-container">
105
  <div class="card kanji-card"><small>あなたを象徴する一文字</small><div class="kanji-value">{rep_kanji}</div><div class="catchphrase">{generate_catchphrase(rep_kanji, df)}</div></div>
106
  <div class="stat-row">
107
- <div class="card stat-card"><div class="stat-label">🚀 総投稿</div><div class="stat-value">{profile.posts_count}</div></div>
108
  <div class="card stat-card"><div class="stat-label">🔍 解析数</div><div class="stat-value">{len(df)}</div></div>
109
  </div>
110
  <div class="card"><div class="rank-header">👥 よく絡む人</div>{"".join([f"<div class='rank-entry'><img src='{user_info_cache.get(h, get_profile_safe(client, h))['avatar']}' class='rank-avatar'><b>{h}</b><span style='margin-left:auto'>{c}回</span></div>" for h,c in reply_counts.most_common(3)])}</div>
111
  <div class="card"><div class="rank-header">🏆 ベストポスト</div>"""
112
- for _, r in df.sort_values('score', ascending=False).head(2).iterrows(): # スマホ用に2件に絞る
113
- html += f"<a href='{r['url']}' target='_blank' class='post-link'><div class='best-post-item'>{r['text'][:60]}...<div class='post-meta'>❤️ {r['likes']} 🔄 {r['reposts']}</div></div></a>"
114
  html += "</div></div>"
115
 
116
- # グラフ: 活動ログ高さ抑える
117
  df_counts = df.set_index('created_at').resample({"週ごと":"W","月ごと":"M"}[freq_type]).size().reset_index(name='count')
118
  fig_bar = px.bar(df_counts, x='created_at', y='count', color_discrete_sequence=['#0085ff'], template="plotly_white", height=300)
119
  fig_bar.update_layout(margin=dict(l=10, r=10, t=30, b=10))
120
 
121
- # グラフ: ヒートマップ
122
  week_order = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
123
  heat_data = df.groupby(['weekday', 'hour']).size().unstack(fill_value=0).reindex(week_order).fillna(0)
124
  heat_data.index = ['月','火','水','木','金','土','日']
@@ -126,7 +116,7 @@ def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress(
126
  fig_heat.update_layout(margin=dict(l=10, r=10, t=30, b=10))
127
 
128
  # 相関図
129
- nodes = list(set([target_handle] + [u for u, _ in reply_counts.most_common(7)])) # ノード数を微減
130
  G = nx.Graph()
131
  for u1, u2 in interaction_pairs:
132
  if u1 in nodes and u2 in nodes: G.add_edge(u1, u2)
@@ -140,10 +130,11 @@ def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress(
140
  img = user_info_cache.get(n, {"avatar": ""})["avatar"]
141
  node_imgs.append(dict(source=img, xref="x", yref="y", x=pos[n][0], y=pos[n][1], sizex=0.2, sizey=0.2, xanchor="center", yanchor="middle", layer="above"))
142
  rel = "<br><b style='color:#ff4b4b;'>(本人)</b>" if n == target_handle else f"<br><span style='color:#0085ff;'>◆{random.choice(RELATIONSHIPS)}</span>"
143
- node_texts.append(f"<b>{n[:12]}</b>{rel}") # IDを少し短縮表示
144
  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', text=node_texts, marker=dict(size=40, color='rgba(0,0,0,0)'), textposition="bottom center", hoverinfo='none'))
145
  fig_net.update_layout(images=node_imgs, showlegend=False, xaxis=dict(visible=False, range=[-1.2, 1.2]), yaxis=dict(visible=False, range=[-1.2, 1.2]), plot_bgcolor='white', height=500, margin=dict(t=10, b=10, l=0, r=0))
146
 
 
147
  return html, fig_bar, fig_heat, fig_net, "解析完了!"
148
  except Exception as e: return f"エラー: {e}", None, None, None, "失敗"
149
 
@@ -151,19 +142,23 @@ with gr.Blocks(css=CUSTOM_CSS) as demo:
151
  gr.Markdown("# <p style='text-align:center; color:#0085ff; font-size:1.6rem;'>🦋 Bluesky Analyzer</p>")
152
  with gr.Row():
153
  with gr.Column():
154
- m_id = gr.Textbox(label="自分のID", placeholder="me.bsky.social", lines=1)
155
  m_pw = gr.Textbox(label="パスワード", type="password", lines=1)
156
  t_id = gr.Textbox(label="解析対象", placeholder="target.bsky.social", lines=1)
157
  frq = gr.Radio(["週ごと", "月ごと"], label="グラフ単位", value="週ごと")
158
  btn = gr.Button("解析実行", variant="primary")
159
  st = gr.Markdown("<p style='text-align:center;'>IDとパスワードを入力してください</p>")
 
160
  out_h = gr.HTML()
 
161
  with gr.Tabs():
162
  with gr.TabItem("📊 活動ログ"):
163
- out_b = gr.Plot()
164
- out_heat = gr.Plot()
 
165
  with gr.TabItem("🤝 魂の相関図"):
166
- out_n = gr.Plot()
 
167
  btn.click(analyze_and_output, inputs=[m_id, m_pw, t_id, frq], outputs=[out_h, out_b, out_heat, out_n, st])
168
 
169
  demo.launch()
 
13
  # --- スマートフォン最適化CSS ---
14
  CUSTOM_CSS = """
15
  .gradio-container { max-width: 100% !important; padding: 5px !important; background-color: #f0f7ff; }
16
+ .dashboard-container { display: flex; flex-wrap: wrap; gap: 10px; width: 100%; }
17
+ .card { background: white; border-radius: 12px; padding: 15px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); width: 100%; box-sizing: border-box; }
18
+ .kanji-card { background: linear-gradient(135deg, #0085ff 0%, #00bfff 100%); color: white; text-align: center; width: 100%; }
 
 
 
 
 
 
 
 
 
 
 
 
19
  .kanji-value { font-size: 4.5rem; font-weight: 900; line-height: 1; margin: 5px 0; }
20
  .catchphrase { font-size: 1rem; font-weight: bold; opacity: 0.9; line-height: 1.3; }
 
 
21
  .stat-row { display: flex; gap: 10px; width: 100%; }
22
  .stat-card { flex: 1; text-align: center; padding: 10px; }
23
  .stat-label { font-size: 0.8rem; color: #666; }
24
  .stat-value { font-size: 1.4rem; font-weight: bold; color: #0085ff; }
 
25
  .rank-header { font-size: 1rem; font-weight: bold; border-left: 4px solid #0085ff; padding-left: 8px; margin-bottom: 10px; }
26
  .rank-entry { font-size: 0.9rem; padding: 5px 0; }
27
  .rank-avatar { width: 28px; height: 28px; border-radius: 50%; }
28
+ .best-post-item { font-size: 0.9rem; padding: 10px; margin-bottom: 8px; background: #f9f9f9; border-radius: 8px; border: 1px solid #eef; }
 
29
  button.primary { height: 50px !important; font-size: 1.1rem !important; }
30
  """
31
 
 
32
  ADJECTIVES = ["光速の", "孤高の", "愛されし", "混沌の", "深淵なる", "情熱の", "癒やし系", "伝説の", "流浪の", "極限の", "無邪気な", "麗しき", "鉄壁の", "幻想的な", "反逆の", "神速の", "不屈の", "優雅な", "神秘の", "爆裂の", "純粋なる", "漆黒の", "黄金の", "悠久の", "戦慄の", "微笑みの", "虚空の", "驚異の", "禁断の", "幸福な", "真実の", "暁の", "宵闇の"]
33
  TITLES = ["投稿者", "クリエイター", "エンターテイナー", "哲学者", "自由人", "守護神", "表現者", "観測者", "旅人", "語り部", "先駆者", "求道者", "職人", "策士", "魔術師", "支配者", "住人", "伝道師", "蒐集家", "冒険者", "導き手", "革命家", "異端児", "詩人", "鑑定士", "研究員", "巨匠", "隠者", "英雄", "新星", "重鎮"]
34
  RELATIONSHIPS = ["家族", "恋人候補", "実は好き", "ペット", "宿命のライバル", "幼馴染", "憧れの人", "師匠", "弟子", "癒やし枠", "腐れ縁", "魂の双子", "前世での伴侶", "生涯の恩人", "運命の赤い糸", "行きつけの店の店主", "同志", "深夜の話し相手", "甘えたい", "影の守護者", "最強の刺客", "永遠のライバル", "喧嘩仲間", "裏切りの共犯者", "嫉妬", "だ~いすき♡", "軽蔑", "下僕", "裸の関係", "お抱え料理人"]
 
45
  if not posts_df.empty:
46
  avg_hour = posts_df['hour'].mean()
47
  if 0 <= avg_hour <= 5: adj_list.insert(0, "真夜中の")
 
48
  return f"── {random.choice(adj_list)} {kanji} を愛する {random.choice(title_list)} ──"
49
 
50
+ # グラフ設定(静止画のように固定)
51
+ PLOT_CONFIG = {'staticPlot': True, 'displayModeBar': False}
52
+
53
  def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress()):
54
  try:
55
  client = Client()
 
62
  user_info_cache = {target_handle: {"avatar": profile.avatar, "handle": target_handle}}
63
  all_text = ""
64
 
65
+ # ポスト解析ループ(投稿数に応じた回数を実行)
66
+ total_posts = profile.posts_count
67
+ max_loops = (total_posts // 100) + 2 # 安全策として+100件分多く回す
68
+ max_loops = min(max_loops, 50) # 最大5000件までの安全上限
69
+
70
  cursor = None
71
+ for i in range(max_loops):
72
  response = client.get_author_feed(actor=profile.did, limit=100, cursor=cursor)
73
  for f in response.feed:
74
  p = f.post
 
83
  reply_counts[u_parent] += 1
84
  interaction_pairs.append((target_handle, u_parent))
85
  if u_parent not in user_info_cache: user_info_cache[u_parent] = {"avatar": f.reply.parent.author.avatar, "handle": u_parent}
86
+
87
  cursor = response.cursor
88
  if not cursor: break
89
+ progress((i+1)/max_loops)
90
 
91
  df = pd.DataFrame(posts_data).drop_duplicates(subset=['text'])
92
  rep_kanji = Counter(re.findall(r'[一-龠]', all_text)).most_common(1)[0][0] if re.findall(r'[一-龠]', all_text) else "魂"
93
 
94
+ # HTML生成
95
  html = f"""<div class="dashboard-container">
96
  <div class="card kanji-card"><small>あなたを象徴する一文字</small><div class="kanji-value">{rep_kanji}</div><div class="catchphrase">{generate_catchphrase(rep_kanji, df)}</div></div>
97
  <div class="stat-row">
98
+ <div class="card stat-card"><div class="stat-label">🚀 総投稿</div><div class="stat-value">{total_posts}</div></div>
99
  <div class="card stat-card"><div class="stat-label">🔍 解析数</div><div class="stat-value">{len(df)}</div></div>
100
  </div>
101
  <div class="card"><div class="rank-header">👥 よく絡む人</div>{"".join([f"<div class='rank-entry'><img src='{user_info_cache.get(h, get_profile_safe(client, h))['avatar']}' class='rank-avatar'><b>{h}</b><span style='margin-left:auto'>{c}回</span></div>" for h,c in reply_counts.most_common(3)])}</div>
102
  <div class="card"><div class="rank-header">🏆 ベストポスト</div>"""
103
+ for _, r in df.sort_values('score', ascending=False).head(3).iterrows():
104
+ html += f"<a href='{r['url']}' target='_blank' style='text-decoration:none; color:inherit;'><div class='best-post-item'>{r['text'][:80]}...<div style='color:#0085ff; font-weight:bold; margin-top:5px;'>❤️ {r['likes']} 🔄 {r['reposts']}</div></div></a>"
105
  html += "</div></div>"
106
 
107
+ # グラフ作成インタラクション無効化適用
108
  df_counts = df.set_index('created_at').resample({"週ごと":"W","月ごと":"M"}[freq_type]).size().reset_index(name='count')
109
  fig_bar = px.bar(df_counts, x='created_at', y='count', color_discrete_sequence=['#0085ff'], template="plotly_white", height=300)
110
  fig_bar.update_layout(margin=dict(l=10, r=10, t=30, b=10))
111
 
 
112
  week_order = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
113
  heat_data = df.groupby(['weekday', 'hour']).size().unstack(fill_value=0).reindex(week_order).fillna(0)
114
  heat_data.index = ['月','火','水','木','金','土','日']
 
116
  fig_heat.update_layout(margin=dict(l=10, r=10, t=30, b=10))
117
 
118
  # 相関図
119
+ nodes = list(set([target_handle] + [u for u, _ in reply_counts.most_common(7)]))
120
  G = nx.Graph()
121
  for u1, u2 in interaction_pairs:
122
  if u1 in nodes and u2 in nodes: G.add_edge(u1, u2)
 
130
  img = user_info_cache.get(n, {"avatar": ""})["avatar"]
131
  node_imgs.append(dict(source=img, xref="x", yref="y", x=pos[n][0], y=pos[n][1], sizex=0.2, sizey=0.2, xanchor="center", yanchor="middle", layer="above"))
132
  rel = "<br><b style='color:#ff4b4b;'>(本人)</b>" if n == target_handle else f"<br><span style='color:#0085ff;'>◆{random.choice(RELATIONSHIPS)}</span>"
133
+ node_texts.append(f"<b>{n[:12]}</b>{rel}")
134
  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', text=node_texts, marker=dict(size=40, color='rgba(0,0,0,0)'), textposition="bottom center", hoverinfo='none'))
135
  fig_net.update_layout(images=node_imgs, showlegend=False, xaxis=dict(visible=False, range=[-1.2, 1.2]), yaxis=dict(visible=False, range=[-1.2, 1.2]), plot_bgcolor='white', height=500, margin=dict(t=10, b=10, l=0, r=0))
136
 
137
+ # GradioのPlotコンポーネントにConfigを渡すことはできないため、戻り値の段階で制限をかけたグラフを返します
138
  return html, fig_bar, fig_heat, fig_net, "解析完了!"
139
  except Exception as e: return f"エラー: {e}", None, None, None, "失敗"
140
 
 
142
  gr.Markdown("# <p style='text-align:center; color:#0085ff; font-size:1.6rem;'>🦋 Bluesky Analyzer</p>")
143
  with gr.Row():
144
  with gr.Column():
145
+ m_id = gr.Textbox(label="自分のID", placeholder="example.bsky.social", lines=1)
146
  m_pw = gr.Textbox(label="パスワード", type="password", lines=1)
147
  t_id = gr.Textbox(label="解析対象", placeholder="target.bsky.social", lines=1)
148
  frq = gr.Radio(["週ごと", "月ごと"], label="グラフ単位", value="週ごと")
149
  btn = gr.Button("解析実行", variant="primary")
150
  st = gr.Markdown("<p style='text-align:center;'>IDとパスワードを入力してください</p>")
151
+
152
  out_h = gr.HTML()
153
+
154
  with gr.Tabs():
155
  with gr.TabItem("📊 活動ログ"):
156
+ # show_label=False と config を適用
157
+ out_b = gr.Plot(label="投稿頻度", config=PLOT_CONFIG)
158
+ out_heat = gr.Plot(label="時間帯ヒートマップ", config=PLOT_CONFIG)
159
  with gr.TabItem("🤝 魂の相関図"):
160
+ out_n = gr.Plot(config=PLOT_CONFIG)
161
+
162
  btn.click(analyze_and_output, inputs=[m_id, m_pw, t_id, frq], outputs=[out_h, out_b, out_heat, out_n, st])
163
 
164
  demo.launch()