Explorer
/opt/struktur/asol-runtime/app.py
← Zurück ↓ Download
#!/usr/bin/env python3
import hashlib, json, os, re, sqlite3, time, threading
from pathlib import Path
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from html.parser import HTMLParser
from urllib.parse import urljoin, urlparse, parse_qs
from urllib.request import Request, urlopen
from xml.etree import ElementTree as ET
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from deep_translator import GoogleTranslator, MyMemoryTranslator
from buffer_support import ensure_schema as buffer_ensure_schema, buffer_status, handoff as buffer_handoff
from media_pipeline import ensure_post_graphic, public_url as media_public_url

BASE = os.path.dirname(__file__)
DB = os.environ.get('ASOL_DB', '/var/lib/asol/signals.db')
SOURCES = json.load(open(os.path.join(BASE, 'sources.json'), encoding='utf-8'))
UA = 'ASOL-Website-Collector/1.1 (+https://sma-asol.agentsolutions-mallorca.com)'
TIMEOUT, DELAY = 15, 0.35

class P(HTMLParser):
    def __init__(self):
        super().__init__(); self.links=[]; self.meta={}; self.title=''; self.in_title=False; self.text=[]
    def handle_starttag(self, tag, attrs):
        d=dict(attrs)
        if tag == 'a' and d.get('href'): self.links.append(d['href'])
        if tag == 'meta' and (d.get('property') or d.get('name')): self.meta[(d.get('property') or d.get('name')).lower()] = d.get('content','')
        if tag == 'time' and d.get('datetime'): self.meta.setdefault('time', d['datetime'])
        if tag == 'title': self.in_title=True
    def handle_endtag(self, tag):
        if tag == 'title': self.in_title=False
    def handle_data(self, data):
        if self.in_title: self.title += data
        if len(' '.join(self.text)) < 8000: self.text.append(data.strip())

def db():
    c=sqlite3.connect(DB); c.row_factory=sqlite3.Row; return c

def init():
    buffer_ensure_schema(DB)
    c=db(); c.executescript('''CREATE TABLE IF NOT EXISTS source_runs(id INTEGER PRIMARY KEY,source_key TEXT,started_at TEXT,finished_at TEXT,status TEXT,found INTEGER DEFAULT 0,stored INTEGER DEFAULT 0,error TEXT, candidate_count INTEGER DEFAULT 0, excluded_count INTEGER DEFAULT 0, dedupe_count INTEGER DEFAULT 0, diagnostics_json TEXT); CREATE TABLE IF NOT EXISTS signals(id TEXT PRIMARY KEY,source_key TEXT,source_name TEXT,url TEXT UNIQUE,title TEXT,summary TEXT,published_at TEXT,discovered_at TEXT,image_url TEXT,image_source TEXT,topics TEXT,company_relevance INTEGER,ai_value INTEGER,automation_potential INTEGER,content_potential INTEGER,score INTEGER,metadata_json TEXT); CREATE TABLE IF NOT EXISTS signal_translations(signal_id TEXT PRIMARY KEY,title_de TEXT NOT NULL,summary_de TEXT NOT NULL,translated_at TEXT NOT NULL,FOREIGN KEY(signal_id) REFERENCES signals(id)); CREATE INDEX IF NOT EXISTS ix_signals_published ON signals(published_at);''')
    for col in ('candidate_count','excluded_count','dedupe_count','diagnostics_json'):
        try: c.execute(f'ALTER TABLE source_runs ADD COLUMN {col} INTEGER DEFAULT 0' if col != 'diagnostics_json' else 'ALTER TABLE source_runs ADD COLUMN diagnostics_json TEXT')
        except sqlite3.OperationalError: pass
    c.execute('''CREATE TABLE IF NOT EXISTS content_items(id INTEGER PRIMARY KEY AUTOINCREMENT,signal_id TEXT NOT NULL,platform TEXT NOT NULL,title TEXT NOT NULL,body TEXT NOT NULL,image_url TEXT,image_source TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,UNIQUE(signal_id,platform))''')
    cols={r[1] for r in c.execute('pragma table_info(content_items)')}
    for name in ('post_media_url','post_media_kind','post_media_source'):
        if name not in cols:
            c.execute(f'alter table content_items add column {name} TEXT')
    c.commit(); c.close()

