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