zendriver + Scrapeless: Điều khiển Trình duyệt từ xa qua CDP
Specialist in Anti-Bot Strategies
TL;DR:
- zendriver là một nhánh cộng đồng của nodriver, điều khiển Chrome thông qua CDP thô, và giống như nodriver, nó được xây dựng để khởi động trình duyệt.
Browser.start()có một đường dẫnconnect_existing, nhưng quá trình phát hiện diễn ra quaHTTPApi, mà mã hóa cứng HTTP đơn giản với một máy chủ và một cổng — một điểm cuối WebSocket an toàn mang theo một mã thông báo không thể được diễn đạt theo cách đó.- Chướng ngại lớn thực sự là địa chỉ tab. zendriver xây dựng mỗi tab từ một máy chủ, một cổng và một id mục tiêu, và một điểm cuối từ xa không phục vụ bất kỳ tuyến đường nào như vậy.
- Một URL CDP có nghĩa là một trình duyệt: một kết nối thứ hai đến cùng một điểm cuối nhận được một trình duyệt từ xa khác và không nhìn thấy bất kỳ mục tiêu nào của trình duyệt đầu tiên.
- Vấn đề là đa hợp session phẳng —
Target.attachToTarget(flatten=True)trả về một sessionId, và đóng dấu nó vào các khung xuất ra giúpTabcủa zendriver tiếp cận trang từ xa. - Với session được đính kèm, một lần trích xuất thực sự trả về một trang đã được kết xuất hoàn toàn và 20 thẻ sản phẩm với tiêu đề và giá cả chính xác.
- Bắt đầu với gói miễn phí Scrapeless và điều khiển một trình duyệt mà bạn không cần phải khởi động.
Trình duyệt Scrapeless Scraping Browser là một trình duyệt đám mây mà bạn kết nối thay vì khởi động. Nó lắng nghe trên một điểm cuối WebSocket CDP an toàn, có cùng giao thức mà zendriver sử dụng — vì vậy trên giấy tờ, hai cái này nên gặp nhau trên cùng một dòng.
Chúng không gặp nhau. Playwright có connect_over_cdp. Puppeteer có connect. zendriver không có tương đương, và yêu cầu về một cái đã được để lại trong theo dõi kể từ tháng 4 năm 2025 như một vấn đề mở yêu cầu chính xác cái này, chưa được trả lời. Tài liệu chính thức về CDP thô đề cập đến page.send() và các trình xử lý sự kiện và không bao giờ đề cập đến các trình duyệt từ xa.
Hướng dẫn này làm rõ điều gì thực sự cản trở — không phải là kết nối — và sau đó gắn một trình duyệt từ xa chỉ bằng các nguyên thủy của thư viện.
Cách zendriver Khởi động một Trình duyệt
zendriver là một nhánh của nodriver được tạo ra để hợp nhất các bản sửa lỗi chưa hợp nhất và mở lại dự án cho các đóng góp. Về mặt kiến trúc, hai cái này là cùng một ý tưởng: điều khiển Chrome trực tiếp thông qua Giao thức Công cụ Phát triển Chrome mà không có lớp WebDriver ở giữa, điều này loại bỏ bề mặt tự động hóa mà các ngăn xếp dựa trên driver phơi bày. Nhánh này thêm vào asyncio.run() chuẩn, hỗ trợ Docker và duy trì cookie.
Điểm truy cập bình thường khởi động một trình duyệt:
python
import asyncio
import zendriver as zd
async def main():
browser = await zd.start(headless=True, no_sandbox=True)
page = await browser.get("https://books.toscrape.com/")
await page.wait_for("h3 a", timeout=20)
await page.sleep(2)
print("cards:", len(await page.select_all("article.product_pod")))
await browser.stop()
asyncio.run(main())
text
cards: 20
no_sandbox=True cần thiết khi quá trình chạy với quyền root, đây là trường hợp bình thường bên trong một container; thả nó vào một máy tính để bàn. Cuộc gọi sleep có lý do của nó mà sẽ được đề cập dưới đây, và lý do đó cũng áp dụng cho đường dẫn từ xa.
Điều đó sẽ khởi động Chrome như một tiến trình con trên bất kỳ máy nào chạy kịch bản. Mỗi hướng dẫn zendriver trong tập hợp kết quả hoạt động theo cách này: điều khiển một trình duyệt mà kịch bản tự khởi động, và hình thành nó qua browser_args — một công tắc --proxy-server Chromium thường là ví dụ điển hình. Không có cái nào trong số đó gắn vào một trình duyệt mà nó không khởi động.
Các yêu cầu trước
- Python 3.10 hoặc mới hơn.
- Một khóa API Scrapeless từ bảng điều khiển, được xuất dưới dạng
SCRAPELESS_KEY. - Một cài đặt Chrome hoặc Chromium cục bộ, chỉ nếu bạn muốn chạy khối khởi động cục bộ ở trên. Đường dẫn từ xa không cần bất kỳ nhị phân trình duyệt nào.
Cài đặt
bash
pip install "zendriver==0.15.5"
bash
export SCRAPELESS_KEY="your_api_key_here"
connect_existing Thực Sự Là Gì
Đọc Browser.start() tạo ấn tượng rằng việc gắn từ xa đã được hỗ trợ. Khi cả config.host và config.port đều được đặt, nó chuyển một cờ và bỏ qua việc khởi động một quá trình hoàn toàn. Gói đã cài đặt sẽ cho bạn thấy:
python
import inspect
import textwrap
from zendriver.core.browser import Browser
source = inspect.getsource(Browser.start).splitlines()
start = next(i for i, line in enumerate(source) if "connect_existing = False" in line)
print(textwrap.dedent("\n".join(source[start:start + 6])))
text
connect_existing = False
if self.config.host is not None and self.config.port is not None:
connect_existing = True
else:
self.config.host = "127.0.0.1"
self.config.port = util.free_port()
Điều đó là thực sự — nó thực sự kết nối với một trình duyệt mà nó không khởi động. Hạn chế là những gì xảy ra tiếp theo. Quá trình phát hiện diễn ra qua HTTPApi, mà lấy một cặp máy chủ và cổng và nội suy nó vào một mẫu địa chỉ cố định, sau đó lấy /json/version với urllib để biết URL WebSocket của trình duyệt. Bạn có thể thấy hình dạng của địa chỉ đó mà không rời khỏi Python:
python
from zendriver.core.browser import HTTPApi
api = HTTPApi(("example-host", 9222))
print("scheme:", api.api.split("://")[0])
print("room for a query string:", "?" in api.api)
print("room for a path:", api.api.count("/") > 2)
text
scheme: http
room for a query string: False
room for a path: False
Ba điều sẽ bị phá vỡ cùng một lúc cho một điểm cuối đám mây. Giao thức được cố định là HTTP http. Địa chỉ là một máy chủ và một cổng, vì vậy không có chỗ nào để đặt một đường dẫn, một chuỗi truy vấn hoặc một mã thông báo xác thực. Và một điểm cuối được lưu trữ thường không phơi bày các tuyến đường phát hiện HTTP CDP ra công khai — yêu cầu /json/version và /json/list trên máy chủ Scrapeless trả về HTTP 403 trong cả hai trường hợp.
Vì vậy connect_existing là cho một Chrome mà bạn tự khởi động với --remote-debugging-port, trên một host và cổng mà bạn kiểm soát. Đây không phải là một tính năng gắn kết từ xa.
Vấn Đề Thực Sự Là URL Của Tab
Giả sử vấn đề phát hiện đã được giải quyết. Kết nối vẫn sẽ không thể sử dụng được, và lý do nằm ở một lớp dưới nơi hầu hết mọi người nhìn vào.
zendriver xây dựng các kết nối tab bằng cách tạo chuỗi từ cùng một host và cổng, được kết hợp với id mục tiêu thành một đường dẫn /devtools/page/<target_id>. Điều này được thực hiện ở hai nơi khác nhau trong browser.py, mà bạn có thể xác nhận từ gói đã được cài đặt:
python
import inspect
from zendriver.core import browser
source = inspect.getsource(browser)
print("tab addresses built from host and port:", source.count("{self.config.host}:{self.config.port}"))
print("devtools path template present:", "/devtools/" in source)
text
tab addresses built from host and port: 2
devtools path template present: True
Một Chrome cục bộ phục vụ cho tuyến đường đó. Một trình duyệt đám mây thì không — nó cung cấp một điểm cuối, và các đường dẫn DevTools theo từng mục tiêu không phải là một phần của bề mặt công khai của nó. Bốn hình dạng khả thi đã được thử nghiệm trong một phiên trực tiếp: với token, không có token, dưới tiền tố /api/v2/browser, và như một tuyến đường /page/. Tất cả bốn cái đều bị từ chối với HTTP 404.
Đây là lý do tại sao vấn đề không thể được giải quyết bằng cách tìm kiếm URL đúng. Không có URL theo tab nào để tìm kiếm.
Một URL CDP, Một Trình Duyệt
Bản năng tiếp theo là mở một kết nối thứ hai cho tab. Điều đó cũng không hoạt động, và nó thất bại theo cách đủ yên lặng để lãng phí một buổi chiều.
python
import asyncio
import os
from urllib.parse import urlencode
import zendriver as zd
from zendriver import cdp
def cdp_url():
return "wss://browser.scrapeless.com/api/v2/browser?" + urlencode(
{"token": os.environ["SCRAPELESS_KEY"], "sessionTTL": 300, "proxyCountry": "US"}
)
async def main():
a = zd.Connection(cdp_url())
b = zd.Connection(cdp_url())
tid = await a.send(cdp.target.create_target("https://books.toscrape.com/"))
a_ids = {str(t.target_id) for t in await a.send(cdp.target.get_targets())}
b_ids = {str(t.target_id) for t in await b.send(cdp.target.get_targets())}
print("A sees its own target:", str(tid) in a_ids)
print("B sees it:", str(tid) in b_ids)
print("shared target ids:", len(a_ids & b_ids))
await a.aclose()
await b.aclose()
asyncio.run(main())
text
A sees its own target: True
B sees it: False
shared target ids: 0
Mỗi kết nối đến điểm cuối là trình duyệt riêng của nó. Hai phiên không chia sẻ gì — không phải mục tiêu bạn vừa tạo, không phải một id mục tiêu nào. Bất cứ điều gì được xây dựng trên "một socket mỗi tab" đang một cách yên lặng điều khiển một trình duyệt khác với nó nghĩ.
Lưu ý điều gì đã hoạt động, tuy nhiên. zd.Connection(cdp_url()) đã kết nối và trả lời các lệnh CDP. Phương tiện vận chuyển không bao giờ là vấn đề.
Sẵn sàng để điều khiển một trình duyệt mà bạn không cần khởi động? Tạo một tài khoản Scrapeless miễn phí và chỉ định zendriver vào đó.
Gắn Một Phiên Phẳng
Mọi thứ phải di chuyển qua một kết nối, đó là những gì phương thức CDP Target.attachToTarget dành cho. Với flatten được thiết lập, nó trả về một sessionId, và bất kỳ khung nào mang sessionId đó sẽ được chuyển đến mục tiêu đã gắn thay vì trình duyệt.
zendriver không sử dụng nó. Mỗi khung ra đi đều được tuần tự hóa bởi một thuộc tính, Transaction.message:
python
import inspect
from zendriver.core.connection import Transaction
body = inspect.getsource(Transaction.message.fget)
print(body.strip().splitlines()[-1].strip())
print("sessionId present:", "sessionId" in body)
text
return json.dumps({"method": self.method, "params": self.params, "id": self.id})
sessionId present: False
Không có trường sessionId, vì vậy các lệnh luôn đến trình duyệt. Thêm một cái là toàn bộ quá trình tích hợp: gắn phiên, sau đó đóng dấu vào các khung khi ra đi.
python
import asyncio
import json
import os
from urllib.parse import urlencode
import zendriver as zd
from zendriver import cdp
CDP_URL = "wss://browser.scrapeless.com/api/v2/browser?" + urlencode(
{"token": os.environ["SCRAPELESS_KEY"], "sessionTTL": 300, "proxyCountry": "US"}
)
class ScrapelessTab(zd.Tab):
"""A zendriver Tab bound to a remote browser over a single websocket."""
def __init__(self, url):
super().__init__(url, target=None)
self._session_id = None
async def open_page(self, url):
tid = await self.send(cdp.target.create_target(url))
infos = await self.send(cdp.target.get_targets())
self._target = next(t for t in infos if str(t.target_id) == str(tid))
self._session_id = await self.send(
cdp.target.attach_to_target(tid, flatten=True)
)
websocket, session_id = self.websocket, self._session_id
original_send = websocket.send
async def send_with_session(message, *args, **kwargs):
frame = json.loads(message)
frame.setdefault("sessionId", str(session_id))
return await original_send(json.dumps(frame), *args, **kwargs)
websocket.send = send_with_session
return self
async def main():
tab = ScrapelessTab(CDP_URL)
await tab.open_page("https://books.toscrape.com/")
print("session attached:", bool(tab._session_id))
await tab.aclose()
asyncio.run(main())
text
session attached: True
Kế thừa Tab thay vì Connection là có chủ đích: Tab đã mang mọi trợ giúp trang, và nó mở rộng Connection, vì vậy một đối tượng giữ một socket, một listener, và một bản đồ phản hồi. Truyền target=None cho bộ tạo là ổn vì mục tiêu thực sự được chỉ định khi nó tồn tại. Các phản hồi đến với cùng id mà chúng đã được gửi đi, do đó việc dispatch hiện có của zendriver giải quyết chúng một cách không bị chạm.
Chạy Một Lần Rút Thực
Với phiên được gắn, API thông thường hoạt động. Hai chi tiết thời gian sẽ cắn đầu tiên, và không cái nào trong số đó là cụ thể cho trình duyệt từ xa — cả hai đều hành xử giống nhau với một trình duyệt được khởi động cục bộ.
get_content() gửi lệnh của nó ngay lập tức thay vì polling. Gọi nó quá sớm và nó trả về khung tài liệu trống, 39 ký tự, không có lỗi nào — mà đọc như một kết nối bị hỏng thay vì một trang chưa được hiển thị.
wait_for() có polling, nhưng nó trả về ngay khi bộ chọn của nó khớp một lần. Trên một trang mà markup vẫn đang đến, đó là một đảm bảo yếu hơn nó trông: chọn ngay sau khi wait_for("h3 a") đã trả về 9 thẻ trong một lần chạy cục bộ và 4 trong lần tiếp theo, so với 20 khi DOM ổn định. Hãy cho trang một khoảnh khắc trước khi bạn đếm bất kỳ thứ gì.
python
await tab.wait_for("h3 a", timeout=20)
await tab.sleep(2)
html = await tab.get_content()
print("fully rendered page:", len(html) > 40000)
print("catalogue marker present:", "All products" in html)
cards = await tab.select_all("article.product_pod")
print("product cards:", len(cards))
for card in cards[:5]:
link = await card.query_selector("h3 a")
price = await card.query_selector("p.price_color")
print(f" {link.attrs.get('title')} — {price.text}")
text
fully rendered page: True
catalogue marker present: True
product cards: 20
A Light in the Attic — £51.77
Tipping the Velvet — £53.74
Soumission — £50.10
Sharp Objects — £47.82
Sapiens: A Brief History of Humankind — £54.23
Các bộ chọn và truy vấn phần tử cư xử chính xác như cách chúng làm đối với một trình duyệt cục bộ, vì từ phía zendriver không có gì thay đổi ngoại trừ socket mà các khung đi xuống. proxyCountry nhận một mã hai chữ cái khi yêu cầu cần xuất phát từ một quốc gia cụ thể, và sessionTTL giới hạn thời gian phiên từ xa ở lại mở.
Kết Luận
zendriver không có connect_over_cdp, và lý do không phải là phương tiện WebSocket bị thiếu — zd.Connection giao tiếp với một điểm cuối từ xa ngay lần đầu tiên. Cản trở là các kết nối tab được xây dựng từ một host và một cổng, và một trình duyệt đám mây không có tuyến đường theo tab nào để chỉ định cho chúng.
Các phiên phẳng đã lấp đầy khoảng trống đó. Một kết nối, một cuộc gọi Target.attachToTarget, và một sessionId được đóng dấu vào các khung ra đi biến đổi Tab của thư viện thành một tay cầm trên một trang từ xa, với mọi trợ giúp bộ chọn vẫn còn nguyên.
Khi một thiết lập như vậy hoạt động không đúng, hai kiểm tra thường giải quyết vấn đề. Nếu kết quả trông trống rỗng thay vì sai, hãy đợi một bộ chọn trước khi đọc nội dung — get_content() không chờ đợi. Nếu các tab dường như mở nhưng không có gì được tìm thấy trong đó, hãy xác nhận rằng bạn không mở một kết nối thứ hai, vì đó là một trình duyệt khác.
Để biết thêm thông tin về giao thức bên dưới, xem giao thức Chrome DevTools là gì và so sánh nodriver và Patchright như các công cụ lén lút cấp trình điều khiển. Chi tiết kế hoạch có trên trang định giá Scrapeless, và các tham số phiên có trong tài liệu Scrapeless.
Câu hỏi thường gặp
Q: zendriver có phương thức connect_over_cdp không?
Không. Không có tương đương với connect_over_cdp của Playwright hoặc connect của Puppeteer. Hành vi connect_existing bên trong Browser.start() thì gần nhưng nhắm đến một máy chủ và cổng có thể tiếp cận được từ xa, không phải một URL WebSocket từ xa với một mã thông báo.
Q: Tại sao việc thiết lập máy chủ và cổng đến một điểm cuối từ xa không hoạt động?
Bởi vì HTTPApi nội suy một máy chủ và một cổng vào một địa chỉ HTTP tĩnh và lấy /json/version từ đó. Một URL WebSocket bảo mật với một chuỗi truy vấn không thể được đại diện như một máy chủ và một cổng, và các điểm cuối được lưu trữ thường không công khai các lộ trình phát hiện đó.
Q: Tôi có thể mở nhiều tab trên một trình duyệt từ xa không?
Có, nhưng chúng phải chia sẻ kết nối. Gọi Target.attachToTarget một lần cho mỗi mục tiêu và đóng dấu sessionId tương ứng lên các khung của tab đó. Việc mở một kết nối khác đến điểm cuối sẽ cung cấp cho bạn một trình duyệt riêng biệt.
Q: Bản vá chống phát hiện của nhánh vẫn áp dụng cho một trình duyệt từ xa không?
Những bản vá đó ảnh hưởng đến cách một trình duyệt được khởi động và cấu hình, vì vậy chúng thuộc về quá trình mà zendriver bắt đầu. Khi bạn gán một trình duyệt mà bạn không khởi động, cấu hình của nó là bất cứ thứ gì mà nhà cung cấp đã thiết lập, và zendriver chỉ hành động như một khách hàng giao thức thuần túy.
Q: Tại sao get_content() trả về một tài liệu gần như trống rỗng?
Bởi vì nó không chờ đợi. Nó phát lệnh ngay lập tức, vì vậy một trang chưa hoàn tất việc render sẽ trả về khung tài liệu trống — 39 ký tự — mà không có lỗi. Hãy đợi một bộ chọn với wait_for() trước.
Q: Tôi có cần Chrome được cài đặt cục bộ cho đường dẫn từ xa không?
Không. Không có gì được khởi động trên máy của bạn, vì vậy không cần mã nhị phân trình duyệt. Bạn chỉ cần một cái để chạy ví dụ zd.start() cục bộ.
Q: sessionTTL điều khiển điều gì?
Thời gian mà phiên trình duyệt từ xa vẫn mở, tính bằng giây. Đặt nó cao hơn thời gian mà việc chạy của bạn cần; phiên sẽ kết thúc khi kết nối đóng hoặc cửa sổ hết hạn, tùy cái nào đến trước.
Tại Scrapless, chúng tôi chỉ truy cập dữ liệu có sẵn công khai trong khi tuân thủ nghiêm ngặt các luật, quy định và chính sách bảo mật trang web hiện hành. Nội dung trong blog này chỉ nhằm mục đích trình diễn và không liên quan đến bất kỳ hoạt động bất hợp pháp hoặc vi phạm nào. Chúng tôi không đảm bảo và từ chối mọi trách nhiệm đối với việc sử dụng thông tin từ blog này hoặc các liên kết của bên thứ ba. Trước khi tham gia vào bất kỳ hoạt động cạo nào, hãy tham khảo ý kiến cố vấn pháp lý của bạn và xem xét các điều khoản dịch vụ của trang web mục tiêu hoặc có được các quyền cần thiết.



