diff options
| -rw-r--r-- | extractLinks.py | 187 | ||||
| -rw-r--r-- | static/graph.html | 80 |
2 files changed, 267 insertions, 0 deletions
diff --git a/extractLinks.py b/extractLinks.py new file mode 100644 index 00000000..8cd7695c --- /dev/null +++ b/extractLinks.py @@ -0,0 +1,187 @@ +import os +import re +import json + +content_dir = "content" +internal_link_pattern = re.compile(r"\[[^\]]*\]\((/[^)]+)\)") + +url_map = {} # file path -> canonical url +alias_map = {} # alias url -> canonical url +link_graph = {} # canonical url -> list of canonical urls +title_map = {} # canonical url -> title + + +def parse_frontmatter(filepath): + with open(filepath, encoding="utf-8") as f: + lines = f.readlines() + + if not lines or lines[0].strip() != "---": + return {}, 0 + + frontmatter = {} + aliases = [] + slug = None + draft = False + title = None + related_cards = [] + mentioned_books = [] + i = 1 + while i < len(lines): + line = lines[i].strip() + if line == "---": + break + if ":" in line: + key, value = line.split(":", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + + if key == "aliases": + aliases = [] + i += 1 + while i < len(lines) and lines[i].startswith(" - "): + alias = lines[i].strip()[3:].strip() + aliases.append(alias) + i += 1 + continue + elif key == "slug": + slug = value + elif key == "draft": + draft = value.lower() == "true" + elif key == "title": + title = value + elif key == "related_cards": + related_cards = [] + i += 1 + while i < len(lines) and lines[i].strip().startswith("- "): + related_cards.append(lines[i].strip()[2:].strip()) + i += 1 + continue + elif key == "mentioned_books": + mentioned_books = [] + i += 1 + while i < len(lines) and lines[i].strip().startswith("- "): + mentioned_books.append(lines[i].strip()[2:].strip()) + i += 1 + continue + i += 1 + + return { + "slug": slug, + "aliases": aliases, + "draft": draft, + "title": title, + "related_cards": related_cards, + "mentioned_books": mentioned_books + }, i + 1 + + +def get_canonical_url(filepath, frontmatter): + rel_path = os.path.relpath(filepath, content_dir) + dirname, filename = os.path.split(rel_path) + name = os.path.splitext(filename)[0] + slug = frontmatter.get("slug") or name + parts = dirname.split(os.sep) + if parts == [""]: + return f"/{slug}/" + return f"/{'/'.join(parts)}/{slug}/" + + +def resolve_url(url): + return alias_map.get(url, url) + + +def resolve_url_from_dir(link, subdir): + """Only search for link in a specific content subdir (like 'cards', 'library')""" + target_dir = os.path.join(content_dir, subdir) + for path, canonical in url_map.items(): + if path.startswith(target_dir): + name = os.path.splitext(os.path.basename(path))[0] + if name == link: + return canonical + return None + + +def get_icon(url): + if url.startswith("/cards/"): + return "📄" + elif url.startswith("/posts/"): + return "📜" + elif url.startswith("/library/"): + return "📖" + elif url.startswith("/fictions/"): + return "🧙♀️" + elif url.startswith("/en/"): + return "🇬🇧" + return "📁" + + +def collect_urls(): + for root, _, files in os.walk(content_dir): + for file in files: + if not file.endswith(".md") or file == "_index.md": + continue + path = os.path.join(root, file) + frontmatter, _ = parse_frontmatter(path) + if frontmatter.get("draft"): + continue + + canonical_url = get_canonical_url(path, frontmatter) + url_map[path] = canonical_url + link_graph[canonical_url] = [] + title = frontmatter.get("title") or canonical_url.strip("/").split("/")[-1] + title_map[canonical_url] = f"{get_icon(canonical_url)} {title}" + + for alias in frontmatter.get("aliases", []): + alias_map[alias] = canonical_url + + +def build_link_graph(): + for path, canonical_url in url_map.items(): + with open(path, encoding="utf-8") as f: + content = f.read() + + # markdown links + for match in internal_link_pattern.finditer(content): + link = match.group(1) + normalized = resolve_url(link) + if normalized and normalized in link_graph: + link_graph[canonical_url].append(normalized) + + frontmatter, _ = parse_frontmatter(path) + + # related_cards → only from cards + for card in frontmatter.get("related_cards", []): + target = resolve_url_from_dir(card, "cards") + if target and target in link_graph: + link_graph[canonical_url].append(target) + + # mentioned_books → only from library + for book in frontmatter.get("mentioned_books", []): + target = resolve_url_from_dir(book, "library") + if target and target in link_graph: + link_graph[canonical_url].append(target) + + +def main(): + collect_urls() + build_link_graph() + + nodes = [ + {"id": url, "label": title_map[url], "url": url} + for url in link_graph + ] + + edges = [] + for source, targets in link_graph.items(): + for target in targets: + edges.append({"from": source, "to": target}) + + graph = {"nodes": nodes, "edges": edges} + + with open("static/link-graph.json", "w", encoding="utf-8") as f: + json.dump(graph, f, indent=2, ensure_ascii=False) + print("✅ Link graph generated: link-graph.json") + + +if __name__ == "__main__": + main() diff --git a/static/graph.html b/static/graph.html new file mode 100644 index 00000000..9cc77cbc --- /dev/null +++ b/static/graph.html @@ -0,0 +1,80 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <title>Page Link Graph</title> + <script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script> + <style> + body { + margin: 0; + background: #111; + color: #eee; + font-family: sans-serif; + } + #graph { + width: 100vw; + height: 100vh; + border: 1px solid #444; + } + </style> +</head> +<body> + <div id="graph"></div> + <script> + fetch("link-graph.json") + .then(res => res.json()) + .then(data => { + const container = document.getElementById("graph"); + + const nodes = new vis.DataSet( + data.nodes.map(node => ({ + id: node.id, + label: node.label, + url: node.url, + font: { color: "#eee" } + })) + ); + + const edges = new vis.DataSet(data.edges); + + const network = new vis.Network(container, { nodes, edges }, { + nodes: { + shape: "dot", + size: 10, + color: { + background: "#29c", + border: "#fff" + }, + font: { + size: 14, + color: "#eee" + } + }, + edges: { + arrows: "to", + color: { + color: "#888" + } + }, + physics: { + stabilization: false + }, + interaction: { + navigationButtons: true, + zoomView: true, + dragView: true + } + }); + + network.on("click", function (params) { + if (params.nodes.length > 0) { + const node = nodes.get(params.nodes[0]); + if (node.url) { + window.open(node.url, "_blank"); + } + } + }); + }); + </script> +</body> +</html> |
