profile / app.py
Nyanpre's picture
Update app.py
dbf10ba verified
import gradio as gr
import pandas as pd
from atproto import Client
from datetime import datetime, timedelta
from collections import Counter
import plotly.express as px
import plotly.graph_objects as go
import networkx as nx
import random
import re
from atproto_client.models.app.bsky.feed.defs import PostView
# --- スマートフォン最適化CSS ---
CUSTOM_CSS = """
.gradio-container { max-width: 100% !important; padding: 5px !important; background-color: #f0f7ff; }
.dashboard-container { display: flex; flex-wrap: wrap; gap: 10px; width: 100%; }
.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; }
.kanji-card { background: linear-gradient(135deg, #0085ff 0%, #00bfff 100%); color: white; text-align: center; width: 100%; }
.kanji-value { font-size: 4.5rem; font-weight: 900; line-height: 1; margin: 5px 0; }
.catchphrase { font-size: 1rem; font-weight: bold; opacity: 0.9; line-height: 1.3; }
.stat-row { display: flex; gap: 10px; width: 100%; }
.stat-card { flex: 1; text-align: center; padding: 10px; }
.stat-label { font-size: 0.8rem; color: #666; }
.stat-value { font-size: 1.4rem; font-weight: bold; color: #0085ff; }
.rank-header { font-size: 1rem; font-weight: bold; border-left: 4px solid #0085ff; padding-left: 8px; margin-bottom: 10px; }
.rank-entry { display: flex; align-items: center; gap: 10px; font-size: 0.9rem; padding: 5px 0; }
.rank-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; }
.best-post-item { font-size: 0.9rem; padding: 10px; margin-bottom: 8px; background: #f9f9f9; border-radius: 8px; border: 1px solid #eef; }
button.primary { height: 50px !important; font-size: 1.1rem !important; background: #0085ff !important; color: white !important; }
"""
ADJECTIVES = ["光速の", "孤高の", "愛されし", "混沌の", "深淵なる", "情熱の", "癒やし系", "伝説の", "流浪の", "極限の", "無邪気な", "麗しき", "鉄壁の", "幻想的な", "反逆の", "神速の", "不屈の", "優雅な", "神秘の", "爆裂の", "純粋なる", "漆黒の", "黄金の", "悠久の", "戦慄の", "微笑みの", "虚空の", "驚異の", "禁断の", "幸福な", "真実の", "暁の", "宵闇の"]
TITLES = ["投稿者", "クリエイター", "エンターテイナー", "哲学者", "自由人", "守護神", "表現者", "観測者", "旅人", "語り部", "先駆者", "求道者", "職人", "策士", "魔術師", "支配者", "住人", "伝道師", "蒐集家", "冒険者", "導き手", "革命家", "異端児", "詩人", "鑑定士", "研究員", "巨匠", "隠者", "英雄", "新星", "重鎮"]
RELATIONSHIPS = ["家族", "恋人候補", "実は好き", "ペット", "宿命のライバル", "幼馴染", "憧れの人", "師匠", "弟子", "癒やし枠", "腐れ縁", "魂の双子", "前世での伴侶", "生涯の恩人", "運命の赤い糸", "行きつけの店の店主", "同志", "深夜の話し相手", "甘えたい", "影の守護者", "最強の刺客", "永遠のライバル", "喧嘩仲間", "裏切りの共犯者", "嫉妬", "だ~いすき♡", "軽蔑", "下僕", "裸の関係", "お抱え料理人"]
def get_profile_safe(client, actor):
try:
p = client.get_profile(actor=actor)
return {"handle": p.handle, "avatar": p.avatar or "https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png"}
except: return {"handle": actor, "avatar": "https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png"}
def generate_catchphrase(kanji, posts_df):
adj_list = ADJECTIVES[:]
title_list = TITLES[:]
if not posts_df.empty:
avg_hour = posts_df['hour'].mean()
if 0 <= avg_hour <= 5: adj_list.insert(0, "真夜中の")
return f"── {random.choice(adj_list)} {kanji} を愛する {random.choice(title_list)} ──"
def analyze_and_output(my_id, my_pw, target_id, freq_type, progress=gr.Progress()):
try:
client = Client()
client.login(my_id.replace('@', '').strip(), my_pw.strip())
target_handle = target_id.replace('@', '').strip()
profile = client.get_profile(actor=target_handle)
posts_data, interaction_pairs = [], []
reply_counts = Counter()
user_info_cache = {target_handle: {"avatar": profile.avatar, "handle": target_handle}}
all_text = ""
total_posts = profile.posts_count
max_loops = min((total_posts // 100) + 2, 100) # 最大1万件まで
cursor = None
for i in range(max_loops):
response = client.get_author_feed(actor=profile.did, limit=100, cursor=cursor)
for f in response.feed:
p = f.post
if not isinstance(p, PostView) or p.author.handle != target_handle: continue
txt = getattr(p.record, 'text', "")
all_text += txt
dt = pd.to_datetime(getattr(p.record, 'created_at')) + timedelta(hours=9)
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()
})
if getattr(f, 'reply', None) and isinstance(f.reply.parent, PostView):
u_parent = f.reply.parent.author.handle
reply_counts[u_parent] += 1
interaction_pairs.append((target_handle, u_parent))
if u_parent not in user_info_cache:
user_info_cache[u_parent] = {"avatar": f.reply.parent.author.avatar, "handle": u_parent}
cursor = response.cursor
if not cursor: break
progress((i+1)/max_loops)
df = pd.DataFrame(posts_data)
if df.empty: return "投稿が見つかりませんでした", None, None, None, "失敗"
# 重複削除
df = df.drop_duplicates(subset=['text'])
# 重要:DatetimeIndexの再設定
df['created_at'] = pd.to_datetime(df['created_at'])
df = df.set_index('created_at').sort_index()
rep_kanji = Counter(re.findall(r'[一-龠]', all_text)).most_common(1)[0][0] if re.findall(r'[一-龠]', all_text) else "魂"
html = f"""<div class="dashboard-container">
<div class="card kanji-card"><small>あなたを象徴する一文字</small><div class="kanji-value">{rep_kanji}</div><div class="catchphrase">{generate_catchphrase(rep_kanji, df)}</div></div>
<div class="stat-row">
<div class="card stat-card"><div class="stat-label">🚀 総投稿</div><div class="stat-value">{total_posts}</div></div>
<div class="card stat-card"><div class="stat-label">🔍 解析数</div><div class="stat-value">{len(df)}</div></div>
</div>
<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>
<div class="card"><div class="rank-header">🏆 ベストポスト</div>"""
for _, r in df.sort_values('score', ascending=False).head(3).iterrows():
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>"
html += "</div></div>"
# 投稿頻度グラフ(エラー回避版)
freq_rule = {"週ごと": "W", "月ごと": "M"}[freq_type]
df_counts = df.resample(freq_rule).size().reset_index(name='count')
fig_bar = px.bar(df_counts, x='created_at', y='count', color_discrete_sequence=['#0085ff'], template="plotly_white", height=300)
fig_bar.update_layout(margin=dict(l=10, r=10, t=30, b=10), dragmode=False)
# ヒートマップ
week_order = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
heat_data = df.copy()
heat_data['weekday'] = heat_data.index.day_name()
heat_data['hour'] = heat_data.index.hour
heat_summary = heat_data.groupby(['weekday', 'hour']).size().unstack(fill_value=0).reindex(week_order).fillna(0)
heat_summary.index = ['月','火','水','木','金','土','日']
fig_heat = px.imshow(heat_summary, color_continuous_scale='Blues', height=300)
fig_heat.update_layout(margin=dict(l=10, r=10, t=30, b=10), dragmode=False)
# 相関図
nodes = list(set([target_handle] + [u for u, _ in reply_counts.most_common(7)]))
G = nx.Graph()
for u1, u2 in interaction_pairs:
if u1 in nodes and u2 in nodes: G.add_edge(u1, u2)
pos = nx.spring_layout(G, k=1.3, seed=42)
cx, cy = pos[target_handle]
for n in pos: pos[n] = (pos[n][0] - cx, pos[n][1] - cy)
fig_net = go.Figure()
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'))
node_imgs, node_texts, node_x, node_y = [], [], [], []
for n in nodes:
img = user_info_cache.get(n, {"avatar": ""})["avatar"]
nx_val, ny_val = pos[n]
node_x.append(nx_val)
node_y.append(ny_val)
node_imgs.append(dict(source=img, xref="x", yref="y", x=nx_val, y=ny_val, sizex=0.22, sizey=0.22, xanchor="center", yanchor="middle", layer="above"))
rel = "<br><b style='color:#ff4b4b;'>本人</b>" if n == target_handle else f"<br><span style='color:#0085ff;'>◆{random.choice(RELATIONSHIPS)}</span>"
display_name = n[:12] + '..' if len(n) > 12 else n
node_texts.append(f"<b>{display_name}</b>{rel}")
fig_net.add_trace(go.Scatter(
x=node_x, y=node_y, mode='markers+text', text=node_texts,
textposition="bottom center", textfont=dict(size=11, color='#333'),
marker=dict(size=40, color='rgba(0,0,0,0)'), hoverinfo='none'
))
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=500, margin=dict(t=10, b=10, l=0, r=0),
dragmode=False
)
return html, fig_bar, fig_heat, fig_net, "解析完了!"
except Exception as e: return f"エラー: {e}", None, None, None, "失敗"
with gr.Blocks() as demo:
gr.Markdown("# <p style='text-align:center; color:#0085ff; font-size:1.6rem;'>🦋 Bluesky Analyzer</p>")
with gr.Row():
with gr.Column():
m_id = gr.Textbox(label="自分のID", placeholder="example.bsky.social")
m_pw = gr.Textbox(label="パスワード", type="password")
t_id = gr.Textbox(label="解析対象", placeholder="target.bsky.social")
frq = gr.Radio(["週ごと", "月ごと"], label="グラフ単位", value="週ごと")
btn = gr.Button("解析実行", variant="primary")
st = gr.Markdown("<p style='text-align:center;'>情報を入力して実行してください</p>")
out_h = gr.HTML()
with gr.Tabs():
with gr.TabItem("📊 活動ログ"):
out_b = gr.Plot(label="投稿頻度")
out_heat = gr.Plot(label="時間帯ヒートマップ")
with gr.TabItem("🤝 魂の相関図"):
out_n = gr.Plot()
btn.click(analyze_and_output, inputs=[m_id, m_pw, t_id, frq], outputs=[out_h, out_b, out_heat, out_n, st])
demo.launch(css=CUSTOM_CSS)