""" 爬虫核心模块 实现广西政府采购网公告的智能爬取功能 """ import time import random import json from typing import List, Dict, Any, Optional, Tuple from datetime import datetime from urllib.parse import urljoin import requests from fake_useragent import UserAgent try: # 尝试相对导入 from ..core.models import Announcement, AnnouncementSource, AnnouncementType, CrawlResult, CrawlStatus from ..core.config_manager import get_config from ..core.logger import get_logger, log_crawl_start, log_crawl_success, log_crawl_error from ..core.reliability import ( retry_on_exception, RetryConfig, session_with_retry, TimeoutConfig, safe_execute, check_system_health ) from .parsers import AnnouncementParser, SensitiveWordChecker, ErrorResponseParser except ImportError: # 尝试绝对导入 from core.models import Announcement, AnnouncementSource, AnnouncementType, CrawlResult, CrawlStatus from core.config_manager import get_config from core.logger import get_logger, log_crawl_start, log_crawl_success, log_crawl_error from core.reliability import ( retry_on_exception, RetryConfig, session_with_retry, TimeoutConfig, safe_execute, check_system_health ) from crawler.parsers import AnnouncementParser, SensitiveWordChecker, ErrorResponseParser logger = get_logger(__name__) class GXGPSpider: """广西政府采购网爬虫""" def __init__(self): self.config = get_config() self.ua = UserAgent() # API端点 self.base_url = self.config.crawler.base_url self.announcement_api = urljoin(self.base_url, "/portal/category") self.sensitive_check_api = urljoin(self.base_url, "/portal/sensitiveWords/check") # 会话管理 self.session = None # 统计信息 self.request_count = 0 self.error_count = 0 def init_session(self): """初始化会话""" if self.session is None: timeout_config = TimeoutConfig( connect_timeout=self.config.crawler.timeout, read_timeout=self.config.crawler.timeout ) retry_config = RetryConfig( max_retries=self.config.crawler.max_retries, initial_delay=self.config.crawler.retry_delay, max_delay=self.config.crawler.max_retry_delay, backoff_factor=self.config.crawler.backoff_factor ) self.session = requests.Session() # 配置重试和超时 adapter = requests.adapters.HTTPAdapter( pool_connections=10, pool_maxsize=20, max_retries=0 # 我们使用自己的重试逻辑 ) self.session.mount('http://', adapter) self.session.mount('https://', adapter) # 设置默认超时 self.session.timeout = (timeout_config.connect_timeout, timeout_config.read_timeout) return self.session def close_session(self): """关闭会话""" if self.session: self.session.close() self.session = None def get_random_user_agent(self) -> str: """获取随机User-Agent""" try: return self.ua.random except: # fallback到配置的user agents return random.choice(self.config.crawler.user_agents) def get_random_proxy(self) -> Optional[Dict[str, str]]: """获取随机代理""" if not self.config.crawler.proxies: return None proxy = random.choice(self.config.crawler.proxies) return { "http": proxy, "https": proxy } def check_sensitive_words(self, payload: Dict[str, Any], category_code: str, childrencode: str) -> bool: """ 执行敏感词检查 Args: payload: 请求参数 category_code: 分类代码 childrencode: 子分类代码 Returns: bool: 检查是否通过 """ try: session = self.init_session() user_agent = self.get_random_user_agent() headers = { "User-Agent": user_agent, "Content-Type": "application/json;charset=UTF-8", "Origin": self.base_url, "Referer": f"{self.base_url}/site/category?parentId={category_code}&childrenCode={childrencode}", "Cookie": "_zcy_log_client_uuid=71e283e0-23d2-11f0-844a-eb67dfa7ab64" } proxies = self.get_random_proxy() logger.debug(f"执行敏感词检查: {category_code}/{childrencode}") response = session.post( self.sensitive_check_api, json=payload, headers=headers, proxies=proxies, timeout=self.session.timeout ) self.request_count += 1 if response.status_code == 200: result = response.json() return SensitiveWordChecker.parse_check_response(result) else: logger.warning(f"敏感词检查请求失败, 状态码: {response.status_code}") return False except Exception as e: logger.error(f"敏感词检查异常: {str(e)}") self.error_count += 1 return False def fetch_announcements_page(self, source: AnnouncementSource, page_no: int = 1) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: """ 获取公告列表页数据 Args: source: 公告来源 page_no: 页码 Returns: Tuple[Optional[Dict[str, Any]], Optional[str]]: (响应数据, 错误信息) """ try: session = self.init_session() # 构建请求参数 payload = { "keyword": "", # 关键词筛选,我们在筛选模块处理 "publishDateBegin": self.config.crawler.start_date or "", "publishDateEnd": self.config.crawler.end_date or "", "pageNo": page_no, "pageSize": self.config.crawler.page_size, "categoryCode": source.code, "_t": int(time.time() * 1000) } # 先执行敏感词检查 if not self.check_sensitive_words(payload, str(source.category_id), source.code): return None, "敏感词检查失败" # 执行公告数据请求 user_agent = self.get_random_user_agent() headers = { "User-Agent": user_agent, "Content-Type": "application/json;charset=UTF-8", "Origin": self.base_url, "Referer": f"{self.base_url}/site/category?parentId={source.category_id}&childrenCode={source.code}", "Cookie": "_zcy_log_client_uuid=71e283e0-23d2-11f0-844a-eb67dfa7ab64" } proxies = self.get_random_proxy() logger.debug(f"请求公告数据: {source.name} 第{page_no}页") # 添加请求间延迟 if page_no > 1: delay = random.uniform( self.config.crawler.request_delay, self.config.crawler.request_delay_max ) time.sleep(delay) response = session.post( self.announcement_api, json=payload, headers=headers, proxies=proxies, timeout=self.session.timeout ) self.request_count += 1 if response.status_code == 200: data = response.json() if data.get("success", False): return data, None else: error_msg = ErrorResponseParser.parse_error(data) return None, f"API返回失败: {error_msg}" else: return None, f"请求失败, 状态码: {response.status_code}" except requests.exceptions.Timeout as e: self.error_count += 1 return None, f"请求超时: {str(e)}" except requests.exceptions.ProxyError as e: self.error_count += 1 return None, f"代理错误: {str(e)}" except Exception as e: self.error_count += 1 logger.error(f"获取公告数据异常: {str(e)}") return None, f"请求异常: {str(e)}" def crawl_source(self, source: AnnouncementSource, max_pages: Optional[int] = None) -> CrawlResult: """ 爬取单个来源的公告 Args: source: 公告来源 max_pages: 最大页数限制 Returns: CrawlResult: 爬取结果 """ if max_pages is None: max_pages = self.config.crawler.max_pages log_crawl_start(source.name) start_time = datetime.now() result = CrawlResult( source=source, status=CrawlStatus.RUNNING, crawled_at=start_time ) try: page = 1 all_announcements = [] while page <= max_pages: # 获取页面数据 response_data, error_msg = self.fetch_announcements_page(source, page) if error_msg: logger.warning(f"{source.name} 第{page}页获取失败: {error_msg}") result.status = CrawlStatus.FAILED result.error_message = error_msg break if not response_data: logger.info(f"{source.name} 第{page}页无数据") break # 解析分页信息 pagination = AnnouncementParser.extract_pagination_info(response_data) result.total_count = pagination["total"] # 解析公告数据 announcements = AnnouncementParser.parse_api_response( response_data, source, start_time) if not announcements: logger.info(f"{source.name} 第{page}页解析到0条公告") break all_announcements.extend(announcements) # 检查是否还有下一页 if not pagination["has_next"] or pagination["empty"]: break page += 1 # 更新结果 result.announcements = all_announcements result.new_count = len(all_announcements) # 这里的新增数需要在筛选后确定 if result.status != CrawlStatus.FAILED: result.status = CrawlStatus.SUCCESS duration = (datetime.now() - start_time).total_seconds() result.duration = duration log_crawl_success(source.name, len(all_announcements), duration) except Exception as e: duration = (datetime.now() - start_time).total_seconds() result.duration = duration result.status = CrawlStatus.FAILED result.error_message = str(e) log_crawl_error(source.name, str(e)) return result @retry_on_exception(RetryConfig(max_retries=2)) def crawl_all_sources(self, sources: Optional[List[AnnouncementSource]] = None) -> List[CrawlResult]: """ 爬取所有来源的公告 Args: sources: 指定的来源列表,如果为None则使用配置中的所有来源 Returns: List[CrawlResult]: 所有来源的爬取结果 """ # 系统健康检查 if not check_system_health(): logger.error("系统健康检查失败,跳过爬取") return [] if sources is None: sources = self._load_sources_from_config() logger.info(f"开始爬取 {len(sources)} 个公告来源") results = [] for source in sources: try: result = self.crawl_source(source) results.append(result) # 检查是否需要暂停 if result.status == CrawlStatus.FAILED: logger.warning(f"来源 {source.name} 爬取失败,继续下一个来源") continue except Exception as e: logger.error(f"爬取来源 {source.name} 时发生未预期错误: {str(e)}") # 创建失败结果 failed_result = CrawlResult( source=source, status=CrawlStatus.FAILED, error_message=str(e), crawled_at=datetime.now() ) results.append(failed_result) # 统计总结果 total_announcements = sum(len(r.announcements) for r in results if r.status == CrawlStatus.SUCCESS) success_count = sum(1 for r in results if r.status == CrawlStatus.SUCCESS) failed_count = len(results) - success_count logger.info( f"爬取完成: 共处理 {len(results)} 个来源," f"成功 {success_count} 个,失败 {failed_count} 个," f"获取 {total_announcements} 条公告" ) return results def _load_sources_from_config(self) -> List[AnnouncementSource]: """从配置加载公告来源""" sources = [] for code, source_config in self.config.sources.items(): try: source = AnnouncementSource( code=code, category_id=source_config["category_id"], name=source_config["name"], type=AnnouncementType(source_config["type"]) ) sources.append(source) except Exception as e: logger.warning(f"加载来源配置失败 {code}: {str(e)}") continue return sources def get_stats(self) -> Dict[str, Any]: """获取爬虫统计信息""" return { "request_count": self.request_count, "error_count": self.error_count, "error_rate": self.error_count / max(self.request_count, 1), "session_active": self.session is not None } def reset_stats(self): """重置统计信息""" self.request_count = 0 self.error_count = 0 def create_spider() -> GXGPSpider: """ 创建爬虫实例 Returns: GXGPSpider: 爬虫实例 """ return GXGPSpider() def crawl_announcements(keywords: Optional[List[str]] = None, sources: Optional[List[str]] = None) -> List[CrawlResult]: """ 便捷函数:爬取公告 Args: keywords: 关键词过滤(暂时未使用,在筛选模块处理) sources: 来源代码列表 Returns: List[CrawlResult]: 爬取结果 """ spider = create_spider() try: # 过滤来源 if sources: all_sources = spider._load_sources_from_config() filtered_sources = [s for s in all_sources if s.code in sources] else: filtered_sources = None return spider.crawl_all_sources(filtered_sources) finally: spider.close_session()