1. 项目缘起为什么动态网站是爬虫的“硬骨头”做爬虫的朋友尤其是刚入门的朋友大概率都经历过这个阶段兴冲冲地写了几行requests.get()代码准备大展身手结果发现目标网站返回的HTML里空空如也只有几行看不懂的JavaScript代码或者一个孤零零的div idapp/div。你明明在浏览器里能看到满屏的数据怎么用代码一抓就没了恭喜你你遇到了现代Web开发中最常见的“动态网站”。这其实就是我们今天要啃的“硬骨头”。传统的静态网站服务器返回的HTML里就包含了所有数据爬虫直接解析HTML就能拿到东西。但动态网站不同它采用了前后端分离的架构。服务器第一次只返回一个“空壳”页面和一堆JavaScript脚本。浏览器运行这些脚本后脚本会再去向服务器发起请求通常是AJAX请求获取真正的数据通常是JSON格式然后再由JavaScript动态地渲染到页面上。所以你用requests直接请求初始的HTML自然看不到数据因为数据是后来才“画”上去的。我刚开始做爬虫时也在这个坑里摔得不轻。当时想爬一个电商网站的价格数据用requests和BeautifulSoup折腾了半天啥也没拿到。后来打开浏览器的开发者工具在“网络”Network标签页里刷新页面才看到了一堆XHRAJAX请求里面赫然躺着包含价格的JSON数据。那一刻我才算真正理解了“动态”的含义。所以爬取动态网站的核心思路已经从“解析HTML”转变为了“模拟浏览器行为”或“直接找到数据接口”。2. 核心武器库从Selenium到Playwright的进化之路面对动态内容我们主要有两大派系的武器无头浏览器和直接调用接口。每种选择背后都有其逻辑和适用场景选错了工具事倍功半。2.1 无头浏览器所见即所得的“重型坦克”无头浏览器的思路最直接既然数据是浏览器渲染出来的那我就用一个真正的、但没有界面的浏览器去访问页面等它把所有JavaScript都执行完页面内容完全加载后我再从它的内存里把最终的HTML抓出来。这是最通用、最“暴力”但也最可靠的方法。Selenium老牌王者生态极其丰富支持多种语言Python, Java, C#等和多种浏览器Chrome, Firefox, Edge等。它的优势在于稳定和广泛的社区支持几乎所有你能想到的浏览器自动化问题都能找到Selenium的解决方案或代码片段。但它的缺点也很明显速度相对较慢资源占用高并且需要为对应的浏览器安装单独的驱动如chromedriver。Playwright后起之秀由微软开发。它可以说是为现代Web和爬虫场景量身定做的。Playwright最大的特点是快和功能强大。它内置了对Chrome、Firefox和WebKitSafari内核的支持无需单独管理驱动。其API设计更现代自动等待机制更智能后面会详细说并且原生支持拦截和修改网络请求这个功能在爬虫中简直是神器。在大多数新项目中如果你需要无头浏览器我首推Playwright。注意无头浏览器方案虽然强大但它是“重型”方案。它会完整启动一个浏览器实例占用几百MB内存。如果你的爬虫任务需要高并发同时开几十上百个页面或者目标网站反爬不严、有直接的数据接口那么无头浏览器可能不是最优解。2.2 直接调用接口“狙击手”的精准打击这是更高效、更优雅的方法但需要一些前期侦查工作。核心思路是绕过浏览器渲染的步骤直接找到JavaScript获取数据的那个后端接口API然后模拟这个接口的请求。如何找到接口这就是“侦查”过程打开目标网站按F12打开开发者工具。切换到“网络”Network标签页。刷新页面或触发你想要爬取数据的操作如点击“加载更多”。在纷繁复杂的请求列表中寻找类型为XHR或Fetch的请求。这些通常就是AJAX请求。点击其中一个查看它的“标头”Headers和“响应”Response。在“标头”里你需要重点关注请求URL、请求方法GET/POST、以及请求头特别是Cookie,User-Agent,Referer, 以及一些自定义的认证头如Authorization或X-Token。在“响应”里你就能看到服务器返回的原始数据通常是JSON格式。一旦你成功复制了这个请求的所有必要信息URL、方法、头、可能的请求体你就可以用requests库来模拟这个请求直接拿到结构化的JSON数据连HTML解析都省了。这种方法速度快、资源消耗低是爬虫工程师追求的理想状态。但缺点也很明显如果网站接口参数复杂例如有加密签名或者严重依赖浏览器环境如需要执行JS生成Token那么模拟起来会非常困难。在实际项目中我通常会先用“直接调用接口”的方式尝试如果走不通或者太复杂再fallback到无头浏览器方案。两者并非互斥有时甚至需要结合使用。3. 实战演练用Playwright爬取一个动态渲染的商品列表光说不练假把式我们用一个模拟场景来实战。假设我们要爬取一个类似电商网站的商品列表页该页面是滚动加载的。3.1 环境搭建与基础配置首先安装Playwright。这里强烈建议使用pip安装并同时安装它自带的浏览器。pip install playwright playwright install chromium # 安装Chromium浏览器也可以安装 firefox 或 webkit接下来我们编写一个最基本的脚本打开页面并获取完整渲染后的内容。from playwright.sync_api import sync_playwright import time def crawl_dynamic_page(url): with sync_playwright() as p: # 启动浏览器headlessFalse表示显示界面便于调试。实际运行时可设为True。 browser p.chromium.launch(headlessFalse, slow_mo100) # slow_mo 让动作慢一点方便观察 page browser.new_page() try: # 导航到目标页面 page.goto(url) print(f已访问页面: {page.title()}) # **关键点1等待页面加载** # 等待某个特定元素出现比如商品列表的容器。比单纯用time.sleep()可靠得多。 page.wait_for_selector(.product-list, timeout10000) # 等待10秒 # **关键点2模拟滚动加载** # 假设页面是滚动加载我们需要滚动到底部多次触发加载。 last_height page.evaluate(document.body.scrollHeight) while True: # 滚动到底部 page.evaluate(window.scrollTo(0, document.body.scrollHeight)) # 等待新内容加载 page.wait_for_timeout(2000) # 等待2秒 new_height page.evaluate(document.body.scrollHeight) if new_height last_height: print(已滚动到底部没有新内容加载。) break last_height new_height print(f页面高度更新为: {new_height}) # 此时页面所有商品应该都已加载完毕 # 获取完整HTML html_content page.content() # 这里可以接上BeautifulSoup或lxml进行解析 # soup BeautifulSoup(html_content, lxml) # products soup.select(.product-item) # for product in products: # name product.select_one(.product-name).text # price product.select_one(.product-price).text # print(name, price) # 为了演示我们直接通过Playwright的API来提取元素 product_elements page.query_selector_all(.product-item) print(f共找到 {len(product_elements)} 个商品) for i, element in enumerate(product_elements[:5]): # 只打印前5个 name element.query_selector(.product-name).inner_text() price element.query_selector(.product-price).inner_text() print(f{i1}. 商品名: {name}, 价格: {price}) except Exception as e: print(f爬取过程中出现错误: {e}) finally: # 关闭浏览器 browser.close() if __name__ __main__: # 替换成你要爬的目标URL target_url https://example-shop.com/products crawl_dynamic_page(target_url)这段代码包含了几个关键点等待策略使用page.wait_for_selector()代替time.sleep()。这是爬虫稳定性的基石。time.sleep(5)意味着死等5秒如果网络慢5秒可能不够如果网络快又白白浪费了时间。而wait_for_selector会一直等待直到指定的CSS选择器对应的元素出现在DOM中或者超时。这符合“页面就绪”的业务逻辑。模拟交互通过page.evaluate()执行JavaScript代码来模拟滚动。window.scrollTo是浏览器原生API。我们通过判断滚动前后页面高度的变化来判断是否还有新内容加载。内容提取提供了两种方式。一是通过page.content()拿到最终HTML再用解析库处理二是直接使用Playwright强大的query_selector和query_selector_allAPI在页面上下文中直接提取这种方式更简洁且能处理一些复杂的动态属性。3.2 应对反爬伪装与节奏控制现代网站没有反爬措施的已经不多了。即使是用无头浏览器也可能被检测到。我们需要做一些伪装。def crawl_with_stealth(url): with sync_playwright() as p: # 1. 使用更真实的启动参数 browser p.chromium.launch( headlessTrue, # 生产环境用True args[ --disable-blink-featuresAutomationControlled, # 禁用自动化控制特征 --start-maximized ] ) # 2. 创建上下文可以设置更全局的伪装 context browser.new_context( viewport{width: 1920, height: 1080}, user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, # 可以加载存储的cookies文件模拟已登录状态 # storage_statecookies.json ) # 3. 注入JS覆盖navigator.webdriver属性这是最常见的无头浏览器检测点 context.add_init_script( Object.defineProperty(navigator, webdriver, { get: () undefined }); ) page context.new_page() # 4. 设置额外的请求头 page.set_extra_http_headers({ Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, br, Referer: https://www.google.com/, # 模拟从搜索引擎跳转而来 Sec-Ch-Ua: Not_A Brand;v8, Chromium;v120, Google Chrome;v120, # 注意这些头可能会变 Sec-Ch-Ua-Mobile: ?0, Sec-Ch-Ua-Platform: Windows, }) try: page.goto(url) # 5. 随机化操作间隔模拟人类行为 import random page.wait_for_load_state(networkidle) # 等待网络空闲 page.mouse.move(random.randint(100, 500), random.randint(100, 300)) page.wait_for_timeout(random.randint(500, 1500)) # ... 后续操作都加入随机延迟 ... finally: context.close() browser.close()此外控制请求节奏至关重要。不要像机枪一样连续请求。在关键操作如翻页、点击加载更多之间使用page.wait_for_timeout(random.randint(1000, 3000))加入随机延迟。对于大规模爬取可以考虑使用更复杂的代理IP池和用户代理User-Agent轮换策略这些可以通过Playwright的context级别进行设置。4. 高阶技巧拦截网络请求与异步加速当你对Playwright越来越熟悉就可以玩一些更高级的操作大幅提升爬虫的效率和能力。4.1 拦截与修改请求直击数据源头这是Playwright相比Selenium的一大优势。我们可以在页面发起请求前就拦截它然后直接分析甚至修改它。这对于直接获取API数据和屏蔽无用资源如图片、CSS、广告非常有用。def intercept_and_capture_data(url): with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() # 存储我们感兴趣的API响应数据 captured_data [] # 定义路由处理函数 def handle_route(route): request route.request # 只拦截包含特定关键词的API请求 if /api/products in request.url: print(f拦截到API请求: {request.url}) # 继续这个请求并获取响应 response route.continue_() # 等待响应完成 # 注意这里需要异步处理我们使用同步API的变通方式 # 更优雅的方式是使用异步API (async/await) # 这里为了演示同步模式我们先继续请求然后在外面通过事件监听来捕获响应 pass else: # 对于其他请求如图片、样式表可以选择直接放弃加载以加快速度 if request.resource_type in [image, stylesheet, font, media]: # print(f中止加载非必要资源: {request.url}) route.abort() else: route.continue_() # 启用请求拦截 page.route(**/*, handle_route) # 监听响应事件来捕获数据 def on_response(response): if /api/products in response.url and response.status 200: # 尝试以JSON格式解析响应 try: json_data response.json() captured_data.append(json_data) print(f捕获到API数据包含 {len(json_data.get(items, []))} 条商品) except: # 如果不是JSON可以查看文本 text_data response.text() print(f捕获到文本响应长度: {len(text_data)}) page.on(response, on_response) page.goto(url) page.wait_for_timeout(5000) # 给页面足够时间发起API请求 print(f共捕获到 {len(captured_data)} 个API响应) # 处理 captured_data... browser.close()通过拦截我们可能直接在页面加载过程中就拿到了结构化的商品列表JSON完全无需再解析HTML效率极高。同时通过中止图片等资源的加载页面加载速度能快上好几倍。4.2 异步并发让爬虫飞起来同步模式sync_playwright下浏览器操作是阻塞的。对于需要爬取大量独立页面的任务我们可以使用异步模式async_playwright结合asyncio同时控制多个浏览器页面甚至多个浏览器实例实现并发爬取。import asyncio from playwright.async_api import async_playwright async def crawl_single_page(page, url, semaphore): async with semaphore: # 用信号量控制并发数避免开太多页面把机器搞崩 try: await page.goto(url, wait_untilnetworkidle) # 执行页面上的操作如滚动、点击等 # ... content await page.content() # 解析content... print(f成功爬取: {url}) return content except Exception as e: print(f爬取 {url} 失败: {e}) return None async def main(url_list): async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) context await browser.new_context() semaphore asyncio.Semaphore(5) # 最大并发5个页面 tasks [] for url in url_list: page await context.new_page() task asyncio.create_task(crawl_single_page(page, url, semaphore)) tasks.append(task) # 注意这里创建了页面但在任务函数里没有关闭。需要在所有任务完成后统一关闭或者在每个任务函数内关闭。 results await asyncio.gather(*tasks) await context.close() await browser.close() return results if __name__ __main__: urls [https://example.com/page1, https://example.com/page2, ...] # 你的URL列表 results asyncio.run(main(urls))使用异步并发时必须注意资源管理。不要一次性创建成百上千个页面你的内存会撑不住的。使用信号量Semaphore来控制并发度是非常必要的。同时要确保在任务完成后正确关闭页面和浏览器上下文避免资源泄漏。5. 从Playwright回归Requests逆向分析与接口调用无头浏览器虽好但终究是“重型武器”。在很多情况下经过仔细的逆向工程我们可以用轻量级的requests库解决问题。这就像从“坦克平推”变成了“特种部队潜入”。5.1 接口逆向实战以某个内容网站为例假设我们要爬取一个新闻网站的文章列表该网站采用滚动加载。我们首先用浏览器开发者工具进行侦查。定位接口打开Network面板清空记录滚动页面触发加载。发现一个名为get_article_list的XHR请求方法为POST返回JSON。分析请求点击该请求查看Headers。Request URL:https://api.example-news.com/v1/articlesRequest Method: POSTQuery String Parameters: 可能包含page,size,category_id等。Request Headers: 重点关注Authorization: Bearer xxxx(Token),User-Agent,Content-Type: application/json。Request Payload: 查看Payload标签发现是JSON格式包含{page: 2, size: 20, sort: new}。模拟请求尝试用Python模拟这个请求。import requests import json def fetch_articles_via_api(base_url, page1, size20): api_url f{base_url}/v1/articles headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36..., Authorization: Bearer YOUR_ACCESS_TOKEN_HERE, # 关键如何获取 Content-Type: application/json, Referer: https://www.example-news.com/, } payload { page: page, size: size, sort: new } response requests.post(api_url, headersheaders, jsonpayload, timeout10) if response.status_code 200: return response.json() # 直接得到结构化数据 else: print(f请求失败状态码: {response.status_code}) print(response.text) return None # 使用 data fetch_articles_via_api(https://api.example-news.com, page1) if data: for article in data.get(items, []): print(article[title], article[publish_time])现在最大的问题来了Authorization: Bearer Token从哪里来这通常是用户登录后服务器颁发的一个凭证。获取方式通常有几种手动复制在已登录的浏览器中从开发者工具的请求头里复制出来。但Token会过期。模拟登录用requests或Selenium/Playwright模拟登录流程从登录成功的响应中提取Token。这需要分析登录接口。寻找公开接口有些网站的列表接口可能不需要认证或者认证很弱如一个固定的API Key。这就需要仔细测试和观察。5.2 处理复杂参数加密与签名有些网站的接口为了安全会对参数进行加密或签名。例如你可能在Payload里看到一堆看不懂的字符串或者在URL里有一个长长的、看起来随机的sign参数。这时逆向工程就变成了JavaScript逆向。你需要找到生成这些加密参数的JavaScript代码。在开发者工具的“源代码”Sources标签页中搜索关键参数名如sign,encrypt,token或者使用“搜索所有文件”功能。找到相关的JS函数后分析其逻辑。处理方式有直接调用JS使用execjs或PyExecJS库在Python环境中执行这些JS函数来计算参数。这要求JS代码不依赖复杂的浏览器环境。Python重写如果加密逻辑不复杂如简单的MD5、Base64、AES可以用Python的加密库如hashlib,pycryptodome重新实现一遍。终极方案如果JS混淆严重、依赖环境多可能不得不退回到无头浏览器方案让浏览器去执行这些逻辑然后你再从发起的请求中“窃取”已经计算好的参数。Playwright的请求拦截功能在这里又能派上用场。6. 工程化与伦理考量让爬虫走得更远写一个能跑的脚本只是第一步。要让爬虫稳定、可维护、可持续还需要考虑工程化和伦理问题。6.1 基础工程化实践配置管理将URL、请求头、关键词、数据库连接信息等抽离到配置文件如config.yaml或.env文件中避免硬编码。日志记录使用Python的logging模块而不是到处用print()。记录信息、警告、错误便于后期排查问题。异常处理与重试网络请求不稳定必须加入重试机制。可以使用tenacity或backoff库来实现带指数退避的优雅重试。数据存储根据数据量选择存储方式。小量数据可以用JSON或CSV文件结构化数据建议用SQLite或MySQL非结构化或大量数据可以考虑MongoDB。使用SQLAlchemy或pymongo这样的ORM/驱动来操作。任务调度对于定期爬取的任务可以使用APScheduler或Celery进行调度或者在服务器上配置cron任务。6.2 遵守Robots协议与法律边界这是爬虫工程师的必修课也是职业生命的保障。尊重robots.txt在爬取任何网站前先访问https://目标网站/robots.txt。这个文件规定了哪些目录允许爬取哪些禁止。虽然这不是法律文件但遵守它是行业共识和基本礼仪。控制访问频率这是最重要的伦理和技术守则。疯狂的请求会构成拒绝服务攻击对目标网站造成伤害。务必在请求间添加延迟并尽量在网站流量低谷期如凌晨进行爬取。识别并遵守服务条款很多网站的用户协议中明确禁止爬虫。在爬取前请阅读相关条款。数据用途爬取的数据应用于个人学习、研究或公益目的。切勿用于商业牟利、侵犯隐私或从事其他非法活动。对于明确声明版权所有的内容如小说、付费论文更应谨慎。识别反爬与应对如果网站采取了明显的反爬措施如返回403、要求验证码、封禁IP首先应该反思自己的行为是否过于激进。降低频率、更换User-Agent、使用优质代理IP是常见手段。但如果对方明确拒绝最好的选择是停止爬取或尝试联系网站所有者获取许可。爬虫技术是一把双刃剑。用它来聚合公开信息、进行学术研究、做价格监控能创造巨大价值。但一旦越过边界就可能触及法律红线。我的原则始终是技术为善取之有道用之有度。在开始任何一个爬虫项目前花点时间想想这些伦理和法律问题远比写出一个高效的爬虫程序更重要。