def _platform_label(p):
    return {'linkedin':'LinkedIn','facebook':'Facebook','instagram':'Instagram','reels':'Reels/TikTok'}.get(p,p)

def _draft_body(sig, platform):
    title=(sig.get('title_de') or sig.get('title') or 'Aktuelles KI- und Automatisierungsthema').strip()
    summary=(sig.get('summary_de') or sig.get('summary') or '').strip()
    if platform=='linkedin':
        return f"{title}\n\n{summary}\n\nEinordnung für Unternehmen: Entscheidend ist, welche konkrete Verbesserung sich für Prozesse, Service oder Produktivität ableiten lässt.\n\n#KI #Automation #AgentSolutions"
    if platform=='facebook':
        return f"{title}\n\n{summary}\n\nWas davon ist für Unternehmen praktisch nutzbar? Entscheidend ist die konkrete Anwendung statt reiner Technologie-Schlagzeilen."
    if platform=='instagram':
        return f"{title}\n\n{summary}\n\nTechnologie wird dann relevant, wenn sie im Alltag messbar Arbeit abnimmt oder bessere Ergebnisse ermöglicht.\n\n#KI #Automation"
    return f"{title}\n\n{summary}\n\nHook: Was davon verändert echte Arbeit – und was bleibt nur Schlagzeile?"

def _get_or_create_draft(signal_id, platform):
    c=db(); sig=c.execute('SELECT s.*,t.title_de,t.summary_de FROM signals s LEFT JOIN signal_translations t ON t.signal_id=s.id WHERE s.id=?',(signal_id,)).fetchone()
    if not sig:
        c.close(); return None
    row=c.execute('SELECT * FROM content_items WHERE signal_id=? AND platform=?',(signal_id,platform)).fetchone()
    if not row:
        d=dict(sig); now=datetime.now(timezone.utc).isoformat(); body=_draft_body(d,platform)
        media=ensure_post_graphic(d.get('title_de') or d.get('title') or '', d.get('summary_de') or d.get('summary') or '', brand='asol', fmt='4x5' if platform=='instagram' else '1x1', source_url=d.get('image_url') or '')
        c.execute('INSERT INTO content_items(signal_id,platform,title,body,image_url,image_source,post_media_url,post_media_kind,post_media_source,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)',(signal_id,platform,d.get('title_de') or d.get('title') or '',body,d.get('image_url'),d.get('image_source'),media.get('url') if media.get('ok') else '', 'image', media.get('source') if media.get('ok') else '', now,now)); c.commit()
        row=c.execute('SELECT * FROM content_items WHERE signal_id=? AND platform=?',(signal_id,platform)).fetchone()
    out=dict(row)
    if not (out.get('post_media_url') or '').strip():
        d=dict(sig)
        media=ensure_post_graphic(d.get('title_de') or d.get('title') or '', d.get('summary_de') or d.get('summary') or '', brand='asol', fmt='4x5' if platform=='instagram' else '1x1', source_url=d.get('image_url') or '')
        if media.get('ok'):
            c.execute('UPDATE content_items SET post_media_url=?,post_media_kind=?,post_media_source=?,updated_at=? WHERE signal_id=? AND platform=?',(media.get('url') or '', 'image', media.get('source') or 'generated', datetime.now(timezone.utc).isoformat(), signal_id, platform)); c.commit()
            row=c.execute('SELECT * FROM content_items WHERE signal_id=? AND platform=?',(signal_id,platform)).fetchone(); out=dict(row)
    out['source_url']=sig['url']; out['source_name']=sig['source_name']; c.close(); return out

