#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json, os, re, sys, time, datetime, urllib.request, urllib.parse

BASE = os.path.expanduser("~/Claude/Projects/에이전트구축")
MEDIA = os.path.join(BASE, "media_feed.jsonl")
XFEED = os.path.join(BASE, "x_feed.jsonl")
STATE = os.path.join(BASE, "sent_alerts.json")
REJECT = os.path.join(BASE, "rejected_feed.jsonl")
NQLEV = os.path.join(BASE, "nq_levels.py")

CUT = 5          # 미디어
CUT_X = 7        # X피드 (션이 직접 봄)
DRY = "--dry" in sys.argv
HOURS = 2
if "--hours" in sys.argv:
    HOURS = int(sys.argv[sys.argv.index("--hours") + 1])

def creds():
    tok = os.environ.get("BOT_TOKEN"); cid = os.environ.get("CHAT_ID")
    if tok and cid: return tok, cid
    try:
        s = open(NQLEV, encoding="utf-8").read()
        tok = tok or re.search(r'BOT_TOKEN\s*=\s*["\']([^"\']+)', s).group(1)
        cid = cid or re.search(r'CHAT_ID\s*=\s*["\']?([^"\'\s]+)', s).group(1)
    except Exception:
        return None, None
    return tok, cid

def send(msg):
    tok, cid = creds()
    if not tok or not cid:
        print("[!] BOT_TOKEN/CHAT_ID 못 찾음"); return False
    data = urllib.parse.urlencode({
        "chat_id": cid, "text": msg,
        "disable_web_page_preview": "true"}).encode()
    try:
        urllib.request.urlopen(
            f"https://api.telegram.org/bot{tok}/sendMessage", data=data, timeout=15)
        return True
    except Exception as e:
        print("[!] 전송 실패:", e); return False

UNIV = ["MU","SNDK","NBIS","AAOI","SOXL","DRAM","META","NVDA","LITE","TER","ALAB",
        "COHR","MRVL","CRDO","GLW","AVGO","CIEN","AEHR","ASML","LRCX","AMAT","KLAC",
        "SKHY","AMD","INTC","TSM","MSFT","GOOGL","AMZN","ORCL","IBM","NOW","CRM",
        "ACN","JPM","CRWV","IREN","QQQ","NDX","AXTI"]
UNIV_KO = ["하이닉스","삼성전자","마이크론","엔비디아","샌디스크","키옥시아","메타","브로드컴"]

PRIMARY_SRC = {"reuters","bloomberg.com","bloomberg","wsj","cnbc","the information",
               "eetimes.com","semiengineering.com","연합인포맥스","전자신문","dart",
               "the korea economic daily global edition","asml","nasdaq",
               "trendforce","counterpoint research","counterpoint",
               "semianalysis","semianalysis.com","digitimes","financial times"}
PRIMARY_X = {"@DeItaone","@SemiAnalysis_","@jukan05","@KobeissiLetter","@ValleyAI_X",
             "@DrNHJ","@pequityresearch","@stocktalkweekly","@Barchart"}


def _srckey(src, url=""):
    a = str(src or "").lower().strip()
    h = ""
    try:
        from urllib.parse import urlparse
        h = (urlparse(url).hostname or "").lower().replace("www.", "")
    except Exception:
        pass
    return a + " | " + h

BLOCK_TOK = {"motley fool","fool.com","seeking alpha","seekingalpha","simplywall",
             "tipranks","marketbeat","quiver","barchart","24/7 wall","moomoo",
             "benzinga","yahoo finance","finance.yahoo","yahoo.com","tradingview",
             "coindesk","bitget","crypto briefing","pluang","kdnuggets","sammobile",
             "globe and mail","moneycontrol","times of india","eciks",
             "investing.com","zacks","insider monkey","nate.com","네이트","daum.net"}

def blocked(src, url=""):
    k = _srckey(src, url)
    return any(tok in k for tok in BLOCK_TOK)

