#!/usr/bin/env python3
"""xsafe: the ONE way every tool and every Claude session calls the X API (twitterapi.io).

Ben, 3 Oct 2026: "Build one tool that excludes all scam links, bots and scam call channels, that all
sessions and all tools use forever, so we don't pay for them and don't count them. Legit callers are fine."

What it does
  1. build_query()  adds exclusion operators to every search, so X never returns (and we never pay for)
                    vote farms, claim/airdrop phishing, whale-buy and trending bot feeds, known bot accounts.
                    Keeps the whole query under 500 characters: twitterapi.io silently returns ZERO posts
                    above ~512 characters (measured 3 Oct 2026), which looks exactly like "nobody posts".
  2. classify()     second pass on every returned post. 'hard' level = scam / campaign / bot feed / blocked
                    account (safe for any tool). 'full' level adds templates, coin lists, no-human-words
                    (for sentiment and mood work).
  3. search(), window(), timeline()   credit guard, cache, per 4h windows with exact or estimated rates.
  4. Legit callers are NOT spam: posting about many coins is never a reason. Only what the text does
     (phishing, vote farming, copy-paste templates, bot feeds) gets an account blocked.

Mood is never scored with keywords here. real posts are returned so a reader judges the feeling.

Key: env TWAPI_KEY, else /opt/common/twitterapi_key, else ~/.twapi_key. Never printed.
Public copy: http://159.223.88.168/hx-c9ois/fw/xsafe.py  (store: .../xsafe_store.json)
"""
import hashlib, json, os, re, sys, time, urllib.parse, urllib.request, urllib.error
import datetime as dt

VERSION = "1.9.1 (3 Oct 2026, gold-set check: 14 verified real posts were removed by 1.7/1.8; fixed zero-width, coin list, copytrading, leaderboard, long-form false positives)"

# ---------------------------------------------------------------- comparable counts (Ben, 3 Oct 2026)
# X search shows EVERY post for about 7 days, then only posts that got engagement (proven 3 Oct 2026: of 378 posts
# captured live 14 to 19 Sep, 82% still exist but only 14% still come back in search; 0 likes 0% stay, 3 to 9 likes 79%,
# 10+ 93%). So a raw count from the last 7 days is NOT comparable with an older window (looked like 8x louder).
# RULE: compare volumes ONLY with comparable(): real people (xsafe 'full') whose post has 3+ likes.
# Likes settle fast: posts 2h+ old already have 96% of their likes (measured 3 Oct), so posts under 2h old are
# projected with the share of settled real posts that reached 3+ likes in the same window (default 0.43).
COMPARABLE_LIKES = 3
SETTLE_H = 2.0
DEFAULT_SHARE = 0.43
VERSION_URL = "http://159.223.88.168/hx-c9ois/fw/xsafe_version.json"
SRC_URL = "http://159.223.88.168/hx-c9ois/fw/xsafe.py"
API = "https://api.twitterapi.io"
MAXQ = 500                    # hard cap; above ~512 chars twitterapi.io returns 0 posts silently
FLOOR = 50000                 # refuse to search below this many credits
COST_PER_PAGE = 150           # measured 3 Oct 2026 (20 posts)
HOME = "/opt/common/xsafe" if os.path.isdir("/opt/common") and os.access("/opt/common", os.W_OK) else os.path.expanduser("~/.xsafe")
STORE_URL = "http://159.223.88.168/hx-c9ois/fw/xsafe_store.json"
BKK = dt.timezone(dt.timedelta(hours=7))
UA = {"User-Agent": "Mozilla/5.0 (Macintosh) Chrome/128", "Accept": "application/json"}

