373 lines
15 KiB
Python
373 lines
15 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""
|
|||
|
|
企业年金投资管理人 · 官网季度管理报告 全量爬取驱动
|
|||
|
|
=================================================
|
|||
|
|
读取 references/registry.json,按每家机构的 strategy 抓取官网披露栏目下
|
|||
|
|
**全部历史季度**的《企业年金基金投资管理情况》报告,落地到 --out 目录,
|
|||
|
|
并做真伪校验(魔数/大小)与清单(manifest)记录。
|
|||
|
|
|
|||
|
|
纯 HTTP 可搞定的策略:pdf_index / detail_index / html_index / spa_api(部分)
|
|||
|
|
需浏览器的策略:headless(本脚本跳过并提示改用 crawl_headless.py)
|
|||
|
|
不公开:login_blocked(跳过并说明)
|
|||
|
|
|
|||
|
|
用法:
|
|||
|
|
python3 crawl.py --list # 只列出名录与每家策略/入口
|
|||
|
|
python3 crawl.py # 全量抓取所有可 HTTP 抓取的机构
|
|||
|
|
python3 crawl.py --only m04,m09,m17 # 只抓指定机构(逗号分隔 slug 或简称)
|
|||
|
|
python3 crawl.py --latest 3 # 每家只保留最新 N 期(默认0=全量)
|
|||
|
|
python3 crawl.py --out /path/to/out # 指定输出目录(默认 ./annuity_reports)
|
|||
|
|
python3 crawl.py --max-pages 40 # 列表分页最多翻多少页(默认30)
|
|||
|
|
python3 crawl.py --include-waf # 也尝试对 waf 机构做HTTP直取(多半失败,仅留痕)
|
|||
|
|
|
|||
|
|
依赖: requests (pip install requests --break-system-packages)
|
|||
|
|
"""
|
|||
|
|
import argparse, json, os, re, sys, time, hashlib
|
|||
|
|
from urllib.parse import urljoin, urlparse, unquote
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
import requests
|
|||
|
|
except ImportError:
|
|||
|
|
sys.exit("需要 requests: pip install requests --break-system-packages")
|
|||
|
|
|
|||
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|||
|
|
REGISTRY = os.path.join(os.path.dirname(HERE), "references", "registry.json")
|
|||
|
|
|
|||
|
|
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
|||
|
|
|
|||
|
|
# 期次识别: 2026Q1 / 2026年1季度 / 2026年第一季度 / 2026-1季度
|
|||
|
|
CN_NUM = {"一": "1", "二": "2", "三": "3", "四": "4"}
|
|||
|
|
PERIOD_RES = [
|
|||
|
|
re.compile(r"(20\d{2})\s*[Qq]\s*([1-4])"),
|
|||
|
|
re.compile(r"(20\d{2})\D{0,4}?第?\s*([1-4一二三四])\s*季度"),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# 报告标题判别词(区分季报条目 vs 栏目里的其它通知)
|
|||
|
|
TITLE_KW = re.compile(r"管理情况|投资管理|年金.{0,6}报告")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_period(*texts):
|
|||
|
|
for t in texts:
|
|||
|
|
if not t:
|
|||
|
|
continue
|
|||
|
|
t = unquote(t)
|
|||
|
|
for rx in PERIOD_RES:
|
|||
|
|
m = rx.search(t)
|
|||
|
|
if m:
|
|||
|
|
q = CN_NUM.get(m.group(2), m.group(2))
|
|||
|
|
return f"{m.group(1)}Q{q}"
|
|||
|
|
# 年度报告
|
|||
|
|
m = re.search(r"(20\d{2})\s*年度", t)
|
|||
|
|
if m:
|
|||
|
|
return f"{m.group(1)}A"
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def fetch(url, referer=None, binary=False, timeout=60, session=None):
|
|||
|
|
s = session or requests
|
|||
|
|
h = {"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"}
|
|||
|
|
if referer:
|
|||
|
|
h["Referer"] = referer
|
|||
|
|
r = s.get(url, headers=h, timeout=timeout, allow_redirects=True, verify=False)
|
|||
|
|
r.raise_for_status()
|
|||
|
|
if binary:
|
|||
|
|
return r.content, r.headers.get("Content-Type", "")
|
|||
|
|
# 文本:尝试正确解码(GBK 站点很多)
|
|||
|
|
raw = r.content
|
|||
|
|
ct = r.headers.get("Content-Type", "").lower()
|
|||
|
|
enc = None
|
|||
|
|
m = re.search(r"charset=([\w-]+)", ct)
|
|||
|
|
if m:
|
|||
|
|
enc = m.group(1)
|
|||
|
|
if not enc:
|
|||
|
|
head = raw[:2000].decode("ascii", "ignore").lower()
|
|||
|
|
m = re.search(r'charset=["\']?([\w-]+)', head)
|
|||
|
|
if m:
|
|||
|
|
enc = m.group(1)
|
|||
|
|
for e in ([enc] if enc else []) + ["utf-8", "gb18030"]:
|
|||
|
|
try:
|
|||
|
|
return raw.decode(e), r.headers.get("Content-Type", "")
|
|||
|
|
except Exception:
|
|||
|
|
continue
|
|||
|
|
return raw.decode("utf-8", "ignore"), r.headers.get("Content-Type", "")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def abs_links(html, base):
|
|||
|
|
"""返回 [(href_abs, anchor_text)]"""
|
|||
|
|
out = []
|
|||
|
|
for m in re.finditer(r'<a\b[^>]*?href=["\']([^"\']+)["\'][^>]*>(.*?)</a>', html, re.I | re.S):
|
|||
|
|
href = m.group(1).strip()
|
|||
|
|
if href.startswith(("javascript:", "#", "mailto:")):
|
|||
|
|
continue
|
|||
|
|
text = re.sub(r"<[^>]+>", "", m.group(2)).strip()
|
|||
|
|
out.append((urljoin(base, href), text))
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def file_from_href(href, base, rx):
|
|||
|
|
"""判断一个链接是否指向报告文件,返回可下载的绝对 URL 或 None。
|
|||
|
|
处理 pdfjs 包装(viewer.html?file=xxx.pdf)、相对路径、以及 .pdf/.docx 后缀兜底。"""
|
|||
|
|
cands = [href]
|
|||
|
|
m = re.search(r"[?&]file=([^&]+)", href)
|
|||
|
|
if m:
|
|||
|
|
cands.append(unquote(m.group(1)))
|
|||
|
|
for c in cands:
|
|||
|
|
cabs = urljoin(base, c)
|
|||
|
|
if rx and (rx.search(c) or rx.search(cabs)):
|
|||
|
|
return cabs
|
|||
|
|
if c.split("?")[0].lower().endswith((".pdf", ".docx", ".doc", ".xlsx")):
|
|||
|
|
return cabs
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def files_in_page(html, base, rx):
|
|||
|
|
"""从一个页面里挖出所有报告文件直链(详情页二跳用)。"""
|
|||
|
|
hits = set()
|
|||
|
|
for href, _ in abs_links(html, base):
|
|||
|
|
f = file_from_href(href, base, rx)
|
|||
|
|
if f:
|
|||
|
|
hits.add(f)
|
|||
|
|
if rx:
|
|||
|
|
for m in rx.finditer(html):
|
|||
|
|
hits.add(urljoin(base, m.group(0)))
|
|||
|
|
# 裸露的 .pdf/.docx 直链(在 JS/iframe src 里)——必须是真路径(以 / 或 http 开头),
|
|||
|
|
# 避免把详情页里的“显示用中文文件名”误当成同目录相对链接
|
|||
|
|
for m in re.finditer(r'["\']((?:https?:)?/[^"\'\s]+\.(?:pdf|docx))["\']', html, re.I):
|
|||
|
|
hits.add(urljoin(base, m.group(1)))
|
|||
|
|
return list(hits)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def list_pages(mgr, max_pages):
|
|||
|
|
"""生成要翻的列表页 URL(去重、保序)。
|
|||
|
|
很多站点第 1 页是 index.html(无编号),第 2 页起才是 index_1/index2…,
|
|||
|
|
因此始终把 disclosure_url 作为首页,再拼编号页。"""
|
|||
|
|
lu = mgr.get("list_url")
|
|||
|
|
disc = mgr.get("disclosure_url")
|
|||
|
|
if not lu:
|
|||
|
|
return [disc] if disc else []
|
|||
|
|
if "{n}" not in lu:
|
|||
|
|
return [lu]
|
|||
|
|
known = mgr.get("list_pages_known")
|
|||
|
|
top = known if known else max_pages
|
|||
|
|
seq = [disc] + [lu.replace("{n}", str(i)) for i in range(1, top + 1)]
|
|||
|
|
seen, out = set(), []
|
|||
|
|
for u in seq:
|
|||
|
|
if u and u not in seen:
|
|||
|
|
seen.add(u); out.append(u)
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def verify(path, item_type):
|
|||
|
|
try:
|
|||
|
|
sz = os.path.getsize(path)
|
|||
|
|
except OSError:
|
|||
|
|
return False, 0, "missing"
|
|||
|
|
if sz < 512:
|
|||
|
|
return False, sz, "too-small"
|
|||
|
|
with open(path, "rb") as f:
|
|||
|
|
head = f.read(8)
|
|||
|
|
if item_type == "pdf":
|
|||
|
|
return (head[:4] == b"%PDF", sz, "ok" if head[:4] == b"%PDF" else "not-pdf")
|
|||
|
|
if item_type == "docx":
|
|||
|
|
return (head[:2] == b"PK", sz, "ok" if head[:2] == b"PK" else "not-docx")
|
|||
|
|
# html
|
|||
|
|
with open(path, "rb") as f:
|
|||
|
|
body = f.read(60000)
|
|||
|
|
txt = body.decode("utf-8", "ignore") + body.decode("gb18030", "ignore")
|
|||
|
|
ok = ("年金" in txt or "<title" in txt.lower()) and b"<html" in body.lower() or sz > 3000
|
|||
|
|
return (bool(ok), sz, "ok" if ok else "suspect")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def safe_name(period, url, item_type, slug):
|
|||
|
|
ext = {"pdf": ".pdf", "docx": ".docx", "html": ".html"}[item_type]
|
|||
|
|
base = period or ("item_" + hashlib.md5(url.encode()).hexdigest()[:8])
|
|||
|
|
return f"{slug}_{base}{ext}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def crawl_manager(mgr, outdir, latest, max_pages, include_waf):
|
|||
|
|
slug = mgr["slug"]
|
|||
|
|
strat = mgr["strategy"]
|
|||
|
|
item_type = mgr["item_type"]
|
|||
|
|
res = {"slug": slug, "name": mgr["name"], "strategy": strat, "access": mgr["access"],
|
|||
|
|
"disclosure_url": mgr["disclosure_url"], "files": [], "skipped": None}
|
|||
|
|
|
|||
|
|
if strat == "login_blocked":
|
|||
|
|
res["skipped"] = "login/非公开:季报仅登录客户门户可见或未在官网公开"
|
|||
|
|
return res
|
|||
|
|
if strat in ("headless",) and not include_waf:
|
|||
|
|
res["skipped"] = "需无头浏览器(WAF/SPA):改用 crawl_headless.py 或加 --include-waf 试探"
|
|||
|
|
return res
|
|||
|
|
|
|||
|
|
d = os.path.join(outdir, slug)
|
|||
|
|
os.makedirs(d, exist_ok=True)
|
|||
|
|
sess = requests.Session()
|
|||
|
|
pat = mgr.get("file_url_pattern")
|
|||
|
|
rx = re.compile(pat) if pat else None
|
|||
|
|
|
|||
|
|
# 1) 逐列表页收集候选(锚点为主,带标题文本→可解析期次并过滤非报告条目)
|
|||
|
|
found = {} # file_url -> period (pdf_index/html_index 的最终文件)
|
|||
|
|
detail_q = {} # detail_url -> period (detail_index 需二跳)
|
|||
|
|
pages = list_pages(mgr, max_pages) or [mgr["disclosure_url"]]
|
|||
|
|
empty_streak = 0
|
|||
|
|
for i, pg in enumerate(pages):
|
|||
|
|
try:
|
|||
|
|
html, _ = fetch(pg, referer=mgr["disclosure_url"], session=sess)
|
|||
|
|
except Exception:
|
|||
|
|
empty_streak += 1
|
|||
|
|
if empty_streak >= 2:
|
|||
|
|
break
|
|||
|
|
continue
|
|||
|
|
if i == 0:
|
|||
|
|
with open(os.path.join(d, "_disclosure_page.html"), "w", encoding="utf-8", errors="ignore") as f:
|
|||
|
|
f.write(html)
|
|||
|
|
new = 0
|
|||
|
|
for href, text in abs_links(html, pg):
|
|||
|
|
p = parse_period(text, href)
|
|||
|
|
titled = bool(text and TITLE_KW.search(text))
|
|||
|
|
if not (p or titled):
|
|||
|
|
continue # 不像报告条目,跳过(滤掉栏目里的其它通知/导航)
|
|||
|
|
direct = file_from_href(href, pg, rx)
|
|||
|
|
if direct: # 锚点本身就是文件(或 pdfjs 包装) → 直接收
|
|||
|
|
if direct not in found:
|
|||
|
|
found[direct] = p; new += 1
|
|||
|
|
elif rx and rx.search(href): # 锚点是 html 报告页(html_index) → 收
|
|||
|
|
if href not in found:
|
|||
|
|
found[href] = p; new += 1
|
|||
|
|
elif strat in ("detail_index", "spa_api"): # 是详情页 → 二跳
|
|||
|
|
if href not in detail_q:
|
|||
|
|
detail_q[href] = (p, text); new += 1
|
|||
|
|
# pdf_index 兜底:直链可能不在 <a> 里(onclick 等),用正则再捞一遍(要求文件名自带期次)
|
|||
|
|
if strat == "pdf_index" and rx:
|
|||
|
|
for m in rx.finditer(html):
|
|||
|
|
u = urljoin(pg, m.group(0))
|
|||
|
|
pu = parse_period(u)
|
|||
|
|
if pu and u not in found:
|
|||
|
|
found[u] = pu; new += 1
|
|||
|
|
empty_streak = 0 if new else empty_streak + 1
|
|||
|
|
if empty_streak >= 3:
|
|||
|
|
break
|
|||
|
|
time.sleep(0.3)
|
|||
|
|
|
|||
|
|
# 2) detail_index:进详情页取文件直链
|
|||
|
|
for href, (p, text) in detail_q.items():
|
|||
|
|
try:
|
|||
|
|
html, _ = fetch(href, referer=mgr["disclosure_url"], session=sess)
|
|||
|
|
except Exception:
|
|||
|
|
continue
|
|||
|
|
for u in files_in_page(html, href, rx):
|
|||
|
|
if u not in found:
|
|||
|
|
found[u] = parse_period(u) or p
|
|||
|
|
time.sleep(0.2)
|
|||
|
|
|
|||
|
|
if not found:
|
|||
|
|
res["skipped"] = "HTTP 抓取 0 命中:列表多为 JS 渲染或被拦,请改用 crawl_headless.py"
|
|||
|
|
return res
|
|||
|
|
|
|||
|
|
# 3) 排序、按 latest 截断(保留最新 N 个不同期次的全部报告)、下载
|
|||
|
|
items = sorted(found.items(), key=lambda kv: (kv[1] or ""), reverse=True)
|
|||
|
|
if latest and latest > 0:
|
|||
|
|
periods_kept, kept = [], []
|
|||
|
|
for u, p in items:
|
|||
|
|
if p is None:
|
|||
|
|
continue # 无法定期次的条目在 latest 模式下略过
|
|||
|
|
if p not in periods_kept:
|
|||
|
|
if len(periods_kept) >= latest:
|
|||
|
|
break
|
|||
|
|
periods_kept.append(p)
|
|||
|
|
kept.append((u, p))
|
|||
|
|
items = kept
|
|||
|
|
|
|||
|
|
seen_md5 = set()
|
|||
|
|
for u, period in items:
|
|||
|
|
try:
|
|||
|
|
content, ct = fetch(u, referer=mgr["disclosure_url"], binary=True, session=sess)
|
|||
|
|
except Exception as e:
|
|||
|
|
res["files"].append({"url": u, "period": period, "status": f"download-fail:{e}", "local": None, "bytes": 0})
|
|||
|
|
continue
|
|||
|
|
h = hashlib.md5(content).hexdigest()
|
|||
|
|
if h in seen_md5:
|
|||
|
|
continue # 同一报告被多个 URL 变体重复收录,按内容去重
|
|||
|
|
seen_md5.add(h)
|
|||
|
|
# 若声明 pdf 却拿到 html 错误页
|
|||
|
|
it = item_type
|
|||
|
|
if item_type == "pdf" and content[:4] != b"%PDF" and content[:2] == b"PK":
|
|||
|
|
it = "docx"
|
|||
|
|
name = safe_name(period, u, it, slug)
|
|||
|
|
path = os.path.join(d, name)
|
|||
|
|
if os.path.exists(path): # 同期两类报告(投资管理/集合计划)等→加短哈希防覆盖
|
|||
|
|
stem, ext = os.path.splitext(name)
|
|||
|
|
path = os.path.join(d, f"{stem}_{hashlib.md5(u.encode()).hexdigest()[:6]}{ext}")
|
|||
|
|
with open(path, "wb") as f:
|
|||
|
|
f.write(content)
|
|||
|
|
ok, sz, why = verify(path, it)
|
|||
|
|
if not ok:
|
|||
|
|
os.rename(path, path + ".suspect")
|
|||
|
|
path = path + ".suspect"
|
|||
|
|
res["files"].append({"url": u, "period": period, "status": why,
|
|||
|
|
"local": os.path.relpath(path), "bytes": sz})
|
|||
|
|
time.sleep(0.2)
|
|||
|
|
return res
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
import urllib3
|
|||
|
|
urllib3.disable_warnings()
|
|||
|
|
ap = argparse.ArgumentParser()
|
|||
|
|
ap.add_argument("--registry", default=REGISTRY)
|
|||
|
|
ap.add_argument("--out", default="annuity_reports")
|
|||
|
|
ap.add_argument("--only", default="")
|
|||
|
|
ap.add_argument("--latest", type=int, default=0, help="每家保留最新N期,0=全量")
|
|||
|
|
ap.add_argument("--max-pages", type=int, default=30)
|
|||
|
|
ap.add_argument("--include-waf", action="store_true")
|
|||
|
|
ap.add_argument("--list", action="store_true")
|
|||
|
|
args = ap.parse_args()
|
|||
|
|
|
|||
|
|
reg = json.load(open(args.registry, encoding="utf-8"))
|
|||
|
|
managers = reg["managers"]
|
|||
|
|
|
|||
|
|
if args.list:
|
|||
|
|
print(f"{'slug':5} {'简称':10} {'access':7} {'strategy':13} 披露入口")
|
|||
|
|
for m in managers:
|
|||
|
|
print(f"{m['slug']:5} {m['short']:10} {m['access']:7} {m['strategy']:13} {m['disclosure_url']}")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
only = [x.strip() for x in args.only.split(",") if x.strip()]
|
|||
|
|
if only:
|
|||
|
|
managers = [m for m in managers if m["slug"] in only or m["short"] in only]
|
|||
|
|
|
|||
|
|
os.makedirs(args.out, exist_ok=True)
|
|||
|
|
all_res = []
|
|||
|
|
for m in managers:
|
|||
|
|
print(f"\n=== {m['slug']} {m['short']} [{m['strategy']}/{m['access']}] ===", flush=True)
|
|||
|
|
r = crawl_manager(m, args.out, args.latest, args.max_pages, args.include_waf)
|
|||
|
|
if r["skipped"]:
|
|||
|
|
print(" ⏭ " + r["skipped"])
|
|||
|
|
else:
|
|||
|
|
okn = sum(1 for f in r["files"] if f["status"] == "ok")
|
|||
|
|
print(f" ✓ {okn}/{len(r['files'])} 个文件有效,落地 {os.path.join(args.out, m['slug'])}/")
|
|||
|
|
for f in r["files"][:6]:
|
|||
|
|
print(f" [{f['period']}] {f['status']} {f['bytes']}B {f['local']}")
|
|||
|
|
all_res.append(r)
|
|||
|
|
|
|||
|
|
# manifest
|
|||
|
|
mani = os.path.join(args.out, "manifest.json")
|
|||
|
|
json.dump({"generated": time.strftime("%Y-%m-%d %H:%M"), "results": all_res},
|
|||
|
|
open(mani, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
|||
|
|
import csv
|
|||
|
|
with open(os.path.join(args.out, "manifest.csv"), "w", newline="", encoding="utf-8-sig") as fp:
|
|||
|
|
w = csv.writer(fp)
|
|||
|
|
w.writerow(["slug", "机构", "策略", "access", "期次", "状态", "字节", "本地路径", "源URL"])
|
|||
|
|
for r in all_res:
|
|||
|
|
if r["skipped"]:
|
|||
|
|
w.writerow([r["slug"], r["name"], r["strategy"], r["access"], "", "SKIP:" + r["skipped"], "", "", ""])
|
|||
|
|
for f in r["files"]:
|
|||
|
|
w.writerow([r["slug"], r["name"], r["strategy"], r["access"], f["period"], f["status"], f["bytes"], f["local"], f["url"]])
|
|||
|
|
tot = sum(len(r["files"]) for r in all_res)
|
|||
|
|
okt = sum(1 for r in all_res for f in r["files"] if f["status"] == "ok")
|
|||
|
|
print(f"\n完成:{okt}/{tot} 个有效文件。清单:{mani} 及 manifest.csv")
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|