BLOCK_SRC = {"the motley fool","seeking alpha","simplywall.st","tipranks","marketbeat",
             "quiver quantitative","barchart.com","24/7 wall st.","moomoo","benzinga",
             "yahoo finance","yahoo finance uk","yahoo finance singapore","tradingview",
             "coindesk","bitget","crypto briefing","pluang","kdnuggets","sammobile",
             "the globe and mail","moneycontrol.com","the times of india","eciks.org",
             "investing.com","zacks","insider monkey","nate","네이트","v.daum.net"}
DEMOTE_SRC = {"tomshardware.com","the globe and mail","business insider"}
BLOCK_X = {"@1ChartMaster","@AshCrypto","@RosannaInvests","@MMatters22596","@saso_capital",
           "@michaelsikand","@CaesarCapitalz","@WhaleTwits","@TradexWhisperer",
           "@aleabitoreddit","@ParadisLabs","@nandeyare","@pelositracker"}

CUT_PAT = re.compile(
    r"(왜 오늘|오늘 왜|why is .*(stock )?(up|down|surging|rising|falling)|"
    r"here is why|what you need to know|outpaces stock market|"
    r"better buy|which .*is the better|where will .* be in 20|"
    r"mind-boggling|is (it )?a buy|buy, sell or hold|looks below fair value|"
    r"stocks that explain|biggest analyst calls|"
    r"price target (raised|lowered|cut) to|목표주가 (상향|하향)만|"
    r"best (stocks|picks)|top \d+ stocks|"
    r"review:|deals|newegg|ssd review|emulation|drone|gaming|laptop|"
    r"청약|최저임금|맛집|음악|뮤직|유튜브|밈|구독자|팔로워)", re.I)

KR_CUT = re.compile(
    r"(외국인|기관) *(순)?(매수|매도)|신용잔고|사이드카|서킷브레이커|"
    r"코스피 *지수|코스닥|금통위|한국은행|기준금리 *(인상|인하)|이재명|"
    r"국장|반대매매|공매도|배당소득세|상법|밸류업|레버리지 *ETF *(논란|규제)|"
    r"청약|부동산|주택|전세|최저임금", re.I)

SUPPLY_CUT = re.compile(
    r"(data ?cent(er|re).*(moratorium|permit|ban|freeze|zoning|tax break)|"
    r"모라토리엄|인허가 *동결|전력망|송전|발전소 *건설|가스 *터빈|"
    r"interconnection queue|power purchase|environmental|배출|오염)", re.I)

NUMCHG = re.compile(
    r"(컨센|예상치|전망치|시장 *예상|가이던스|guidance|vs\.? *(consensus|est)|"
    r"estimates?|상회|하회|상향|하향|beat[s]?|miss(ed|es)?|raise[ds]? .*(to|target)|"
    r"cut to|revis(ed|es)|QoQ|YoY|전년 *(동기)? *대비|전분기 *대비|"
    r"기록|사상 *최|record)", re.I)
NUMANY = re.compile(
    r"[+\-]?\d[\d,\.]* *(%|퍼센트|억|조|억달러|billion|million|trillion|bps|만개|대)", re.I)

EARN = re.compile(
    r"(어닝콜|실적 *발표|컨퍼런스 *콜|conference call|earnings call|"
    r"분기 *실적|quarterly results|Q[1-4] *(실적|results|earnings)|"
    r"가이던스|guidance|IR *공시|기업설명회|preliminary results|예비 *실적)", re.I)

TECH_GATE = re.compile(
    r"(HBM4?|HBF|CXMT|YMTC|루빈|Rubin|CoWoS|COUPE|CPO|실리콘 *포토닉스|"
    r"silicon photonic|KV *cache|KV *캐시|양자화|quantization|MoE|"
    r"인터포저|interposer|EUV|DUV|WFE|수율|yield|테이프아웃|tape ?out|"
    r"1\.6T|800G|DDR5|LPDDR|스택|stack|베이스 *다이|base die|번인|burn-?in)", re.I)

