Python爬虫实战:5分钟搞定化妆品生产许可证数据抓取(含Ajax动态加载解析)

最近在帮一个做美妆行业分析的朋友整理数据,他需要批量获取国内化妆品生产企业的许可证信息。这类数据通常由官方机构公开,但网站往往没有提供便捷的批量下载入口,一页页手动复制显然不现实。这让我想起了几年前自己刚学爬虫时,面对那些“点击下一页才加载内容”的网站,总是一头雾水,不知道数据到底藏在哪里。

实际上,现代网页大量采用Ajax技术动态加载数据,页面的URL不变,内容却通过后台接口悄悄更新。对于数据采集者来说,这既是挑战也是机遇。挑战在于,你不能再用简单的requests.get(页面URL)拿到所有HTML;机遇在于,一旦找到那个“悄悄”传输数据的接口,你就能以更高效、更结构化的方式获取数据,往往还是干净的JSON格式,省去了从HTML中解析的麻烦。

今天,我们就以“国家药品监督管理局”下属的化妆品生产许可信息平台为例,手把手带你走通一个完整的Ajax动态数据抓取流程。整个过程的核心,不是写复杂的代码,而是学会像侦探一样,用浏览器的开发者工具找到数据的真实来源。只要你跟下来,以后遇到类似的动态网站,基本都能举一反三。

1. 侦察阶段:从网页表象到数据接口

动手写代码之前,最关键的一步是搞清楚数据从哪里来。很多新手会直接对看到的网页地址发起请求,结果拿到的HTML里空空如也,这是因为数据是后来通过JavaScript动态填充的。

1.1 识别动态加载

打开目标网站,映入眼帘的是一个企业列表。如果你右键“查看网页源代码”,会发现源码里根本没有这些企业的名字和链接。这是一个典型的动态加载信号。

提示:区分静态和动态加载的一个快速方法是,对比“网页源代码”(Ctrl+U)和“检查元素”(F12)中Elements标签页的内容。如果后者有数据而前者没有,那数据几乎肯定是动态加载的。

接下来,我们打开浏览器的开发者工具(F12),切换到 Network(网络) 标签页。刷新页面,你会看到一系列网络请求。我们的目标是找到那个真正携带了列表数据的请求。

  1. 在请求列表中,寻找类型为 XHRFetch 的请求。这些通常是Ajax请求。
  2. 在列表页面上进行翻页操作,同时观察Network面板。哪个请求在每次翻页时都会出现,并且其Response(响应)里包含了新的企业数据,哪个就是我们的目标。

通过这个步骤,我找到了一个关键的请求:

POST http://125.35.6.84:81/xk/itownet/portalAction.do?method=getXkzsList

它的响应是一个JSON字符串,里面包含了企业ID、名称等关键信息。这就是列表数据的源头。

1.2 分析请求详情

点击这个请求,查看它的详细信息,我们需要复制几样东西来模拟浏览器发送请求:

  • Headers(请求头): 特别是 User-Agent,用来伪装成浏览器,避免被简单的反爬机制拦截。
  • Payload(负载)Request Body(请求体): 对于POST请求,这里包含了发送给服务器的参数,比如页码(page)、每页数量(pageSize)等。

一个典型的请求参数可能如下所示:

{
  "on": "true",
  "page": "1",
  "pageSize": "15",
  "productName": "",
  "conditionType": "1",
  "applyname": "",
  "applysn": ""
}

至此,侦察工作完成。我们知道了:

  1. 数据是通过一个特定的POST接口动态获取的。
  2. 获取列表需要模拟发送带有分页参数的请求。
  3. 从列表的JSON响应中,我们可以提取每个企业的唯一ID。

2. 深入挖掘:获取详情数据

列表数据拿到了,但通常我们还需要每个企业的详细许可证信息。点击列表中的企业名称,会跳转到详情页。同样,我们需要用老办法分析这个详情页的数据来源。

2.1 定位详情接口