# ---------------------------------------------------------------- query side (what we never pay for)
EXCL_TERMS = ['"whale just bought"', '"Listing ID"', 'leaderboard', '"vote matters"', '"votes are needed"',
              '"PAID DEXScreener"', '"Community Vote"', '"private TG"', '"DEX PAID"', '"new gainer"', '"WHALE BUY"',
              '"just got bought"', '"TRENDING on"', '"votes needed"', '"alpha group"', '"votes to go"', 'giveaway',
              '"claim your"', '"eligibility checker"', '"allocation portal"', '"wallet trail"', '"reward status"',
              '"dump risk"', '"allocation tool"', '"reward page"', '"wallet checker"', '"holder page"', '"time to claim"',
              '"token record"', '"connect wallet"', '"reward drop"', '"Daily Report"', '"VIP group"']
# measured on 132k stored posts (3 Oct 2026): each term removes spam and ZERO real posts. Words that also hit
# real people (airdrop 501 real, allocation 180, "my TG" 145, presale 37, "DM me" 63) are left to the post filter.
EXCL_FROM_SEED = ['LongLaunches', 'whalewatchRH', 'anubisgtrade', 'MintDetector1', 'ALaunchhunter',
                  'topcontract', 'MoontokListing', 'TrenchWatchX']

# ---------------------------------------------------------------- post side
SCAM = re.compile(r"\bclaim(s|ing)? (your|now|here|live|is live|it now)|time to claim|opened up claims|"
                  r"airdrop (is )?(live|campaign|checker|claim)|claim (the |your )?airdrop|airdrop.{0,40}(connect|check your|eligib)|"
                  r"eligib\w* (checker|check|wallets?)|check (your )?eligib|wallets? (qualif\w*|is eligible)|"
                  r"allocations? (tool|portal|is live|for (robinhood|solana) users)|check(ing)? (the )?allocation|"
                  r"(allocation|claim|reward|airdrop) portal|portal is (now |officially )?live|"
                  r"connect (your )?(web3 |metamask )?wallet|link your wallet|wallet checker|giveaway|"
                  r"dm (me|for) (for )?(access|the next|early|calls?|promo)|promo code|(join|buy) (the )?presale|presale (is )?live|"
                  r"free (bag|airdrop)|free tokens? to claim|check (your |ur )?allocation|sign up for a free|reward (page|status|drop|lookup)|guaranteed .{0,20}reward|"
                  r"holder (page|lookup|status)|wallet trail|token record|address (review|records?|history|tool)|"
                  r"scan id|dump risk|check (it )?out on #?gmgn|check out this (crypto )?airdrop|"
                  r"claim(ed)? mine|got a claim|has a claim|holders? (might want to|can|should) check|check (your |ur )?wallet|"
                  r"wallet check|eligibility (is )?show|on the portal|holders? (are )?getting (something|smth)|holders? actually got|"
                  r"got smth|if you faded|just claimed|claims? (is |are )?(live|open)", re.I)
CAMPAIGN = re.compile(r"\bvotes? (needed|are needed|matters|counts|to go|makes the difference)|listing id|leaderboard.{0,40}vote|vote.{0,40}leaderboard|vote dashboard|community vote\b|"
                      r"paid dexscreener|dex ?paid|new gainer|whale (just )?(bought|buy)|just got bought|whale alert|"
                      r"buy alert|volume alert|new pair (alert|detected|created)|token alert|trending on|top \d+ gainers|daily report|"
                      r"t\.me/|join (our|my) (private )?(tg|telegram|alpha|vip|group)|in my (private )?(alpha )?(tg|telegram|group)|my tg buys|"
                      r"call channel|calls? group|vip group|alpha group|private (alpha|group|tg)|"
                      r"copy ?trade (me|my)|signal group|timeline &(amp;)? audit|alpha spotted|making waves|(from|in) my (tg|telegram)( channel)?|telegram channel", re.I)
PHISH_WORDS = ['record', 'lookup', 'look up', 'trail', 'status', 'history', 'address', 'wallet data', 'on-chain',
               'check', 'review', 'outcome', 'result', 'share', 'balances', 'page', 'query', 'receipts', 'trace',
               'eligible', 'reward']
