| 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 |
|
|
| |
| 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) |
| |
| 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']) |
| |
| 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) |