进入某个企业的详情页,再次打开开发者工具的Network面板,刷新或直接查看加载出的请求。寻找另一个携带了详细数据的XHR请求。

很快,我发现了第二个关键接口:

POST http://125.35.6.84:81/xk/itownet/portalAction.do?method=getXkzsById

它的请求体非常简单,只有一个参数:

{
  "id": "ff83aff95c5541cdab5ca6e847514f88"

这个id值,正是我们从列表接口的JSON响应中获取的企业ID。响应同样是一个结构清晰的JSON,包含了企业名称、许可证编号、生产地址、许可项目等所有我们需要的信息。

2.2 构建抓取逻辑链条

现在,整个数据流的逻辑链条就非常清晰了:

  1. 抓列表: 循环请求列表接口,改变page参数,获取所有页的企业ID。
  2. 取详情: 用上一步获取的每一个企业ID,作为参数去请求详情接口。
  3. 存数据: 将详情接口返回的JSON数据保存下来。

这个“列表-详情”的模式在数据抓取中非常常见,掌握了它,你就攻克了一大类动态网站。

3. 实战编码:从零构建爬虫脚本

理论清晰了,我们开始用Python的requests库将整个过程自动化。我会将代码分成几个函数,让结构更清晰,也便于理解和维护。

3.1 环境准备与基础配置

首先,确保你安装了requests库。如果没有,在命令行执行 pip install requests

我们创建一个新的Python文件,比如叫 cosmetic_license_spider.py。开头先进行一些基础配置:

import requests
import json
import time
from typing import List, Dict

# 基础配置
BASE_URL = 'http://125.35.6.84:81/xk'
LIST_API = f'{BASE_URL}/itownet/portalAction.do?method=getXkzsList'
DETAIL_API = f'{BASE_URL}/itownet/portalAction.do?method=getXkzsById'

# 请求头,用于模拟浏览器
HEADERS = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
    'Host': '125.35.6.84:81',
    'Origin': BASE_URL,
    'Referer': f'{BASE_URL}/xk/',
}

这里我定义了基础URL和两个API接口地址。HEADERS中的信息是从浏览器开发者工具中复制并整理过来的,RefererOrigin有时是服务端用于验证请求来源的,加上会更稳妥。

3.2 高效获取企业ID列表

接下来,我们编写获取列表页数据的函数。这个函数需要处理分页。

def fetch_company_ids(start_page: int = 1, end_page: int = 5) -> List[str]:
    """
    从列表接口分页获取所有企业的ID
    Args:
        start_page: 起始页码
        end_page: 结束页码
    Returns:
        企业ID的列表
    """
    all_ids = []
    for page in range(start_page, end_page + 1):
        print(f'正在抓取第 {page} 页列表...')
        
        # 构建POST请求的数据体
        post_data = {
            'on': 'true',
            'page': str(page),
            'pageSize': '15',  # 每页数量,可根据需要调整
            'productName': '',
            'conditionType': '1',
            'applyname': '',
            'applysn': '',
        }
        
        try:
            # 发送POST请求
            response = requests.post(LIST_API, data=post_data, headers=HEADERS, timeout=10)
            response.raise_for_status()  # 如果状态码不是200,抛出异常
            
            # 解析JSON响应
            result = response.json()
            if result.get('list'):
                # 从每一条记录中提取ID字段
                page_ids = [item['ID'] for item in result['list']]
                all_ids.extend(page_ids)
                print(f'  第 {page} 页获取到 {len(page_ids)} 个ID。')
            else:
                print(f'  第 {page} 页无数据或数据结构异常。')
                
        except requests.exceptions.RequestException as e:
            print(f'请求第 {page} 页时发生错误: {e}')
        except json.JSONDecodeError as e:
            print(f'解析第 {page} 页JSON响应时发生错误: {e}')
        
        # 礼貌性延时,避免请求过快给服务器带来压力
        time.sleep(1)
    
    print(f'列表抓取完成,共获取到 {len(all_ids)} 个企业ID。')
    return all_ids