BOTFEED2 = re.compile(r"portal is (active|open|live)|takes \d+ ?(sec|seconds|min)|boosted|ticket id|listing request|has reached \d+(\.\d+)?x|"
                      r"since being surfaced|surfaced by|from my call|tg frens|is performing [+-]?\d|just did \d+(\.\d+)?x|early call|"
                      r"\d+(\.\d+)?x (gain )?from my|trench scanner|join the effort|community followers|trending alert|call-?outs have done|watch this video to change", re.I)
HIDDEN_ZW = re.compile('[\u200b-\u200d\u2060\ufeff]')   # zero-width chars: also in normal copy-paste, so only with scam words
LOOKALIKE = re.compile('[a-zA-Z][\u0400-\u04ff]|[\u0400-\u04ff][a-zA-Z]')
ZW_SCAMWORDS = re.compile(r'\bclaim\b|claim (now|here|your)|airdrop (live|is live|claim)|portal|allocation|eligib', re.I)
COINREF = re.compile(r'\$[A-Za-z][A-Za-z0-9]{1,11}\b|0x[0-9a-fA-F]{40}|\b[1-9A-HJ-NP-Za-km-z]{32,44}\b')
MASK = [(re.compile(r'https?://\S+'), ' '), (re.compile(r'\b[1-9A-HJ-NP-Za-km-z]{32,44}\b'), ' '),
        (re.compile(r'0x[0-9a-fA-F]{40}'), ' '), (re.compile(r'[$#@]\w+'), ' '),
        (re.compile(r'\d[\d,.]*[kmb%x]?', re.I), ' ')]


# 1.6 (3 Oct 2026): caught on 152k stored posts, none of them a real opinion post.
GIVEAWAY = re.compile(r"drop (your|ur) (sol|solana|eth|evm|base|wallet|address)|\bi'?ll be giving|\b(giving|give) away \d|"
                      r"(first|1st) \d[\d,]* (people|users|followers|wallets).{0,60}(follow|retweet|\brt\b|tollow)|"
                      r"lost wallets?.{0,40}claim|on incinerator|telegram is here", re.I)
BRAG = re.compile(r"(kept telling you|told you|i gave you|begged you) to buy|al diye yalvard|\d+x yapt|it did \d+(\.\d+)?x|"
                  r"first to call|callout hit \d|\bi called\b.{0,40}\d+(\.\d+)?x|\bnext call\b", re.I)


def norm(x):
    x = x.lower()
    for r, s in MASK: x = r.sub(s, x)
    return re.sub(r'\s+', ' ', x).strip()[:90]


def nwords(x):
    n = x.lower()
    for r, sub in MASK: n = r.sub(sub, n)
    return len(re.findall(r"[a-z']{2,}", n)) + len(re.findall(r'[぀-鿿가-힯]', n)) // 2


def _author(t):
    a = t.get('author') or {}
    return (a.get('userName') or t.get('a') or '').lower()


def _text(t):
    return t.get('text') if t.get('text') is not None else t.get('x', '')


def comparable(posts, now=None, level='full'):
    """the ONE count to compare windows across time (see COMPARABLE_LIKES). Returns dict(count, settled, young,
    share). count = settled real posts with 3+ likes + young real posts x share."""
    now = now or time.time()
    real = [t for t in posts if not classify(t, level)]
    settled = [t for t in real if now - ts(t) >= SETTLE_H * 3600]
    young = [t for t in real if now - ts(t) < SETTLE_H * 3600]
    hit = sum((t.get('likeCount') or 0) >= COMPARABLE_LIKES for t in settled)
    share = hit / len(settled) if len(settled) >= 10 else DEFAULT_SHARE
    return dict(count=hit + len(young) * share, settled=hit, young=len(young), share=round(share, 3))


_LATEST = {'t': 0}


def _own_md5():
    try: return hashlib.md5(open(__file__, 'rb').read()).hexdigest()
    except Exception: return None