def _draft_page(item):
    import html as _h
    if not item: return '<!doctype html><html lang="de"><meta charset="utf-8"><body>Entwurf nicht gefunden.</body></html>'
    media=item.get('post_media_url') or item.get('image_url') or ''
    source=item.get('post_media_source') or item.get('image_source') or '—'
    img=(f"<img id='mediaImg' src='{_h.escape(media)}' style='max-width:100%;max-height:460px;object-fit:contain;border-radius:8px;background:#0b0e14'>" if media else "<div class='emptyimg'>Keine Grafik vorhanden.</div>")
    return f"""<!doctype html><html lang='de'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>{_h.escape(_platform_label(item['platform']))}-Beitrag</title><style>body{{margin:0;background:#0f1117;color:#e2e8f0;font:14px system-ui}}a{{color:#8bb4ff;text-decoration:none}}.wrap{{max-width:1180px;margin:auto;padding:22px}}.grid{{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(320px,.8fr);gap:18px}}.card{{background:#1a1d27;border:1px solid #3a4354;border-radius:9px;padding:16px;margin-bottom:14px}}h1{{font-size:20px}}h2{{font-size:16px}}.tag{{display:inline-block;background:#4f8ef722;border:1px solid #4f8ef766;color:#8bb4ff;padding:5px 8px;border-radius:5px;font-weight:700}}textarea,input,select{{background:#0b0e14;color:#fff;border:1px solid #3a4354;border-radius:7px;padding:9px}}textarea{{width:100%;min-height:430px;line-height:1.55;font:14px system-ui;resize:vertical}}button{{background:#4f8ef7;color:#fff;border:0;border-radius:6px;padding:9px 13px;font-weight:700;cursor:pointer}}label{{display:block;margin:8px 0;color:#cbd5e1;font-size:12px}}.meta,.emptyimg{{color:#cbd5e1;font-size:12px}}@media(max-width:850px){{.grid{{grid-template-columns:1fr}}}}</style></head><body><div class='wrap'><div><a href='/'>← Cockpit</a></div><span class='tag'>{_h.escape(_platform_label(item['platform']))}</span><h1>Beitragsentwurf</h1><div class='grid'><div class='card'><h2>{_h.escape(item['title'])}</h2><textarea id='body'>{_h.escape(item['body'])}</textarea><p><button onclick='saveDraft()'>Speichern</button> <span id='saveStatus' class='meta'></span></p></div><aside><div class='card'><h2>Grafik / Video</h2>{img}<div class='meta'>Herkunft: {_h.escape(source)}</div><button onclick='generateGraphic()' style='margin-top:8px'>Ersatzgrafik erzeugen</button></div><div class='card'><h2>Veröffentlichen / planen (Buffer)</h2><label>Plattform <select id='pubPlatform'><option value='facebook'>Facebook</option><option value='instagram'>Instagram</option><option value='linkedin'>LinkedIn</option></select></label><label>Aktion <select id='pubAction' onchange='toggleDue()'><option value='draft'>Draft</option><option value='queue'>Queue</option><option value='schedule'>Termin</option><option value='now'>Jetzt</option></select></label><label id='dueWrap' style='display:none'>Datum/Uhrzeit <input type='datetime-local' id='dueAt'></label><label>Medientyp <select id='mediaKind'><option value='image'>Bild</option><option value='video'>Video</option></select></label><label>Post/Reel/Story <select id='postType'><option value='post'>Post</option><option value='reel'>Reel</option><option value='story'>Story</option></select></label><label>Link <input id='linkUrl' placeholder='optional' style='width:100%'></label><button onclick='sendBuffer()'>An Buffer senden</button><div id='bufStatus' class='meta'></div></div><div class='card'><h2>Quelle/Fakten</h2><div class='meta'>Quelle: {_h.escape(item.get('source_name') or '')}</div><a href='{_h.escape(item.get('source_url') or '')}' target='_blank' rel='noopener'>Quelle öffnen ↗</a></div></aside></div></div><script>const sid={item['signal_id']!r},plat={item['platform']!r};document.getElementById('pubPlatform').value=plat==='reels'?'instagram':plat;function toggleDue(){{document.getElementById('dueWrap').style.display=document.getElementById('pubAction').value==='schedule'?'block':'none'}}function saveDraft(){{fetch('/api/draft/save',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{signal_id:sid,platform:plat,body:document.getElementById('body').value}})}}).then(r=>r.json()).then(d=>document.getElementById('saveStatus').textContent=d.ok?'Gespeichert':(d.error||'Fehler'))}}function generateGraphic(){{fetch('/api/draft/graphic',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{signal_id:sid,platform:plat}})}}).then(r=>r.json()).then(d=>{{if(!d.ok)alert(d.error||'Fehler');else location.reload();}})}}function sendBuffer(){{let a=document.getElementById('pubAction').value,due=document.getElementById('dueAt').value;if(a==='schedule'&&!due){{alert('Datum/Uhrzeit fehlt');return}}fetch('/api/draft/buffer',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{signal_id:sid,platform:document.getElementById('pubPlatform').value,body:document.getElementById('body').value,action:a,due_at:due,media_kind:document.getElementById('mediaKind').value,post_type:document.getElementById('postType').value,link_url:document.getElementById('linkUrl').value}})}}).then(r=>r.json()).then(d=>{{document.getElementById('bufStatus').textContent=d.ok?('Buffer-ID: '+d.post_id):('Fehler: '+(d.error||'?'));}})}}</script></body></html>"""

