spider_sch.py

spider_sch.py — 学校官网爬虫

核心类 SpiderSch,负责从高校招聘页爬取数据。

流程:

  1. 读取 INI 配置文件(setting_sch_*.ini),加载每所学校的URL、CSS选择器、解析函数等参数
  2. run() → 逐个学校打开首页,调用 get_page_data() 爬取列表页
  3. get_page_data() → 用 Playwright 渲染页面,提取列表 HTML,调用自动生成的解析函数(auto_gen.gen.*)解析成 JSON
  4. get_page_detail_data() → 逐条访问详情页,处理各种边界情况(无链接时点击、相对路径、外部跳转、iframe、微信链接等)
  5. 详情页内容和元数据分别保存为 .html.json 文件

亮点:

  • 支持进度持久化(progress_*.txt),程序中断后从断点继续
  • 标题过滤:用正则黑名单文件剔除无关公告
  • 日期过滤:只爬取180天内的公告
  • 多种异常兜底:selector找不到时有备选 selector、iframe 穿透、微信链接特殊处理

代码

# -*- coding: utf-8 -*-
"""
学校爬虫核心类:SpiderSch
功能:根据配置文件自动爬取学校招聘/公告信息
流程:打开列表页 → 解析标题/时间/链接 → 进入详情页 → 提取内容 → 保存HTML/JSON
支持:断点续爬、标题过滤、iframe解析、跳转链接、微信文章、BS4提取、防重复爬取
"""
import os, hashlib, json, time
import configparser
import glob
import requests
import re
from collections import defaultdict

# 自动生成的函数调用工具
from auto_gen.func_call import call_func, execute_page_action, execute_index_action
# 工具类:日志、URL处理、文本校验、时间判断、随机数、HTML清洗等
from utils import ner_logger, get_long_url_domain
from utils_date import is_near_month
from utils import get_final_url, check_contact, check_url_type, get_random_number
from utils import is_wechat_url
from utils_html import clean_html, find_wx_url
from utils_html import get_directory_from_url
from utils_resume import remove_announcement_word
from utils_playwright import click_by_text_and_get_url, get_iframe_urls, get_redirect_url
from utils_bs4 import get_node_text

# ===================== 全局常量配置 =====================
# 临时爬取进度文件(断点续爬用)
TMP_PROGRESS_FILE = "data/progress.txt"
# 首页加载停顿时间(防止访问过快)
FIRST_PAUSE_TIME = 10
# 页面超时时间(毫秒):60秒
PAGE_TIMEOUT = 60000
# 默认函数执行包路径
DEFAULT_FUNC_PACKAGE = "auto_gen.gen"

# 配置文件节点名称
DEFAULT_COMMON = "Common"
DEFAULT_TEMPLATE = "Template"
DEFAULT_SCH = "School"


