Last active
January 22, 2026 15:55
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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