怎麼用Python寫一個瀏覽器集群框架

来源:https://www.cnblogs.com/kanadeblisst/archive/2023/10/27/17792187.html
-Advertisement-
Play Games

這是做什麼用的 框架用途 在採集大量新聞網站時,不可避免的遇到動態載入的網站,這給配模版的人增加了很大難度。本來配靜態網站只需要兩個技能點:xpath和正則,如果是動態網站的還得抓包,遇到加密的還得js逆向。 所以就需要用瀏覽器渲染這些動態網站,來減少了配模板的工作難度和技能要求。動態載入的網站在新 ...


這是做什麼用的

框架用途

在採集大量新聞網站時,不可避免的遇到動態載入的網站,這給配模版的人增加了很大難度。本來配靜態網站只需要兩個技能點: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秒多點:

file

攔截指定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/的介面響應數據,結果如圖:

file

請求一個網站用時21秒,這是因為網站一直在轉圈,其實要的數據已經載入完成了,可能是一些圖標或者css還在請求。

超時強制返回

加上timeout參數後,即使頁面未載入完成也會強制停止並返回數據。如果這個時候已經攔截到了ajax請求會返回ajax響應內容,不然就是返回空

不過好像因為有緩存,現在時間不到1秒就返回了

file

截圖

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


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 一、數組的存儲 1、當聲明一個變數時,var a = 111; 在後臺電腦翻譯時,var聲明 a變數 所以此時會產生一個棧記憶體,變數 a 的初始值為undefined,然後 = 111 ; undefined消失,111的值被賦值給了a。如果多個變數賦值的話,棧記憶體的執行順序是先進後出的順序。也叫 ...
  • 引言 近期,三大主流瀏覽器引擎均發佈最新版本,支持W3C的CSS Color 4標準,包含新的取色方法color()和相應語法,可展示更多的色域及色彩空間,這意味著web端能展示更豐富更高清的色彩。雖然目前只有最新版本的現代瀏覽器才支持,我們可以先提前瞭解一下這項新標準。 本文首先會先簡單介紹幾個色 ...
  • 非功能性需求是什麼呢?保障系統持續健康運轉的輔助需求。依然以電商系統的優惠券為例,在促銷活動期間發放大量優惠券,如何防止用戶集中領券時系統不崩盤呢?活動結束後,如何收縮伺服器,節省伺服器資源呢? 非功能性需求是面向運維的,重要但是不太緊迫,有時候可以沒有操作界面,由架構師提出解決方案,再推動各個業務... ...
  • 單詞 "多態" 意味著 "多種形式",在編程中,它指的是具有相同名稱的方法/函數/操作符,可以在許多不同的對象或類上執行。 函數多態性 一個示例是 Python 中的 len() 函數,它可以用於不同的對象。 字元串 對於字元串,len() 返回字元的數量: 示例 x = "Hello World! ...
  • PDF(Portable Document Format)是一種常用的文檔格式,具有跨平臺相容性、保真性、安全性和交互性等特點。我們日常生活工作中的合同、報告、論文等通常都採用PDF格式,以確保文檔在不同的操作系統(例如 Windows、Mac、Linux)和設備上被查看時都能保持外觀的一致性。 P ...
  • 本章節主要講的是如何配置熱載入,會碰到什麼問題,要怎麼處理。`wmproxy`是由`Rust`編寫,已實現`http/https`代理,`socks5`代理, 反向代理,靜態文件伺服器,內網穿透,配置熱更新等, ...
  • 字元串中的BKDRHash哈希函數 在電腦科學中,哈希函數是一種將任意長度的輸入(也稱為“消息”)通過散列演算法轉換成固定長度的輸出,該輸出就是哈希值。哈希函數的一個重要特性是,對於相同的輸入,無論何時執行哈希函數,它都應該產生相同的輸出。然而,對於不同的輸入,即使它們只有微小的差別,哈希函數也應該 ...
  • 本文分享自華為雲社區《深入理解Java中的Reader類:一步步剖析》,作者:bug菌。 前言 在Java開發過程中,我們經常需要讀取文件中的數據,而數據的讀取需要一個合適的類進行處理。Java的IO包提供了許多類用於數據的讀取和寫入,其中Reader便是其中之一。本文將對Java中的Reader進 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...