Files
stocks-query/scripts/yahoo-etf-holdings.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

165 lines
5.4 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
"""抓 Yahoo 台股 ETF 持股分析頁的前十大持股 / 行業比重 / 資產分佈。"""
from __future__ import annotations
import argparse
import csv
import json
import re
import sys
import urllib.request
from html import unescape
USER_AGENT = "Mozilla/5.0 (OpenClaw; yahoo-etf-holdings)"
BASE_URL = "https://tw.stock.yahoo.com/quote/{symbol}.TW/holding"
def fetch(url: str) -> str:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=30) as response:
return response.read().decode("utf-8", "ignore")
def strip_tags(text: str) -> str:
return unescape(re.sub(r"<[^>]+>", "", text)).strip()
def extract_date(block: str) -> str:
m = re.search(r'<time[^>]*datatime="([0-9]{4}/[0-9]{2}/[0-9]{2})"', block)
return m.group(1) if m else ""
def extract_block(html: str, start_marker: str, end_marker: str | None = None) -> str:
start = html.find(start_marker)
if start == -1:
return ""
sub = html[start:]
if end_marker:
end = sub.find(end_marker)
if end != -1:
sub = sub[:end]
return sub
def parse_rank_list(block: str, value_class: str, limit: int | None = None) -> list[dict[str, str]]:
pattern = re.compile(
rf'">(\d+)\.</div>([^<]+)</div><div class="{re.escape(value_class)}">([0-9]+\.[0-9]+%)</div>',
re.S,
)
items = []
for rank, name, pct in pattern.findall(block):
items.append({"排名": rank, "名稱": strip_tags(name), "占比": pct})
if limit and len(items) >= limit:
break
return items
def parse_industries(block: str) -> list[dict[str, str]]:
pattern = re.compile(
r'<div class="D\(f\) Ai\(c\)">(?:<div[^>]*></div>)?([^<]+)</div><div class="Fx\(n\) Fw\(b\) Pstart\(12px\)">([0-9]+\.[0-9]+%)</div>',
re.S,
)
items = []
for idx, (name, pct) in enumerate(pattern.findall(block), start=1):
items.append({"排名": str(idx), "名稱": strip_tags(name), "占比": pct})
return items
def parse_holding_page(symbol: str) -> dict:
url = BASE_URL.format(symbol=symbol)
html = fetch(url)
asset_block = extract_block(html, '資產分佈</h2>', '前十大持股</h2>')
top_block = extract_block(html, '前十大持股</h2>', '網友也在看')
industry_block = extract_block(html, '行業比重</h2>', '持股明細</h2>')
assets = parse_rank_list(asset_block, 'Fx(n) Pstart(16px)')
top10 = parse_rank_list(top_block, 'Fx(n) Pstart(16px)', limit=10)
industries = parse_industries(industry_block)
return {
"symbol": symbol,
"url": url,
"asset_date": extract_date(asset_block),
"assets": assets,
"top10_date": extract_date(top_block),
"top10": top10,
"industry_date": extract_date(industry_block),
"industries": industries,
}
def print_section(title: str, date: str, items: list[dict[str, str]]) -> None:
print(f"{title}|資料時間:{date or '無資料'}")
for item in items:
print(f"{item['排名']}. {item['名稱']}{item['占比']}")
print()
def write_json(data: dict, out: str | None) -> None:
text = json.dumps(data, ensure_ascii=False, indent=2)
if out:
with open(out, 'w', encoding='utf-8') as f:
f.write(text)
else:
print(text)
def write_csv(data: dict, out: str | None, section: str) -> None:
mapping = {
'top': ('top10', 'top10_date'),
'industry': ('industries', 'industry_date'),
'asset': ('assets', 'asset_date'),
}
rows = []
if section == 'all':
for sec in ['top', 'industry', 'asset']:
key, date_key = mapping[sec]
rows.extend({"section": sec, "data_date": data[date_key], **item} for item in data[key])
else:
key, date_key = mapping[section]
rows.extend({"section": section, "data_date": data[date_key], **item} for item in data[key])
fieldnames = ['section', 'data_date', '排名', '名稱', '占比']
if out:
f = open(out, 'w', encoding='utf-8-sig', newline='')
else:
f = sys.stdout
try:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
finally:
if out:
f.close()
def main() -> None:
parser = argparse.ArgumentParser(description='抓 Yahoo ETF 持股分析頁資料')
parser.add_argument('symbol', help='ETF 代號,例如 00922、0050')
parser.add_argument('--format', choices=['text', 'json', 'csv'], default='text')
parser.add_argument('--section', choices=['all', 'top', 'industry', 'asset'], default='all')
parser.add_argument('--out', help='輸出檔案路徑')
args = parser.parse_args()
data = parse_holding_page(args.symbol)
if args.format == 'json':
write_json(data, args.out)
return
if args.format == 'csv':
write_csv(data, args.out, args.section)
return
if args.section in ('all', 'top'):
print_section(f"{args.symbol} 前十大持股", data['top10_date'], data['top10'])
if args.section in ('all', 'industry'):
print_section(f"{args.symbol} 行業比重", data['industry_date'], data['industries'])
if args.section in ('all', 'asset'):
print_section(f"{args.symbol} 資產分佈", data['asset_date'], data['assets'])
if __name__ == '__main__':
main()