Python爬虫实战:从研招网动态页面高效抓取结构化招生信息
2026/8/13 13:50:55 网站建设 项目流程

1. 项目概述与核心价值

最近在整理一些考研相关的资料,发现很多同学在查找院校专业信息时,还是习惯于手动去研招网一个个点开看,效率低不说,还容易遗漏。这让我想起了几年前自己备考时,为了对比几个心仪学校的招生人数、考试科目和参考书目,在浏览器里开了几十个标签页,看得头晕眼花。其实,这类结构化的公开信息,完全可以用Python爬虫来高效获取和整理。今天,我就结合“爬取研招网招生信息详情”这个实战项目,把从环境搭建、页面分析、数据抓取到数据清洗存储的全流程,以及我踩过的坑和总结的技巧,系统地分享给大家。这个项目不仅能帮你快速构建一个考研信息数据库,更重要的是,它能让你掌握处理动态加载、应对反爬策略、解析复杂JSON数据等爬虫实战中的核心技能,这些技能在抓取其他类似网站时同样适用。无论你是想为考研助力,还是单纯想提升自己的Python爬虫水平,这篇内容都会是一个不错的起点。

2. 项目整体设计与思路拆解

2.1 目标网站分析与技术选型

我们的目标是“研招网”,即中国研究生招生信息网。这是一个典型的政府类信息服务网站,信息权威但页面结构可能比较复杂。首先需要明确我们要爬取什么。招生信息详情通常包括:招生单位(大学/研究所)、院系所、专业(代码及名称)、研究方向、拟招生人数、考试科目、专业课参考书目等。这些信息分布在不同的页面层级。

经过初步探查,研招网的信息查询主要有两个入口:一是通过“硕士目录”进行条件筛选查询列表,二是各高校自己发布的详细招生简章或专业目录页面。前者通常以列表形式呈现概要信息,点击后跳转到详情页。考虑到我们要的是“详情”,所以策略定为:先获取符合条件的专业列表及对应的详情页链接,再逐个访问详情页抓取完整信息。

技术栈选择上,核心是Python。requests库用于发送HTTP请求,这是基础。但研招网的列表页和详情页很可能涉及动态加载(AJAX),直接requests.get()拿到的可能是空壳HTML。因此,我们需要用到SeleniumPlaywright这样的浏览器自动化工具来模拟真实用户操作,获取渲染后的完整页面内容。考虑到Playwright对现代Web技术的支持更好、异步性能更强,本项目选择Playwright作为核心抓取工具。数据解析方面,虽然动态内容多为JSON格式,但详情页的HTML结构解析仍离不开BeautifulSouplxml,这里选择BeautifulSoup,因其API对新手更友好。数据存储则用轻量级的SQLite数据库,方便管理和后续查询。