这个函数有几个关键点:

  • 错误处理: 使用try...except包裹网络请求和JSON解析,让程序更健壮。
  • 数据提取: 确认响应的JSON结构后,用列表推导式高效提取ID。
  • 延时time.sleep(1) 在每次请求后暂停1秒,这是遵守网络礼仪、避免被封IP的基本操作。

3.3 批量抓取企业详情数据

拿到ID列表后,我们就可以遍历它,去详情接口获取完整数据了。

def fetch_company_details(company_ids: List[str]) -> List[Dict]:
    """
    根据企业ID列表,批量获取详情数据
    Args:
        company_ids: 企业ID列表
    Returns:
        企业详情字典的列表
    """
    all_details = []
    total = len(company_ids)
    
    for idx, company_id in enumerate(company_ids, 1):
        print(f'正在抓取企业详情 ({idx}/{total}): ID={company_id}')
        
        post_data = {'id': company_id}
        
        try:
            response = requests.post(DETAIL_API, data=post_data, headers=HEADERS, timeout=10)
            response.raise_for_status()
            
            detail_info = response.json()
            all_details.append(detail_info)
            
        except requests.exceptions.RequestException as e:
            print(f'  请求ID {company_id} 详情时发生错误: {e}')
            # 可以选择记录失败的ID,后续重试
        except json.JSONDecodeError as e:
            print(f'  解析ID {company_id} 的JSON响应时发生错误: {e}')
        
        # 详情请求也加入延时
        time.sleep(0.5)
    
    print(f'详情抓取完成,共获取 {len(all_details)} 条详情数据。')
    return all_details

这个函数遍历ID列表,为每个ID构造请求。同样加入了错误处理和延时。enumerate函数用来显示当前进度,在处理大量数据时非常有用。

3.4 数据存储与主流程

数据抓取回来后,我们需要将其持久化保存。JSON格式是个不错的选择,它易于阅读,也方便后续用Python或其他工具处理。

def save_to_json(data: List[Dict], filename: str = 'cosmetic_licenses.json'):
    """将数据保存为JSON文件"""
    try:
        with open(filename, 'w', encoding='utf-8') as f:
            # ensure_ascii=False 确保中文正常显示
            # indent=2 让JSON文件有缩进,更美观
            json.dump(data, f, ensure_ascii=False, indent=2)
        print(f'数据已成功保存至文件: {filename}')
    except IOError as e:
        print(f'保存文件时发生错误: {e}')

def main():
    """主函数,串联整个抓取流程"""
    print("开始化妆品生产许可证数据抓取任务...")
    
    # 步骤1: 获取企业ID列表
    company_ids = fetch_company_ids(start_page=1, end_page=3)  # 先抓3页作为演示
    
    if not company_ids:
        print("未获取到任何企业ID,程序退出。")
        return
    
    # 步骤2: 根据ID获取详情
    all_details = fetch_company_details(company_ids)
    
    # 步骤3: 保存数据
    if all_details:
        save_to_json(all_details)
        
        # 简单预览一下第一条数据
        print("\n数据预览(第一条记录的部分字段):")
        sample = all_details[0]
        preview_fields = ['epsName', 'productSn', 'xkzh', 'qfManagerName']
        for field in preview_fields:
            print(f"  {field}: {sample.get(field, 'N/A')}")
    else:
        print("未获取到任何详情数据。")
    
    print("抓取任务结束。")

if __name__ == '__main__':
    main()

主函数main()清晰地展示了整个工作流。保存数据时使用了json.dumpindent参数,让生成的JSON文件不是紧凑的一行,而是有缩进格式,便于人工查阅。

4. 进阶技巧与避坑指南

基本的爬虫跑起来了,但在实际项目中,你可能会遇到更多问题。下面分享几个进阶技巧和常见坑点。

4.1 处理请求头与反爬策略

我们之前只设置了最基本的User-Agent。有些网站会检查更多的请求头。一个更完整的、模拟Chrome浏览器的请求头可以这样设置:

