Files
stocks-query/scripts/yahoo-broker-trading-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

216 lines
7.3 KiB
Python
Raw Permalink 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 skills/stocks-query/scripts/yahoo-broker-trading-browserless.py 2330
python3 skills/stocks-query/scripts/yahoo-broker-trading-browserless.py 3481
環境變數(可覆蓋):
BROWSERLESS_ENDPOINT=http://192.168.42.124:13000
BROWSERLESS_TOKEN=6R0W53R135510
"""
import json
import os
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
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 "2330"
def fetch_html(stock_no: str) -> str:
url = f"https://tw.stock.yahoo.com/quote/{stock_no}.TW/broker-trading"
api = f"{ENDPOINT}/content?token={TOKEN}"
payload = {
"url": url,
"gotoOptions": {"waitUntil": "domcontentloaded", "timeout": 60000},
}
req = urllib.request.Request(
api,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=90) as resp:
text = resp.read().decode("utf-8", errors="replace")
status = resp.getcode()
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace") if hasattr(e, "read") else str(e)
raise RuntimeError(f"Browserless HTTP {e.code}: {body[:200]}") from e
if status != 200:
raise RuntimeError(f"Browserless HTTP {status}: {text[:200]}")
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_brokers(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()
# 嘗試從嵌入 JSON 中抓券商進出資料
buy_brokers = []
sell_brokers = []
# 方法1從 brokerBuySellData / topBrokers JSON 結構抓取
json_match = re.search(r'"brokerBuySellData"\s*:\s*(\{[\s\S]*?\})\s*[,}]', html)
if json_match:
try:
raw = json.loads(json_match.group(1))
for item in raw.get("buy", raw.get("buyBrokers", []))[:10]:
buy_brokers.append({
"name": item.get("brokerName", item.get("name", "-")),
"buy": str(item.get("buy", item.get("buyVolume", 0))),
"sell": str(item.get("sell", item.get("sellVolume", 0))),
"diff": str(item.get("diff", item.get("net", 0))),
})
for item in raw.get("sell", raw.get("sellBrokers", []))[:10]:
sell_brokers.append({
"name": item.get("brokerName", item.get("name", "-")),
"buy": str(item.get("buy", item.get("buyVolume", 0))),
"sell": str(item.get("sell", item.get("sellVolume", 0))),
"diff": str(item.get("diff", item.get("net", 0))),
})
except (json.JSONDecodeError, KeyError):
pass
# 方法2從 HTML table 結構抓取
if not buy_brokers and not sell_brokers:
# 買超券商區塊
buy_section = ""
sell_section = ""
buy_start = html.find("買超券商")
sell_start = html.find("賣超券商")
if buy_start != -1 and sell_start != -1:
buy_section = html[buy_start:sell_start]
sell_section = html[sell_start:sell_start + 50000]
elif buy_start != -1:
buy_section = html[buy_start:buy_start + 50000]
broker_row_pat = re.compile(
r'<li[^>]*>.*?'
r'(?:券商|broker)[^<]*</[^>]+>.*?'
r'([^<]{2,20})</(?:span|div|td)>'
r'.*?([\d,]+).*?([\d,]+).*?([+-]?[\d,]+)',
re.S,
)
# 更通用的抓法:連續出現的券商名+數字 pattern
simple_pat = re.compile(
r'>([^\d<>]{2,12}(?:證券|期貨|投顧|銀行|金控)?[^<>]{0,6})</(?:span|div|td|a)>'
r'[^<]*<[^>]+>([\d,]+)</[^>]+>'
r'[^<]*<[^>]+>([\d,]+)</[^>]+>'
r'[^<]*<[^>]+>([+-]?[\d,]+)</[^>]+>',
re.S,
)
for section, target_list in [(buy_section, buy_brokers), (sell_section, sell_brokers)]:
for m in simple_pat.finditer(section):
broker_name = m.group(1).strip()
if len(broker_name) < 2 or broker_name.isdigit():
continue
target_list.append({
"name": broker_name,
"buy": m.group(2).replace(",", ""),
"sell": m.group(3).replace(",", ""),
"diff": m.group(4).replace(",", ""),
})
if len(target_list) >= 10:
break
# 日期
date = pick(html, r"資料日期[:]\s*([0-9/\-]+)")
if date == "-":
date = pick(html, r'"formattedDate"\s*:\s*"([^"]+)"')
return {
"name": name,
"stock_no": stock_no,
"date": date,
"buy_brokers": buy_brokers[:10],
"sell_brokers": sell_brokers[:10],
}
def sign(v: str) -> str:
if not v or v == "-":
return "-"
try:
n = int(v.replace(",", ""))
return f"+{n:,}" if n >= 0 else f"{n:,}"
except ValueError:
return v
def print_summary(d: dict) -> None:
print(f"{d['name']}{d['stock_no']})|主力券商進出")
if d["date"] != "-":
print(f"• 日期:{d['date']}")
if d["buy_brokers"]:
print("• 買超前幾大券商:")
for b in d["buy_brokers"][:5]:
print(f" - {b['name']}:買 {b['buy']}/賣 {b['sell']}/差 {sign(b['diff'])}")
else:
print("• 買超券商:無資料")
if d["sell_brokers"]:
print("• 賣超前幾大券商:")
for b in d["sell_brokers"][:5]:
print(f" - {b['name']}:買 {b['buy']}/賣 {b['sell']}/差 {sign(b['diff'])}")
else:
print("• 賣超券商:無資料")
if __name__ == "__main__":
try:
html = fetch_html(STOCK_NO)
data = parse_brokers(html, STOCK_NO)
print_summary(data)
except Exception as e:
print(f"取得失敗:{e}", file=sys.stderr)
sys.exit(1)