怎么用Python写一个浏览器集群框架
这是做什么用的
框架用途
在采集大量新闻网站时,不可避免的遇到动态加载的网站,这给配模版的人增加了很大难度。本来配静态网站只需要两个技能点:xpath和正则,如果是动态网站的还得抓包,遇到加密的还得js逆向。
所以就需要用浏览器渲染这些动态网站,来减少了配模板的工作难度和技能要求。动态加载的网站在新闻网站里占比很低,需要的硬件资源相对于一个人工来说更便宜。
实现方式
采集框架使用浏览器渲染有两种方式,一种是直接集成到框架,类似GerapyPyppeteer,这个项目你看下源代码就会发现写的很粗糙,它把浏览器放在_process_request方法里启动,然后采集完一个链接再关闭浏览器,大部分时间都浪费在浏览器的启动和关闭上,而且采集多个链接会打开多个浏览器抢占资源。
另一种则是将浏览器渲染独立成一个服务,类似scrapy-splash,这种方式比直接集成要好,本来就是两个不同的功能,实际就应该解耦成两个单独的模块。不过听前辈说这东西不太好用,会有内存泄漏的情况,我就没测试它。
自己实现
原理:在自动化浏览器中嵌入http服务实现http控制浏览器。这里我选择aiohttp+pyppeteer。之前看到有大佬使用go的rod来做,奈何自己不会go语言,还是用Python比较顺手。
后面会考虑用playwright重写一遍,pyppeteer的github说此仓库不常维护了,建议使用playwright。
开始写代码
web服务
from aiohttp import web
app = web.Application()
app.router.add_view('/render.html', RenderHtmlView)
app.router.add_view('/render.png', RenderPngView)
app.router.add_view('/render.jpeg', RenderJpegView)
app.router.add_view('/render.json', RenderJsonView)
然后在RenderHtmlView类中写/render.html请求的逻辑。/render.json是用于获取网页的某个ajax接口响应内容。有些情况网页可能不方便解析,想拿到接口的json响应数据。
初始化浏览器
浏览器只需要初始化一次,所以启动放到on_startup,关闭放到on_cleanup
c = LaunchChrome()
app.on_startup.append(c.on_startup_tasks)
app.on_cleanup.append(c.on_cleanup_tasks)
其中on_startup_tasks和on_cleanup_tasks方法如下:
async def on_startup_tasks(self, app: web.Application) -> None:
page_count = 4
await asyncio.create_task(self._launch())
app["browser"] = self.browser
tasks = [asyncio.create_task(self.launch_tab()) for _ in range(page_count-1)]
await asyncio.gather(*tasks)
queue = asyncio.Queue(maxsize=page_count+1)
for i in await self.browser.pages():
await queue.put(i)
app["pages_queue"] = queue
app["screenshot_lock"] = asyncio.Lock()
async def on_cleanup_tasks(self, app: web.Application) -> None:
await self.browser.close()
page_count为初始化的标签页数,这种常量一般定义到配置文件里,这里我图方便就不写配置文件了。
首先初始化所有的标签页放到队列里,然后存放在app这个对象里,这个对象可以在RenderHtmlView类里通过self.request.app访问到, 到时候就能控制使用哪个标签页来访问链接
我还初始化了一个协程锁,后面在RenderPngView类里截图的时候会用到,因为多标签不能同时截图,需要加锁。
超时停止页面继续加载
async def _goto(self, page: Optional[Page], options: AjaxPostData) -> Dict:
try:
await page.goto(options.url,
waitUntil=options.wait_util, timeout=options.timeout*1000)
except PPTimeoutError:
#await page.evaluate('() => window.stop()')
await page._client.send("Page.stopLoading")
finally:
page.remove_all_listeners("request")
有时间页面明明加载出来了,但还在转圈,因为某个图片或css等资源访问不到,强制停止加载也不会影响到网页的内容。
Page.stopLoading和window.stop()都可以停止页面继续加载,忘了之前为什么选择前者了
定义请求参数
class HtmlPostData(BaseModel):
url: str
timeout: float = 30
wait_util: str = "domcontentloaded"
wait: float = 0
js_name: str = ""
filters: List[str] = []
images: bool = 0
forbidden_content_types: List[str] = ["image", "media"]
cache: bool = 1
cookie: bool = 0
text: bool = 1
headers: bool = 1
url: 访问的链接timeout: 超时时间wait_util: 页面加载完成的标识,一般都是domcontentloaded,只有截图的时候会选择networkidle2,让网页加载全一点。更多的选项的选项请看:Puppeteer waitUntil Optionswait: 页面加载完成后等待的时间,有时候还得等页面的某个元素加载完成js_name: 预留的参数,用于在页面访问前加载js,目前就只有一个js(stealth.min.js)用于去浏览器特征filters: 过滤的请求列表, 支持正则。比如有些css请求你不想让他加载images: 是否加载图片forbidden_content_types: 禁止加载的资源类型,默认是图片和视频。所有的类型见: resourcetypecache: 是否启用缓存cookie: 是否在返回结果里包含cookietext: 是否在返回结果里包含htmlheaders: 是否在返回结果里包含headers
图片的参数
class PngPostData(HtmlPostData):
render_all: int = 0
text: bool = 0
images: bool = 1
forbidden_content_types: List[str] = []
wait_util: str = "networkidle2"
参数和html的基本一样,增加了一个render_all用于是否截取整个页面。截图的时候一般是需要加载图片的,所以就启用了图片加载
怎么使用
多个标签同时采集
默认是启动了四个标签页,这四个标签页可以同时访问不同链接。如果标签页过多可能会影响性能,不过开了二三十个应该没什么问题
请求例子如下:
import sys
import asyncio
import aiohttp
if sys.platform == 'win32':
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
async def get_sign(session, delay):
url = f"http://www.httpbin.org/delay/{delay}"
api = f'http://127.0.0.1:8080/render.html?url={url}'
async with session.get(api) as resp:
data = await resp.json()
print(url, data.get("status"))
return data
async def main():
headers = {
"Content-Type": "application/json",
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
}
loop = asyncio.get_event_loop()
t = loop.time()
async with aiohttp.ClientSession(headers=headers) as session:
tasks = [asyncio.create_task(get_sign(session, i)) for i in range(1, 5)]
await asyncio.gather(*tasks)
print("耗时: ", loop.time()-t)
if __name__ == "__main__":
asyncio.run(main())
http://www.httpbin.org/delay后面跟的数字是多少,网站就会多少秒后返回。所以如果同步运行的话至少需要1+2+3+4秒,而多标签页异步运行的话至少需要4秒
结果如图,四个链接只用了4秒多点:

