Newsletter-Werkzeuge

Stand 2026-11-14 (nach #11–#20)
Bezug 2026-11-14_newsletter, newsletter, edwiki_import

Skripte, mit denen die Newsletter aus den Wayback-Kopien (HTML) zu Wiki-Seiten und Import-Paketen werden. Herunterladen: auf den Dateinamen über dem Code klicken.

Ablauf pro Block

  1. HTML-Kopien (Wayback, „Webseite komplett“) je in einen eigenen Ordner; Datumsliste newsletter_index.json (aus der Galnet-France-Liste).
  2. html2wiki.py <datei.html> <NNN> raw → raw/en_NNN.txt (Rohtext) + raw/images_NNN.json (Bilder in Reihenfolge, erstes = Kopfbild = Box-Bild).
  3. Gegenprobe mit dem Forumtext von CMDR Kzak (fehlende Sätze in beide Richtungen).
  4. clean_en.py raw newsletter_index.json en → en/en_NNN.txt: Inhaltsverzeichnis als fette Zeile, Links auf andere Newsletter als …, Einzelkorrekturen in FIXES.
  5. Deutsche Übersetzung von Hand (Claude) nach de/de_NNN.txt – gleiche Bildzeilen und Links wie EN (wird geprüft), Begriffe nach glossar, Drabble-Titel englisch + deutsch in Klammern, Rubrik „Elite Fiction“ → „Elite-Fiction“.
  6. meta.json je Nummer: author, wayback, related (nur vorhandene Seiten), translation.
  7. build_newsletter.py . <von> <bis> → pakete/newsletter_paket_<von>-<bis>_tN.zip (≤ 3,7 MB, CSV im letzten Teil), dazu luecken_*.txt mit defekten Bildern.

Regeln

Skripte

html2wiki.py
#!/usr/bin/env python3
"""Mailchimp-Newsletter (Wayback-Kopie) -> DokuWiki-Rohtext (Englisch) + Bildliste.
 
Aufruf: html2wiki.py <newsletter.html> <nr> <ausgabeordner>
Schreibt <ausgabe>/en_<nr>.txt (Seitenrumpf ohne Rahmen) und <ausgabe>/images_<nr>.json
"""
import json
import os
import re
import sys
import urllib.parse
 
from bs4 import BeautifulSoup, NavigableString, Tag
 
SKIP_IMG = re.compile(r'(social|google|tumblr|like_|cdn-images|facebook|twitter)', re.I)
WAYBACK = re.compile(r'^https?://web\.archive\.org/web/\d+[a-z_]*/', re.I)
MEDIA_NS = 'news:newsletter:{nr}:'
 
 
def clean_name(fname):
    """Wayback-Anhang entfernen, DokuWiki-taugliche Kleinschreibung."""
    base, ext = os.path.splitext(fname)
    base = re.sub(r'_[A-Za-z0-9]{4}$', '', base)  # _0oNk usw.
    base = re.sub(r'[^A-Za-z0-9]+', '_', base).strip('_').lower()
    ext = ext.lower().replace('jpeg', 'jpg')
    return base + ext
 
 
def wrap(text, mk):
    """Formatierung je Absatz/Zeile, Leerraum außerhalb der Marker."""
    parts = re.split(r'(\n+)', text)
    res = []
    for p in parts:
        if not p.strip() or p.startswith('\n'):
            res.append(p)
            continue
        lead = p[:len(p) - len(p.lstrip())]
        trail = p[len(p.rstrip()):]
        res.append(lead + mk + p.strip() + mk + trail)
    return ''.join(res)
 
 
def esc(text):
    """Sternchen-Folgen (z. B. Teaser 'the *****') vor DokuWiki schützen."""
    return re.sub(r'(?<!\*)(\*{3,})(?!\*)', r'%%\1%%', text)
 
 
def orig_url(href):
    href = WAYBACK.sub('', href or '')
    return href
 
 
class Conv:
    def __init__(self, path, nr):
        self.path = path
        self.dir = os.path.dirname(path)
        self.nr = nr
        self.out = []          # Liste von Blöcken
        self.inline = []       # laufender Absatz
        self.images = []
        self.seen = set()
 
    # ---------- Ausgabe
    def flush(self):
        txt = ''.join(self.inline)
        txt = re.sub(r'[ \t ]+', ' ', txt)
        txt = re.sub(r' *\n *', '\n', txt).strip()
        # leere Formatierungen entfernen
        prot = re.findall(r'%%.*?%%', txt)
        txt = re.sub(r'%%.*?%%', '\x00', txt)
        txt = txt.strip()
        for pr in prot:
            txt = txt.replace('\x00', pr, 1)
        if txt:
            for para in re.split(r'\n{2,}', txt):
                para = para.strip()
                if para:
                    self.out.append(('p', para.replace('\n', ' \\\\ ')))
        self.inline = []
 
    def add_img(self, img):
        src = urllib.parse.unquote(img.get('src', ''))
        if SKIP_IMG.search(src) or not src:
            return
        local = os.path.join(self.dir, src)
        if not os.path.exists(local):
            local = os.path.join(self.dir, os.path.basename(os.path.dirname(src)), os.path.basename(src))
        name = clean_name(os.path.basename(src))
        if name in self.seen:
            return
        self.seen.add(name)
        try:
            w = int(re.search(r'\d+', img.get('width') or '560').group())
        except Exception:
            w = 560
        link = img.parent.get('href') if img.parent and img.parent.name == 'a' else ''
        self.images.append({'name': name, 'local': local, 'width': w,
                            'orig': orig_url(link) if link and re.search(r'\.(jpe?g|png|gif)', link, re.I) else ''})
        self.flush()
        self.out.append(('img', name, w))
 
    # ---------- Durchlauf
    def walk(self, node, fmt=''):
        for ch in node.children:
            if isinstance(ch, NavigableString):
                if ch.__class__.__name__ == 'Comment':
                    continue
                t = esc(str(ch).replace('\r', '').replace('\n', ' '))
                self.inline.append(t)
                continue
            if not isinstance(ch, Tag):
                continue
            n = ch.name
            if n in ('script', 'style'):
                continue
            if n == 'img':
                self.add_img(ch)
            elif n == 'br':
                self.inline.append('\n')
            elif n in ('h1', 'h2', 'h3', 'h4'):
                self.flush()
                t = re.sub(r'\s+', ' ', ch.get_text()).strip()
                if t:
                    self.out.append(('h', t))
                for im in ch.find_all('img'):
                    self.add_img(im)
            elif n in ('ul', 'ol'):
                self.flush()
                items = []
                for li in ch.find_all('li', recursive=False):
                    sub = Conv(self.path, self.nr)
                    sub.seen = self.seen
                    sub.walk(li)
                    sub.flush()
                    items.append(' '.join(b[1] for b in sub.out if b[0] == 'p'))
                    self.images += sub.images
                self.out.append(('ul' if n == 'ul' else 'ol', items))
            elif n in ('p', 'div', 'td', 'tr', 'table', 'tbody', 'blockquote', 'center'):
                self.flush()
                self.walk(ch)
                self.flush()
            elif n in ('strong', 'b', 'em', 'i'):
                mk = '**' if n in ('strong', 'b') else '//'
                inner = self.sub_inline(ch)
                self.inline.append(wrap(inner, mk))
            elif n == 'a':
                href = ch.get('href', '')
                if ch.find('img'):
                    for im in ch.find_all('img'):
                        self.add_img(im)
                    continue
                text = self.sub_inline(ch)
                if not href or href.startswith('#') or 'return to contents' in text.lower():
                    if 'return to contents' not in text.lower():
                        self.inline.append(text)
                    continue
                url = orig_url(href)
                if url.startswith('mailto:'):
                    self.inline.append(text)
                    continue
                self.inline.append('[[%s|%s]]' % (url, text.strip()) if text.strip() else '')
            else:
                self.walk(ch)
 
    def sub_inline(self, node):
        sub = Conv(self.path, self.nr)
        sub.seen = self.seen
        sub.walk(node)
        self.images += sub.images
        paras = []
        for b in sub.out:  # Bilder in Formatierung
            if b[0] == 'img':
                self.flush()
                self.out.append(b)
            elif b[0] == 'p':
                paras.append(b[1].replace(' \\\\ ', '\n'))
        paras.append(''.join(sub.inline))
        return re.sub(r'[ \t\u00a0]+', ' ', '\n\n'.join(x for x in paras if x.strip()))
 
 
def main():
    path, nr, outdir = sys.argv[1], sys.argv[2], sys.argv[3]
    html = open(path, encoding='utf-8', errors='replace').read()
    html = re.sub(r'<!-- BEGIN WAYBACK TOOLBAR INSERT -->.*?<!-- END WAYBACK TOOLBAR INSERT -->', '', html, flags=re.S)
    soup = BeautifulSoup(html, 'html.parser')
    body = soup.find(id='templateBody') or soup.body
    header = soup.find(id='templateHeader')
    c = Conv(path, nr)
    if header:
        for im in header.find_all('img'):
            c.add_img(im)
    c.walk(body)
    c.flush()
    # Ende abschneiden: "Follow Elite: Dangerous" und alles danach
    blocks = []
    for b in c.out:
        if b[0] == 'h' and re.match(r'follow elite', b[1], re.I):
            break
        blocks.append(b)
    ns = MEDIA_NS.format(nr=nr)
    lines = []
    # Kopfbild (mail_head / erstes Bild im Kopf) nicht in den Text, es ist das Box-Bild
    if blocks and blocks[0][0] == 'img':
        blocks = blocks[1:]
    grouped = []
    for b in blocks:
        if b[0] == 'img' and b[2] < 500 and grouped and grouped[-1][0] == 'imgs':
            grouped[-1][1].append(b)
        elif b[0] == 'img' and b[2] < 500:
            grouped.append(('imgs', [b]))
        else:
            grouped.append(b)
    blocks = grouped
    for b in blocks:
        if b[0] == 'imgs':
            lines.append(' '.join('{{%s%s?%d|}}' % (ns, i[1], i[2]) for i in b[1]) + '\n')
            continue
        if b[0] == 'p':
            lines.append(b[1] + '\n')
        elif b[0] == 'h':
            lines.append('===== %s =====\n' % b[1])
        elif b[0] == 'img':
            lines.append('{{%s%s?%d|}}\n' % (ns, b[1], min(b[2], 560)))
        elif b[0] in ('ul', 'ol'):
            mark = '*' if b[0] == 'ul' else '-'
            lines.append('\n'.join('  %s %s' % (mark, i) for i in b[1]) + '\n')
    os.makedirs(outdir, exist_ok=True)
    open(os.path.join(outdir, 'en_%s.txt' % nr), 'w').write('\n'.join(lines))
    json.dump(c.images, open(os.path.join(outdir, 'images_%s.json' % nr), 'w'), indent=1)
 
 
if __name__ == '__main__':
    main()
clean_en.py
#!/usr/bin/env python3
"""Rohtext (html2wiki) -> bereinigter englischer Seitenrumpf.
 
- Inhaltsverzeichnis: Überschrift -> fette Zeile, ohne "Follow Elite: Dangerous", Banner bleibt
- Links auf andere Newsletter (Mailchimp-IDs) -> interne Links
- E-Mail-Adressen nicht fett, kleine Leerzeichen-/Satzzeichenfehler
- gezielte Einzelkorrekturen (FIXES)
Aufruf: clean_en.py <raw-ordner> <index.json> <ausgabe-ordner>
"""
import json
import os
import re
import sys
 
FIXES = {
    '015': [('**[[http://elite.frontier.co.uk/|http://elite.frontier.co.u]]k**', '**[[http://elite.frontier.co.uk/|http://elite.frontier.co.uk]]**'),
            ('**Engines**//: "When', '**Engines:** //"When')],
    '002': [('but**[[', 'but **[['), (']]**to check', ']]** to check')],
    '003': [('**[[http://i1305.photobucket.com/albums/s553/FrontierAshley/EmpireshipsketchesCropped_zpsc19947fb.jpg|smaller Imperial Ships]]**',
             '**[[NL:002|smaller Imperial Ships]]**')],
    '004': [('**[[http://s1305.photobucket.com/user/FrontierAshley/media/FedandEmpirefighters_zps35212663.jpg.html|early concept thumbnails]]**',
             '**[[NL:001|early concept thumbnails]]**'),
            ('\nShield Systems:\n', '\n**Shield Systems:**\n')],
    '007': [('&e=[UNIQID]', '')],
    '008': [(' \\\\ --- \\\\ This week’s drabble', '\n\n===== Elite Fiction: Childish Dreams =====\n\nThis week’s drabble'),
            ('**[[http://www.laveradio.com/|their site]]**now', '**[[http://www.laveradio.com/|their site]]** now')],
    '009': [('**“**"We don\'t need the whole universe, just the Milky Way." Where to start?',
             '“We don\'t need the whole universe, just the Milky Way.” Where to start?'),
            ('ring system(s). **”**', 'ring system(s).'),
            ('**[[http://elite.frontier.co.uk/images/newsletter_2013_07_05/schematic_flat_empfigher.png|Empire]][[http://elite.frontier.co.uk/images/newsletter_2013_07_05/schematic_flat_empfigher.png|Fighter]]**',
             '**[[http://elite.frontier.co.uk/images/newsletter_2013_07_05/schematic_flat_empfigher.png|Empire Fighter]]**'),
            ('\n- Elsewhere in the Elite galaxy', '\nElsewhere in the Elite galaxy')],
}
 
 
def main():
    rawdir, index, outdir = sys.argv[1:4]
    idx = json.load(open(index))
    by_id = {}
    for r in idx:
        m = re.search(r'id=([0-9a-f]{10})', r['url'])
        if m and re.fullmatch(r'\d+', r['nr']):
            by_id.setdefault(m.group(1), '%03d' % int(r['nr']))
    os.makedirs(outdir, exist_ok=True)
    for fn in sorted(os.listdir(rawdir)):
        m = re.match(r'en_(\d{3})\.txt$', fn)
        if not m:
            continue
        nr = m.group(1)
        t = open(os.path.join(rawdir, fn)).read()
        # Inhaltsverzeichnis
        t = re.sub(r'===== Table of Contents[^=]*=====', '**Contents:**', t)
        t = re.sub(r'\n  \* (\*\*)?Follow Elite: Dangerous(\*\*)?\n', '\n', t)
        # persönliche Anrede aus dem Mailchimp-Seriendruck (Name des Abonnenten) neutralisieren
        t = re.sub(r'^Hi (?!Commander)[^,\n]{1,40},', 'Hi Commander,', t)
        t = re.sub(r'^===== return to contents =====\n', '', t, flags=re.M | re.I)
        t = re.sub(r'^\{\{[^}]*transparent[^}]*\}\}\n', '', t, flags=re.M)  # Platzhalter-GIF
        # Werbe-Parameter (utm_…) aus Links entfernen
        t = re.sub(r'[?&]utm_[^|\]]*?(?=[|\]])', '', t)
        # Einzelkorrekturen
        for a, b in FIXES.get(nr, []):
            assert a in t, (nr, a[:60])
            t = t.replace(a, b)
        # Links auf andere Newsletter
 
        def nl_link(mm):
            url, text = mm.group(1), mm.group(2)
            i = re.search(r'id=([0-9a-f]{10})', url)
            if i and i.group(1) in by_id:
                target = by_id[i.group(1)]
                if target != nr:
                    return '[[NL:%s|%s]]' % (target, text)
            return mm.group(0)
        t = re.sub(r'\[\[https?://us2\.campaign-archive\d?\.com/home/[^|\]]*\|', '[[NL:start|', t)
        t = re.sub(r'\[\[(https?://us2\.campaign-archive[^|\]]*)\|([^\]]*)\]\]', nl_link, t)
        # Fettung um Links mit eigener Fettung: [[url|**x**]] -> **[[url|x]]**
        t = re.sub(r'\[\[([^|\]]+)\|\*\*([^\]]*?)\*\*\]\]', r'**[[\1|\2]]**', t)
        # E-Mail-Adresse nicht fett
        t = re.sub(r'\*\*([\w.]+@frontier\.co\.uk)\*\*', r'\1', t)
        t = re.sub(r' +([.,!?])', r'\1', t)
        t = re.sub(r'\]\]\*\*(?=[A-Za-z])', ']]** ', t)
        t = re.sub(r'(?<=[A-Za-z])\*\*\[\[', ' **[[', t)
        t = re.sub(r'^If you do not wish to receive any more.*\n', '', t, flags=re.M)
        t = re.sub(r'\n{3,}', '\n\n', t).strip() + '\n'
        open(os.path.join(outdir, 'en_%s.txt' % nr), 'w').write(t)
 
 
if __name__ == '__main__':
    main()
build_newsletter.py
#!/usr/bin/env python3
"""Newsletter-Paket für edwiki-import.php bauen.
 
Eingaben (Arbeitsordner):
  en/en_NNN.txt          bereinigter englischer Rumpf (clean_en.py)
  de/de_NNN.txt          deutsche Übersetzung (Claude)
  raw/images_NNN.json    Bildliste (html2wiki.py)
  newsletter_index.json  Nummer, Datum, Original-Link (galnet.fr-Liste)
  meta.json              je Nummer: author, wayback, related, translation
 
Ausgabe: build/pages/..., build/media/..., build/newsletter_NNN-NNN.csv,
         pakete/newsletter_paket_<von>-<bis>_tN.zip (je <= 3,7 MB, CSV im letzten Teil)
Aufruf:  build_newsletter.py <arbeitsordner> <von> <bis>
"""
import csv
import io
import json
import os
import re
import shutil
import sys
import zipfile
 
from PIL import Image
 
MAXPX = 1920
ZIPMAX = 3_700_000
KI_NOTE = '//Übersetzung aus dem Englischen: Claude, ungeprüft.//'
LANGS = {'de': 'news:newsletter', 'en': 'en:news:newsletter'}
 
 
def frame(title, body, footer):
    return f"""[<>]
 
<WRAP centeralign>
====== {title} ======
</WRAP>
 
----
 
<WRAP group>
<WRAP twothirds column box round>
{body.strip()}
 
{footer}
</WRAP>
 
<WRAP third column box round>
{{{{structbox>newsletter}}}}
</WRAP>
</WRAP>
 
----
 
[<>]
"""
 
 
def de_date(iso):
    y, m, d = iso.split('-')
    return f'{d}.{m}.{y}'
 
 
def links(text, lang):
    ns = LANGS[lang]
    text = text.replace('[[NL:start|', '[[news:newsletter:start|')  # Übersicht gibt es nur auf Deutsch
    return re.sub(r'\[\[NL:(\d{3})\|', lambda m: f'[[{ns}:{m.group(1)}|', text)
 
 
def save_image(src, dst):
    try:
        Image.open(src).verify()
    except Exception:
        return 'FEHLT (Wayback-Kopie defekt)'
    os.makedirs(os.path.dirname(dst), exist_ok=True)
    ext = os.path.splitext(dst)[1]
    if ext == '.jpg':
        im = Image.open(src)
        if max(im.size) > MAXPX:
            im.thumbnail((MAXPX, MAXPX), Image.LANCZOS)
            im.convert('RGB').save(dst, 'JPEG', quality=88, optimize=True)
            return 'verkleinert'
    shutil.copyfile(src, dst)
    return ''
 
 
def main():
    work, a, b = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])
    idx = {r['nr']: r for r in json.load(open(os.path.join(work, 'newsletter_index.json')))}
    meta = json.load(open(os.path.join(work, 'meta.json')))
    build = os.path.join(work, 'build')
    shutil.rmtree(build, ignore_errors=True)
    rows, report, gaps = [], [], []
    for n in range(a, b + 1):
        nr = '%03d' % n
        info, m = idx[str(n)], meta[nr]
        date = info['date']
        title = f'Elite: Dangerous Newsletter #{n}'
        src = f'//Quelle: Frontier Developments, {title}, {de_date(date)}//'
        de = links(open(os.path.join(work, 'de', f'de_{nr}.txt')).read(), 'de')
        en = links(open(os.path.join(work, 'en', f'en_{nr}.txt')).read(), 'en')
        pages = {
            f'pages/news/newsletter/{nr}.txt': frame(title, de, KI_NOTE + ' \\\\\n' + src),
            f'pages/en/news/newsletter/{nr}.txt': frame(title, en,
                f'//Source: Frontier Developments, {title}, {date}//'),
        }
        for p, t in pages.items():
            os.makedirs(os.path.dirname(os.path.join(build, p)), exist_ok=True)
            open(os.path.join(build, p), 'w').write(t)
        # Bilder: alle, die im Text vorkommen + Kopfbild (Box)
        used = set(re.findall(r'\{\{news:newsletter:%s:([^?|}]+)' % nr, de))
        imgs = json.load(open(os.path.join(work, 'raw', f'images_{nr}.json')))
        head = imgs[0]['name']
        if m.get('header_local'):  # Wayback hat ein falsches Kopfbild geliefert (gleicher Dateiname bei Mailchimp)
            imgs[0]['local'] = m['header_local']
            report.append(f'#{n} Kopfbild ersetzt durch {os.path.basename(m["header_local"])}')
        for im in imgs:
            if im['name'] in used or im['name'] == head:
                note = save_image(im['local'], os.path.join(build, 'media/news/newsletter', nr, im['name']))
                if note.startswith('FEHLT'):
                    gaps.append(f'#{n}\tnews:newsletter:{nr}:{im["name"]}\t{im["orig"] or "-"}')
                elif note:
                    report.append(f'#{n} {im["name"]}: {note} auf {MAXPX} px')
        missing = used - {i['name'] for i in imgs}
        if missing:
            report.append(f'#{n} FEHLENDE BILDER: {sorted(missing)}')
        rows.append({
            'pid': f'news:newsletter:{nr}',
            'number': str(n),
            'date': date,
            'picture': f'news:newsletter:{nr}:{head}',
            'author': m['author'],
            'url': re.sub(r'&e=[^&]*', '', info['url']),  # Abonnenten-Kennung entfernen
            'archive': m['wayback'],
            'translation': m.get('translation', 'KI'),
            'relations.related': ','.join(m.get('related', [])),
        })
    # feste Seiten (Übersicht, Vorlage, news:start, Sidebar): static/pages/... 1:1 übernehmen
    shutil.copytree(os.path.join(work, 'static', 'pages'), os.path.join(build, 'pages'), dirs_exist_ok=True)
    if gaps:
        open(os.path.join(build, f'luecken_newsletter_{a:03d}-{b:03d}.txt'), 'w').write(
            'Nr\tfehlendes Bild (Medien-ID)\tOriginal (über Wayback suchen)\n' + '\n'.join(gaps) + '\n')
        report.append(f'{len(gaps)} Bilder fehlen, siehe luecken_newsletter_{a:03d}-{b:03d}.txt')
    csvname = f'newsletter_{a:03d}-{b:03d}.csv'
    with open(os.path.join(build, csvname), 'w', newline='') as fh:
        w = csv.DictWriter(fh, fieldnames=list(rows[0]))
        w.writeheader()
        w.writerows(rows)
    # Pakete
    files = []
    for root, _, fs in os.walk(build):
        for f in sorted(fs):
            full = os.path.join(root, f)
            rel = os.path.relpath(full, build)
            if rel.endswith('.csv'):
                continue
            files.append((rel, full))
    files.sort()
    outdir = os.path.join(work, 'pakete')
    shutil.rmtree(outdir, ignore_errors=True)
    os.makedirs(outdir)
    parts, cur, size = [], [], 0
    for rel, full in files:
        s = os.path.getsize(full) + 200 + len(rel) * 2
        if cur and size + s > ZIPMAX:
            parts.append(cur)
            cur, size = [], 0
        cur.append((rel, full))
        size += s
    cur.append((csvname, os.path.join(build, csvname)))
    parts.append(cur)
    for i, part in enumerate(parts, 1):
        zn = os.path.join(outdir, f'newsletter_paket_{a:03d}-{b:03d}_t{i}.zip')
        with zipfile.ZipFile(zn, 'w', zipfile.ZIP_DEFLATED) as z:
            for rel, full in part:
                z.write(full, rel)
        report.append(f'{os.path.basename(zn)}: {len(part)} Dateien, {os.path.getsize(zn) / 1e6:.2f} MB')
    print('\n'.join(report))
 
 