THESIS_BREAK = re.compile(
    r"(capex *(cut|guidance *cut|reduction|삭감|축소)|자본지출 *(축소|삭감)|"
    r"inference *(cost|memory) *(reduction|efficienc\w*|saving\w*|절감|효율|down|하락)|"
    r"추론 *(비용|메모리) *(절감|효율|하락)|"
    r"CXMT.*(DDR5|DRAM|양산|출하|capacity|캐파|점유율|matching|근접)|메모리 *(수요 *둔화|가격 *하락)|"
    r"(DeepSeek|Qwen|Kimi|Moonshot|MiniMax|Zhipu|GLM-?\d|Ernie|Doubao|디프시크)"
    r"[^\n]{0,80}(cheap|low-?cost|cost|pricing|price|가격|저가|free|open[- ]?source|오픈소스|"
    r"efficien\w*|효율|beat\w*|surpass\w*|outperform\w*|close[sd]? the gap|격차|"
    r"release\w*|launch\w*|unveil\w*|debut\w*|preview|V\d|출시|공개)|"
    r"(Chinese|China'?s?|중국)[^\n]{0,40}(A\.?I\.?|AI)?[^\n]{0,20}(model|lab|모델)s?"
    r"[^\n]{0,70}(cheap|low-?cost|cost|adopt\w*|switch\w*|shift\w*|gaining ground|surge|"
    r"close[sd]? the gap|catch\w* up|가격|저가|채택|전환|확산|잠식|격차)|"
    r"(Chinese|China'?s?|중국)[^\n]{0,30}(affordable|cheap|low-?cost|저가)"
    r"[^\n]{0,30}(A\.?I\.?|AI|모델))", re.I)

# 축A 수요축 — 티커 없이 오는 추론수요 실측치 (숫자 동반 시 게이트 통과)
DEMAND_AXIS = re.compile(
    r"(token[s]?[^\n]{0,25}(per (minute|second|day|month)|daily|usage|consumption|"
    r"demand|growth|processed|processes)|"
    r"\d[\d,\.]* *(billion|trillion|million|조|억) *tokens?|"
    r"토큰[^\n]{0,20}(사용량|소비|수요|처리|증가)|"
    r"inference (demand|volume|workload|spending)|추론 (수요|물량|지출)|"
    r"(price|pricing|cost)[^\n]{0,25}per million tokens|per million tokens|"
    r"100만 토큰|per-token (price|cost))", re.I)

# 축C 최종재 수요 파괴 — 메모리 논지 정면 반증
END_DEMAND = re.compile(
    r"((notebook|laptop|PC|smartphone|handset|노트북|스마트폰|휴대폰)[^\n]{0,50}"
    r"(shipment|출하|demand|수요)[^\n]{0,40}(cut|lower|decline|revised down|하향|감소|둔화|축소)|"
    r"(cut|lower|revis\w+|slash\w*|하향|축소)[^\n]{0,45}"
    r"(notebook|laptop|PC|smartphone|handset|노트북|스마트폰)[^\n]{0,35}(shipment|forecast|출하|전망)|"
    r"(memory|DRAM|LPDDR|메모리)[^\n]{0,70}(bill of materials|BOM|원가 비중)|"
    r"(smartphone|handset|notebook|laptop|PC|스마트폰|노트북)[^\n]{0,40}"
    r"(raise[sd]?|hike[sd]?|increase[sd]?|인상)[^\n]{0,20}price[^\n]{0,45}"
    r"(memory|LPDRAM|LPDDR|DRAM|메모리))", re.I)

# CXMT 실물화 트리거 (2026-07-17) — 티어1이 실제 채택하면 논지파괴
T1 = r"(Apple|Dell|HP|HPE|Lenovo|Asus|Acer|Microsoft|Google|Amazon|Meta|애플|델|레노버)"
CN_MEM = r"(CXMT|ChangXin|YMTC|Chinese (DRAM|memory|NAND)|중국산? *(D램|DRAM|메모리|낸드))"
ACT = (r"(qualif\w*|adopt\w*|sourc\w*|purchas\w*|order\w*|test\w*|approv\w*|"
       r"design win|채택|승인|구매|도입|테스트|검증)")
TIER1_CXMT = re.compile(
    "(" + T1 + r"[^\n]{0,60}" + CN_MEM + r"[^\n]{0,60}" + ACT + "|"
        + T1 + r"[^\n]{0,40}" + ACT + r"[^\n]{0,60}" + CN_MEM + "|"
        + CN_MEM + r"[^\n]{0,60}" + ACT + r"[^\n]{0,40}" + T1 + ")", re.I)

