# -*- coding: utf-8 -*- """ 批量机构/公司爬虫 Batch Institution/Company Crawler 功能: 1. 批量爬取多个机构或公司信息 2. 按类别爬取整个类别的机构 3. 爬取结果保存与分析 4. 支持机构名称、类型、列表导入 支持爬取模式: - 单个机构爬取 - 多机构批量爬取 - 按类型爬取(所有基金管理公司、所有证券公司等) - 从文件导入机构列表批量爬取 """ import json import time import re import ssl import urllib.request import urllib.parse from pathlib import Path from typing import List, Dict, Optional, Callable from datetime import datetime from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field # 尝试导入crawl_utils try: from crawl_utils import safe_request, detect_encoding HAS_CRAWL_UTILS = True except ImportError: HAS_CRAWL_UTILS = False # 路径配置 SCRIPT_DIR = Path(__file__).resolve().parent DATA_DIR = SCRIPT_DIR.parent / "data" DATA_DIR.mkdir(exist_ok=True, parents=True) @dataclass class CrawlResult: """爬取结果""" name: str code: str url: str status: str # success/failed/not_found content: str = "" error: str = "" crawl_time: str = "" def to_dict(self) -> dict: return { "name": self.name, "code": self.code, "url": self.url, "status": self.status, "content": self.content[:500] if self.content else "", "error": self.error, "crawl_time": self.crawl_time } @dataclass class BatchCrawlConfig: """批量爬取配置""" max_workers: int = 5 # 并发数 timeout: int = 30 # 超时时间(秒) retry: int = 2 # 重试次数 delay: float = 1.0 # 请求间隔(秒) save_intermediate: bool = True # 保存中间结果 class BatchInstitutionCrawler: """ 批量机构/公司爬虫 功能: 1. 批量爬取多个机构信息 2. 按类型爬取整类机构 3. 并发加速 4. 断点续爬 5. 结果导出 """ # 机构类型到URL的映射 INSTITUTION_TYPE_URLS = { "基金管理公司": "https://www.amac.org.cn/fund Industry/public list/", "证券公司": "https://www.sac.net.cn/association/member/member_public/", "保险公司": "data/insurance_list.json", "信托公司": "data/trust_company_list.json", "银行": "data/institution_registry.json", "私募基金": "https://www.amac.org.cn/Private Equity/private fund disclosure/public list/", "第三方销售": "https://www.eastmoney.com", } def __init__(self, data_dir=None, config: BatchCrawlConfig = None): self.data_dir = data_dir or DATA_DIR self.config = config or BatchCrawlConfig() self.ctx = ssl.create_default_context() self.ctx.check_hostname = False self.ctx.verify_mode = ssl.CERT_NONE # 结果存储 self.results: List[CrawlResult] = [] self.failed: List[CrawlResult] = [] # 加载已保存的结果(用于断点续爬) self._load_existing_results() def _load_existing_results(self): """加载已有结果""" results_file = self.data_dir / "batch_crawl_results.json" if results_file.exists(): try: data = json.loads(results_file.read_text(encoding="utf-8")) for r in data.get("results", []): self.results.append(CrawlResult(**r)) except Exception as e: print(f"加载已有结果失败: {e}") def _save_results(self): """保存结果""" results_file = self.data_dir / "batch_crawl_results.json" output = { "update_time": datetime.now().isoformat(), "total": len(self.results), "success": sum(1 for r in self.results if r.status == "success"), "failed": sum(1 for r in self.results if r.status == "failed"), "results": [r.to_dict() for r in self.results] } results_file.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8") def _crawl_single(self, name: str, code: str = "", url: str = None) -> CrawlResult: """爬取单个机构""" result = CrawlResult( name=name, code=code, url=url or "", status="not_found", crawl_time=datetime.now().isoformat() ) if not url: # 尝试从名称推导URL url = self._guess_url(name) if not url: result.error = "无法确定URL" return result try: if HAS_CRAWL_UTILS: raw = safe_request(url, timeout=self.config.timeout) else: headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://www.google.com" } req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=self.config.timeout, context=self.ctx) as resp: raw = resp.read() if raw: if isinstance(raw, bytes): content = raw.decode("utf-8", errors="replace") else: content = raw result.content = content result.status = "success" result.url = url else: result.status = "failed" result.error = "空响应" except Exception as e: result.status = "failed" result.error = str(e) return result def _guess_url(self, name: str) -> Optional[str]: """从名称推测URL""" # 常见基金公司URL模式 name_lower = name.lower() # 基金公司URL猜测 fund_patterns = { "易方达": "https://www.efunds.com.cn", "华夏": "https://www.chinaamc.com", "广发": "https://www.gffunds.com.cn", "嘉实": "https://www.jsfund.cn", "南方": "https://www.southernfund.com", "博时": "https://www.bosera.com", "招商": "https://www.cmfchina.com", "工银": "https://www.icbccs.com.cn", "建信": "https://www.ccbfund.cn", "富国": "https://www.fullgoal.com.cn", "鹏华": "https://www.phfund.com.cn", "汇添富": "https://www.htffund.com", "中欧": "https://www.zofund.com.cn", "兴证全球": "https://www.xingqiu.com", "华安": "https://www.huaan.com.cn", "银华": "https://www.yhfund.com.cn", "天弘": "https://www.thfund.com.cn", "平安": "https://fund.pingan.com", } for keyword, url in fund_patterns.items(): if keyword in name: return url # 通用搜索URL if "基金" in name: search_url = f"https://www.google.com/search?q={urllib.parse.quote(name)}" return search_url return None def crawl_by_names(self, names: List[Dict], progress_callback: Callable = None) -> List[CrawlResult]: """ 按名称批量爬取(并发执行) Args: names: 机构名称列表 [{"name": "xxx", "code": "xxx", "url": "xxx"}] progress_callback: 进度回调函数 Returns: List[CrawlResult]: 爬取结果列表 """ print(f"[批量爬虫] 开始爬取 {len(names)} 个机构(并发数: {self.config.max_workers})...") completed_count = 0 def crawl_single_item(item): """爬取单个机构""" name = item.get("name", "") code = item.get("code", "") url = item.get("url") result = self._crawl_single(name, code, url) return result # 使用线程池并发爬取 with ThreadPoolExecutor(max_workers=self.config.max_workers) as executor: # 提交所有任务 future_to_item = { executor.submit(crawl_single_item, item): item for item in names } # 收集结果 for future in as_completed(future_to_item): item = future_to_item[future] name = item.get("name", "") try: result = future.result(timeout=self.config.timeout + 10) self.results.append(result) if result.status == "failed": self.failed.append(result) print(f" ❌ {name}: {result.error}") else: print(f" ✅ {name}") except Exception as e: error_result = CrawlResult( name=name, code=item.get("code", ""), url=item.get("url", ""), status="failed", error=f"执行异常: {str(e)}", crawl_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S") ) self.results.append(error_result) self.failed.append(error_result) print(f" ❌ {name}: {str(e)}") completed_count += 1 # 保存中间结果 if self.config.save_intermediate and completed_count % 10 == 0: self._save_results() print(f" 已保存 {completed_count} 条中间结果") # 进度回调 if progress_callback: progress_callback(completed_count, len(names)) self._save_results() success_count = len(self.results) - len(self.failed) print(f"[批量爬虫] 完成! 成功: {success_count}, 失败: {len(self.failed)}") return self.results def crawl_by_type(self, institution_type: str, progress_callback: Callable = None) -> List[CrawlResult]: """ 按类型爬取整类机构 Args: institution_type: 机构类型(如"基金管理公司"、"证券公司") progress_callback: 进度回调 Returns: List[CrawlResult]: 爬取结果 """ # 从full_institution_crawler获取机构列表 from full_institution_crawler import FullInstitutionCrawler full_crawler = FullInstitutionCrawler(data_dir=self.data_dir) # 根据类型获取对应方法 type_method_map = { "基金管理公司": full_crawler.crawl_amac_fund_companies, "证券公司": full_crawler.crawl_sac_securities, "保险公司": full_crawler.crawl_cbirc_insurance, "信托公司": full_crawler.crawl_cbirc_banks, "银行": full_crawler.crawl_cbirc_banks, "私募基金": full_crawler.crawl_amac_private_funds, "第三方销售": full_crawler.crawl_third_party_sales, "外资金融机构": full_crawler.crawl_foreign_institutions, } method = type_method_map.get(institution_type) if not method: print(f"[批量爬虫] 未知类型: {institution_type}") return [] institutions = method() print(f"[批量爬虫] 类型'{institution_type}'共有 {len(institutions)} 个机构") # 转换为名称列表 names = [ {"name": inst.get("name", ""), "code": inst.get("code", ""), "url": inst.get("home")} for inst in institutions ] return self.crawl_by_names(names, progress_callback) def crawl_all_types(self, progress_callback: Callable = None) -> Dict[str, List[CrawlResult]]: """ 爬取所有类型的机构 Returns: Dict[str, List[CrawlResult]]: 按类型分类的结果 """ results_by_type = {} for inst_type in self.INSTITUTION_TYPE_URLS.keys(): print(f"\n{'='*60}") print(f"开始爬取类型: {inst_type}") print('='*60) results = self.crawl_by_type(inst_type, progress_callback) results_by_type[inst_type] = results # 保存每种类型的结果 type_file = self.data_dir / f"batch_{inst_type}_results.json" output = { "type": inst_type, "count": len(results), "success": sum(1 for r in results if r.status == "success"), "update_time": datetime.now().isoformat(), "results": [r.to_dict() for r in results] } type_file.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8") return results_by_type def export_results(self, output_file: str = None, format: str = "json") -> str: """ 导出结果 Args: output_file: 输出文件路径 format: 导出格式(json/txt/csv) Returns: str: 导出文件路径 """ if not output_file: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") output_file = str(self.data_dir / f"batch_crawl_export_{timestamp}.{format}") if format == "json": self._export_json(output_file) elif format == "txt": self._export_txt(output_file) elif format == "csv": self._export_csv(output_file) return output_file def _export_json(self, output_file: str): """导出JSON格式""" output = { "summary": { "total": len(self.results), "success": sum(1 for r in self.results if r.status == "success"), "failed": len(self.failed), "export_time": datetime.now().isoformat() }, "results": [r.to_dict() for r in self.results] } Path(output_file).write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8") def _export_txt(self, output_file: str): """导出TXT格式""" lines = ["=" * 60, "批量爬取结果", "=" * 60, ""] lines.append(f"总数量: {len(self.results)}") lines.append(f"成功: {sum(1 for r in self.results if r.status == 'success')}") lines.append(f"失败: {len(self.failed)}") lines.append("") for r in self.results: lines.append(f"{r.name} ({r.code})") lines.append(f" URL: {r.url}") lines.append(f" 状态: {r.status}") if r.error: lines.append(f" 错误: {r.error}") lines.append("") Path(output_file).write_text("\n".join(lines), encoding="utf-8") def _export_csv(self, output_file: str): """导出CSV格式""" import csv with open(output_file, 'w', newline='', encoding='utf-8-sig') as f: writer = csv.writer(f) writer.writerow(["名称", "代码", "URL", "状态", "错误"]) for r in self.results: writer.writerow([r.name, r.code, r.url, r.status, r.error]) def get_statistics(self) -> Dict: """获取统计信息""" return { "total": len(self.results), "success": sum(1 for r in self.results if r.status == "success"), "failed": len(self.failed), "not_found": sum(1 for r in self.results if r.status == "not_found"), "by_type": self._get_by_type_summary() } def _get_by_type_summary(self) -> Dict: """按类型统计""" summary = {} for r in self.results: key = r.name[:2] if len(r.name) >= 2 else r.name if key not in summary: summary[key] = {"total": 0, "success": 0} summary[key]["total"] += 1 if r.status == "success": summary[key]["success"] += 1 return summary class StockBatchCrawler: """ 股票批量爬虫 用于批量爬取多个上市公司的信息 """ def __init__(self, data_dir=None): self.data_dir = data_dir or DATA_DIR self.results = [] def crawl_by_codes(self, codes: List[str], progress_callback: Callable = None) -> List[Dict]: """ 按股票代码批量爬取 Args: codes: 股票代码列表 progress_callback: 进度回调 Returns: List[Dict]: 爬取结果 """ print(f"[股票批量爬虫] 开始爬取 {len(codes)} 只股票...") for i, code in enumerate(codes): code = str(code).zfill(6) print(f" [{i+1}/{len(codes)}] 爬取: {code}") result = self._crawl_stock(code) self.results.append(result) if progress_callback: progress_callback(i + 1, len(codes)) time.sleep(0.5) # 避免请求过快 return self.results def _crawl_stock(self, code: str) -> Dict: """爬取单只股票信息""" result = { "code": code, "name": "", "status": "pending", "data": {} } try: # 尝试从东方财富获取股票信息 url = f"https://push2.eastmoney.com/api/qt/stock/get?secid={'1' if code.startswith(('6', '5', '9')) else '0'}.{code}&fields=f57,f58,f43,f44,f45,f46,f47,f48,f50,f170" if HAS_CRAWL_UTILS: raw = safe_request(url, timeout=10) else: req = urllib.request.Request(url) ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE with urllib.request.urlopen(req, timeout=10, context=ctx) as resp: raw = resp.read() if raw: import json data = json.loads(raw) stock_data = data.get("data", {}) result["name"] = stock_data.get("f58", "") result["status"] = "success" result["data"] = { "price": stock_data.get("f43", 0), "change_pct": stock_data.get("f170", 0), "open": stock_data.get("f46", 0), # v4.4.0 修复:f46 是开盘价,之前错误使用 f43 "high": stock_data.get("f44", 0), "low": stock_data.get("f45", 0), "volume": stock_data.get("f48", 0), } except Exception as e: result["status"] = "failed" result["error"] = str(e) return result def export_to_json(self, output_file: str = None) -> str: """导出结果到JSON""" if not output_file: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") output_file = str(self.data_dir / f"stock_batch_results_{timestamp}.json") output = { "total": len(self.results), "success": sum(1 for r in self.results if r["status"] == "success"), "update_time": datetime.now().isoformat(), "results": self.results } Path(output_file).write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8") print(f"[股票批量爬虫] 已导出到: {output_file}") return output_file def main(): """测试""" import sys sys.path.insert(0, str(SCRIPT_DIR.parent)) print("=" * 60) print("批量机构/公司爬虫 - 测试") print("=" * 60) crawler = BatchInstitutionCrawler() # 测试按类型爬取 print("\n1. 测试爬取基金管理公司...") institutions = [ {"name": "易方达基金", "code": "EF"}, {"name": "华夏基金", "code": "HX"}, {"name": "广发基金", "code": "GF"}, ] results = crawler.crawl_by_names(institutions) print(f"爬取完成: {len(results)} 条") # 统计 stats = crawler.get_statistics() print(f"\n统计:") print(f" 总数: {stats['total']}") print(f" 成功: {stats['success']}") print(f" 失败: {stats['failed']}") if __name__ == "__main__": main()