#!/usr/bin/env python3
import html
import json
import os
import re
import sys
import argparse
import urllib.request
import urllib.error
from collections import Counter
from datetime import datetime, timezone, timedelta
TIPS = [
"Hashtagy fungují jako klíčová slova – používej je a ostatní tě snáz najdou.",
"Pomocí seznamů si můžeš organizovat sledované účty do tematických skupin.",
"Příspěvky s viditelností \"Pouze sledující\" vidí jen tvoji sledující, ne celý fediverse.",
"Filtrovat nežádoucí obsah lze přes Nastavení → Filtry.",
"Zmínit někoho funguje i napříč instancemi – stačí napsat @uzivatel@instance.tld.",
"Záložky ti umožní uložit toot na později – nikdo o tom neví.",
"Na Mamutovo.cz máš limit 2000 znaků – víc než dost na dlouhý příspěvek.",
"Zvýrazněné hashtagy v profilu pomáhají ostatním tě najít podle zájmů.",
"Boost = sdílení. Pomáhá dobrému obsahu se šířit po fediversu.",
"Obsah za varováním (CW) vidí jen ti, kdo kliknou – hodí se na citlivá témata.",
"Sleduj hashtag místo účtu – přes fedi.mamutovo.cz najdeš české účty.",
"Fediverse není jen Mastodon – komunikuješ i s uživateli Pixelfedu, PeerTube a dalších.",
"V profilu můžeš zvýraznit oblíbené účty – ostatní je uvidí přímo u tebe.",
]
def load_env(path=".env"):
env = {}
try:
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
env[key.strip()] = val.strip().strip('"').strip("'")
except FileNotFoundError:
pass
return env
def api_get(url, token=None):
headers = {"Authorization": f"Bearer {token}"} if token else {}
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
print(f"HTTP {e.code} při volání {url}: {e.read().decode()}", file=sys.stderr)
raise
except urllib.error.URLError as e:
print(f"Chyba sítě při volání {url}: {e.reason}", file=sys.stderr)
raise
def api_post(url, token, data):
body = json.dumps(data).encode()
req = urllib.request.Request(
url,
data=body,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
print(f"HTTP {e.code} při odesílání tootu: {e.read().decode()}", file=sys.stderr)
raise
except urllib.error.URLError as e:
print(f"Chyba sítě při odesílání tootu: {e.reason}", file=sys.stderr)
raise
def get_measures(base_url, admin_token, date_from, date_to, keys=None):
if keys is None:
keys = ["new_users", "active_users", "interactions"]
url = f"{base_url}/api/v1/admin/measures"
payload = {
"keys": keys,
"start_at": date_from.isoformat(),
"end_at": date_to.isoformat(),
}
body = json.dumps(payload).encode()
req = urllib.request.Request(
url,
data=body,
headers={
"Authorization": f"Bearer {admin_token}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
print(f"HTTP {e.code} při volání measures API: {e.read().decode()}", file=sys.stderr)
raise
except urllib.error.URLError as e:
print(f"Chyba sítě při volání measures API: {e.reason}", file=sys.stderr)
raise
def truncate(text, max_chars=100):
text = re.sub(r"<[^>]+>", " ", text)
text = html.unescape(text)
text = re.sub(r"\s+", " ", text).strip()
if len(text) <= max_chars:
return text
cut = text[:max_chars].rsplit(" ", 1)[0].rstrip(".,!?;:")
return cut + "…"
def format_date_cs(dt):
months = [
"ledna", "února", "března", "dubna", "května", "června",
"července", "srpna", "září", "října", "listopadu", "prosince",
]
return f"{dt.day}. {months[dt.month - 1]}"
def format_month_cs(dt):
months = [
"leden", "únor", "březen", "duben", "květen", "červen",
"červenec", "srpen", "září", "říjen", "listopad", "prosinec",
]
return f"{months[dt.month - 1]} {dt.year}"
def build_monthly_toot(measures_data, tags, top_tooty, date_to, prev_stats, instance_info,
total_count=0, top_author=None, newest_account=None, top_links=None,
media_count=None, hourly_count=None, most_discussed=None):
stats = {m["key"]: int(m["total"]) for m in measures_data}
new_users = stats.get("new_users", 0)
active_users = stats.get("active_users", 0)
interactions = stats.get("interactions", 0)
def fmt_diff(current, key, long=False):
if not prev_stats or key not in prev_stats:
return ""
d = current - prev_stats[key]
sign = "+" if d >= 0 else ""
suffix = " oproti minulému měsíci" if long else ""
return f" ({sign}{d}{suffix})"
hashtags = " ".join(f"#{t['name']}" for t in tags[:5]) if tags else "(žádné)"
inst_stats = instance_info.get("stats", {}) if instance_info else {}
user_count_val = inst_stats.get("user_count", 0)
user_count = user_count_val if user_count_val else "?"
domain_count = inst_stats.get("domain_count", "?")
author_line = f"✍️ Top přispěvatel: @{top_author['acct']} ({top_author['count']} tootů)\n" if top_author else ""
newest_line = f"👋 Nový účet: @{newest_account['acct']}\n" if newest_account else ""
milestone_line = (
f"🎉 Milník: Mamutovo dosáhlo {user_count_val} uživatelů!\n"
if user_count_val and user_count_val % 10 == 0 else ""
)
extra = author_line + newest_line + milestone_line
if top_tooty:
blocks = "\n\n".join(
f"🐘 @{s['acct']}\n\"{truncate(s['text'], 80).replace(chr(10), ' ')}\"\n🔁 {s['reblogs']} ⭐ {s['favourites']}\n🔗 {s.get('url', '')}"
for s in top_tooty
)
tooty_sekce = f"\n🌟 Tooty měsíce:\n\n{blocks}"
else:
tooty_sekce = ""
if top_links:
link_lines = "\n".join(
f"🔗 {link.get('title', link.get('url', ''))}\n{link.get('provider_name', '')} · {link.get('url', '')}"
for link in top_links[:3]
)
links_sekce = f"\n🌐 Top odkazy měsíce:\n\n{link_lines}"
else:
links_sekce = ""
if media_count and media_count.get("total"):
videos = media_count.get("video", 0) + media_count.get("gifv", 0)
media_line = f"📸 Sdílená média: {media_count['total']} ({media_count.get('image', 0)} fotek, {videos} videí)\n"
else:
media_line = ""
if hourly_count and any(hourly_count.values()):
peak_h = max(hourly_count, key=lambda h: hourly_count[h])
peak_line = f"⏰ Nejaktivnější čas: {int(peak_h):02d}:00–{(int(peak_h) + 1) % 24:02d}:00 ({hourly_count[peak_h]} tootů)\n"
else:
peak_line = ""
if most_discussed:
discussed_sekce = (
f"\n💬 Nejdiskutovanější toot:\n"
f"@{most_discussed['acct']} ({most_discussed['replies']} odpovědí)\n"
f"\"{truncate(most_discussed['text'], 80).replace(chr(10), ' ')}\"\n"
f"🔗 {most_discussed.get('url', '')}"
)
else:
discussed_sekce = ""
return (
f"🐘 Měsíční přehled Mamutovo.cz\n"
f"📅 {format_month_cs(date_to)}\n"
f"\n"
f"👥 Noví uživatelé: {new_users}{fmt_diff(new_users, 'new_users', long=True)}\n"
f"✅ Aktivní uživatelé: {active_users}{fmt_diff(active_users, 'active_users')}\n"
f"📝 Interakce: {interactions}{fmt_diff(interactions, 'interactions')}\n"
f"📝 Tooty měsíce: {total_count}\n"
f"{media_line}"
f"{peak_line}"
f"\n"
f"📊 Celkem uživatelů: {user_count}\n"
f"🌐 Federovaných instancí: {domain_count}\n"
f"\n"
f"🔥 Top hashtagy měsíce:\n"
f"{hashtags}\n"
f"\n"
f"{extra}"
f"{tooty_sekce}"
f"{links_sekce}"
f"{discussed_sekce}"
)
def build_toot(measures_data, tags, top_tooty, date_from, date_to, week_number,
total_count=0, top_author=None, newest_account=None, user_count=0, top_links=None,
prev_stats=None, media_count=None, hourly_count=None, most_discussed=None):
stats = {m["key"]: int(m["total"]) for m in measures_data}
new_users = stats.get("new_users", 0)
active_users = stats.get("active_users", 0)
interactions = stats.get("interactions", 0)
def fmt_diff(current, key):
if not prev_stats or key not in prev_stats:
return ""
d = current - prev_stats[key]
sign = "+" if d >= 0 else ""
return f" ({sign}{d})"
hashtags = " ".join(f"#{t['name']}" for t in tags[:3]) if tags else "(žádné)"
tip = TIPS[week_number % len(TIPS)]
if top_tooty:
blocks = "\n\n".join(
f"🐘 @{s['acct']}\n\"{truncate(s['text'], 80).replace(chr(10), ' ')}\"\n🔁 {s['reblogs']} ⭐ {s['favourites']}\n🔗 {s.get('url', '')}"
for s in top_tooty
)
toot_tyden = f"🌟 Tooty týdne:\n\n{blocks}\n\n"
else:
toot_tyden = ""
author_line = f"✍️ Top přispěvatel: @{top_author['acct']} ({top_author['count']} tootů)\n" if top_author else ""
newest_line = f"👋 Nový účet: @{newest_account['acct']}\n" if newest_account else ""
milestone_line = f"🎉 Milník: Mamutovo dosáhlo {user_count} uživatelů!\n" if user_count and user_count % 10 == 0 else ""
extra = author_line + newest_line + milestone_line
if top_links:
link_lines = "\n".join(
f"🔗 {link.get('title', link.get('url', ''))}\n{link.get('provider_name', '')} · {link.get('url', '')}"
for link in top_links[:3]
)
links_sekce = f"🌐 Top odkazy týdne:\n\n{link_lines}\n\n"
else:
links_sekce = ""
if media_count and media_count.get("total"):
videos = media_count.get("video", 0) + media_count.get("gifv", 0)
media_line = f"📸 Sdílená média: {media_count['total']} ({media_count.get('image', 0)} fotek, {videos} videí)\n"
else:
media_line = ""
if hourly_count and any(hourly_count.values()):
peak_h = max(hourly_count, key=lambda h: hourly_count[h])
peak_line = f"⏰ Nejaktivnější čas: {int(peak_h):02d}:00–{(int(peak_h) + 1) % 24:02d}:00 ({hourly_count[peak_h]} tootů)\n"
else:
peak_line = ""
if most_discussed:
discussed_sekce = (
f"💬 Nejdiskutovanější toot:\n"
f"@{most_discussed['acct']} ({most_discussed['replies']} odpovědí)\n"
f"\"{truncate(most_discussed['text'], 80).replace(chr(10), ' ')}\"\n"
f"🔗 {most_discussed.get('url', '')}\n\n"
)
else:
discussed_sekce = ""
date_from_str = format_date_cs(date_from)
date_to_str = format_date_cs(date_to)
year = date_to.year
return (
f"🐘 Týdenní přehled Mamutovo.cz\n"
f"📅 {date_from_str} – {date_to_str} {year}\n"
f"\n"
f"👥 Noví uživatelé: {new_users}{fmt_diff(new_users, 'new_users')}\n"
f"✅ Aktivní uživatelé: {active_users}{fmt_diff(active_users, 'active_users')}\n"
f"📝 Interakce: {interactions}{fmt_diff(interactions, 'interactions')}\n"
f"📝 Tooty týdne: {total_count}\n"
f"{media_line}"
f"{peak_line}"
f"\n"
f"🔥 Populární hashtagy:\n"
f"{hashtags}\n"
f"\n"
f"{toot_tyden}"
f"{links_sekce}"
f"{discussed_sekce}"
f"{extra}"
f"💡 Tip týdne: {tip}"
)
def load_tags_from_data(date_to, days, top_n):
counts = Counter()
found_any = False
for i in range(days):
day = (date_to - timedelta(days=i)).strftime("%Y-%m-%d")
path = os.path.join("data", f"{day}.json")
try:
with open(path, encoding="utf-8") as f:
file_data = json.load(f)
tags = file_data.get("tags")
if tags:
found_any = True
for tag in tags:
counts[tag] += 1
except FileNotFoundError:
pass
if not found_any:
return None
return [{"name": tag} for tag, _ in counts.most_common(top_n)]
def load_tooty_from_data(date_to, days):
seen = set()
all_tooty = []
for i in range(days):
day = (date_to - timedelta(days=i)).strftime("%Y-%m-%d")
path = os.path.join("data", f"{day}.json")
try:
with open(path, encoding="utf-8") as f:
file_data = json.load(f)
for s in file_data.get("top", []):
key = (s.get("acct", ""), s.get("text", ""))
if key not in seen:
seen.add(key)
all_tooty.append(s)
except FileNotFoundError:
pass
all_tooty.sort(key=lambda s: s.get("score", 0), reverse=True)
return all_tooty[:3]
def load_total_count_from_data(date_to, days):
total = 0
for i in range(days):
day = (date_to - timedelta(days=i)).strftime("%Y-%m-%d")
path = os.path.join("data", f"{day}.json")
try:
with open(path, encoding="utf-8") as f:
file_data = json.load(f)
total += file_data.get("total_count", 0)
except FileNotFoundError:
pass
return total
def load_top_author_from_data(date_to, days):
counts = Counter()
for i in range(days):
day = (date_to - timedelta(days=i)).strftime("%Y-%m-%d")
path = os.path.join("data", f"{day}.json")
try:
with open(path, encoding="utf-8") as f:
file_data = json.load(f)
for acct, n in file_data.get("authors_count", {}).items():
counts[acct] += n
except FileNotFoundError:
pass
if not counts:
return None
acct, n = counts.most_common(1)[0]
return {"acct": acct, "count": n}
def load_top_links_from_data(date_to, days):
seen = set()
result = []
for i in range(days):
day = (date_to - timedelta(days=i)).strftime("%Y-%m-%d")
path = os.path.join("data", f"{day}.json")
try:
with open(path, encoding="utf-8") as f:
file_data = json.load(f)
for link in file_data.get("top_links", []):
url = link.get("url", "")
if url and url not in seen:
seen.add(url)
result.append(link)
if len(result) >= 3:
return result
except FileNotFoundError:
pass
return result
def load_media_count_from_data(date_to, days):
totals = {"image": 0, "video": 0, "gifv": 0, "audio": 0, "total": 0}
for i in range(days):
day = (date_to - timedelta(days=i)).strftime("%Y-%m-%d")
path = os.path.join("data", f"{day}.json")
try:
with open(path, encoding="utf-8") as f:
file_data = json.load(f)
mc = file_data.get("media_count", {})
for key in totals:
totals[key] += mc.get(key, 0)
except FileNotFoundError:
pass
return totals
def load_hourly_count_from_data(date_to, days):
totals = {str(h): 0 for h in range(24)}
for i in range(days):
day = (date_to - timedelta(days=i)).strftime("%Y-%m-%d")
path = os.path.join("data", f"{day}.json")
try:
with open(path, encoding="utf-8") as f:
file_data = json.load(f)
hc = file_data.get("hourly_count", {})
for h in totals:
totals[h] += hc.get(h, 0)
except FileNotFoundError:
pass
return totals
def load_most_discussed_from_data(date_to, days):
best = None
for i in range(days):
day = (date_to - timedelta(days=i)).strftime("%Y-%m-%d")
path = os.path.join("data", f"{day}.json")
try:
with open(path, encoding="utf-8") as f:
file_data = json.load(f)
md = file_data.get("most_discussed")
if md and (best is None or md.get("replies", 0) > best.get("replies", 0)):
best = md
except FileNotFoundError:
pass
return best
def load_newest_account_from_data(date_to, days):
for i in range(days):
day = (date_to - timedelta(days=i)).strftime("%Y-%m-%d")
path = os.path.join("data", f"{day}.json")
try:
with open(path, encoding="utf-8") as f:
file_data = json.load(f)
acc = file_data.get("newest_account")
if acc:
return acc
except FileNotFoundError:
pass
return None
def main():
parser = argparse.ArgumentParser(description="Statistiky Mamutovo.cz")
parser.add_argument("--dry-run", action="store_true", help="Pouze vypíše toot, neodešle")
parser.add_argument("--monthly", action="store_true", help="Měsíční přehled místo týdenního")
args = parser.parse_args()
env = {**load_env(), **os.environ}
for var in ("NOVINKY_TOKEN", "INSTANCE_URL", "STATS_TOKEN"):
if not env.get(var):
print(f"Chybí proměnná prostředí: {var}", file=sys.stderr)
sys.exit(1)
novinky_token = env["NOVINKY_TOKEN"]
admin_token = env["STATS_TOKEN"]
base_url = env["INSTANCE_URL"].rstrip("/")
now = datetime.now(timezone.utc)
date_to = now.replace(hour=0, minute=0, second=0, microsecond=0)
if args.monthly:
date_from = date_to - timedelta(days=30)
try:
measures_data = get_measures(
base_url, admin_token, date_from, date_to,
keys=["new_users", "active_users", "interactions"],
)
except Exception:
sys.exit(1)
tags = load_tags_from_data(date_to, 30, 5)
if tags is None:
try:
tags = api_get(f"{base_url}/api/v1/trends/tags?limit=5", admin_token)
except Exception:
tags = []
try:
instance_info = api_get(f"{base_url}/api/v1/instance")
except Exception:
instance_info = {}
monthly_stats_path = os.path.join("data", "monthly_stats.json")
prev_stats = None
try:
with open(monthly_stats_path, encoding="utf-8") as f:
prev_stats = json.load(f)
except FileNotFoundError:
pass
top_tooty = load_tooty_from_data(date_to, 30)
total_count = load_total_count_from_data(date_to, 30)
top_author = load_top_author_from_data(date_to, 30)
newest_account = load_newest_account_from_data(date_to, 30)
top_links = load_top_links_from_data(date_to, 30)
media_count = load_media_count_from_data(date_to, 30)
hourly_count = load_hourly_count_from_data(date_to, 30)
most_discussed = load_most_discussed_from_data(date_to, 30)
toot = build_monthly_toot(
measures_data, tags, top_tooty, date_to, prev_stats, instance_info,
total_count, top_author, newest_account, top_links, media_count, hourly_count,
most_discussed,
)
if args.dry_run:
print(toot)
return
try:
result = api_post(
f"{base_url}/api/v1/statuses",
novinky_token,
{"status": toot, "visibility": "public"},
)
print(f"Toot odeslán: {result.get('url', '(bez URL)')}")
except Exception:
sys.exit(1)
cur_stats = {m["key"]: int(m["total"]) for m in measures_data}
os.makedirs("data", exist_ok=True)
with open(monthly_stats_path, "w", encoding="utf-8") as f:
json.dump({
"date": date_to.strftime("%Y-%m-%d"),
"new_users": cur_stats.get("new_users", 0),
"active_users": cur_stats.get("active_users", 0),
"interactions": cur_stats.get("interactions", 0),
}, f, ensure_ascii=False, indent=2)
print("Měsíční statistiky uloženy.")
return
# Týdenní přehled
date_from = date_to - timedelta(days=7)
week_number = now.isocalendar()[1]
try:
measures_data = get_measures(base_url, admin_token, date_from, date_to)
except Exception:
sys.exit(1)
tags = load_tags_from_data(date_to, 7, 3)
if tags is None:
try:
tags = api_get(f"{base_url}/api/v1/trends/tags?limit=3", admin_token)
except Exception:
tags = []
try:
instance_info = api_get(f"{base_url}/api/v1/instance")
except Exception:
instance_info = {}
user_count = instance_info.get("stats", {}).get("user_count", 0)
weekly_stats_path = os.path.join("data", "weekly_stats.json")
prev_stats = None
try:
with open(weekly_stats_path, encoding="utf-8") as f:
prev_stats = json.load(f)
except FileNotFoundError:
pass
top_tooty = load_tooty_from_data(date_to, 7)
total_count = load_total_count_from_data(date_to, 7)
top_author = load_top_author_from_data(date_to, 7)
newest_account = load_newest_account_from_data(date_to, 7)
top_links = load_top_links_from_data(date_to, 7)
media_count = load_media_count_from_data(date_to, 7)
hourly_count = load_hourly_count_from_data(date_to, 7)
most_discussed = load_most_discussed_from_data(date_to, 7)
toot = build_toot(
measures_data, tags, top_tooty, date_from, date_to, week_number,
total_count, top_author, newest_account, user_count, top_links, prev_stats, media_count,
hourly_count, most_discussed,
)
if args.dry_run:
print(toot)
return
try:
result = api_post(
f"{base_url}/api/v1/statuses",
novinky_token,
{"status": toot, "visibility": "public"},
)
print(f"Toot odeslán: {result.get('url', '(bez URL)')}")
except Exception:
sys.exit(1)
cur_stats = {m["key"]: int(m["total"]) for m in measures_data}
os.makedirs("data", exist_ok=True)
with open(weekly_stats_path, "w", encoding="utf-8") as f:
json.dump({
"date": date_to.strftime("%Y-%m-%d"),
"new_users": cur_stats.get("new_users", 0),
"active_users": cur_stats.get("active_users", 0),
"interactions": cur_stats.get("interactions", 0),
}, f, ensure_ascii=False, indent=2)
print("Týdenní statistiky uloženy.")
cutoff = date_to - timedelta(days=60)
data_dir = "data"
if os.path.isdir(data_dir):
for fname in os.listdir(data_dir):
if not fname.endswith(".json"):
continue
try:
file_date = datetime.strptime(fname[:-5], "%Y-%m-%d").replace(tzinfo=timezone.utc)
except ValueError:
continue
if file_date < cutoff:
os.remove(os.path.join(data_dir, fname))
print(f"Smazán starý soubor: {fname}")
if __name__ == "__main__":
main()