Update app.py
Browse files
app.py
CHANGED
|
@@ -3,6 +3,8 @@ import pandas as pd
|
|
| 3 |
from atproto import Client
|
| 4 |
import time
|
| 5 |
from datetime import datetime
|
|
|
|
|
|
|
| 6 |
|
| 7 |
def fetch_all_posts(my_id, my_pw, target_id, progress=gr.Progress()):
|
| 8 |
try:
|
|
@@ -15,8 +17,10 @@ def fetch_all_posts(my_id, my_pw, target_id, progress=gr.Progress()):
|
|
| 15 |
total_count_on_profile = profile.posts_count
|
| 16 |
|
| 17 |
posts = []
|
|
|
|
|
|
|
| 18 |
cursor = None
|
| 19 |
-
max_limit =
|
| 20 |
|
| 21 |
progress(0, desc="データ取得を開始します...")
|
| 22 |
|
|
@@ -25,124 +29,126 @@ def fetch_all_posts(my_id, my_pw, target_id, progress=gr.Progress()):
|
|
| 25 |
|
| 26 |
for feed_view in response.feed:
|
| 27 |
post = feed_view.post
|
|
|
|
|
|
|
| 28 |
if post.author.handle == target_handle:
|
| 29 |
rkey = post.uri.split('/')[-1]
|
| 30 |
post_url = f"https://bsky.app/profile/{target_handle}/post/{rkey}"
|
| 31 |
|
| 32 |
-
|
| 33 |
-
has_link = 1 if hasattr(post.record, 'facets') and post.record.facets else 0
|
| 34 |
-
|
| 35 |
posts.append({
|
| 36 |
'text': post.record.text,
|
| 37 |
'created_at': pd.to_datetime(post.record.created_at),
|
| 38 |
'likes': post.like_count,
|
| 39 |
'reposts': post.repost_count,
|
| 40 |
-
'url': post_url
|
| 41 |
-
'has_image': has_image,
|
| 42 |
-
'has_link': has_link
|
| 43 |
})
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
cursor = response.cursor
|
| 47 |
-
if not cursor: break
|
| 48 |
time.sleep(0.1)
|
| 49 |
|
| 50 |
-
if not posts:
|
| 51 |
-
return "投稿が見つかりませんでした", "", "", "データなし"
|
| 52 |
|
| 53 |
df = pd.DataFrame(posts)
|
| 54 |
|
| 55 |
-
# ---
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
days_active = max(days_active, 1) # 0日回避
|
| 61 |
-
avg_freq = len(df) / days_active
|
| 62 |
-
|
| 63 |
stats_html = f"""
|
| 64 |
-
<div style='display: grid; grid-template-columns: repeat(
|
| 65 |
-
<div style='background: #e3f2fd; padding: 15px; border-radius: 10px; text-align: center;
|
| 66 |
-
<div style='color: #1565c0; font-size: 0.8em;'>総
|
| 67 |
-
<div style='font-size: 1.5em; font-weight: bold;'>{total_count_on_profile}</div>
|
| 68 |
</div>
|
| 69 |
-
<div style='background: #f1f8e9; padding: 15px; border-radius: 10px; text-align: center;
|
| 70 |
-
<div style='color: #33691e; font-size: 0.8em;'>
|
| 71 |
-
<div style='font-size: 1.5em; font-weight: bold;'>{days_active}日</div>
|
| 72 |
</div>
|
| 73 |
-
<div style='background: #fff3e0; padding: 15px; border-radius: 10px; text-align: center;
|
| 74 |
-
<div style='color: #e65100; font-size: 0.8em;'>平均
|
| 75 |
-
<div style='font-size: 1.5em; font-weight: bold;'>{avg_freq:.2f} <span style='font-size: 0.5em;'>回/日</span></div>
|
| 76 |
</div>
|
| 77 |
</div>
|
| 78 |
"""
|
| 79 |
|
| 80 |
-
# ---
|
| 81 |
-
|
| 82 |
-
<div style='
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
| 88 |
|
| 89 |
-
# ---
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
</div>
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
</div>
|
| 101 |
"""
|
| 102 |
|
| 103 |
-
|
| 104 |
-
df['score'] = df['likes'] + df['reposts']
|
| 105 |
-
top_posts = df.sort_values(by='score', ascending=False).head(3)
|
| 106 |
-
best_posts_html = "<h3>🏆 ベストポスト Top 3</h3>"
|
| 107 |
-
for i, (_, row) in enumerate(top_posts.iterrows(), 1):
|
| 108 |
-
truncated_text = (row['text'][:80] + '...') if len(row['text']) > 80 else row['text']
|
| 109 |
-
best_posts_html += f"""
|
| 110 |
-
<div style='border: 1px solid #ddd; padding: 12px; border-radius: 8px; margin-bottom: 10px; background: #fff;'>
|
| 111 |
-
<div style='display: flex; justify-content: space-between; align-items: center;'>
|
| 112 |
-
<span style='background: #ffd700; padding: 2px 8px; border-radius: 4px; font-weight: bold;'>第{i}位</span>
|
| 113 |
-
<span style='color: #666; font-size: 0.8em;'>❤️ {row['likes']} 🔄 {row['reposts']}</span>
|
| 114 |
-
</div>
|
| 115 |
-
<p style='margin: 8px 0; font-size: 0.9em; color: #333;'>{truncated_text}</p>
|
| 116 |
-
<a href='{row['url']}' target='_blank' style='color: #0085ff; text-decoration: none; font-size: 0.8em;'>🔗 投稿を見る</a>
|
| 117 |
-
</div>
|
| 118 |
-
"""
|
| 119 |
-
|
| 120 |
-
return stats_html, first_post_html, content_html + best_posts_html, "解析完了!"
|
| 121 |
|
| 122 |
except Exception as e:
|
| 123 |
-
return f"
|
| 124 |
|
| 125 |
-
# --- UI
|
| 126 |
-
with gr.Blocks(
|
| 127 |
-
gr.Markdown("# 🦋 Bluesky 自己分析
|
| 128 |
-
|
| 129 |
with gr.Row():
|
| 130 |
with gr.Column(scale=1):
|
| 131 |
-
in_id = gr.Textbox(label="自分の
|
| 132 |
in_pw = gr.Textbox(label="アプリパスワード", type="password")
|
| 133 |
-
in_target = gr.Textbox(label="解析
|
| 134 |
-
btn = gr.Button("分析
|
| 135 |
-
|
| 136 |
-
|
| 137 |
with gr.Column(scale=2):
|
| 138 |
-
out_stats = gr.HTML()
|
| 139 |
-
|
| 140 |
-
|
| 141 |
|
| 142 |
-
btn.click(
|
| 143 |
-
fn=fetch_all_posts,
|
| 144 |
-
inputs=[in_id, in_pw, in_target],
|
| 145 |
-
outputs=[out_stats, out_first, out_main, out_status]
|
| 146 |
-
)
|
| 147 |
|
| 148 |
demo.launch()
|
|
|
|
| 3 |
from atproto import Client
|
| 4 |
import time
|
| 5 |
from datetime import datetime
|
| 6 |
+
import re
|
| 7 |
+
from collections import Counter
|
| 8 |
|
| 9 |
def fetch_all_posts(my_id, my_pw, target_id, progress=gr.Progress()):
|
| 10 |
try:
|
|
|
|
| 17 |
total_count_on_profile = profile.posts_count
|
| 18 |
|
| 19 |
posts = []
|
| 20 |
+
hashtags = []
|
| 21 |
+
interactions = []
|
| 22 |
cursor = None
|
| 23 |
+
max_limit = 10000
|
| 24 |
|
| 25 |
progress(0, desc="データ取得を開始します...")
|
| 26 |
|
|
|
|
| 29 |
|
| 30 |
for feed_view in response.feed:
|
| 31 |
post = feed_view.post
|
| 32 |
+
|
| 33 |
+
# 本人の投稿のみを対象
|
| 34 |
if post.author.handle == target_handle:
|
| 35 |
rkey = post.uri.split('/')[-1]
|
| 36 |
post_url = f"https://bsky.app/profile/{target_handle}/post/{rkey}"
|
| 37 |
|
| 38 |
+
# 1. 基本データ
|
|
|
|
|
|
|
| 39 |
posts.append({
|
| 40 |
'text': post.record.text,
|
| 41 |
'created_at': pd.to_datetime(post.record.created_at),
|
| 42 |
'likes': post.like_count,
|
| 43 |
'reposts': post.repost_count,
|
| 44 |
+
'url': post_url
|
|
|
|
|
|
|
| 45 |
})
|
| 46 |
+
|
| 47 |
+
# 2. ハッシュタグ抽出 (facets または テキストから)
|
| 48 |
+
tags = re.findall(r'#(\w+)', post.record.text)
|
| 49 |
+
hashtags.extend(tags)
|
| 50 |
+
|
| 51 |
+
# 3. メンション・リプライ相手の抽出
|
| 52 |
+
# リプライ
|
| 53 |
+
if feed_view.reply and feed_view.reply.parent:
|
| 54 |
+
parent_author = feed_view.reply.parent.author.handle
|
| 55 |
+
if parent_author != target_handle:
|
| 56 |
+
interactions.append(parent_author)
|
| 57 |
+
|
| 58 |
+
# メンション (facetsから)
|
| 59 |
+
if post.record.facets:
|
| 60 |
+
for facet in post.record.facets:
|
| 61 |
+
for feature in facet.features:
|
| 62 |
+
if hasattr(feature, 'did'):
|
| 63 |
+
# DIDからハンドル名を取得するのは重いため、一旦カウント用
|
| 64 |
+
# ※ここでは簡易化のためリプライ相手を主軸にします
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
progress(min(len(posts) / max_limit, 0.95), desc=f"{len(posts)}件分析中...")
|
| 68 |
cursor = response.cursor
|
| 69 |
+
if not cursor or len(posts) >= max_limit: break
|
| 70 |
time.sleep(0.1)
|
| 71 |
|
| 72 |
+
if not posts: return "投稿なし", "", "", "終了"
|
|
|
|
| 73 |
|
| 74 |
df = pd.DataFrame(posts)
|
| 75 |
|
| 76 |
+
# --- 統計計算 ---
|
| 77 |
+
first_post = df.iloc[-1]
|
| 78 |
+
days_active = max((datetime.now().replace(tzinfo=None) - first_post['created_at'].replace(tzinfo=None)).days, 1)
|
| 79 |
+
|
| 80 |
+
# --- A. 基本スタッツ (HTML) ---
|
|
|
|
|
|
|
|
|
|
| 81 |
stats_html = f"""
|
| 82 |
+
<div style='display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 20px;'>
|
| 83 |
+
<div style='background: #e3f2fd; padding: 15px; border-radius: 10px; text-align: center;'>
|
| 84 |
+
<div style='color: #1565c0; font-size: 0.8em;'>総ポスト</div><div style='font-size: 1.5em; font-weight: bold;'>{total_count_on_profile}</div>
|
|
|
|
| 85 |
</div>
|
| 86 |
+
<div style='background: #f1f8e9; padding: 15px; border-radius: 10px; text-align: center;'>
|
| 87 |
+
<div style='color: #33691e; font-size: 0.8em;'>継続日数</div><div style='font-size: 1.5em; font-weight: bold;'>{days_active}日</div>
|
|
|
|
| 88 |
</div>
|
| 89 |
+
<div style='background: #fff3e0; padding: 15px; border-radius: 10px; text-align: center;'>
|
| 90 |
+
<div style='color: #e65100; font-size: 0.8em;'>1日平均</div><div style='font-size: 1.5em; font-weight: bold;'>{len(df)/days_active:.2f}</div>
|
|
|
|
| 91 |
</div>
|
| 92 |
</div>
|
| 93 |
"""
|
| 94 |
|
| 95 |
+
# --- B. ハッシュタグランキング ---
|
| 96 |
+
tag_counts = Counter(hashtags).most_common(5)
|
| 97 |
+
tag_html = "<h4>#️⃣ よく使うタグ</h4><div style='display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 20px;'>"
|
| 98 |
+
if tag_counts:
|
| 99 |
+
for tag, count in tag_counts:
|
| 100 |
+
tag_html += f"<span style='background: #eee; padding: 4px 10px; border-radius: 15px; font-size: 0.9em;'>#{tag} ({count})</span>"
|
| 101 |
+
else:
|
| 102 |
+
tag_html += "<span style='color: #999;'>なし</span>"
|
| 103 |
+
tag_html += "</div>"
|
| 104 |
|
| 105 |
+
# --- C. 仲良しユーザーランキング ---
|
| 106 |
+
user_counts = Counter(interactions).most_common(5)
|
| 107 |
+
user_html = "<h4>🤝 よく交流する相手</h4><div style='margin-bottom: 20px;'>"
|
| 108 |
+
if user_counts:
|
| 109 |
+
for user, count in user_counts:
|
| 110 |
+
user_html += f"<div style='font-size: 0.9em; margin-bottom: 3px;'>@{user} <span style='color: #666;'>({count}回)</span></div>"
|
| 111 |
+
else:
|
| 112 |
+
user_html += "<div style='color: #999;'>なし</div>"
|
| 113 |
+
user_html += "</div>"
|
| 114 |
+
|
| 115 |
+
# --- D. 初投稿 & ベストポスト ---
|
| 116 |
+
top_post = df.sort_values('likes', ascending=False).iloc[0]
|
| 117 |
+
|
| 118 |
+
main_content_html = f"""
|
| 119 |
+
<div style='background: #fff; border: 1px solid #0085ff; padding: 15px; border-radius: 12px; margin-bottom: 15px;'>
|
| 120 |
+
<div style='color: #0085ff; font-weight: bold;'>🌱 分析範囲内の最古ポスト</div>
|
| 121 |
+
<p style='font-size: 0.9em;'>{first_post['text']}</p>
|
| 122 |
+
<a href='{first_post['url']}' target='_blank' style='font-size: 0.8em;'>🔗 投稿を見る</a>
|
| 123 |
+
</div>
|
| 124 |
+
<div style='background: #fff; border: 1px solid #ff4081; padding: 15px; border-radius: 12px;'>
|
| 125 |
+
<div style='color: #ff4081; font-weight: bold;'>🏆 最大いいねポスト</div>
|
| 126 |
+
<p style='font-size: 0.9em;'>{top_post['text']}</p>
|
| 127 |
+
<span style='font-size: 0.8em; color: #666;'>❤️ {top_post['likes']}</span> |
|
| 128 |
+
<a href='{top_post['url']}' target='_blank' style='font-size: 0.8em;'>🔗 投稿を見る</a>
|
| 129 |
</div>
|
| 130 |
"""
|
| 131 |
|
| 132 |
+
return stats_html, tag_html + user_html, main_content_html, "解析完了!"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
except Exception as e:
|
| 135 |
+
return f"エラー: {str(e)}", "", "", "失敗"
|
| 136 |
|
| 137 |
+
# --- UI ---
|
| 138 |
+
with gr.Blocks(title="Bluesky Summary") as demo:
|
| 139 |
+
gr.Markdown("# 🦋 Bluesky 自己分析くん")
|
|
|
|
| 140 |
with gr.Row():
|
| 141 |
with gr.Column(scale=1):
|
| 142 |
+
in_id = gr.Textbox(label="自分のハンドル", placeholder="example.bsky.social")
|
| 143 |
in_pw = gr.Textbox(label="アプリパスワード", type="password")
|
| 144 |
+
in_target = gr.Textbox(label="解析したいID")
|
| 145 |
+
btn = gr.Button("分析開始", variant="primary")
|
| 146 |
+
status = gr.Textbox(label="状況")
|
|
|
|
| 147 |
with gr.Column(scale=2):
|
| 148 |
+
out_stats = gr.HTML() # 基本数字
|
| 149 |
+
out_rank = gr.HTML() # タグ・ユーザー
|
| 150 |
+
out_posts = gr.HTML() # 注目ポスト
|
| 151 |
|
| 152 |
+
btn.click(fn=fetch_all_posts, inputs=[in_id, in_pw, in_target], outputs=[out_stats, out_rank, out_posts, status])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
demo.launch()
|