auto: 일일 백업 2026-10-08 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-10-08 02:00:06 +09:00
parent 114a1ef816
commit 4f63ec56dc
114 changed files with 1667 additions and 1290 deletions
+42 -2
View File
@@ -7318,6 +7318,18 @@ table.adr-info-table td.adr-breakdown { font-size: 11px; color: #8b8f9a; white-s
.ipo-done { font-size: 12px; color: #8b8f9a; }
.ipo-done.mine { color: #7ee2a8; font-weight: 600; }
.ipo-stock.done .ipo-kv { margin-top: 2px; }
.ipo-yt { margin: 2px 0 8px; padding: 8px 10px; border-radius: 8px; background: #10131b; border-left: 3px solid #8b8f9a; font-size: 12px; }
.ipo-yt.positive { border-left-color: #7ee2a8; }
.ipo-yt.negative { border-left-color: #ff8a96; }
.ipo-stock.done .ipo-yt { margin-top: 8px; }
.ipo-yt-head { font-weight: 600; color: #cfd6e6; margin-bottom: 4px; }
.ipo-yt-v { margin-left: 4px; color: #e6e8ec; }
.ipo-yt.positive .ipo-yt-v { color: #7ee2a8; }
.ipo-yt.negative .ipo-yt-v { color: #ff8a96; }
.ipo-yt-pts { margin: 0 0 6px; padding-left: 16px; color: #d6d8dd; line-height: 1.5; }
.ipo-yt-pts li { overflow-wrap: anywhere; }
.ipo-yt-link, .ipo-yt-more { display: inline-block; margin-right: 10px; color: #8ab4ff; text-decoration: none; font-size: 11px; }
.ipo-yt-more { color: #8b8f9a; }
/* 자산정보 탭 내부 sub-tab — 자산보기/차트보기. 상단 배치는 밑줄 강조, 하단 배치는 일반 버튼 형태. */
.sub-tabs { display: flex; gap: 4px; margin: 8px 14px 0; border-bottom: 1px solid #1f2330; }
.sub-tab { appearance: none; background: transparent; color: #8b8f9a; border: 0; border-bottom: 2px solid transparent; padding: 8px 14px; font-size: 13px; cursor: pointer; font-family: inherit; font-weight: 500; }
@@ -8455,6 +8467,28 @@ def _ipo_flag() -> tuple[list[str], str]:
return (['청약'] if parts else []), ' · '.join(parts)
def _ipo_yt_html(entries: list[dict] | None) -> str:
"""비하이브 '공모청약' 영상 요약 블록(ipo_youtube_digest 가 미리 저장, 렌더는 파일 읽기만).
전용 영상(제목 #종목명) 우선·최신 1건만 본문으로, 나머지는 링크만."""
if not entries:
return ''
esc = html.escape
e = entries[0]
pts = ''.join(f'<li>{esc(p)}</li>' for p in e.get('points') or [])
more = ''.join(
f'<a class="ipo-yt-more" href="{esc(x["url"])}" target="_blank" rel="noopener">▶ {esc(x["title"].replace("[🐝 공모청약]", "").strip()[:28])}</a>'
for x in entries[1:]
)
return (
f'<div class="ipo-yt {esc(e.get("verdict") or "neutral")}">'
f'<div class="ipo-yt-head">🐝 비하이브 의견 <span class="ipo-yt-v">{esc(e.get("verdict_text") or "")}</span>'
f'<span class="ipo-sub"> {esc(_ipo_md((e.get("published_kst") or "")[:10]))}</span></div>'
f'<ul class="ipo-yt-pts">{pts}</ul>'
f'<a class="ipo-yt-link" href="{esc(e["url"])}" target="_blank" rel="noopener">▶ 영상 보기</a>{more}'
'</div>'
)
def _render_ipo_panel() -> str:
"""자산정보 > 공모주 sub-tab. ① 오늘청약(청약기간 중 종목 × 증권사) ② 청약완료(상장일까지).
네이버 IPO API(30분 캐시) + 청약기간 중·완료 종목만 상세 API. 키움 콜 0."""
@@ -8465,6 +8499,12 @@ def _render_ipo_panel() -> str:
subscribed = _load_ipo_subscribed()
if rows is None:
return '<div class="market-card market-card-error">공모주 정보를 불러올 수 없습니다.</div>'
try:
from ipo_youtube_digest import summaries_by_code
yt = summaries_by_code()
except Exception as e:
sys.stderr.write(f'ipo youtube summaries failed: {type(e).__name__}: {e}\n')
yt = {}
by_code = {(r.get('ipoCode') or r.get('itemCode') or '').strip(): r for r in rows}
# ① 오늘청약 — 청약기간 중인 종목 전부(1일차·2일차 모두)
@@ -8487,7 +8527,7 @@ def _render_ipo_panel() -> str:
f'<details class="ipo-stock" data-ipo-key="t:{esc(code)}"><summary class="ipo-stock-name">'
f'<span class="ipo-sum-main">{esc(name)}<span class="ipo-sub"> {esc(r.get("marketType") or "")}'
f' · {esc(_ipo_period(r.get("poStartDate") or "", r.get("poEndDate") or ""))} · {len(blocks)}곳</span></span>'
f'<span class="ipo-sum-tags">{tags}</span></summary>{items}</details>'
f'<span class="ipo-sum-tags">{tags}</span></summary>{_ipo_yt_html(yt.get(code))}{items}</details>'
)
today_html = ''.join(today_parts) or '<div class="ipo-empty">오늘 청약 중인 공모주가 없습니다.</div>'
@@ -8518,7 +8558,7 @@ def _render_ipo_panel() -> str:
f'<dt>청약일</dt><dd>{esc(_ipo_period(r.get("poStartDate") or "", r.get("poEndDate") or ""))}</dd>'
f'<dt>환불일</dt><dd>{esc(_ipo_md(r.get("refundDate") or "") or "미정")}</dd>'
f'<dt>상장일</dt><dd>{esc(_ipo_md(lcal) or "미정")}</dd>'
'</dl></details>'
f'</dl>{_ipo_yt_html(yt.get(code))}</details>'
)
done_html = ''.join(done_parts) or '<div class="ipo-empty">청약완료한 종목이 없습니다.</div>'
@@ -3,6 +3,7 @@ from __future__ import annotations
import argparse
import json
import subprocess
import sys
from datetime import date, datetime
from decimal import Decimal, ROUND_HALF_UP
@@ -140,6 +141,53 @@ def format_alert(kind: str, row: dict, detail: dict) -> str:
return '\n\n'.join(sections)
GAHEE_CRED = Path('/Users/snowoyh/.openclaw/credentials/gahee_imessage.json')
IPO_SUBSCRIBED = Path('/Users/snowoyh/.openclaw/agents/stock/workspace/state/behive_ipo_subscribed.json')
def gahee_listing_message(target: date, rows: list[tuple[dict, dict]] | None) -> tuple[list[str], str]:
"""가희가 청약한 종목 중 target 상장분 → (ipoCode 목록, 문자 본문). 없으면 ([], '').
상장일은 API 상장 목록 ∪ 청약기록 lcal_date(네이버 조회 실패해도 기록으로 보낸다)."""
try:
subscribed = json.loads(IPO_SUBSCRIBED.read_text())
except Exception:
return [], ''
by_code = {(r.get('ipoCode') or r.get('itemCode') or '').strip(): (r, d) for r, d in (rows or [])}
codes, blocks = [], []
for code, rec in subscribed.items():
broker = ((rec.get('by') or {}).get('gahee') or {}).get('broker')
if not broker or (code not in by_code and rec.get('lcal_date') != target.isoformat()):
continue
row, detail = by_code.get(code, ({}, None))
if detail is None:
try:
detail = _detail(code)
except Exception:
detail = {}
price = next((b['price'] for b in _manager_blocks(row, detail) if b.get('price') is not None), None)
lines = [f"[공모주 상장] 오늘 {rec.get('name') or row.get('compName') or code} 상장일이에요",
f'· 청약 증권사: {broker}']
if price is not None:
lines.append(f'· 확정 공모가: {price:,}원')
lines.append(f'· 09:00 거래 시작 — 배정 주식은 {broker} 앱에서 확인하세요')
codes.append(code)
blocks.append('\n'.join(lines))
return codes, '\n\n'.join(blocks)
def send_gahee_imessage(text: str) -> bool:
"""가희 리마인더(gahee_reminder._send_imessage)와 같은 방식 — imessage 강제(sms 는 이 수신자에게 3전 3패).
⚠️ rc=0 은 Messages 에 넘겼다는 뜻일 뿐 전달 확인이 아니다. 코디 셸에선 AppleEvents 거부로 안 되고 launchd 경유로만."""
handle = json.loads(GAHEE_CRED.read_text()).get('handle')
if not handle:
return False
p = subprocess.run(['imsg', 'send', '--to', handle, '--text', text, '--service', 'imessage'],
capture_output=True, text=True, timeout=30)
if p.returncode != 0:
print(f'gahee imsg send rc={p.returncode}: {p.stderr[:200]}', file=sys.stderr)
return p.returncode == 0
def collect_for_date(kind: str, target: date) -> list[tuple[dict, dict]]:
payload = json.loads(fetch(NAVER_IPO_API_URL, encoding='utf-8'))
matches = []
@@ -178,6 +226,30 @@ def main() -> int:
print(f'IPO interests added: {", ".join(added)}')
except Exception as exc:
print(f'IPO interests add failed: {type(exc).__name__}: {exc}', file=sys.stderr)
if not args.dry_run:
# 비하이브 '공모청약' 영상 요약 → 자산웹 공모주 탭. 새 영상 없으면 채널 페이지 1콜로 끝난다.
try:
from ipo_youtube_digest import run as run_ipo_youtube
print(f'IPO youtube digest: {json.dumps(run_ipo_youtube(), ensure_ascii=False)}')
except Exception as exc:
print(f'IPO youtube digest failed: {type(exc).__name__}: {exc}', file=sys.stderr)
if args.kind == 'listing':
# 가희가 청약한 종목의 상장일 아침 iMessage(2026-10-07 관리자님 요청). 같은 날 여러 종목이면 한 통.
try:
codes, text = gahee_listing_message(target, rows)
state = load_state()
key = f'gahee_listing|{target.isoformat()}|{",".join(sorted(codes))}'
if not codes:
pass
elif args.dry_run:
print(f'[dry-run] 가희 상장 문자:\n{text}\n')
elif key not in state.get('sent', {}):
if send_gahee_imessage(text):
state.setdefault('sent', {})[key] = datetime.now(KST).isoformat()
save_state(state)
print(f'gahee listing imessage sent: {",".join(codes)}')
except Exception as exc:
print(f'gahee listing imessage failed: {type(exc).__name__}: {exc}', file=sys.stderr)
if rows is None:
return 1
@@ -0,0 +1,199 @@
#!/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())