Update app.py
Browse files
app.py
CHANGED
|
@@ -31,45 +31,25 @@ CUSTOM_CSS = """
|
|
| 31 |
button.primary { background: #0085ff !important; height: 60px !important; color: white !important; font-size: 1.2rem !important; }
|
| 32 |
"""
|
| 33 |
|
| 34 |
-
#
|
| 35 |
-
ADJECTIVES = [
|
| 36 |
-
|
| 37 |
-
"流浪の", "極限の", "無邪気な", "麗しき", "鉄壁の", "幻想的な", "反逆の", "神速の",
|
| 38 |
-
"不屈の", "優雅な", "神秘の", "爆裂の", "純粋なる", "漆黒の", "黄金の", "悠久の",
|
| 39 |
-
"戦慄の", "微笑みの", "虚空の", "驚異の", "禁断の", "幸福な", "真実の", "暁の", "宵闇の"
|
| 40 |
-
]
|
| 41 |
-
|
| 42 |
-
TITLES = [
|
| 43 |
-
"投稿者", "クリエイター", "エンターテイナー", "哲学者", "自由人", "守護神",
|
| 44 |
-
"表現者", "観測者", "旅人", "語り部", "先駆者", "求道者", "職人", "策士",
|
| 45 |
-
"魔術師", "支配者", "住人", "伝道師", "蒐集家", "冒険者", "導き手", "革命家",
|
| 46 |
-
"異端児", "詩人", "鑑定士", "研究員", "巨匠", "隠者", "英雄", "新星", "重鎮"
|
| 47 |
-
]
|
| 48 |
-
|
| 49 |
RELATIONSHIPS = ["家族", "恋人候補", "実は好き", "ペット", "宿命のライバル", "幼馴染", "憧れの人", "師匠", "弟子", "癒やし枠", "腐れ縁", "魂の双子", "前世での伴侶", "生涯の恩人", "運命の赤い糸", "行きつけの店の店主", "同志", "深夜の話し相手", "甘えたい", "影の守護者", "最強の刺客", "永遠のライバル", "喧嘩仲間", "裏切りの共犯者", "嫉妬", "だ~いすき♡", "軽蔑", "下僕", "裸の関係", "お抱え料理人"]
|
| 50 |
|
| 51 |
def get_profile_safe(client, actor):
|
| 52 |
try:
|
| 53 |
p = client.get_profile(actor=actor)
|
| 54 |
return {"handle": p.handle, "avatar": p.avatar or "https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png"}
|
| 55 |
-
except:
|
| 56 |
-
return {"handle": actor, "avatar": "https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png"}
|
| 57 |
|
| 58 |
def generate_catchphrase(kanji, posts_df):
|
| 59 |
adj_list = ADJECTIVES[:]
|
| 60 |
title_list = TITLES[:]
|
| 61 |
-
|
| 62 |
-
# 簡易的な特徴分析
|
| 63 |
if not posts_df.empty:
|
| 64 |
avg_hour = posts_df['hour'].mean()
|
| 65 |
if 0 <= avg_hour <= 5: adj_list.insert(0, "真夜中の")
|
| 66 |
-
elif 6 <= avg_hour <= 10: adj_list.insert(0, "早起きの")
|
| 67 |
-
|
| 68 |
if (posts_df['likes'] > 10).any(): title_list.insert(0, "カリスマ")
|
| 69 |
-
|
| 70 |
-
adj = random.choice(adj_list)
|
| 71 |
-
title = random.choice(title_list)
|
| 72 |
-
return f"── {adj} {kanji} を愛する {title} ──"
|
| 73 |
|
| 74 |
def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress()):
|
| 75 |
try:
|
|
@@ -78,7 +58,7 @@ def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress(
|
|
| 78 |
target_handle = target_id.replace('@', '').strip()
|
| 79 |
profile = client.get_profile(actor=target_handle)
|
| 80 |
|
| 81 |
-
posts_data, interaction_pairs
|
| 82 |
reply_counts = Counter()
|
| 83 |
user_info_cache = {target_handle: {"avatar": profile.avatar, "handle": target_handle}}
|
| 84 |
all_text = ""
|
|
@@ -89,50 +69,44 @@ def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress(
|
|
| 89 |
for f in response.feed:
|
| 90 |
p = f.post
|
| 91 |
if not isinstance(p, PostView) or p.author.handle != target_handle: continue
|
| 92 |
-
|
| 93 |
txt = getattr(p.record, 'text', "")
|
| 94 |
all_text += txt
|
| 95 |
-
post_id = p.uri.split('/')[-1]
|
| 96 |
-
post_url = f"https://bsky.app/profile/{target_handle}/post/{post_id}"
|
| 97 |
dt = pd.to_datetime(getattr(p.record, 'created_at')) + timedelta(hours=9)
|
|
|
|
| 98 |
|
| 99 |
-
posts_data.append({'text': txt, 'likes': p.like_count, 'reposts': p.repost_count, 'created_at': dt, 'url': post_url, 'score': p.like_count + p.repost_count, 'hour': dt.hour, 'weekday': dt.day_name()})
|
| 100 |
-
|
| 101 |
-
clean = re.sub(r'[!\?!?。\n\s]+$', '', txt)
|
| 102 |
-
if len(clean) >= 2:
|
| 103 |
-
end = clean[-3:];
|
| 104 |
-
if re.search(r'[ぁ-んーwWwW]$', end): ends_list.append(end)
|
| 105 |
-
|
| 106 |
if getattr(f, 'reply', None) and isinstance(f.reply.parent, PostView):
|
| 107 |
u_parent = f.reply.parent.author.handle
|
| 108 |
reply_counts[u_parent] += 1
|
| 109 |
interaction_pairs.append((target_handle, u_parent))
|
| 110 |
-
if u_parent not in user_info_cache:
|
| 111 |
-
user_info_cache[u_parent] = {"avatar": f.reply.parent.author.avatar, "handle": u_parent}
|
| 112 |
-
|
| 113 |
cursor = response.cursor
|
| 114 |
if not cursor: break
|
| 115 |
progress((i+1)/20)
|
| 116 |
|
| 117 |
df = pd.DataFrame(posts_data).drop_duplicates(subset=['text'])
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
# HTML
|
| 123 |
html = f"""<div class="dashboard-container">
|
| 124 |
-
<div class="card kanji-card"><small>あなたを象徴する一文字</small><div class="kanji-value">{rep_kanji}</div><div class="catchphrase">{
|
| 125 |
<div class="card stat-card"><div class="stat-label">🚀 総投稿数</div><div class="stat-value">{profile.posts_count}</div></div>
|
| 126 |
<div class="card stat-card"><div class="stat-label">🔍 今回の解析数</div><div class="stat-value">{len(df)}</div></div>
|
| 127 |
-
<div class="card"><div class="rank-header">👥 よく絡む人</div>
|
| 128 |
-
{"".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>
|
| 129 |
<div class="card" style="grid-column: 1 / -1;"><div class="rank-header">🏆 ベストポスト</div>"""
|
| 130 |
for _, r in df.sort_values('score', ascending=False).head(3).iterrows():
|
| 131 |
html += f"<a href='{r['url']}' target='_blank' class='post-link'><div class='best-post-item'>{r['text']}<div class='post-meta'>❤️ {r['likes']} 🔄 {r['reposts']} — {r['created_at'].strftime('%Y/%m/%d')}</div></div></a>"
|
| 132 |
html += "</div></div>"
|
| 133 |
|
| 134 |
-
|
|
|
|
|
|
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
# 相関図
|
| 137 |
nodes = list(set([target_handle] + [u for u, _ in reply_counts.most_common(8)]))
|
| 138 |
G = nx.Graph()
|
|
@@ -141,36 +115,35 @@ def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress(
|
|
| 141 |
pos = nx.spring_layout(G, k=1.5, seed=42)
|
| 142 |
cx, cy = pos[target_handle]
|
| 143 |
for n in pos: pos[n] = (pos[n][0] - cx, pos[n][1] - cy)
|
| 144 |
-
|
| 145 |
fig_net = go.Figure()
|
| 146 |
for e in G.edges(): fig_net.add_trace(go.Scatter(x=[pos[e[0]][0], pos[e[1]][0]], y=[pos[e[0]][1], pos[e[1]][1]], mode='lines', line=dict(color='#ccc', width=1), hoverinfo='none'))
|
| 147 |
-
|
| 148 |
-
node_imgs = []
|
| 149 |
-
node_texts = []
|
| 150 |
for n in nodes:
|
| 151 |
img = user_info_cache.get(n, {"avatar": ""})["avatar"]
|
| 152 |
node_imgs.append(dict(source=img, xref="x", yref="y", x=pos[n][0], y=pos[n][1], sizex=0.22, sizey=0.22, xanchor="center", yanchor="middle", layer="above"))
|
| 153 |
rel = "<br><b style='color:#ff4b4b;'>(本人)</b>" if n == target_handle else f"<br><span style='color:#0085ff;'>◆{random.choice(RELATIONSHIPS)}</span>"
|
| 154 |
node_texts.append(f"<b>{n}</b>{rel}")
|
| 155 |
-
|
| 156 |
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=50, color='rgba(0,0,0,0)'), textposition="bottom center", hoverinfo='none'))
|
| 157 |
fig_net.update_layout(images=node_imgs, showlegend=False, xaxis=dict(visible=False, range=[-1.3, 1.3]), yaxis=dict(visible=False, range=[-1.3, 1.3]), plot_bgcolor='white', height=650, margin=dict(t=20, b=20, l=0, r=0))
|
| 158 |
|
| 159 |
-
return html, fig_bar, fig_net, "解析完了!"
|
| 160 |
-
except Exception as e: return f"エラー: {e}", None, None, "失敗"
|
| 161 |
|
| 162 |
with gr.Blocks(css=CUSTOM_CSS) as demo:
|
| 163 |
gr.Markdown("# <p style='text-align:center; color:#0085ff; font-size:2rem;'>🦋 Bluesky Analyzer</p>")
|
| 164 |
with gr.Row():
|
| 165 |
with gr.Column():
|
| 166 |
m_id, m_pw, t_id = gr.Textbox(label="自分のハンドル"), gr.Textbox(label="アプリパスワード", type="password"), gr.Textbox(label="解析対象")
|
| 167 |
-
frq = gr.Radio(["
|
| 168 |
btn = gr.Button("解析実行", variant="primary")
|
| 169 |
st = gr.Markdown("認証情報を入力してください")
|
| 170 |
out_h = gr.HTML()
|
| 171 |
with gr.Tabs():
|
| 172 |
-
with gr.TabItem("📊 活動ログ"):
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
demo.launch()
|
|
|
|
| 31 |
button.primary { background: #0085ff !important; height: 60px !important; color: white !important; font-size: 1.2rem !important; }
|
| 32 |
"""
|
| 33 |
|
| 34 |
+
# 文言リスト
|
| 35 |
+
ADJECTIVES = ["光速の", "孤高の", "愛されし", "混沌の", "深淵なる", "情熱の", "癒やし系", "伝説の", "流浪の", "極限の", "無邪気な", "麗しき", "鉄壁の", "幻想的な", "反逆の", "神速の", "不屈の", "優雅な", "神秘の", "爆裂の", "純粋なる", "漆黒の", "黄金の", "悠久の", "戦慄の", "微笑みの", "虚空の", "驚異の", "禁断の", "幸福な", "真実の", "暁の", "宵闇の"]
|
| 36 |
+
TITLES = ["投稿者", "クリエイター", "エンターテイナー", "哲学者", "自由人", "守護神", "表現者", "観測者", "旅人", "語り部", "先駆者", "求道者", "職人", "策士", "魔術師", "支配者", "住人", "伝道師", "蒐集家", "冒険者", "導き手", "革命家", "異端児", "詩人", "鑑定士", "研究員", "巨匠", "隠者", "英雄", "新星", "重鎮"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
RELATIONSHIPS = ["家族", "恋人候補", "実は好き", "ペット", "宿命のライバル", "幼馴染", "憧れの人", "師匠", "弟子", "癒やし枠", "腐れ縁", "魂の双子", "前世での伴侶", "生涯の恩人", "運命の赤い糸", "行きつけの店の店主", "同志", "深夜の話し相手", "甘えたい", "影の守護者", "最強の刺客", "永遠のライバル", "喧嘩仲間", "裏切りの共犯者", "嫉妬", "だ~いすき♡", "軽蔑", "下僕", "裸の関係", "お抱え料理人"]
|
| 38 |
|
| 39 |
def get_profile_safe(client, actor):
|
| 40 |
try:
|
| 41 |
p = client.get_profile(actor=actor)
|
| 42 |
return {"handle": p.handle, "avatar": p.avatar or "https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png"}
|
| 43 |
+
except: return {"handle": actor, "avatar": "https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png"}
|
|
|
|
| 44 |
|
| 45 |
def generate_catchphrase(kanji, posts_df):
|
| 46 |
adj_list = ADJECTIVES[:]
|
| 47 |
title_list = TITLES[:]
|
|
|
|
|
|
|
| 48 |
if not posts_df.empty:
|
| 49 |
avg_hour = posts_df['hour'].mean()
|
| 50 |
if 0 <= avg_hour <= 5: adj_list.insert(0, "真夜中の")
|
|
|
|
|
|
|
| 51 |
if (posts_df['likes'] > 10).any(): title_list.insert(0, "カリスマ")
|
| 52 |
+
return f"── {random.choice(adj_list)} {kanji} を愛する {random.choice(title_list)} ──"
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress()):
|
| 55 |
try:
|
|
|
|
| 58 |
target_handle = target_id.replace('@', '').strip()
|
| 59 |
profile = client.get_profile(actor=target_handle)
|
| 60 |
|
| 61 |
+
posts_data, interaction_pairs = [], []
|
| 62 |
reply_counts = Counter()
|
| 63 |
user_info_cache = {target_handle: {"avatar": profile.avatar, "handle": target_handle}}
|
| 64 |
all_text = ""
|
|
|
|
| 69 |
for f in response.feed:
|
| 70 |
p = f.post
|
| 71 |
if not isinstance(p, PostView) or p.author.handle != target_handle: continue
|
|
|
|
| 72 |
txt = getattr(p.record, 'text', "")
|
| 73 |
all_text += txt
|
|
|
|
|
|
|
| 74 |
dt = pd.to_datetime(getattr(p.record, 'created_at')) + timedelta(hours=9)
|
| 75 |
+
posts_data.append({'text': txt, 'likes': p.like_count, 'reposts': p.repost_count, 'created_at': dt, 'url': f"https://bsky.app/profile/{target_handle}/post/{p.uri.split('/')[-1]}", 'score': p.like_count + p.repost_count, 'hour': dt.hour, 'weekday': dt.day_name()})
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
if getattr(f, 'reply', None) and isinstance(f.reply.parent, PostView):
|
| 78 |
u_parent = f.reply.parent.author.handle
|
| 79 |
reply_counts[u_parent] += 1
|
| 80 |
interaction_pairs.append((target_handle, u_parent))
|
| 81 |
+
if u_parent not in user_info_cache: user_info_cache[u_parent] = {"avatar": f.reply.parent.author.avatar, "handle": u_parent}
|
|
|
|
|
|
|
| 82 |
cursor = response.cursor
|
| 83 |
if not cursor: break
|
| 84 |
progress((i+1)/20)
|
| 85 |
|
| 86 |
df = pd.DataFrame(posts_data).drop_duplicates(subset=['text'])
|
| 87 |
+
rep_kanji = Counter(re.findall(r'[一-龠]', all_text)).most_common(1)[0][0] if re.findall(r'[一-龠]', all_text) else "魂"
|
| 88 |
+
|
| 89 |
+
# HTML生成
|
|
|
|
|
|
|
| 90 |
html = f"""<div class="dashboard-container">
|
| 91 |
+
<div class="card kanji-card"><small>あなたを象徴する一文字</small><div class="kanji-value">{rep_kanji}</div><div class="catchphrase">{generate_catchphrase(rep_kanji, df)}</div></div>
|
| 92 |
<div class="card stat-card"><div class="stat-label">🚀 総投稿数</div><div class="stat-value">{profile.posts_count}</div></div>
|
| 93 |
<div class="card stat-card"><div class="stat-label">🔍 今回の解析数</div><div class="stat-value">{len(df)}</div></div>
|
| 94 |
+
<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>
|
|
|
|
| 95 |
<div class="card" style="grid-column: 1 / -1;"><div class="rank-header">🏆 ベストポスト</div>"""
|
| 96 |
for _, r in df.sort_values('score', ascending=False).head(3).iterrows():
|
| 97 |
html += f"<a href='{r['url']}' target='_blank' class='post-link'><div class='best-post-item'>{r['text']}<div class='post-meta'>❤️ {r['likes']} 🔄 {r['reposts']} — {r['created_at'].strftime('%Y/%m/%d')}</div></div></a>"
|
| 98 |
html += "</div></div>"
|
| 99 |
|
| 100 |
+
# グラフ: 活動ログ
|
| 101 |
+
df_counts = df.set_index('created_at').resample({"週ごと":"W","月ごと":"M"}[freq_type]).size().reset_index(name='count')
|
| 102 |
+
fig_bar = px.bar(df_counts, x='created_at', y='count', color_discrete_sequence=['#0085ff'], template="plotly_white", title="投稿頻度")
|
| 103 |
|
| 104 |
+
# グラフ: ヒートマップ
|
| 105 |
+
week_order = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
| 106 |
+
heat_data = df.groupby(['weekday', 'hour']).size().unstack(fill_value=0).reindex(week_order).fillna(0)
|
| 107 |
+
heat_data.index = ['月','火','水','木','金','土','日']
|
| 108 |
+
fig_heat = px.imshow(heat_data, color_continuous_scale='Blues', title="活動時間帯(JST)")
|
| 109 |
+
|
| 110 |
# 相関図
|
| 111 |
nodes = list(set([target_handle] + [u for u, _ in reply_counts.most_common(8)]))
|
| 112 |
G = nx.Graph()
|
|
|
|
| 115 |
pos = nx.spring_layout(G, k=1.5, seed=42)
|
| 116 |
cx, cy = pos[target_handle]
|
| 117 |
for n in pos: pos[n] = (pos[n][0] - cx, pos[n][1] - cy)
|
|
|
|
| 118 |
fig_net = go.Figure()
|
| 119 |
for e in G.edges(): fig_net.add_trace(go.Scatter(x=[pos[e[0]][0], pos[e[1]][0]], y=[pos[e[0]][1], pos[e[1]][1]], mode='lines', line=dict(color='#ccc', width=1), hoverinfo='none'))
|
| 120 |
+
node_imgs, node_texts = [], []
|
|
|
|
|
|
|
| 121 |
for n in nodes:
|
| 122 |
img = user_info_cache.get(n, {"avatar": ""})["avatar"]
|
| 123 |
node_imgs.append(dict(source=img, xref="x", yref="y", x=pos[n][0], y=pos[n][1], sizex=0.22, sizey=0.22, xanchor="center", yanchor="middle", layer="above"))
|
| 124 |
rel = "<br><b style='color:#ff4b4b;'>(本人)</b>" if n == target_handle else f"<br><span style='color:#0085ff;'>◆{random.choice(RELATIONSHIPS)}</span>"
|
| 125 |
node_texts.append(f"<b>{n}</b>{rel}")
|
|
|
|
| 126 |
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=50, color='rgba(0,0,0,0)'), textposition="bottom center", hoverinfo='none'))
|
| 127 |
fig_net.update_layout(images=node_imgs, showlegend=False, xaxis=dict(visible=False, range=[-1.3, 1.3]), yaxis=dict(visible=False, range=[-1.3, 1.3]), plot_bgcolor='white', height=650, margin=dict(t=20, b=20, l=0, r=0))
|
| 128 |
|
| 129 |
+
return html, fig_bar, fig_heat, fig_net, "解析完了!"
|
| 130 |
+
except Exception as e: return f"エラー: {e}", None, None, None, "失敗"
|
| 131 |
|
| 132 |
with gr.Blocks(css=CUSTOM_CSS) as demo:
|
| 133 |
gr.Markdown("# <p style='text-align:center; color:#0085ff; font-size:2rem;'>🦋 Bluesky Analyzer</p>")
|
| 134 |
with gr.Row():
|
| 135 |
with gr.Column():
|
| 136 |
m_id, m_pw, t_id = gr.Textbox(label="自分のハンドル"), gr.Textbox(label="アプリパスワード", type="password"), gr.Textbox(label="解析対象")
|
| 137 |
+
frq = gr.Radio(["週ごと", "月ごと"], label="グラフ単位", value="週ごと")
|
| 138 |
btn = gr.Button("解析実行", variant="primary")
|
| 139 |
st = gr.Markdown("認証情報を入力してください")
|
| 140 |
out_h = gr.HTML()
|
| 141 |
with gr.Tabs():
|
| 142 |
+
with gr.TabItem("📊 活動ログ"):
|
| 143 |
+
out_b = gr.Plot()
|
| 144 |
+
out_heat = gr.Plot()
|
| 145 |
+
with gr.TabItem("🤝 魂の相関図"):
|
| 146 |
+
out_n = gr.Plot()
|
| 147 |
+
btn.click(analyze_and_output, inputs=[m_id, m_pw, t_id, frq], outputs=[out_h, out_b, out_heat, out_n, st])
|
| 148 |
|
| 149 |
demo.launch()
|