2026-01-24 17:07:34 +08:00

1298 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
51job 职位爬虫脚本
包含代理IP管理、远程数据上报和签名算法功能
"""
import httpx
import socket
import time
import hashlib
import hmac
import json
import random
from loguru import logger
import os
from datetime import datetime
from typing import List, Dict, Optional, Any, Tuple
from dataclasses import dataclass
from urllib.parse import urljoin, quote
import requests
os.makedirs("logs", exist_ok=True)
logger.add("logs/log_{time:YYYY-MM-DD}.log", level="INFO", rotation="00:00", retention="30 days", enqueue=True)
API_BASE_URL = os.getenv('API_BASE_URL', 'http://124.222.106.226:9999')
API_PUBLIC_HOST = os.getenv('API_PUBLIC_HOST')
PROXY_URL = "http://t13319619426654:ln8aj9nl@s432.kdltps.com:15818"
def sleep_random_between() -> float:
try:
min_seconds = float(os.getenv('SLEEP_MIN_SECONDS', '1'))
max_seconds = float(os.getenv('SLEEP_MAX_SECONDS', '10'))
if max_seconds < min_seconds:
max_seconds = min_seconds
wait_time = random.uniform(min_seconds, max_seconds)
except Exception:
wait_time = 1.0
time.sleep(wait_time)
return wait_time
def get_job_id(job: Dict[str, Any]) -> Optional[str]:
"""从职位数据中提取jobId作为唯一标识"""
return job.get('jobId') or job.get('id') or job.get('job_id') or None
def deduplicate_jobs(jobs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""对职位列表进行去重基于jobId"""
seen_ids = set()
unique_jobs = []
for job in jobs:
job_id = get_job_id(job)
if job_id:
if job_id not in seen_ids:
seen_ids.add(job_id)
unique_jobs.append(job)
else:
# 如果没有jobId保留该条记录可能是异常数据
unique_jobs.append(job)
return unique_jobs
def save_job_record(job: Dict[str, Any], job_area: str, function_type: str,
page_no: int, records_file: str = "crawl_jobs_records.log") -> None:
"""保存单条职位记录到文件
Args:
job: 职位数据字典
job_area: 地区代码
function_type: 职能代码
page_no: 页码
records_file: 记录文件名
"""
try:
records_dir = os.path.dirname(os.path.abspath(__file__))
records_path = os.path.join(records_dir, records_file)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 提取关键信息
job_id = get_job_id(job)
job_name = job.get('jobName') or job.get('name') or job.get('title') or ''
company_name = job.get('fullCompanyName') or job.get('companyName') or job.get('coName') or ''
salary = job.get('salary') or job.get('salaryDesc') or ''
# 构建记录信息
record = {
"timestamp": timestamp,
"page_no": page_no,
"job_area": job_area,
"function_type": function_type,
"job_id": job_id,
"job_name": job_name,
"company_name": company_name,
"salary": salary,
"job_data": job # 保存完整的职位数据
}
# 追加到文件JSONL格式每行一条JSON记录
with open(records_path, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
except Exception as e:
logger.error(f"保存职位记录失败: {e}")
def save_crawl_stats(total_count: int, unique_count: int, total_count_api: Optional[int],
job_area: str, function_type: str, stats_file: str = "crawl_stats.log") -> None:
"""保存爬取统计信息到文件"""
try:
stats_dir = os.path.dirname(os.path.abspath(__file__))
stats_path = os.path.join(stats_dir, stats_file)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
stats_info = {
"timestamp": timestamp,
"total_crawled": total_count,
"unique_count": unique_count,
"duplicate_count": total_count - unique_count,
"api_total_count": total_count_api,
"job_area": job_area,
"function_type": function_type
}
# 追加到文件
with open(stats_path, "a", encoding="utf-8") as f:
f.write(json.dumps(stats_info, ensure_ascii=False) + "\n")
logger.info(f"统计信息已保存到: {stats_path}")
except Exception as e:
logger.error(f"保存统计信息失败: {e}")
def fetch_service_params() -> Optional[Dict[str, str]]:
"""获取当天未使用的城市与职位并占用
Returns:
dict: {"job_area": str, "job_name": str} 或 None
"""
try:
url = f"{API_BASE_URL}/api/v1/keyword/available"
r = requests.get(url, params={"source": "qcwy", "limit": 1}, timeout=10)
if r.status_code != 200:
return None
js = r.json()
data = js.get("data") or {}
items = data.get("items") or []
if not items:
return None
item = items[0]
ids = [item.get("id")]
if ids and ids[0]:
try:
murl = f"{API_BASE_URL}/api/v1/keyword/mark-used"
requests.post(murl, json={"source": "qcwy", "ids": ids}, timeout=10)
except Exception:
pass
city = str(item.get("city", ""))
job = str(item.get("job", ""))
if not city or not job:
return None
return {"job_area": city, "job_name": job}
except Exception:
return None
@dataclass
class ProxyConfig:
"""代理配置"""
host: str
port: int
username: Optional[str] = None
password: Optional[str] = None
protocol: str = "http"
def to_url(self) -> str:
"""转换为代理URL"""
if self.username and self.password:
return f"{self.protocol}://{self.username}:{self.password}@{self.host}:{self.port}"
return f"{self.protocol}://{self.host}:{self.port}"
class ProxyManager:
"""代理IP管理器"""
def __init__(self, proxy_list: List[ProxyConfig] = None):
self.proxy_list = proxy_list or []
self.current_proxy_index = 0
self.failed_proxies = set()
def add_proxy(self, proxy: ProxyConfig):
"""添加代理"""
self.proxy_list.append(proxy)
def get_current_proxy(self) -> Optional[str]:
"""获取当前可用代理"""
return PROXY_URL
def mark_proxy_failed(self, proxy_url: str):
"""标记代理失败"""
for i, proxy in enumerate(self.proxy_list):
if proxy.to_url() == proxy_url:
self.failed_proxies.add(i)
logger.warning(f"代理 {proxy_url} 标记为失败")
break
def get_proxy_dict(self) -> Optional[Dict[str, str]]:
"""获取代理字典格式"""
return {"http": PROXY_URL, "https": PROXY_URL}
class RemoteReporter:
"""远程数据上报器"""
def __init__(self, report_url: str, proxy_url: Optional[str] = None):
self.report_url = report_url
self.public_host = API_PUBLIC_HOST
if proxy_url:
self.client = httpx.Client(timeout=30.0, proxy=proxy_url, verify=True)
else:
self.client = httpx.Client(timeout=30.0, verify=True)
def get_local_ip(self) -> str:
"""获取本地IP地址"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
return local_ip
except Exception:
return "127.0.0.1"
def report_data(self, data: List[Dict[str, Any]], data_type: str) -> bool:
"""上报数据到远程API"""
try:
# 计算实际数据条数
data_count = len(data) if isinstance(data, list) else 1
# 使用新的数据格式
universal_data = {
"data_list":data,
"data_type": data_type,
"platform": "qcwy"
}
headers = {
"accept": "application/json",
"Content-Type": "application/json",
'X-Forwarded-For': self.get_local_ip()
}
if self.public_host:
headers["Host"] = self.public_host
headers["X-Forwarded-Host"] = self.public_host
# 使用新的API端点
api_endpoint = f"{self.report_url}/api/v1/universal/data/batch-store-async"
response = requests.post(
api_endpoint,
json=universal_data,
headers=headers,
timeout=300
)
response_size = len(response.content) if hasattr(response, 'content') else 0
if 200 <= response.status_code < 300:
logger.info(f"数据上报成功: {data_type}, 上报条数: {data_count}, 响应大小: {response_size} 字节")
return True
else:
logger.error(f"数据上报失败: {response.status_code} - {response.text}, 上报条数: {data_count}, 响应大小: {response_size} 字节")
return False
except Exception as e:
logger.error(f"数据上报异常: {e}")
return False
def close(self):
"""关闭客户端"""
self.client.close()
class SignatureGenerator:
"""签名生成器"""
def __init__(self, sign_key: str):
self.sign_key = sign_key
def hmac_sha256(self, message: str) -> str:
"""使用HMAC-SHA256算法生成签名"""
key_bytes = self.sign_key.encode('utf-8')
message_bytes = message.encode('utf-8')
return hmac.new(key_bytes, message_bytes, hashlib.sha256).hexdigest()
def generate_signature(self, url_path: str, data: Dict[str, Any] = None) -> str:
"""生成请求签名"""
sign_message = url_path
if data:
# 将布尔值转换为字符串
data_copy = data.copy()
for key, value in data_copy.items():
if isinstance(value, bool):
data_copy[key] = "true" if value else "false"
# 添加请求体到签名消息
sign_message += json.dumps(data_copy, ensure_ascii=False, separators=(',', ':'))
return self.hmac_sha256(sign_message)
class JobCrawler:
"""51job职位爬虫主类"""
def __init__(self,
proxy_manager: ProxyManager = None,
remote_reporter: RemoteReporter = None,
sign_key: str = "abfc8f9dcf8c3f3d8aa294ac5f2cf2cc7767e5592590f39c3f503271dd68562b"):
self.proxy_manager = proxy_manager or ProxyManager()
self.remote_reporter = remote_reporter
self.signature_generator = SignatureGenerator(sign_key)
# API配置
self.base_url = "https://cupid.51job.com"
self.api_key = "51job"
# 基础请求头不包含动态property
self.base_headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/6.8.0(0x16080000) NetType/WIFI MiniProgramEnv/Mac MacWechat/WMPF MacWechat/3.8.10(0x13080a10) XWEB/1227",
"Connection": "keep-alive",
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/json",
"account-id": "",
"From-Domain": "51job_weixin_wxapp",
"xweb_xhr": "1",
"user-token": "",
"uuid": str(int(time.time() * 1000)) + str(random.randint(10000000, 99999999)),
"partner": "",
"timestamp": str(int(time.time() * 1000)),
"Sec-Fetch-Site": "cross-site",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Dest": "empty",
"Referer": "https://servicewechat.com/wx1131e5c71e668b5d/391/page-frame.html",
"Accept-Language": "zh-CN,zh;q=0.9"
}
# Session复用 - httpx客户端
self.client = None
self._init_client()
# 加载城市和工作类型数据
self.cities_data = self._load_cities_data()
self.work_types_data = self._load_work_types_data()
def build_property(self, manual_login_method: str = "", page_code: str = "home|hotjob|jobfxlist") -> str:
"""动态生成property字段
Args:
manual_login_method: 手动登录方式
page_code: 页面代码
Returns:
URL编码的property JSON字符串
"""
import uuid
# 生成随机distinct_id模拟微信小程序的设备ID
distinct_id = str(int(time.time() * 1000)) + str(random.randint(100000, 999999))
property_data = {
"frompageUrl": "", # 来源页面URL
"pageUrl": "pages/index/index", # 当前页面URL
"isLogin": "", # 是否登录
"accountid": "", # 账户ID
"resumeId": "", # 简历ID
"firstFrompageUrl": "", # 首次来源页面URL
"distinct_id": distinct_id, # 设备唯一标识
"pageCode": page_code, # 页面代码
"shortPageCode": page_code, # 短页面代码
"policyType": "推荐" # 策略类型
}
# 如果有手动登录方式添加到property中
if manual_login_method:
property_data["manualLoginMethod"] = manual_login_method
# 转换为JSON字符串并URL编码
property_json = json.dumps(property_data, ensure_ascii=False, separators=(',', ':'))
property_encoded = quote(property_json)
return property_encoded
def _init_client(self):
"""初始化或重新初始化httpx客户端"""
if self.client:
self.client.close()
# 获取当前可用代理
proxies = self.proxy_manager.get_proxy_dict()
self.current_proxy = PROXY_URL
# 创建客户端配置不包含headers因为签名是动态的
client_kwargs = {
"timeout": 30.0,
"verify": True,
"trust_env": False
}
# 启用代理配置httpx使用proxy参数传入字符串URL
if self.current_proxy:
client_kwargs["proxy"] = self.current_proxy
logger.info(f"初始化客户端,使用代理: {self.current_proxy}")
self.client = httpx.Client(**client_kwargs)
def _switch_proxy_and_reinit(self):
"""切换代理并重新初始化客户端"""
if self.current_proxy:
logger.warning(f"标记代理失败并切换: {self.current_proxy}")
self.proxy_manager.mark_proxy_failed(self.current_proxy)
# 重新初始化客户端
self._init_client()
def _load_cities_data(self) -> Dict[str, Any]:
"""加载城市数据"""
try:
city_file = os.path.join(os.path.dirname(__file__), 'city.json')
with open(city_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"加载城市数据失败: {e}")
return {}
def _load_work_types_data(self) -> Dict[str, Any]:
"""加载工作类型数据"""
try:
work_file = os.path.join(os.path.dirname(__file__), 'work.json')
with open(work_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"加载工作类型数据失败: {e}")
return {}
def get_random_city(self) -> Dict[str, str]:
"""随机获取一个城市"""
try:
if not self.cities_data:
logger.warning("城市数据为空,使用默认城市")
return {"id": "020000", "value": "上海"}
all_cities: List[Dict[str, str]] = []
# 兼容多种结构:列表或包含 items 的字典
if isinstance(self.cities_data, list):
for item in self.cities_data:
code = item.get("city_code") or item.get("code")
name = item.get("city_name") or item.get("name")
if code and name:
all_cities.append({"id": str(code), "value": name})
elif isinstance(self.cities_data, dict):
for item in self.cities_data.get("items", []):
code = item.get("city_code") or item.get("code")
name = item.get("city_name") or item.get("name")
if code and name:
all_cities.append({"id": str(code), "value": name})
if not all_cities:
logger.warning("未找到城市数据,使用默认城市")
return {"id": "020000", "value": "上海"}
return random.choice(all_cities)
except Exception:
return {"id": "020000", "value": "上海"}
def get_city_name_by_code(self, city_code: str) -> str:
"""根据城市代码获取城市名称
Args:
city_code: 城市代码
Returns:
str: 城市名称,未找到时返回代码
"""
try:
if not city_code:
return city_code
if not self.cities_data:
return city_code
# 兼容列表和字典格式
items = self.cities_data if isinstance(self.cities_data, list) else self.cities_data.get("items", [])
for item in items:
if str(item.get("city_code")) == str(city_code):
return item.get("city_name") or city_code
return city_code
except Exception:
return city_code
def get_city_pinyin_by_code(self, city_code: str) -> str:
"""根据城市代码获取拼音
Args:
city_code: 城市代码
Returns:
str: 城市对应的拼音,未找到时返回默认值
"""
try:
if not city_code:
return "hanzhong"
if not self.cities_data:
return "hanzhong"
# 兼容列表和字典格式
items = self.cities_data if isinstance(self.cities_data, list) else self.cities_data.get("items", [])
for item in items:
if str(item.get("city_code")) == str(city_code):
return item.get("pinyin") or "hanzhong"
return "hanzhong"
except Exception:
return "hanzhong"
def get_function_name_by_code(self, function_code: str) -> str:
"""根据职能代码获取职能名称
Args:
function_code: 职能代码
Returns:
str: 职能名称,未找到时返回代码
"""
try:
if not function_code:
return function_code
if isinstance(self.work_types_data, dict):
for cat in self.work_types_data.get('items', []):
for sub in cat.get('items', []):
for it in sub.get('items', []):
code = it.get('code') or it.get('funcTypeCode')
name = it.get('value') or it.get('name')
if str(code) == str(function_code):
return name or function_code
return function_code
except Exception:
return function_code
def get_random_work_type(self) -> Dict[str, str]:
"""随机获取一个工作类型"""
try:
items = []
if isinstance(self.work_types_data, dict):
for cat in self.work_types_data.get('items', []):
for sub in cat.get('items', []):
for it in sub.get('items', []):
code = it.get('code') or it.get('funcTypeCode')
name = it.get('value') or it.get('name')
if code and name:
items.append({'code': str(code), 'value': name})
if not items:
logger.warning("工作类型数据为空,使用默认工作类型")
return {"code": "A0N7", "value": "数据分析"}
return random.choice(items)
except Exception:
return {"code": "A0N7", "value": "数据分析"}
def find_function_code_by_name(self, name: str) -> Optional[str]:
"""根据工作类型名称查找职能代码
Args:
name: 工作类型中文名称
Returns:
职能代码或None
"""
try:
if not name:
return None
if isinstance(self.work_types_data, dict):
for cat in self.work_types_data.get('items', []):
for sub in cat.get('items', []):
for it in sub.get('items', []):
val = it.get('value') or it.get('name')
code = it.get('code') or it.get('funcTypeCode')
if isinstance(val, str) and isinstance(code, str) and val.strip() == name.strip():
return code
except Exception:
return None
return None
def _normalize_job_area(self, code: Optional[str]) -> str:
code_str = str(code or "").strip()
return code_str if len(code_str) == 6 and code_str.isdigit() else "020000"
def _normalize_function_type(self, code: Optional[str]) -> str:
code_str = str(code or "").strip().upper()
return code_str if code_str.startswith("A0") else "A0N7"
def _make_request(self, url: str, data: Dict[str, Any] = None, headers: Dict[str, str] = None, max_retries: int = 3, method: str = "POST") -> Optional[Dict[str, Any]]:
"""发送HTTP请求支持session复用和智能代理切换"""
for attempt in range(max_retries):
try:
# 检查客户端是否需要重新初始化
if not self.client or self.client.is_closed:
self._init_client()
# 使用传入的headers或默认headers
request_headers = headers or self.base_headers
# 根据方法类型发送请求
if method.upper() == "GET":
response = self.client.get(url, headers=request_headers)
else:
response = self.client.post(url, headers=request_headers, json=data)
if response.status_code == 200:
result = response.json()
logger.info("请求参数 method={} url={} status={} resp_size={}", method.upper(), url, response.status_code, len(response.content))
return result
else:
logger.warning(f"请求失败: {response.status_code} - {response.text}")
# 如果是代理相关的状态码,切换代理
if response.status_code in [407, 502, 503, 504, 403]:
logger.warning(f"检测到代理问题,状态码: {response.status_code}")
self._switch_proxy_and_reinit()
continue
# 其他错误,等待后重试
if attempt < max_retries - 1:
time.sleep(2 ** attempt)
except Exception as e:
logger.error(f"请求异常 (尝试 {attempt + 1}/{max_retries}): {e}")
# 检查是否是代理相关异常
error_str = str(e).lower()
if any(keyword in error_str for keyword in ['proxy', 'connection', 'timeout', 'ssl', 'certificate']):
logger.warning(f"检测到代理相关异常: {e}")
self._switch_proxy_and_reinit()
continue
# 非代理异常,等待后重试
if attempt < max_retries - 1:
time.sleep(2 ** attempt) # 指数退避
logger.error(f"所有重试失败,请求终止")
return None
def get_recommend_jobs(self,
page_no: int = 1,
page_size: int = 10,
job_area: str = "020000",
function_type: str = "A0N7",
seen_job_ids: Optional[set] = None) -> Tuple[List[Dict[str, Any]], int]:
"""获取推荐职位
Returns:
tuple: (职位列表, totalCount总数)
"""
logger.info(f"获取推荐职位: 页码={page_no}, 页大小={page_size}, 地区={job_area}, 工作类型={function_type}")
timestamp = int(time.time())
# 请求参数
data = {
"pageNo": page_no,
"pageSize": page_size,
"specialPageCode": True,
"isTouristMode": True,
"type": "recommend",
"jobArea": self._normalize_job_area(job_area),
"personAsLabel": "",
"functionType": self._normalize_function_type(function_type)
}
# 构建URL路径
api_path = "open/noauth/recommend/job-tab-dynamic-wx-mini"
url_path = f"/{api_path}?api_key={self.api_key}&timestamp={timestamp}"
full_url = f"{self.base_url}{url_path}"
# 生成签名
signature = self.signature_generator.generate_signature(url_path, data)
# 动态生成property字段
property_value = self.build_property(page_code="home|hotjob|jobfxlist")
# 添加签名和动态property到请求头
headers = self.base_headers.copy()
headers["sign"] = signature
headers["property"] = property_value
# 将布尔值转换为字符串
for key, value in data.items():
if isinstance(value, bool):
data[key] = "true" if value else "false"
# 先尝试 GET再回退 POST
signature_get = self.signature_generator.generate_signature(url_path)
headers_get = self.base_headers.copy()
headers_get["sign"] = signature_get
headers_get["property"] = property_value
headers_get["Content-Type"] = "application/x-www-form-urlencoded"
response_data = self._make_request(full_url, None, headers_get, method="GET")
if not (response_data and response_data.get("status") in ['1', 1]):
response_data = self._make_request(full_url, data, headers, method="POST")
if not response_data:
return [], 0
# 解析响应
try:
if response_data.get("status") in [1, "1"] and response_data.get("resultbody"):
job_list = response_data["resultbody"].get("jobList", {})
jobs = job_list.get("items", [])
# 提取totalCount
total_count_raw = job_list.get("totalCount") or job_list.get("totalcount")
total_count = 0
if total_count_raw is not None:
if isinstance(total_count_raw, str):
try:
total_count = int(total_count_raw)
except (ValueError, TypeError):
total_count = 0
elif isinstance(total_count_raw, (int, float)):
total_count = int(total_count_raw)
else:
total_count = 0
all_jobs = jobs
# 获取城市名称和职能名称用于日志
city_name = self.get_city_name_by_code(str(job_area))
function_name = self.get_function_name_by_code(str(function_type))
# 🔍 调试打印每页返回的前5个 jobId验证分页是否正确
job_ids_preview = [get_job_id(job) for job in jobs[:5]]
logger.info(f"🔍 第 {page_no} 页 jobIds 预览: {job_ids_preview}")
# 内存去重:过滤掉已爬取的职位
if seen_job_ids is not None:
unique_jobs = []
duplicate_count = 0
for job in all_jobs:
job_id = get_job_id(job)
if job_id and job_id in seen_job_ids:
duplicate_count += 1
continue
if job_id:
seen_job_ids.add(job_id)
unique_jobs.append(job)
all_jobs = unique_jobs
if duplicate_count > 0:
logger.warning(f"{page_no} 页发现 {duplicate_count} 个重复职位(已跳过)")
logger.info(f"{page_no} 页成功获取 {len(jobs)} 个职位(去重后: {len(all_jobs)} | API返回totalCount: {total_count} (原始值: {total_count_raw}) | 地区: {city_name}({job_area}) | 职能: {function_name}({function_type})")
# 保存每条职位记录到文件
if all_jobs:
for job in all_jobs:
save_job_record(job, job_area, function_type, page_no)
# 上报数据(只上报去重后的数据)
if all_jobs:
jobs_with_details = []
for i, job in enumerate(all_jobs, 1):
try:
job_id = job.get('jobId') or job.get('id') or job.get('job_id')
company_id = job.get('coId') or job.get('companyId')
target_job = job.copy()
city_pinyin = self.get_city_pinyin_by_code(str(job_area))
target_job["jobHref"] = f"https://jobs.51job.com/{city_pinyin}/{job_id}.html"
# 获取职位详情
if job_id:
try:
job_detail = self.get_job_detail(str(job_id))
if job_detail:
target_job['jobDescribe'] = job_detail.get('detailJobInfo', {}).get("jobDescribe")
if not company_id:
company_id = job_detail.get('coId') or job_detail.get('companyId')
except Exception as e:
logger.warning(f"获取职位详情失败 job_id={job_id}: {e}")
# 获取公司信息
if company_id:
try:
company_info = self.get_company_info(str(company_id))
if company_info:
target_job['company_info'] = company_info
target_job['company_desc'] = company_info.get("coinfo",{}).get("coinfo")
target_job['companyHref'] = company_info.get("share",{}).get("weixinshareurl")
except Exception as e:
logger.warning(f"获取公司信息失败 company_id={company_id}: {e}")
jobs_with_details.append(target_job)
# 在获取详情之间添加延迟(最后一个元素不需要延迟)
if i < len(all_jobs):
sleep_random_between()
except Exception as e:
logger.error(f"处理职位数据失败: {e}")
# 即使处理失败,也继续处理下一条
continue
if self.remote_reporter and jobs_with_details:
logger.info(f"准备上报第 {page_no} 页数据,包含详细信息的数据条数: {len(jobs_with_details)}")
self.remote_reporter.report_data(jobs_with_details, "job")
return jobs, total_count
else:
logger.warning(f"API返回错误: {response_data}")
return [], 0
except Exception as e:
import traceback
traceback.print_exc()
logger.error(f"解析响应失败: {e}")
return [], 0
def get_company_jobs(self,
page_no: int = 1,
page_size: int = 30,
co_id: str = "",
job_area: str = "",
function: str = "",
salary_type: str = "") -> List[Dict[str, Any]]:
"""获取公司职位"""
url = "https://cupid.51job.com/open/noauth/jobs/company"
params = {
"api_key": "51job",
"timestamp": int(time.time())
}
data = {
"pageNum": page_no,
"pageSize": page_size,
"coId": co_id,
"jobArea": job_area,
"function": function,
"salaryType": salary_type,
"scene": 14,
"requestId": ""
}
# 构建完整URL
full_url = f"{url}?api_key={params['api_key']}&timestamp={params['timestamp']}"
# 生成签名(包含查询参数)
url_with_params = f"/open/noauth/jobs/company?api_key={params['api_key']}&timestamp={params['timestamp']}"
signature = self.signature_generator.generate_signature(url_with_params, data)
# 构建property字段
property_value = self.build_property(page_code="companyDetail|job|joblist")
# 添加签名和动态property到请求头
headers = self.base_headers.copy()
headers["sign"] = signature
headers["property"] = property_value
try:
response_data = self._make_request(full_url, data, headers)
if not response_data:
signature_get = self.signature_generator.generate_signature(url_with_params)
headers_get = self.base_headers.copy()
headers_get["sign"] = signature_get
headers_get["property"] = property_value
response_data = self._make_request(full_url, None, headers_get, method="GET")
if response_data and response_data.get('status') in ['1', 1]:
result_list = response_data.get('resultbody', {}).get('items', [])
all_jobs = result_list
if result_list:
logger.info(f"成功获取 {len(result_list)} 个公司职位")
else:
logger.warning("API返回成功但职位列表为空")
return result_list
else:
logger.error(f"获取公司职位失败: {response_data}")
return []
except Exception as e:
logger.error(f"获取公司职位时发生异常: {str(e)}")
return []
def get_job_detail(self, job_id: str) -> Dict[str, Any]:
"""获取职位详情"""
timestamp = int(time.time())
# 构建URL
api_path = f"open/noauth/jobs/detail/base/{job_id}"
url_path = f"/{api_path}?api_key={self.api_key}&timestamp={timestamp}"
full_url = f"{self.base_url}{url_path}"
# 生成签名
signature = self.signature_generator.generate_signature(url_path)
# 构建property字段
property_value = self.build_property(page_code="pages/jobs/jobdetail/jobdetail")
# 添加签名和动态property到请求头
headers = self.base_headers.copy()
headers["sign"] = signature
headers["property"] = property_value
headers["Content-Type"] = "application/x-www-form-urlencoded"
# 可选登录头,开放接口无需
try:
# 使用GET请求获取职位详情
response_data = self._make_request(full_url, None, headers, method="GET")
if response_data and response_data.get('status') in ['1', 1]:
job_detail = response_data.get('resultbody', {})
if job_detail:
return job_detail
else:
logger.warning(f"职位详情为空: {job_id}")
return {}
else:
logger.error(f"获取职位详情失败: {job_id}, 响应: {response_data}")
return {}
except Exception as e:
logger.error(f"获取职位详情时发生异常: {job_id}, 错误: {str(e)}")
return {}
def get_company_info(self, company_id: str) -> Dict[str, Any]:
"""获取公司详情信息"""
timestamp = int(time.time())
# 构建URL和参数
api_path = "open/noauth/company-info/info-data"
params = {
"companyId": company_id,
"colorOne": "#ffffff",
"colorTwo": "#ffffffcc"
}
# 构建完整URL路径包含查询参数
url_path = f"/{api_path}?api_key={self.api_key}&timestamp={timestamp}"
# 添加业务参数到URL
param_str = "&".join([f"{k}={quote(str(v))}" for k, v in params.items()])
url_path += f"&{param_str}"
full_url = f"{self.base_url}{url_path}"
# 生成签名GET请求签名只包含URL路径
signature = self.signature_generator.generate_signature(url_path)
# 构建property字段
property_value = self.build_property(page_code="companyDetail|company|companyinfo")
# 添加签名和动态property到请求头
headers = self.base_headers.copy()
headers["sign"] = signature
headers["property"] = property_value
headers["Content-Type"] = "application/x-www-form-urlencoded"
try:
# 使用GET请求获取公司详情
response_data = self._make_request(full_url, None, headers, method="GET")
if response_data and response_data.get('status') in ['1', 1]:
company_info = response_data.get('resultbody', {})
if company_info:
return company_info
else:
logger.warning(f"公司详情为空: {company_id}")
return {}
else:
logger.error(f"获取公司详情失败: {response_data}")
return {}
except Exception as e:
logger.error(f"获取公司详情时发生异常: {str(e)}")
return {}
def crawl_multiple_pages(self,
max_pages: int = 5,
page_size: int = 10,
job_area: str = "020000",
function_type: str = "A0N7",
delay: float = 1.0) -> List[Dict[str, Any]]:
"""爬取指定页数的数据根据totalCount动态计算页数"""
all_jobs = []
total_count = None
calculated_max_pages = max_pages
page = 1
actual_pages_crawled = 0 # 记录实际爬取的页数
# 内存去重维护已爬取的job_id集合避免重复获取详情和上报
seen_job_ids = set()
# 获取城市名称和职能名称用于日志
city_name = self.get_city_name_by_code(str(job_area))
function_name = self.get_function_name_by_code(str(function_type))
logger.info("=" * 80)
logger.info(f"开始爬取推荐职位 | 最大页数: {max_pages} | 每页大小: {page_size} | 地区: {city_name}({job_area}) | 职能: {function_name}({function_type})")
logger.info("=" * 80)
while page <= calculated_max_pages:
logger.info(f"\n>>> 正在爬取第 {page}/{calculated_max_pages} 页...")
try:
self._init_client()
except Exception:
pass
jobs, current_total_count = self.get_recommend_jobs(
page_no=page,
page_size=page_size,
job_area=job_area,
function_type=function_type,
seen_job_ids=seen_job_ids # 传入已爬取的job_id集合用于去重
)
# 记录实际爬取的页数
actual_pages_crawled += 1
# 首次获取时根据totalCount动态计算需要爬取的页数
if total_count is None and current_total_count > 0:
total_count = current_total_count
# 根据totalCount计算实际需要的页数向上取整
calculated_max_pages = min(max_pages, (total_count + page_size - 1) // page_size)
logger.info(f"检测到API返回totalCount: {total_count} | 计算需要爬取: {calculated_max_pages} 页 (每页 {page_size} 条) | 预计最大数据: {calculated_max_pages * page_size}")
if jobs:
all_jobs.extend(jobs)
logger.info(f"{page} 页获取到 {len(jobs)} 个职位 | 累计: {len(all_jobs)}")
else:
logger.warning(f"{page} 页没有获取到职位数据,提前结束")
# 如果某页没有数据,提前结束
break
# 如果已获取的数据量达到或超过totalCount提前结束
if total_count and len(all_jobs) >= total_count:
logger.info(f"已获取所有数据 ({len(all_jobs)}/{total_count}),提前结束爬取")
break
# 延迟避免被封(在每页请求之间添加休眠)
if page < calculated_max_pages:
sleep_time = sleep_random_between()
logger.info(f"休眠 {sleep_time:.2f} 秒后继续...")
page += 1
# 统计和去重
total_crawled = len(all_jobs)
unique_jobs = deduplicate_jobs(all_jobs)
unique_count = len(unique_jobs)
duplicate_count = total_crawled - unique_count
# 打印详细统计信息
logger.info("\n" + "=" * 80)
logger.info("爬取统计信息:")
logger.info(f" 总抓取数量: {total_crawled}")
logger.info(f" 去重后数量: {unique_count}")
logger.info(f" 重复数量: {duplicate_count}")
logger.info(f" API返回totalCount: {total_count if total_count is not None else '未知'}")
logger.info(f" 实际爬取页数: {actual_pages_crawled}")
logger.info(f" 地区: {city_name}({job_area})")
logger.info(f" 职能: {function_name}({function_type})")
if total_count and total_count > 0:
coverage_rate = (unique_count / total_count * 100)
logger.info(f" 覆盖率: {coverage_rate:.2f}% ({unique_count}/{total_count})")
if unique_count < total_count:
logger.warning(f" 注意: 实际抓取数量({unique_count}) < API返回总数({total_count}),可能存在分页问题或数据过滤")
elif total_count == 0:
logger.warning(f"注意: API返回totalCount为0可能是该地区/职能组合下没有数据")
logger.info("=" * 80 + "\n")
# 保存统计信息到文件
save_crawl_stats(
total_count=total_crawled,
unique_count=unique_count,
total_count_api=total_count,
job_area=job_area,
function_type=function_type
)
# 记录保存的文件路径
records_dir = os.path.dirname(os.path.abspath(__file__))
records_path = os.path.join(records_dir, "crawl_jobs_records.log")
logger.info(f"职位记录已保存到: {records_path} (共 {unique_count} 条记录)")
return unique_jobs
def crawl_company_jobs_multiple_pages(self,
co_id: str,
max_pages: int = 5,
page_size: int = 30,
job_area: str = "",
function: str = "",
salary_type: str = "",
delay: float = 1.0) -> List[Dict[str, Any]]:
"""爬取指定页数的公司职位数据"""
all_jobs = []
for page in range(1, max_pages + 1):
logger.info(f"正在爬取公司 {co_id}{page} 页职位...")
try:
self._init_client()
except Exception:
pass
jobs = self.get_company_jobs(
page_no=page,
page_size=page_size,
co_id=co_id,
job_area=job_area,
function=function,
salary_type=salary_type
)
if jobs:
all_jobs.extend(jobs)
logger.info(f"{page} 页获取到 {len(jobs)} 个职位")
else:
logger.warning(f"{page} 页没有获取到职位数据")
# 延迟避免被封
if page < max_pages:
sleep_random_between()
logger.info(f"总共获取到 {len(all_jobs)} 个公司职位")
return all_jobs
def close(self):
"""关闭资源"""
# 关闭httpx客户端
if self.client and not self.client.is_closed:
self.client.close()
logger.info("httpx客户端已关闭")
# 关闭远程报告器
if self.remote_reporter:
self.remote_reporter.close()
def crawl_recommend_jobs_main(max_pages: int = 10, page_size: int = 15, job_area: str = None, function_type: str = None, delay: float = 2.0):
"""推荐职位爬取主函数
Args:
max_pages: 最大爬取页数
page_size: 每页数量
job_area: 地区代码为None时随机选择
function_type: 工作类型代码为None时随机选择
delay: 页面间隔时间
Returns:
List[Dict]: 职位列表
"""
# 1. 配置代理(可选)
proxy_manager = ProxyManager()
username = os.getenv('PROXY_USERNAME', 't13319619426654')
password = os.getenv('PROXY_PASSWORD', 'ln8aj9nl')
tunnel = os.getenv('PROXY_TUNNEL', 's432.kdltps.com:15818')
# 解析隧道地址和端口
if tunnel and ':' in tunnel:
host, port_str = tunnel.split(':')
port = int(port_str)
# 使用环境变量中的代理信息创建代理配置
proxy_config = ProxyConfig(host, port, username, password)
proxy_manager.add_proxy(proxy_config)
logger.info(f"已配置代理: {proxy_config.to_url()}")
else:
logger.warning("未找到有效的隧道配置,将使用本地代理")
proxy_manager.add_proxy(ProxyConfig("127.0.0.1", 8080)) # 添加本地代理作为备选
# 2. 配置远程上报(可选)
# 3. 创建爬虫实例
remote_reporter = RemoteReporter(
report_url=API_BASE_URL,
# proxy_url=proxy_manager.get_current_proxy()
)
crawler = JobCrawler(
proxy_manager=proxy_manager,
remote_reporter=remote_reporter
)
try:
svc = fetch_service_params()
if svc:
job_area = svc["job_area"]
code = crawler.find_function_code_by_name(svc["job_name"]) if function_type is None else function_type
if code:
function_type = code
# 回退随机选择
if job_area is None:
random_city = crawler.get_random_city()
job_area = random_city['id']
logger.info(f"随机选择城市: {random_city['value']} ({random_city['id']})")
if function_type is None:
random_work_type = crawler.get_random_work_type()
function_type = random_work_type['code']
logger.info(f"随机选择工作类型: {random_work_type['value']} ({random_work_type['code']})")
logger.info(f"开始爬取推荐职位,最多 {max_pages}")
all_jobs = crawler.crawl_multiple_pages(
max_pages=max_pages,
page_size=page_size,
job_area=job_area,
function_type=function_type,
delay=delay
)
# 显示结果
if all_jobs:
logger.info(f"推荐职位爬取完成!共找到 {len(all_jobs)} 个职位")
else:
logger.warning("未找到任何推荐职位信息")
return all_jobs
except KeyboardInterrupt:
logger.info("用户中断推荐职位爬取")
return []
except Exception as e:
logger.error(f"推荐职位爬取过程中发生错误: {e}")
return []
finally:
crawler.close()
def crawl_company_jobs_main(co_id: str, max_pages: int = 5, page_size: int = 30, job_area: str = "", function: str = "", salary_type: str = "", delay: float = 2.0):
"""公司职位爬取主函数
Args:
co_id: 公司ID
max_pages: 最大爬取页数
page_size: 每页数量
job_area: 地区代码
function: 职能代码
salary_type: 薪资类型
delay: 页面间隔时间
Returns:
List[Dict]: 职位列表
"""
# 1. 配置代理(可选)
proxy_manager = ProxyManager()
# proxy_manager.add_proxy(ProxyConfig("127.0.0.1", 8080)) # 添加代理
# 2. 配置远程上报(可选)
remote_reporter = RemoteReporter(
report_url=API_BASE_URL,
proxy_url=proxy_manager.get_current_proxy()
)
# 3. 创建爬虫实例
crawler = JobCrawler(
proxy_manager=proxy_manager,
remote_reporter = remote_reporter
)
try:
logger.info(f"开始爬取公司 {co_id} 的职位,最多 {max_pages}")
all_jobs = crawler.crawl_company_jobs_multiple_pages(
co_id=co_id,
max_pages=max_pages,
page_size=page_size,
job_area=job_area,
function=function,
salary_type=salary_type,
delay=delay
)
return all_jobs
except KeyboardInterrupt:
logger.info("用户中断公司职位爬取")
return []
except Exception as e:
logger.error(f"公司职位爬取过程中发生错误: {e}")
return []
finally:
crawler.close()
def main():
"""主函数示例 - 演示两个爬取函数的用法"""
# 使用更大的默认值根据totalCount会动态调整实际爬取的页数
recommend_jobs = crawl_recommend_jobs_main(max_pages=50, page_size=20)
if __name__ == "__main__":
while True:
main()
sleep_random_between()