File size: 12,153 Bytes
6c443f4 954ce8a a12dbd5 3e36bc4 fd5613d 9e0aff6 572efc1 9cb89c5 ea169df 9f755a1 9febb92 9f755a1 6c7b2ea 9f755a1 dbf10ba 6c7b2ea dbf10ba 9febb92 0715ca7 9872f43 23aa325 0fa4570 ea169df 0fa4570 0715ca7 954ce8a a5f594a 0715ca7 9872f43 7f2f202 954ce8a 6c443f4 5ca6f32 6c443f4 954ce8a 0715ca7 0fa4570 5ca6f32 2b87004 6c7b2ea dbf10ba 6c7b2ea 40a70e7 6c7b2ea 5ca6f32 9872f43 dbf10ba 9872f43 0fa4570 9872f43 dbf10ba 6c7b2ea fce3124 6da17c4 6c7b2ea fce3124 dbf10ba 0715ca7 a5f594a 0715ca7 9f755a1 6c7b2ea 9f755a1 0715ca7 9f755a1 6c7b2ea 5ca6f32 d465416 dbf10ba 9f755a1 dbf10ba 9872f43 a9ca70e 0715ca7 dbf10ba 0715ca7 a5f594a 6c7b2ea 9cb89c5 5ca6f32 9872f43 9f755a1 9872f43 a5f594a a9ca70e 6da17c4 a9ca70e 49e4070 9872f43 a9ca70e dbf10ba a9ca70e 0fa4570 0715ca7 0fa4570 a9ca70e dbf10ba aa4ebb0 5ca6f32 a9ca70e 0715ca7 0fa4570 dbf10ba 6c7b2ea 5ca6f32 6c7b2ea 9febb92 dbf10ba a9ca70e dbf10ba 0715ca7 a9ca70e 6c7b2ea 0715ca7 e1b9530 a9ca70e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 | 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) |