拦截指定ajax请求的响应
import json
import sys
import asyncio
import aiohttp
if sys.platform == 'win32':
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
async def get_sign(session, url):
api = f'http://127.0.0.1:8080/render.json'
data = {
"url": url,
"xhr": "/api/", # 拦截接口包含/api/的响应并返回
"cache": 0,
"filters": [".png", ".jpg"]
}
async with session.post(api, data=json.dumps(data)) as resp:
data = await resp.json()
print(url, data)
return data
async def main():
urls = ["https://spa1.scrape.center/"]
headers = {
"Content-Type": "application/json",
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
}
loop = asyncio.get_event_loop()
t = loop.time()
async with aiohttp.ClientSession(headers=headers) as session:
tasks = [asyncio.create_task(get_sign(session, url)) for url in urls]
await asyncio.gather(*tasks)
print(loop.time()-t)
if __name__ == "__main__":
asyncio.run(main())
请求https://spa1.scrape.center/这个网站并获取ajax链接中包含/api/的接口响应数据,结果如图:

请求一个网站用时21秒,这是因为网站一直在转圈,其实要的数据已经加载完成了,可能是一些图标或者css还在请求。
超时强制返回
加上timeout参数后,即使页面未加载完成也会强制停止并返回数据。如果这个时候已经拦截到了ajax请求会返回ajax响应内容,不然就是返回空
不过好像因为有缓存,现在时间不到1秒就返回了