# 전주(하이퍼스케일러 5사) 지갑 축 (2026-07-17) — FCF·조달방식이 CapEx 가이던스보다 빠름
PATRON = r"(Meta|Alphabet|Google|Amazon|Microsoft|Oracle|하이퍼스케일러|빅테크|메타|알파벳|구글|아마존|마이크로소프트|오라클)"
FIN_EVENT = (r"(negative free cash ?flow|free cash ?flow[^\n]{0,25}(negative|decline|drop|fall|plunge|below zero)|"
             r"FCF[^\n]{0,20}(negative|마이너스)|잉여현금흐름[^\n]{0,15}(마이너스|급감|감소)|"
             r"(bond|note|debt)[^\n]{0,15}(sale|offering|issuance|raise)|채권[^\n]{0,10}발행|회사채[^\n]{0,10}발행|"
             r"(pause[sd]?|halt\w*|suspend\w*|중단)[^\n]{0,20}(buyback|repurchase|자사주)|"
             r"(buyback|repurchase|자사주)[^\n]{0,20}(pause|halt|suspend|중단)|"
             r"(SPV|special purpose vehicle|private credit|off-?balance)[^\n]{0,30}(data ?cent|financ|capex)|"
             r"capex[^\n]{0,25}(exceed|outstrip|surpass)[^\n]{0,25}(cash ?flow|operating cash)|"
             r"(cash ?flow|operating cash)[^\n]{0,25}(crossover|교차))")
PATRON_FIN = re.compile("(" + PATRON + r"[^\n]{0,70}" + FIN_EVENT + "|"
                            + FIN_EVENT + r"[^\n]{0,70}" + PATRON + ")", re.I)

STACK_BOTH = re.compile(
    r"((메타|Meta|Microsoft|Google|Amazon|OpenAI|xAI|Oracle)[^\n]{0,60}"
    r"(capex|자본지출|투자|investment|buildout|증설|확장|expansion|GW|기가와트)|"
    r"(self-build|자체 *(구축|증설)|in-house (cloud|compute)|own (data ?cent|cloud)))", re.I)

OPINION_ONLY = re.compile(
    r"(analyst|애널리스트|목표주가|price target|투자의견|rating|"
    r"initiat(e|ed) coverage|reiterat|maintains?|overweight|비중확대)", re.I)

IRAN = re.compile(
    r"(이란|Iran|호르무즈|Hormuz|IRGC|혁명수비대|테헤란|Tehran|"
    r"페르시아만|Strait of Hormuz|핵 *(시설|합의)|nuclear (site|deal))", re.I)

KRREG = re.compile(
    r"((해외|무허가|미등록|불법) *(가상자산)? *거래소|특금법|가상자산 *이용자 *보호법|"
    r"금감원.*(경고|제재|차단|점검)|금융위.*(해외|거래소|레버리지)|"
    r"(바이빗|바이낸스|Bybit|Binance|OKX).*(규제|차단|경고|제재|조사|접속)|"
    r"레버리지 *(ETF|상품).*(규제|제한|승인|논란)|"
    r"주식 *(퍼페추얼|무기한|선물).*(규제|금지)|해외 *선물.*(규제|과세))", re.I)

TRUMP = re.compile(r"(트럼프|Trump|TRUMP:|백악관|White House)", re.I)
TRUMP_NQ = re.compile(
    r"(관세|tariff|연준|Fed|금리|rate|반도체|chip|semiconductor|엔비디아|Nvidia|"
    r"수출 *(규제|통제|허가)|export (control|ban|license)|AI|인공지능|"
    r"빅테크|big tech|증시|stock market|나스닥|Nasdaq|감세|tax cut|"
    r"애플|Apple|메타|Meta|구글|Google|아마존|Amazon|마이크로소프트|Microsoft|"
    r"H200|H20|블랙웰|Blackwell|中|중국|China)", re.I)