class SpiderSch():
    """学校招聘/公告爬虫核心类"""

    def __init__(self, _file="99"):
        """
        初始化爬虫
        :param _file: 配置文件编号,如 setting_sch_10.ini
        """
        # 浏览器实例(外部传入)
        self.browser = None
        # 配置文件编号
        self.file = _file
        # 配置读取器
        self.config = configparser.ConfigParser()

        # 按优先级加载配置文件(后面覆盖前面)
        self.config.read("data/setting_default.ini", encoding="utf-8")    # 默认配置
        self.config.read("data/setting_template.ini", encoding="utf-8")   # 模板配置
        self.config.read(f"data/setting_sch_{_file}.ini", encoding="utf-8") # 学校专属配置

        # 标题必须包含的关键词(|分隔)
        self.title_includes = self.config.get(DEFAULT_COMMON, "title_include").split("|")
        # 标题排除关键词(从黑名单文件读取)
        with open("data/black_wx_exclude_title.txt", encoding="utf-8") as f:
            _wxlist = f.read().splitlines()
            self.title_excludes = list(set(_wxlist))  # 去重

        # 爬取进度列表(断点续爬)
        self.progress_list = []
        if os.path.exists(self.get_progress_file()):
            with open(self.get_progress_file(), "r", encoding="utf-8") as f:
                for line in f.readlines():
                    if line.strip():
                        self.progress_list = [line.strip()]

        # 初始化临时文件保存目录
        TMP_DIR = self.get_savepath()
        ner_logger.info(f"临时目录:{TMP_DIR}")

    def get_progress_file(self):
        """获取当前配置对应的进度文件路径"""
        return f"data/progress_{self.file}.txt"

    def print_all_sch(self):
        """
        打印所有已配置的学校名称(用于调试/核对)
        遍历0-99号配置文件,汇总所有学校并按出现次数展示
        """
        config = configparser.ConfigParser()
        config.read("data/setting_default.ini", encoding="utf-8")
        printdict = defaultdict(list)

        # 加载所有配置文件
        for i in range(0, 100):
            _confilefile = f"data/setting_sch_{i}.ini"
            if os.path.exists(_confilefile):
                config.read(_confilefile, encoding="utf-8")

        # 解析所有学校节点
        for _key in config.options(DEFAULT_SCH):
            if _key.startswith("sch_"):
                _svalue = config.get(DEFAULT_SCH, _key)
                _value = json.loads(_svalue)
                for _sch_info in _value:
                    sch = _sch_info.get("sch_name")
                    sch_webname = _sch_info.get("sch_webname")
                    printdict[sch].append(sch_webname)

        # 打印只出现一次的学校
        for _key in printdict.keys():
            if len(printdict[_key]) == 1:
                print(f'{_key} : {" ".join(printdict[_key])}')

        print("-" * 20)
        # 打印出现多次的学校
        for _key in printdict.keys():
            if len(printdict[_key]) > 1:
                print(f'{_key} : {" ".join(printdict[_key])}')

    def is_title_include(self, title):
        """
        标题过滤:判断是否需要爬取
        逻辑:排除黑名单关键词 → 允许爬取
        """
        _title = remove_announcement_word(title.strip())
        for _blkre in self.title_excludes:
            if _blkre.strip() == "":
                continue
            pattern = re.compile(_blkre)
            if re.findall(pattern, _title):
                ner_logger.info(f"招聘公告的标题不符合要求,被过滤掉 {title} : {_blkre}")
                return False
        return True

    def get_other_type(self):
        """获取微信等外部页面样式配置"""
        return self.config.get(DEFAULT_COMMON, "weixin_style")

    def is_external_link(self, domain, _fullurl):
        """
        判断是否是外部链接
        :return: True=外部链接 False=本站链接
        """
        # 同域名 → 内部
        if _fullurl.startswith(domain):
            return False
        # 微信链接 → 视为内部
        if 'mp.weixin.qq.com' in _fullurl:
            return False
        return True

    def get_browser_path(self):
        """根据操作系统获取浏览器路径"""
        tmp_dir = ""
        if os.name == "nt":  # Windows
            tmp_dir = self.config.get(DEFAULT_COMMON, "browser_path_win")
        if os.name == "posix":  # Mac/Linux
            tmp_dir = self.config.get(DEFAULT_COMMON, "browser_path_mac")

        if not os.path.exists(tmp_dir):
            os.makedirs(tmp_dir)
        return tmp_dir

    def get_savepath(self, _tmp="/data/tmp"):
        """获取数据保存根目录"""
        if os.name == "nt":
            return self.config.get(DEFAULT_COMMON, "savepath_win") + _tmp
        if os.name == "posix":
            return self.config.get(DEFAULT_COMMON, "savepath_mac") + _tmp
        return ""

    def get_md_exe(self):
        """获取Markdown转换工具路径(Windows/Mac)"""
        if os.name == "nt":
            return self.config.get(DEFAULT_COMMON, "md_path_win")
        if os.name == "posix":
            return self.config.get(DEFAULT_COMMON, "md_path_mac")
        return ""

    def get_html_md_exe(self):
        """获取HTML转MD工具路径"""
        if os.name == "nt":
            return self.config.get(DEFAULT_COMMON, "html_md_path_win")
        if os.name == "posix":
            return self.config.get(DEFAULT_COMMON, "html_md_path_mac")
        return ""

    def write_process_file(self, line):
        """
        写入进度文件(断点续爬)
        每次爬完一个学校就写入,下次从这里继续
        """
        with open(self.get_progress_file(), "w", encoding="utf-8") as f:
            f.write(line)
            f.write("\n")
        if not line in self.progress_list:
            self.progress_list = [line]

    def get_progress(self):
        """
        获取爬取进度(断点续爬核心)
        :return: 待爬取学校列表
        """
        _finish = []
        _remain = []
        _find = False

        for _key, _node in self.get_nodes().items():
            if _key in self.progress_list:
                _finish.append(_key)
                _find = True
            elif _find:
                _remain.append(_key)
            else:
                _finish.append(_key)

        # 输出进度日志
        if len(_remain) > 0 and (not 'sch_88888' in _remain or len(_remain) > 1):
            _msg = "\n".join(_remain)
            ner_logger.info(f'剩余进度 {len(_remain)/len(self.get_nodes()):.0%},人工启动重新跑\n{_msg}')
            return _remain
        elif len(_finish) > 0 and (not 'sch_88888' in _finish or len(_finish) > 1):
            _msg = "\n".join(_finish)
            ner_logger.info(f'重新进度 {len(_finish)/len(self.get_nodes()):.0%},人工启动重新跑\n{_msg}')
            return _finish
        return _finish + _remain

    def get_nodes(self):
        """
        从配置中读取所有学校节点
        格式:sch_00001、sch_00002...
        """
        nodes = {}
        for i in range(1, 100000):
            num = str(i).zfill(5)  # 转为5位数字
            _key = f"sch_{num}"
            if self.config.has_option(DEFAULT_SCH, _key):
                _svalue = self.config.get(DEFAULT_SCH, _key)
                _value = json.loads(_svalue)
                # 补充模板字段
                self.supplement_node_info(_value)
                nodes[_key] = _value
        return nodes

    def supplement_node_info(self, _node):
        """
        从模板配置中补充缺失的字段
        如选择器、函数名、点击方式等
        """
        for _sch_info in _node:
            _template = _sch_info.get("template")
            if _template:
                _tv = self.config.get(DEFAULT_TEMPLATE, _template)
                _tvjson = json.loads(_tv)
                for _key, _va in _tvjson.items():
                    if not _key in _sch_info:
                        _sch_info[_key] = _va

    def get_full_url(self, domain, _link):
        """拼接完整URL(处理相对路径)"""
        if _link.startswith("http"):
            return _link
        if _link.startswith("/"):
            return f'{domain}{_link}'
        return f'{domain}/{_link}'

    def get_key_dir(self, _key):
        """为每个学校创建独立临时目录"""
        TMP_DIR = self.get_savepath()
        key_tmp_dir = f"{TMP_DIR}/{_key}"
        if not os.path.exists(key_tmp_dir):
            os.makedirs(key_tmp_dir)
        return key_tmp_dir

    def get_selector_text(self, page, sch_info, selector1, selector2, style3=""):
        """
        智能获取页面选择器(容错机制)
        1. 先尝试主选择器 selector1
        2. 失败则尝试备选列表 selector2
        3. 最后尝试通用样式 style3
        """
        html = page.content()
        table_selector = sch_info.get(selector1)
        style_element = page.query_selector(table_selector)

        if not style_element:
            table_selectors = sch_info.get(selector2)
            if table_selectors:
                table_selectors = table_selectors + style3
            elif style3:
                table_selectors = style3

            if table_selectors:
                for _selector in table_selectors.split("|"):
                    if not _selector.strip():
                        continue
                    ner_logger.info(f"尝试使用 {_selector}")
                    style_element = page.query_selector(_selector)
                    if style_element:
                        table_selector = _selector
                        break

        if style_element:
            ner_logger.info(f"找到元素 {table_selector}")
            return True, table_selector

        ner_logger.info(f"没有找到元素在页面内容里面:\n{html}")
        ner_logger.info(f"没有找到元素 {table_selector}")
        return False, table_selector

    def open_with_url(self, page, url, refer=""):
        """
        安全打开页面
        支持:防盗链Refer、状态码判断、加载等待、异常捕获
        """
        try:
            if refer:
                page.set_extra_http_headers({"Referer": refer})

            response = page.goto(url, timeout=PAGE_TIMEOUT)
            if response:
                status = response.status
                # 200/412都视为成功(部分学校特殊返回412)
                if status in [20, 412]:
                    try:
                        page.wait_for_load_state('load', timeout=PAGE_TIMEOUT)
                        page.wait_for_load_state('networkidle', timeout=120000)
                    except Exception as e:
                        ner_logger.info(f"尝试打开url时出错 networkidle: {e}")
                    time.sleep(3)
                    return True
                else:
                    ner_logger.debug(f"页面状态码错误 {status}")
            elif page.url == url:
                ner_logger.debug(f"无返回值,默认打开成功:{url}")
                time.sleep(3)
                return True
        except Exception as e:
            ner_logger.debug(f"操作超时 {e}")

        ner_logger.debug(f"页面存在问题 {url}")
        return False

    def pre_page_run(self, page, sch_info):
        """
        页面前置任务:进入列表后执行自定义JS操作
        如点击加载更多、关闭弹窗等
        """
        func_package = DEFAULT_FUNC_PACKAGE
        table_func_name = sch_info.get("table_func_name")
        if table_func_name:
            package_func_name = f"{func_package}.{table_func_name}"
            execute_page_action(package_func_name, page)
            ner_logger.info(f"执行前置任务 {package_func_name}")

    def get_index_list(self, page, sch_info):
        """
        获取列表页URL
        支持:固定配置 / 动态函数获取
        """
        if 'index_url_func' in sch_info:
            _ok = self.open_with_url(page, sch_info.get("urls").get('k1'))
            if not _ok:
                return []

            func_name = sch_info.get("index_url_func")
            func_package = DEFAULT_FUNC_PACKAGE
            package_func_name = f"{func_package}.{func_name}"
            urls = execute_index_action(package_func_name, page, sch_info)
            ner_logger.info(f"执行获取index_url_func\n{urls}")
            return urls
        else:
            return sch_info.get("urls")

    def run(self, page, _key, sch_info, _stat={}):
        """
        单个学校爬虫入口
        遍历所有列表页 → 爬取数据 → 记录进度
        """
        _ret_list = []
        sch_name = sch_info.get("sch_name")
        sch_webname = sch_info.get("sch_webname")
        print(f"爬取学校 {sch_name} - {sch_webname}")

        # 获取所有列表URL
        urls = self.get_index_list(page, sch_info)
        for i, k in enumerate(urls):
            url = urls.get(k)

            # 预先打开域名首页(降低反爬概率)
            pre_open_url = sch_info.get("pre_open_url")
            if pre_open_url:
                _ok = self.open_with_url(page, pre_open_url)
                if not _ok:
                    ner_logger.info(f"预先打开页面失败 {pre_open_url}")
                    return False
                time.sleep(FIRST_PAUSE_TIME)
            else:
                domain = get_long_url_domain(url)
                if domain:
                    _ok = self.open_with_url(page, domain[0])
                    if not _ok:
                        return False
                    time.sleep(FIRST_PAUSE_TIME)

            # 爬取当前列表页数据
            _list = self.get_page_data(page, _key, sch_info, url)
            if False in _list:
                _ret_list.append(False)
            else:
                _ret_list.append(True)

            time.sleep(FIRST_PAUSE_TIME)

        # 写入进度:该学校爬取完成
        self.write_process_file(_key)

    def get_page_data(self, page, _key, sch_info, url):
        """
        爬取列表页数据
        打开 → 提取列表 → 调用解析函数 → 遍历详情
        """
        _ret_list = []
        ner_logger.info(f"开始爬取链接:{_key} / {url}")

        # 打开列表页
        _ok = self.open_with_url(page, url)
        if not _ok:
            ner_logger.info(f"页面打开失败,跳过 {url}")
            _ret_list.append(False)
            return _ret_list

        # 获取列表选择器
        _ok, table_selector = self.get_selector_text(page, sch_info, "table_selector", "table_selectors")
        if not _ok:
            ner_logger.error(f"列表页面无匹配元素,需人工处理:{table_selector}")
            _ret_list.append(False)
            return _ret_list

        # 执行前置操作
        self.pre_page_run(page, sch_info)

        # 定位列表元素
        tableObj = page.locator(table_selector)
        if tableObj.count() > 1:
            # 取第一个可见元素
            for i in range(tableObj.count()):
                _tableObj = tableObj.nth(i)
                if _tableObj.is_visible():
                    tableObj = _tableObj
                    break
        if tableObj.count() > 1:
            tableObj = tableObj.nth(0)

        # 保存列表HTML
        key_tmp_dir = self.get_key_dir(_key)
        _hash = hashlib.md5(url.encode("utf-8")).hexdigest()
        tmp_file = os.path.join(key_tmp_dir, f"index_{_hash}.html")
        _context_outtext = tableObj.inner_html()

        with open(tmp_file, "w", encoding="utf-8") as f:
            f.write(f"<div>{_context_outtext}</div>")

        # 调用解析函数生成JSON
        func_package = DEFAULT_FUNC_PACKAGE
        func_name = sch_info.get("func_name")
        package_func_name = f"{func_package}.{func_name}"
        tmp_fname = f'{key_tmp_dir}/index_{_hash}.json'

        _ok = call_func(package_func_name, _context_outtext, tmp_fname)
        if not _ok:
            ner_logger.info(f"列表解析失败,跳过 {url}")
            _ret_list.append(False)
            return _ret_list

        time.sleep(FIRST_PAUSE_TIME)

        # 遍历每条公告,进入详情页
        with open(tmp_fname, "r", encoding="utf-8") as f:
            _data = json.load(f)
            for _item in _data:
                _ok = self.get_page_detail_data(page, _key, url, key_tmp_dir, sch_info, _item)
                time.sleep(get_random_number())
                _ret_list.append(_ok)

        return _ret_list

    def get_page_detail_data(self, page, _key, url, key_tmp_dir, sch_info, _item):
        """
        爬取详情页数据(核心)
        逻辑:
        1. 时间过滤(180天内)
        2. 标题过滤
        3. 智能获取真实链接(点击/相对路径/JS)
        4. 去重爬取
        5. 保存HTML+JSON
        """
        # 时间过滤:只爬取近180天
        _publish_time = _item.get("publish_time")
        _item_title = _item.get("announcement_name")
        if _publish_time and not is_near_month(_publish_time):
            ner_logger.info(f"过期跳过 {_publish_time} | {_item_title}")
            return True

        # 标题黑名单过滤
        if not self.is_title_include(_item_title):
            return True

        # 获取链接
        _link = _item.get("link")
        _click_text = sch_info.get("click_text")
        _click_type = sch_info.get("click_type")

        # 处理JS链接
        if _link and _link.startswith(""):
            _link = ""
        # 处理相对路径 ./
        elif _link and _link.startswith("./"):
            _url_path = get_directory_from_url(url)
            _link = _link.replace("./", _url_path)

        # 无链接 → 通过点击标题获取
        if not _link or _click_text == 'Y':
            _text = _item_title
            _hash = hashlib.md5(_text.encode("utf-8")).hexdigest()
            tmp_file = os.path.join(key_tmp_dir, f"detail_{_hash}.url")

            # 从缓存读取
            if os.path.exists(tmp_file):
                with open(tmp_file, "r", encoding="utf-8") as f:
                    _link = f.read()
                    ner_logger.info(f"从缓存读取链接 {_link}")

            # 缓存无则模拟点击
            if not _link or _click_text == 'Y':
                try:
                    page.goto(url)
                except Exception as e:
                    return False
                time.sleep(get_random_number())
                self.pre_page_run(page, sch_info)

                # 点击标题获取真实URL
                new_url, content = click_by_text_and_get_url(page, url, _text, _click_type)
                if new_url:
                    _link = new_url
                    with open(tmp_file, "w", encoding="utf-8") as f:
                        f.write(_link)
                    ner_logger.info(f"点击获取链接 {_link}")

            if not _link:
                return False

        # 拼接完整URL
        domain = sch_info.get("json_domain")
        _fullurl = self.get_full_url(domain, _link)
        _final_link = get_final_url(_fullurl)
        if _fullurl != _final_link:
            _fullurl = _final_link

        # 文件去重:已存在则跳过
        _hash = hashlib.md5(_fullurl.encode("utf-8")).hexdigest()
        tmp_file = os.path.join(key_tmp_dir, f"detail_{_hash}.html")
        tmp_json_file = os.path.join(key_tmp_dir, f"detail_{_hash}.json")

        if os.path.exists(tmp_file) and os.path.exists(tmp_json_file):
            print(f"已存在,跳过 {_fullurl}")
            return True

        # 获取详情内容
        _ok, _context_outtext, _context_full_outtext = self.get_page_detail_content(
            page, sch_info, domain, _fullurl, refer=url
        )
        if not _ok or not _context_outtext:
            return False

        # 最终真实URL
        _last_url = page.url

        # 内容过短 → 尝试查找微信文章
        if len(_context_outtext) < 400 or 'search_wx_file' in sch_info:
            _search_wx = sch_info.get("search_wx_file", "N")
            _ok, wx_url = find_wx_url(_context_outtext, _search_wx)
            if _ok:
                _last_url = wx_url

        # 保存文件
        with open(tmp_file, "w", encoding="utf-8") as f:
            f.write(_context_outtext)
        with open(tmp_file + ".full", "w", encoding="utf-8") as f:
            f.write(_context_full_outtext)

        # 保存JSON元数据
        _item['full_url'] = _fullurl
        _item['last_url'] = _last_url
        _item['file_path'] = tmp_file
        _item['contact'] = check_contact(_context_outtext)
        _item['parent_url'] = url
        _item['channel'] = _key
        _item['type_url'] = check_url_type(_fullurl, _last_url)

        with open(tmp_json_file, "w", encoding="utf-8") as f:
            f.write(json.dumps(_item, ensure_ascii=False))

        time.sleep(get_random_number())
        return True

    def get_page_detail_content(self, page, sch_info, domain, _fullurl, refer="", noiframe=True):
        """
        获取详情页正文内容
        支持:
        1. 普通页面选择器
        2. iframe嵌套
        3. 微信文章
        4. BS4解析
        5. 302跳转
        """
        _context_outtext = ""
        _context_full_outtext = ""

        # 打开页面
        _ok = self.open_with_url(page, _fullurl, refer)
        if not _ok:
            return False, "", ""

        # 微信文章直接返回全文
        if is_wechat_url(_fullurl):
            return True, page.content(), page.content()

        # 使用BS4解析
        if sch_info.get('use_bs4'):
            html = get_node_text(_fullurl, sch_info['use_bs4'])
            return True, html, html

        # 处理页面跳转
        if 'redirect_url' in sch_info:
            _ok, jump_url = get_redirect_url(page)
            if _ok and noiframe:
                return self.get_page_detail_content(page, sch_info, domain, jump_url, refer, False)

        # 清洗全文HTML
        _context_full_outtext = clean_html(page.content())

        # 获取正文选择器
        _style3 = self.get_other_type()
        _ok, detail_selector = self.get_selector_text(
            page, sch_info, "detail_selector", "detail_selectors", _style3
        )

        if _ok:
            detailObj = page.locator(detail_selector)
            if detailObj.count() != 1:
                return False, "", _context_full_outtext
            html = detailObj.inner_html()
            return True, f"<div>{html}</div>", _context_full_outtext

        # 处理iframe
        iframe_selector = sch_info.get("detail_iframe", "")
        iurls = get_iframe_urls(page, iframe_selector)
        if noiframe and iurls:
            return self.get_page_detail_content(page, sch_info, domain, iurls[0], refer, False)

        # 外部链接直接返回全文
        if self.is_external_link(domain, _fullurl):
            return True, _context_full_outtext, _context_full_outtext

        ner_logger.error(f"详情页无匹配元素,需人工处理:{detail_selector}")
        return False, "", _context_full_outtext

项目分区导航spider_data.py ⬅️ | 04-spider_sch.py | ➡️ parsegpt