add initial code
This commit is contained in:
40
README.md
40
README.md
@@ -1,3 +1,39 @@
|
|||||||
# grosse_haufen
|
# Grosse_Haufen — Thread-Scraper
|
||||||
|
|
||||||
Scraper für den "Große Haufen"-Thread im haustechnikdialog-Forum
|
Scraper für den "Große Haufen"-Thread im Haustechnikdialog-Forum
|
||||||
|
|
||||||
|
- `scrape.py`: Hauptskript — lädt Thread-Seiten, parst Beiträge aus der Forumstabelle und schreibt `thread_posts.json`.
|
||||||
|
- `requirements.txt`: minimale Abhängigkeiten (`requests`, `beautifulsoup4`).
|
||||||
|
- `thread_posts.json`: exportierte Beiträge (Array von Objekten mit `page`, `author`, `timestamp`, `likes`, `post_id`, `text`).
|
||||||
|
- `thread_posts.html`: einfache lokale Viewer-Seite, zeigt alle Posts untereinander (falls `fetch` scheitert: Dateiupload nutzen).
|
||||||
|
|
||||||
|
Kurzanleitung
|
||||||
|
|
||||||
|
1. Abhängigkeiten installieren:
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Scraper starten (erzeugt `thread_posts.json`):
|
||||||
|
|
||||||
|
```
|
||||||
|
python scrape.py
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Viewer öffnen (lokaler Server empfohlen):
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m http.server 8000
|
||||||
|
# dann öffnen: http://localhost:8000/thread_posts.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Kontext & kultureller Wert
|
||||||
|
|
||||||
|
Der gescrapte Thread "Große Haufen" (HaustechnikDialog, 2005) ist ein charakteristisches Beispiel früher deutscher Forenkommunikation: er zeigt kollektiven Humor, ironische Überzeichnungen und die Art, wie sich Themen in Communitys viral verbreiten. Solche Threads sind wertvoll für die Untersuchung von Netzkultur, Meme-Entwicklung, Diskursdynamiken und sprachlicher Variation im deutschsprachigen Web. Bei Nutzung zu Forschungszwecken bitte Quellen und Urheberrechte beachten; vermeide Publikationen, die sensible Inhalte ohne Kontext reproduzieren.
|
||||||
|
|
||||||
|
Hinweis
|
||||||
|
|
||||||
|
Dieses Projekt ist als Experiment/Archiv gedacht. Wenn du das Scrapen für andere Seiten verwendest, respektiere die Nutzungsbedingungen, Robots-Policies und Datenschutz.
|
||||||
|
|
||||||
|
Wenn du möchtest, kann ich die Timestamps in ISO-Format umwandeln oder zusätzliche Felder (z. B. `author_id`) extrahieren.
|
||||||
|
|||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
requests
|
||||||
|
beautifulsoup4
|
||||||
321
scrape.py
Normal file
321
scrape.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import re
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from typing import List, Optional
|
||||||
|
from urllib.parse import urljoin, urlparse, parse_qs
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
|
THREAD_URL = "https://www.haustechnikdialog.de/Forum/t/19886/Grosse-Haufen"
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (compatible; HTD-ThreadScraper/3.0)"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Post:
|
||||||
|
page: int
|
||||||
|
author: str
|
||||||
|
timestamp: str
|
||||||
|
likes: int
|
||||||
|
post_id: str
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
TIME_RE = re.compile(r"^Zeit:\s*(\d{2}\.\d{2}\.\d{4}\s+\d{2}:\d{2}:\d{2})\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_html(url: str, session: requests.Session, timeout: int = 30) -> str:
|
||||||
|
r = session.get(url, headers=HEADERS, timeout=timeout)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.text
|
||||||
|
|
||||||
|
|
||||||
|
def detect_max_page(html: str, base_url: str) -> int:
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
pages = set()
|
||||||
|
for a in soup.select('a[href*="page="]'):
|
||||||
|
href = a.get("href")
|
||||||
|
if not href:
|
||||||
|
continue
|
||||||
|
full = urljoin(base_url, href)
|
||||||
|
qs = parse_qs(urlparse(full).query)
|
||||||
|
for v in qs.get("page", []):
|
||||||
|
if v.isdigit():
|
||||||
|
pages.add(int(v))
|
||||||
|
return max(pages) if pages else 1
|
||||||
|
|
||||||
|
|
||||||
|
def page_url(base: str, page: int) -> str:
|
||||||
|
return base if page == 1 else f"{base}?page={page}"
|
||||||
|
|
||||||
|
|
||||||
|
def table_to_lines(table) -> List[str]:
|
||||||
|
"""
|
||||||
|
Konvertiert NUR den Tabelleninhalt in Zeilen.
|
||||||
|
Vorteil: Keine Footer/Nav/Sidebar-Texte.
|
||||||
|
"""
|
||||||
|
# get_text mit separator="\n" macht es viel stabiler als .text
|
||||||
|
txt = table.get_text("\n")
|
||||||
|
txt = txt.replace("\r", "\n")
|
||||||
|
# Whitespace normalisieren
|
||||||
|
txt = re.sub(r"[ \t]+", " ", txt)
|
||||||
|
txt = re.sub(r"\n{3,}", "\n\n", txt)
|
||||||
|
lines = [ln.strip() for ln in txt.split("\n")]
|
||||||
|
# Leere Zeilen nicht komplett entfernen, aber trimmen ist ok
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def parse_posts_from_lines(lines: List[str], page_num: int) -> List[Post]:
|
||||||
|
"""
|
||||||
|
State-Machine auf Basis der bekannten 'Verfasser:' / 'Zeit:' Struktur,
|
||||||
|
aber NUR innerhalb table.tablebeitraege.
|
||||||
|
"""
|
||||||
|
posts: List[Post] = []
|
||||||
|
i = 0
|
||||||
|
seen_ids = set()
|
||||||
|
|
||||||
|
def skip_empty(idx: int) -> int:
|
||||||
|
while idx < len(lines) and lines[idx] == "":
|
||||||
|
idx += 1
|
||||||
|
return idx
|
||||||
|
|
||||||
|
while i < len(lines):
|
||||||
|
if lines[i] != "Verfasser:":
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# author
|
||||||
|
i += 1
|
||||||
|
i = skip_empty(i)
|
||||||
|
if i >= len(lines):
|
||||||
|
break
|
||||||
|
author = lines[i].strip()
|
||||||
|
|
||||||
|
# Manche Autorenzeilen haben noch "Image: Registrierter..." daneben/drunter -> nur Namen nehmen.
|
||||||
|
# (Das ist heuristisch, aber in der Praxis gut.)
|
||||||
|
author = re.split(r"\s{2,}|Image:|Registrierter", author, maxsplit=1)[0].strip()
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# time line
|
||||||
|
i = skip_empty(i)
|
||||||
|
if i >= len(lines):
|
||||||
|
break
|
||||||
|
|
||||||
|
# Manche Seiten haben die Beschriftung "Zeit:" in einer eigenen Zelle/Zeile
|
||||||
|
# und die eigentliche Zeit steht in der nächsten Zeile. Behandle diesen Fall.
|
||||||
|
if lines[i].strip() == "Zeit:":
|
||||||
|
# nächste nicht-leere Zeile als Zeit verwenden
|
||||||
|
j = i + 1
|
||||||
|
while j < len(lines) and lines[j].strip() == "":
|
||||||
|
j += 1
|
||||||
|
if j >= len(lines):
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
candidate_line = lines[j].strip()
|
||||||
|
mm = re.search(r"\d{1,2}\.\d{1,2}\.\d{2,4}[,]?\s*\d{1,2}:\d{2}(?::\d{2})?", candidate_line)
|
||||||
|
ts = mm.group(0) if mm else candidate_line
|
||||||
|
i = j + 1
|
||||||
|
else:
|
||||||
|
m = TIME_RE.match(lines[i])
|
||||||
|
if not m:
|
||||||
|
# falls Layout mal anders: versuche "Zeit:" irgendwo in der Zeile
|
||||||
|
if "Zeit:" in lines[i]:
|
||||||
|
candidate = lines[i].split("Zeit:", 1)[1].strip()
|
||||||
|
mm = re.search(r"\d{1,2}\.\d{1,2}\.\d{2,4}[,]?\s*\d{1,2}:\d{2}(?::\d{2})?", candidate)
|
||||||
|
ts = mm.group(0) if mm else candidate
|
||||||
|
else:
|
||||||
|
# Suche in dieser und den nächsten zwei Zeilen nach einem Datum
|
||||||
|
ts = ""
|
||||||
|
for j in range(i, min(i + 3, len(lines))):
|
||||||
|
mm = re.search(r"\d{1,2}\.\d{1,2}\.\d{2,4}[,]?\s*\d{1,2}:\d{2}(?::\d{2})?", lines[j])
|
||||||
|
if mm:
|
||||||
|
ts = mm.group(0)
|
||||||
|
break
|
||||||
|
if not ts:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
ts = m.group(1)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# likes (nächste reine Zahl)
|
||||||
|
i = skip_empty(i)
|
||||||
|
while i < len(lines) and not lines[i].isdigit():
|
||||||
|
i += 1
|
||||||
|
if i >= len(lines):
|
||||||
|
break
|
||||||
|
likes = int(lines[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# post_id (nächste reine Zahl)
|
||||||
|
i = skip_empty(i)
|
||||||
|
while i < len(lines) and not lines[i].isdigit():
|
||||||
|
i += 1
|
||||||
|
if i >= len(lines):
|
||||||
|
break
|
||||||
|
post_id = lines[i]
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Dedup (innerhalb einer Seite)
|
||||||
|
if post_id in seen_ids:
|
||||||
|
# body überspringen
|
||||||
|
while i < len(lines) and lines[i] != "Verfasser:":
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
seen_ids.add(post_id)
|
||||||
|
|
||||||
|
# optional "Image:" oder leere Zeilen überspringen
|
||||||
|
i = skip_empty(i)
|
||||||
|
while i < len(lines) and lines[i].startswith("Image:"):
|
||||||
|
i += 1
|
||||||
|
i = skip_empty(i)
|
||||||
|
|
||||||
|
# body sammeln bis zum nächsten "Verfasser:" oder Ende
|
||||||
|
body_lines: List[str] = []
|
||||||
|
while i < len(lines) and lines[i] != "Verfasser:":
|
||||||
|
if lines[i] != "":
|
||||||
|
body_lines.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
body = "\n".join(body_lines).strip()
|
||||||
|
body = re.sub(r"\n{3,}", "\n\n", body)
|
||||||
|
|
||||||
|
posts.append(Post(
|
||||||
|
page=page_num,
|
||||||
|
author=author,
|
||||||
|
timestamp=ts,
|
||||||
|
likes=likes,
|
||||||
|
post_id=post_id,
|
||||||
|
text=body
|
||||||
|
))
|
||||||
|
|
||||||
|
return posts
|
||||||
|
|
||||||
|
|
||||||
|
def parse_page_posts(html: str, page_num: int) -> List[Post]:
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
|
||||||
|
# genau die Tabelle(n), die die Beiträge enthalten
|
||||||
|
tables = soup.select("table.tablebeitraege")
|
||||||
|
if not tables:
|
||||||
|
raise RuntimeError("Keine table.tablebeitraege gefunden (evtl. Cookie-Wall / Layout geändert).")
|
||||||
|
|
||||||
|
all_posts: List[Post] = []
|
||||||
|
|
||||||
|
for table in tables:
|
||||||
|
rows = table.find_all("tr")
|
||||||
|
seen_ids = set()
|
||||||
|
|
||||||
|
for idx, tr in enumerate(rows):
|
||||||
|
tr_id = (tr.get("id") or "")
|
||||||
|
# Erkenne Kopfzeilen für Posts: id enthält '_trPostHead' oder die Zelle enthält 'Verfasser:'
|
||||||
|
is_head = "trPostHead" in tr_id or tr.select_one("span.fontcolor") and "Verfasser:" in tr.get_text()
|
||||||
|
if not is_head:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tds = tr.find_all("td")
|
||||||
|
if not tds:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Links: Verfasser
|
||||||
|
left_td = tds[0]
|
||||||
|
# bevorzugt sichtbaren Benutzernamen in .hl oder <a>
|
||||||
|
author_el = left_td.select_one(".hl") or left_td.select_one("a") or left_td.find("span")
|
||||||
|
author = author_el.get_text(strip=True) if author_el else left_td.get_text(" ", strip=True)
|
||||||
|
# Aufräumen: entferne 'Verfasser:' Wortteile
|
||||||
|
author = re.sub(r"^Verfasser:\s*", "", author, flags=re.I).strip()
|
||||||
|
|
||||||
|
# Rechts: Zeit / Likes / Post-ID
|
||||||
|
right_td = tds[1] if len(tds) > 1 else left_td
|
||||||
|
right_text = right_td.get_text(" ", strip=True)
|
||||||
|
|
||||||
|
# timestamp: suche nach Datum/Zeit im Text
|
||||||
|
m = re.search(r"\d{1,2}\.\d{1,2}\.\d{2,4}[,]?\s*\d{1,2}:\d{2}(?::\d{2})?", right_text)
|
||||||
|
ts = m.group(0) if m else ""
|
||||||
|
|
||||||
|
# likes: oft in .fr-buttons > span
|
||||||
|
likes = 0
|
||||||
|
fr_buttons = right_td.select_one(".fr-buttons")
|
||||||
|
if fr_buttons:
|
||||||
|
num = fr_buttons.find("span")
|
||||||
|
if num and num.get_text(strip=True).isdigit():
|
||||||
|
likes = int(num.get_text(strip=True))
|
||||||
|
|
||||||
|
# post_id: versteckt in einem input (hfPostId) oder als Zahl im rechten Bereich
|
||||||
|
post_id = ""
|
||||||
|
hid = tr.find("input", attrs={"id": re.compile(r"hfPostId$")}) or right_td.find("input", attrs={"id": re.compile(r"hfPostId$")})
|
||||||
|
if hid and hid.get("value"):
|
||||||
|
post_id = hid.get("value")
|
||||||
|
else:
|
||||||
|
m2 = re.search(r"\b(\d{5,9})\b", right_text)
|
||||||
|
if m2:
|
||||||
|
post_id = m2.group(1)
|
||||||
|
|
||||||
|
if not post_id:
|
||||||
|
# kein gültiges Post-ID — überspringen
|
||||||
|
continue
|
||||||
|
if post_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(post_id)
|
||||||
|
|
||||||
|
# body ist im nächsten TR (üblicherweise), suche nach .divB
|
||||||
|
body = ""
|
||||||
|
if idx + 1 < len(rows):
|
||||||
|
nexttr = rows[idx + 1]
|
||||||
|
divb = nexttr.select_one(".divB") or nexttr.find_all("td") and nexttr.find_all("td")[0]
|
||||||
|
if divb:
|
||||||
|
body = divb.get_text("\n", strip=True)
|
||||||
|
body = re.sub(r"\n{2,}", "\n\n", body)
|
||||||
|
|
||||||
|
all_posts.append(Post(
|
||||||
|
page=page_num,
|
||||||
|
author=author,
|
||||||
|
timestamp=ts,
|
||||||
|
likes=likes,
|
||||||
|
post_id=post_id,
|
||||||
|
text=body,
|
||||||
|
))
|
||||||
|
|
||||||
|
return all_posts
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_thread(thread_url: str, sleep_s: float = 1.0) -> List[Post]:
|
||||||
|
out: List[Post] = []
|
||||||
|
seen_global = set()
|
||||||
|
|
||||||
|
with requests.Session() as session:
|
||||||
|
html1 = fetch_html(thread_url, session)
|
||||||
|
max_page = detect_max_page(html1, thread_url)
|
||||||
|
|
||||||
|
for p in range(1, max_page + 1):
|
||||||
|
url = page_url(thread_url, p)
|
||||||
|
html = html1 if p == 1 else fetch_html(url, session)
|
||||||
|
|
||||||
|
posts = parse_page_posts(html, p)
|
||||||
|
|
||||||
|
# Global dedup über post_id (sollte bei sauberer Tabellenbegrenzung i.d.R. nichts mehr finden)
|
||||||
|
for post in posts:
|
||||||
|
if post.post_id not in seen_global:
|
||||||
|
seen_global.add(post.post_id)
|
||||||
|
out.append(post)
|
||||||
|
|
||||||
|
print(f"[OK] Seite {p}/{max_page}: {len(posts)} Posts (unique so far: {len(out)})")
|
||||||
|
time.sleep(sleep_s)
|
||||||
|
|
||||||
|
# optional sortieren
|
||||||
|
out.sort(key=lambda x: int(x.post_id))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
posts = scrape_thread(THREAD_URL, sleep_s=1.0)
|
||||||
|
|
||||||
|
with open("thread_posts.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump([asdict(p) for p in posts], f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"Fertig. Insgesamt eindeutige Posts: {len(posts)}")
|
||||||
123
thread_posts.html
Normal file
123
thread_posts.html
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Thread Posts — Liste</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;line-height:1.4;margin:0;padding:20px;background:#f7f7f8;color:#111}
|
||||||
|
.wrap{max-width:900px;margin:0 auto}
|
||||||
|
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
|
||||||
|
h1{font-size:18px;margin:0}
|
||||||
|
.controls{display:flex;gap:8px}
|
||||||
|
.post{background:#fff;border:1px solid #e3e3e6;padding:12px 14px;margin-bottom:12px;border-radius:6px}
|
||||||
|
.meta{font-size:13px;color:#555;margin-bottom:8px;display:flex;gap:12px;flex-wrap:wrap}
|
||||||
|
.text{white-space:pre-wrap;font-size:15px}
|
||||||
|
.small{font-size:13px;color:#666}
|
||||||
|
.file-load{display:none;margin-top:8px}
|
||||||
|
footer{margin-top:20px;color:#666;font-size:13px}
|
||||||
|
button{padding:6px 10px;border-radius:6px;border:1px solid #bbb;background:#fff;cursor:pointer}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<h1>Thread Posts — alle Beiträge untereinander</h1>
|
||||||
|
<div class="controls">
|
||||||
|
<button id="reload">Neu laden</button>
|
||||||
|
<button id="openFileBtn">Lokale JSON laden</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="status" class="small">Versuche, <code>thread_posts.json</code> zu laden…</div>
|
||||||
|
|
||||||
|
<div id="fileLoad" class="file-load">
|
||||||
|
<input id="fileInput" type="file" accept="application/json">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main id="list"></main>
|
||||||
|
|
||||||
|
<footer>Hinweis: Wenn Sie die Datei lokal öffnen und `fetch` fehlschlägt, nutzen Sie bitte die Schaltfläche "Lokale JSON laden" oder starten einen kleinen HTTP-Server.</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const list = document.getElementById('list');
|
||||||
|
const fileLoad = document.getElementById('fileLoad');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const reloadBtn = document.getElementById('reload');
|
||||||
|
const openFileBtn = document.getElementById('openFileBtn');
|
||||||
|
|
||||||
|
function renderPosts(posts){
|
||||||
|
list.innerHTML = '';
|
||||||
|
if(!Array.isArray(posts)){
|
||||||
|
status.textContent = 'JSON hat kein Array von Posts.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.textContent = `Beiträge: ${posts.length}`;
|
||||||
|
|
||||||
|
posts.forEach(p => {
|
||||||
|
const el = document.createElement('article');
|
||||||
|
el.className = 'post';
|
||||||
|
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'meta';
|
||||||
|
meta.innerHTML = `
|
||||||
|
<strong>${escapeHtml(String(p.author||'—'))}</strong>
|
||||||
|
<span>${escapeHtml(String(p.timestamp||'—'))}</span>
|
||||||
|
<span>Likes: ${escapeHtml(String(p.likes||0))}</span>
|
||||||
|
<span>ID: ${escapeHtml(String(p.post_id||'—'))}</span>
|
||||||
|
<span>Seite: ${escapeHtml(String(p.page||1))}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'text';
|
||||||
|
body.textContent = p.text || '';
|
||||||
|
|
||||||
|
el.appendChild(meta);
|
||||||
|
el.appendChild(body);
|
||||||
|
list.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s){
|
||||||
|
return s.replace(/[&<>\"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryFetch(){
|
||||||
|
try{
|
||||||
|
const resp = await fetch('thread_posts.json', {cache: 'no-store'});
|
||||||
|
if(!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
const data = await resp.json();
|
||||||
|
renderPosts(data);
|
||||||
|
}catch(e){
|
||||||
|
console.warn('fetch failed', e);
|
||||||
|
status.textContent = 'Laden per fetch fehlgeschlagen — wähle Datei manuell.';
|
||||||
|
fileLoad.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', e => {
|
||||||
|
const f = e.target.files && e.target.files[0];
|
||||||
|
if(!f) return;
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = ev => {
|
||||||
|
try{
|
||||||
|
const data = JSON.parse(ev.target.result);
|
||||||
|
renderPosts(data);
|
||||||
|
}catch(err){
|
||||||
|
status.textContent = 'Ungültiges JSON.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
r.readAsText(f, 'utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
openFileBtn.addEventListener('click', ()=> fileInput.click());
|
||||||
|
reloadBtn.addEventListener('click', ()=> { status.textContent = 'Neu laden…'; tryFetch(); });
|
||||||
|
|
||||||
|
// initial
|
||||||
|
tryFetch();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1242
thread_posts.json
Normal file
1242
thread_posts.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user