from pathlib import Path import re import sys import time from urllib.parse import urljoin, urlsplit try: import tomllib except ModuleNotFoundError: import tomli as tomllib from common.content import sanitize_body_text from common.fetcher import PoliteFetcher from common.protocol import ( AnnouncementItem, AttachmentItem, CollectorMeta, IngestPayload, ) from common.security import is_safe_url from selectolax.lexbor import LexborHTMLParser def crawl_channel( channel_cfg: dict, site_cfg: dict, template_cfg: dict, max_items: int = 10, ) -> IngestPayload: source_id = channel_cfg["source_id"] name = channel_cfg["name"] province = site_cfg.get("province", "") city = site_cfg.get("city", "") start_url = channel_cfg["start_url"] start_host = urlsplit(start_url).netloc.split(":")[0].lower() allowed_domains = channel_cfg.get( "allowed_domains", site_cfg.get("allowed_domains", [start_host]), ) item_selector = channel_cfg.get( "item_selector", template_cfg.get("item_selector", "a") ) url_pattern = channel_cfg.get( "url_pattern", template_cfg.get("url_pattern") ) keywords = channel_cfg.get( "keywords", template_cfg.get( "keywords", ["招聘", "招考", "公告", "公示", "选拔", "录用", "选调"] ), ) title_selectors = channel_cfg.get( "title_selector", template_cfg.get("title_selector", "h1, h2, title"), ) body_selectors = channel_cfg.get( "body_selector", template_cfg.get( "body_selector", "div#content, div.content, div.article, div.article-content, div.con", ), ) date_regex = channel_cfg.get( "date_regex", template_cfg.get("date_regex", r"(\d{4}[-/年]\d{1,2}[-/月]\d{1,2})"), ) att_extensions = channel_cfg.get( "attachment_extensions", template_cfg.get( "attachment_extensions", [".xlsx", ".xls", ".pdf", ".docx", ".doc"], ), ) fetcher = PoliteFetcher() start_time = time.time() error_msg = None items: list[AnnouncementItem] = [] try: html = fetcher.get_text(start_url, allowed_domains, start_host) tree = LexborHTMLParser(html) candidates = [] for elem in tree.css(item_selector): text = elem.text(strip=True) href = elem.attributes.get("href", "").strip() if not text or not href or href.startswith("javascript:"): continue if url_pattern and not re.search(url_pattern, href): continue if keywords and not any(k in text for k in keywords): continue full_url = urljoin(start_url, href) if not is_safe_url(full_url, allowed_domains, start_host): continue candidates.append((text, full_url)) seen_urls = set() for i, (title, url) in enumerate(candidates): if url in seen_urls: continue seen_urls.add(url) if i > 0: time.sleep(0.3) body_text = None attachments = [] pub_date = None try: detail_html = fetcher.get_text(url, allowed_domains, start_host) detail_tree = LexborHTMLParser(detail_html) detail_tree.strip_tags(["script", "style", "noscript", "iframe", "svg"]) for crumb in detail_tree.css(".breadcrumb, .nav, .crumbs, .location, .pos, .head_top, .footer, .header"): crumb.decompose() title_node = detail_tree.css_first(title_selectors) if title_node: parsed_title = title_node.text(strip=True) site_name = site_cfg.get("name", "") if ( len(parsed_title) >= 6 and parsed_title != site_name and not any(k == parsed_title for k in [site_name, "人才网", "人民政府", "人事考试网", "人事考试中心"]) ): title = parsed_title body_node = detail_tree.css_first(body_selectors) if body_node: extracted = body_node.text(strip=True) extracted = re.sub( r"^(?:首页|当前位置[::]\s*首页|您当前所在的位置[::]\s*首页)[^\n\r]{0,100}(?:>|»|/|·|正文|内容|详情)?\s*", "", extracted, ) body_text = sanitize_body_text(extracted) if body_text and date_regex: m = re.search(date_regex, body_text) if m: pub_date = ( m.group(1) .replace("年", "-") .replace("月", "-") .replace("/", "-") ) seen_att_urls = set() for att_a in detail_tree.css("a"): if len(attachments) >= 50: break att_href = att_a.attributes.get("href", "").strip() att_text = att_a.text(strip=True) if any(att_href.lower().endswith(ext) for ext in att_extensions): full_att_url = urljoin(url, att_href) if ( is_safe_url(full_att_url, allowed_domains, start_host) and full_att_url not in seen_att_urls ): seen_att_urls.add(full_att_url) attachments.append( AttachmentItem(name=att_text or "附件", url=full_att_url) ) except Exception: pass if not body_text and not attachments: continue items.append( AnnouncementItem( url=url, title=title, kind="招聘" if "公示" not in title else "拟聘用", employer=site_cfg.get("employer", f"{name} / 用人单位"), employer_type=site_cfg.get("employer_type", "事业单位"), published_at=pub_date, region_hint=f"{province} {city}".strip(), body_text=body_text, attachments=attachments, positions=[], ) ) if len(items) >= max_items: break except Exception as exc: error_msg = str(exc) finally: fetcher.close() elapsed_ms = int((time.time() - start_time) * 1000) meta = CollectorMeta( name=f"{name}_declarative", runtime="declarative/v1", auth_status="none", duration_ms=elapsed_ms, error_message=error_msg, ) return IngestPayload( source_id=source_id, batch_id=f"{source_id}-{int(time.time())}", collector=meta, items=items, ) def load_declarative_collectors( config_path: Path | str, base_dir: Path | None = None ) -> list[dict]: cfg_file = Path(config_path) with open(cfg_file, "rb") as f: cfg = tomllib.load(f) site_cfg = cfg.get("site", {}) template_cfg = cfg.get("template", {}) if not template_cfg: template_cfg = {**cfg.get("list", {}), **cfg.get("detail", {})} site_id = site_cfg.get("id", f"{cfg_file.parent.name}.{cfg_file.stem}") site_name = site_cfg.get("name", site_id) province = site_cfg.get("province", cfg_file.parent.name) city = site_cfg.get("city", "") site_tags = site_cfg.get("tags", []) channels = cfg.get("channels", []) if not channels and "start_url" in site_cfg: channels = [ { "id": "", "name": site_name, "start_url": site_cfg["start_url"], "tags": site_tags, } ] rel_file = ( str(cfg_file.relative_to(base_dir)) if base_dir else str(cfg_file) ) result = [] for ch in channels: ch_id = ch.get("id", "").strip() full_source_id = f"{site_id}.{ch_id}" if ch_id else site_id ch_name = ch.get("name", ch_id) full_name = f"{site_name} - {ch_name}" if ch_id and ch_name != site_name else site_name ch_tags = list(set(site_tags + ch.get("tags", []))) channel_entry = dict(ch) channel_entry["source_id"] = full_source_id channel_entry["name"] = full_name channel_entry["start_url"] = ch.get("start_url", site_cfg.get("start_url", "")) if not channel_entry["start_url"]: continue domain = urlsplit(channel_entry["start_url"]).netloc.lower() meta = { "source_id": full_source_id, "name": full_name, "province": province, "city": city, "tags": ch_tags, "domain": domain, } crawl_fn = ( lambda ch_e=channel_entry, s_c=site_cfg, t_c=template_cfg: ( lambda max_items=10: crawl_channel( ch_e, s_c, t_c, max_items=max_items ) ) )() result.append({ "meta": meta, "crawl_fn": crawl_fn, "file": rel_file, "type": "toml", "domain": domain, }) return result