WIDGET = re.compile(
    r"[A-Z][A-Za-z\.\-&' ]{1,28}(Corp|Inc|Ltd|PLC|N\.?V\.?|Holding[s]? NV|Group|"
    r"Technology|Systems|Solutions|Semiconductor|Platforms|ETF)[A-Za-z\.\- ]{0,20}"
    r"\$[\d,\.]+ *[+\-][\d\.]+%")
TAIL = re.compile(r"(더 보기|원문 언어 영어|원본 보기|구독자|· *\d+ *(분|시간|초|일))")

def clean(t):
    t = WIDGET.sub(" ", t)
    t = TAIL.sub(" ", t)
    t = re.sub(r"\s+", " ", t)
    return t

ALIAS = {
    "MU": ["micron"], "SNDK": ["sandisk"], "NBIS": ["nebius"],
    "AAOI": ["applied optoelectronics"], "META": ["meta platforms"],
    "NVDA": ["nvidia"], "LITE": ["lumentum"], "TER": ["teradyne"],
    "ALAB": ["astera labs"], "COHR": ["coherent"], "MRVL": ["marvell"],
    "CRDO": ["credo tech"], "GLW": ["corning"], "AVGO": ["broadcom"],
    "CIEN": ["ciena"], "LRCX": ["lam research"], "AMAT": ["applied materials"],
    "KLAC": ["kla corp", "kla-tencor"], "SKHY": ["sk hynix", "하이닉스"],
    "AMD": ["advanced micro"], "INTC": ["intel"],
    "TSM": ["tsmc", "taiwan semiconductor"], "MSFT": ["microsoft"],
    "GOOGL": ["google", "alphabet", "구글", "알파벳"], "AMZN": ["amazon"],
    "ORCL": ["oracle"], "NOW": ["servicenow"], "CRM": ["salesforce"],
    "ACN": ["accenture"], "JPM": ["jpmorgan", "jp morgan"],
    "CRWV": ["coreweave"], "IREN": ["iris energy"], "AXTI": ["axt inc"],
}

def tickers(t):
    hit = set()
    for tk in UNIV:
        if re.search(r"(?<![A-Za-z])\$?" + tk + r"(?![A-Za-z])", t): hit.add(tk)
    for k in UNIV_KO:
        if k in t: hit.add(k)
    tl = t.lower()
    for tk, names in ALIAS.items():
        for n in names:
            if n in tl: hit.add(tk); break
    return hit

def parse_dt(p):
    if not p: return None
    try: return datetime.datetime.fromisoformat(p.replace("Z", "+00:00"))
    except Exception:
        try:
            from email.utils import parsedate_to_datetime
            return parsedate_to_datetime(p)
        except Exception: return None

def score(item, now):
    raw = item.get("text", "") or ""
    t = clean(raw)
    src = (item.get("source") or item.get("author") or "").strip()
    srcl = src.lower()
    pub = parse_dt(item.get("pub"))
    tags, s = [], 0

    if CUT_PAT.search(t): return None, ["하드컷:노이즈패턴"]
    if blocked(src, item.get("url", "")) or src in BLOCK_X: return None, ["하드컷:소스"]
    tk = tickers(t)
    if KR_CUT.search(t) and not KRREG.search(t) and not tk & {"MU","SNDK","NVDA","META","AAOI","SOXL"}:
        return None, ["하드컷:코스피수급/한국정책"]
    CAPEX_KEEP = re.compile(r"(capex|자본지출|투자액|billion|억달러|조원|\d+ *GW|기가와트|"
                            r"buildout|증설|확대|expansion)", re.I)
    if SUPPLY_CUT.search(t) and not CAPEX_KEEP.search(t):
        return None, ["하드컷:공급제약(전력/부지)"]

    prim = srcl in PRIMARY_SRC or src in PRIMARY_X
    strong = bool(NUMCHG.search(t) and NUMANY.search(t))

    dem = bool(DEMAND_AXIS.search(t) and NUMANY.search(t))
    if not (prim or (strong and tk) or THESIS_BREAK.search(t)
            or END_DEMAND.search(t) or dem or PATRON_FIN.search(t)):
        return None, ["게이트 미달"]

    if srcl in DEMOTE_SRC: s -= 2
    if prim: s += 3; tags.append("1차")
    if strong: s += 3; tags.append("숫자")
    elif NUMANY.search(t): s += 1
    if tk: s += 2; tags.append("/".join(sorted(tk)[:4]))
    if pub and (now - pub).total_seconds() < 86400: s += 1
    if pub and (now - pub).total_seconds() > 172800: s -= 2
    if OPINION_ONLY.search(t) and not strong: s -= 3; tags.append("의견만")

    if TECH_GATE.search(t):
        if not (prim or strong): return None, ["기술축 게이트 미달"]
        s += 2; tags.append("기술")
    if STACK_BOTH.search(t) and re.search(r"[\d,\.]+ *(billion|억달러|조|GW|기가와트)", t, re.I):
        s += 2; tags.append("스택양면(반도체+ / 중간상-)")
    if dem: s += 3; tags.append("수요축")
    if END_DEMAND.search(t):
        s += 4; tags.append("!최종수요파괴")
        s = max(s, CUT_X + 1)
    if PATRON_FIN.search(t):
        s += 4; tags.append("!전주지갑")
        s = max(s, CUT_X + 1)
    if TIER1_CXMT.search(t):
        s += 4; tags.append("!티어1中메모리채택")
        s = max(s, CUT_X + 1)
    if THESIS_BREAK.search(t):
        s += 4; tags.append("!논지파괴")
        s = max(s, CUT_X + 1)  # 논지파괴는 소스·채널 불문 무조건 전송
    if EARN.search(t) and tk: s += 3; tags.append("어닝")

    return s, tags

