Skip to content

Instantly share code, notes, and snippets.

@dewomser
Last active January 22, 2026 15:55
Show Gist options
  • Select an option

  • Save dewomser/680bc601b7138a9cc269c2c1b814cc4d to your computer and use it in GitHub Desktop.

Select an option

Save dewomser/680bc601b7138a9cc269c2c1b814cc4d to your computer and use it in GitHub Desktop.
Followers Knowledgegraph on mastodon? . Token is obligatory . Depth =2 . Avatar Images as the nodes. python
# Mastodon Followers Knowledge Graph (no-pyvis)
This script builds a knowledge graph of your Mastodon followers up to depth 2 and writes an interactive HTML using vis.js (no pyvis / Jinja2 required).
Usage:
1. Install dependencies:
pip install -r requirements.txt
2. Run:
export MASTODON_TOKEN="YOUR_TOKEN"
python mastodon_followers_kg_no_pyvis.py --base-url https://mastodon.social --depth 2 --out graph.html
Options:
--base-url Mastodon instance base URL (required)
--token Token; falls back to MASTODON_TOKEN env var
--depth Depth to crawl (default 2)
--max-per-user Max followers per user fetched (default 80)
--max-total-nodes Cap to avoid huge graphs (default 500)
--out Output HTML filename (default graph.html)
--sleep Sleep between requests (default 0.25)
#!/usr/bin/env python3
"""
--------------This Script is vibe Code ---------------
mastodon_followers_kg_no_pyvis.py
Build a knowledge graph of a Mastodon account's followers up to a given depth,
using avatar images as node icons. Writes a standalone HTML using vis.js (no pyvis/Jinja2).
Example:
export MASTODON_TOKEN="..." alternativ use --token
python mastodon_followers_kg_no_pyvis.py --base-url https://mastodon.social --depth 2 --out graph.html
"""
from __future__ import annotations
import os
import sys
import time
import argparse
import requests
import urllib.parse
import json
from concurrent.futures import ThreadPoolExecutor, as_completed
from tqdm import tqdm
from typing import Dict, List, Tuple
DEFAULT_MAX_PER_USER = 80
DEFAULT_DEPTH = 2
DEFAULT_SLEEP = 0.25
DEFAULT_MAX_TOTAL_NODES = 500
def get_headers(token: str):
return {"Authorization": f"Bearer {token}", "User-Agent": "mastodon-kg-script/1.0"}
def parse_link_header(link_header: str):
if not link_header:
return {}
parts = link_header.split(",")
links = {}
for part in parts:
section = part.strip().split(";")
if len(section) < 2:
continue
url_part = section[0].strip()
if url_part.startswith("<") and url_part.endswith(">"):
url = url_part[1:-1]
else:
continue
rel = None
for s in section[1:]:
s = s.strip()
if s.startswith('rel='):
rel = s.split("=", 1)[1].strip().strip('"')
if rel:
links[rel] = url
return links
def fetch_me(base_url: str, headers: dict):
url = urllib.parse.urljoin(base_url, "/api/v1/accounts/verify_credentials")
r = requests.get(url, headers=headers, timeout=15)
r.raise_for_status()
return r.json()
def fetch_followers_page(url: str, headers: dict, session: requests.Session):
r = session.get(url, headers=headers, timeout=20)
r.raise_for_status()
data = r.json()
links = parse_link_header(r.headers.get("link", ""))
return data, links.get("next")
def build_followers_list(base_url: str, account_id: str, headers: dict, max_per_user: int, session: requests.Session):
followers = []
limit = min(max_per_user, 80)
params = {"limit": limit}
url = urllib.parse.urljoin(base_url, f"/api/v1/accounts/{account_id}/followers")
url = url + "?" + urllib.parse.urlencode(params)
while url and len(followers) < max_per_user:
data, next_url = fetch_followers_page(url, headers, session)
followers.extend(data)
if not next_url:
break
url = next_url
return followers[:max_per_user]
def crawl_followers_graph(base_url: str, token: str, depth: int=2, max_per_user: int=80, max_total_nodes: int=500, sleep_between: float=0.25, workers: int=6):
session = requests.Session()
headers = get_headers(token)
me = fetch_me(base_url, headers)
start_id = str(me["id"])
nodes: Dict[str, dict] = {}
edges: List[Tuple[str, str]] = []
nodes[start_id] = {
"id": start_id,
"acct": me.get("acct"),
"display_name": me.get("display_name") or me.get("username"),
"avatar": me.get("avatar_static") or me.get("avatar"),
"followers_count": me.get("followers_count", 0),
}
frontier = [start_id]
visited = set([start_id])
total_nodes = 1
for current_depth in range(1, depth + 1):
if not frontier:
break
next_frontier = []
futures = {}
with ThreadPoolExecutor(max_workers=workers) as ex:
for node_id in frontier:
if total_nodes >= max_total_nodes:
break
futures[ex.submit(build_followers_list, base_url, node_id, headers, max_per_user, session)] = node_id
for fut in tqdm(as_completed(futures), total=len(futures), desc=f"Depth {current_depth} fetching"):
parent_id = futures[fut]
try:
followers = fut.result()
except Exception as e:
print(f"Warning: failed fetching followers of {parent_id}: {e}", file=sys.stderr)
followers = []
for acct in followers:
child_id = str(acct["id"])
edges.append((child_id, parent_id)) # follower -> parent
if child_id not in visited:
nodes[child_id] = {
"id": child_id,
"acct": acct.get("acct"),
"display_name": acct.get("display_name") or acct.get("username"),
"avatar": acct.get("avatar_static") or acct.get("avatar"),
"followers_count": acct.get("followers_count", 0),
}
visited.add(child_id)
next_frontier.append(child_id)
total_nodes += 1
if total_nodes >= max_total_nodes:
break
time.sleep(sleep_between)
frontier = next_frontier
if total_nodes >= max_total_nodes:
print(f"Reached max_total_nodes={max_total_nodes}, stopping crawl.")
break
return nodes, edges
def write_visjs_html(nodes: Dict[str, dict], edges: List[Tuple[str, str]], out: str):
# Build vis.js-compatible arrays
vis_nodes = []
for nid, info in nodes.items():
avatar = info.get("avatar")
title = f"{info.get('display_name') or ''} (@{info.get('acct')})\\nfollowers: {info.get('followers_count', 0)}"
node_obj = {
"id": nid,
"label": "", # we will show image only; tooltip shows title
"title": title,
# Use avatar URL if available; vis-network supports 'image' + shape 'circularImage'
"image": avatar if avatar else None,
"shape": "circularImage" if avatar else "dot",
"size": 30,
}
vis_nodes.append(node_obj)
vis_edges = [{"from": src, "to": dst} for src, dst in edges]
html_template = f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Mastodon Followers Knowledge Graph</title>
<script src="https://unpkg.com/vis-network@9.1.2/dist/vis-network.min.js"></script>
<style>
body {{ margin: 0; font-family: Arial, sans-serif; }}
#mynetwork {{ width: 100%; height: 95vh; border: 1px solid #ddd; }}
#topbar {{ padding: 8px; background: #f8f8f8; border-bottom: 1px solid #eee; }}
</style>
</head>
<body>
<div id="topbar">
<strong>Mastodon Followers Knowledge Graph</strong> — nodes: {len(vis_nodes)}, edges: {len(vis_edges)}
</div>
<div id="mynetwork"></div>
<script>
const nodes = {json.dumps(vis_nodes)};
const edges = {json.dumps(vis_edges)};
const container = document.getElementById('mynetwork');
const data = {{
nodes: new vis.DataSet(nodes),
edges: new vis.DataSet(edges)
}};
const options = {{
interaction: {{
hover: true,
tooltipDelay: 100
}},
physics: {{
barnesHut: {{
gravitationalConstant: -8000,
centralGravity: 0.3,
springLength: 250,
springConstant: 0.01,
damping: 0.09
}},
stabilization: {{ iterations: 250 }}
}},
nodes: {{
borderWidth: 2
}},
edges: {{
arrows: {{
to: {{ enabled: true, scaleFactor: 0.5 }}
}},
color: '#888'
}}
}};
const network = new vis.Network(container, data, options);
// show tooltip on hover is built-in via 'title'
network.on("click", function(params) {{
if (params.nodes && params.nodes.length) {{
const nid = params.nodes[0];
const n = data.nodes.get(nid);
// open account in new tab if possible (we only have acct name; best effort)
if (n && n.title) {{
// nothing exact to open without full url; just copy to clipboard
// show a small alert for now
alert(n.title);
}}
}}
}});
</script>
</body>
</html>
"""
with open(out, "w", encoding="utf-8") as fh:
fh.write(html_template)
print(f"Wrote {out}")
def main():
p = argparse.ArgumentParser(description="Mastodon followers KG (no pyvis).")
p.add_argument("--base-url", required=True, help="Mastodon instance base URL, e.g. https://mastodon.social")
p.add_argument("--token", help="Mastodon token; falls back to MASTODON_TOKEN env var")
p.add_argument("--depth", type=int, default=DEFAULT_DEPTH)
p.add_argument("--max-per-user", type=int, default=DEFAULT_MAX_PER_USER)
p.add_argument("--max-total-nodes", type=int, default=DEFAULT_MAX_TOTAL_NODES)
p.add_argument("--out", default="graph.html")
p.add_argument("--sleep", type=float, default=DEFAULT_SLEEP)
p.add_argument("--workers", type=int, default=6)
args = p.parse_args()
token = args.token or os.environ.get("MASTODON_TOKEN")
if not token:
print("Error: provide token via --token or MASTODON_TOKEN env var", file=sys.stderr)
sys.exit(2)
base_url = args.base_url.rstrip("/")
try:
nodes, edges = crawl_followers_graph(
base_url=base_url,
token=token,
depth=args.depth,
max_per_user=args.max_per_user,
max_total_nodes=args.max_total_nodes,
sleep_between=args.sleep,
workers=args.workers,
)
write_visjs_html(nodes, edges, args.out)
print("Done.")
except Exception as e:
print("Error:", e, file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment