这是做什么用的

框架用途

在采集大量新闻网站时,不可避免的遇到动态加载的网站,这给配模版的人增加了很大难度。本来配静态网站只需要两个技能点: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 Options
  • wait: 页面加载完成后等待的时间,有时候还得等页面的某个元素加载完成
  • js_name: 预留的参数,用于在页面访问前加载js,目前就只有一个js(stealth.min.js)用于去浏览器特征
  • filters: 过滤的请求列表, 支持正则。比如有些css请求你不想让他加载
  • images: 是否加载图片
  • forbidden_content_types: 禁止加载的资源类型,默认是图片和视频。所有的类型见: resourcetype
  • cache: 是否启用缓存
  • cookie: 是否在返回结果里包含cookie
  • text: 是否在返回结果里包含html
  • headers: 是否在返回结果里包含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写一个浏览器集群框架的更多相关文章

  1. 用Python写一个简单的Web框架

    一.概述 二.从demo_app开始 三.WSGI中的application 四.区分URL 五.重构 1.正则匹配URL 2.DRY 3.抽象出框架 六.参考 一.概述 在Python中,WSGI( ...

  2. Kubernetes 学习笔记(二):本地部署一个 kubernetes 集群

    前言 前面用到过的 minikube 只是一个单节点的 k8s 集群,这对于学习而言是不够的.我们需要有一个多节点集群,才能用到各种调度/监控功能.而且单节点只能是一个加引号的"集群&quo ...

  3. 怎样加快master数据库的写操作?分表原则!将表水平划分!或者添加写数据库的集群

    1.怎样加快master数据库的写操作?分表原则!将表水平划分!减少表的锁定时间!!! 或者或者添加写数据库的集群!!!或者添加写数据库的集群!!! 2.既然分表了,就一定要注意分表的规则!要在代码层 ...

  4. 十行代码--用python写一个USB病毒 (知乎 DeepWeaver)

    昨天在上厕所的时候突发奇想,当你把usb插进去的时候,能不能自动执行usb上的程序.查了一下,发现只有windows上可以,具体的大家也可以搜索(搜索关键词usb autorun)到.但是,如果我想, ...

  5. 【Python】如何基于Python写一个TCP反向连接后门

    首发安全客 如何基于Python写一个TCP反向连接后门 https://www.anquanke.com/post/id/92401 0x0 介绍 在Linux系统做未授权测试,我们须准备一个安全的 ...

  6. 手把手教你用Docker部署一个MongoDB集群

    MongoDB是一个介于关系数据库和非关系数据库之间的产品,是非关系数据库当中最像关系数据库的.支持类似于面向对象的查询语言,几乎可以实现类似关系数据库单表查询的绝大部分功能,而且还支持对数据建立索引 ...

  7. python 连接 redis cluster 集群

    一. redis集群模式有多种, cluster模式只是其中的一种实现方式, 其原理请自行谷歌或者百度, 这里只举例如何使用Python操作 redis cluster 集群 二. python 连接 ...

  8. 手把手教你搭建一个 Elasticsearch 集群

    为何要搭建 Elasticsearch 集群 凡事都要讲究个为什么.在搭建集群之前,我们首先先问一句,为什么我们需要搭建集群?它有什么优势呢? 高可用性 Elasticsearch 作为一个搜索引擎, ...

  9. Python写一个自动点餐程序

    Python写一个自动点餐程序 为什么要写这个 公司现在用meican作为点餐渠道,每天规定的时间是早7:00-9:40点餐,有时候我经常容易忘记,或者是在地铁/公交上没办法点餐,所以总是没饭吃,只有 ...

  10. 第3章:快速部署一个Kubernetes集群

    kubeadm是官方社区推出的一个用于快速部署kubernetes集群的工具. 这个工具能通过两条指令完成一个kubernetes集群的部署: # 创建一个 Master 节点$ kubeadm in ...

随机推荐

  1. CF1810D Candies题解

    CF1810D Candies 点击查看原题 点击查看思路 经典的小学数学奥数题. 设 \(a\) 为每天往上爬的高度,\(b\) 为每天向下降的高度,\(n\) 为给定的需要爬上去的天数. 请注意, ...

  2. Parallel 与 ConcurrentBag<T> 这对儿黄金搭档

    〇.前言 日常开发中经常会遇到数据统计,特别是关于报表的项目.数据处理的效率和准确度当然是首要关注点. 本文主要介绍,如何通过 Parallel 来并行处理数据,并组合 ConcurrentBag&l ...

  3. JNI c++ 与 java 通信过程

    JNI(Java Native Interface)是Java提供的一种机制,用于在Java和本地C/C++代码之间进行通信.下面是JNI C++与Java通信的一般过程: 1. 编写Java代码:首 ...

  4. PlayWright(二十)- Pytest之conftest文件

    1.介绍与使用场景 conftest.py 这个是什么呢?   顾名思义,他就是一个文件,那这个文件是干什么用的呢?   在我们上文中,用了fixture函数是直接在用例的文件里定义的,那不能我们所有 ...

  5. 前后端分离实现注册+登录(Vue3.0 + Django3.2)

    博客地址:https://www.cnblogs.com/zylyehuo/ 一.使用 vite+webstorm 搭建 Vue 环境,构建前端 1.结构树 2.main.js import { cr ...

  6. while(1) ; CPU 会一直等下去吗

    标题答疑 不会,计算机从外部设备读取数据到存储器,每读取1word的数据,CPU对外设状态进行一次检查. 可能有小伙伴看到这就要退出了,别急,看下去或许会有收获. 首先解决第一个问题:一个字是多少字节 ...

  7. js判断h5页面地址的打开方式(微信、pc、移动端)

    1 function isOpenMode(){ 2 //平台.设备和操作系统 3 var system = { 4 win: false, 5 mac: false, 6 xll: false, 7 ...

  8. [Pwn之路]根据所给库,获得远程同环境——使用patchelf的正确姿势

    原文:https://www.freebuf.com/sectool/366854.html 存自己这里方便看. 0x00 前言 如何修改本地pwn文件和题目所给环境一致,从而进行调试,这是从学习堆开 ...

  9. Spring源码学习笔记13——总结篇, 从IOC到AOP

    系列文章目录和关于我 零丶序言 在<Spring源码学习笔记12--总结篇,IOC,Bean的生命周期,三大扩展点>中,我们总结了Spring IOC部分的知识,为了更好的给群里的伙伴们分 ...

  10. AI绘图开源工具Stable Diffusion WebUI前端API对接

    背景 本文主要介绍 AI 绘图开源工具 Stable Diffusion WebUI 的 API 开启和基本调用方法,通过本文的阅读,你将了解到 stable-diffusion-webui 的基本介 ...