#!/usr/bin/env python3 """ 用 Browserless 抓 Yahoo 三大法人頁,輸出精簡摘要(代號可變) 用法: python3 stocks/yahoo-institutional-browserless.py 3481 python3 stocks/yahoo-institutional-browserless.py 2330 """ import json import os from pathlib import Path import re import sys import subprocess def load_dotenv() -> None: """載入 .env(優先目前目錄,其次 workspace 根目錄)。""" candidates = [ Path.cwd() / ".env", Path(__file__).resolve().parents[3] / ".env", ] for env_path in candidates: if not env_path.exists(): continue for raw in env_path.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) k = k.strip() v = v.strip().strip('"').strip("'") if k and k not in os.environ: os.environ[k] = v break load_dotenv() ENDPOINT = os.getenv("BROWSERLESS_ENDPOINT") TOKEN = os.getenv("BROWSERLESS_TOKEN") if not ENDPOINT or not TOKEN: raise RuntimeError("缺少必要環境變數:BROWSERLESS_ENDPOINT / BROWSERLESS_TOKEN") STOCK_NO = sys.argv[1] if len(sys.argv) > 1 else "3481" def fetch_html(stock_no: str) -> str: url = f"https://tw.stock.yahoo.com/quote/{stock_no}.TW/institutional-trading" api = f"{ENDPOINT}/content?token={TOKEN}" payload = json.dumps( { "url": url, "gotoOptions": {"waitUntil": "domcontentloaded", "timeout": 60000}, }, ensure_ascii=False, ) cmd = [ "curl", "-sS", "--max-time", "90", "-X", "POST", api, "-H", "Content-Type: application/json", "-d", payload, ] try: text = subprocess.check_output(cmd, text=True, timeout=95) except subprocess.CalledProcessError as e: raise RuntimeError(f"Browserless 呼叫失敗: {e}") from e except subprocess.TimeoutExpired as e: raise RuntimeError("Browserless 呼叫逾時") from e if "TimeoutError" in text: raise RuntimeError("Browserless 導航逾時(TimeoutError)") return text def pick(html: str, pattern: str, default: str = "-") -> str: m = re.search(pattern, html, flags=re.S) return m.group(1) if m else default def parse_inst(html: str, stock_no: str) -> dict: name = pick(html, r'property="og:title" content="([^"(]+)\([0-9]+\.TW\)', "").strip() if not name: name = pick(html, r"([^<(]+)\([0-9]+\.TW\)", "-").strip() # 抓 summary data(避免大範圍 regex 回溯,先縮小搜尋範圍) anchor = html.find('institutionBuySellSummaryData') chunk = html[anchor: anchor + 250000] if anchor != -1 else html rows = [] obj_pat = re.compile(r'\{[^{}]*"formattedDate":"[^"]+"[^{}]*\}') for om in obj_pat.finditer(chunk): s = om.group(0) foreign_m = re.search(r'"foreignDiffVolK":(-?\d+)', s) trust_m = re.search(r'"investmentTrustDiffVolK":(-?\d+)', s) dealer_m = re.search(r'"dealerDiffVolK":(-?\d+)', s) total_m = re.search(r'"totalDiffVolK":(-?\d+)', s) date_m = re.search(r'"formattedDate":"([^"]+)"', s) if foreign_m and trust_m and dealer_m and total_m and date_m: rows.append((foreign_m.group(1), trust_m.group(1), dealer_m.group(1), total_m.group(1), date_m.group(1))) if not rows: raise RuntimeError("抓不到三大法人資料(頁面結構可能變更)") def normalize_date(ds: str) -> str: return ds.replace("\\u002F", "/") def date_key(ds: str): s = normalize_date(ds) try: y, m, d = s.split("/") return int(y), int(m), int(d) except Exception: return (0, 0, 0) foreign, trust, dealer, total, date = max(rows, key=lambda r: date_key(r[4])) date = normalize_date(date) return { "name": name, "stock_no": stock_no, "date": date, "foreign": int(foreign), "trust": int(trust), "dealer": int(dealer), "total": int(total), } def sign(v: int) -> str: return f"+{v:,}" if v >= 0 else f"{v:,}" def print_summary(d: dict) -> None: print(f"{d['name']}({d['stock_no']})|法人買賣超摘要") print(f"• 日期:{d['date']}") print(f"• 外資:{sign(d['foreign'])} 張") print(f"• 投信:{sign(d['trust'])} 張") print(f"• 自營商:{sign(d['dealer'])} 張") print(f"• 三大法人合計:{sign(d['total'])} 張") if __name__ == "__main__": try: html = fetch_html(STOCK_NO) data = parse_inst(html, STOCK_NO) print_summary(data) except Exception as e: print(f"取得失敗:{e}", file=sys.stderr) sys.exit(1)