feat/modernize-robust-docker #1

Merged
tdpeuter merged 4 commits from feat/modernize-robust-docker into master 2026-08-18 18:27:46 +02:00
12 changed files with 1507 additions and 329 deletions

9
.dockerignore Normal file
View file

@ -0,0 +1,9 @@
.git
.gitignore
.agents
.idea
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache

View file

@ -0,0 +1,80 @@
name: Build, Push, and Release
on:
push:
branches:
- main
- master
- 'ci/*'
tags:
- 'v*'
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Forgejo Container Registry
uses: docker/login-action@v3
with:
registry: ${{ gitea.server_url }}
username: ${{ gitea.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ gitea.server_url }}/${{ gitea.repository }}
tags: |
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' || gitea.ref == 'refs/heads/master' }}
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=
- name: Build and Push Docker Image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
create-release:
needs: build-and-push
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Create Release in Forgejo
run: |
if [ "${{ startsWith(github.ref, 'refs/tags/v') }}" = "true" ]; then
TAG_NAME="${GITHUB_REF#refs/tags/}"
RELEASE_NAME="Release ${TAG_NAME}"
else
TAG_NAME="v2.0.${{ github.run_number }}"
RELEASE_NAME="Phikipathia Release ${TAG_NAME}"
fi
echo "Creating release ${RELEASE_NAME} with tag ${TAG_NAME}..."
curl -s -X POST "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG_NAME}\",
\"target_commitish\": \"${{ github.sha }}\",
\"name\": \"${RELEASE_NAME}\",
\"body\": \"Phikipathia release for commit ${{ github.sha }}. Docker container images are published to the Forgejo Container Registry.\",
\"draft\": false,
\"prerelease\": false
}"

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.idea/
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.agents/

18
Dockerfile Normal file
View file

@ -0,0 +1,18 @@
FROM python:3.11-slim
WORKDIR /app
# Prevent Python from writing .pyc files & buffer stdout/stderr
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PORT=8000 \
USER_AGENT="Phikipathia/1.0 (https://git.depeuter.dev/tdpeuter/2022ST-project-Phikipathia; tibo@depeuter.dev)"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "server.py"]

View file

@ -1,20 +1,40 @@
#!/usr/bin/env python3
"""
Dit script wordt gebruikt om de methodes in de scraper aan te roepen via JavaScript. Zo kan dit toch nog redelijk
onafhankelijk gebeuren en kan er gemakkelijker ge-debugged worden.
De bestandsextensie werd aangepast om te kunnen debuggen op zowel Windows als Linux.
Dit script wordt gebruikt om de methodes in de scraper aan te roepen via CLI of CGI.
De bestandsextensie werd behouden voor achterwaartse compatibiliteit.
"""
import cgi
import json
import sys
import os
from urllib.parse import parse_qs, unquote
sys.path.insert(0, os.path.dirname(__file__))
from scraper import run
parameters = cgi.FieldStorage()
data = json.loads(parameters.getvalue('data'))
antwoord = run(data['taal'], data['start'], data['einde'])
print("Content-Type: application/json")
print() # Lege lijn na headers
print(json.dumps(antwoord))
def get_input_data():
query_string = os.environ.get('QUERY_STRING', '')
if query_string:
params = parse_qs(query_string)
if 'data' in params:
return json.loads(unquote(params['data'][0]))
# Fallback to stdin or args
if len(sys.argv) > 1:
return json.loads(sys.argv[1])
return {'taal': 'en', 'start': 'Special:Random', 'einde': 'Philosophy'}
if __name__ == '__main__':
try:
data = get_input_data()
antwoord = run(data.get('taal', 'en'), data.get('start', 'Special:Random'), data.get('einde', 'Philosophy'))
except Exception as err:
antwoord = {'error': f"Fout bij verwerken: {str(err)}"}
print("Content-Type: application/json")
print() # Lege lijn na headers
print(json.dumps(antwoord))

View file

