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"""
あなたを象徴する一文字
{rep_kanji}
{generate_catchphrase(rep_kanji, df)}
🚀 総投稿
{total_posts}
🔍 解析数
{len(df)}
👥 よく絡む人
{"".join([f"
{h}{c}回
" for h,c in reply_counts.most_common(3)])}
🏆 ベストポスト
""" for _, r in df.sort_values('score', ascending=False).head(3).iterrows(): html += f"
{r['text'][:80]}...
❤️ {r['likes']} 🔄 {r['reposts']}
" html += "
" # 投稿頻度グラフ(エラー回避版) 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 = "
本人" if n == target_handle else f"
◆{random.choice(RELATIONSHIPS)}" display_name = n[:12] + '..' if len(n) > 12 else n node_texts.append(f"{display_name}{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("#

🦋 Bluesky Analyzer

") 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("

情報を入力して実行してください

") 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)