截图
import json
import sys
import asyncio
import base64
import aiohttp
if sys.platform == 'win32':
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
async def get_sign(session, url, name):
api = f'http://127.0.0.1:8080/render.png'
data = {
"url": url,
#"render_all": 1,
"images": 1,
"cache": 1,
"wait": 1
}
async with session.post(api, data=json.dumps(data)) as resp:
data = await resp.json()
if data.get('image'):
image_bytes = base64.b64decode(data["image"])
with open(name, 'wb') as f:
f.write(image_bytes)
print(url, name, len(image_bytes))
return data
async def main():
urls = [
"https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&tn=44004473_102_oem_dg&wd=%E5%9B%BE%E7%89%87&rn=50",
"https://www.toutiao.com/article/7145668657396564518/",
"https://new.qq.com/rain/a/NEW2022092100053400",
"https://new.qq.com/rain/a/DSG2022092100053300"
]
headers = {
"Content-Type": "application/json",
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
}
loop = asyncio.get_event_loop()
t = loop.time()
async with aiohttp.ClientSession(headers=headers) as session:
tasks = [asyncio.create_task(get_sign(session, url, f"{n}.png")) for n,url in enumerate(urls)]
await asyncio.gather(*tasks)
print(loop.time()-t)
if __name__ == "__main__":
asyncio.run(main())
集成到scrapy
import json
import logging
from scrapy.exceptions import NotConfigured
logger = logging.getLogger(__name__)
class BrowserMiddleware(object):
def __init__(self, browser_base_url: str):
self.browser_base_url = browser_base_url
self.logger = logger
@classmethod
def from_crawler(cls, crawler):
s = crawler.settings
browser_base_url = s.get('PYPPETEER_CLUSTER_URL')
if not browser_base_url:
raise NotConfigured
o = cls(browser_base_url)
return o
def process_request(self, request, spider):
if "browser_options" not in request.meta or request.method != "GET":
return
browser_options = request.meta["browser_options"]
url = request.url
browser_options["url"] = url
uri = browser_options.get('browser_uri', "/render.html")
browser_url = self.browser_base_url.rstrip('/') + '/' + uri.lstrip('/')
new_request = request.replace(
url=browser_url,
method='POST',
body=json.dumps(browser_options)
)
new_request.meta["ori_url"] = url
return new_request
def process_response(self, request, response, spider):
if "browser_options" not in request.meta or "ori_url" not in request.meta:
return response
try:
datas = json.loads(response.text)
except json.decoder.JSONDecodeError:
return response.replace(url=url, status=500)
datas = self.deal_datas(datas)
url = request.meta["ori_url"]
new_response = response.replace(url=url, **datas)
return new_response
def deal_datas(self, datas: dict) -> dict:
status = datas["status"]
text: str = datas.get('text') or datas.get('content')
headers = datas.get('headers')
response = {
"status": status,
"headers": headers,
"body": text.encode()
}
return response
开始想用aiohttp来请求,后面想了下,其实都要替换请求和响应,为什么不直接用scrapy的下载器
完整源代码
现在还只是个半成品玩具,还没有用于实际生产中,集群打包也没做。有兴趣的话可以自己完善一下
如果感兴趣的人比较多,后面也会系统的完善一下,打包成docker和发布第三方库到pypi
github:https://github.com/kanadeblisst00/browser_cluster
怎么用Python写一个浏览器集群框架的更多相关文章
- 用Python写一个简单的Web框架
一.概述 二.从demo_app开始 三.WSGI中的application 四.区分URL 五.重构 1.正则匹配URL 2.DRY 3.抽象出框架 六.参考 一.概述 在Python中,WSGI( ...
- Kubernetes 学习笔记(二):本地部署一个 kubernetes 集群
前言 前面用到过的 minikube 只是一个单节点的 k8s 集群,这对于学习而言是不够的.我们需要有一个多节点集群,才能用到各种调度/监控功能.而且单节点只能是一个加引号的"集群&quo ...
- 怎样加快master数据库的写操作?分表原则!将表水平划分!或者添加写数据库的集群
1.怎样加快master数据库的写操作?分表原则!将表水平划分!减少表的锁定时间!!! 或者或者添加写数据库的集群!!!或者添加写数据库的集群!!! 2.既然分表了,就一定要注意分表的规则!要在代码层 ...
- 十行代码--用python写一个USB病毒 (知乎 DeepWeaver)
昨天在上厕所的时候突发奇想,当你把usb插进去的时候,能不能自动执行usb上的程序.查了一下,发现只有windows上可以,具体的大家也可以搜索(搜索关键词usb autorun)到.但是,如果我想, ...
- 【Python】如何基于Python写一个TCP反向连接后门
首发安全客 如何基于Python写一个TCP反向连接后门 https://www.anquanke.com/post/id/92401 0x0 介绍 在Linux系统做未授权测试,我们须准备一个安全的 ...
- 手把手教你用Docker部署一个MongoDB集群
MongoDB是一个介于关系数据库和非关系数据库之间的产品,是非关系数据库当中最像关系数据库的.支持类似于面向对象的查询语言,几乎可以实现类似关系数据库单表查询的绝大部分功能,而且还支持对数据建立索引 ...
- python 连接 redis cluster 集群
一. redis集群模式有多种, cluster模式只是其中的一种实现方式, 其原理请自行谷歌或者百度, 这里只举例如何使用Python操作 redis cluster 集群 二. python 连接 ...
- 手把手教你搭建一个 Elasticsearch 集群
为何要搭建 Elasticsearch 集群 凡事都要讲究个为什么.在搭建集群之前,我们首先先问一句,为什么我们需要搭建集群?它有什么优势呢? 高可用性 Elasticsearch 作为一个搜索引擎, ...
- Python写一个自动点餐程序
Python写一个自动点餐程序 为什么要写这个 公司现在用meican作为点餐渠道,每天规定的时间是早7:00-9:40点餐,有时候我经常容易忘记,或者是在地铁/公交上没办法点餐,所以总是没饭吃,只有 ...
- 第3章:快速部署一个Kubernetes集群
kubeadm是官方社区推出的一个用于快速部署kubernetes集群的工具. 这个工具能通过两条指令完成一个kubernetes集群的部署: # 创建一个 Master 节点$ kubeadm in ...
随机推荐
- QOJ 6504. CCPC Final 2022 D Flower's Land 2题解
QOJ 6504. CCPC Final 2022 D Flower's Land 2题解 题意简述 给你一个只含 \(0,1,2\) 的序列,相邻两个相同的数字可以直接消掉. 询问包含两种 区间所有 ...
- 数据库连接池之c3p0-0.9.1.2,线上偶发APPARENT DEADLOCK,如何解?
前言 本篇其实是承接前面两篇的,都是讲定位线上的c3p0数据库连接池,发生连接泄露的问题. 第二篇讲到,可以配置两个参数,来找出是哪里的代码借了连接后没有归还.但是,在我这边的情况是,对于没有归还的连 ...
- 模型部署 — PaddleNLP 基于 Paddle Serving 快速使用(服务化部署 - Docker)— 图像识别 + 信息抽取(UIE-X)
目录 流程 版本 安装 Docker 安装 PaddleNLP 安装 环境准备 模型准备 压缩模型 下载模型 模型部署 环境配置 启动服务 测试 -- 暂时还没通过 重启 图像识别 + 信息抽取(UI ...
- 面试题 01.03. URL化
面试题 01.03. URL化 简单 URL化.编写一种方法,将字符串中的空格全部替换为%20.假定该字符串尾部有足够的空间存放新增字符,并且知道字符串的"真实"长度.(注:用Ja ...
- #Powerbi 1分钟学会,设置有密码保护的powerbi报告
目前,有一些朋友和笔者一样,公司暂时没有部署powerbi服务器,但是有时也需要使用powerbi共享一些看板. 如果直接将制作好的报告直接发布在公网上,又存在一定的风险,即便可能只是公布1天. 那么 ...
- [mysql]安全加固
前言 因等保安全的要求,需要对MySQL用户密码和登录策略进行安全加固,以满足以下需求: 密码至少8位,包含大小写字母.数字和特殊字符. 当密码登录失败一定次数后锁定账户. 密码90天过期 本文使用的 ...
- nginx-http反向代理与负载均衡
前言 反向代理服务器位于用户与目标服务器之间,但是对于用户而言,反向代理服务器就相当于目标服务器,即用户直接访问反向代理服务器就可以获得目标服务器的资源.同时,用户不需要知道目标服务器的地址,也无须在 ...
- 调试linux内核(1): 环境准备和原理介绍
开篇 现在流行的开源项目经历了长时间的开发, 积累了大量的代码, 想要一行一行地阅读代码去学习开源项目, 需要的时间成本是巨大的. 所以, 我们也需要用一种高效的方式去"阅读"代码 ...
- AVR汇编(五):算术和逻辑指令
AVR汇编(五):算术和逻辑指令 算术运算指令 AVR中对于算术运算提供了加法.减法和乘法指令,没有除法指令. ADD ADD 指令用于执行加法操作,相关的变体指令有:一般加法 ADD .带进位加法 ...
- 免费拥有自己的 Github 资源加速器
TurboHub 是一个免费的 Github 资源加速下载站点,可以帮助你快速下载 Github 上的资源.其核心逻辑是通过 Azure Static Web Apps 服务和 Azure Funct ...