This commit is contained in:
2026-09-02 14:19:09 +02:00
commit 476b1cd28a
11 changed files with 23464 additions and 0 deletions
Binary file not shown.
Binary file not shown.
+214
View File
@@ -0,0 +1,214 @@
"""Baut aus den GDELT-Feeds eine story-zentrierte JSON-Datei fuer die Website.
python build_site.py # heute (UTC)
python build_site.py --date 20260901 --limit 120
Joint die Event-Tabelle mit den Artikeltiteln aus dem GKG-Feed, verdichtet
auf Artikel-Ebene und uebersetzt die CAMEO-Codes in Klartext.
"""
import argparse
import datetime as dt
import io
import json
import re
import urllib.error
import urllib.request
import zipfile
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pandas as pd
from load_europe import EUROPE, TRANSCONTINENTAL, fetch_slot, slots
from tracker import NUMERIC
GKG_URL = "http://data.gdeltproject.org/gdeltv2/{}.gkg.csv.zip"
CAMEO_JSON = (Path(__file__).parent / '.venv' / 'lib' / 'python3.14'
/ 'site-packages' / 'data' / 'cameoCodes.json')
# QuadClass: verbal/material x kooperativ/konfliktiv
QUAD = {'1': 'verbal kooperativ', '2': 'material kooperativ',
'3': 'verbal konfliktiv', '4': 'material konfliktiv'}
TITLE_RE = re.compile(r'<PAGE_TITLE>(.*?)</PAGE_TITLE>', re.S)
def cameo_labels():
"""{EventCode: (Beschreibung, GoldsteinScale)} aus dem CAMEO-Codebook."""
raw = json.loads(CAMEO_JSON.read_text())
df = pd.DataFrame(raw)
return {str(k): (v['Description'], v['GoldsteinScale'])
for k, v in df.to_dict('index').items()}
def fetch_titles(stamp):
"""{url: titel} aus einem GKG-Paket. Leer, wenn das Paket fehlt."""
try:
with urllib.request.urlopen(GKG_URL.format(stamp), timeout=120) as r:
raw = r.read()
except urllib.error.HTTPError as err:
if err.code == 404:
return {}
raise
z = zipfile.ZipFile(io.BytesIO(raw))
text = z.open(z.namelist()[0]).read().decode('utf-8', 'replace')
out = {}
for line in text.split('\n'):
cols = line.split('\t')
if len(cols) < 27:
continue
m = TITLE_RE.search(cols[-1])
if m:
out[cols[4]] = html_unescape(m.group(1).strip())
return out
def html_unescape(s):
import html
# GKG kodiert doppelt: &#x2014; und &amp;#x2014; kommen beide vor
return html.unescape(html.unescape(s))
def build_stories(events, titles, cameo, limit):
"""Verdichtet Event-Zeilen auf Artikel-Ebene."""
events = events.copy()
events['title'] = events['SOURCEURL'].map(titles)
events['domain'] = events['SOURCEURL'].str.extract(r'https?://(?:www\.)?([^/]+)')
stories = []
for url, grp in events.groupby('SOURCEURL'):
top = grp.loc[grp['NumArticles'].idxmax()]
seen = set()
acts = []
for _, e in grp.sort_values('NumArticles', ascending=False).iterrows():
desc, gold = cameo.get(str(e.EventCode), (f'CAMEO {e.EventCode}', None))
key = (e.Actor1Name, e.Actor2Name, e.EventCode)
if key in seen:
continue
seen.add(key)
acts.append({
'code': e.EventCode, 'label': desc,
'goldstein': None if pd.isna(e.GoldsteinScale) else float(e.GoldsteinScale),
'quad': QUAD.get(str(e.QuadClass), ''),
'actor1': None if pd.isna(e.Actor1Name) else e.Actor1Name.title(),
'actor2': None if pd.isna(e.Actor2Name) else e.Actor2Name.title(),
})
stories.append({
'url': url,
'title': top['title'] if isinstance(top['title'], str) else None,
'domain': top['domain'],
'country': top['ActionGeo_Country'],
'place': top['ActionGeo_FullName'],
'lat': None if pd.isna(top['ActionGeo_Lat']) else float(top['ActionGeo_Lat']),
'lon': None if pd.isna(top['ActionGeo_Long']) else float(top['ActionGeo_Long']),
'seen': str(top['DATEADDED']),
'articles': int(grp['NumArticles'].max()),
'mentions': int(grp['NumMentions'].max()),
'tone': round(float(grp['AvgTone'].mean()), 2),
'goldstein': round(float(grp['GoldsteinScale'].mean()), 2),
'events': acts,
'event_rows': len(grp),
})
stories = cluster_by_title(stories)
# Stories ohne Artikeltitel sind fuer Leser wertlos - der GKG-Join hat
# sie nicht gefunden, weil der Artikel aus einem aelteren Paket stammt.
stories = [s for s in stories if s['title']]
stories.sort(key=lambda s: (-s['articles'], -s['mentions']))
return stories[:limit]
def norm_title(t):
"""Titel auf Vergleichsform bringen: Kleinschreibung, ohne Satzzeichen
und ohne den bei vielen Quellen angehaengten Blattnamen."""
t = t.lower().split(' - ')[0].split(' | ')[0]
return re.sub(r'[^a-z0-9]+', ' ', t).strip()
def cluster_by_title(stories):
"""Syndizierte Agenturmeldungen zusammenfassen: gleicher Titel, andere Quelle.
Der Cluster behaelt die staerkste Variante; die uebrigen Quellen haengen
als 'sources' daran, ihre Events werden mit eingesammelt.
"""
clusters = {}
for s in stories:
key = norm_title(s['title']) if s['title'] else s['url']
clusters.setdefault(key, []).append(s)
merged = []
for group in clusters.values():
group.sort(key=lambda s: -s['articles'])
lead = dict(group[0])
lead['sources'] = [{'domain': g['domain'], 'url': g['url']} for g in group]
lead['event_rows'] = sum(g['event_rows'] for g in group)
if len(group) > 1:
seen = set()
acts = []
for g in group:
for e in g['events']:
key = (e['actor1'], e['actor2'], e['code'])
if key not in seen:
seen.add(key)
acts.append(e)
lead['events'] = acts
lead['tone'] = round(sum(g['tone'] for g in group) / len(group), 2)
merged.append(lead)
return merged
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--date')
p.add_argument('--limit', type=int, default=100, help='Anzahl Stories in der Ausgabe')
p.add_argument('--workers', type=int, default=8)
p.add_argument('--out', default='site/stories.json')
args = p.parse_args()
now = dt.datetime.now(dt.timezone.utc)
date = (dt.datetime.strptime(args.date, "%Y%m%d").date() if args.date
else now.date())
stamps = slots(date, until=now if date == now.date() else None)
codes = {**EUROPE, **TRANSCONTINENTAL}
print(f"{date}: {len(stamps)} Pakete")
with ThreadPoolExecutor(max_workers=args.workers) as pool:
ev_results = list(pool.map(fetch_slot, stamps))
world = pd.concat([df for df, _ in ev_results if df is not None],
ignore_index=True)
events = world[world['ActionGeo_CountryCode'].isin(codes)].copy()
for col in NUMERIC:
events[col] = pd.to_numeric(events[col], errors='coerce')
events['ActionGeo_Country'] = events['ActionGeo_CountryCode'].map(codes)
print(f" {len(events):,} Europa-Events aus {len(world):,} weltweit")
print(" GKG-Titel laden (das ist der grosse Teil)...")
with ThreadPoolExecutor(max_workers=args.workers) as pool:
maps = list(pool.map(fetch_titles, stamps))
titles = {}
for m in maps:
titles.update(m)
print(f" {len(titles):,} Titel im GKG")
stories = build_stories(events, titles, cameo_labels(), args.limit)
hit = sum(1 for s in stories if s['title'])
payload = {
'generated': now.strftime('%Y-%m-%dT%H:%M:%SZ'),
'date': str(date),
'slots': len(stamps),
'events_world': int(len(world)),
'events_europe': int(len(events)),
'articles_total': int(events['SOURCEURL'].nunique()),
'countries': events['ActionGeo_Country'].value_counts().head(14).to_dict(),
'top_codes': (events['EventCode'].map(lambda c: cameo_labels().get(str(c), ('?', None))[0])
.value_counts().head(8).to_dict()),
'stories': stories,
}
Path(args.out).write_text(json.dumps(payload, ensure_ascii=False, indent=1))
print(f" {len(stories)} Stories, davon {hit} mit Titel -> {args.out}")
if __name__ == '__main__':
main()
BIN
View File
Binary file not shown.
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
# Basic use and new schema method
import gdelt
import geopandas as gpd
def main():
gd = gdelt.gdelt()
# NOTE: output='gpd' is broken in gdelt 0.1.14 with current numpy/pandas
# (its parallel geometry builder feeds numpy arrays into DataFrame.apply,
# and it still uses the removed crs={'init': ...} form). We pull plain
# pandas and build the GeoDataFrame ourselves instead.
events = gd.Search(['2017 May 23'], table='events', output='pandas',
normcols=True, coverage=False)
events = events[events['actiongeolat'].notnull()
& events['actiongeolong'].notnull()]
events = gpd.GeoDataFrame(
events,
geometry=gpd.points_from_xy(events['actiongeolong'],
events['actiongeolat']),
crs='EPSG:4326',
)
print(events.shape)
print(events[['globaleventid', 'actiongeofullname', 'geometry']].head())
# new schema method
print(gd.schema('events'))
if __name__ == '__main__':
main()
+120
View File
@@ -0,0 +1,120 @@
"""Laedt alle GDELT-2.0-Events eines Tages und filtert auf Europa.
python load_europe.py # heute (UTC), bis zum letzten Paket
python load_europe.py --date 20260901 # ein voller vergangener Tag
python load_europe.py --no-transcontinental # ohne Russland und Tuerkei
Schreibt events_europe_<datum>.csv.gz ins Projektverzeichnis.
"""
import argparse
import datetime as dt
import gzip
import io
import urllib.error
import urllib.request
import zipfile
from concurrent.futures import ThreadPoolExecutor
import pandas as pd
from tracker import COLUMNS, NUMERIC
BASE = "http://data.gdeltproject.org/gdeltv2/{}.export.CSV.zip"
# FIPS-10-4-Codes, wie sie in ActionGeo_CountryCode stehen (NICHT ISO!)
EUROPE = {
'AL': 'Albanien', 'AN': 'Andorra', 'AU': 'Oesterreich', 'BE': 'Belgien',
'BK': 'Bosnien-Herzegowina', 'BO': 'Belarus', 'BU': 'Bulgarien',
'CY': 'Zypern', 'DA': 'Daenemark', 'EI': 'Irland', 'EN': 'Estland',
'EZ': 'Tschechien', 'FI': 'Finnland', 'FO': 'Faeroeer', 'FR': 'Frankreich',
'GI': 'Gibraltar', 'GK': 'Guernsey', 'GM': 'Deutschland', 'GR': 'Griechenland',
'HR': 'Kroatien', 'HU': 'Ungarn', 'IC': 'Island', 'IM': 'Isle of Man',
'IT': 'Italien', 'JE': 'Jersey', 'KV': 'Kosovo', 'LG': 'Lettland',
'LH': 'Litauen', 'LO': 'Slowakei', 'LS': 'Liechtenstein', 'LU': 'Luxemburg',
'MD': 'Moldau', 'MJ': 'Montenegro', 'MK': 'Nordmazedonien', 'MN': 'Monaco',
'MT': 'Malta', 'NL': 'Niederlande', 'NO': 'Norwegen', 'PL': 'Polen',
'PO': 'Portugal', 'RI': 'Serbien', 'RO': 'Rumaenien', 'SI': 'Slowenien',
'SM': 'San Marino', 'SP': 'Spanien', 'SV': 'Svalbard', 'SW': 'Schweden',
'SZ': 'Schweiz', 'UK': 'Vereinigtes Koenigreich', 'UP': 'Ukraine',
'VT': 'Vatikanstadt',
}
# transkontinental - je nach Fragestellung mitzaehlen oder nicht
TRANSCONTINENTAL = {'RS': 'Russland', 'TU': 'Tuerkei'}
def slots(date, until=None):
"""Alle 15-Minuten-Zeitstempel eines Tages, optional bis 'until' (UTC)."""
start = dt.datetime.combine(date, dt.time.min, tzinfo=dt.timezone.utc)
out = []
for i in range(96):
ts = start + dt.timedelta(minutes=15 * i)
if until and ts > until:
break
out.append(ts.strftime("%Y%m%d%H%M%S"))
return out
def fetch_slot(stamp):
"""Ein Paket laden. Gibt (DataFrame|None, bytes) zurueck; None bei 404."""
try:
with urllib.request.urlopen(BASE.format(stamp), timeout=90) as r:
raw = r.read()
except urllib.error.HTTPError as err:
if err.code == 404:
return None, 0 # Slot fehlt oder noch nicht hochgeladen
raise
z = zipfile.ZipFile(io.BytesIO(raw))
df = pd.read_csv(z.open(z.namelist()[0]), sep='\t', header=None,
names=COLUMNS, dtype=str)
return df, len(raw)
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--date', help='YYYYMMDD, default heute (UTC)')
p.add_argument('--no-transcontinental', action='store_true',
help='Russland und Tuerkei ausschliessen')
p.add_argument('--workers', type=int, default=8)
p.add_argument('--out', help='Zieldatei, default events_europe_<datum>.csv.gz')
args = p.parse_args()
now = dt.datetime.now(dt.timezone.utc)
date = (dt.datetime.strptime(args.date, "%Y%m%d").date() if args.date
else now.date())
stamps = slots(date, until=now if date == now.date() else None)
codes = dict(EUROPE)
if not args.no_transcontinental:
codes.update(TRANSCONTINENTAL)
print(f"{date}: {len(stamps)} Pakete a 15 Minuten")
with ThreadPoolExecutor(max_workers=args.workers) as pool:
results = list(pool.map(fetch_slot, stamps))
frames = [df for df, _ in results if df is not None]
missing = sum(1 for df, _ in results if df is None)
downloaded = sum(n for _, n in results)
world = pd.concat(frames, ignore_index=True)
europe = world[world['ActionGeo_CountryCode'].isin(codes)].copy()
for col in NUMERIC:
europe[col] = pd.to_numeric(europe[col], errors='coerce')
europe['ActionGeo_Country'] = europe['ActionGeo_CountryCode'].map(codes)
out = args.out or f"events_europe_{date:%Y%m%d}.csv.gz"
europe.to_csv(out, index=False, compression='gzip')
print(f"geladen: {len(world):>8,} Events weltweit "
f"({downloaded / 1e6:.1f} MB gezippt, {missing} Pakete fehlten)")
print(f"Europa: {len(europe):>8,} Events "
f"({len(europe) / len(world):.1%})")
print(f"eindeutige Event-IDs: {europe['GLOBALEVENTID'].nunique():,}")
print(f"geschrieben: {out}")
print()
print("Top-Laender:")
print(europe['ActionGeo_Country'].value_counts().head(12).to_string())
if __name__ == '__main__':
main()
+374
View File
@@ -0,0 +1,374 @@
<title>Lagebild Europa</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,500;6..72,600&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
<style>
:root{
--paper:#e9ecf1; --surface:#ffffff; --sunk:#dfe4ec;
--ink:#151a23; --muted:#5a6474; --faint:#8d97a6;
--line:#ccd3dd; --hair:#dde2ea;
--accent:#2f4d7a; --accent-soft:#dbe3f1;
--conflict:#a53f31; --coop:#1c6c58; --neutralbar:#98a3b3;
--focus:#2f4d7a;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--paper:#11151c; --surface:#181d26; --sunk:#1f2631;
--ink:#e4e8ef; --muted:#8e99ab; --faint:#6d7889;
--line:#28303c; --hair:#222a35;
--accent:#84a8e0; --accent-soft:#22304a;
--conflict:#e08272; --coop:#54b596; --neutralbar:#5d6875;
--focus:#84a8e0;
}
}
:root[data-theme="dark"]{
--paper:#11151c; --surface:#181d26; --sunk:#1f2631;
--ink:#e4e8ef; --muted:#8e99ab; --faint:#6d7889;
--line:#28303c; --hair:#222a35;
--accent:#84a8e0; --accent-soft:#22304a;
--conflict:#e08272; --coop:#54b596; --neutralbar:#5d6875;
--focus:#84a8e0;
}
*{box-sizing:border-box}
body{
margin:0; background:var(--paper); color:var(--ink);
font-family:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",sans-serif;
font-size:15px; line-height:1.5;
}
.wrap{max-width:1180px; margin:0 auto; padding:0 24px 72px}
/* ---------- Kopf ---------- */
header{padding:44px 0 0}
.eyebrow{
font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:11px;
letter-spacing:.13em; text-transform:uppercase; color:var(--faint);
display:flex; gap:14px; flex-wrap:wrap; align-items:baseline;
}
h1{
font-family:Newsreader,Georgia,serif; font-weight:500;
font-size:clamp(34px,5vw,52px); line-height:1.05; margin:10px 0 0;
letter-spacing:-.015em; text-wrap:balance;
}
.standfirst{
color:var(--muted); max-width:62ch; margin:12px 0 0; font-size:15.5px;
}
/* ---------- Trichter ---------- */
.funnel{
display:grid; grid-template-columns:repeat(4,1fr); gap:1px;
background:var(--line); border:1px solid var(--line);
margin:30px 0 0;
}
.step{background:var(--surface); padding:14px 16px 15px}
.step .n{
font-family:"IBM Plex Mono",monospace; font-size:23px; font-weight:500;
font-variant-numeric:tabular-nums; letter-spacing:-.02em; display:block;
}
.step .k{font-size:12px; color:var(--muted); margin-top:2px}
.step:last-child{background:var(--accent-soft)}
.step:last-child .n{color:var(--accent)}
/* ---------- Steuerung ---------- */
.controls{
display:flex; gap:10px; flex-wrap:wrap; align-items:center;
margin:26px 0 0; padding-bottom:14px; border-bottom:2px solid var(--ink);
}
.controls label{
font-family:"IBM Plex Mono",monospace; font-size:11px; letter-spacing:.1em;
text-transform:uppercase; color:var(--faint);
}
select{
font:inherit; font-size:13.5px; color:var(--ink); background:var(--surface);
border:1px solid var(--line); border-radius:2px; padding:5px 9px;
}
select:focus-visible, .expand:focus-visible, a:focus-visible{
outline:2px solid var(--focus); outline-offset:2px;
}
.count{margin-left:auto; font-family:"IBM Plex Mono",monospace; font-size:12px; color:var(--muted)}
/* ---------- Layout ---------- */
.cols{display:grid; grid-template-columns:1fr 250px; gap:44px; margin-top:4px; align-items:start}
@media(max-width:900px){ .cols{grid-template-columns:1fr; gap:36px} }
/* ---------- Story ---------- */
.story{
display:grid; grid-template-columns:38px 1fr; gap:16px;
padding:20px 0; border-bottom:1px solid var(--hair);
}
.rank{
font-family:"IBM Plex Mono",monospace; font-size:12px; color:var(--faint);
font-variant-numeric:tabular-nums; padding-top:5px;
}
.head{font-family:Newsreader,Georgia,serif; font-size:21px; line-height:1.28;
font-weight:500; margin:0; text-wrap:balance; letter-spacing:-.005em}
.head a{color:inherit; text-decoration:none; background-image:linear-gradient(var(--line),var(--line));
background-size:100% 1px; background-position:0 100%; background-repeat:no-repeat}
.head a:hover{background-image:linear-gradient(var(--accent),var(--accent))}
.meta{
display:flex; flex-wrap:wrap; gap:6px 12px; align-items:center;
margin-top:8px; font-size:12.5px; color:var(--muted);
}
.place{color:var(--ink)}
.mono{font-family:"IBM Plex Mono",monospace; font-variant-numeric:tabular-nums}
.dot{color:var(--faint)}
/* Goldstein-Instrument: divergierende Skala -10..+10 um die Mitte */
.scale{display:flex; align-items:center; gap:7px; margin-top:11px}
.track{
position:relative; width:132px; height:7px; background:var(--sunk); flex:0 0 auto;
}
.track::before{
content:""; position:absolute; left:50%; top:-2px; bottom:-2px; width:1px;
background:var(--faint); opacity:.55;
}
.fill{position:absolute; top:0; bottom:0}
.scale .val{font-family:"IBM Plex Mono",monospace; font-size:11.5px; font-variant-numeric:tabular-nums}
.tone{font-family:"IBM Plex Mono",monospace; font-size:11.5px; color:var(--muted)}
/* Quellen */
.sources{display:flex; flex-wrap:wrap; gap:6px; margin-top:10px}
.src{
font-family:"IBM Plex Mono",monospace; font-size:11px; color:var(--muted);
border:1px solid var(--line); padding:2px 6px; text-decoration:none;
}
.src:hover{border-color:var(--accent); color:var(--accent)}
/* CAMEO-Behauptungen */
.claims{margin-top:12px; border-left:2px solid var(--line); padding-left:13px;
display:flex; flex-direction:column; gap:5px}
.more{display:flex; flex-direction:column; gap:5px}
.claim{display:grid; grid-template-columns:46px 1fr; gap:10px; font-size:12.5px; align-items:baseline}
.code{font-family:"IBM Plex Mono",monospace; font-size:11.5px; color:var(--faint)}
.claim b{font-weight:500}
.actors{color:var(--muted)}
.q3,.q4{color:var(--conflict)}
.q1,.q2{color:var(--coop)}
.expand{
margin-top:9px; font:inherit; font-size:12px; color:var(--accent);
background:none; border:none; padding:0; cursor:pointer;
font-family:"IBM Plex Mono",monospace; letter-spacing:.02em;
}
.expand:hover{text-decoration:underline}
/* ---------- Rail ---------- */
.rail h2{
font-family:"IBM Plex Mono",monospace; font-size:11px; letter-spacing:.12em;
text-transform:uppercase; color:var(--faint); font-weight:500;
margin:0 0 12px; padding-bottom:8px; border-bottom:1px solid var(--line);
}
.rail section + section{margin-top:32px}
.bar{display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center;
font-size:12.5px; padding:3px 0}
.bar .lab{position:relative; padding-left:0}
.bar .lab span{position:relative; z-index:1}
.bar .lab::before{
content:""; position:absolute; left:-4px; top:-2px; bottom:-2px;
width:var(--w); background:var(--accent-soft);
}
.bar .v{font-family:"IBM Plex Mono",monospace; font-size:11.5px; color:var(--muted);
font-variant-numeric:tabular-nums}
.note{font-size:12px; color:var(--muted); line-height:1.55; margin-top:10px}
.status{
border:1px solid var(--line); border-left:3px solid var(--conflict);
background:var(--surface); padding:16px 18px; margin:24px 0 0; font-size:14px;
}
.status h2{font-family:"IBM Plex Sans",sans-serif; font-size:14.5px; margin:0 0 6px; font-weight:600}
.status p{margin:0 0 8px; color:var(--muted); max-width:60ch}
.status pre{
font-family:"IBM Plex Mono",monospace; font-size:12.5px; background:var(--sunk);
padding:9px 11px; margin:0; overflow-x:auto;
}
footer{margin-top:44px; padding-top:18px; border-top:1px solid var(--line);
font-size:12.5px; color:var(--muted); max-width:70ch}
footer code{font-family:"IBM Plex Mono",monospace; font-size:11.5px; background:var(--sunk); padding:1px 4px}
@media(prefers-reduced-motion:reduce){*{transition:none!important; animation:none!important}}
</style>
<div class="wrap">
<header>
<div class="eyebrow">
<span>GDELT 2.0 &middot; Event-Feed</span>
<span id="stamp"></span>
</div>
<h1>Lagebild Europa</h1>
<p class="standfirst">
Alle 15 Minuten veröffentlicht GDELT ein Paket maschinell kodierter Ereignisse.
Diese Seite verdichtet einen Tag davon auf das, was ein Mensch tatsächlich lesen kann —
und zeigt darunter, welche CAMEO-Behauptungen die Maschine aus jedem Artikel gezogen hat.
</p>
<div class="funnel" id="funnel"></div>
</header>
<div class="controls">
<label for="land">Land</label>
<select id="land"></select>
<label for="sort">Sortierung</label>
<select id="sort">
<option value="articles">Reichweite</option>
<option value="conflict">Konfliktivität</option>
<option value="time">Zuletzt gesehen</option>
</select>
<span class="count" id="count"></span>
</div>
<div class="status" id="status" hidden></div>
<div class="cols">
<main id="list"></main>
<aside class="rail">
<section>
<h2>Ereignisorte</h2>
<div id="countries"></div>
<p class="note">Gezählt über alle Event-Zeilen des Tages, nicht nur die gezeigten Stories.</p>
</section>
<section>
<h2>Häufigste CAMEO-Codes</h2>
<div id="codes"></div>
<p class="note">Der Feed ist stark diplomatie-lastig: Besuche, Erklärungen, Konsultationen dominieren die Kodierung.</p>
</section>
<section>
<h2>Goldstein-Skala</h2>
<p class="note" style="margin-top:0">
Jedem CAMEO-Code ist ein fester Wert von −10 bis +10 zugeordnet — der theoretische
Konfliktbeitrag des <em>Ereignistyps</em>, keine Messung des konkreten Vorfalls.
<br><br>
<span class="mono" style="color:var(--conflict)">−10,0</span> Militärgewalt &middot;
<span class="mono" style="color:var(--conflict)">−5,0</span> Festnahme &middot;
<span class="mono" style="color:var(--coop)">+1,9</span> Besuch &middot;
<span class="mono" style="color:var(--coop)">+7,4</span> Wirtschaftshilfe
</p>
</section>
</aside>
</div>
<footer id="foot"></footer>
</div>
<script>
let D;
const nf = new Intl.NumberFormat('de-DE');
const sign = v => (v > 0 ? '+' : '−') + Math.abs(v).toFixed(1).replace('.', ',');
/* Trichter: die vier Verdichtungsstufen mit echten Zahlen */
function boot(data){
D = data;
document.getElementById('funnel').innerHTML = [
[D.events_world, 'Event-Zeilen weltweit'],
[D.events_europe, 'davon in Europa'],
[D.articles_total, 'verschiedene Artikel'],
[D.stories.length, 'Stories auf dieser Seite'],
].map(([n, k]) => `<div class="step"><span class="n">${nf.format(n)}</span><span class="k">${k}</span></div>`).join('');
document.getElementById('stamp').textContent =
D.date.split('-').reverse().join('.') + ' · ' + D.slots + ' Pakete · Stand ' + D.generated.slice(11, 16) + ' UTC';
/* Rail */
const railBars = (obj, el) => {
const max = Math.max(...Object.values(obj));
el.innerHTML = Object.entries(obj).map(([k, v]) =>
`<div class="bar"><div class="lab" style="--w:${(v / max * 100).toFixed(1)}%"><span>${k}</span></div><div class="v">${nf.format(v)}</div></div>`
).join('');
};
railBars(D.countries, document.getElementById('countries'));
railBars(D.top_codes, document.getElementById('codes'));
/* Landfilter */
const sel = document.getElementById('land');
const lands = [...new Set(D.stories.map(s => s.country))].sort((a, b) => a.localeCompare(b, 'de'));
sel.innerHTML = '<option value="">alle</option>' + lands.map(l => `<option>${l}</option>`).join('');
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const hhmm = s => s.slice(8, 10) + ':' + s.slice(10, 12);
function claimHTML(e) {
const cls = 'q' + (['verbal kooperativ', 'material kooperativ', 'verbal konfliktiv', 'material konfliktiv'].indexOf(e.quad) + 1);
const pair = [e.actor1, e.actor2].filter(Boolean).join(' → ') || 'ohne benannte Akteure';
const g = e.goldstein === null ? '' : ` <span class="code">${sign(e.goldstein)}</span>`;
return `<div class="claim"><span class="code">${esc(e.code)}</span>
<span><b class="${cls}">${esc(e.label)}</b>${g}<br><span class="actors">${esc(pair)}</span></span></div>`;
}
function storyHTML(s, i) {
const g = s.goldstein, w = Math.min(Math.abs(g) / 10, 1) * 50;
const col = g < -0.2 ? 'var(--conflict)' : g > 0.2 ? 'var(--coop)' : 'var(--neutralbar)';
const fill = g >= 0
? `left:50%;width:${w}%;background:${col}`
: `right:50%;width:${w}%;background:${col}`;
const shown = s.events.slice(0, 3), rest = s.events.slice(3);
const srcs = s.sources.length > 1
? `<div class="sources">${s.sources.map(q => `<a class="src" href="${esc(q.url)}" target="_blank" rel="noopener">${esc(q.domain)}</a>`).join('')}</div>` : '';
return `<article class="story">
<div class="rank">${String(i + 1).padStart(2, '0')}</div>
<div>
<h3 class="head"><a href="${esc(s.url)}" target="_blank" rel="noopener">${esc(s.title)}</a></h3>
<div class="meta">
<span class="place">${esc(s.country)}</span><span class="dot">·</span>
<span>${esc(s.place)}</span><span class="dot">·</span>
<span class="mono">${hhmm(s.seen)}</span><span class="dot">·</span>
<span class="mono">${nf.format(s.articles)} Artikel</span><span class="dot">·</span>
<span class="mono">${s.event_rows} Event-Zeilen → ${s.events.length} Behauptungen</span>
${s.sources.length > 1 ? `<span class="dot">·</span><span class="mono">${s.sources.length} Quellen</span>` : `<span class="dot">·</span><span class="mono">${esc(s.sources[0].domain)}</span>`}
</div>
<div class="scale">
<span class="track"><span class="fill" style="${fill}"></span></span>
<span class="val" style="color:${col}">${sign(g)}</span>
<span class="tone">Ton ${sign(s.tone)}</span>
</div>
${srcs}
<div class="claims">${shown.map(claimHTML).join('')}
${rest.length ? `<div class="more" hidden>${rest.map(claimHTML).join('')}</div>` : ''}</div>
${rest.length ? `<button class="expand" aria-expanded="false">+ ${rest.length} weitere Behauptungen</button>` : ''}
</div>
</article>`;
}
function render() {
const land = sel.value, mode = document.getElementById('sort').value;
let rows = D.stories.filter(s => !land || s.country === land);
if (mode === 'conflict') rows = [...rows].sort((a, b) => a.goldstein - b.goldstein);
else if (mode === 'time') rows = [...rows].sort((a, b) => b.seen.localeCompare(a.seen));
document.getElementById('list').innerHTML = rows.map(storyHTML).join('');
document.getElementById('count').textContent =
rows.length + (rows.length === 1 ? ' Story' : ' Stories');
}
document.getElementById('list').addEventListener('click', ev => {
const btn = ev.target.closest('.expand');
if (!btn) return;
const box = btn.previousElementSibling.lastElementChild;
const open = box.hidden;
box.hidden = !open;
btn.setAttribute('aria-expanded', String(open));
btn.textContent = open ? '− weniger' : `+ ${box.children.length} weitere Behauptungen`;
});
sel.addEventListener('change', render);
document.getElementById('sort').addEventListener('change', render);
document.getElementById('foot').innerHTML =
`Quelle: GDELT Project, Event-Datenbank 2.0 (<code>export.CSV</code>) verbunden mit den Artikeltiteln
aus dem Global Knowledge Graph (<code>gkg.csv</code>, Feld <code>&lt;PAGE_TITLE&gt;</code>).
Ereignisort gefiltert über <code>ActionGeo_CountryCode</code> in FIPS-10-4-Kodierung.
Die Behauptungen sind maschinelle Extraktionen und teils fehlerhaft — Ortszuordnung und
Akteurserkennung liegen sichtbar daneben, wenn ein Artikel mehrere Schauplätze nennt.`;
render();
}
/* Daten kommen zur Laufzeit - die Seite selbst wird nie neu gebaut,
nur stories.json alle 15 Minuten ueberschrieben. */
fetch('stories.json', {cache: 'no-store'})
.then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
.then(boot)
.catch(err => {
const box = document.getElementById('status');
const local = location.protocol === 'file:';
box.hidden = false;
box.innerHTML = local
? `<h2>Die Seite braucht einen Webserver</h2>
<p>Über <code>file://</code> blockiert der Browser das Laden von
<code>stories.json</code>. Starte im Ordner <code>site/</code> einen Server
und öffne die angezeigte Adresse:</p>
<pre>cd site &amp;&amp; python3 -m http.server 8000</pre>`
: `<h2>stories.json konnte nicht geladen werden</h2>
<p>${err.message}. Erzeuge die Datei neu:</p>
<pre>python build_site.py --limit 60</pre>`;
});
</script>
+7608
View File
File diff suppressed because it is too large Load Diff
+8128
View File
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
"""Minimaler GDELT-2.0-Tracker: pollt den 15-Minuten-Feed und meldet neue Events.
python tracker.py --country DE --root-codes 14 18 19 20 --once
python tracker.py --country DE --min-mentions 5 # Endlosschleife
Kein API-Key noetig. GDELT veroeffentlicht alle 15 Minuten eine neue
export.CSV.zip; lastupdate.txt zeigt auf die juengste.
"""
import argparse
import io
import json
import time
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
import pandas as pd
LASTUPDATE = "http://data.gdeltproject.org/gdeltv2/lastupdate.txt"
STATE = Path(__file__).with_name(".tracker_state.json")
# Spaltennamen der GDELT 2.0 Event-Tabelle (61 Spalten, Datei hat keinen Header)
COLUMNS = [
'GLOBALEVENTID', 'SQLDATE', 'MonthYear', 'Year', 'FractionDate',
'Actor1Code', 'Actor1Name', 'Actor1CountryCode', 'Actor1KnownGroupCode',
'Actor1EthnicCode', 'Actor1Religion1Code', 'Actor1Religion2Code',
'Actor1Type1Code', 'Actor1Type2Code', 'Actor1Type3Code',
'Actor2Code', 'Actor2Name', 'Actor2CountryCode', 'Actor2KnownGroupCode',
'Actor2EthnicCode', 'Actor2Religion1Code', 'Actor2Religion2Code',
'Actor2Type1Code', 'Actor2Type2Code', 'Actor2Type3Code',
'IsRootEvent', 'EventCode', 'EventBaseCode', 'EventRootCode', 'QuadClass',
'GoldsteinScale', 'NumMentions', 'NumSources', 'NumArticles', 'AvgTone',
'Actor1Geo_Type', 'Actor1Geo_FullName', 'Actor1Geo_CountryCode',
'Actor1Geo_ADM1Code', 'Actor1Geo_ADM2Code', 'Actor1Geo_Lat',
'Actor1Geo_Long', 'Actor1Geo_FeatureID',
'Actor2Geo_Type', 'Actor2Geo_FullName', 'Actor2Geo_CountryCode',
'Actor2Geo_ADM1Code', 'Actor2Geo_ADM2Code', 'Actor2Geo_Lat',
'Actor2Geo_Long', 'Actor2Geo_FeatureID',
'ActionGeo_Type', 'ActionGeo_FullName', 'ActionGeo_CountryCode',
'ActionGeo_ADM1Code', 'ActionGeo_ADM2Code', 'ActionGeo_Lat',
'ActionGeo_Long', 'ActionGeo_FeatureID',
'DATEADDED', 'SOURCEURL',
]
NUMERIC = ['GoldsteinScale', 'NumMentions', 'NumSources', 'NumArticles',
'AvgTone', 'ActionGeo_Lat', 'ActionGeo_Long']
def _get(url, timeout=60):
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.read()
def latest_export_url():
"""URL der juengsten export.CSV.zip laut lastupdate.txt."""
lines = _get(LASTUPDATE, timeout=30).decode().split()
return next(u for u in lines if u.endswith("export.CSV.zip"))
def fetch_events(url):
"""Laedt ein 15-Minuten-Paket und gibt es als DataFrame zurueck."""
z = zipfile.ZipFile(io.BytesIO(_get(url)))
df = pd.read_csv(z.open(z.namelist()[0]), sep='\t', header=None,
names=COLUMNS, dtype=str)
for col in NUMERIC:
df[col] = pd.to_numeric(df[col], errors='coerce')
return df
def filter_events(df, country=None, root_codes=(), min_mentions=1,
max_tone=None):
if country:
df = df[df['ActionGeo_CountryCode'] == country]
if root_codes:
df = df[df['EventRootCode'].isin(root_codes)]
if min_mentions:
df = df[df['NumMentions'] >= min_mentions]
if max_tone is not None:
df = df[df['AvgTone'] <= max_tone]
return df.sort_values('NumMentions', ascending=False)
def report(df):
df = df.fillna({'Actor1Name': '?', 'Actor2Name': '?'})
for _, e in df.iterrows():
print(f"[{e.DATEADDED}] {e.EventCode} goldstein={e.GoldsteinScale} "
f"tone={e.AvgTone:.1f} mentions={int(e.NumMentions)}")
print(f" {e.Actor1Name} -> {e.Actor2Name} "
f"@ {e.ActionGeo_FullName}")
print(f" {e.SOURCEURL}")
def load_seen():
if STATE.exists():
return set(json.loads(STATE.read_text()))
return set()
def save_seen(seen):
# nur die letzten paar tausend IDs behalten, sonst waechst die Datei ewig
STATE.write_text(json.dumps(sorted(seen)[-20000:]))
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--country', help='FIPS-Laendercode des Ereignisorts, z.B. GM fuer Deutschland')
p.add_argument('--root-codes', nargs='*', default=[],
help='CAMEO EventRootCodes, z.B. 14 (Protest) 18 (Assault) 19 (Fight)')
p.add_argument('--min-mentions', type=int, default=1)
p.add_argument('--max-tone', type=float, default=None,
help='nur Events mit AvgTone <= X (negativ = schlechte Nachrichten)')
p.add_argument('--interval', type=int, default=900, help='Sekunden zwischen Polls')
p.add_argument('--once', action='store_true')
args = p.parse_args()
seen = load_seen()
while True:
try:
url = latest_export_url()
df = fetch_events(url)
except urllib.error.HTTPError as err:
# lastupdate.txt zeigt gelegentlich auf ein Paket, das noch nicht
# hochgeladen ist -> beim naechsten Durchlauf erneut versuchen
print(f"noch nicht verfuegbar ({err.code}), warte...")
except urllib.error.URLError as err:
print(f"Netzwerkfehler: {err.reason}")
else:
hits = filter_events(df, args.country, args.root_codes,
args.min_mentions, args.max_tone)
new = hits[~hits['GLOBALEVENTID'].isin(seen)]
print(f"{url.rsplit('/', 1)[-1]}: {len(df)} Events, "
f"{len(hits)} passend, {len(new)} neu")
report(new)
seen.update(new['GLOBALEVENTID'])
save_seen(seen)
if args.once:
break
time.sleep(args.interval)
if __name__ == '__main__':
main()