--- title: "09-对照项目源码拆解——从配置到入库的完整链路" created: 2026-05-08 tags: - 项目 aliases: - 第 6 讲:对照项目源码拆解 —— 从配置到入库的完整链路 --- # 对照项目源码拆解——从配置到入库的完整链路 ## 第 6 讲:追踪一条职位数据的完整生命线 前五讲铺完了理论。这一讲,我们不再"概述模块",而是**死死咬住一条数据**,看着它从命令行启动到最终变成结构化 JSON 的全过程。每一步都对应到源码的具体行,每一处设计都解释为什么。 我们以企业职位(SpiderCom)的 DOM 抓取路径为主干线。这条路径最长、最复杂,覆盖了系统的大半核心机制。API 直连和 on\_response 路径会作为分支穿插讲解。 > 假设我们现在执行: > > ``` > python main.py -m cp_full -f 2 -d dev > ``` > > 目标:抓取第 2 号配置分片中所有企业的职位列表,再逐条抓取详情页。 --- ## 第一阶段:起航 —— 从命令行到一个浏览器窗口 ### 1.1 参数进了谁的口袋 `main.py` 第 18-28 行,`argparse` 把命令行参数解析成 `args` 对象: ```properties args.method = "cp_full" # -m args.file = "2" # -f args.dist = "dev" # -d args.proxy = "" # -p(未传,空) args.pagestart= "2" # -t(未传,默认 2) args.company = "" # -c(未传,空) ``` 第 315-337 行,`__main__` 块只做三件事: ```properties set_logger_debug(args.file) # ① 日志落盘到 log/log_2.txt s = SpiderSch(args.file) # ② 学校采集器(备用) cs = SpiderCom(args.file) # ③ 企业采集器(主角) d = SpiderData(s) # ④ 数据处理器(共享 s 的配置) run_periodically(s, cs, d) # ⑤ 进入调度 ``` **为什么** `SpiderData` **要接收** `SpiderSch` **而不是** `SpiderCom`**?** 因为 `SpiderData` 需要访问 INI 配置、浏览器路径、工具路径——这些都在 `SpiderSch` 的方法里。两个采集器共享同一套基础设施,没必要给 `SpiderData` 传两个参数。 ### 1.2 run\_periodically 的路由 第 151-177 行,`run_periodically` 先把参数打包进 `_stat` 字典: ```text _stat['method'] = "cp_full" _stat['page_start'] = 2 _stat['dist'] = "dev" _stat['retry'] = "1" _stat['p_count'] = 0 _stat['all_proc_list'] = [] ``` 这个 `_stat` 字典会贯穿整个调用链——它不是全局变量,而是作为参数逐层传递。**用字典而不是对象的原因**:字典可以随时加字段,不需要改任何函数签名。`_stat['method']`、`_stat['total']`、`_stat['pfile']` 这些字段是不同函数在不同阶段加进去的。 然后命中第 175-178 行的分支: ```text if args.method in ["cp", "cp_full"]: _stat['method'] = args.method clawler_main(cs, _stat) ``` ### 1.3 clawler\_main:浏览器的一生 第 32-71 行。这个函数的名字暴露了它的本质——它就是爬虫的"生命周期管理器"。 ```python def clawler_main(s, _stat=None): executable_path = s.get_browser_path() # ① 拿浏览器路径 with sync_playwright() as p: # ② 启动 Playwright browser = get_browser(p, executable_path, args.proxy) # ③ 创建浏览器实例 s.browser = browser # ④ 注入到爬虫对象 page = browser.new_page() # ⑤ 开一个标签页 nodes = s.get_nodes() # ⑥ 读配置,拿到所有要爬的公司 process = s.get_progress() # ⑦ 读进度文件,知道从哪开始 for _key, _node in nodes.items(): # ⑧ 逐个公司处理 for _sch_info in _node: if _key in process: s.run(page, _key, _sch_info, _stat) # ⑨ 核心 time.sleep(10) browser.close() # ⑩ 关闭浏览器 ``` 几个关键设计: `with sync_playwright() as p`:上下文管理器保证 Playwright 进程一定被释放。如果不用 `with`,程序中途崩了 Playwright 进程会留在后台。 `s.browser = browser`:把浏览器实例挂在爬虫对象上,这样 `SpiderCom` 的任何方法都能通过 `self.browser` 拿到它——比如 `get_page_detail_content` 里要新开 tab,就需要 `self.browser.new_page()`。 `process = s.get_progress()`:进度文件的用法——不是读"已完成列表",而是读"最后完成到哪了"。后面在 `run()` 的最后会 `write_process_file(_key)` 更新这个进度。 --- ## 第二阶段:落位 —— SpiderCom 如何"认识"一家公司 ### 2.1 `__init__`:三层配置叠加 `spider_com.py` 第 42-64 行: ```python def __init__(self, _file="99"): 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_com_{_file}.ini", encoding="utf-8") # 第三层 ``` Python 的 `configparser.read()` 是**增量式**的——同一个 `[section]` 下的同一个 key,后读的覆盖先读的。所以: - `setting_default.ini` 定义全局默认值(浏览器路径、保存目录、通用超时时间) - `setting_template.ini` 定义站点模板("百度系职位列表"、"兴业系详情页" 等) - `setting_com_2.ini` 定义第 2 号分片具体包含哪些公司 **这就是第 5 讲讲的"default → override"模式的落地实现**——不需要任何继承或组合的代码,`configparser` 自带覆盖语义。 ### 2.2 进度文件:一行文字决定从哪开始 第 49-57 行: ```properties 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: line = line.strip() if line: self.progress_list = [line] # 只取第一行 break ``` `data/progress_com_2.txt` 的内容可能就一行:`com_00005`。意思是"上次跑到了 com\_00005,这次从它之后继续"。 ### 2.3 get\_nodes():把 INI 变成可迭代的公司列表 第 149-161 行: ```python def get_nodes(self): nodes = {} keys = [k for k in self.config.options("Company") if k.startswith("com_")] for _key in sorted(keys): _svalue = self.config.get("Company", _key) _value = json.loads(_svalue) self.supplement_node_info(_value) # 注入模板字段 nodes[_key] = _value return nodes ``` `setting_com_2.ini` 里大概是这样的: ```json [Company] com_00001 = [{"com_name":"百度","com_webname":"Baidu","template":"tpl_baidu","data_proc_type":"api",...}] com_00002 = [{"com_name":"京东","com_webname":"JD","template":"tpl_jd","data_proc_type":"api",...}] com_00003 = [{"com_name":"某某公司","com_webname":"XX","template":"tpl_dom",...}] ``` 注意 `template` 字段。第 175-184 行的 `supplement_node_info` 会把模板里的字段**注入但不会覆盖**: ```python def supplement_node_info(self, _node): for _com_info in _node: _template = _com_info.get("template") if _template: _tv = self.config.get("Template", _template) _tvjson = json.loads(_tv) for _key, _va in _tvjson.items(): if not _key in _com_info: # ← 关键:只在公司没定义时才注入 _com_info[_key] = _va ``` `if not _key in _com_info`——公司自身配置的优先级永远高于模板。例如模板定义了 `table_selector: ".job-list"`,但某家公司用的是 `.career-list`,公司在自己的配置里写一个 `table_selector` 就能覆盖模板。 --- ## 第三阶段:采集 —— 从列表页 HTML 到一条条详情文件 现在浏览器打开了,`nodes` 拿到了,`process` 告诉我们要从哪个公司开始。`clawler_main` 的循环进入了 `s.run(page, "com_00003", com_info, _stat)`。 ### 3.1 run():三通道分叉口 `spider_com.py` 第 406-473 行。这个函数的第一件事不是打开页面,而是**判断用哪种采集方式**: ```python def run(self, page, _key, com_info, _stat): data_proc_type = com_info.get("data_proc_type", "") if data_proc_type == "api": return self.api_proc(page, _key, com_info, _stat) elif data_proc_type == "on_response": return self.on_resp_proc(page, _key, com_info, _stat) # 以下:默认的 DOM 抓取路径 urls = com_info.get("urls") for i, k in enumerate(urls): url = urls.get(k) # ... ``` 三条路的分叉取决于 INI 配置文件里 `data_proc_type` 这一个字段: | data\_proc\_type | 走的方法 | 适用场景 | | --- | --- | --- | | `"api"` | `api_proc` → `auto_api/baidu_data_proc_api.py` | 有公开职位接口的大厂(百度、京东、金蝶) | | `"on_response"` | `on_resp_proc` → `auto_on_response/main_proc.py` | 接口有签名、但浏览器能正常调的站点(兴业银行) | | 空或其他 | DOM 抓取 | 没有接口可用的普通企业站 | 我们先走 DOM 主干道。 ### 3.2 打开列表页 ```properties # 第 425-438 行 pre_open_url = com_info.get("pre_open_url") if pre_open_url: _ok = self.open_with_url(page, pre_open_url) # 先"预热"首页 time.sleep(FIRST_PAUSE_TIME) _ok = self.open_with_url(page, url) # 再打开列表页 ``` `pre_open_url` **的设计是踩坑踩出来的**。很多企业招聘系统要求先访问首页建立 Session/Cookie,再访问列表页才给数据。没有这一步,列表页直接返回 403 或空白。 `open_with_url`(第 240-269 行)做了四层保障: ```python def open_with_url(self, page, url, refer=""): response = page.goto(url, timeout=PAGE_TIMEOUT) if response: status = response.status if status in [200, 412]: # 兰州大学返回 412 的特殊兼容 page.wait_for_load_state('load') # 等 DOM 加载完 try: page.wait_for_load_state('networkidle', timeout=30000) # 等网络安静 except: pass # networkidle 超时不算失败 time.sleep(3) return True elif page.url == url: # 无 response 对象但 URL 确实变了 return True # (某些 SPA 站点的情况) ``` `networkidle` **超时被** `except` **吞掉了**——因为很多页面有持续的心跳请求或 WebSocket,永远不会真正 idle。等 30 秒足够 JS 渲染完,超时就超时,数据已经有了。 ### 3.3 定位列表容器——选择器回退机制 第 482-486 行: ```text _ok, table_selector = self.get_selector_text( page, sch_info, "table_selector", "table_selectors" ) if not _ok: ner_logger.error(f"列表页面没有找到元素:{table_selector},人工处理!") return _ret_list ``` `get_selector_text`(第 193-238 行)的完整逻辑: ```python def get_selector_text(self, page, sch_info, selector1, selector2, style3=""): # 第一步:用主选择器 table_selector = sch_info.get("table_selector") # 如 ".job-list-container" style_element = page.query_selector(table_selector) if not style_element: # 第二步:备选选择器列表(用 | 分隔) table_selectors = sch_info.get("table_selectors") # 如 ".list|.career-list|.position-wrap" if table_selectors: for _selector in table_selectors.split("|"): style_element = page.query_selector(_selector) if style_element: table_selector = _selector break if not style_element: # 第三步:正则匹配 class 名 selector1_re = sch_info.get("table_selector_re") # 如 "job.*list" if selector1_re: div_elements = page.query_selector_all('div') for element in div_elements: class_name = element.get_attribute('class') or '' if re.search(regex_pattern, class_name): return True, f"div.{class_name}" ``` 三层回退:**精确选择器 → 备选列表逐个试 → 正则模糊匹配**。每一层覆盖一种改版场景: - 精确选择器失效:站点小改,换了 class 名 → 备选列表兜底 - 备选全部失效:站点大改,class 命名规则都变了 → 正则模糊匹配(如 `job.*list` 能匹配 `jobNewList`、`job_2024_list`) **为什么正则匹配只查** `div` **标签?** 因为列表容器 99% 是 `div`。查所有标签太慢,而且误匹配率高。 ### 3.4 动态加载:滚动 + 点击"更多" 第 489-490 行,在选择器定位成功之后: ```text self._auto_scroll_to_bottom(page) # 触发懒加载 self._click_load_more(page, sch_info) # 点击"加载更多"按钮 ``` #### 自动滚动 第 300-314 行: ```python def _auto_scroll_to_bottom(self, page, *, max_scrolls=9999, sleep_s=2.0): last_height = page.evaluate("document.body.scrollHeight") scroll_count = 0 while scroll_count < max_scrolls: page.evaluate("window.scrollTo(0, document.body.scrollHeight)") time.sleep(sleep_s) new_height = page.evaluate("document.body.scrollHeight") if new_height == last_height: return # 高度稳定 = 没新内容了 last_height = new_height scroll_count += 1 ``` **判停条件是"页面高度不变"而不是"滚了多少次"**。一次滚到底 vs 分十次渐进加载,都能正确处理。 #### 点击加载更多 第 316-358 行。支持两种模式: ```properties if load_more_method == "element": # 模式 A:配置 CSS 选择器,直接点击 load_more_button = page.query_selector(load_more_selector) if load_more_button and load_more_button.is_visible(): load_more_button.click() else: # 模式 B:调用站点专属函数(处理复杂的点击逻辑) self.pre_page_run(page, sch_info, "click_load_more_func_name") ``` 模式 B 通过 `pre_page_run`(第 361-372 行)动态导入并执行: ```python def pre_page_run(self, page, sch_info, func_name="table_func_name"): table_func_name = sch_info.get(func_name) # 如 "click_00088" if table_func_name: package_func_name = f"auto_gen_com.gen.{table_func_name}" return execute_page_action(package_func_name, page) ``` `execute_page_action` 在 `auto_gen/func_call.py` 第 52-61 行: ```python def execute_page_action(module_name, page): module = importlib.import_module(module_name) func = getattr(module, "crawl_page") func(page) ``` **所以一个"点击加载更多"可以是任意复杂的 Playwright 操作**——先滚动到按钮位置、等它出现、关掉弹窗、再点击——全部封装在一个 `auto_gen_com/gen/click_xxxxx.py` 文件里。 ### 3.5 提取列表 HTML → 落盘 → 调用解析函数 第 492-513 行。列表容器的 HTML 拿到了,接下来: ```html tableObj = page.locator(table_selector) outtext = ["