def ensure_latest(max_age=600):
    """every run uses the latest filter (Ben, 3 Oct 2026). Checks the published version at most every 10 min.
    If this copy is older, downloads the latest, checks its md5, overwrites this file when writable and reloads
    the code in place, so callers keep working on the new rules without a restart."""
    if time.time() - _LATEST['t'] < max_age: return VERSION
    _LATEST['t'] = time.time()
    try:
        v = json.load(urllib.request.urlopen(urllib.request.Request(VERSION_URL, headers=UA), timeout=10))
    except Exception:
        return VERSION
    if not v.get('md5') or v['md5'] == _own_md5(): return VERSION
    try:
        src = urllib.request.urlopen(urllib.request.Request(SRC_URL, headers=UA), timeout=20).read()
        if hashlib.md5(src).hexdigest() != v['md5']: return VERSION
        try:
            if os.access(__file__, os.W_OK) and not __file__.startswith('/opt/common/'):
                open(__file__, 'wb').write(src)
        except Exception: pass
        g = globals(); keep = {k: g[k] for k in ('_STORE', '_SPENT', '_CFG', '_LATEST') if k in g}
        exec(compile(src, __file__, 'exec'), g); g.update(keep); g['_LATEST']['t'] = time.time()
        sys.stderr.write(f"xsafe: updated to {g['VERSION']}\n")
    except Exception:
        pass
    return globals()['VERSION']


_STORE = None


def store():
    """templates (normalized text seen on 3+ coins) and blocked accounts (repeat scam/campaign posters)."""
    global _STORE
    if _STORE is not None: return _STORE
    p = os.path.join(HOME, 'store.json')
    d = None
    try:
        if os.path.exists(p) and time.time() - os.path.getmtime(p) < 6 * 3600: d = json.load(open(p))
    except Exception: d = None
    if d is None:
        try:
            d = json.load(urllib.request.urlopen(urllib.request.Request(STORE_URL, headers=UA), timeout=15))
            os.makedirs(HOME, exist_ok=True); json.dump(d, open(p, 'w'))
        except Exception:
            try: d = json.load(open(p))
            except Exception: d = {}
    pats = []
    for x in d.get('patterns', []):
        try: pats.append(re.compile(x, re.I))
        except Exception: pass
    _STORE = dict(patterns=pats, templates=set(d.get('templates', [])), blocked=set(a.lower() for a in d.get('blocked', [])),
                  allow=set(a.lower() for a in d.get('allow', [])))
    return _STORE


def classify(t, level='full'):
    """None = a real person. Else the reason. level 'hard' is safe for any tool; 'full' is for sentiment work."""
    raw = _text(t) or ''; a = _author(t); S = store()
    if a in S['allow']: return None
    if LOOKALIKE.search(raw): return 'scam (look-alike letters)'
    long_form = nwords(raw) >= 150      # 1.9: phishing, vote farms and giveaways are short; long analysis posts quoting these words are real
    if HIDDEN_ZW.search(raw) and ZW_SCAMWORDS.search(raw) and nwords(raw) < 80: return 'scam (hidden letters)'
    if not long_form and SCAM.search(raw): return 'scam'
    if re.search(r'votes? (needed|are needed|matters|to go)|listing id', raw, re.I): return 'campaign or bot feed'
    if not long_form and CAMPAIGN.search(raw): return 'campaign or bot feed'
    low = raw.lower()
    if sum(w in low for w in PHISH_WORDS) >= 5 and not re.search(r'\b(buy|sell|sold|bought|pump|dump|chart|mc|cap|bag|moon|bottom|top)\b', low):
        return 'scam (phishing prose)'
    if a in S['blocked'] or a in (x.lower() for x in EXCL_FROM_SEED): return 'blocked account'
    if not long_form and GIVEAWAY.search(raw): return 'giveaway farm'
    if len(BRAG.findall(raw)) >= 2: return 'call channel brag list'
    if level == 'hard': return None
    if norm(raw) in S['templates']: return 'template'
    if any(r.search(raw) for r in S.get('patterns', [])): return 'learned pattern'
    refs = len(set(COINREF.findall(raw)))
    if refs >= 4 and nwords(raw) < 1.5 * refs: return 'coin list'     # 1.9: was < 3x (removed real lists with an opinion)
    if nwords(raw) < 2: return 'no human words'      # was < 4: dropped 1 in 3 real people ("$wire just getting started"), blind test 3 Oct
    if not long_form and BOTFEED2.search(raw): return 'bot feed'
    return None


