#!/usr/bin/env python3 """ 事件驱动舆情触发选币 v1.7.3 目标:重大消息刚发生 → 时间窗/去重/重要性评级 → 单币快速技术检查 → 飞书推送。 原则:消息只负责触发检查,技术形态决定是否推荐。 """ import os import re import sys import json import time import hashlib import sqlite3 from datetime import datetime, timedelta, timezone from email.utils import parsedate_to_datetime from urllib.parse import quote_plus import ccxt import pandas as pd import requests import yaml sys.path.insert(0, os.path.dirname(__file__)) from config_loader import load_rules, get_meta, get_strategy_direction from altcoin_db import init_db, get_conn, create_recommendation, log_screening, log_cron_run, should_push, log_push, get_recommendation_for_push from altcoin_screener import ( fetch_all_tickers, detect_volume_price_fly, detect_static_accumulation, STABLECOINS, WRAPPED, BTC_ETH, GOLD_METAL, BNB_CHAIN, EXCLUDED_BASES, EXCLUDED_BASE_SUFFIXES, ) from altcoin_confirm import fetch_derivatives_context from pa_engine import full_pa_analysis, calc_atr from feishu_push import push_recommendation_state_alert DB_PATH = os.getenv("ALPHAX_DB_PATH", os.path.join(os.path.dirname(__file__), "data", "altcoin_monitor.db")) exchange = ccxt.binance({"enableRateLimit": True}) LEVEL_RANK = {"S": 4, "A": 3, "B": 2, "C": 1, "D": 0, "RISK": 5} def _level_max(a, b): """返回重要性更高的级别。""" return a if LEVEL_RANK.get(a, 0) >= LEVEL_RANK.get(b, 0) else b def _now(): return datetime.now() def _cfg(): return load_rules(force_reload=True).get("event_driven", {}) def _parse_binance_time(ms): try: return datetime.fromtimestamp(int(ms) / 1000) except Exception: return None def _parse_pubdate(value): if not value: return None try: dt = parsedate_to_datetime(value) if dt.tzinfo: dt = dt.astimezone().replace(tzinfo=None) return dt except Exception: return None def _is_recent(dt, max_hours=None): if not dt: return False hours = max_hours or _cfg().get("news_time_window_hours", 3) return (_now() - dt) <= timedelta(hours=hours) and dt <= _now() + timedelta(minutes=5) def _event_hash(source, title, symbol): raw = f"{source}|{title}|{symbol}".lower().strip() return hashlib.sha256(raw.encode()).hexdigest()[:20] def init_event_tables(): conn = get_conn() conn.execute(""" CREATE TABLE IF NOT EXISTS event_news ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_hash TEXT UNIQUE, source TEXT NOT NULL, symbol TEXT NOT NULL, title TEXT NOT NULL, url TEXT DEFAULT '', published_at TEXT NOT NULL, detected_at TEXT NOT NULL, importance TEXT DEFAULT 'B', event_type TEXT DEFAULT '', raw_json TEXT DEFAULT '{}', processed INTEGER DEFAULT 0, decision TEXT DEFAULT '', tech_score INTEGER DEFAULT 0, rec_id INTEGER DEFAULT 0, pushed INTEGER DEFAULT 0 ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_event_news_time ON event_news(published_at, detected_at)") conn.execute("CREATE INDEX IF NOT EXISTS idx_event_news_symbol ON event_news(symbol)") conn.commit() conn.close() def _symbol_from_title(title): """从标题里提取可能的币种,返回 AAA/USDT 列表。""" text = title or "" candidates = set() # XXXUSDT / XXXUSDT Perpetual for m in re.finditer(r"\b([A-Z0-9]{2,15})USDT\b", text): base = m.group(1).upper() candidates.add(f"{base}/USDT") # Binance Will List XXX (Name) / Add XXX patterns = [ r"Will List\s+([A-Z0-9]{2,15})(?![A-Z0-9]*USDT)\b", r"Will Launch\s+([A-Z0-9]{2,15})(?![A-Z0-9]*USDT)\b", r"Will Add\s+([A-Z0-9]{2,15})(?![A-Z0-9]*USDT)\b", r"Earn\s+([A-Z0-9]{2,15})(?![A-Z0-9]*USDT)\b", r"Add\s+([A-Z0-9]{2,15})(?![A-Z0-9]*USDT)\b", ] for pat in patterns: for m in re.finditer(pat, text, flags=re.I): base = m.group(1).upper() if base.endswith("USDT"): continue candidates.add(f"{base}/USDT") # 括号中的 ticker for m in re.finditer(r"\(([A-Z0-9]{2,15})\)", text): base = m.group(1).upper() candidates.add(f"{base}/USDT") return [s for s in sorted(candidates) if _tradable_symbol(s)] def _tradable_symbol(symbol): base = symbol.split("/")[0].upper() if base in STABLECOINS or base in WRAPPED or base in BTC_ETH or base in GOLD_METAL or base in BNB_CHAIN: return False if base in EXCLUDED_BASES or base.endswith(EXCLUDED_BASE_SUFFIXES): return False if not base.isascii(): return False return True def _base_symbol(symbol): return (symbol or "").split("/")[0].upper() def _theme_cfg(): return _cfg().get("theme_expansion", {}) or {} def _theme_definitions(): return _theme_cfg().get("themes", {}) or {} def _matched_themes(title="", symbol=""): """识别标题/命中币种所属的生态主题。""" if not _theme_cfg().get("enabled", False): return [] low = (title or "").lower() base = _base_symbol(symbol) matched = [] for theme_name, theme in _theme_definitions().items(): keywords = [str(k).lower() for k in theme.get("keywords", [])] primary = {str(s).upper() for s in theme.get("primary_symbols", [])} symbols = {str(s).upper() for s in theme.get("symbols", [])} if any(k and k in low for k in keywords) or base in primary or base in symbols: matched.append((theme_name, theme)) return matched def _event_copy_for_symbol(event, symbol, theme_name, theme, expanded=True): min_level = _theme_cfg().get("min_theme_importance", "A") importance = _level_max(event.get("importance", "B"), min_level) original_title = event.get("title", "") return { **event, "symbol": symbol, "importance": importance, "event_type": "theme_expansion" if expanded else "theme_direct", "title": f"[主题扩散:{theme_name}] {original_title}", "raw": { "parent_event": event.get("raw", {}), "parent_symbol": event.get("symbol", ""), "theme": theme_name, "theme_symbols": theme.get("symbols", []), "expansion_reason": theme.get("note", "生态主题消息扩散"), }, } def expand_theme_events(events): """重大生态/主题事件扩散到同生态币,解决 TON/DOGS 这类联动行情漏选。""" if not _theme_cfg().get("enabled", False): return events expanded = list(events) seen = {(e.get("source"), e.get("title"), e.get("symbol")) for e in expanded} for e in events: for theme_name, theme in _matched_themes(e.get("title", ""), e.get("symbol", "")): direct = _event_copy_for_symbol(e, e.get("symbol"), theme_name, theme, expanded=False) key = (direct.get("source"), direct.get("title"), direct.get("symbol")) if direct.get("symbol") and key not in seen and _tradable_symbol(direct.get("symbol")): expanded.append(direct) seen.add(key) for base in theme.get("symbols", []): symbol = f"{str(base).upper()}/USDT" if symbol == e.get("symbol") or not _tradable_symbol(symbol): continue child = _event_copy_for_symbol(e, symbol, theme_name, theme, expanded=True) key = (child.get("source"), child.get("title"), child.get("symbol")) if key not in seen: expanded.append(child) seen.add(key) return expanded def classify_event(title, source=""): cfg = _cfg().get("importance", {}) low = (title or "").lower() negs = [k.lower() for k in cfg.get("negative_keywords", [])] s_keys = [k.lower() for k in cfg.get("s_keywords", [])] a_keys = [k.lower() for k in cfg.get("a_keywords", [])] if any(k in low for k in negs): return "RISK", "risk_negative" if any(k in low for k in s_keys): return "S", "major_listing_or_contract" if any(k in low for k in a_keys): return "A", "important_catalyst" if "trending" in source.lower() or "coingecko" in source.lower(): return "B", "market_heat" return "C", "minor_or_unknown" def _passes_min_importance(level): min_level = _cfg().get("min_importance_level", "A") if level == "RISK": return True return LEVEL_RANK.get(level, 0) >= LEVEL_RANK.get(min_level, 3) def fetch_binance_events(source_key, source_cfg): if not source_cfg.get("enabled", True): return [] url = source_cfg.get("url") events = [] try: r = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"}) if r.status_code != 200: return [] data = r.json() catalogs = (data.get("data") or {}).get("catalogs") or [] for cat in catalogs: for a in cat.get("articles", []) or []: title = a.get("title", "") pub = _parse_binance_time(a.get("releaseDate")) if not _is_recent(pub, _cfg().get("news_time_window_hours", 3)): continue symbols = _symbol_from_title(title) if not symbols: continue importance, event_type = classify_event(title, source_key) if not _passes_min_importance(importance): continue code = a.get("code") or "" link = f"https://www.binance.com/en/support/announcement/{code}" if code else "" for symbol in symbols: events.append({ "source": source_key, "symbol": symbol, "title": title, "url": link, "published_at": pub, "importance": importance, "event_type": event_type, "raw": a, }) except Exception as e: print(f"[event] fetch_binance_events error {source_key}: {e}") return events def fetch_coingecko_trending_events(): cfg = _cfg().get("sources", {}).get("coingecko_trending", {}) if not cfg.get("enabled", True): return [] try: from sentiment_monitor import fetch_trending_coins, _get_previous_trending trending = fetch_trending_coins() prev = {r["symbol"] for r in _get_previous_trending()} events = [] now = _now() for t in trending[:10]: sym = (t.get("symbol") or "").upper() full = f"{sym}/USDT" if not _tradable_symbol(full): continue # Trending 只作为热度源:必须同时满足新进Top10/Top5 + 交易所可交易,且后续仍需技术确认。 if sym not in prev or t.get("trend_rank", 99) <= 5: title = f"{sym}({t.get('name','')}) enters CoinGecko Trending #{t.get('trend_rank')}" events.append({ "source": "coingecko_trending", "symbol": full, "title": title, "url": "https://www.coingecko.com/en/trending-crypto", "published_at": now, "importance": "B", "event_type": "market_heat", "raw": t, }) return events except Exception as e: print(f"[event] fetch trending error: {e}") return [] def collect_events(): cfg = _cfg() sources = cfg.get("sources", {}) events = [] for key in ("binance_listing", "binance_latest"): if key in sources: events.extend(fetch_binance_events(key, sources[key])) events.extend(fetch_coingecko_trending_events()) return expand_theme_events(events) def store_events(events): init_event_tables() conn = get_conn() stored = [] now = _now().isoformat() for e in events: h = _event_hash(e["source"], e["title"], e["symbol"]) try: conn.execute(""" INSERT INTO event_news (event_hash, source, symbol, title, url, published_at, detected_at, importance, event_type, raw_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( h, e["source"], e["symbol"], e["title"], e.get("url", ""), e["published_at"].isoformat(), now, e["importance"], e["event_type"], json.dumps(e.get("raw", {}), ensure_ascii=False), )) e["event_hash"] = h stored.append(e) except sqlite3.IntegrityError: continue except Exception as ex: print(f"[event] store error {e.get('title')}: {ex}") conn.commit() conn.close() return stored def fetch_klines(symbol, timeframe, limit=120): try: ohlcv = exchange.fetch_ohlcv(symbol, timeframe, limit=limit) df = pd.DataFrame(ohlcv, columns=["timestamp", "open", "high", "low", "close", "volume"]) df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms") return df except Exception: return None def _ticker_info(symbol): try: t = exchange.fetch_ticker(symbol) return { "price": float(t.get("last") or 0), "change_24h": float(t.get("percentage") or 0), "volume_24h": float(t.get("quoteVolume") or 0), } except Exception: return {"price": 0, "change_24h": 0, "volume_24h": 0} def quick_technical_check(event): symbol = event["symbol"] cfg = _cfg().get("technical_check", {}) ticker = _ticker_info(symbol) price = ticker["price"] signals = [f"📢 {event['importance']}级舆情触发: {event['title']}"] score = 0 decision = "ignore" reason = "" entry_plan = {} if price <= 0: return {"decision": "ignore", "reason": "交易对不可用或无价格", "score": 0, "signals": signals, "price": price} if event.get("importance") == "RISK": return {"decision": "risk", "reason": "负面重大消息,禁买/风控", "score": 0, "signals": signals, "price": price, "ticker": ticker} reject_gain = cfg.get("reject_if_24h_gain_gt", 30) warn_gain = cfg.get("warn_if_24h_gain_gt", 18) if ticker["change_24h"] > reject_gain: signals.append(f"⛔ 24h已涨{ticker['change_24h']:.1f}%>{reject_gain}%,不追高") return {"decision": "risk", "reason": "重大消息但已过度拉升,不追高", "score": 0, "signals": signals, "price": price, "ticker": ticker} elif ticker["change_24h"] > warn_gain: signals.append(f"⚠️ 24h已涨{ticker['change_24h']:.1f}%,追高风险升高") score -= 1 h1 = fetch_klines(symbol, "1h", 100) h4 = fetch_klines(symbol, "4h", 100) if h1 is None or len(h1) < 30: return {"decision": "observe", "reason": "K线数据不足,仅观察", "score": 0, "signals": signals, "price": price, "ticker": ticker} current_triggers = [{"type": "news", "label": event.get("event_type") or "消息触发", "source": event.get("source"), "title": event.get("title"), "published_at": event.get("published_at").isoformat() if hasattr(event.get("published_at"), "isoformat") else str(event.get("published_at", ""))}] stale_background = [] vp = detect_volume_price_fly(h1) if vp: if vp.get("vp_fly_count", 0) >= 1: score += 5 signals.append(f"1H量价齐飞({vp.get('vp_fly_count')}根, 最大量比{vp.get('max_vol_ratio')}x)") current_triggers.append({"type": "technical", "label": "当前1H量价齐飞", "source": "binance_ohlcv_1h", "age_hours": vp.get("latest_vp_age_hours")}) elif vp.get("relaxed_vp_fly_count", 0) >= 2: score += 4 signals.append(f"1H连续放宽量价齐飞({vp.get('relaxed_vp_fly_count')}根)") elif vp.get("stale_vp_fly_count", 0): stale = vp.get("stale_vp_fly_details", [{}])[-1] signals.append(f"1H历史量价齐飞已过期({stale.get('age_hours')}小时前, 量{stale.get('vol_ratio')}x)") stale_background.append({"type": "technical", "label": "历史1H量价齐飞", "source": "binance_ohlcv_1h", "age_hours": stale.get("age_hours"), "vol_ratio": stale.get("vol_ratio")}) elif vp.get("max_consecutive_3x", 0) >= 2: score += 2 signals.append(f"1H连续{vp.get('max_consecutive_3x')}根3x放量") static_acc = detect_static_accumulation(symbol, h4) if h4 is not None and len(h4) >= 30 else None if static_acc: score += 3 signals.append(f"4H静K蓄力({static_acc['static_count']}静K,量比{static_acc['vol_ratio']}x)") current_triggers.append({"type": "technical", "label": "当前4H静K蓄力", "source": "pa_engine_4h"}) theme_bonus_cfg = _theme_cfg().get("static_accumulation_bonus", {}) or {} if event.get("event_type") in ("theme_expansion", "theme_direct") and theme_bonus_cfg.get("enabled", True): min_static = theme_bonus_cfg.get("min_static_count", 8) bonus = theme_bonus_cfg.get("score_bonus", 3) if static_acc.get("static_count", 0) >= min_static: score += bonus signals.append(f"生态主题+强静K蓄力升权(+{bonus})") pa1 = full_pa_analysis(h1, "1h") ignitions = pa1.get("ignition_points", []) if pa1 else [] max_ig = 0 stale_igs = [] for ig in ignitions[-5:]: if ig.get("direction") != 1: continue if ig.get("age_bars", 999) <= 1: max_ig = max(max_ig, ig.get("strength_ratio", 0)) else: stale_igs.append(ig) if max_ig >= 5: score += 3 signals.append(f"1H静K→阳动K起爆(强度{max_ig}×)") current_triggers.append({"type": "technical", "label": "当前1H起爆点", "source": "pa_engine_1h", "strength": max_ig}) elif stale_igs: ig = stale_igs[-1] signals.append(f"1H历史起爆点已过期({ig.get('age_bars')}根前, 强度{ig.get('strength_ratio')}×)") stale_background.append({"type": "technical", "label": "历史1H起爆点", "source": "pa_engine_1h", "age_bars": ig.get("age_bars"), "strength": ig.get("strength_ratio")}) deriv = fetch_derivatives_context(symbol) funding = deriv.get("funding_rate", 0) or 0 if funding > cfg.get("reject_if_funding_gt", 0.003): signals.append(f"⛔ Funding过热({funding*100:.3f}%)") return {"decision": "risk", "reason": "资金费率过热,不追", "score": score, "signals": signals, "price": price, "ticker": ticker, "derivatives": deriv} if deriv.get("top_trader_long_pct", 0) and deriv.get("top_trader_long_pct", 0) > 55: score += 1 signals.append(f"大户偏多({deriv.get('top_trader_long_pct')}%)") atr = calc_atr(h1, 14) if atr and atr > 0: stop_loss = round(max(price * 0.92, price - 2 * atr), 6) tp1 = round(price * 1.05, 6) tp2 = round(price * 1.10, 6) risk = price - stop_loss entry_plan = { "entry_price": round(price, 6), "entry_method": "事件驱动即时技术确认", "entry_action": "可即刻买入" if score >= cfg.get("min_tech_score_recommend", 6) else "等技术确认", "stop_loss": stop_loss, "stop_pct": round((stop_loss / price - 1) * 100, 1) if price else 0, "tp1": tp1, "tp2": tp2, "rr1": round((tp1 - price) / risk, 2) if risk > 0 else 0, "rr2": round((tp2 - price) / risk, 2) if risk > 0 else 0, "current_price": round(price, 6), "risk_reward_ok": risk > 0, "trigger_context": { "trigger_status": "news_current" if current_triggers else "background", "trigger_label": "消息面触发 + 技术确认" if current_triggers else "消息背景观察", "current_triggers": current_triggers, "stale_background": stale_background, "event_source": event.get("source"), "event_title": event.get("title"), "event_url": event.get("url"), "event_importance": event.get("importance"), "published_at": event.get("published_at").isoformat() if hasattr(event.get("published_at"), "isoformat") else str(event.get("published_at", "")), }, } if score >= cfg.get("min_tech_score_recommend", 6) and event.get("importance") in ("S", "A"): decision = "recommend" reason = "重大消息+技术形态确认" elif score >= cfg.get("min_tech_score_observe", 3): decision = "observe" reason = "消息重大但技术只到观察级" else: decision = "ignore" reason = "技术形态未确认" return { "decision": decision, "reason": reason, "score": score, "signals": signals, "entry_plan": entry_plan, "price": round(price, 6), "ticker": ticker, "derivatives": deriv, "static_accumulation": static_acc, "trigger_context": { "trigger_status": "news_current" if current_triggers else "background", "trigger_label": "消息面触发 + 技术确认" if current_triggers else "消息背景观察", "current_triggers": current_triggers, "stale_background": stale_background, "event_source": event.get("source"), "event_title": event.get("title"), "event_url": event.get("url"), "event_importance": event.get("importance"), "published_at": event.get("published_at").isoformat() if hasattr(event.get("published_at"), "isoformat") else str(event.get("published_at", "")), }, } def process_event(event): result = quick_technical_check(event) rec_id = 0 pushed = False symbol = event["symbol"] decision = result["decision"] signals = result.get("signals", []) price = result.get("price", 0) log_screening( layer="舆情触发", symbol=symbol, state="爆发" if decision == "recommend" else "蓄力" if decision == "observe" else "风险" if decision == "risk" else "过期", score=result.get("score", 0), price=price or 0, signals=signals, sector="", leader_status=event.get("source", ""), is_meme=0, change_24h=(result.get("ticker") or {}).get("change_24h", 0), funding_rate=(result.get("derivatives") or {}).get("funding_rate", 0), ) if decision == "recommend": ep = result.get("entry_plan") or {} rec_id = create_recommendation( symbol=symbol, rec_state="爆发", rec_score=result.get("score", 0), entry_price=price, stop_loss=ep.get("stop_loss", 0), tp1=ep.get("tp1", 0), tp2=ep.get("tp2", 0), sector="事件驱动", signals=signals, is_meme=0, entry_plan=ep, direction=get_strategy_direction(), force_reason="重大舆情触发", base_state="舆情触发", sector_signal_count=0, market_context={"event_source": event.get("source"), "trigger_context": result.get("trigger_context", {}), **(result.get("ticker") or {})}, derivatives_context=result.get("derivatives") or {}, sector_context={"event_title": event.get("title"), "event_url": event.get("url"), "event_source": event.get("source"), "event_importance": event.get("importance"), "trigger_context": result.get("trigger_context", {})}, ) # 飞书只是通知层:事件脚本不再直接推 observe/risk,也不允许 rec_id=0 的事件旁路进通知。 # 只有 decision=recommend 且已创建主推荐记录后,消费主链路派生状态进行通知。 if decision == "recommend" and rec_id and _cfg().get("push", {}).get(decision, True): mainline_item = get_recommendation_for_push(rec_id) if mainline_item and mainline_item.get("execution_status") in ("buy_now", "wait_pullback"): push_type = "event_entry" if mainline_item.get("execution_status") == "buy_now" else "event_watch_pool" action = mainline_item.get("action_status", "") if should_push(symbol, push_type, action): ok, resp = push_recommendation_state_alert(mainline_item, title_prefix="事件触发机会") if ok: pushed = True log_push(symbol, push_type, action, rec_id=rec_id) else: print(f"[event] push failed {symbol}: {resp}") else: status = mainline_item.get("execution_status") if mainline_item else "missing" print(f"[event] skip push {symbol}: mainline_status={status}") elif decision in ("observe", "risk"): print(f"[event] skip push {symbol}: decision={decision} is not a主链路推荐通知") conn = get_conn() conn.execute(""" UPDATE event_news SET processed=1, decision=?, tech_score=?, rec_id=?, pushed=? WHERE event_hash=? """, (decision, result.get("score", 0), rec_id, int(pushed), event.get("event_hash"))) conn.commit() conn.close() return {"event": event, "result": result, "rec_id": rec_id, "pushed": pushed} def load_unprocessed_events(limit=20): init_event_tables() conn = get_conn() cutoff = (_now() - timedelta(hours=_cfg().get("max_event_age_hours", 6))).isoformat() rows = conn.execute(""" SELECT * FROM event_news WHERE processed=0 AND published_at >= ? ORDER BY published_at DESC LIMIT ? """, (cutoff, limit)).fetchall() conn.close() events = [] for r in rows: d = dict(r) d["published_at"] = datetime.fromisoformat(d["published_at"]) d["raw"] = json.loads(d.get("raw_json") or "{}") events.append(d) return events def run_once(process_existing=True): started = _now() init_db() init_event_tables() collected = collect_events() stored = store_events(collected) to_process = stored if stored else (load_unprocessed_events() if process_existing else []) processed = [] for e in to_process: if isinstance(e.get("published_at"), str): e["published_at"] = datetime.fromisoformat(e["published_at"]) processed.append(process_event(e)) output = { "status": "processed" if processed else "no_new_events", "collected_count": len(collected), "stored_count": len(stored), "processed_count": len(processed), "decisions": {k: sum(1 for p in processed if p["result"]["decision"] == k) for k in ["recommend", "observe", "risk", "ignore"]}, "events": [ { "symbol": p["event"].get("symbol"), "importance": p["event"].get("importance"), "title": p["event"].get("title"), "decision": p["result"].get("decision"), "score": p["result"].get("score"), "reason": p["result"].get("reason"), "rec_id": p.get("rec_id"), "pushed": p.get("pushed"), } for p in processed ], "check_time": _now().isoformat(), } log_cron_run( job_name="事件舆情", script_name="event_driven_screener.py", run_status="success", result_status=output["status"], started_at=started.isoformat(), finished_at=_now().isoformat(), duration_ms=int((_now() - started).total_seconds() * 1000), summary={"stored_count": len(stored), "processed_count": len(processed), "decisions": output["decisions"]}, error_message="", ) return output def main(): import argparse parser = argparse.ArgumentParser(description="事件驱动舆情触发选币") parser.add_argument("--once", action="store_true") parser.add_argument("--no-process-existing", action="store_true") args = parser.parse_args() out = run_once(process_existing=not args.no_process_existing) print(json.dumps(out, ensure_ascii=False, indent=2, default=str)) if __name__ == "__main__": main()