JobData/jobs_spider/qcwy/search_company_jobs.py

975 lines
28 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import os
import time
import uuid as _uuid
from typing import Any, Dict, Optional, Callable
import socket
import random
import sqlite3
from urllib.parse import quote
from urllib.request import Request, urlopen, build_opener, ProxyHandler, HTTPSHandler
from urllib.error import HTTPError, URLError
import ssl
BASE_URL = "https://cupid.51job.com"
SIGN_KEY = os.getenv(
"JOB_SIGN_KEY",
"abfc8f9dcf8c3f3d8aa294ac5f2cf2cc7767e5592590f39c3f503271dd68562b",
)
FROM_DOMAIN = "51job_weixin_wxapp"
API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:9999")
COMPANY_INFO_CACHE: Dict[str, Any] = {}
SUCCESS_LOG_PATH = os.path.join(os.path.dirname(__file__), "success.txt")
_SUCCESS_WRITTEN: set = set()
def _timestamp() -> int:
"""Get current UNIX timestamp.
Returns:
int: Current timestamp in seconds.
"""
return int(time.time())
def _encode_query(params: Optional[Dict[str, object]]) -> str:
"""Encode query parameters preserving insertion order.
Args:
params (Optional[Dict[str, object]]): Query parameters to encode.
Returns:
str: URL-encoded query string starting with '&' when params exist.
"""
if not params:
return ""
pieces = []
for k, v in params.items():
key = quote(str(k), safe="")
if isinstance(v, (list, tuple)):
for item in v:
pieces.append(f"{key}={quote(str(item), safe='')}")
elif v is None:
pieces.append(f"{key}=")
else:
pieces.append(f"{key}={quote(str(v), safe='')}")
return "&" + "&".join(pieces)
def build_signature(
method: str,
path: str,
query_params: Optional[Dict[str, object]] = None,
body_json: Optional[str] = None,
timestamp: Optional[int] = None,
) -> Dict[str, Any]:
"""Construct signing string and compute HMAC-SHA256 signature.
Args:
method (str): HTTP method (GET or POST).
path (str): API path without leading slash.
query_params (Optional[Dict[str, object]]): Query parameters for GET.
body_json (Optional[str]): JSON body string for POST.
timestamp (Optional[int]): Provided timestamp; generates if None.
Returns:
Dict[str, Any]: dict with fields 'sig'(hex), 'signed_path', and 'ts'.
"""
import hmac
import hashlib
ts = timestamp or _timestamp()
base = f"/{path}?api_key=51job&timestamp={ts}"
sign_str = base
method_u = method.upper()
if method_u == "GET":
q = _encode_query(query_params)
sign_str += q
signed_path = base + q
else:
if body_json:
sign_str += body_json
signed_path = base
key_bytes = SIGN_KEY.encode("utf-8")
sig = hmac.new(key_bytes, sign_str.encode("utf-8"), hashlib.sha256).hexdigest()
return {"sig": sig, "signed_path": signed_path, "ts": ts}
def _build_headers(
sign: str,
content_type: str,
uuid: Optional[str] = None,
account_id: Optional[str] = None,
user_token: Optional[str] = None,
partner: Optional[str] = None,
property_obj: Optional[Dict[str, Any]] = None,
headers_ext: Optional[Dict[str, str]] = None,
) -> Dict[str, str]:
"""Build request headers including signing and context.
Args:
sign (str): Hex-encoded signature.
content_type (str): Content-Type header.
uuid (Optional[str]): UUID value for tracing.
account_id (Optional[str]): Account id.
user_token (Optional[str]): User token.
partner (Optional[str]): Partner identifier.
property_obj (Optional[Dict[str, Any]]): Property payload.
headers_ext (Optional[Dict[str, str]]): Extra headers to merge.
Returns:
Dict[str, str]: Complete headers dict.
"""
did = uuid or str(_uuid.uuid4())
headers = {
"sign": sign,
"From-Domain": FROM_DOMAIN,
"Content-Type": content_type,
"Accept": "application/json",
"uuid": did,
}
if account_id:
headers["account-id"] = account_id
if user_token:
headers["user-token"] = user_token
if partner:
headers["partner"] = partner
prop = property_obj or {
"frompageUrl": "",
"pageUrl": "",
"isLogin": "" if bool(account_id) else "",
"accountid": account_id or "",
"resumeId": "",
"firstFrompageUrl": "",
"distinct_id": did,
}
headers["property"] = quote(json.dumps(prop, ensure_ascii=False), safe="")
if headers_ext:
headers.update(headers_ext)
return headers
def _request(
method: str,
path: str,
params: Optional[Dict[str, Any]] = None,
body: Optional[Dict[str, Any]] = None,
uuid: Optional[str] = None,
account_id: Optional[str] = None,
user_token: Optional[str] = None,
partner: Optional[str] = None,
property_obj: Optional[Dict[str, Any]] = None,
headers_ext: Optional[Dict[str, str]] = None,
proxies: Optional[list] = None,
timeout: int = 10,
retries: int = 2,
raw_sink: Optional[Callable[[str], None]] = None,
) -> Dict[str, Any]:
"""Execute signed HTTP request with basic retries.
Args:
method (str): HTTP method.
path (str): API path without leading slash.
params (Optional[Dict[str, Any]]): Query for GET.
body (Optional[Dict[str, Any]]): JSON body for POST.
uuid/account_id/user_token/partner/property_obj: Header context.
headers_ext (Optional[Dict[str, str]]): Extra headers to merge.
proxies (Optional[list]): Proxy entries.
timeout (int): Timeout seconds.
retries (int): Retry attempts.
Returns:
Dict[str, Any]: Parsed JSON response.
"""
body_json = json.dumps(body, ensure_ascii=False) if body is not None else None
sig = build_signature(method, path, params, body_json)
content_type = "application/x-www-form-urlencoded" if method.upper() == "GET" else "application/json"
headers = _build_headers(
sign=sig["sig"],
content_type=content_type,
uuid=uuid,
account_id=account_id,
user_token=user_token,
partner=partner,
property_obj=property_obj,
headers_ext=headers_ext,
)
url = f"{BASE_URL}{sig['signed_path']}"
data_bytes = body_json.encode("utf-8") if (method.upper() == "POST" and body_json is not None) else None
attempt = 0
backoff = 0.5
last_error: Optional[Exception] = None
use_insecure_ssl = os.getenv("JOB_INSECURE_SSL") in ("1", "true", "TRUE")
while attempt <= retries:
_sleep_between_requests(0.2, 0.7)
req = Request(url=url, data=data_bytes, headers=headers, method=method.upper())
try:
opener = None
ctx = _get_ssl_context(use_insecure_ssl)
if proxies:
idx = attempt % len(proxies)
p = proxies[idx]
if isinstance(p, str):
ph = ProxyHandler({"http": p, "https": p})
elif isinstance(p, dict):
ph = ProxyHandler(p)
else:
ph = None
if ph:
opener = build_opener(ph, HTTPSHandler(context=ctx))
if opener:
with opener.open(req, timeout=timeout) as resp:
payload = resp.read().decode("utf-8")
else:
with urlopen(req, timeout=timeout, context=ctx) as resp:
payload = resp.read().decode("utf-8")
if raw_sink and isinstance(payload, str):
try:
raw_sink(payload)
except Exception:
pass
return json.loads(payload) if payload else {}
except (HTTPError, URLError) as e:
print(e)
last_error = e
msg = str(e)
if ("CERTIFICATE_VERIFY_FAILED" in msg) and (not use_insecure_ssl):
use_insecure_ssl = True
attempt += 1
continue
if attempt == retries:
return {}
time.sleep(backoff)
backoff *= 2
attempt += 1
if last_error:
return {}
return {}
def search_company_keyword(
keyword: str,
page: int = 1,
size: int = 20,
job_area: str = "020000",
sort_type: str = "0",
search_type: str = "2",
scene: str = "12",
uuid: Optional[str] = None,
account_id: Optional[str] = None,
user_token: Optional[str] = None,
partner: Optional[str] = None,
property_obj: Optional[Dict[str, Any]] = None,
raw_sink: Optional[Callable[[str], None]] = None,
) -> Dict[str, Any]:
"""Call open/noauth/search with a company keyword.
Args:
keyword (str): Keyword to search.
page (int): Page number.
size (int): Page size.
job_area (str): Area code.
sort_type (str): Sort type.
search_type (str): Search type.
scene (str): Scene id.
uuid/account_id/user_token/partner/property_obj: Header context.
Returns:
Dict[str, Any]: API response JSON.
"""
params: Dict[str, Any] = {
"userLonLat": "",
"sortType": sort_type,
"keyword": keyword,
"pageSize": str(size),
"pageNum": str(page),
"jobArea": job_area,
"landmark": "",
"radius": "",
"workYear": "",
"degree": "",
"companyType": "",
"companySize": "",
"salary": "NaN-NaN",
"jobType": "",
"metro": "",
"function": "",
"industry": "",
"issueDate": "",
"searchType": search_type,
"scene": scene,
}
return _request(
method="GET",
path="open/noauth/search",
params=params,
uuid=uuid,
account_id=account_id,
user_token=user_token,
partner=partner,
property_obj=property_obj,
raw_sink=raw_sink,
)
def company_jobs_by_id(
co_id: str,
page: int = 1,
size: int = 20,
uuid: Optional[str] = None,
account_id: Optional[str] = None,
user_token: Optional[str] = None,
partner: Optional[str] = None,
property_obj: Optional[Dict[str, Any]] = None,
raw_sink: Optional[Callable[[str], None]] = None,
) -> Dict[str, Any]:
"""Fetch jobs for a company by numeric id using POST.
Args:
co_id (str): Numeric company id.
page (int): Page number.
size (int): Page size.
uuid/account_id/user_token/partner/property_obj: Header context.
Returns:
Dict[str, Any]: API response JSON.
"""
body: Dict[str, Any] = {
"pageNum": page,
"pageSize": size,
"coId": co_id,
"scene": 14,
"requestId": "",
}
return _request(
method="POST",
path="open/noauth/jobs/company",
body=body,
uuid=uuid,
account_id=account_id,
user_token=user_token,
partner=partner,
property_obj=property_obj,
raw_sink=raw_sink,
)
def _extract_items(resp: Dict[str, Any]) -> list:
"""Extract the first list of items from a nested response.
This function searches common keys first, then falls back to a recursive
traversal to find the first list encountered. It is resilient to schema
variations of the API response.
Args:
resp (Dict[str, Any]): Parsed JSON response.
Returns:
list: The extracted items list; empty when not found or no data.
"""
if not isinstance(resp, dict):
return []
# Prefer job items under resultbody/job/items
rb = resp.get("resultbody") or resp.get("resultBody")
if isinstance(rb, dict):
job_node = rb.get("job")
if isinstance(job_node, dict) and isinstance(job_node.get("items"), list):
return job_node.get("items", [])
preferred_keys = (
"items",
"list",
"jobs",
"jobList",
"companies",
"companyList",
"resultList",
"dataList",
)
for key in preferred_keys:
val = resp.get(key)
if isinstance(val, list):
return val
def _walk(node: Any) -> Optional[list]:
if isinstance(node, list):
return node
if isinstance(node, dict):
for k in preferred_keys:
v = node.get(k)
if isinstance(v, list):
return v
for v in node.values():
found = _walk(v)
if isinstance(found, list):
return found
return None
found = _walk(resp)
return found or []
def _get_local_ip() -> str:
"""Get local IP address for forwarding header.
Returns:
str: Local IP string.
"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "127.0.0.1"
def _get_ssl_context(insecure: bool = False) -> ssl.SSLContext:
"""Return SSL context, optionally unverified.
Args:
insecure (bool): Whether to disable certificate verification.
Returns:
ssl.SSLContext: Configured SSL context.
"""
if insecure:
try:
return ssl._create_unverified_context()
except Exception:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
try:
return ssl.create_default_context()
except Exception:
return ssl._create_unverified_context()
def _get_db_path() -> str:
"""Return default SQLite DB path for storing raw responses.
Returns:
str: Absolute file path to SQLite database.
"""
base_dir = os.path.dirname(__file__)
return os.path.join(base_dir, "qcwy_raw.sqlite3")
def _init_db(db_path: str) -> None:
"""Initialize SQLite database with responses table if absent.
Args:
db_path (str): Path to SQLite database file.
Returns:
None
"""
try:
con = sqlite3.connect(db_path)
cur = con.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS responses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
page INTEGER NOT NULL,
created_at INTEGER NOT NULL,
payload TEXT NOT NULL
)
"""
)
cur.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_responses_keyword_page
ON responses(keyword, page)
"""
)
con.commit()
con.close()
except Exception:
pass
def _save_raw_response(db_path: str, keyword: str, page: int, raw_payload: str) -> None:
"""Persist raw HTTP response payload into SQLite without modification.
Args:
db_path (str): Path to SQLite database file.
keyword (str): Search keyword.
page (int): Page number for the response.
raw_payload (str): Raw JSON text as received.
Returns:
None
"""
try:
con = sqlite3.connect(db_path)
cur = con.cursor()
cur.execute(
"INSERT OR IGNORE INTO responses(keyword, page, created_at, payload) VALUES(?, ?, ?, ?)",
(keyword, int(page), int(time.time()), raw_payload),
)
con.commit()
con.close()
except Exception:
pass
def _has_page_record(db_path: str, keyword: str, page: int) -> bool:
"""Check if the given keyword+page already exists in SQLite."""
try:
con = sqlite3.connect(db_path)
cur = con.cursor()
cur.execute("SELECT 1 FROM responses WHERE keyword=? AND page=? LIMIT 1", (keyword, int(page)))
row = cur.fetchone()
con.close()
return row is not None
except Exception:
return False
def _make_item_key(it: Any) -> str:
"""Return a stable deduplication key for an item."""
try:
if isinstance(it, dict):
for k in ("jobId", "id", "job_id", "positionId"):
v = it.get(k)
if v is not None:
return f"id:{v}"
return "hash:" + json.dumps(it, ensure_ascii=False, sort_keys=True)
return "val:" + str(it)
except Exception:
return "val:" + str(it)
def _sleep_between_requests(min_seconds: float = 1.0, max_seconds: float = 3.0) -> None:
"""Sleep for a random duration between min_seconds and max_seconds.
Args:
min_seconds (float): Minimum seconds to sleep.
max_seconds (float): Maximum seconds to sleep.
Returns:
None
"""
try:
dur = random.uniform(min_seconds, max_seconds)
time.sleep(dur)
except Exception:
time.sleep(min_seconds)
def _record_company_success(company_name: Optional[str]) -> None:
"""Append successful company name to success log file once per process.
Args:
company_name (Optional[str]): Company name string.
Returns:
None
"""
try:
name = (company_name or "").strip()
if not name or name in _SUCCESS_WRITTEN:
return
with open(SUCCESS_LOG_PATH, "a", encoding="utf-8") as f:
f.write(f"{name}\n")
_SUCCESS_WRITTEN.add(name)
except Exception:
pass
def _extract_company_name(info: Dict[str, Any]) -> Optional[str]:
"""Extract company name from company info payload across common keys.
Args:
info (Dict[str, Any]): Company info dict.
Returns:
Optional[str]: Company name when found.
"""
keys = (
"coname",
"coName",
"fullCompanyName",
"companyName",
"fullname",
"name",
)
for k in keys:
v = info.get(k)
if isinstance(v, str) and v.strip():
return v.strip()
for parent in ("basicinfo", "basicInfo"):
node = info.get(parent)
if isinstance(node, dict):
for k in keys:
v = node.get(k)
if isinstance(v, str) and v.strip():
return v.strip()
return None
def _report_universal(items: list, data_type: str = "job") -> bool:
"""Post items list to universal batch-store-async endpoint.
Args:
items (list): Data list to send.
data_type (str): Logical data type label.
Returns:
bool: True when accepted, else False.
"""
return False
def _extract_total_count(resp: Dict[str, Any]) -> Optional[int]:
"""直接从响应中读取 job.totalCount 字段。
Args:
resp (Dict[str, Any]): 解析后的响应 JSON。
Returns:
Optional[int]: 总数,若不存在则为 None。
"""
if not isinstance(resp, dict):
return None
def _direct_get(path: tuple) -> Optional[int]:
node: Any = resp
for key in path:
if not isinstance(node, dict):
return None
node = node.get(key)
if isinstance(node, dict):
tc = node.get("totalCount")
if isinstance(tc, int):
return tc
if isinstance(tc, str):
s = tc.strip()
if s.isdigit():
return int(s)
return None
for p in (
("resultbody", "job"),
("resultBody", "job"),
("job",),
("jobs",),
("result", "job"),
("data", "job"),
("payload", "job"),
):
v = _direct_get(p)
if isinstance(v, int):
return v
return None
def paginate_search_company_keyword(
keyword: str,
size: int = 20,
job_area: str = "020000",
sort_type: str = "0",
search_type: str = "2",
scene: str = "12",
start_page: int = 1,
max_pages: Optional[int] = None,
delay: float = 0.2,
verbose: bool = False,
db_path: Optional[str] = None,
) -> list:
"""Iterate pages for company keyword search until no data.
Args:
keyword (str): Keyword to search.
size (int): Page size per request.
job_area (str): Area code.
sort_type (str): Sort type.
search_type (str): Search type.
scene (str): Scene id.
start_page (int): Starting page number.
max_pages (Optional[int]): Maximum pages to fetch; None for unlimited.
delay (float): Delay seconds between requests.
verbose (bool): Whether to print per-page stats.
Returns:
list: Aggregated items across pages.
"""
results: list = []
seen_keys: set = set()
page = start_page
fetched_pages = 0
total_count: Optional[int] = None
db_path = db_path or _get_db_path()
_init_db(db_path)
while True:
if max_pages is not None and fetched_pages >= max_pages:
break
if verbose:
print(json.dumps({"fetching_page": page}, ensure_ascii=False))
# Skip crawling when this page is already recorded
if _has_page_record(db_path, keyword, page):
if verbose:
print(json.dumps({"page": page, "skipped": True}, ensure_ascii=False))
page += 1
fetched_pages += 1
ms = delay if delay > 0 else 0.2
mx = ms * 2
_sleep_between_requests(ms, mx)
continue
def _sink(raw: str) -> None:
_save_raw_response(db_path, keyword, page, raw)
resp = search_company_keyword(
keyword=keyword,
page=page,
size=size,
job_area=job_area,
sort_type=sort_type,
search_type=search_type,
scene=scene,
raw_sink=_sink,
)
if verbose:
print(json.dumps({"page": page, "saved": True}, ensure_ascii=False))
if total_count is None:
total_count = _extract_total_count(resp)
if verbose and total_count is not None:
print(json.dumps({"totalCount": total_count}, ensure_ascii=False))
items = _extract_items(resp)
filtered: list = []
for it in items:
key = _make_item_key(it)
if key in seen_keys:
continue
seen_keys.add(key)
filtered.append(it)
if verbose:
print(json.dumps({"page": page, "items_on_page": len(items), "unique_added": len(filtered)}, ensure_ascii=False))
if not filtered:
break
results.extend(filtered)
if total_count is not None:
if len(results) >= total_count:
break
page += 1
fetched_pages += 1
ms = delay if delay > 0 else 0.2
mx = ms * 2
_sleep_between_requests(ms, mx)
return results
# Static configuration for pagination demo
CONFIG: Dict[str, Any] = {
"keyword": "字节跳动",
"size": 20,
"job_area": "020000",
"sort_type": "0",
"search_type": "2",
"scene": "12",
"start_page": 1,
"max_pages": None,
"delay": 0.2,
"verbose": False,
"db_path": None,
}
def main(keyword: str) -> None:
"""Run a demo of keyword search pagination until no data using static config.
Returns:
None
"""
cfg = CONFIG
results = paginate_search_company_keyword(
keyword=keyword,
size=cfg["size"],
job_area=cfg["job_area"],
sort_type=cfg["sort_type"],
search_type=cfg["search_type"],
scene=cfg["scene"],
start_page=cfg["start_page"],
max_pages=cfg["max_pages"],
delay=cfg["delay"],
verbose=cfg["verbose"],
db_path=cfg["db_path"],
)
print(json.dumps({"total_items": len(results)}, ensure_ascii=False))
def get_company_info(company_id: str) -> Dict[str, Any]:
"""Fetch company details with caching.
Args:
company_id (str): Company identifier string.
Returns:
Dict[str, Any]: Company information dict; empty dict when not found.
"""
if not company_id:
return {}
cached = COMPANY_INFO_CACHE.get(company_id)
if isinstance(cached, dict) and cached:
return cached
params = {
"companyId": company_id,
"colorOne": "#ffffff",
"colorTwo": "#ffffffcc",
}
property_obj = {
"frompageUrl": "",
"pageUrl": "",
"isLogin": "",
"accountid": "",
"resumeId": "",
"firstFrompageUrl": "",
"distinct_id": str(_uuid.uuid4()),
"pageCode": "companyDetail|company|companyinfo",
"shortPageCode": "companyDetail|company|companyinfo",
}
try:
resp = _request(
method="GET",
path="open/noauth/company-info/info-data",
params=params,
property_obj=property_obj,
)
except Exception:
resp = {}
if resp and resp.get("status") in (1, "1"):
info = resp.get("resultbody", {})
if isinstance(info, dict) and info:
COMPANY_INFO_CACHE[company_id] = info
name = _extract_company_name(info)
_record_company_success(name)
return info
return {}
return {}
def _enrich_items_with_company_info(resp: Dict[str, Any]) -> list:
"""Attach company info to items using cache.
Args:
resp (Dict[str, Any]): Parsed JSON response.
Returns:
list: Items with company_info fields when available.
"""
items = resp.get("resultbody", {}).get("job", {}).get("items", [])
enriched = []
for it in items:
target = dict(it) if isinstance(it, dict) else {"_value": it}
co_id = target.get("coId") or target.get("companyId")
job_id = target.get("jobId")
city_pinyin = target.get("hrefAreaPinYin")
if co_id:
info = get_company_info(str(co_id))
if info:
target["company_info"] = info
target["company_desc"] = (info.get("coinfo", {}) or {}).get("coinfo")
target["companyHref"] = (info.get("share", {}) or {}).get("weixinshareurl")
target["jobHref"] = f"https://jobs.51job.com/{city_pinyin}/{job_id}.html"
nm = _extract_company_name(info) or target.get("fullCompanyName") or target.get("companyName")
_record_company_success(nm)
_sleep_between_requests()
enriched.append(target)
return enriched
def _load_keywords(path: str) -> list:
"""Load keywords from a UTF-8 text file, one per line.
Args:
path (str): File path.
Returns:
list: Non-empty trimmed lines.
"""
try:
with open(path, "r", encoding="utf-8") as f:
lines = [ln.strip() for ln in f.readlines()]
return [ln for ln in lines if ln]
except Exception:
return []
def _progress_iter(seq: list, desc: str = "", total: Optional[int] = None):
"""Iterate with a simple console progress bar.
Args:
seq (list): Items to iterate.
desc (str): Progress description.
total (Optional[int]): Total count for percentage.
Yields:
Any: Items from seq.
"""
n = 0
m = total if total is not None else len(seq)
bar_len = 24
for item in seq:
n += 1
filled = int(bar_len * n / m) if m else 0
bar = "#" * filled + "-" * (bar_len - filled)
pct = int(100 * n / m) if m else 100
print(f"\r{desc} [{bar}] {n}/{m} {pct}%", end="", flush=True)
yield item
print("", flush=True)
if __name__ == "__main__":
base_dir = os.path.dirname(__file__)
fp = os.path.join(base_dir, "company.txt")
td = _load_keywords(fp) or [CONFIG.get("keyword")]
for keyword in _progress_iter(td, desc="Keywords", total=len(td)):
print(keyword)
main(keyword)
_sleep_between_requests()