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:
192
scripts/yahoo-quote-browserless.py
Normal file
192
scripts/yahoo-quote-browserless.py
Normal file
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
用 Browserless 抓 Yahoo 股市頁面,輸出精簡報價摘要(省 token 版)
|
||||
|
||||
用法:
|
||||
python3 stocks/yahoo-quote-browserless.py 3481
|
||||
python3 stocks/yahoo-quote-browserless.py 2330
|
||||
|
||||
環境變數(可覆蓋):
|
||||
BROWSERLESS_ENDPOINT=http://192.168.42.124:13000
|
||||
BROWSERLESS_TOKEN=6R0W53R135510
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
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 "3481"
|
||||
|
||||
|
||||
def fetch_html(stock_no: str) -> str:
|
||||
url = f"https://tw.stock.yahoo.com/quote/{stock_no}.TW"
|
||||
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 to_float(num: str) -> float | None:
|
||||
try:
|
||||
if not num or num == "-":
|
||||
return None
|
||||
return float(num.replace(",", ""))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def parse_quote(html: str, stock_no: str) -> dict:
|
||||
# 名稱容錯:有些股票 h1 結構會不一樣
|
||||
name = pick(html, r"<h1[^>]*>([^<(]+)", "-").strip()
|
||||
if not name or name in ("-", "Yahoo股市"):
|
||||
name = pick(html, r'property="og:title" content="([^"(]+)\([0-9]+\.TW\)', "").strip()
|
||||
if not name or name in ("-", "Yahoo股市"):
|
||||
name = pick(html, r"<title>([^<(]+)\([0-9]+\.TW\)", "-").strip()
|
||||
|
||||
# 價格容錯:支援千分位與不同 class 變化
|
||||
num_pat = r"([\d,]+\.?\d*)"
|
||||
price = pick(html, rf"Fz\(32px\)[^>]*>{num_pat}<")
|
||||
|
||||
# 漲跌先抓頁面值,後續再用(現價-昨收)覆寫,避免符號抓錯
|
||||
updown = pick(html, rf"Fz\(20px\)[^>]*>([\d\.\+\-]+)<", "-")
|
||||
if updown == "-":
|
||||
updown = pick(html, rf"Fz\(20px\)[^>]*>[\s\S]*?</span>([\d\.\+\-]+)<", "0.00")
|
||||
|
||||
pct = pick(html, r"\(([+-]?[0-9]+\.[0-9]+%)\)</span>", "0.00%")
|
||||
|
||||
open_price = pick(html, rf">開盤</span><span[^>]*>{num_pat}")
|
||||
high = pick(html, rf">最高</span><span[^>]*>{num_pat}")
|
||||
low = pick(html, rf">最低</span><span[^>]*>{num_pat}")
|
||||
prev = pick(html, rf">昨收</span><span[^>]*>{num_pat}")
|
||||
avg = pick(html, rf">均價</span><span[^>]*>{num_pat}")
|
||||
|
||||
volume_lots = pick(
|
||||
html,
|
||||
r'Mb\(4px\)">([0-9,]+)</span><span class="Fz\(12px\) C\(\$c-icon\)">成交量',
|
||||
"0",
|
||||
)
|
||||
volume_shares = "{:,.0f}".format(float(volume_lots.replace(",", "")) * 1000)
|
||||
|
||||
bids = re.findall(r"Fw\(n\)[^>]*>([\d,]+\.?\d*)</span>", html)
|
||||
best_bid = bids[0] if len(bids) >= 1 else "-"
|
||||
best_ask = bids[5] if len(bids) >= 6 else "-"
|
||||
|
||||
time_raw = pick(html, r"開盤 \| ([0-9/ :]+) 更新")
|
||||
time_fmt = "-" if time_raw == "-" else f"{time_raw.replace('/', '-')}(台北)"
|
||||
market = "未知" if time_raw == "-" else "開盤中"
|
||||
|
||||
# 以現價與昨收重算漲跌,避免頁面箭頭造成正負號誤判
|
||||
p = to_float(price)
|
||||
pv = to_float(prev)
|
||||
if p is not None and pv is not None:
|
||||
diff = p - pv
|
||||
updown = f"{diff:+.2f}"
|
||||
if pv != 0:
|
||||
pct = f"{(diff / pv) * 100:+.2f}%"
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"stock_no": stock_no,
|
||||
"price": price,
|
||||
"updown": updown,
|
||||
"pct": pct,
|
||||
"open": open_price,
|
||||
"high": high,
|
||||
"low": low,
|
||||
"prev": prev,
|
||||
"volume_lots": volume_lots,
|
||||
"volume_shares": volume_shares,
|
||||
"avg": avg,
|
||||
"best_bid": best_bid,
|
||||
"best_ask": best_ask,
|
||||
"market": market,
|
||||
"time": time_fmt,
|
||||
}
|
||||
|
||||
|
||||
def sign_num(v: str) -> str:
|
||||
if v in ("-", ""):
|
||||
return "-"
|
||||
return v if v.startswith("-") or v.startswith("+") else f"+{v}"
|
||||
|
||||
|
||||
def print_summary(q: dict) -> None:
|
||||
print(f"{q['name']}({q['stock_no']})")
|
||||
print(f"• 現價:{q['price']}")
|
||||
print(f"• 漲跌:{sign_num(q['updown'])}({sign_num(q['pct'])})")
|
||||
print(f"• 開盤/最高/最低:{q['open']}/{q['high']}/{q['low']}")
|
||||
print(f"• 昨收:{q['prev']}")
|
||||
print(f"• 成交量:{q['volume_shares']}(Yahoo 顯示 {q['volume_lots']} 張,換算股數)")
|
||||
print(f"• 均價:{q['avg']}")
|
||||
print(f"• 最佳買賣:{q['best_bid']}/{q['best_ask']}")
|
||||
print(f"• 市場狀態:{q['market']}")
|
||||
print(f"• 資料時間:{q['time']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
html = fetch_html(STOCK_NO)
|
||||
quote = parse_quote(html, STOCK_NO)
|
||||
print_summary(quote)
|
||||
except Exception as e:
|
||||
print(f"取得失敗:{e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user