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)