459 lines
15 KiB
Python
459 lines
15 KiB
Python
"""
|
|
爬虫核心模块
|
|
实现广西政府采购网公告的智能爬取功能
|
|
"""
|
|
|
|
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()
|