注意:在爬取任何网站前,务必先检查其robots.txt文件(通常在网站根目录,如https://yz.chsi.com.cn/robots.txt),并尊重网站的爬虫协议。对于明确禁止爬取的目录,应予以回避。同时,需要在请求头中设置合理的User-Agent,并控制请求频率,避免对目标网站服务器造成压力。

2.2 核心难点与应对策略预判

根据经验,这类网站通常会设置一些反爬机制,我们需要提前规划应对策略:

  1. 动态加载与参数加密:列表数据很可能通过POST请求一个特定API接口获取,请求参数可能经过简单编码或加密。我们需要通过浏览器开发者工具的“网络(Network)”面板,追踪XHR/Fetch请求,找到真正的数据接口和参数规律。
  2. IP限制与请求频率:短时间内发起大量请求,可能会导致IP被暂时封锁。解决方案是使用代理IP池,并在请求间添加随机延时。对于本项目,由于我们不是商业爬虫,只需在抓取每个详情页之间设置time.sleep(random.uniform(2, 5))这样的随机等待即可大幅降低风险。
  3. 登录与会话维持:部分详细信息(如历年录取分数)可能需要登录才能查看。研招网的统一登录有时会涉及滑块验证等复杂交互,实现全自动化登录成本较高。本项目暂定只爬取公开的招生目录信息,这部分通常无需登录。
  4. 数据清洗与结构化:抓取到的文本数据往往包含多余空格、换行符、特殊字符(如\u3000全角空格),且不同学校的信息展示格式不统一。这就需要编写健壮的清洗函数,使用正则表达式或字符串方法进行规范化处理。

我的整体思路是:使用Playwright启动一个浏览器实例,导航到硕士目录查询页面,模拟人工选择查询条件(如学科门类、专业类别等),然后拦截或等待列表数据接口的响应,直接解析JSON数据获得专业列表和详情页ID。接着,根据详情页ID构造出每个专业的详情页URL,再使用Playwrightrequests(如果详情页是静态的)去抓取详情页HTML,用BeautifulSoup解析出所需字段。最后,将所有数据清洗后存入SQLite数据库。这个流程清晰地将“获取列表”和“解析详情”解耦,便于调试和维护。

3. 环境搭建与核心工具详解

3.1 Python环境与依赖库安装

首先确保你安装了Python(3.7及以上版本)。我强烈建议使用虚拟环境来管理项目依赖,避免污染全局环境。可以使用venvconda

# 创建项目目录并进入 mkdir yzw_spider && cd yzw_spider # 创建虚拟环境(以venv为例) python -m venv venv # 激活虚拟环境 # Windows: venv\Scripts\activate # Linux/Mac: source venv/bin/activate

激活虚拟环境后,安装必要的库:

pip install playwright beautifulsoup4 sqlite-requests lxml # 安装Playwright所需的浏览器内核 playwright install chromium

这里解释一下各个库的作用:

  • playwright: 用于控制浏览器,获取动态渲染的页面内容或拦截API请求。
  • beautifulsoup4: 用于解析HTML文档,提取结构化数据。
  • sqlite-requests: 一个轻量级库,方便进行SQLite数据库操作。你也可以使用Python标准库sqlite3
  • lxml: 是BeautifulSoup的一个解析器后端,比默认的html.parser速度更快、容错能力更强。
  • requests: 虽然我们用Playwright处理动态页,但一些静态的详情页或接口调用,用requests更轻量。

3.2 Playwright快速上手与配置

Playwright的功能非常强大,我们这里主要用到它的同步API(易于理解)和请求拦截功能。

from playwright.sync_api import sync_playwright import time import random def init_browser(headless=False): """ 初始化浏览器上下文 headless: 无头模式,不显示浏览器界面。调试时可设为False。 """ p = sync_playwright().start() # 选择Chromium内核 browser = p.chromium.launch(headless=headless, slow_mo=100) # slow_mo让操作慢下来,便于观察 # 创建一个新的浏览器上下文,可以独立设置视窗、User-Agent等 context = browser.new_context( viewport={'width': 1920, 'height': 1080}, user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ) page = context.new_page() return p, browser, context, page

关键配置解析

  • headless: 调试阶段建议设为False,这样你能看到浏览器每一步操作,确认脚本是否按预期执行。正式运行时可以改为True以节省资源。
  • slow_mo: 单位是毫秒,它会在每个Playwright操作(如点击、输入)后增加一个延迟。这对于观察自动化过程和应对一些基于操作速度的反爬很有用。
  • user_agent: 设置一个常见的桌面浏览器UA,模拟真实用户。
  • viewport: 设置浏览器窗口大小,有些网站的响应式布局在不同尺寸下元素位置可能不同,固定一个常用尺寸可以避免意外。

实操心得:在编写爬虫脚本时,我习惯将浏览器初始化、页面打开、元素定位等操作都封装在try...except块中,并配合详细的日志记录。这样当脚本在无人值守运行时出错,我能快速定位到是哪个页面、哪个元素出了问题。日志可以记录时间、URL、操作动作和结果(成功或异常信息)。

4. 核心爬取流程实现与解析

4.1 步骤一:分析数据接口与获取专业列表

我们首先手动打开研招网硕士目录页面,用浏览器开发者工具分析网络请求。

  1. 打开开发者工具:按F12,切换到“网络(Network)”面板,勾选“保留日志(Preserve log)”。
  2. 执行一次查询:在页面选择查询条件(例如,门类类别选择“工学”,学科类别选择“计算机科学与技术”),点击查询。
  3. 寻找数据请求:在网络请求列表中,过滤XHR或Fetch请求。你会看到一些包含“search”或“query”字样的请求。点击查看其“载荷(Payload)”和“响应(Response)”。
  4. 分析请求:通常,这是一个POST请求,其请求体是一个JSON或表单数据,包含了你的查询条件。响应也是一个JSON格式的数据,里面包含了专业列表,每个专业项里会有school_idspeciality_iddetail_url等关键信息。

假设我们分析出的API接口是https://yz.chsi.com.cn/zsml/queryAction.do,请求方法为POST,参数在Form Data里。那么,我们可以用Playwright来拦截这个请求,直接获取其响应数据,这比解析渲染后的HTML更直接、更稳定。

import json from playwright.sync_api import sync_playwright def get_major_list(query_params): """ 获取专业列表 query_params: 字典,包含查询条件,如{'ssdm': '11', 'dwmc': '', 'mldm': '08', 'yjxkdm': '0812'} """ major_list = [] def handle_response(response): # 拦截特定的响应 if 'queryAction.do' in response.url and response.request.method == 'POST': try: # 尝试解析JSON响应 json_data = response.json() # 根据实际响应结构提取列表,这里假设数据在json_data['data']['list']里 if json_data.get('data') and json_data['data'].get('list'): for item in json_data['data']['list']: # 提取我们需要的信息,例如专业名称、代码、详情页路径 major_info = { 'school_name': item.get('dwmc'), 'major_code': item.get('zydm'), 'major_name': item.get('zymc'), 'detail_path': item.get('detailUrl'), # 可能是相对路径 'plan_count': item.get('jhrs'), # 拟招生人数 } major_list.append(major_info) except Exception as e: print(f"解析响应失败: {e}, URL: {response.url}") p, browser, context, page = init_browser(headless=False) # 添加响应事件监听器 page.on('response', handle_response) # 导航到查询页面 page.goto('https://yz.chsi.com.cn/zsml/querySchAction.do') # 等待页面基本加载完成 page.wait_for_load_state('networkidle') # 模拟输入查询条件(这里需要根据实际页面元素调整选择器) # 例如:选择学科门类 page.select_option('select[name="mldm"]', value=query_params.get('mldm', '')) # 选择专业领域 page.fill('input[name="yjxkdm"]', query_params.get('yjxkdm', '')) # 点击查询按钮 page.click('input[type="submit"]') # 按钮选择器需根据实际情况调整 # 等待一段时间,确保响应被拦截到 page.wait_for_timeout(5000) # 关闭浏览器 context.close() browser.close() p.stop() return major_list # 示例查询参数(需要根据实际页面调整) params = { 'mldm': '08', # 门类代码,08代表工学 'yjxkdm': '0812', # 学科专业代码,0812代表计算机科学与技术 } majors = get_major_list(params) print(f"共获取到 {len(majors)} 个专业信息")

关键点解析

  • page.on('response', handle_response): 这是Playwright的核心功能之一,可以为页面设置事件监听器。这里监听所有响应,当遇到目标URL的响应时,触发处理函数。
  • response.json(): 直接解析响应的JSON体,比从HTML里抠数据高效准确得多。
  • 选择器(Selector)page.select_optionpage.fillpage.click这些操作都需要准确的目标元素选择器。你必须通过开发者工具的“元素(Elements)”面板,找到对应下拉框、输入框、按钮的CSS选择器或XPath。这是编写自动化脚本最耗时但也最关键的一步。选择器要尽量稳定,优先使用idname属性,其次是用包含特定类或属性的CSS选择器。

4.2 步骤二:构造详情页链接与访问策略

获取到专业列表后,每个专业对象中应该包含一个访问详情页的关键信息,比如detail_path(可能是像/zsml/querySchAction.do?ssdm=11&dwmc=某某大学&...这样的相对路径或参数集合)。

我们需要构造出完整的详情页URL。研招网的详情页URL通常也是有一个固定的模式,例如:https://yz.chsi.com.cn/zsml/querySchAction.do?method=view&dwmc=XXX&zydm=YYY

import urllib.parse BASE_URL = 'https://yz.chsi.com.cn' def construct_detail_url(major_info): """ 根据专业信息构造详情页完整URL """ # 情况1: detail_path是相对路径 if major_info.get('detail_path'): if major_info['detail_path'].startswith('http'): return major_info['detail_path'] else: return urllib.parse.urljoin(BASE_URL, major_info['detail_path']) # 情况2: 需要自己拼接参数 (更常见) else: # 假设我们从列表接口拿到了学校代码(school_id)和专业代码(major_code) params = { 'method': 'view', 'dwmc': major_info.get('school_name'), 'zydm': major_info.get('major_code'), # 可能还需要其他参数,如ssdm(省市代码)、yjxkdm等 } # 过滤掉空值参数 valid_params = {k: v for k, v in params.items() if v} query_string = urllib.parse.urlencode(valid_params, encoding='gb2312') # 注意编码,研招网常用gb2312 return f"{BASE_URL}/zsml/querySchAction.do?{query_string}"

编码问题注意:在构造URL时,特别是中文字符作为参数时(如dwmc=北京大学),必须注意编码。研招网通常使用GB2312GBK编码,而不是UTF-8。使用urllib.parse.urlencode时指定encoding='gb2312'可以避免乱码问题。这是一个非常容易踩坑的地方,如果编码不对,服务器可能无法识别参数,返回错误页面或空数据。

4.3 步骤三:解析详情页HTML与数据提取

详情页的HTML结构相对复杂,信息以文本形式散布在多个<tr><td>标签中。我们需要仔细分析其结构,编写针对性的解析代码。

首先,我们使用Playwrightrequests获取详情页HTML。由于详情页可能内容较多,但通常是静态的,为了效率,我们可以用requests来获取,但需要携带必要的请求头(如Referer,有时服务器会校验)。

import requests from bs4 import BeautifulSoup def fetch_detail_page_html(url): """获取详情页HTML内容""" headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': 'https://yz.chsi.com.cn/zsml/querySchAction.do', # 重要,模拟从查询页跳转过来 'Accept-Language': 'zh-CN,zh;q=0.9', } try: # 注意,如果URL参数含中文且已正确编码,requests会自动处理 resp = requests.get(url, headers=headers, timeout=10) resp.encoding = 'gb2312' # 关键!设置响应的正确编码 if resp.status_code == 200: return resp.text else: print(f"请求详情页失败,状态码: {resp.status_code}, URL: {url}") return None except Exception as e: print(f"请求详情页异常: {e}, URL: {url}") return None def parse_detail_html(html, major_base_info): """ 解析详情页HTML,提取详细信息 major_base_info: 从列表页获取的基础信息字典 """ if not html: return None soup = BeautifulSoup(html, 'lxml') detail_data = major_base_info.copy() # 先复制基础信息 # 研招网详情页信息通常在一个id为`result`或`content`的div下的表格里 # 我们需要找到包含“招生人数”、“考试科目”、“参考书目”等关键字的表格行(tr) info_table = soup.find('table', class_='zsml-result') # 类名需根据实际情况调整 if not info_table: # 如果没找到特定类,尝试找包含信息的第一个大table info_table = soup.find('table', {'width': '100%', 'border': '0'}) if info_table: rows = info_table.find_all('tr') for row in rows: th = row.find('th') td = row.find('td') if th and td: header_text = th.get_text(strip=True) content_text = td.get_text(' ', strip=True) # 用空格连接多个文本段 # 根据表头关键词映射到我们的字段 if '招生人数' in header_text: detail_data['plan_count_detail'] = content_text # 详情页的招生人数可能更细 elif '考试科目' in header_text: detail_data['exam_subjects'] = content_text elif '参考书目' in header_text or '参考教材' in header_text: detail_data['reference_books'] = content_text elif '研究方向' in header_text: detail_data['research_direction'] = content_text elif '指导教师' in header_text: detail_data['supervisors'] = content_text # ... 可以根据需要添加更多字段 else: print("未找到详情信息表格") # 补充:有时信息也在<ul><li>列表中,或者分散在多个<div>里,需要更复杂的解析逻辑 # 这里可以添加备用解析方案 backup_info = soup.find('div', class_='zsml-bxx') if backup_info: # 对backup_info进行进一步解析... pass return detail_data

解析策略详解

  1. 定位核心容器:首先找到包含所有信息的父容器,通常是一个有特定idclass<table><div>。你需要手动打开几个不同学校的详情页,观察其HTML结构的共性。
  2. 遍历行与单元格:在表格结构中,信息通常以<tr>(行)为单位,每行包含<th>(表头)和<td>(内容)。我们遍历所有行,提取表头文本和内容文本。
  3. 关键词匹配:由于不同学校、不同年份的页面模板可能有细微差别,表头文字可能不完全一致(如“招生人数”和“拟招生人数”)。我们的匹配逻辑要具有一定的容错性,使用in操作符进行子串匹配。
  4. 文本清洗:使用.get_text(strip=True)可以移除文本前后的空白字符。对于内容单元格<td>,里面可能包含<br>标签,get_text(' ', strip=True)中的' '参数表示用空格替换标签,这样能保留基本的段落分隔。
  5. 备用方案:如果首选定位方式失败,必须有备用的定位逻辑。例如,查找所有包含特定文本的标签。这能提高爬虫的健壮性。

4.4 步骤四:数据清洗、存储与去重

抓取到的原始数据是杂乱的文本,我们需要清洗并结构化存储。

数据清洗

import re def clean_data(detail_data): """清洗和规范化提取的数据""" cleaned = {} for key, value in detail_data.items(): if isinstance(value, str): # 1. 去除首尾空白 value = value.strip() # 2. 替换全角空格、多个空格为单个空格 value = re.sub(r'[\u3000\s]+', ' ', value) # 3. 处理换行符,统一为分号或其他分隔符(针对参考书目等) if key in ['reference_books', 'exam_subjects', 'research_direction']: # 将连续的换行、分号等统一为中文分号“;” value = re.sub(r'[\n\r;]+', ';', value) # 去除首尾可能多余的分号 value = value.strip(';') cleaned[key] = value return cleaned

数据存储(SQLite)

import sqlite3 from datetime import datetime def init_database(db_path='yzw_majors.db'): """初始化数据库和表""" conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute(''' CREATE TABLE IF NOT EXISTS majors ( id INTEGER PRIMARY KEY AUTOINCREMENT, school_name TEXT, major_code TEXT, major_name TEXT, plan_count TEXT, plan_count_detail TEXT, exam_subjects TEXT, reference_books TEXT, research_direction TEXT, supervisors TEXT, detail_url TEXT UNIQUE, -- 唯一约束,用于去重 crawl_time TIMESTAMP, UNIQUE(school_name, major_code, major_name) -- 联合唯一键,防止重复插入 ) ''') conn.commit() return conn def save_to_database(conn, cleaned_data): """将清洗后的数据存入数据库""" cursor = conn.cursor() # 准备插入数据,如果冲突(根据UNIQUE约束)则忽略或更新 sql = ''' INSERT OR REPLACE INTO majors (school_name, major_code, major_name, plan_count, plan_count_detail, exam_subjects, reference_books, research_direction, supervisors, detail_url, crawl_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''' values = ( cleaned_data.get('school_name'), cleaned_data.get('major_code'), cleaned_data.get('major_name'), cleaned_data.get('plan_count'), cleaned_data.get('plan_count_detail'), cleaned_data.get('exam_subjects'), cleaned_data.get('reference_books'), cleaned_data.get('research_direction'), cleaned_data.get('supervisors'), cleaned_data.get('detail_url'), datetime.now() ) try: cursor.execute(sql, values) conn.commit() print(f"成功保存/更新: {cleaned_data.get('school_name')} - {cleaned_data.get('major_name')}") except sqlite3.Error as e: print(f"数据库操作失败: {e}")

去重策略: 在数据库表设计时,我们设置了两个唯一性约束:

  1. detail_url UNIQUE:详情页URL是唯一的,这是最直接的去重依据。
  2. UNIQUE(school_name, major_code, major_name):学校、专业代码、专业名称的组合也应该是唯一的,作为双重保障。

使用INSERT OR REPLACE语句,当遇到冲突时,会用新数据替换旧数据。这对于更新已爬取专业的最新信息很有用。

5. 完整脚本组装与流程控制

将上述所有函数组装起来,并加入流程控制、错误处理和日志记录。

import time import random import logging from tqdm import tqdm # 进度条库,可选, pip install tqdm # 配置日志 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler('yzw_crawler.log'), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) def main(): """主函数,控制整个爬取流程""" # 1. 初始化数据库 conn = init_database() logger.info("数据库初始化完成。") # 2. 设置查询条件(这里以计算机专业为例,可以循环多种条件) query_params_list = [ {'mldm': '08', 'yjxkdm': '0812'}, # 工学-计算机科学与技术 # {'mldm': '07', 'yjxkdm': '0701'}, # 理学-数学 # ... 添加其他学科 ] all_majors_crawled = 0 for query_params in query_params_list: logger.info(f"开始爬取查询条件: {query_params}") # 3. 获取专业列表 logger.info("正在获取专业列表...") major_list = get_major_list(query_params) if not major_list: logger.warning(f"未获取到查询条件 {query_params} 下的专业列表,跳过。") continue logger.info(f"获取到 {len(major_list)} 个专业。") # 4. 遍历专业列表,抓取详情 for idx, major_base in enumerate(tqdm(major_list, desc=f"爬取详情页")): # 构造详情页URL detail_url = construct_detail_url(major_base) if not detail_url: logger.warning(f"无法为专业 {major_base} 构造URL,跳过。") continue # 将URL存入基础信息,便于后续存储 major_base['detail_url'] = detail_url # 5. 获取并解析详情页 logger.debug(f"正在处理第 {idx+1}/{len(major_list)} 个: {detail_url}") html = fetch_detail_page_html(detail_url) if html: detail_data = parse_detail_html(html, major_base) if detail_data: # 6. 数据清洗 cleaned_data = clean_data(detail_data) # 7. 存入数据库 save_to_database(conn, cleaned_data) all_majors_crawled += 1 else: logger.error(f"解析详情页失败: {detail_url}") else: logger.error(f"获取详情页失败: {detail_url}") # 8. 礼貌性延迟,避免请求过快 delay = random.uniform(3, 8) # 随机等待3-8秒 time.sleep(delay) # 9. 关闭数据库连接 conn.close() logger.info(f"爬取任务结束。共处理 {all_majors_crawled} 个专业详情。") if __name__ == '__main__': main()

流程控制要点

  • 分步查询:研招网一次查询返回的专业可能成百上千,我们可以通过遍历不同的学科门类(mldm)和一级学科(yjxkdm)代码来分批次抓取,这样既符合网站查询逻辑,也便于管理。
  • 进度反馈:使用tqdm库或简单的print语句显示进度,对于长时间运行的脚本非常必要。
  • 错误隔离:每个专业详情页的抓取和解析都应该是独立的,一个页面失败不应影响其他页面。因此,在循环内部进行了充分的try...except捕获和日志记录。
  • 随机延迟time.sleep(random.uniform(3, 8))是简单有效的反反爬策略。更复杂的策略可以引入代理IP,但本项目数据量不大,随机延迟通常足够。

6. 常见问题排查与实战技巧

6.1 请求被拒绝或返回空数据

  • 症状requests.get()返回403/404,或者Playwright页面内容为空。
  • 排查
    1. 检查请求头:特别是User-AgentReferer。有些网站会校验Referer,必须设置为来自其站内的页面。用浏览器正常访问一次,从开发者工具里复制完整的请求头信息。
    2. 检查Cookies:某些页面需要先访问首页获取一个会话Cookie。可以用requests.Session()来保持会话,或者用Playwrightcontext,它会自动管理Cookies。
    3. 检查参数编码:确认POST请求的参数或GET请求的查询字符串编码是否正确。对于中文参数,尝试gb2312gbk编码。
    4. 模拟更真实的操作:如果使用Playwright,确保在关键操作(如点击查询)前,使用page.wait_for_selector等待相关元素加载完成,而不是固定等待时间。

6.2 解析不到数据或数据错位

  • 症状BeautifulSoup找不到预期的标签,或者提取的文本驴唇不对马嘴。
  • 排查
    1. 确认HTML结构:将抓取到的HTML保存到本地文件,用浏览器打开,对比与在线页面的差异。可能是网站有反爬机制,对自动化工具返回了不同的内容(如返回一个验证页面)。
    2. 使用更稳健的选择器:不要依赖绝对的位置或索引。使用包含特征属性(如classid)的选择器。如果类名是动态生成的(例如class="table-abc123"),可以尝试用XPath的contains函数进行部分匹配,如soup.find('table', class_=re.compile('^zsml-'))
    3. 打印中间结果:在解析函数中,打印出找到的soup对象或关键标签,确认你定位到了正确的元素。
    4. 考虑页面有iframe:有些信息可能嵌套在<iframe>里。你需要先用soup.find('iframe')找到iframe,然后获取其src属性,再单独请求那个URL。

6.3 数据库写入错误或重复数据

  • 症状sqlite3.IntegrityError: UNIQUE constraint failed
  • 排查
    1. 明确去重逻辑:检查你的UNIQUE约束字段。是detail_url唯一,还是(school_name, major_code)组合唯一?要根据业务逻辑确定。
    2. 使用UPSERT操作:正如我们用的INSERT OR REPLACE,它会在冲突时替换旧行。如果你只想忽略重复项,可以用INSERT OR IGNORE
    3. 爬取前先查询:对于大规模爬取,可以在插入前先执行一次SELECT查询,判断是否存在,但这会增加数据库IO。对于中小规模数据,直接使用INSERT OR REPLACE更简单高效。

6.4 爬虫被封锁或访问变慢

  • 症状:请求超时增多,返回验证码页面,或直接无法连接。
  • 应对策略
    1. 降低请求频率:这是首要措施。将随机延迟时间加大,例如random.uniform(5, 15)
    2. 使用代理IP:搭建或购买代理IP池,在请求时随机切换。对于requests,可以传入proxies参数;对于Playwright,可以在创建browsercontext时配置代理。
    3. 模拟人类行为:在Playwright中,可以随机移动鼠标page.mouse.move(x, y),随机滚动页面page.evaluate('window.scrollBy(0, Math.random() * 500)')
    4. 设置超时与重试:为网络请求设置合理的超时时间,并实现重试机制(如最多重试3次,每次重试前等待更长时间)。

6.5 实战技巧与心得

  1. 从简单到复杂:不要一开始就想爬取所有细节。先确保能稳定获取到专业列表和详情页链接,再逐步完善详情页的解析逻辑。可以先将解析出的原始HTML片段保存下来,离线编写和测试解析代码。
  2. 数据抓取与数据清洗分离:建议将原始HTML或JSON响应保存到文件或数据库的“原始数据”字段中。这样即使后续解析逻辑需要调整,也无需重新爬取,直接从原始数据中重新解析即可。
  3. 使用配置文件:将查询参数、请求头、数据库路径、延迟时间等配置项写入一个config.pyconfig.json文件,使主脚本更清晰,也便于修改。
  4. 定时增量爬取:招生信息每年更新。可以编写一个脚本,定期(如每周一次)运行,只爬取crawl_time在最近一段时间内更新过的专业,或者通过对比新旧数据来发现更新。
  5. 尊重robots.txt:再次强调,检查并遵守目标网站的爬虫协议。对于Disallow的路径,不要爬取。将爬虫的User-Agent设置为一个易于识别的名称(如MyResearchSpider/1.0),并在请求头中附带一个联系邮箱,以示友好。

这个项目从分析到实现,涵盖了Python爬虫实战中大部分核心环节。过程中最考验人的不是编码,而是耐心地分析网站结构、处理各种反爬策略和数据异常。每完成一个这样的项目,你对网络请求、数据解析和流程控制的掌握就会更深一层。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询