#!/usr/bin/env python3 """λΉ„ν•˜μ΄λΈŒ '[🐝 곡λͺ¨μ²­μ•½]' μ˜μƒ β†’ μ’…λͺ©λ³„ μ²­μ•½ 의견 μš”μ•½ (μžμ‚°μ›Ή 곡λͺ¨μ£Ό νƒ­ ν‘œμ‹œμš©). 흐름: 채널 μ˜μƒ λͺ©λ‘(youtube_briefing_digest μž¬μ‚¬μš©) β†’ 제λͺ© '곡λͺ¨μ²­μ•½' ν•„ν„° β†’ 처리 μ•ˆ ν•œ μ˜μƒλ§Œ μžλ§‰ β†’ LLM 이 'μ§€κΈˆ μ§„ν–‰Β·μ˜ˆμ • 곡λͺ¨μ£Ό λͺ©λ‘' 쀑 μ˜μƒμ΄ 닀룬 μ’…λͺ©λ³„λ‘œ μš”μ•½ β†’ state 에 μ˜μƒ λ‹¨μœ„ μ €μž₯. behive_web 은 state 파일만 μ½λŠ”λ‹€(λ Œλ” 경둜 LLMΒ·λ„€νŠΈμ›Œν¬ 0). ⚠️ μ˜μƒ ν•˜λ‚˜κ°€ μ—¬λŸ¬ μ’…λͺ©μ„ 닀룬닀(예: 멜콘 μ˜μƒ = 1μ£Όμ°¨ 3μ’…λͺ©) β†’ 제λͺ© ν•΄μ‹œνƒœκ·Έλ‘œ λ§€μΉ­ν•˜μ§€ μ•Šκ³  후보 λͺ©λ‘μ„ LLM 에 μ£Όκ³  ipoCode 둜 λŒλ €λ°›λŠ”λ‹€. λͺ©λ‘μ— μ—†λŠ” μ½”λ“œλŠ” 버린닀. ⚠️ μžλ§‰μ€ μ—…λ‘œλ“œ 직후 없을 수 μžˆλ‹€ β†’ μ‹€νŒ¨λŠ” 기둝만 ν•˜κ³  λ‹€μŒ μ‹€ν–‰μ—μ„œ μž¬μ‹œλ„, MAX_ATTEMPTS ν›„ 포기. 트리거: ipo_alert.py(stock.ipo-alert 08:30Β·10:00)κ°€ 끝에 run() 을 λΆ€λ₯Έλ‹€. μƒˆ 트리거 μ—†μŒ. CLI: python3 ipo_youtube_digest.py [run|show] [--dry-run] """ from __future__ import annotations import argparse import json import re import subprocess import sys from datetime import date, datetime, timedelta from pathlib import Path sys.path.insert(0, '/Users/snowoyh/.openclaw/workspace/scripts') import youtube_briefing_digest as ytd # noqa: E402 from ipo_calendar_sync import KST, NAVER_IPO_API_URL, _naver_api_rows, fetch # noqa: E402 STATE_FILE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace/state/ipo_youtube_summaries.json') VIDEOS_URL = ytd.CHANNELS['behive']['videos_url'] TITLE_FILTER = '곡λͺ¨μ²­μ•½' # launchd PATH 의 /opt/homebrew/bin/openclaw λŠ” node22 라 μ•ˆ λœ¬λ‹€ β€” node24 래퍼λ₯Ό μ ˆλŒ€κ²½λ‘œλ‘œ. OPENCLAW_BIN = str(Path.home() / '.local/bin/openclaw') LLM_MODEL = 'openai/gpt-5.6-sol' LLM_TIMEOUT_SEC = 180 MAX_ATTEMPTS = 6 # μžλ§‰ 미생성·LLM μ‹€νŒ¨ μž¬μ‹œλ„ μƒν•œ (ν•˜λ£¨ 2회 μ‹€ν–‰ β†’ μ•½ 3일) KEEP_DAYS = 120 # 상μž₯ λλ‚œ μ§€ 였래된 μ˜μƒ 기둝 정리 VERDICTS = {'positive': 'μ²­μ•½ λ§€λ ₯ λ†’μŒ', 'neutral': 'μ€‘λ¦½Β·μˆ˜μš”μ˜ˆμΈ‘ 확인', 'negative': 'μ²­μ•½ λΆ€λ‹΄'} def load_state() -> dict: try: return json.loads(STATE_FILE.read_text()) except Exception: return {'videos': {}} def save_state(state: dict) -> None: tmp = STATE_FILE.with_suffix('.json.tmp') tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2)) tmp.replace(STATE_FILE) def _candidates(today: date) -> list[dict]: """μš”μ•½ λŒ€μƒ 후보 = 아직 상μž₯ μ•ˆ ν–ˆκ±°λ‚˜ 상μž₯ 2μ£Ό 이내인 곡λͺ¨μ£Ό(μ²­μ•½ 전·쀑·후 λͺ¨λ‘).""" rows = _naver_api_rows(json.loads(fetch(NAVER_IPO_API_URL, encoding='utf-8'))) floor = (today - timedelta(days=14)).isoformat() out = [] for r in rows: code = (r.get('ipoCode') or r.get('itemCode') or '').strip() name = (r.get('compName') or '').strip() if not code or not name: continue if (r.get('lcalDate') or '9999') < floor: continue out.append({'ipo_code': code, 'name': name, 'po_start': r.get('poStartDate') or '', 'po_end': r.get('poEndDate') or '', 'lcal_date': r.get('lcalDate') or '', 'hope': f"{r.get('hopePubStart') or '?'}~{r.get('hopePubEnd') or '?'}", 'fix': r.get('fixPubPrice') or ''}) return out def _call_llm(prompt: str) -> str: cmd = [OPENCLAW_BIN, 'capability', 'model', 'run', '--prompt', prompt, '--model', LLM_MODEL, '--json'] p = subprocess.run(cmd, capture_output=True, text=True, timeout=LLM_TIMEOUT_SEC) if p.returncode != 0: raise RuntimeError(f'LLM rc={p.returncode}: {p.stderr[-300:]}') data = json.loads(p.stdout[p.stdout.index('{'):]) text = ((data.get('outputs') or [{}])[0].get('text') or '').strip() if not data.get('ok') or not text: raise RuntimeError(f'LLM 응닡 비정상: {str(data)[:300]}') return text def _prompt(title: str, transcript: str, cands: list[dict]) -> str: cand_lines = '\n'.join( f'- {c["ipo_code"]} {c["name"]} (μ²­μ•½ {c["po_start"]}~{c["po_end"]}, 희망곡λͺ¨κ°€ {c["hope"]}원' + (f', 확정곡λͺ¨κ°€ {c["fix"]}원' if c['fix'] else '') + ')' for c in cands) return f"""μ•„λž˜λŠ” 유튜브 채널 'λΉ„ν•˜μ΄λΈŒ 투자자문'의 곡λͺ¨μ£Ό μ²­μ•½ μ˜μƒ μžλ§‰(μžλ™μƒμ„±, μ˜€νƒ€ 많음)이닀. μ˜μƒμ΄ **ꡬ체적으둜 λΆ„μ„ν•œ** μ’…λͺ©λ§Œ 골라 μ’…λͺ©λ³„λ‘œ μš”μ•½ν•˜λΌ. μΌμ •λ§Œ μ–ΈκΈ‰ν•˜κ³  μ§€λ‚˜κ°„ μ’…λͺ©μ€ μ œμ™Έ. μ’…λͺ©μ€ λ°˜λ“œμ‹œ μ•„λž˜ 후보 λͺ©λ‘μ˜ μ½”λ“œλ‘œ μ§€μ •ν•œλ‹€(μžλ§‰μ˜ μ’…λͺ©λͺ… μ˜€νƒ€λŠ” 후보λͺ…μœΌλ‘œ 맞좰 νŒλ‹¨). 후보에 μ—†μœΌλ©΄ μ œμ™Έ. [후보] {cand_lines} [μ˜μƒ 제λͺ©] {title} [μžλ§‰] {transcript} 좜λ ₯은 JSON λ°°μ—΄ ν•˜λ‚˜λ§Œ(μ„€λͺ…Β·μ½”λ“œνŽœμŠ€ κΈˆμ§€): [{{"ipo_code":"A123456","verdict":"positive|neutral|negative","verdict_text":"μ˜μƒ κ²°λ‘  ν•œ ꡬ절(15자 이내)","points":["핡심 3~4개, 각 40자 이내"]}}] - verdict: μ˜μƒ ν™”μžμ˜ μ²­μ•½ 의견. 긍정=positive, μœ λ³΄Β·μˆ˜μš”μ˜ˆμΈ‘ 보고 νŒλ‹¨=neutral, λΆ€μ •Β·λΆ€λ‹΄=negative - μžλ§‰ μˆ«μžλŠ” λ°›μ•„μ“°κΈ° 였λ₯˜κ°€ μž¦λ‹€(예: 74,000β†’7,400). 곡λͺ¨κ°€λŠ” 후보 정보가 μ •λ‹΅μ΄λ‹ˆ κ·Έ 값을 μ“°κ³ , λ‹€λ₯Έ μˆ˜μΉ˜λ„ μ•žλ’€κ°€ μ•ˆ 맞으면 빼라 - points: 사업 ν•œ 쀄 / 싀적 좔이(수치) / μœ ν†΅κ°€λŠ₯λ¬ΌλŸ‰(%) / 곡λͺ¨κ°€Β·λ°Έλ₯˜ λΆ€λ‹΄ λ“± ν™”μžκ°€ μ§šμ€ μž₯단점. μ˜μƒμ— μ—†λŠ” λ‚΄μš© μ§€μ–΄λ‚΄μ§€ 말 것 - ν•΄λ‹Ή μ’…λͺ©μ΄ μ—†μœΌλ©΄ []""" def _parse(text: str, cands: list[dict]) -> list[dict]: m = re.search(r'\[.*\]', text, re.S) arr = json.loads(m.group(0)) if m else [] names = {c['ipo_code']: c['name'] for c in cands} out = [] for it in arr: code = str(it.get('ipo_code') or '').strip() if code not in names: continue verdict = it.get('verdict') if it.get('verdict') in VERDICTS else 'neutral' points = [str(p).strip() for p in (it.get('points') or []) if str(p).strip()][:4] out.append({'ipo_code': code, 'name': names[code], 'verdict': verdict, 'verdict_text': str(it.get('verdict_text') or VERDICTS[verdict]).strip()[:30], 'points': points}) return out def run(dry_run: bool = False) -> dict: """μƒˆ 곡λͺ¨μ²­μ•½ μ˜μƒλ§Œ μš”μ•½. λ°˜ν™˜: {new, summarized, failed}.""" now = datetime.now(KST) state = load_state() videos = state.setdefault('videos', {}) listing = [v for v in ytd.fetch_channel_videos(VIDEOS_URL) if TITLE_FILTER in v.get('title', '')] todo = [v for v in listing if not videos.get(v['video_id'], {}).get('done') and videos.get(v['video_id'], {}).get('attempts', 0) < MAX_ATTEMPTS] result = {'new': len(todo), 'summarized': 0, 'failed': 0} if not todo: return result cands = _candidates(now.date()) for v in todo: vid = v['video_id'] rec = videos.setdefault(vid, {'title': v['title'], 'url': ytd.WATCH_URL.format(vid=vid), 'attempts': 0}) try: transcript, status = ytd.fetch_transcript(vid) if status != 'ok': raise RuntimeError(f'μžλ§‰ {status}') stocks = _parse(_call_llm(_prompt(v['title'], transcript, cands)), cands) except Exception as e: rec['attempts'] = rec.get('attempts', 0) + 1 rec['last_error'] = f'{type(e).__name__}: {e}'[:300] result['failed'] += 1 print(f'ipo yt {vid} μ‹€νŒ¨({rec["attempts"]}): {rec["last_error"]}', file=sys.stderr) continue published = ytd.get_publish_kst(vid) rec.update({'done': True, 'stocks': stocks, 'summarized_at': now.isoformat(timespec='seconds'), 'published_kst': published.isoformat() if published else None}) rec.pop('last_error', None) result['summarized'] += 1 if dry_run: print(json.dumps({vid: rec}, ensure_ascii=False, indent=2)) # 였래된 기둝 정리 β€” λͺ©λ‘μ—μ„œ 빠쑌고 μš”μ•½ μ‹œκ°μ΄ KEEP_DAYS λ„˜μ€ 것 cutoff = (now - timedelta(days=KEEP_DAYS)).isoformat() for vid in [k for k, r in videos.items() if (r.get('summarized_at') or now.isoformat()) < cutoff]: videos.pop(vid) if not dry_run: save_state(state) return result def summaries_by_code() -> dict[str, list[dict]]: """behive_web 용: ipoCode β†’ [{video_id,title,url,published_kst,verdict,verdict_text,points,tagged}]. 제λͺ© ν•΄μ‹œνƒœκ·Έμ— κ·Έ μ’…λͺ©μ΄ μžˆλŠ” μ „μš© μ˜μƒμ„ μ•žμ—, κ·Έλ‹€μŒ μ΅œμ‹ μˆœ.""" out: dict[str, list[dict]] = {} for vid, rec in load_state().get('videos', {}).items(): for s in rec.get('stocks') or []: out.setdefault(s['ipo_code'], []).append({ 'video_id': vid, 'title': rec.get('title', ''), 'url': rec.get('url', ''), 'published_kst': rec.get('published_kst') or rec.get('summarized_at') or '', 'verdict': s['verdict'], 'verdict_text': s['verdict_text'], 'points': s['points'], 'tagged': f'#{s["name"]}' in rec.get('title', ''), }) for lst in out.values(): lst.sort(key=lambda x: x['published_kst'], reverse=True) lst.sort(key=lambda x: not x['tagged']) return out def main() -> int: ap = argparse.ArgumentParser() ap.add_argument('cmd', nargs='?', default='run', choices=('run', 'show')) ap.add_argument('--dry-run', action='store_true') args = ap.parse_args() if args.cmd == 'show': print(json.dumps(summaries_by_code(), ensure_ascii=False, indent=2)) return 0 print(json.dumps(run(args.dry_run), ensure_ascii=False)) return 0 if __name__ == '__main__': raise SystemExit(main())