@ -1,18 +1,107 @@
#!/usr/bin/env python3
"""
Dit is het 'CGI-script'.
De bestandsextensie werd aangepast om te kunnen debuggen op zowel Windows als Linux.
Phikipathia Scraper Engine (cgi-bin/scraper.py)
Finds the semantic path between Wikipedia articles leading to a target article
(defaulting to "Philosophy" / "Filosofie" / "Philosophie") following the
'Getting to Philosophy' rule: the first valid non-parenthetical link in the main
body text of each article, with smart re-routing to bypass loops.
"""
from bs4 import BeautifulSoup
import json
import os
import sys
from typing import List, Optional, Tuple
from urllib.parse import quote, unquote, urlparse
from bs4 import BeautifulSoup, NavigableString, Tag
import requests
# Default HTTP User-Agent adhering to Wikimedia API policy
DEFAULT_USER_AGENT = (
"Phikipathia/1.0 (https://git.depeuter.dev/tdpeuter/2022ST-project-Phikipathia; tibo@depeuter.dev)"
)
def converteer(tekst, gebruiker=True):
# Per-request HTTP timeout in seconds
REQUEST_TIMEOUT = 15
# Maximum allowed hops per path search to prevent infinite exploration
MAX_HOPS = 100
# Blacklisted non-article MediaWiki namespaces (case-insensitive prefixes)
EXCLUDED_NAMESPACES = (
# English
"file:", "help:", "special:", "wikipedia:", "talk:", "category:",
"portal:", "template:", "template_talk:", "user:", "user_talk:",
"media:", "draft:", "draft_talk:", "module:", "module_talk:",
"mediawiki:", "mediawiki_talk:", "timedtext:", "topic:",
"portal_talk:", "category_talk:", "help_talk:", "wikipedia_talk:",
# Dutch (nl)
"bestand:", "hulp:", "speciaal:", "overleg:", "categorie:",
"portaal:", "sjabloon:", "sjabloon_overleg:", "gebruiker:", "gebruiker_overleg:",
"overleg_bestand:", "overleg_hulp:", "overleg_categorie:", "overleg_portaal:",
"overleg_sjabloon:", "overleg_gebruiker:", "overleg_module:",
# French (fr)
"fichier:", "aide:", "spécial:", "discussion:", "catégorie:",
"portail:", "modèle:", "discussion_modèle:", "utilisateur:", "discussion_utilisateur:",
"discussion_fichier:", "discussion_aide:", "discussion_catégorie:", "discussion_portail:",
"discussion_projet:", "projet:", "référence:", "discussion_référence:",
# German (de)
"datei:", "hilfe:", "spezial:", "diskussion:", "kategorie:",
"vorlage:", "vorlagendiskussion:", "benutzer:", "benutzer_diskussion:",
"datei_diskussion:", "hilfe_diskussion:", "kategorie_diskussion:", "portal_diskussion:",
"portal:", "medium:",
# Interwiki prefixes & Wikimedia projects
"w:", "s:", "b:", "q:", "n:", "v:", "voy:", "d:", "m:", "meta:",
"commons:", "wikidata:", "wikinews:", "wikiquote:", "wikibooks:",
"wikisource:", "wikiversity:", "wikivoyage:", "species:", "wikt:",
"wiktionary:", "incubator:", "foundation:", "phabricator:", "phab:",
)
# CSS selectors for elements that must be stripped before link extraction
BOILERPLATE_SELECTORS = (
# Infoboxes & sidebars
"table.infobox", "div.infobox", "table.sidebar", "div.sidebar",
"div.vertical-navbox", "div.navbox", "table.vertical-navbox", "table.navbox",
".infobox", ".infobox_v3", ".ib-country",
# Hatnotes & disambiguation notices
"div.hatnote", "div.dablink", "div.rellink", 'div[role="note"]',
"div.shortdescription", ".hatnote", ".dablink", ".rellink",
# Maintenance message boxes & stub templates
"table.ambox", "table.cmbox", "table.tmbox", "table.fmbox", "div.asbox",
"div.metadata", "div.boilerplate_metadata",
# Table of contents
"nav#toc", "div#toc", "div.toc", ".toc", ".mw-table-of-contents-container",
# Coordinates
"span#coordinates", "div#coordinates", "span.coordinates", "div.coordinates",
"span.geo-default", "span.geo-dec", "span.geo-dms", "span.geo",
"#mw-indicator-coordinates", ".mw-indicator-coordinates",
# References & citations & footnotes
"sup.reference", "ol.references", "div.reflist", "div.references",
"span.reference-text", "cite.citation", ".reflist", ".references",
# Audio, IPA & pronunciation annotations
"span.haudio", "span.IPA", "span.unicode", "div.audioplayer",
"span.audiolink", "span.audio-button", "span.audio", ".phonetics",
# Thumbnails, images & media containers
"div.thumb", "figure", "div.floatright", "div.floatleft",
"div.tright", "div.tleft", "span.mw-image-border",
# UI controls & print artifacts
"span.mw-editsection", "div.mw-jump-link", "div.noprint", "div.printfooter",
# Empty placeholder elements
"p.mw-empty-elt",
)
def get_user_agent() -> str:
"""
Ik blijf bewust case sensitive werken omdat dit héél soms wel nog belangrijk is om een verschl
te maken tussen twee linkjes.
Returns the configured User-Agent string with USER_AGENT environment variable override.
"""
env_ua = os.environ.get("USER_AGENT", "").strip()
return env_ua if env_ua else DEFAULT_USER_AGENT
def converteer(tekst: Optional[str], gebruiker: bool = True) -> Optional[str]:
"""
Converts between URL-encoded slugs and display article titles.
>>> converteer('Northwestern Europe')
'Northwestern Europe'
@ -22,180 +111,450 @@ def converteer(tekst, gebruiker=True):
'MedchalMalkajgiri district'
>>> converteer('MedchalMalkajgiri district', False)
'Medchal%E2%80%93Malkajgiri_district'
>>> converteer('C%23_(programming_language)')
'C# (programming language)'
>>> converteer('Caf%C3%A9#Etymology')
'Café'
>>> converteer(None) is None
True
"""
from urllib import parse
if tekst is None:
return None
if gebruiker:
return parse.unquote(tekst).replace('_', ' ').split('#')[0]
return unquote(str(tekst).split("#")[0]).replace("_", " ")
else:
return parse.quote(tekst.replace(' ', '_'))
return quote(str(tekst).replace(" ", "_"))
def zoek_link(soep):
def extract_wiki_target(href: Optional[str]) -> Optional[str]:
"""
Doorzoek de meegeleverde soep naar een geldige Wikipedia-link.
:return: een geldige link indien beschikbaar, anders
>>> zoek_link(BeautifulSoup(requests.get('https://en.wikipedia.org/wiki/Belgium').content, 'html.parser'))
'Northwestern_Europe'
>>> zoek_link(BeautifulSoup(requests.get('https://en.wikipedia.org/wiki/Glossary_of_engineering').content, 'html.parser'))
'Glossary_of_civil_engineering'
>>> zoek_link(BeautifulSoup(requests.get('https://en.wikipedia.org/wiki/Engineering').content, 'html.parser'))
'Scientific_method'
>>> zoek_link(BeautifulSoup(requests.get('https://en.wikipedia.org/wiki/Python_(programming_language)').content, 'html.parser'))
'High-level_programming_language'
>>> zoek_link(BeautifulSoup(requests.get('https://en.wikipedia.org/wiki/Department_of_Standards_Malaysia').content, 'html.parser'))
'Ministry_of_International_Trade_and_Industry_(Malaysia)'
>>> zoek_link(BeautifulSoup(requests.get('https://en.wikipedia.org/wiki/Malawi').content, 'html.parser'))
'Tumbuka_language'
Maar eventueel als alternatief 'Landlocked_country' ??
>>> zoek_link(BeautifulSoup(requests.get('https://en.wikipedia.org/wiki/%27s-Gravenweg_168,_Kralingen').content, 'html.parser'))
'Kralingen'
>>> zoek_link(BeautifulSoup(requests.get('https://fr.wikipedia.org/wiki/Langue').content, 'html.parser'))
'Syst%C3%A8me'
Extracts the article slug from a wiki href (supports both /wiki/Slug and https://xx.wikipedia.org/wiki/Slug).
"""
if not href or not isinstance(href, str):
return None
# Ik heb besloten om ook lijsttypes te kunnen doorzoeken op nuttige linkjes. Dit is strikt genomen tegen de
# beschrijving van de opgave, maar volgens https://en.wikipedia.org/wiki/Wikipedia:Getting_to_Philosophy lijkt dit
# de logische methode, dus ik besloot ook lijsten te ondersteunen, als extra uitdaging. Ik vermoedde dat de opgave
# van het project eerder <p> specifieerde om ons wat hoofdbreuk te besparen.
ondersteuning = ['p', 'ul', 'ol']
link = None
if "/wiki/" not in href:
return None
# Navigeer naar het niveau op de pagina waarop we zullen zoeken.
soep = soep.find('div', {'id': 'bodyContent'}) \
.find('div', {'class': 'mw-parser-output'})
parts = href.split("/wiki/", 1)
prefix, target = parts[0], parts[1]
# Niet recursief zoeken om niet in een tabel of iets dergelijke vast te komen.
# Het niveau onder 'mw-parser-output' is wat we nodig hebben en waar alle nodige alinea's in staan.
alinea = soep.find(ondersteuning, recursive=False)
# Ga steeds verder omlaag (niet dieper) totdat er een geldige link is gevonden.
while link is None:
# Navigeer naar het volgende stuk tekst (alinea of lijst) met een link.
# Negeer linkjes in superscript, dus zoek enkel op het huidige niveau.
potentieel = alinea.find('a')
# Spans negeren is redelijk enkel en alleen om coördinaat stukjes te vermijden.
while potentieel is None or alinea.findChild().name == 'span':
alinea = alinea.findNextSibling(ondersteuning, recursive=False)
# Het zou kunnen dat de bewerking hierboven ervoor zorgt dat we op het einde van de pagina zijn
if alinea is None:
if prefix:
if prefix.startswith("http://") or prefix.startswith("https://") or prefix.startswith("//"):
parsed = urlparse(prefix if not prefix.startswith("//") else "https:" + prefix)
if not parsed.netloc.endswith("wikipedia.org"):
return None
elif prefix not in ("", "."):
return None
potentieel = alinea.find('a')
# Blijf zoeken naar linkjes in dezelfde alinea totdat deze voorwaarden voldaan zijn, maar stop van zodra er geen
# naasten meer gevonden kunnen worden.
while potentieel is not None and (
tussen_haakjes(potentieel, alinea)
# Soms kunnen hier 'interne'/externe linkjes of linkjes die niet werken (rode) tussen zitten.
or not potentieel['href'].startswith('/wiki')
or not potentieel['href'].count(':') == 0 # Bijzondere Wikipedia links negeren.
):
potentieel = potentieel.findNext('a')
if potentieel is not None:
link = potentieel['href'].split('/')[2]
return link
target = target.split("#")[0].split("?")[0].strip()
return target if target else None
def tussen_haakjes(zin, omgeving):
def is_valid_link(a_tag: Tag, current_title: Optional[str] = None) -> bool:
"""
Controleer de geldigheid van de link door het aantal openende haakjes te vergelijken met het aantal sluitende
haakjes die VOOR de onderzochte link voorkomen.
Deze methode is meer waterdicht dan het vergelijken van indexen van haakjes.
Strikt genomen zou het kunnen voorkomen dat een paragraaf tussen haken staat, die we dus niet kunnen meerekenen,
maar dit voelt grammaticaal onjuist en we zullen er hier dan ook vanuit gaan dat dit niet het geval is.
>>> tussen_haakjes('woord', 'dit is een heel belangrijk woord dat we nu zoeken')
False
>>> tussen_haakjes('woord', 'dit is een (heel) belangrijk woord dat we nu zoeken')
False
>>> tussen_haakjes('woord', 'dit (is een heel belangrijk woord dat we nu) zoeken')
True
>>> tussen_haakjes('woord', 'dit (is (een heel) belangrijk woord) dat we nu zoeken')
True
Determines if an <a> tag is a valid main-namespace Wikipedia article link.
"""
if not isinstance(a_tag, Tag) or a_tag.name != "a":
return False
openende_haakjes = str(omgeving).count("(", 0, str(omgeving).find(str(zin)))
sluitende_haakjes = str(omgeving).count(")", 0, str(omgeving).find(str(zin)))
return openende_haakjes != sluitende_haakjes
href = a_tag.get("href")
target = extract_wiki_target(href)
if not target:
return False
# Filter out non-article namespaces and interwiki prefixes
target_lower = target.lower()
if any(target_lower.startswith(ns) for ns in EXCLUDED_NAMESPACES):
return False
if ":" in target:
prefix_part = target.split(":", 1)[0]
if (prefix_part.lower() + ":") in EXCLUDED_NAMESPACES:
return False
if len(prefix_part) in (2, 3) and prefix_part.isalpha():
return False
# Skip red links (non-existent pages) and self-links
classes = a_tag.get("class", [])
if isinstance(classes, str):
classes = classes.split()
if "new" in classes or "mw-selflink" in classes or "selflink" in classes or "external" in classes:
return False
# Skip image and file links
if "image" in classes or "mw-file-description" in classes:
return False
if a_tag.find("img") is not None:
return False
# Skip italicized links (e.g. hatnotes or foreign translations)
if a_tag.find_parent(["i", "em"]) is not None:
return False
# Check for inline style font-style: italic on parent hierarchy
curr = a_tag.parent
while curr and hasattr(curr, "get"):
style = curr.get("style", "")
if "italic" in style:
return False
if curr.name in ("p", "div", "body", "[document]"):
break
curr = curr.parent
# Skip self-links matching current article title
if current_title:
clean_target = converteer(target, True).strip().lower()
clean_current = converteer(current_title, True).strip().lower()
if clean_target == clean_current:
return False
return True
def run(taal, start, stop):
def is_excluded_inline(tag: Tag) -> bool:
"""
Paden kunnen gecontroleerd worden met https://www.xefer.com/wikipedia, al doet dit niets met 'speciale' Wikipedia-
links.
Gewone controles
>>> run('en', "Belgium", "Philosophy")
{'pad': ['Belgium', 'Northwestern Europe', 'Subregion', 'Region', 'Geography', 'Science', 'Scientific method', 'Empirical evidence', 'Proposition', 'Logic', 'Reason', 'Consciousness', 'Sentience', 'Emotion', 'Mental state', 'Mind', 'Phenomenon', 'Immanuel Kant', 'Philosophy']}
>>> run('en', 'Department of Standards Malaysia', 'Philosophy')
{'pad': ['Department of Standards Malaysia', 'Ministry of International Trade and Industry (Malaysia)', 'Ministry (government department)', 'Executive (government)', 'Government', 'State (polity)', 'Germans', 'Germany', 'Central Europe', 'Europe', 'Continent', 'Landmass', 'Region', 'Geography', 'Science', 'Scientific method', 'Empirical evidence', 'Proposition', 'Logic', 'Reason', 'Consciousness', 'Sentience', 'Emotion', 'Mental state', 'Mind', 'Phenomenon', 'Immanuel Kant', 'Philosophy']}
>>> run('en', 'Malkajgiri mandal', 'Philosophy')
{'pad': ['Malkajgiri mandal', 'MedchalMalkajgiri district', 'District', 'Administrative division', 'Sovereign state', 'Polity', 'Politics', 'Decision-making', 'Psychology', 'Science', 'Scientific method', 'Empirical evidence', 'Proposition', 'Logic', 'Reason', 'Consciousness', 'Sentience', 'Emotion', 'Mental state', 'Mind', 'Phenomenon', 'Immanuel Kant', 'Philosophy']}
>>> run('fr', 'Langue', 'Philosophie')
{'pad': ['Langue', 'Système', 'Ensemble', 'Mathématiques', 'Connaissance', 'Notion', 'Connaissance (philosophie)', 'Philosophie']}
Loops
>>> run('nl', 'België', 'Philosophy')
{'error': "Cyclus gedetecteerd op 'Wetenschap', startende vanaf 'België'. Het onafgewerkte pad zal niet worden toegevoegd aan het overzicht. "}
>>> run('en', 'Tom Inglesby', 'Contract failure')
{'pad': ['Tom Inglesby', 'Johns Hopkins Center for Health Security', 'Nonprofit organization', 'Contract failure']}
Doodlopende eindes
>>> run('en', 'Gaumee Film Awards', 'Philosophy')
{'error': "Er konden geen nieuwe links meer gevonden worden op 'Gaumee Film Awards'"}
Foute linkjes
>>> run('en', 'Deze pagina bestaat niet', 'Philosophy')
{'error': "Er ging iets fout bij het inladen van 'Deze pagina bestaat niet'. Bestaat de website?"}
Checks if an inline tag should be skipped during parenthesis traversal.
"""
if not isinstance(tag, Tag):
return False
if tag.name in ("sup", "table", "figure", "style", "script", "nav", "math"):
return True
classes = tag.get("class", [])
if isinstance(classes, str):
classes = classes.split()
excluded_classes = {
"infobox", "sidebar", "hatnote", "toc", "reference", "reflist",
"haudio", "audiolink", "IPA", "mw-empty-elt", "navbox",
"vertical-navbox", "shortdescription", "coordinates", "geo",
"audioplayer", "mw-editsection", "noprint", "mw-jump-link",
}
if any(c in excluded_classes for c in classes):
return True
if tag.get("id") in ("coordinates", "toc", "mw-indicator-coordinates"):
return True
return False
# Negeer de 'random' waarde als er op deze manier gestart wordt.
lijst = [converteer(start, True)] if start != 'Special:Random' else []
base = f"https://{taal}.wikipedia.org/wiki/"
while start != stop:
def extract_all_links_from_block(block: Tag, current_title: Optional[str] = None) -> List[str]:
"""
Traverses a content block in document order, tracking parenthesis depth
strictly across text nodes (NavigableString) to find all valid
non-parenthetical links.
"""
paren_depth = 0
links: List[str] = []
# Pagina inladen.
try:
pagina = requests.get(base + converteer(start, False))
except:
return {'error': f"Er ging iets fout bij het inladen van '{start}'. Bestaat de taalcode?"}
def walk(node):
nonlocal paren_depth
# Stoppen indien het inladen van de pagina niet lukte.
if pagina.status_code != 200:
if len(lijst) == 0:
return {'error': f"Er ging iets fout bij het inladen van '{start}'. Bestaat de website?"}
if isinstance(node, NavigableString):
for ch in str(node):
if ch == "(":
paren_depth += 1
elif ch == ")":
paren_depth = max(0, paren_depth - 1)
return
if isinstance(node, Tag):
if is_excluded_inline(node):
return
if node.name == "a":
if paren_depth == 0 and is_valid_link(node, current_title):
target = extract_wiki_target(node.get("href", ""))
if target and target not in links:
links.append(target)
# Traverse anchor children to ensure any parens inside text update depth
for child in node.children:
walk(child)
return
for child in node.children:
walk(child)
walk(block)
return links
def clean_soup(soup: BeautifulSoup) -> Tag:
"""
Finds the main content container and decomposes all boilerplate elements.
Returns the cleaned content root Tag.
"""
content_div = soup.find("div", id="mw-content-text")
if content_div:
p_out = content_div.find("div", class_="mw-parser-output")
root = p_out if p_out else content_div
else:
p_out = soup.find("div", class_="mw-parser-output")
if p_out:
root = p_out
else:
body_content = soup.find("div", id="bodyContent")
if body_content:
root = body_content
else:
root = soup.body if soup.body else soup
for selector in BOILERPLATE_SELECTORS:
for el in root.select(selector):
el.decompose()
return root
def zoek_kandidaat_links(soep: Optional[BeautifulSoup], current_title: Optional[str] = None) -> List[str]:
"""
Extracts all candidate valid Wikipedia links from the article in document order.
"""
if soep is None:
return []
content_root = clean_soup(soep)
all_links: List[str] = []
content_blocks = content_root.find_all(["p", "ul", "ol"])
for block in content_blocks:
classes = block.get("class", [])
if isinstance(classes, str):
classes = classes.split()
if "mw-empty-elt" in classes or not block.get_text(strip=True):
continue
block_links = extract_all_links_from_block(block, current_title)
for link in block_links:
if link not in all_links:
all_links.append(link)
return all_links
def zoek_link(soep: Optional[BeautifulSoup], current_title: Optional[str] = None) -> Optional[str]:
"""
Searches the provided BeautifulSoup document for the first valid Wikipedia link.
>>> html_basic = '''
... <div id="mw-content-text">
... <div class="mw-parser-output">
... <p class="mw-empty-elt"></p>
... <div class="hatnote">For other uses, see <a href="/wiki/Belgium_(disambiguation)">Belgium (disambiguation)</a>.</div>
... <table class="infobox"><tr><td><a href="/wiki/Infobox_Link">Capital</a></td></tr></table>
... <p>
... <b>Belgium</b> (<span class="IPA">/ˈbɛldʒəm/</span>; Dutch: <i lang="nl">België</i>, <a href="/wiki/Help:IPA">help</a>; <a href="/wiki/File:Audio.ogg">listen</a>)
... is a country in <a href="/wiki/Northwestern_Europe">Northwestern Europe</a>.
... </p>
... </div>
... </div>
... '''
>>> zoek_link(BeautifulSoup(html_basic, 'html.parser'))
'Northwestern_Europe'
"""
links = zoek_kandidaat_links(soep, current_title)
return links[0] if links else None
def tussen_haakjes(zin: str, omgeving: str) -> bool:
"""
Legacy helper checking if a phrase is inside parentheses.
Preserved for backward compatibility.
"""
s_omg = str(omgeving)
s_zin = str(zin)
idx = s_omg.find(s_zin)
if idx == -1:
return False
open_count = s_omg.count("(", 0, idx)
close_count = s_omg.count(")", 0, idx)
return open_count > close_count
def fetch_wikipedia_page(
session: requests.Session,
url: str,
headers: dict
) -> Tuple[Optional[BeautifulSoup], Optional[str], Optional[str]]:
"""
Fetches a Wikipedia page following redirects.
Returns (soup, canonical_resolved_title, error_type).
"""
try:
response = session.get(url, headers=headers, timeout=REQUEST_TIMEOUT, allow_redirects=True)
except (requests.exceptions.ConnectionError, requests.exceptions.InvalidURL):
return None, None, "dns_error"
except requests.exceptions.Timeout:
return None, None, "timeout_error"
except Exception:
return None, None, "request_error"
if response.status_code != 200:
return None, None, f"http_{response.status_code}"
# Extract canonical title from final redirected URL
parsed_path = urlparse(response.url).path
canonical_title = None
if parsed_path.startswith("/wiki/"):
canonical_title = parsed_path[6:].split("#")[0].split("?")[0]
soup = BeautifulSoup(response.content, "html.parser")
return soup, canonical_title, None
import re
def is_valid_lang_code(taal: str) -> bool:
"""
Validates Wikipedia language subdomain string to prevent SSRF or injection.
Allows 2-10 lowercase alphanumeric characters/hyphens (e.g. 'en', 'nl', 'zh-min-nan', 'simple').
"""
if not taal or not isinstance(taal, str):
return False
return bool(re.match(r"^[a-z]{2,10}(-[a-z0-9]+)?$", taal.strip().lower()))
def run(taal: str, start: str, stop: Optional[str] = "Philosophy", max_hops: int = MAX_HOPS) -> dict:
"""
Executes the Wikipedia path traversal from start to stop in the specified language,
with smart re-routing if the primary link creates a loop.
"""
if not is_valid_lang_code(taal):
return {
"pad": [],
"status": "error",
"stappen": 0,
"herleid": 0,
"error": f"Ongeldige taalcode '{taal}'. Gebruik een geldige Wikipedia taalcode (bijv. 'en', 'nl', 'fr')."
}
if not stop:
stop = "Filosofie" if taal == "nl" else ("Philosophie" if taal == "fr" else "Philosophy")
session = requests.Session()
headers = {"User-Agent": get_user_agent()}
base_url = f"https://{taal}.wikipedia.org/wiki/"
pad: List[str] = []
visited: set[str] = set()
current_title = start
herleid_count = 0
for hop in range(max_hops + 1):
if hop >= max_hops:
return {
'error': f"Er ging iets fout bij het inladen van '{lijst[-1]}', vertrekkende vanaf '{lijst[0]}'. Bestaat de website?"}
"pad": pad,
"status": "max_hops",
"stappen": len(pad) - 1,
"herleid": herleid_count,
"error": f"Maximum aantal stappen ({max_hops}) bereikt voor '{pad[0]}'."
}
# Verwerken.
soep = BeautifulSoup(pagina.content, 'html.parser')
target_url = base_url + converteer(current_title, False)
soup, canonical_slug, err = fetch_wikipedia_page(session, target_url, headers)
# Volgende link zoeken.
start = converteer(zoek_link(soep))
if err is not None or soup is None:
if len(pad) == 0:
err_msg = f"Er ging iets fout bij het inladen van '{start}'. Bestaat de taalcode of website?"
else:
err_msg = f"Er ging iets fout bij het inladen van '{pad[-1]}', vertrekkende vanaf '{pad[0]}'."
return {
"pad": pad,
"status": "error",
"stappen": len(pad) - 1,
"herleid": herleid_count,
"error": err_msg
}
if start is None:
return {'error': f"Er konden geen nieuwe links meer gevonden worden op '{lijst[-1]}'"}
resolved_title = converteer(canonical_slug if canonical_slug else current_title, True)
# Cyclus detecteren
if start in lijst:
return {'error':
f"Cyclus gedetecteerd op '{start}', startende vanaf '{lijst[0]}'. Het onafgewerkte pad zal niet worden toegevoegd aan het overzicht. "
}
if len(pad) == 0:
pad.append(resolved_title)
visited.add(resolved_title.lower())
elif pad[-1] != resolved_title:
if resolved_title.lower() in visited:
return {
"pad": pad,
"status": "cyclus",
"cyclus_op": resolved_title,
"stappen": len(pad) - 1,
"herleid": herleid_count,
"error": f"Cyclus gedetecteerd op '{resolved_title}', startende vanaf '{pad[0]}'."
}
pad.append(resolved_title)
visited.add(resolved_title.lower())
lijst.append(start)
# Check if destination article reached
if resolved_title.lower() == stop.lower():
return {
"pad": pad,
"status": "success",
"stappen": len(pad) - 1,
"herleid": herleid_count
}
return {'pad': lijst}
# Option 2: Smart Re-routing among candidate links on current page
candidate_slugs = zoek_kandidaat_links(soup, resolved_title)
if not candidate_slugs:
return {
"pad": pad,
"status": "doodlopend",
"stappen": len(pad) - 1,
"herleid": herleid_count,
"error": f"Er konden geen nieuwe links meer gevonden worden op '{pad[-1]}'."
}
# Find first candidate link that does NOT lead into an already visited page
selected_slug = None
for idx, cand_slug in enumerate(candidate_slugs):
cand_title = converteer(cand_slug, True)
if cand_title.lower() not in visited and cand_title not in pad:
selected_slug = cand_slug
if idx > 0:
herleid_count += 1
break
if selected_slug is None:
# All candidate links on this page lead to visited pages -> cycle
primary_title = converteer(candidate_slugs[0], True)
return {
"pad": pad,
"status": "cyclus",
"cyclus_op": primary_title,
"stappen": len(pad) - 1,
"herleid": herleid_count,
"error": f"Cyclus gedetecteerd op '{primary_title}', startende vanaf '{pad[0]}'."
}
current_title = selected_slug
return {
"pad": pad,
"status": "max_hops",
"stappen": len(pad) - 1,
"herleid": herleid_count,
"error": f"Maximum aantal stappen ({max_hops}) bereikt voor '{pad[0]}'."
}
def main():
if len(sys.argv) > 1 and sys.argv[1] == "--test":
import doctest
results = doctest.testmod()
print(f"Doctests: {results}")
sys.exit(0 if results.failed == 0 else 1)
elif len(sys.argv) > 1:
taal = sys.argv[1] if len(sys.argv) > 1 else "en"
start = sys.argv[2] if len(sys.argv) > 2 else "Special:Random"
stop = sys.argv[3] if len(sys.argv) > 3 else "Philosophy"
antwoord = run(taal, start, stop)
print(json.dumps(antwoord, indent=2, ensure_ascii=False))
else:
import doctest
results = doctest.testmod()
print(f"Doctests: {results}")
sys.exit(0 if results.failed == 0 else 1)
if __name__ == "__main__":
main()