def split(posts, level='full'):
    real, spam = [], []
    for t in posts:
        r = classify(t, level)
        (spam if r else real).append(t if not r else dict(t, _why=r))
    return real, spam


def build_query(q, since=None, until=None, room=MAXQ):
    """q plus exclusions, never longer than MAXQ. 'xsafe:off' in q = raw query (only for scam research)."""
    if 'xsafe:off' in q:
        q = q.replace('xsafe:off', '').strip(); ex = ''
    else:
        ex = ''
        tail = (f' since_time:{int(since)}' if since else '') + (f' until_time:{int(until)}' if until else '')
        budget = room - len(q) - len(tail) - 1
        for term in EXCL_TERMS + ['from:' + f for f in EXCL_FROM_SEED]:
            piece = ' -' + term
            if term.split(':')[-1].strip('"').lower() in q.lower(): continue
            if len(ex) + len(piece) > budget: break
            ex += piece
        q = q + ex
    if since: q += f' since_time:{int(since)}'
    if until: q += f' until_time:{int(until)}'
    if len(q) > MAXQ + 10: raise ValueError(f"query {len(q)} chars: twitterapi.io returns 0 posts above ~512")
    return q


# ---------------------------------------------------------------- calls, credit guard, cache
def _key():
    k = os.environ.get('TWAPI_KEY')
    for p in ['/opt/common/twitterapi_key', os.path.expanduser('~/.twapi_key')]:
        if k: break
        try: k = open(p).read().strip()
        except Exception: pass
    if not k: raise RuntimeError("no twitterapi.io key (env TWAPI_KEY or /opt/common/twitterapi_key)")
    return k


def _get(path, params, tries=3):
    u = API + path + ('?' + urllib.parse.urlencode(params) if params else '')
    for i in range(tries):
        try:
            with urllib.request.urlopen(urllib.request.Request(u, headers={"X-API-Key": _key(), **UA}), timeout=40) as r:
                return json.load(r)
        except urllib.error.HTTPError as e:
            if e.code in (400, 401, 402, 403, 404): raise RuntimeError(f"X API {e.code}: {'no credits' if e.code == 402 else 'refused'}")
            time.sleep(3 + 4 * i)
        except Exception:
            time.sleep(2 + 2 * i)
    raise RuntimeError("X API unreachable")


def credits(max_age=600):
    p = os.path.join(HOME, 'bal.json')
    try:
        d = json.load(open(p))
        if time.time() - d['t'] < max_age: return d['c']
    except Exception: pass
    d = _get('/oapi/my/info', None)
    c = (d.get('recharge_credits') or 0) + (d.get('total_bonus_credits') or 0)
    try: os.makedirs(HOME, exist_ok=True); json.dump({'t': time.time(), 'c': c}, open(p, 'w'))
    except Exception: pass
    return c


_SPENT = {'pages': 0}
_CFG = {'cap': 60, 'bulk': False}       # default run cap 60 pages (~9k credits). Bigger jobs must call budget().
STATUS_URL = "http://159.223.88.168/hx-c9ois/fw/xstatus.json"


def budget(pages, bulk=True):
    """declare a bigger job: max pages this run. Bulk jobs stop by themselves when the shared burn brake is on."""
    _CFG['cap'] = int(pages); _CFG['bulk'] = bool(bulk)