def load(path, hours, now):
    out = []
    if not os.path.exists(path): return out
    for line in open(path, "rb"):
        line = line.decode("utf-8", "replace").strip()
        if not line: continue
        try: d = json.loads(line)
        except Exception: continue
        p = parse_dt(d.get("pub")) or parse_dt(d.get("ts"))
        if p and (now - p).total_seconds() <= hours * 3600:
            out.append(d)
    return out

def main():
    now = datetime.datetime.now(datetime.timezone.utc)
    try: sent = set(json.load(open(STATE)))
    except Exception: sent = set()

    items = [("m", d) for d in load(MEDIA, HOURS, now)] \
            + [("x", d) for d in load(XFEED, HOURS, now)]
    queue, rejected = [], []

    for origin, it in items:
        cut = CUT_X if origin == "x" else CUT
        url = it.get("url", "")
        if not url or url in sent: continue
        t = it.get("text", "") or ""
        src = it.get("source") or it.get("author") or "?"

        ct = clean(t)
        srcl = str(src).lower()
        prim = srcl in PRIMARY_SRC or src in PRIMARY_X
        ch = None
        if KRREG.search(ct): ch = "★계정리스크(한국규제)"
        elif prim and IRAN.search(ct): ch = "이란"
        elif prim and TRUMP.search(ct) and TRUMP_NQ.search(ct): ch = "트럼프/NQ"

        s, tags = score(it, now)
        if ch and (s is None or s < cut):
            s, tags = cut, [ch]
        elif ch:
            tags = [ch] + tags

        if s is None or s < cut:
            rejected.append({"url": url, "src": src, "text": t[:200],
                             "score": 0 if s is None else s,
                             "reason": tags, "ts": now.isoformat()})
            continue
        queue.append((s, tags, src, t, url))

    queue.sort(key=lambda x: -x[0])

    if rejected and not DRY:
        with open(REJECT, "a", encoding="utf-8") as f:
            for r in rejected: f.write(json.dumps(r, ensure_ascii=False) + "\n")

    for s, tags, src, t, url in queue:
        grade = "즉시확인" if s >= 8 else "맥락"
        body = " ".join(t.split())[:400]
        msg = f"[{grade}] {' · '.join(tags)}\n{src}\n{body}\n{url}"
        if DRY: print(msg + "\n" + "·" * 40)
        else:
            if send(msg): sent.add(url); time.sleep(1)

    if not DRY:
        json.dump(list(sent)[-5000:], open(STATE, "w"))
    print(f"[수집 {len(items)} / 전송 {len(queue)} / 컷 {len(rejected)}]")

if __name__ == "__main__":
    main()