27
docker-compose.yml Normal file
View file

@ -0,0 +1,27 @@
services:
phikipathia:
build:
context: .
dockerfile: Dockerfile
image: ${DOCKER_REGISTRY:-phikipathia}:${DOCKER_TAG:-latest}
container_name: phikipathia-app
restart: unless-stopped
ports:
- "${PORT:-8000}:8000"
environment:
- PORT=8000
- USER_AGENT=${USER_AGENT:-Phikipathia/1.0 (https://git.depeuter.dev/tdpeuter/2022ST-project-Phikipathia; tibo@depeuter.dev)}
healthcheck:
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/\")'"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M

View file

@ -1,36 +1,92 @@
<!doctype html>
<html lang="HTML5">
<html lang="nl">
<head>
<meta charset=UTF-8>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Phikipathia</title>
<link rel="icon" href="./images/wikipedia_edit.png">
<link id="favicon" rel="icon" href="./images/wikipedia_edit.png">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="header">
<header class="header">
<img src="images/wikipedia_edit.png" alt="The Wikipedia Logo but rotated" id="logo">
<h1>Phikipathia</h1>
<p><b>Phi</b>losophy Wi<b>ki</b>pedia <b>Path</b> <i>(ia)</i> visualisatie</p>
</div>
<div class="box">
<label for="taal">Taal: </label>
<input type="text" id="taal" placeholder="en" oninput="inputWaarschuwing()"/>
<label for="start">Startpagina: </label>
<input type="text" id="start" placeholder="Special:Random"/>
<label for="einde">Eindpunt: </label>
<input type="text" id="einde" placeholder="Philosophy" oninput="inputWaarschuwing()"/>
<button type="button" id="toevoegKnop" onclick="zoek()">voeg toe</button>
<label id="waarschuwing" hidden="hidden">
<b>Waarschuwing:</b> De huidige boom zal overschreven worden bij een nieuwe toevoeging.
</label>
</div>
<div id="visualisatie">
</div>
<div class="header-content">
<h1>Phikipathia</h1>
<p><b>Phi</b>losophy Wi<b>ki</b>pedia <b>Path</b> <i>(ia)</i> visualisatie</p>
</div>
</header>
<main>
<!-- Controls Panel (Top Sticky Box) -->
<div class="box">
<div class="form-row">
<div class="form-group">
<label for="taal">Taal: </label>
<select id="taal">
<option value="en" selected>en (English)</option>
<option value="nl">nl (Nederlands)</option>
<option value="fr">fr (Français)</option>
<option value="de">de (Deutsch)</option>
</select>
</div>
<div class="form-group">
<label for="start">Startpagina: </label>
<input type="text" id="start" placeholder="Special:Random" autocomplete="off" />
</div>
<div class="form-group">
<label for="einde">Eindpunt: </label>
<input type="text" id="einde" placeholder="Philosophy" autocomplete="off" />
</div>
<button type="button" id="toevoegKnop">voeg toe</button>
</div>
<div id="waarschuwing" hidden>
<b>Waarschuwing:</b> De huidige boom zal overschreven worden bij een nieuwe toevoeging.
</div>
<div id="errorBanner" class="error-banner" hidden role="alert" aria-live="polite">
<span id="errorMessage" class="error-message"></span>
<button type="button" id="errorDismiss" class="error-dismiss" aria-label="Sluiten">&times;</button>
</div>
</div>
<!-- Tree Visualization -->
<div id="visualisatie" aria-live="polite">
</div>
<!-- Full-Width Statistics Box at Bottom -->
<div id="statistieken" class="box stats-box" hidden>
<div class="stats-row">
<div class="stat-item">
<span class="stat-label">Status:</span>
<span id="statStatusText" class="stat-val">Bereikt</span>
</div>
<div class="stat-item">
<span class="stat-label">Stappen:</span>
<span id="statHops" class="stat-val">0</span>
</div>
<div class="stat-item" id="statReroutesRow" hidden>
<span class="stat-label">Omleidingen:</span>
<span id="statReroutes" class="stat-val">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Paden verkend:</span>
<span id="statTotalPaths" class="stat-val">0</span>
</div>
<button type="button" id="wisKnop" class="wiki-btn-link">Boom wissen</button>
</div>
</div>
</main>
<footer class="box">
<p>
Deze pagina visualiseert een fenomeen waarbij je door steeds op de eerste link van een Wikipedia-pagina te klikken,
bij <a href="https://en.wikipedia.org/wiki/Philosophy">filosofie</a> uitkomt. Lees er
<a href="https://en.wikipedia.org/wiki/Wikipedia:Getting_to_Philosophy">hier</a> meer over.
bij <a href="https://en.wikipedia.org/wiki/Philosophy" target="_blank" rel="noopener">filosofie</a> uitkomt. Lees er
<a href="https://en.wikipedia.org/wiki/Wikipedia:Getting_to_Philosophy" target="_blank" rel="noopener">hier</a> meer over.
</p>
<p class="footer-meta">
Code bijgewerkt in 2026. Originele baseline:
<a href="https://git.depeuter.dev/tdpeuter/2022ST-project-Phikipathia/src/commit/5ed9fdf2c1eb71068a08256fd4bc46f47b7f1889" target="_blank" rel="noopener">bekijk originele code (commit 5ed9fdf)</a>.
</p>
<small>© 2022 Tibo De Peuter</small>
</footer>

470
index.js
View file

@ -1,138 +1,386 @@
// TODO Optimalisatie: De huidige lijst meegeven en controleren tijdens het opstellen zodat er gestopt wordt vanaf er een titel gevonden is
// Initialiseer de webpagina
const taal = document.getElementById('taal');
const start = document.getElementById('start');
const einde = document.getElementById('einde');
const toevoegKnop = document.getElementById('toevoegKnop');
const visualisatie = document.getElementById('visualisatie');
const logo = document.getElementById('logo');
const waarschuwing = document.getElementById('waarschuwing');
let huidigeTaal;
let huidigeDoel;
let timer;
let rotatie;
refreshTree();
/**
* Zoek een pad met python backend en voeg deze toe in de lijst.
* Phikipathia - Philosophy Wikipedia Path Visualization
* Features: Clickable Wikipedia links, Collapsible Tree Nodes, Clean Branch Lines, Wikipedia Infobox, Tab Favicon Spinner
*/
function zoek() {
(() => {
'use strict';
// Schakel de knop 'toevoegen' uit zolang het zoekproces bezig is, zodat er geen meerdere
// paden tegelijk toegevoegd kunnen worden.
toggleZoeken(false);
// 1. Cached DOM Element References
const favicon = document.getElementById('favicon');
const taal = document.getElementById('taal');
const start = document.getElementById('start');
const einde = document.getElementById('einde');
const toevoegKnop = document.getElementById('toevoegKnop');
const wisKnop = document.getElementById('wisKnop');
const visualisatie = document.getElementById('visualisatie');
const logo = document.getElementById('logo');
const waarschuwing = document.getElementById('waarschuwing');
const errorBanner = document.getElementById('errorBanner');
const errorMessage = document.getElementById('errorMessage');
const errorDismiss = document.getElementById('errorDismiss');
refreshTree();
// Wikipedia Infobox Stats Elements
const statistieken = document.getElementById('statistieken');
const statHops = document.getElementById('statHops');
const statStatusText = document.getElementById('statStatusText');
const statReroutesRow = document.getElementById('statReroutesRow');
const statReroutes = document.getElementById('statReroutes');
const statTotalPaths = document.getElementById('statTotalPaths');
const verzoek = { // Standaardwaarden gebruiken indien het veld leeg is.
taal: getOrDefault(taal),
start: getOrDefault(start),
einde: getOrDefault(einde)
};
// 2. Favicon Animation Engine (Canvas Offscreen Rendering)
const faviconSrc = './images/wikipedia_edit.png';
const faviconImg = new Image();
faviconImg.src = faviconSrc;
fetch(`cgi-bin/init.py?data=${JSON.stringify(verzoek)}`)
.then(antwoord => antwoord.json())
.then(data => voegToe(data))
.catch(reason => alert('‼ ' + reason))
.finally(() => toggleZoeken()); // Herstel de knop. Ook als er ondertussen iets mis ging.
const faviconCanvas = document.createElement('canvas');
faviconCanvas.width = 32;
faviconCanvas.height = 32;
const faviconCtx = faviconCanvas.getContext('2d');
}
let faviconAngle = 0;
let faviconTimer = null;
function voegToe(data) {
if (data.hasOwnProperty('error')) {
alert('↯ ' + data.error);
} else {
let pad = data.pad;
function renderFaviconFrame() {
if (!favicon || !faviconImg.complete || !faviconCtx) return;
faviconCtx.clearRect(0, 0, 32, 32);
faviconCtx.save();
faviconCtx.translate(16, 16);
faviconCtx.rotate((faviconAngle * Math.PI) / 180);
faviconCtx.drawImage(faviconImg, -16, -16, 32, 32);
faviconCtx.restore();
favicon.href = faviconCanvas.toDataURL('image/png');
faviconAngle = (faviconAngle + 12) % 360;
}
function startFaviconSpin() {
if (faviconTimer) return;
faviconAngle = 0;
faviconTimer = setInterval(renderFaviconFrame, 40);
}
function stopFaviconSpin() {
if (faviconTimer) {
clearInterval(faviconTimer);
faviconTimer = null;
}
if (favicon) {
favicon.href = faviconSrc;
}
}
// 3. Encapsulated Application State
let huidigeTaal = null;
let huidigeDoel = null;
let isSearching = false;
let totalPathsExplored = 0;
// 4. Helper: Extract input value or placeholder
function getOrDefault(inputElem) {
if (!inputElem) return '';
const val = inputElem.value.trim();
return val === '' ? (inputElem.getAttribute('placeholder') || '').trim() : val;
}
// Helper: Construct Wikipedia Article URL
function getWikiUrl(title, lang) {
const langCode = lang || getOrDefault(taal) || 'en';
const slug = encodeURIComponent(title.replace(/ /g, '_'));
return `https://${langCode}.wikipedia.org/wiki/${slug}`;
}
// 5. UI: Error & Stats Infobox Management
function showError(message, prefix = '↯ ') {
if (!errorBanner || !errorMessage) return;
errorMessage.textContent = `${prefix}${message}`;
errorBanner.hidden = false;
}
function hideError() {
if (!errorBanner) return;
errorBanner.hidden = true;
if (errorMessage) {
errorMessage.textContent = '';
}
}
function updateStats(data) {
if (!statistieken) return;
statistieken.hidden = false;
const hops = data.stappen !== undefined ? data.stappen : (Array.isArray(data.pad) ? Math.max(0, data.pad.length - 1) : 0);
if (statHops) statHops.textContent = hops;
if (statStatusText) {
if (data.status === 'success' || (!data.status && data.pad && !data.error)) {
statStatusText.textContent = `${huidigeDoel} bereikt`;
statStatusText.style.color = '#196f3d';
statStatusText.style.fontWeight = 'bold';
} else if (data.status === 'cyclus' || (data.error && data.error.includes('Cyclus'))) {
statStatusText.textContent = 'Cyclus gedetecteerd';
statStatusText.style.color = '#7d6608';
statStatusText.style.fontWeight = 'bold';
} else {
statStatusText.textContent = 'Fout / Doodlopend';
statStatusText.style.color = '#78281f';
statStatusText.style.fontWeight = 'bold';
}
}
if (statReroutesRow && statReroutes) {
if (data.herleid && data.herleid > 0) {
statReroutes.textContent = data.herleid;
statReroutesRow.hidden = false;
} else {
statReroutesRow.hidden = true;
}
}
if (statTotalPaths) {
statTotalPaths.textContent = totalPathsExplored;
}
}
// 6. UI: Dynamic Warning Visibility
function inputWaarschuwing() {
if (!waarschuwing) return;
const currentLangVal = getOrDefault(taal);
const currentTargetVal = getOrDefault(einde);
waarschuwing.hidden = (huidigeTaal === currentLangVal && huidigeDoel === currentTargetVal);
}
// 7. UI: Loading State & Animation Toggles
function setLoadingState(isLoading) {
isSearching = isLoading;
if (toevoegKnop) toevoegKnop.disabled = isLoading;
if (logo) {
if (isLoading) logo.classList.add('spinning');
else logo.classList.remove('spinning');
}
if (isLoading) {
startFaviconSpin();
hideError();
} else {
stopFaviconSpin();
}
}
// 8. DOM: Tree Node Creation
function insertTreeNode(title, parentElem, elementId, badgeText = null) {
if (!parentElem) return null;
const existingLists = parentElem.querySelectorAll(':scope > ul');
const isBranchSplit = existingLists.length >= 1;
if (isBranchSplit && parentElem.tagName === 'LI') {
parentElem.classList.add('has-split-branch');
// Mark primary branch (Branch 1) so a clean vertical connector line is drawn
if (existingLists[0] && !existingLists[0].classList.contains('branch-primary')) {
existingLists[0].classList.add('branch-primary');
}
}
const wrap = document.createElement('ul');
if (isBranchSplit) {
wrap.classList.add('branch-split');
}
const newItem = document.createElement('li');
newItem.setAttribute('item', title);
// Clickable Wikipedia Article Link
const wikiLink = document.createElement('a');
wikiLink.className = 'wiki-link';
wikiLink.href = getWikiUrl(title, huidigeTaal);
wikiLink.target = '_blank';
wikiLink.rel = 'noopener noreferrer';
wikiLink.textContent = title;
newItem.appendChild(wikiLink);
if (badgeText) {
const badge = document.createElement('span');
badge.className = 'loop-marker';
badge.textContent = badgeText;
newItem.appendChild(badge);
}
wrap.appendChild(newItem);
if (elementId !== undefined) {
wrap.id = elementId;
}
parentElem.appendChild(wrap);
// Add Collapsible toggle button to parent element
if (parentElem.tagName === 'LI' && !parentElem.querySelector(':scope > .toggle-btn')) {
const toggleBtn = document.createElement('span');
toggleBtn.className = 'toggle-btn';
toggleBtn.textContent = '˅';
toggleBtn.title = 'Klap in / uit';
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
parentElem.classList.toggle('collapsed');
});
parentElem.insertBefore(toggleBtn, parentElem.firstChild);
}
return newItem;
}
// 9. Tree Reset & Clear
function refreshTree() {
const langVal = getOrDefault(taal);
const targetVal = getOrDefault(einde);
if (huidigeDoel !== targetVal || huidigeTaal !== langVal) {
huidigeTaal = langVal;
huidigeDoel = targetVal;
totalPathsExplored = 0;
if (visualisatie) {
visualisatie.replaceChildren();
insertTreeNode(huidigeDoel, visualisatie, 'wortel');
}
if (statistieken) statistieken.hidden = true;
inputWaarschuwing();
}
}
function clearTree() {
if (visualisatie) {
visualisatie.replaceChildren();
insertTreeNode(getOrDefault(einde), visualisatie, 'wortel');
}
totalPathsExplored = 0;
if (statistieken) statistieken.hidden = true;
hideError();
inputWaarschuwing();
}
// 10. Path Rendering
function renderPath(data) {
if (!data) return;
totalPathsExplored += 1;
if (data.error && (!data.pad || data.pad.length === 0)) {
showError(data.error, '↯ ');
return;
}
const padList = Array.isArray(data.pad) ? [...data.pad] : [];
if (padList.length === 0) {
showError(data.error || 'Geen geldig pad ontvangen van de server.', '↯ ');
return;
}
let parentNode = visualisatie;
const existingNodes = visualisatie.getElementsByTagName('li');
const existingNames = Array.from(existingNodes).map(item => item.getAttribute('item'));
// Locatie van invoegen bepalen.
let moeder = visualisatie;
let kinderen = visualisatie.getElementsByTagName('li');
let kindernamen = Array.from(kinderen).map(item => item.getAttribute('item'));
let i = 0;
while (moeder === visualisatie && i < pad.length) {
if (kindernamen.indexOf(pad[i]) !== -1) {
moeder = kinderen[kindernamen.indexOf(pad[i])];
while (parentNode === visualisatie && i < padList.length) {
const foundIdx = existingNames.indexOf(padList[i]);
if (foundIdx !== -1) {
parentNode = existingNodes[foundIdx];
}
i += 1;
}
// Alleen nog die elementen toevoegen die nog niet op het pad staan.
pad = pad.slice(0, i - 1);
if (parentNode === visualisatie) {
const rootLi = visualisatie.querySelector('#wortel li');
if (rootLi) {
parentNode = rootLi;
}
}
// Nieuw element creëren (zonder toe te voegen aan het DOM).
for (let item of pad.reverse()) {
moeder = maakIn(item, moeder);
const remainingItems = padList.slice(0, i - 1);
const reversedItems = remainingItems.reverse();
for (let idx = 0; idx < reversedItems.length; idx++) {
const item = reversedItems[idx];
let badge = null;
if (idx === 0 && data.status === 'cyclus') {
badge = 'cyclus';
}
parentNode = insertTreeNode(item, parentNode, undefined, badge);
}
updateStats(data);
if (data.error) {
showError(data.error, ' ');
}
}
}
/**
* Maakt een element aan binnen een ander element, en geeft het nieuwste (diepste) element terug.
*/
function maakIn(titel, moeder, id) {
let wrap = document.createElement('ul');
let nieuw = document.createElement('li');
nieuw.innerHTML = titel;
nieuw.setAttribute('item', titel);
wrap.appendChild(nieuw);
// 11. API Search Execution
function zoek() {
if (isSearching) return;
if (id !== undefined) {
wrap.id = id;
setLoadingState(true);
refreshTree();
const verzoek = {
taal: getOrDefault(taal),
start: getOrDefault(start),
einde: getOrDefault(einde)
};
const encodedData = encodeURIComponent(JSON.stringify(verzoek));
const endpointUrl = `cgi-bin/init.py?data=${encodedData}`;
fetch(endpointUrl)
.then(response => {
if (!response.ok) {
return response.json()
.then(errJson => {
throw new Error(errJson.error || `Server HTTP ${response.status}`);
})
.catch(parseErr => {
if (parseErr.message && !parseErr.message.includes('JSON')) throw parseErr;
throw new Error(`Server HTTP ${response.status}`);
});
}
return response.json();
})
.then(data => {
renderPath(data);
})
.catch(err => {
showError(err.message || 'Er is een netwerkfout opgetreden.', '‼ ');
})
.finally(() => {
setLoadingState(false);
});
}
moeder.appendChild(wrap);
return nieuw;
}
// 12. Event Binding & Initialization
function init() {
if (toevoegKnop) toevoegKnop.addEventListener('click', zoek);
if (wisKnop) wisKnop.addEventListener('click', clearTree);
if (taal) {
taal.addEventListener('input', inputWaarschuwing);
taal.addEventListener('change', inputWaarschuwing);
}
if (einde) einde.addEventListener('input', inputWaarschuwing);
if (errorDismiss) errorDismiss.addEventListener('click', hideError);
/**
* Probeer of de huidige boom verwijderd moet worden en voeg de bestemming reeds toe aan de nieuwe boom.
*/
function refreshTree() {
if (huidigeDoel !== getOrDefault(einde) || huidigeTaal !== getOrDefault(taal)) {
huidigeTaal = getOrDefault(taal);
huidigeDoel = getOrDefault(einde);
[taal, start, einde].forEach(input => {
if (input) {
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
zoek();
}
});
}
});
visualisatie.innerHTML = '';
maakIn(huidigeDoel, visualisatie, 'wortel');
inputWaarschuwing();
refreshTree();
}
}
/**
* Toon of verberg een speciale waarschuwing die zegt of de huidige boom verwijderd zal worden
* bij een nieuwe toevoeging.
*/
function inputWaarschuwing() {
waarschuwing.hidden = (huidigeTaal === getOrDefault(taal) && huidigeDoel === getOrDefault(einde));
}
function getOrDefault(element) {
return element.value === '' ? element.getAttribute('placeholder') : element.value;
}
/**
* Toggle tussen de zoek-status en de wacht-op-input-status. Dit heeft invloed op zowel de indienkop als op het
* roterende logo. Standaardaanroep zal alles stoppen, om überveel animaties te vermijden bij fout gebruik.
*/
function toggleZoeken(stop = true) {
toevoegKnop.disabled = !stop;
rotatie = 0;
if (stop) {
clearInterval(timer);
logo.style.transform = 'rotate(0)';
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
timer = setInterval(draai, 24);
init();
}
}
function draai() {
rotatie += 5;
logo.style.transform = `rotate(${rotatie % 360}deg`;
}
})();

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
requests>=2.28.0
beautifulsoup4>=4.11.0
urllib3>=1.26.0

84
server.py Normal file
View file

@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
Python standard library HTTP server replacing legacy CGI execution.
Serves static frontend files and provides JSON API endpoint for scraper paths.
"""
import http.server
import json
import os
import sys
from urllib.parse import parse_qs, urlparse, unquote
# Add cgi-bin directory to sys.path so scraper can be imported cleanly
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'cgi-bin'))
from scraper import run
class PhikipathiaHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
parsed_url = urlparse(self.path)
# Handle API endpoints: /cgi-bin/init.py or /api/find-path
if parsed_url.path in ['/cgi-bin/init.py', '/api/find-path']:
query_params = parse_qs(parsed_url.query)
taal = 'en'
start = 'Special:Random'
einde = 'Philosophy'
if 'data' in query_params:
try:
data_raw = unquote(query_params['data'][0])
data_json = json.loads(data_raw)
taal = data_json.get('taal', taal)
start = data_json.get('start', start)
einde = data_json.get('einde', einde)
except Exception as err:
self.send_json({'error': f"Fout bij verwerken van request parameters: {str(err)}"}, status=400)
return
else:
taal = query_params.get('taal', [taal])[0]
start = query_params.get('start', [start])[0]
einde = query_params.get('einde', [einde])[0]
try:
antwoord = run(taal, start, einde)
self.send_json(antwoord)
except Exception as e:
self.send_json({'error': f"Interne serverfout: {str(e)}"}, status=500)
return
# Default: serve static files (index.html, style.css, index.js, images)
super().do_GET()
def send_json(self, data, status=200):
body = json.dumps(data).encode('utf-8')
self.send_response(status)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('X-Frame-Options', 'SAMEORIGIN')
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
# Clean logging format
sys.stderr.write(f"[{self.log_date_time_string()}] {format % args}\n")
def main():
port = int(os.environ.get('PORT', 8000))
server_address = ('', port)
httpd = http.server.HTTPServer(server_address, PhikipathiaHandler)
print(f"Phikipathia server active on http://localhost:{port}")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nServer shutting down.")
httpd.server_close()
if __name__ == '__main__':
main()

339
style.css
View file

@ -1,17 +1,16 @@
/*
Waarschijnlijk eerder de enigste file die niet deftig gedocumenteerd is.
Ach, de kans dat jullie dit bekijken is hoe dan ook klein.
*/
/* Algemeen */
body {
font-family: 'Linux Libertine','Georgia','Times',serif;
font-family: 'Linux Libertine', 'Georgia', 'Times', serif;
padding: 0 1.5em 1.5em 1.5em;
line-height: 1.6;
margin: 0;
color: #202122;
background-color: #ffffff;
}
h1 {
font-family: 'Linux Libertine', 'Georgia', 'Times', serif;
margin-bottom: 0.25em;
padding: 0;
line-height: 1.3;
@ -20,97 +19,365 @@ h1 {
border-bottom: 1px solid #a2a9b1;
}
li ul {
margin: 0.2vh 1ch 0 1ch;
padding: 0.2vh 1vw;
}
/* Header */
.header {
display: flex;
align-items: center;
gap: 0.8em;
padding: 0.5em 0;
}
.header img {
height: 1.8em;
margin-top: 0.3em;
float: left;
height: 2.2em;
width: auto;
flex-shrink: 0;
}
.header-content {
flex: 1;
}
.header h1 {
font-family: 'Linux Libertine', 'Georgia', 'Times', serif;
margin: 0;
padding: 0;
line-height: 1.2;
font-size: 1.8em;
font-weight: normal;
border-bottom: none;
}
.header p {
font-family: 'Linux Libertine', 'Georgia', 'Times', serif;
margin: 0.2em 0 0 0;
font-size: 92%;
display: block;
color: #202122;
font-family: sans-serif;
}
/* Box */
/* Loading Animation */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.spinning {
animation: spin 1.2s linear infinite;
will-change: transform;
}
/* Controls & Box Layout */
.box {
font-family: sans-serif;
font-size: 95%;
padding: 7px;
padding: 10px 14px;
border: 1px solid #a2a9b1;
background-color: #f8f9fa;
color: #202122;
position: sticky;
top: 7px;
}
.box p {
padding: 0;
margin: 0;
main > .box:first-child {
position: sticky;
top: 7px;
z-index: 10;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
}
.form-group {
display: inline-flex;
align-items: center;
gap: 4px;
}
.form-group input[type="text"],
.form-group select {
padding: 3px 6px;
border: 1px solid #a2a9b1;
border-radius: 2px;
font-size: 0.95em;
font-family: sans-serif;
background-color: #ffffff;
}
#taal {
min-width: 9em;
}
#start, #einde {
min-width: 140px;
flex: 1 1 auto;
}
/* Full-Width Statistics Box at Bottom */
.stats-box {
margin-top: 1.2em;
}
.stats-box[hidden] {
display: none !important;
}
.stats-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 24px;
font-size: 92%;
}
.stat-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.stat-label {
color: #54595d;
font-weight: bold;
}
.stat-val {
color: #202122;
}
.stats-row button#wisKnop {
margin-left: auto;
}
/* Warning & Error Banners */
#waarschuwing {
display: block;
margin-top: 8px;
font-size: 90%;
color: #54595d;
}
#waarschuwing[hidden] {
display: none !important;
}
#waarschuwing b {
color: #ba0000;
}
/* Visualisatie */
.error-banner {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
padding: 8px 12px;
background-color: #fdf2f2;
border: 1px solid #d33;
border-left: 4px solid #ba0000;
color: #721c24;
border-radius: 2px;
font-size: 90%;
animation: fadeIn 0.2s ease-in-out;
}
.error-banner[hidden] {
display: none !important;
}
.error-message {
flex: 1;
word-break: break-word;
}
.error-dismiss {
background: none;
border: none;
color: #ba0000;
font-size: 1.3em;
line-height: 1;
cursor: pointer;
padding: 0 4px;
margin-left: 8px;
}
.error-dismiss:hover {
color: #000;
text-decoration: none;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Wikipedia Table of Contents (TOC) Style Visualisatie */
#visualisatie {
margin: 0.5em 0;
font-size: 1em;
margin: 1em 0;
font-family: sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
font-size: 0.92em;
line-height: 1.7;
overflow-x: auto;
max-width: 100%;
padding-bottom: 0.5em;
}
#visualisatie ul {
list-style-type: symbols("⬑"); /* alternatieven "↪" "⬉" */
list-style-type: none;
padding-left: 1.2em;
margin: 0.15em 0;
}
ul#wortel {
padding: 0;
padding-left: 0;
list-style-type: none;
font-weight: bold;
}
ul#wortel > li > a.wiki-link {
font-weight: bold;
color: #202122;
font-size: 1.05em;
}
#visualisatie li {
padding: 0.1em 0;
position: relative;
white-space: nowrap;
}
/* Clickable Wikipedia Article Links - Wikipedia Blue */
#visualisatie a.wiki-link {
color: #3366bb;
text-decoration: none;
white-space: nowrap;
transition: color 0.1s, text-decoration 0.1s;
}
#visualisatie a.wiki-link:hover {
color: #0645ad;
text-decoration: underline;
}
#visualisatie a.wiki-link:visited {
color: #0b0080;
}
/* Pixel-Perfect Tree Connector Lines:
- Primary branch (Branch 1) draws a clean vertical line starting at parent and ending at split point.
- Secondary branch (Branch 2) draws a horizontal tick starting at x=0 that seamlessly meets the vertical line. */
#visualisatie ul.branch-primary {
border-left: 2px solid #3366bb;
padding-left: 1.2em;
margin-left: 0;
}
#visualisatie ul.branch-split {
margin-top: 6px;
padding-left: 1.2em;
position: relative;
}
#visualisatie ul.branch-split::before {
content: "";
position: absolute;
top: 0.85em;
left: 0;
width: 0.85em;
height: 0;
border-top: 2px solid #3366bb;
}
/* Collapsible Node Toggle Chevron (Wikipedia Style) */
.toggle-btn {
display: inline-block;
cursor: pointer;
user-select: none;
font-size: 70%;
margin-right: 5px;
color: #72777d;
transition: transform 0.15s ease;
width: 0.9em;
text-align: center;
vertical-align: middle;
}
.toggle-btn:hover {
color: #202122;
}
li.collapsed > ul {
display: none !important;
}
li.collapsed > .toggle-btn {
transform: rotate(-90deg);
}
/* Loop node marker in tree */
.loop-marker {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
font-size: 80%;
font-family: sans-serif;
border-radius: 2px;
background-color: #fcf3cf;
color: #7d6608;
border: 1px solid #f9e79f;
font-weight: normal;
}
/* Footer */
footer {
text-align: center;
margin-top: 1.5em;
}
/* Wikipedia-achtige knop */
/* Wikipedia Buttons */
button {
cursor: pointer;
border: none;
background: none;
color: #3366bb;
font-size: 0.95em;
}
button:disabled {
color: #ba0000;
color: #72777d;
cursor: not-allowed;
}
button:hover {
button:not(:disabled):hover {
text-decoration: underline;
}
button:before {
#toevoegKnop:before {
color: #54595d;
margin-right: 0.25em;
content: "[";
}
button:after {
#toevoegKnop:after {
color: #54595d;
margin-left: 0.25em;
content: "]";