def translate_de(text):
    text=(text or '').strip()
    if not text: return ''
    bad=("Error 500","That’s an error","That\'s an error","Server Error")
    try:
        out=GoogleTranslator(source='auto', target='de').translate(text) or ''
        if out and not any(x in out for x in bad): return out
    except Exception:
        pass
    try:
        out=MyMemoryTranslator(source='en-GB',target='de-DE').translate(text) or ''
        if out and not any(x in out for x in bad): return out
    except Exception:
        pass
    return ''

def ensure_translation(c, sid, title, summary):
    row=c.execute('SELECT title_de,summary_de FROM signal_translations WHERE signal_id=?',(sid,)).fetchone()
    if row and row['title_de'] and row['summary_de']:
        return row['title_de'],row['summary_de']
    title_de=translate_de(title) or 'Übersetzung derzeit nicht verfügbar'
    summary_de=translate_de(summary) or 'Deutsche Zusammenfassung derzeit nicht verfügbar.'
    c.execute('INSERT INTO signal_translations(signal_id,title_de,summary_de,translated_at) VALUES(?,?,?,?) ON CONFLICT(signal_id) DO UPDATE SET title_de=excluded.title_de,summary_de=excluded.summary_de,translated_at=excluded.translated_at',(sid,title_de,summary_de,datetime.now(timezone.utc).isoformat()))
    return title_de,summary_de

def fetch(url):
    r=urlopen(Request(url, headers={'User-Agent':UA, 'Accept':'text/html,application/rss+xml,application/atom+xml,application/xml'}), timeout=TIMEOUT)
    return r.read(2_000_000).decode('utf-8','ignore'), r.headers.get_content_type()

def iso_date(value):
    if not value: return None
    value=str(value).strip()
    try:
        d=datetime.fromisoformat(value.replace('Z','+00:00'))
    except ValueError:
        try: d=parsedate_to_datetime(value)
        except (TypeError, ValueError): return None
    if d.tzinfo is None: d=d.replace(tzinfo=timezone.utc)
    return d.astimezone(timezone.utc).isoformat()

def date_meta(p, raw=''):
    # echte Erstveroeffentlichung bevorzugen; Aenderungsdaten duerfen Artikel nicht kuenstlich verjuengen
    for key in ('datePublished','dateCreated'):
        m=re.search(r'"'+key+r'"\s*:\s*"([^"]+)"', raw, re.I)
        if m and iso_date(m.group(1)): return iso_date(m.group(1))
    for k in ('datepublished','article:published_time','og:published_time','pubdate','publishdate','sailthru.date','time','date'):
        if p.meta.get(k) and iso_date(p.meta[k]): return iso_date(p.meta[k])
    return None

