473 lines
18 KiB
Python
473 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
综合报告爬虫
|
|
统一接口获取定期报告 + 券商研报 + 所有公告
|
|
支持批量处理和按需下载
|
|
"""
|
|
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Dict, Any, List, Optional, Callable, Union
|
|
from datetime import datetime, timedelta
|
|
from dataclasses import dataclass
|
|
|
|
try:
|
|
import requests
|
|
REQUESTS_AVAILABLE = True
|
|
except ImportError:
|
|
REQUESTS_AVAILABLE = False
|
|
|
|
SKILL_DATA_DIR = Path(__file__).parent.parent / "data"
|
|
DEFAULT_DOWNLOAD_DIR = SKILL_DATA_DIR / "comprehensive_reports"
|
|
|
|
|
|
@dataclass
|
|
class ReportSummary:
|
|
"""综合报告摘要"""
|
|
stock_code: str
|
|
stock_name: str
|
|
periodic_count: int = 0
|
|
broker_count: int = 0
|
|
announcement_count: int = 0
|
|
latest_periodic_date: str = ""
|
|
latest_broker_date: str = ""
|
|
latest_announcement_date: str = ""
|
|
has_buy_rating: bool = False # 是否有买入评级研报
|
|
|
|
|
|
class ComprehensiveDownloader:
|
|
"""统一下载器"""
|
|
|
|
def __init__(self, download_dir: str = None):
|
|
self.download_dir = Path(download_dir) if download_dir else DEFAULT_DOWNLOAD_DIR
|
|
self.download_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# 子目录
|
|
self.periodic_dir = self.download_dir / "periodic"
|
|
self.broker_dir = self.download_dir / "broker"
|
|
self.announcement_dir = self.download_dir / "announcements"
|
|
|
|
for d in [self.periodic_dir, self.broker_dir, self.announcement_dir]:
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
|
|
self.session = None
|
|
if REQUESTS_AVAILABLE:
|
|
self.session = requests.Session()
|
|
self.session.headers.update({
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
})
|
|
|
|
self.downloaded_count = 0
|
|
self.failed_count = 0
|
|
|
|
def download_periodic_report(self, report) -> str:
|
|
"""下载定期报告"""
|
|
return self._download_generic(report, self.periodic_dir)
|
|
|
|
def download_broker_report(self, report) -> str:
|
|
"""下载券商研报"""
|
|
return self._download_generic(report, self.broker_dir)
|
|
|
|
def download_announcement(self, report) -> str:
|
|
"""下载公告"""
|
|
return self._download_generic(report, self.announcement_dir)
|
|
|
|
def _download_generic(self, report, save_dir: Path) -> str:
|
|
"""通用下载方法"""
|
|
if not report.url:
|
|
return ""
|
|
|
|
stock_dir = save_dir / report.stock_code
|
|
stock_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
filename = self._generate_filename(report)
|
|
save_path = stock_dir / filename
|
|
|
|
if save_path.exists():
|
|
self.downloaded_count += 1
|
|
return str(save_path)
|
|
|
|
try:
|
|
if self.session:
|
|
resp = self.session.get(report.url, timeout=60, stream=True)
|
|
if resp.status_code == 200:
|
|
actual_name = self._get_filename_from_cd(resp.headers)
|
|
if actual_name:
|
|
save_path = stock_dir / actual_name
|
|
|
|
with open(save_path, 'wb') as f:
|
|
for chunk in resp.iter_content(chunk_size=8192):
|
|
f.write(chunk)
|
|
|
|
self.downloaded_count += 1
|
|
return str(save_path)
|
|
|
|
except Exception as e:
|
|
print(f"[错误] 下载失败: {report.url}, {e}")
|
|
|
|
self.failed_count += 1
|
|
return ""
|
|
|
|
def _get_filename_from_cd(self, headers) -> Optional[str]:
|
|
"""从Content-Disposition获取文件名"""
|
|
import re
|
|
cd = headers.get('Content-Disposition', '')
|
|
match = re.search(r'filename[^=]*=\s*"?([^";]+)"?', cd)
|
|
if match:
|
|
return match.group(1).strip('" ')
|
|
return None
|
|
|
|
def _generate_filename(self, report) -> str:
|
|
"""生成文件名"""
|
|
import re
|
|
title = getattr(report, 'title', 'unknown') or 'unknown'
|
|
safe_title = re.sub(r'[<>:"/\\|?*]', '', title)[:50]
|
|
date_str = getattr(report, 'publish_date', '').replace("-", "") or ""
|
|
ext = ".pdf"
|
|
|
|
file_type = getattr(report, 'file_type', 'pdf') or 'pdf'
|
|
if file_type == 'html':
|
|
ext = ".html"
|
|
elif file_type in ['doc', 'docx']:
|
|
ext = ".docx"
|
|
|
|
return f"{date_str}_{safe_title}{ext}"
|
|
|
|
|
|
class ComprehensiveReportManager:
|
|
"""
|
|
综合报告管理器
|
|
|
|
整合三种数据源的统一入口:
|
|
- 定期报告 (年报/半年报/季报)
|
|
- 券商研报 (买方评级/行业分析)
|
|
- 所有公告 (临时公告/重大事项)
|
|
"""
|
|
|
|
def __init__(self, download_dir: str = None):
|
|
self.downloader = ComprehensiveDownloader(download_dir)
|
|
|
|
# 延迟导入避免循环
|
|
from .company_report_scraper import EastMoneyReportAPI, CompanyReport
|
|
from .research_report_scraper import EastMoneyBrokerReportAPI, BrokerReport
|
|
from .announcement_scraper import AnnouncementSearcher, Announcement
|
|
|
|
self.periodic_api = EastMoneyReportAPI()
|
|
self.broker_api = EastMoneyBrokerReportAPI()
|
|
self.announcement_api = AnnouncementSearcher()
|
|
|
|
self.session = None
|
|
if REQUESTS_AVAILABLE:
|
|
self.session = requests.Session()
|
|
|
|
def get_all_reports(self, stock_code: str,
|
|
start_date: str = "",
|
|
end_date: str = "",
|
|
report_types: List[str] = None) -> Dict[str, Any]:
|
|
"""
|
|
获取指定股票所有类型的报告/公告
|
|
|
|
Args:
|
|
stock_code: 股票代码
|
|
start_date: 开始日期 YYYY-MM-DD
|
|
end_date: 结束日期 YYYY-MM-DD
|
|
report_types: 筛选类型 ["periodic", "broker", "announcement"]
|
|
None = 所有类型
|
|
|
|
Returns:
|
|
{
|
|
"periodic_reports": [...],
|
|
"broker_reports": [...],
|
|
"announcements": [...],
|
|
"summary": {...}
|
|
}
|
|
"""
|
|
if report_types is None:
|
|
report_types = ["periodic", "broker", "announcement"]
|
|
|
|
result = {
|
|
"stock_code": stock_code,
|
|
"periodic_reports": [],
|
|
"broker_reports": [],
|
|
"announcements": [],
|
|
"summary": ReportSummary(stock_code=stock_code, stock_name="")
|
|
}
|
|
|
|
# 获取股票名称
|
|
stock_name = self._get_stock_name(stock_code)
|
|
result["summary"].stock_name = stock_name
|
|
|
|
# 定期报告
|
|
if "periodic" in report_types:
|
|
periodic = self._fetch_periodic_reports(stock_code, start_date, end_date)
|
|
result["periodic_reports"] = periodic
|
|
result["summary"].periodic_count = len(periodic)
|
|
if periodic:
|
|
dates = [getattr(p, 'publish_date', '') for p in periodic if hasattr(p, 'publish_date')]
|
|
result["summary"].latest_periodic_date = max(dates)[:10] if dates else ""
|
|
|
|
# 券商研报
|
|
if "broker" in report_types:
|
|
broker = self._fetch_broker_reports(stock_code, start_date, end_date)
|
|
result["broker_reports"] = broker
|
|
result["summary"].broker_count = len(broker)
|
|
if broker:
|
|
dates = [getattr(b, 'publish_date', '') for b in broker if hasattr(b, 'publish_date')]
|
|
result["summary"].latest_broker_date = max(dates)[:10] if dates else ""
|
|
# 检查是否有买入评级
|
|
result["summary"].has_buy_rating = any(
|
|
getattr(b, 'rating', '') == "买入" for b in broker
|
|
)
|
|
|
|
# 公告
|
|
if "announcement" in report_types:
|
|
announcements = self._fetch_announcements(stock_code, start_date, end_date)
|
|
result["announcements"] = announcements
|
|
result["summary"].announcement_count = len(announcements)
|
|
if announcements:
|
|
dates = [getattr(a, 'publish_date', '') for a in announcements if hasattr(a, 'publish_date')]
|
|
result["summary"].latest_announcement_date = max(dates)[:10] if dates else ""
|
|
|
|
return result
|
|
|
|
def _get_stock_name(self, stock_code: str) -> str:
|
|
"""获取股票名称"""
|
|
code = stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '')
|
|
try:
|
|
url = "https://80.push2.eastmoney.com/api/qt/clist/get"
|
|
params = {
|
|
"pn": 1, "pz": 1, "np": 1, "fltt": 2, "invt": 2,
|
|
"fid": "f3",
|
|
"fs": f"m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048",
|
|
"fields": "f12,f14"
|
|
}
|
|
resp = self.session.get(url, params=params, timeout=10)
|
|
data = resp.json()
|
|
if data.get('data') and data['data'].get('diff'):
|
|
for item in data['data']['diff']:
|
|
if item.get('f12') == code:
|
|
return item.get('f14', '')
|
|
except Exception:
|
|
pass
|
|
return code
|
|
|
|
def _fetch_periodic_reports(self, stock_code: str,
|
|
start_date: str, end_date: str) -> List:
|
|
"""获取定期报告"""
|
|
from .company_report_scraper import CompanyReport
|
|
try:
|
|
reports = self.periodic_api.search_reports(
|
|
stock_code, "", start_date, end_date, 100
|
|
)
|
|
return reports
|
|
except Exception as e:
|
|
print(f"[错误] 获取定期报告失败: {e}")
|
|
return []
|
|
|
|
def _fetch_broker_reports(self, stock_code: str,
|
|
start_date: str, end_date: str) -> List:
|
|
"""获取券商研报"""
|
|
from .research_report_scraper import BrokerReport
|
|
try:
|
|
reports = self.broker_api.get_stock_reports(
|
|
stock_code, start_date, end_date, 100
|
|
)
|
|
return reports
|
|
except Exception as e:
|
|
print(f"[错误] 获取券商研报失败: {e}")
|
|
return []
|
|
|
|
def _fetch_announcements(self, stock_code: str,
|
|
start_date: str, end_date: str) -> List:
|
|
"""获取公告"""
|
|
from .announcement_scraper import Announcement
|
|
try:
|
|
announcements = self.announcement_api.search_announcements(
|
|
keyword="", fund_code=stock_code, date_from=start_date, date_to=end_date, max_results=100
|
|
)
|
|
return announcements
|
|
except Exception as e:
|
|
print(f"[错误] 获取公告失败: {e}")
|
|
return []
|
|
|
|
def download_all_reports(self, stock_code: str,
|
|
report_types: List[str] = None,
|
|
progress_callback: Callable = None) -> Dict[str, Any]:
|
|
"""下载所有类型报告"""
|
|
data = self.get_all_reports(stock_code, report_types=report_types)
|
|
|
|
results = {"periodic": [], "broker": [], "announcement": []}
|
|
total = data["summary"].periodic_count + data["summary"].broker_count + data["summary"].announcement_count
|
|
|
|
current = 0
|
|
for report in data.get("periodic_reports", []):
|
|
path = self.downloader.download_periodic_report(report)
|
|
results["periodic"].append({"title": getattr(report, 'title', ''), "path": path})
|
|
current += 1
|
|
if progress_callback:
|
|
progress_callback(current, total)
|
|
|
|
for report in data.get("broker_reports", []):
|
|
path = self.downloader.download_broker_report(report)
|
|
results["broker"].append({"title": getattr(report, 'title', ''), "path": path})
|
|
current += 1
|
|
if progress_callback:
|
|
progress_callback(current, total)
|
|
|
|
for ann in data.get("announcements", []):
|
|
path = self.downloader.download_announcement(ann)
|
|
results["announcement"].append({"title": getattr(ann, 'title', ''), "path": path})
|
|
current += 1
|
|
if progress_callback:
|
|
progress_callback(current, total)
|
|
|
|
return {
|
|
"stock_code": stock_code,
|
|
"results": results,
|
|
"total_downloaded": sum(len(v) for v in results.values())
|
|
}
|
|
|
|
def batch_process_stocks(self, stock_codes: List[str],
|
|
report_types: List[str] = None,
|
|
progress_callback: Callable = None) -> Dict[str, Any]:
|
|
"""批量处理多只股票"""
|
|
results = []
|
|
total = len(stock_codes)
|
|
|
|
for i, code in enumerate(stock_codes):
|
|
try:
|
|
data = self.get_all_reports(code, report_types=report_types)
|
|
results.append({
|
|
"stock_code": code,
|
|
"stock_name": data["summary"].stock_name,
|
|
"periodic": data["summary"].periodic_count,
|
|
"broker": data["summary"].broker_count,
|
|
"announcement": data["summary"].announcement_count,
|
|
"has_buy_rating": data["summary"].has_buy_rating
|
|
})
|
|
except Exception as e:
|
|
results.append({
|
|
"stock_code": code,
|
|
"error": str(e)
|
|
})
|
|
|
|
if progress_callback:
|
|
progress_callback(i + 1, total)
|
|
|
|
# 避免请求过快
|
|
time.sleep(0.3)
|
|
|
|
return {
|
|
"total_stocks": total,
|
|
"processed": len(results),
|
|
"results": results
|
|
}
|
|
|
|
def generate_report_summary(self, stock_code: str) -> str:
|
|
"""生成综合报告摘要"""
|
|
data = self.get_all_reports(stock_code)
|
|
summary = data["summary"]
|
|
|
|
lines = []
|
|
lines.append(f"\n{'='*70}")
|
|
lines.append(f"【{stock_code} {summary.stock_name} 综合报告】")
|
|
lines.append(f"{'='*70}")
|
|
|
|
lines.append(f"\n📊 报告统计:")
|
|
lines.append(f" 定期报告: {summary.periodic_count}份" +
|
|
(f" (最新: {summary.latest_periodic_date})" if summary.latest_periodic_date else ""))
|
|
lines.append(f" 券商研报: {summary.broker_count}份" +
|
|
(f" (最新: {summary.latest_broker_date})" if summary.latest_broker_date else ""))
|
|
lines.append(f" 公告: {summary.announcement_count}份" +
|
|
(f" (最新: {summary.latest_announcement_date})" if summary.latest_announcement_date else ""))
|
|
|
|
if summary.has_buy_rating:
|
|
lines.append(f"\n✅ 有买入评级研报")
|
|
|
|
# 定期报告列表
|
|
periodic = data.get("periodic_reports", [])
|
|
if periodic:
|
|
lines.append(f"\n📄 定期报告 (最近5份):")
|
|
for p in periodic[:5]:
|
|
date = getattr(p, 'publish_date', '')[:10]
|
|
title = getattr(p, 'title', '')[:45]
|
|
ptype = getattr(p, 'report_type', '')
|
|
lines.append(f" [{date}] {ptype} {title}")
|
|
|
|
# 券商研报列表
|
|
broker = data.get("broker_reports", [])
|
|
if broker:
|
|
lines.append(f"\n📈 券商研报 (最近5份):")
|
|
for b in broker[:5]:
|
|
date = getattr(b, 'publish_date', '')[:10]
|
|
title = getattr(b, 'title', '')[:40]
|
|
rating = getattr(b, 'rating', '?')
|
|
broker_name = getattr(b, 'broker_name', '')
|
|
rating_icon = {"买入": "++", "增持": "+", "中性": "=", "减持": "-"}.get(rating, "?")
|
|
lines.append(f" {rating_icon} {broker_name} | {date} | {title}")
|
|
|
|
# 公告列表
|
|
announcements = data.get("announcements", [])
|
|
if announcements:
|
|
lines.append(f"\n📢 公告 (最近5份):")
|
|
for a in announcements[:5]:
|
|
date = getattr(a, 'publish_date', '')[:10]
|
|
title = getattr(a, 'title', '')[:45]
|
|
lines.append(f" [{date}] {title}")
|
|
|
|
lines.append(f"\n{'='*70}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ============ CLI入口 ============
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
if len(sys.argv) < 2:
|
|
print("用法:")
|
|
print(" python comprehensive_report_scraper.py get <股票代码> # 获取所有报告")
|
|
print(" python comprehensive_report_scraper.py download <代码> # 下载所有报告")
|
|
print(" python comprehensive_report_scraper.py batch <代码1,代码2> # 批量处理")
|
|
sys.exit(1)
|
|
|
|
cmd = sys.argv[1]
|
|
|
|
if cmd == "get":
|
|
if len(sys.argv) < 3:
|
|
print("请提供股票代码")
|
|
sys.exit(1)
|
|
code = sys.argv[2]
|
|
manager = ComprehensiveReportManager()
|
|
print(manager.generate_report_summary(code))
|
|
|
|
elif cmd == "download":
|
|
if len(sys.argv) < 3:
|
|
print("请提供股票代码")
|
|
sys.exit(1)
|
|
code = sys.argv[2]
|
|
manager = ComprehensiveReportManager()
|
|
result = manager.download_all_reports(code)
|
|
print(f"\n下载完成:")
|
|
print(f" 定期报告: {len(result['results']['periodic'])}份")
|
|
print(f" 券商研报: {len(result['results']['broker'])}份")
|
|
print(f" 公告: {len(result['results']['announcement'])}份")
|
|
|
|
elif cmd == "batch":
|
|
if len(sys.argv) < 3:
|
|
print("请提供股票代码(逗号分隔)")
|
|
sys.exit(1)
|
|
codes = sys.argv[2].split(',')
|
|
manager = ComprehensiveReportManager()
|
|
result = manager.batch_process_stocks(codes)
|
|
print(f"\n批量处理完成: {result['processed']}/{result['total_stocks']}只")
|
|
for r in result['results']:
|
|
if 'error' in r:
|
|
print(f" {r['stock_code']}: 错误 - {r['error']}")
|
|
else:
|
|
has_buy = "✅" if r.get('has_buy_rating') else ""
|
|
print(f" {r['stock_code']} {r.get('stock_name', '')}: 定期{r.get('periodic',0)} 研报{r.get('broker',0)} 公告{r.get('announcement',0)} {has_buy}")
|
|
|
|
else:
|
|
print(f"未知命令: {cmd}") |