Files
stocks-query/scripts/yahoo-institutional-browserless.py
Timmy 7d72aed984 restore and rebuild all stock query scripts
Recover 7 existing scripts from remote repo and rebuild 5 missing ones:
- twse-margin-trading.py (融資融券)
- twse-day-trading.py (當沖交易)
- twse-foreign-holdings.py (外資持股)
- yahoo-dividend-browserless.py (股利資訊)
- yahoo-broker-trading-browserless.py (主力券商進出)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:07:31 +08:00

159 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"<title>([^<(]+)\([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)