def xml_items(raw, base):
    out=[]
    try: root=ET.fromstring(raw)
    except ET.ParseError: return out
    for node in root.findall('.//item') + root.findall('.//{*}entry'):
        def text(*names):
            for name in names:
                x=node.find(name) or node.find('{*}'+name)
                if x is not None and (x.text or '').strip(): return x.text.strip()
            return ''
        link=text('link')
        if not link:
            x=node.find('{*}link')
            link=(x.attrib.get('href','') if x is not None else '')
        out.append({'url':urljoin(base,link), 'title':text('title'), 'summary':text('description','summary','content'), 'published_at':iso_date(text('pubDate','published','updated','date'))})
    return out

def candidates(src, html):
    feed_items=xml_items(html, src['url'])
    if feed_items: return feed_items
    p=P(); p.feed(html); base=src['url']; out=[]
    for h in p.links:
        u=urljoin(base,h).split('#')[0]; path=urlparse(u).path.lower()
        if urlparse(u).netloc != urlparse(base).netloc or u.rstrip('/') == base.rstrip('/'): continue
        if path.count('/') >= 2 and not any(x in path for x in ('/tag/','/author/','/page/','/assets/','/public/','/category/')): out.append({'url':u})
    return [{'url':u} for u in dict.fromkeys(x['url'] for x in out)]

def score(title, summary, topics):
    s=(title+' '+summary).lower(); topicset=' '.join(topics).lower()
    groups={'ai':['ai','künst','ki','llm','gpt','copilot','agent','model'],'automation':['automation','automatis','workflow','n8n','zapier','make','integration','api','mcp','voice'],'company':['enterprise','business','crm','erp','governance','compliance','audit','security','backoffice']}
    hits={k:sum(x in s for x in v) for k,v in groups.items()}; topic_hits=sum(x in s for x in topics)
    a,b,c=hits['ai'],hits['automation'],hits['company']
    relevance=min(100,20+min(a,4)*9+min(b,4)*11+min(c,4)*10+min(topic_hits,3)*4)
    return relevance,min(100,25+a*12+c*8),min(100,20+b*15),min(100,20+(a+b+c)*10+topic_hits*3),hits

