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>
This commit is contained in:
158
scripts/yahoo-institutional-browserless.py
Normal file
158
scripts/yahoo-institutional-browserless.py
Normal file
@@ -0,0 +1,158 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user