ADVANCED_HEADERS = {
    'Accept': 'application/json, text/javascript, */*; q=0.01',
    'Accept-Encoding': 'gzip, deflate',
    'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
    'Connection': 'keep-alive',
    'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
    'X-Requested-With': 'XMLHttpRequest', # 表明这是一个Ajax请求
    'Host': '125.35.6.84:81',
    'Origin': 'http://125.35.6.84:81',
    'Referer': 'http://125.35.6.84:81/xk/',
}

其中X-Requested-With: XMLHttpRequest是标识Ajax请求的关键头。如果遇到请求失败,可以尝试将这些头信息全部复制过来。

4.2 使用Session保持会话

如果网站需要登录或使用会话(Session),requests.Session()可以帮你自动管理cookies,让多次请求处于同一个会话中。

def fetch_with_session():
    """使用Session对象进行请求示例"""
    session = requests.Session()
    session.headers.update(ADVANCED_HEADERS)
    
    # 第一个请求(如果需要登录或初始化会话)
    # init_resp = session.get(LOGIN_URL)
    
    # 后续的请求会自动携带cookies
    list_resp = session.post(LIST_API, data={'page':'1', 'pageSize':'15'})
    # ... 处理响应

对于我们这个案例网站,可能不需要Session,但知道这个工具在应对复杂场景时很有用。

4.3 应对频率限制与IP封禁

大规模抓取时,最常遇到的就是访问频率限制。除了简单的time.sleep,还有更智能的策略:

  • 随机延时: 让请求间隔时间不那么规律。
    import random
    time.sleep(random.uniform(0.5, 2.0))  # 随机休眠0.5到2秒
    
  • 代理IP池: 当单个IP被封锁时,轮换使用不同的代理IP是终极解决方案。你可以使用一些付费或免费的代理服务。
    PROXIES = {
        'http': 'http://your-proxy-ip:port',
        'https': 'https://your-proxy-ip:port',
    }
    response = requests.post(API_URL, data=post_data, headers=HEADERS, proxies=PROXIES)
    
  • 捕获异常并重试: 使用tenacityretrying库,为请求添加重试机制,当遇到网络波动或短暂封锁时自动重试几次。

4.4 数据清洗与结构化存储

我们保存的是原始的JSON,但分析时可能需要更结构化的数据,比如存入CSV或数据库。这里演示如何将JSON数据转换为CSV:

import csv

def json_to_csv(json_filename, csv_filename):
    """将JSON文件转换为CSV文件"""
    with open(json_filename, 'r', encoding='utf-8') as f:
        data = json.load(f)
    
    if not data:
        return
    
    # 获取所有可能的字段(取第一条数据的键)
    fieldnames = set()
    for item in data:
        fieldnames.update(item.keys())
    fieldnames = list(fieldnames)
    
    with open(csv_filename, 'w', newline='', encoding='utf-8-sig') as f: # utf-8-sig支持Excel中文
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(data)
    
    print(f'CSV文件已生成: {csv_filename}')

这个函数会遍历所有JSON条目,收集所有出现过的字段名作为CSV的表头,确保不遗漏任何信息。

4.5 调试与日志记录

print语句替换为更专业的日志记录,可以方便地控制输出级别(调试、信息、错误),并将日志保存到文件。

import logging

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('spider.log'),
        logging.StreamHandler() # 同时输出到控制台
    ]
)
logger = logging.getLogger(__name__)

# 在代码中使用
logger.info(f'正在抓取第 {page} 页列表...')
logger.error(f'请求发生错误: {e}')

整个流程走下来,你会发现核心的代码其实并不多,大部分工作是在分析和调试。我习惯把这种爬虫脚本保存在一个专门的项目文件夹里,并附上一个README.md文件,记录目标网站、接口分析过程、关键参数以及脚本的使用方法。这样即使过了几个月再回头看,或者分享给同事,也能立刻上手。数据抓取就像解谜,找到那个正确的接口,一切就水到渠成了。下次遇到动态网站,不妨先打开开发者工具的Network标签页看看,惊喜往往就藏在那些XHR请求里。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