从"抓不到"到"抓得全":requests-html网页解析实战,3招拿下JS动态数据
【免费下载链接】requests-htmlPythonic HTML Parsing for Humans™项目地址: https://gitcode.com/gh_mirrors/re/requests-html
凌晨一点,同事把一段爬虫代码拍在我桌上:"帮我看下,这个页面明明有数据,我抓出来却是空的。"他抓的是一个内部培训站的课程列表,浏览器里整整齐齐排着三十行课程,requests拿回来的 HTML 里却只有一张空壳。这种"浏览器有、源码没有"的诡异现象,几乎每个做数据抓取的人都撞过——问题不在网络请求,而在 JavaScript 渲染。这篇文章就用 requests-html 这个网页解析库,从一次真实救火出发,把静态抓取、JS 渲染、异步批量这三关逐一打通。
先搞清"抓不到"的三种病因
排查问题之前,先给数据抓取做个快速体检。网页上的数据大体分三种,对应三种完全不同的处理策略:
| 数据形态 | 典型特征 | 传统方案是否有效 |
|---|---|---|
| 纯静态 HTML | 数据写在返回的源码里 | ✅ 有效 |
| 懒加载 / 异步接口 | 滚动后 data 才从接口拉回 | ❌ 拿不到 |
| JS 动态渲染 | DOM 由脚本生成 | ❌ 拿不到 |
同事抓的就是第三种。他的原方案是经典的"三件套":urlopen拿源码、正则抠字符串、BeautifulSoup 收尾。这套组合对付静态页面绰绰有余,但遇到前端框架(React/Vue 之类)生成的页面就彻底哑火——服务端返回的是空骨架,真正的数据要靠浏览器里的 JavaScript 去填充。
要治这种病,思路只有一条:把"发请求"和"跑浏览器"合并成一件事。requests-html 干的就是这个活:它外面套着 requests 的皮,里面装着解析引擎,还内置了一个无头 Chromium,能在抓取时像真人浏览器一样把脚本执行完。
第一关:HTMLSession 取代裸 requests,三行代码建会话
先看最基础的动作。requests-html 对 requests 的老用户几乎零迁移成本,把requests.get()换成session.get()即可,返回的对象在请求能力之外多了一个会解析的.html属性:
from requests_html import HTMLSession session = HTMLSession() response = session.get("https://python.org/") # 这行是关键:响应对象自带解析能力 page = response.html # 一行拿到全部链接(自动去重、剔除锚点) print(len(page.links)) print(len(page.absolute_links)) # 绝对地址版本HTMLSession并不是简单包一层:它会自动模拟浏览器 User-Agent、自动跟随重定向、复用连接池、持久化 Cookie。这些特性在后面的反爬环节都会派上用场。
第二关:CSS 选择器与 XPath,两把"手术刀"交替用
会话建好了,接下来是定位数据。requests-html 的find()方法在项目源码 requests_html.py 的BaseParser类中定义,支持完整的 CSS 选择器语法,用法和 jQuery 几乎一致:
# 取第一个 #about 元素 about = page.find("#about", first=True) # 按 class 筛选所有链接 nav_links = page.find("nav a") # 按属性筛选:找所有外链 external = page.find('a[href^="http"]') # 按包含文本筛选:找含 "python" 的元素 matches = page.find(containing="python")定位到元素后,.text拿文本、.attrs拿属性字典、.html拿内层 HTML,.links和.absolute_links拿元素内的链接集合。项目测试文件 tests/test_requests_html.py 里有一整套断言,可以当用法速查表来读。
习惯 XPath 的也不用纠结,xpath()方法平行存在,项目文档 docs/source/index.rst 里两者都有示例:
# XPath 同样支持,拿整个文档根节点 root = page.xpath("/html", first=True) print(root.attrs["class"]) # 输出 no-js # 直接取所有 a 标签的 href 属性值列表 hrefs = page.xpath("//a/@href")第三关:render() 唤醒沉睡的数据——真正的重头戏
现在回到同事的问题。静态抓取解决了,但目标站点是 JS 渲染的,怎么办?答案是render()方法——它在源码 requests_html.py 的HTML类中实现,会启动无头 Chromium,把页面完整执行一遍再拿回渲染后的 DOM:
# 渲染整页,等 JS 跑完 response.html.render( retries=3, # 加载失败自动重试 wait=1, # 打开页面后先等 1 秒 scrolldown=2, # 向下滚动 2 次,触发懒加载 sleep=1 # 每次滚动后歇 1 秒 ) # 渲染之后,之前空荡荡的选择器终于有结果了 courses = response.html.find(".course-item") print(f"渲染后抓到 {len(courses)} 个课程")❗注意:render()首次运行会往主目录(~/.pyppeteer/)下载一份 Chromium,耗时几分钟,之后就不再下载。在服务器上跑之前先确认磁盘和网络环境。
render()的几个参数对应着真实用户的行为:scrolldown模拟滚动(很多页面滚动到哪才加载到哪)、sleep给异步请求留出返回时间、retries兜底页面偶发超时。如果页面滚动后仍缺数据,把scrolldown和sleep一起调大,通常能解决九成问题。
⚡进阶玩法:render()还能直接执行自定义 JavaScript 并把返回值带回来,等于在浏览器里装了个"数据探头":
script = """ () => { const items = document.querySelectorAll(".course-item"); return items.map(el => ({ title: el.querySelector(".title").textContent, url: el.querySelector("a").href, })); } """ result = response.html.render(script=script) print(result) # 直接拿到结构化列表相对链接不用愁:一个方法自动转绝对地址
抓链接时最烦的一件事:页面里全是/course/42这种相对路径,存下来没法直接用。requests-html 内置的_make_absolute()(同样定义在BaseParser中)专门治这个,测试文件里test_links就是靠它把 6 个相对链接全部转成了绝对地址:
from requests_html import HTML # 不经过网络,直接解析 HTML 字符串也行 doc = """<a href='/course/42'>Python 入门</a>""" html = HTML(html=doc, url="https://example.com/") # 相对路径自动拼接成完整 URL for link in html.links: print(link) # /course/42 print(html._make_absolute(link)) # https://example.com/course/42_make_absolute()的实现逻辑很实在:没有域名就拼上基础地址,有域名缺协议就补http(s),已经完整的原样返回。比自己手写urljoin省心得多。
批量抓取提速:AsyncHTMLSession 一次开多个页面
同事的需求很快变成了"帮我把十个栏目都抓下来"。这时候逐页串行抓就太慢了——每页都要等渲染,动辄几秒。requests-html 自带异步方案AsyncHTMLSession,对应源码里的arender()方法:
import asyncio from requests_html import AsyncHTMLSession asession = AsyncHTMLSession() async def fetch_courses(url): response = await asession.get(url) # 异步渲染,效果等同 render() await response.html.arender(scrolldown=1, sleep=1) return { "url": response.html.url, "count": len(response.html.find(".course-item")), } async def main(): urls = [ "https://example.com/python", "https://example.com/data", "https://example.com/web", ] # 并发跑起来,各自返回结果 results = await asyncio.gather(*[fetch_courses(u) for u in urls]) for r in results: print(r) asyncio.run(main())💡提示:异步版本能并发但也要节制,目标站点扛不住瞬时压力会直接把你 IP 拉黑。批量大时建议分批(每批 5~10 个页面)并加随机延时。
把前面全串起来:一个能直接跑的通关脚本
下面这个脚本把三关全打通——静态抓取、JS 渲染、批量并发,加上去重和结果输出,就是同事最终交出去的版本:
from requests_html import HTMLSession, AsyncHTMLSession import asyncio BASE = "https://example.com/training" def extract_from_page(html): """从渲染完成的页面里抠出课程清单""" items = [] for el in html.find(".course-item"): title_el = el.find(".title", first=True) link_el = el.find("a", first=True) if title_el is not None: items.append({ "title": title_el.text.strip(), "url": html._make_absolute(link_el.attrs["href"]), }) return items def fetch_static(url): """第一关:不渲染,直接抓静态内容""" session = HTMLSession() response = session.get(url) session.close() return response.html def fetch_dynamic(url): """第二关:渲染 JS 后再抓""" session = HTMLSession() response = session.get(url) response.html.render(scrolldown=2, sleep=1) session.close() return response.html async def fetch_many(urls): """第三关:异步并发批量抓""" asession = AsyncHTMLSession() async def one(url): response = await asession.get(url) await response.html.arender(scrolldown=1, sleep=1) return extract_from_page(response.html) return await asyncio.gather(*[one(u) for u in urls]) if __name__ == "__main__": # 单页 + 渲染 html = fetch_dynamic(f"{BASE}/index.html") print("本页课程数:", len(extract_from_page(html))) # 批量异步 pages = [f"{BASE}/list/{i}.html" for i in range(1, 4)] all_items = asyncio.run(fetch_many(pages)) merged = {item["url"]: item for batch in all_items for item in batch} print("批量去重后共:", len(merged), "门课程") for item in list(merged.values())[:5]: print("-", item["title"])三个实战中高频踩到的坑
坑一:抓回来还是空。先确认是不是render()没等够。用response.html.search("xxx...{}yyy")或直接len(html.text)对比渲染前后的内容量,能快速判断"没抓到"还是"没渲染完"。项目 README 里那个 pythonclock 例子就是标准诊断流程。
坑二:被反爬拦截。先把render()停掉观察——有时数据其实在静态页里,渲染反而触发风控。请求头方面,HTMLSession默认已经带了浏览器风格的 User-Agent,如果还不行,在会话上手动补Accept和Accept-Language,并优先通过 Cookie 保持会话状态。
坑三:下载音频、文件这类资源。抓链接只是第一步,真正下载时注意用流式写入,别一把梭进内存,同时控制并发,别让目标服务器过载。
让这套抓取方案自己"转"起来
同事的脚本最后跑在了服务器上,配了一个简单的定时任务,每天凌晨抓一次更新课程表,数据落成 JSON 供内部系统消费。想更进一步,你可以把抓下来的数据接上 SQLite 做增量归档、接到企业微信/钉钉机器人做变更推送,或者把课程页面整页导出成 Markdown 存档。
requests-html 的价值在于它把"请求—解析—渲染"揉成了一个顺手的小工具:静态页面用find(),动态页面加一个render(),量大就换AsyncHTMLSession。同样的三板斧,换成抓图片、抓视频、抓文档,思路完全一致,只是选择器里的标签名不同。下次再遇到"浏览器有、源码没有"的页面,别急着上 Selenium 全家桶——先试试把浏览器塞进 requests 里,三行代码,问题可能就没了。
【免费下载链接】requests-htmlPythonic HTML Parsing for Humans™项目地址: https://gitcode.com/gh_mirrors/re/requests-html
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考