def collect():
    init(); total=stored=0; runs=[]
    for src in SOURCES:
        started=datetime.now(timezone.utc).isoformat(); diag={'fetches':0,'parse_items':0,'candidates':0,'date_missing':0,'irrelevant':0,'dedupe':0}; status='ok'; err=''; saved=0; c=db(); rid=c.execute('INSERT INTO source_runs(source_key,started_at,status) VALUES(?,?,?)',(src['key'],started,'running')).lastrowid; c.commit()
        try:
            items=[]
            for u in ([src.get('rss')] if src.get('rss') else []) + ([src.get('sitemap')] if src.get('sitemap') else []) + src.get('articles',[]):
                if not u: continue
                try:
                    raw,_=fetch(u); diag['fetches']+=1
                    if 'sitemap' in u: items += [{'url':x} for x in re.findall(r'<loc[^>]*>(.*?)</loc>',raw,re.I|re.S)[:50]]
                    else: items += xml_items(raw,u)
                except Exception: pass
            try:
                raw,_=fetch(src['url']); diag['fetches']+=1; items += candidates(src,raw)
            except Exception: pass
            unique=[]; seen=set()
            for x in items:
                u=x.get('url','').strip()
                if u and u not in seen: seen.add(u); unique.append(x)
            diag['candidates']=len(unique)
            for item in unique[:40]:
                u=item['url']; title=item.get('title',''); summary=item.get('summary',''); pub=item.get('published_at')
                try:
                    page,_=fetch(u); diag['fetches']+=1; p=P(); p.feed(page)
                    title=re.sub(r'\s+',' ',p.meta.get('og:title') or p.title or title).strip(); summary=re.sub(r'\s+',' ',(p.meta.get('og:description') or p.meta.get('description') or summary or ' '.join(p.text))).strip()[:700]; page_pub=date_meta(p,page); pub=page_pub or pub
                    if not pub: pub=date_meta(p, page)
                    img=p.meta.get('og:image') or p.meta.get('twitter:image')
                except Exception: continue
                if not title or not pub: diag['date_missing']+=1; continue
                if any(x in u.lower() for x in ('/tag/','/category/','/author/','/page/')): diag['irrelevant']+=1; continue
                sc,ai,aut,cont,hits=score(title,summary,src['topics'])
                if sum(hits.values()) == 0: diag['irrelevant']+=1; continue
                sid=hashlib.sha256(u.encode()).hexdigest()[:24]
                try:
                    c.execute('''INSERT INTO signals VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(url) DO UPDATE SET title=excluded.title,summary=excluded.summary,published_at=excluded.published_at,image_url=excluded.image_url,score=excluded.score,metadata_json=excluded.metadata_json''',(sid,src['key'],src['name'],u,title,summary,pub,datetime.now(timezone.utc).isoformat(),img,src['name'] if img else None,','.join(src['topics']),sc,ai,aut,cont,round((sc+ai+aut+cont)/4),json.dumps({'date_source':'rss/link+page metadata','collector':'asol-direct-website','score_components':hits})))
                    ensure_translation(c,sid,title,summary)
                    saved+=1; c.commit()
                except sqlite3.IntegrityError: diag['dedupe']+=1
                time.sleep(DELAY)
        except Exception as e: status='error'; err=str(e)[:300]
        c.execute('UPDATE source_runs SET finished_at=?,status=?,found=?,stored=?,error=?,candidate_count=?,excluded_count=?,dedupe_count=?,diagnostics_json=? WHERE id=?',(datetime.now(timezone.utc).isoformat(),status,diag['candidates']-diag['date_missing']-diag['irrelevant'],saved,err,diag['candidates'],diag['date_missing']+diag['irrelevant'],diag['dedupe'],json.dumps(diag),rid)); c.commit(); c.close(); total+=diag['candidates']; stored+=saved; runs.append({'source':src['key'],'status':status,'candidates':diag['candidates'],'stored':saved,'excluded':diag['date_missing']+diag['irrelevant'],'dedupe':diag['dedupe']})
    return {'sources':len(SOURCES),'found':total,'stored':stored,'runs':runs}

def read_sources():
    c=db(); rows=[dict(x) for x in c.execute('SELECT source_key,source_name,COUNT(*) AS signals,MAX(published_at) AS newest FROM signals GROUP BY source_key ORDER BY source_key')]; runs=[dict(x) for x in c.execute('SELECT source_key,status,candidate_count,stored,excluded_count,dedupe_count,finished_at,error FROM source_runs WHERE id IN (SELECT MAX(id) FROM source_runs GROUP BY source_key) ORDER BY source_key')]; c.close(); return {'sources':rows,'latest_runs':runs}

