init
This commit is contained in:
Binary file not shown.
Binary file not shown.
+214
@@ -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: — und &#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()
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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 · 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 ·
|
||||
<span class="mono" style="color:var(--conflict)">−5,0</span> Festnahme ·
|
||||
<span class="mono" style="color:var(--coop)">+1,9</span> Besuch ·
|
||||
<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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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><PAGE_TITLE></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 && 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
File diff suppressed because it is too large
Load Diff
+8128
File diff suppressed because it is too large
Load Diff
+145
@@ -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()
|
||||
Reference in New Issue
Block a user