if __name__ == '__main__':
    main()

Metadaten

meta.json
{
 "001": {
  "author": "Michael Brookes",
  "wayback": "https://web.archive.org/web/20190316200753/https://us2.campaign-archive.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=bafc8227dd",
  "related": [
   "hangar:ships:sidewinder",
   "codex:federal:start",
   "codex:imperial:start"
  ],
  "translation": "KI"
 },
 "002": {
  "author": "Ashley Barley",
  "wayback": "https://web.archive.org/web/20150408094846/http://us2.campaign-archive2.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=b5519ec0fe",
  "related": [
   "hangar:ships:sidewinder",
   "hangar:ships:anaconda",
   "codex:imperial:start",
   "codex:federal:start",
   "galaxy:systems:lave"
  ],
  "translation": "KI"
 },
 "003": {
  "author": "Ashley Barley",
  "wayback": "https://web.archive.org/web/20170901205441/http://us2.campaign-archive1.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=7b8136bd4a",
  "related": [
   "hangar:ships:sidewinder",
   "codex:imperial:start",
   "codex:federal:start"
  ],
  "translation": "KI"
 },
 "004": {
  "author": "Ashley Barley",
  "wayback": "https://web.archive.org/web/20201112042442/https://us2.campaign-archive.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=d266df1c3b",
  "related": [
   "hangar:ships:sidewinder",
   "hangar:ships:cobra_mk3",
   "codex:federal:start",
   "galaxy:systems:lave"
  ],
  "translation": "KI"
 },
 "005": {
  "author": "Ashley Barley",
  "wayback": "https://web.archive.org/web/20151104160412/http://us2.campaign-archive1.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=a4bd777a7f",
  "related": [
   "codex:imperial:start",
   "codex:federal:start",
   "codex:corporations:lakon_spaceways",
   "codex:corporations:saud_kruger",
   "codex:corporations:gutamaya",
   "codex:corporations:core_dynamics"
  ],
  "translation": "KI"
 },
 "006": {
  "author": "Ashley Barley",
  "wayback": "https://web.archive.org/web/20151104144759/http://us2.campaign-archive1.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=94dad3b09c",
  "related": [
   "hangar:ships:eagle",
   "hangar:ships:adder",
   "hangar:ships:asp_explorer"
  ],
  "translation": "KI"
 },
 "007": {
  "author": "Ashley Barley",
  "wayback": "https://web.archive.org/web/20151104144817/http://us2.campaign-archive1.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=c0b11fe947",
  "related": [
   "hangar:ships:imperial_courier",
   "hangar:ships:imperial_cutter",
   "codex:imperial:start"
  ],
  "translation": "KI"
 },
 "008": {
  "author": "Ashley Barley",
  "wayback": "https://web.archive.org/web/20150130055157/http://us2.campaign-archive1.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=b3760cf65e",
  "related": [
   "hangar:ships:sidewinder",
   "hangar:ships:anaconda",
   "codex:federal:start",
   "codex:imperial:start",
   "codex:alliance:start"
  ],
  "translation": "KI"
 },
 "009": {
  "author": "Ashley Barley",
  "wayback": "https://web.archive.org/web/20140901000640/http://us2.campaign-archive2.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=ffe2385d13",
  "related": [
   "codex:federal:start",
   "codex:imperial:start",
   "codex:alliance:start",
   "codex:misc:pilotenvereinigung",
   "galaxy:systems:sol",
   "galaxy:systems:achenar",
   "galaxy:systems:alioth"
  ],
  "translation": "KI"
 },
 "010": {
  "author": "Ashley Barley",
  "wayback": "https://web.archive.org/web/20140901000639/http://us2.campaign-archive2.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=ee59a3bb5e",
  "related": [
   "hangar:ships:dolphin",
   "hangar:ships:orca",
   "codex:corporations:gutamaya"
  ],
  "translation": "KI"
 },
 "011": {
  "author": "Ashley Barley",
  "wayback": "https://web.archive.org/web/20140723125234/http://us2.campaign-archive1.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=7157d0e3e1",
  "related": [
   "hangar:ships:cobra_mk3",
   "codex:federal:start"
  ],
  "translation": "KI"
 },
 "012": {
  "author": "",
  "wayback": "https://web.archive.org/web/20190316185013/https://us2.campaign-archive.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=d6ef3ab726",
  "related": [],
  "translation": "KI",
  "header_local": "/home/claude/nl2_in/Elite_Dangerous Newsletter #017_files/mail_head_copy_jWHE.JPG",
  "header_note": "Wayback-Kopien von #12 und #17 haben die Kopfbilder vertauscht (Mailchimp-Dateiname mail_head_copy.jpg doppelt)"
 },
 "013": {
  "author": "",
  "wayback": "https://web.archive.org/web/20150130220838/http://us2.campaign-archive2.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=94e38570c6",
  "related": [],
  "translation": "KI"
 },
 "014": {
  "author": "",
  "wayback": "https://web.archive.org/web/20140901000640/http://us2.campaign-archive2.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=f703580f39",
  "related": [
   "hangar:ships:sidewinder",
   "codex:corporations:remlok"
  ],
  "translation": "KI"
 },
 "015": {
  "author": "",
  "wayback": "https://web.archive.org/web/20140901000556/http://us2.campaign-archive1.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=4075b302f4",
  "related": [
   "hangar:ships:sidewinder",
   "hangar:ships:anaconda",
   "hangar:ships:cobra_mk3"
  ],
  "translation": "KI"
 },
 "016": {
  "author": "",
  "wayback": "https://web.archive.org/web/20170901201148/http://us2.campaign-archive1.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=7d88ceec82",
  "related": [
   "hangar:ships:anaconda",
   "hangar:ships:sidewinder",
   "codex:federal:start"
  ],
  "translation": "KI"
 },
 "017": {
  "author": "Michael Brookes, Mark Boss",
  "wayback": "https://web.archive.org/web/20150131230822/http://us2.campaign-archive1.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=f9f6f99d01",
  "related": [
   "codex:corporations:sirius_corporation"
  ],
  "translation": "KI",
  "header_local": "/home/claude/nl2_in/Elite_Dangerous Newsletter #012_files/mail_head_copy_vJtR.jpg",
  "header_note": "Wayback-Kopien von #12 und #17 haben die Kopfbilder vertauscht (Mailchimp-Dateiname mail_head_copy.jpg doppelt)"
 },
 "018": {
  "author": "",
  "wayback": "https://web.archive.org/web/20140806215529/http://us2.campaign-archive2.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=89b597852e",
  "related": [
   "hangar:ships:cobra_mk3",
   "hangar:ships:sidewinder",
   "hangar:ships:python",
   "codex:corporations:faulcon_delacy"
  ],
  "translation": "KI"
 },
 "019": {
  "author": "Frontier-Team",
  "wayback": "https://web.archive.org/web/20140901000600/http://us2.campaign-archive1.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=d6f301a16e",
  "related": [
   "hangar:ships:cobra_mk3",
   "hangar:ships:eagle",
   "hangar:ships:sidewinder",
   "codex:misc:pilotenvereinigung",
   "galaxy:systems:sol",
   "galaxy:systems:achenar",
   "galaxy:systems:alioth"
  ],
  "translation": "KI"
 },
 "020": {
  "author": "Frontier-Team",
  "wayback": "https://web.archive.org/web/20140901000638/http://us2.campaign-archive2.com/?u=dcbf6b86b4b0c7d1c21b73b1e&id=ee05d74c71",
  "related": [
   "hangar:ships:imperial_courier",
   "hangar:ships:sidewinder",
   "codex:federal:start",
   "codex:imperial:start",
   "codex:alliance:start"
  ],
  "translation": "KI"
 }
}