class H(BaseHTTPRequestHandler):
    def json(self,x):
        body=json.dumps(x,ensure_ascii=False); self.send_response(200); self.send_header('Content-Type','application/json; charset=utf-8'); self.send_header('Content-Length',str(len(body.encode()))); self.end_headers(); self.wfile.write(body.encode())
    def do_GET(self):
        path=self.path.split('?',1)[0]
        if path=='/api/health': self.json({'ok':True,'runtime':'asol-direct-website','db':DB,'mas_connected':False,'collector':'active'})
        elif path=='/api/radar':
            c=db(); rows=[]
            for r in c.execute('SELECT * FROM signals ORDER BY score DESC,published_at DESC LIMIT 100'):
                d=dict(r); tr=c.execute('SELECT title_de,summary_de FROM signal_translations WHERE signal_id=?',(d['id'],)).fetchone()
                d['title']=(tr['title_de'] if tr and tr['title_de'] else 'Übersetzung derzeit nicht verfügbar')
                d['summary']=(tr['summary_de'] if tr and tr['summary_de'] else 'Deutsche Zusammenfassung derzeit nicht verfügbar.')
                rows.append(d)
            self.json(rows); c.close()
        elif path=='/api/source-stats': self.json(read_sources())
        elif path.startswith('/static/'):
            static_root=Path(os.environ.get('ASOL_STATIC_DIR','/var/lib/asol/static')).resolve()
            fp=(static_root/path[len('/static/'):].lstrip('/')).resolve()
            if str(fp).startswith(str(static_root)) and fp.is_file():
                data=fp.read_bytes(); self.send_response(200); ext=fp.suffix.lower(); mime={'.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.gif':'image/gif'}.get(ext,'application/octet-stream'); self.send_header('Content-Type',mime); self.send_header('Content-Length',str(len(data))); self.end_headers(); self.wfile.write(data)
            else: self.send_response(404); self.end_headers()
        elif path=='/api/buffer/status': self.json(buffer_status())
        elif path=='/draft':
            q=parse_qs(self.path.split('?',1)[1] if '?' in self.path else ''); sid=(q.get('signal_id') or [''])[0]; platform=(q.get('platform') or ['facebook'])[0]
            if platform not in ('linkedin','facebook','instagram','reels'): platform='facebook'
            body=_draft_page(_get_or_create_draft(sid,platform)).encode('utf-8'); self.send_response(200); self.send_header('Content-Type','text/html; charset=utf-8'); self.send_header('Content-Length',str(len(body))); self.end_headers(); self.wfile.write(body)
        else:
            self.send_response(200); self.send_header('Content-Type','text/html; charset=utf-8'); body=open(os.path.join(BASE,'cockpit.html'),encoding='utf-8').read(); self.send_header('Content-Length',str(len(body.encode()))); self.end_headers(); self.wfile.write(body.encode())
    def do_POST(self):
        if self.path not in ('/api/draft/save','/api/draft/buffer','/api/draft/graphic'): self.send_response(404); self.end_headers(); return
        try:
            n=int(self.headers.get('Content-Length','0')); data=json.loads(self.rfile.read(n) or b'{}'); sid=data.get('signal_id',''); p=data.get('platform',''); body=str(data.get('body',''))
            if self.path=='/api/draft/graphic':
                c=db(); c.row_factory=sqlite3.Row; row=c.execute('select ci.*,s.summary from content_items ci join signals s on s.id=ci.signal_id where signal_id=? and platform=?',(sid,p)).fetchone()
                if not row: c.close(); self.json({'ok':False,'error':'Entwurf nicht gefunden'}); return
                d=dict(row); media=ensure_post_graphic(d.get('title') or '', d.get('summary') or d.get('body') or '', brand='asol', fmt='4x5' if p=='instagram' else '1x1', source_url=d.get('image_url') or '')
                if media.get('ok'):
                    c.execute('update content_items set post_media_url=?,post_media_kind=?,post_media_source=?,updated_at=? where signal_id=? and platform=?',(media.get('url'),'image',media.get('source'),datetime.now(timezone.utc).isoformat(),sid,p)); c.commit()
                c.close(); self.json({'ok':bool(media.get('ok')), **media}); return
            if self.path=='/api/draft/buffer': self.json(buffer_handoff(DB,data)); return
            c=db(); c.execute('UPDATE content_items SET body=?,updated_at=? WHERE signal_id=? AND platform=?',(body,datetime.now(timezone.utc).isoformat(),sid,p)); c.commit(); c.close(); self.json({'ok':True})
        except Exception as e: self.json({'ok':False,'error':str(e)})

def background_collector():
    while True:
        try: collect()
        except Exception: pass
        time.sleep(900)

init(); threading.Thread(target=background_collector,name='asol-collector',daemon=True).start(); ThreadingHTTPServer(('127.0.0.1',18767),H).serve_forever()