def status(max_age=60):
    """shared live balance + burn rate for ALL callers of the key (published every minute by the droplet)."""
    p = os.path.join(HOME, 'status.json')
    try:
        if time.time() - os.path.getmtime(p) < max_age: return json.load(open(p))
    except Exception: pass
    try:
        d = json.load(urllib.request.urlopen(urllib.request.Request(STATUS_URL, headers=UA), timeout=10))
        os.makedirs(HOME, exist_ok=True); json.dump(d, open(p, 'w')); return d
    except Exception:
        return {}


def _guard():
    st = status()
    if st and time.time() - st.get('t', 0) < 600:
        if st.get('balance') is not None and st['balance'] < st.get('floor', FLOOR):
            raise RuntimeError(f"X credits {st['balance']:,} under floor: no X calls until top up")
        if _CFG['bulk'] and st.get('brake'):
            raise RuntimeError(f"X burn brake ON ({st.get('burn_per_min_10m'):,} credits/min across all callers): bulk job stopped")
    elif credits() < FLOOR:
        raise RuntimeError(f"X credits under {FLOOR}: refusing to spend (top up twitterapi.io)")


def _page(query, cursor=None, cache_ttl=300):
    key = hashlib.sha1(f"{query}|{cursor}".encode()).hexdigest()
    cp = os.path.join(HOME, 'cache', key[:2], key + '.json')
    m = re.search(r'until_time:(\d+)', query)
    frozen = bool(m) and int(m.group(1)) < time.time() - 3 * 3600     # fully past window: cache forever
    try:
        if cache_ttl >= 0 and os.path.exists(cp) and (frozen or time.time() - os.path.getmtime(cp) < cache_ttl): return json.load(open(cp))
    except Exception: pass
    if _SPENT['pages'] >= _CFG['cap']:
        raise RuntimeError(f"xsafe run cap reached ({_CFG['cap']} pages, ~{_CFG['cap'] * COST_PER_PAGE:,} credits). Call xsafe.budget(n) for a bigger job.")
    if _SPENT['pages'] % 10 == 0: _guard()
    params = {'queryType': 'Latest', 'query': query}
    if cursor: params['cursor'] = cursor
    d = _get('/twitter/tweet/advanced_search', params); _SPENT['pages'] += 1
    try: os.makedirs(os.path.dirname(cp), exist_ok=True); json.dump(d, open(cp, 'w'))
    except Exception: pass
    return d


def ts(t):
    return dt.datetime.strptime(t['createdAt'], '%a %b %d %H:%M:%S %z %Y').timestamp()


def search(q, since=None, until=None, pages=10, level='full', cache_ttl=300):
    """returns dict(posts, real, spam, n_pages, has_more, oldest, newest, query). Spam is already mostly excluded by the query."""
    ensure_latest()
    query = build_query(q, since, until)
    posts, cur, n, more = [], None, 0, False
    seen = set()
    for _ in range(max(1, pages)):
        d = _page(query, cur, cache_ttl); n += 1
        for t in d.get('tweets') or []:
            if t.get('id') not in seen: seen.add(t.get('id')); posts.append(t)
        more = bool(d.get('has_next_page')); cur = d.get('next_cursor')
        if not more or not cur: break
    if not posts:
        # X sometimes answers empty by mistake (seen on SI, 3 Oct). An empty window is only believed after one fresh re-read.
        time.sleep(2); d = _page(query, None, -1); n += 1
        posts = list(d.get('tweets') or []); more = bool(d.get('has_next_page'))
    real, spam = split(posts, level)
    _strikes(spam)
    tt = [ts(t) for t in posts]
    # RULE (3 Oct 2026, Ben): never judge from 'the last 20 posts'. A count, 'nobody posts' or 'quiet' claim needs a
    # closed time window read to the END (complete=True). If the page cap stops first, the count is 'at least N'.
    return dict(query=query, posts=posts, real=real, spam=spam, n_pages=n, has_more=more, complete=not more,
                oldest=min(tt) if tt else None, newest=max(tt) if tt else None)


def window(q, since, until, max_pages=10):
    """posts per hour in [since, until). Exact when the window fits in the pages read, else estimated from the
    covered part (the newest posts). Rates: all returned posts (spam excluded by query) and real people only."""
    r = search(q, since, until, pages=max_pages)
    hrs = (until - since) / 3600
    if r['has_more'] and r['oldest']:
        cov = max((until - r['oldest']) / 3600, 0.05); exact = False
    else:
        cov = hrs; exact = True
    r.update(hours=hrs, covered_h=cov, exact=exact,
             rate_all=len(r['posts']) / cov, rate_real=len(r['real']) / cov, rate_spam=len(r['spam']) / cov,
             comparable=comparable(r['posts']), rate_comparable=comparable(r['posts'])['count'] / cov)
    return r


def timeline(q, end=None, hours=24, step=4, max_pages=10):
    """per-step windows ending at `end` (default now), oldest first. One page per window (~150 credits)."""
    end = int(end or time.time()); out = []
    for k in range(hours // step, 0, -1):
        a, b = end - k * step * 3600, end - (k - 1) * step * 3600
        out.append(window(q, a, b, max_pages))
    return out


def _strikes(spam):
    """repeat scam/campaign posters become blocked accounts (built hourly by xsafe_learn.py). Content only:
    a caller who posts many coins never gets a strike."""
    try:
        lines = [json.dumps({'a': _author(t), 'w': t['_why'], 't': int(time.time())}) for t in spam
                 if t.get('_why', '').startswith(('scam', 'campaign'))]
        if lines:
            os.makedirs(HOME, exist_ok=True)
            with open(os.path.join(HOME, 'strikes.jsonl'), 'a') as f: f.write('\n'.join(lines) + '\n')
    except Exception: pass


def show(r, n=25):
    when = lambda t: dt.datetime.fromtimestamp(ts(t), BKK).strftime('%d %b %H:%M')
    lines = [f"real people {len(r['real'])} | spam left after query filter {len(r['spam'])} | pages {r['n_pages']}"]
    for t in r['real'][:n]:
        a = t.get('author') or {}
        body = re.sub(r'\s+', ' ', _text(t))[:220]
        lines.append(f"  {when(t)} @{a.get('userName')} ({a.get('followers', '?')} followers): {body}")
    return '\n'.join(lines)


if __name__ == '__main__':
    import argparse
    ap = argparse.ArgumentParser(description='xsafe: X search without scams, bots and vote farms')
    ap.add_argument('query'); ap.add_argument('--hours', type=float, help='look back N hours')
    ap.add_argument('--pages', type=int, default=1); ap.add_argument('--timeline', type=int, help='per-4h windows over N hours')
    ap.add_argument('--why', action='store_true', help='also list removed posts with the reason')
    a = ap.parse_args()
    now = time.time()
    if a.timeline:
        for w in timeline(a.query, hours=a.timeline):
            end = dt.datetime.fromtimestamp(int(re.search(r'until_time:(\d+)', w['query']).group(1)), BKK)
            kind = 'exact' if w['exact'] else 'estimated'
            print(f"window ending {end:%d %b %H:%M} BKK: comparable {w['rate_comparable']:.2f}/h, real people {w['rate_real']:.2f}/h, all {w['rate_all']:.2f}/h ({kind})")
            for t in w['real'][:6]:
                body = re.sub(r'\s+', ' ', _text(t))[:160]
                print(f"    @{_author(t)}: {body}")
    else:
        r = search(a.query, since=now - a.hours * 3600 if a.hours else None, pages=a.pages)
        print(show(r))
        if a.why:
            for t in r['spam']:
                body = re.sub(r'\s+', ' ', _text(t))[:120]
                print(f"  [removed: {t['_why']}] @{_author(t)}: {body}")
