zed-scrapy-playwright 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.3
2
+ Name: zed-scrapy-playwright
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: zhouzhouos
6
+ Author-email: zhouzhouos <leisure-zyf@qq.com>
7
+ Requires-Dist: playwright>=1.62.0
8
+ Requires-Dist: playwright-stealth>=2.0.3
9
+ Requires-Dist: scrapy>=2.18.0
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+
13
+ # zed-scrapy-playwright
14
+
15
+ > Less meta config, more Python code.
16
+
17
+ > 把 Scrapy 变成 Playwright 的事件调度引擎。
18
+
19
+ [English](docs/README-en.md)
20
+
21
+ ---
22
+
23
+ ## 简介
24
+
25
+ `zed-scrapy-playwright` 是一个 Scrapy 中间件,它重新定义了 Scrapy 与 Playwright 的集成方式——**让 Scrapy 成为事件调度器,让 Playwright 成为可编程的执行引擎。**
26
+
27
+ 不同于传统方案将每个请求限制为单次页面导航,本中间件允许你在一个 `execution` 中完成完整的业务流程:登录、搜索、详情、下单,一气呵成。
28
+
29
+ ---
30
+
31
+ ## 核心特性
32
+
33
+ - **会话级控制** — 单次 execution 内多次 `goto`,登录态自动保持
34
+ - **线性编码** — 复杂流程写在一个函数里,告别 callback 地狱
35
+ - **精准数据传输** — 在 Page 中直接提取初筛数据,避免传输冗余 HTML
36
+ - **完全开放** — 任意 Playwright API 均可使用,无预定义操作限制
37
+ - **双向交叉检查** — 中间件与爬虫相互验证,配置错误快速失败
38
+ - **Stealth 灵活可控** — 可全局注入,也可在每个 execution 中自定义
39
+ - **类型安全** — 完整的类型提示,IDE 友好
40
+
41
+ ---
42
+
43
+ ## 快速开始
44
+
45
+ ### 安装
46
+
47
+ ```bash
48
+ pip install zed-scrapy-playwright
49
+ ```
50
+
51
+ ### 配置 settings.py
52
+
53
+ ```python
54
+ DOWNLOADER_MIDDLEWARES = {
55
+ "zed_scrapy_playwright.handler.PlaywrightDownloaderMiddleware": 300,
56
+ }
57
+ ```
58
+
59
+ ### 编写爬虫
60
+
61
+ ```python
62
+ import zed_scrapy_playwright as zsp
63
+ from playwright.async_api import Page
64
+
65
+ class MySpider(zsp.Spider):
66
+ name = "example"
67
+
68
+ async def start(self):
69
+ yield zsp.Request(
70
+ exec_type="NewPage",
71
+ execution=self.search_and_scrape,
72
+ callback=self.parse,
73
+ meta={"keyword": "laptop"},
74
+ )
75
+
76
+ @staticmethod
77
+ async def search_and_scrape(request: zsp.Request, page: Page):
78
+ keyword = request.meta["keyword"]
79
+
80
+ await page.goto("https://example.com")
81
+ await page.fill("#search", keyword)
82
+ await page.click("#search-btn")
83
+ await page.wait_for_selector(".results")
84
+
85
+ # 初筛数据:直接在页面中提取
86
+ items = await page.evaluate("""
87
+ Array.from(document.querySelectorAll('.item')).map(el => ({
88
+ title: el.querySelector('.title')?.innerText,
89
+ price: el.querySelector('.price')?.innerText,
90
+ }))
91
+ """)
92
+
93
+ return {"items": items, "keyword": keyword}
94
+
95
+ async def parse(self, response: zsp.Response):
96
+ data = response.data # 初筛数据
97
+ for item in data["items"]:
98
+ yield {
99
+ "title": item["title"],
100
+ "price": float(item["price"].replace("$", "")),
101
+ }
102
+ ```
103
+
104
+ ---
105
+
106
+ ## 对比 scrapy-playwright
107
+
108
+ | 维度 | scrapy-playwright | zed-scrapy-playwright |
109
+ |------|:---:|:---:|
110
+ | 操作自由度 | 有限 | 无限 |
111
+ | 多次 goto | ❌ | ✅ |
112
+ | 登录态保持 | 配置复杂 | 天然支持 |
113
+ | 代码组织 | 分散 callback | 线性聚合 |
114
+ | 数据传输 | 完整 HTML | 初筛数据 |
115
+ | 防误用机制 | 无 | 双向交叉检查 |
116
+
117
+ ---
118
+
119
+ ## 许可证
120
+
121
+ 暂定:MIT
@@ -0,0 +1,109 @@
1
+ # zed-scrapy-playwright
2
+
3
+ > Less meta config, more Python code.
4
+
5
+ > 把 Scrapy 变成 Playwright 的事件调度引擎。
6
+
7
+ [English](docs/README-en.md)
8
+
9
+ ---
10
+
11
+ ## 简介
12
+
13
+ `zed-scrapy-playwright` 是一个 Scrapy 中间件,它重新定义了 Scrapy 与 Playwright 的集成方式——**让 Scrapy 成为事件调度器,让 Playwright 成为可编程的执行引擎。**
14
+
15
+ 不同于传统方案将每个请求限制为单次页面导航,本中间件允许你在一个 `execution` 中完成完整的业务流程:登录、搜索、详情、下单,一气呵成。
16
+
17
+ ---
18
+
19
+ ## 核心特性
20
+
21
+ - **会话级控制** — 单次 execution 内多次 `goto`,登录态自动保持
22
+ - **线性编码** — 复杂流程写在一个函数里,告别 callback 地狱
23
+ - **精准数据传输** — 在 Page 中直接提取初筛数据,避免传输冗余 HTML
24
+ - **完全开放** — 任意 Playwright API 均可使用,无预定义操作限制
25
+ - **双向交叉检查** — 中间件与爬虫相互验证,配置错误快速失败
26
+ - **Stealth 灵活可控** — 可全局注入,也可在每个 execution 中自定义
27
+ - **类型安全** — 完整的类型提示,IDE 友好
28
+
29
+ ---
30
+
31
+ ## 快速开始
32
+
33
+ ### 安装
34
+
35
+ ```bash
36
+ pip install zed-scrapy-playwright
37
+ ```
38
+
39
+ ### 配置 settings.py
40
+
41
+ ```python
42
+ DOWNLOADER_MIDDLEWARES = {
43
+ "zed_scrapy_playwright.handler.PlaywrightDownloaderMiddleware": 300,
44
+ }
45
+ ```
46
+
47
+ ### 编写爬虫
48
+
49
+ ```python
50
+ import zed_scrapy_playwright as zsp
51
+ from playwright.async_api import Page
52
+
53
+ class MySpider(zsp.Spider):
54
+ name = "example"
55
+
56
+ async def start(self):
57
+ yield zsp.Request(
58
+ exec_type="NewPage",
59
+ execution=self.search_and_scrape,
60
+ callback=self.parse,
61
+ meta={"keyword": "laptop"},
62
+ )
63
+
64
+ @staticmethod
65
+ async def search_and_scrape(request: zsp.Request, page: Page):
66
+ keyword = request.meta["keyword"]
67
+
68
+ await page.goto("https://example.com")
69
+ await page.fill("#search", keyword)
70
+ await page.click("#search-btn")
71
+ await page.wait_for_selector(".results")
72
+
73
+ # 初筛数据:直接在页面中提取
74
+ items = await page.evaluate("""
75
+ Array.from(document.querySelectorAll('.item')).map(el => ({
76
+ title: el.querySelector('.title')?.innerText,
77
+ price: el.querySelector('.price')?.innerText,
78
+ }))
79
+ """)
80
+
81
+ return {"items": items, "keyword": keyword}
82
+
83
+ async def parse(self, response: zsp.Response):
84
+ data = response.data # 初筛数据
85
+ for item in data["items"]:
86
+ yield {
87
+ "title": item["title"],
88
+ "price": float(item["price"].replace("$", "")),
89
+ }
90
+ ```
91
+
92
+ ---
93
+
94
+ ## 对比 scrapy-playwright
95
+
96
+ | 维度 | scrapy-playwright | zed-scrapy-playwright |
97
+ |------|:---:|:---:|
98
+ | 操作自由度 | 有限 | 无限 |
99
+ | 多次 goto | ❌ | ✅ |
100
+ | 登录态保持 | 配置复杂 | 天然支持 |
101
+ | 代码组织 | 分散 callback | 线性聚合 |
102
+ | 数据传输 | 完整 HTML | 初筛数据 |
103
+ | 防误用机制 | 无 | 双向交叉检查 |
104
+
105
+ ---
106
+
107
+ ## 许可证
108
+
109
+ 暂定:MIT
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "zed-scrapy-playwright"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "playwright>=1.62.0",
9
+ "playwright-stealth>=2.0.3",
10
+ "scrapy>=2.18.0",
11
+ ]
12
+
13
+ [[project.authors]]
14
+ name = "zhouzhouos"
15
+ email = "leisure-zyf@qq.com"
16
+
17
+ [project.scripts]
18
+ zed-scrapy-playwright = "zed_scrapy_playwright:main"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.12.5,<0.13.0"]
22
+ build-backend = "uv_build"
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "zed-scrapy-playwright"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "zhouzhouos", email = "leisure-zyf@qq.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "playwright>=1.62.0",
12
+ "playwright-stealth>=2.0.3",
13
+ "scrapy>=2.18.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ zed-scrapy-playwright = "zed_scrapy_playwright:main"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.12.5,<0.13.0"]
21
+ build-backend = "uv_build"
@@ -0,0 +1 @@
1
+ from .definition import PREFIX, Request, Response, Spider
@@ -0,0 +1,104 @@
1
+ # from scrapy.http import Response, TextResponse
2
+ from collections.abc import Awaitable, Callable
3
+ from typing import Any, Literal, Self
4
+
5
+ import scrapy
6
+ import scrapy.http
7
+ from playwright.async_api import Page
8
+ from scrapy.crawler import Crawler
9
+ from scrapy.exceptions import NotConfigured
10
+
11
+ PREFIX = "playwright"
12
+ ExecType = Literal["NewPage"]
13
+
14
+ X = "zed_scrapy_playwright.handler.PlaywrightDownloaderMiddleware"
15
+
16
+
17
+ class Response(scrapy.http.Response):
18
+ def __init__(
19
+ self,
20
+ result,
21
+ url=f"{PREFIX}://response",
22
+ status=200,
23
+ headers=None,
24
+ body=b"",
25
+ flags=None,
26
+ request=None,
27
+ certificate=None,
28
+ ip_address=None,
29
+ protocol=None,
30
+ ):
31
+ super().__init__(
32
+ url,
33
+ status,
34
+ headers,
35
+ body,
36
+ flags,
37
+ request,
38
+ certificate,
39
+ ip_address,
40
+ protocol,
41
+ )
42
+ self.result: Any = result
43
+
44
+
45
+ class Request(scrapy.Request):
46
+ def __init__(
47
+ self,
48
+ exec_type: ExecType = "NewPage",
49
+ execution: Callable[["Request", Page], Awaitable[Any]] | None = None,
50
+ callback=None,
51
+ # method="GET",
52
+ # headers=None,
53
+ # body=None,
54
+ # cookies=None,
55
+ meta=None,
56
+ # encoding="utf-8",
57
+ # priority=0,
58
+ dont_filter=True,
59
+ # errback=None,
60
+ # flags=None,
61
+ # cb_kwargs=None,
62
+ ):
63
+ super().__init__(
64
+ url=f"{PREFIX}://{exec_type}",
65
+ callback=callback,
66
+ # method,
67
+ # headers,
68
+ # body,
69
+ # cookies,
70
+ meta=meta,
71
+ # encoding,
72
+ # priority,
73
+ # dont_filter,
74
+ # errback,
75
+ # flags,
76
+ # cb_kwargs,
77
+ )
78
+ self.exec_type: ExecType = exec_type
79
+ self.execution = execution if execution else self._default_execution
80
+
81
+ @staticmethod
82
+ async def _default_execution(request: "Request", page: Page):
83
+ print("Warning")
84
+
85
+
86
+ class Spider(scrapy.Spider):
87
+ """用以判断是否启用此对应中间件的环境"""
88
+
89
+ info = {"headless": False, "executable_path": None}
90
+
91
+ @classmethod
92
+ def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self:
93
+ spider = cls(*args, **kwargs)
94
+ spider._set_crawler(crawler)
95
+
96
+ if X not in spider.settings.getdict("DOWNLOADER_MIDDLEWARES"):
97
+ raise NotConfigured(
98
+ f"没有注册 {X}, 则不能启用该中间件及其对应的爬虫类 {type(spider)}。"
99
+ )
100
+
101
+ return spider
102
+
103
+ def __init__(self, name: str | None = None, **kwargs: dict):
104
+ super().__init__(name, **kwargs)
@@ -0,0 +1,25 @@
1
+ from playwright.async_api import Page
2
+
3
+ import zed_scrapy_playwright as zsp
4
+
5
+
6
+ class ExampleExecutor(zsp.Spider):
7
+ name = "example"
8
+ info = {"headless": False, "executable_path": "/opt/google/chrome/chrome"}
9
+ # allowed_domains = ["example.com"]
10
+ # start_urls = ["https://example.com"]
11
+
12
+ async def start(self):
13
+
14
+ yield zsp.Request(
15
+ execution=self.exection,
16
+ callback=self.parse,
17
+ )
18
+
19
+ @staticmethod
20
+ async def exection(request: zsp.Request, page: Page):
21
+ await page.goto("https://example.com")
22
+ return await page.content()
23
+
24
+ def parse(self, response: zsp.Response):
25
+ print(response.result)
@@ -0,0 +1,115 @@
1
+ # Define here the models for your spider middleware
2
+ #
3
+ # See documentation in:
4
+ # https://docs.scrapy.org/en/latest/topics/spider-middleware.html
5
+
6
+ # useful for handling different item types with a single interface
7
+ # from itemadapter import ItemAdapter
8
+
9
+
10
+ from playwright.async_api import async_playwright
11
+ from playwright_stealth import Stealth
12
+ from scrapy import Request, crawler, signals
13
+
14
+ from . import definition as Zed
15
+
16
+
17
+ class PlaywrightDownloaderMiddleware:
18
+ # Not all methods need to be defined. If a method is not defined,
19
+ # scrapy acts as if the downloader middleware does not modify the
20
+ # passed objects.
21
+
22
+ @classmethod
23
+ def from_crawler(cls, crawler: crawler.Crawler):
24
+ # This method is used by Scrapy to create your spiders.
25
+ s = cls()
26
+ s.c = crawler
27
+ # 连接中间件在爬虫开始时的触发事件
28
+ crawler.signals.connect(s.spider_opened, signal=signals.spider_opened)
29
+ # 连接中间件在爬虫结束时的触发事件
30
+ crawler.signals.connect(s.spider_closed, signal=signals.spider_closed)
31
+ return s
32
+
33
+ async def process_request(self, request: Request, spider=None):
34
+ # Called for each request that goes through the downloader
35
+ # middleware.
36
+
37
+ # Must either:
38
+ # - return None: continue processing this request
39
+ # - or return a Response object
40
+ # - or return a Request object
41
+ # - or raise IgnoreRequest: process_exception() methods of
42
+ # installed downloader middleware will be called
43
+
44
+ # 这里处理主动发起的自定义的 request 类型
45
+ if isinstance(request, Zed.Request):
46
+ print("主动发起", request.url, request.meta)
47
+
48
+ match request.exec_type:
49
+ case "NewPage":
50
+ page = await self.context.new_page()
51
+
52
+ if request.meta.get("no-stealth"):
53
+ pass
54
+ else:
55
+ print("已为 new page 自动施加 Stealth.apply_stealth_async")
56
+ await Stealth().apply_stealth_async(page)
57
+
58
+ r = await request.execution(request, page)
59
+ return Zed.Response(r, request=request)
60
+
61
+ case _:
62
+ pass
63
+
64
+ # 这里处理自动发起的 scrapy.Request 类型,比如 <class 'scrapy.http.request.Request'> wpwp://nothing/robots.txt
65
+ if request.url.startswith(Zed.PREFIX):
66
+ print("自动请求", request.url)
67
+ return Zed.Response("", request=request)
68
+
69
+ # 其他的寻常的 request 不在这里截留,让其 continue
70
+
71
+ def process_response(self, request, response, spider=None):
72
+ # Called with the response returned from the downloader.
73
+
74
+ # Must either;
75
+ # - return a Response object
76
+ # - return a Request object
77
+ # - or raise IgnoreRequest
78
+ return response
79
+
80
+ def process_exception(self, request, exception, spider=None):
81
+ # Called when a download handler or a process_request()
82
+ # (from other downloader middleware) raises an exception.
83
+
84
+ # Must either:
85
+ # - return None: continue processing this exception
86
+ # - return a Response object: stops process_exception() chain
87
+ # - return a Request object: stops process_exception() chain
88
+ pass
89
+
90
+ async def spider_opened(self, spider=None):
91
+ self.c: crawler.Crawler
92
+ if not isinstance(self.c.spider, Zed.Spider):
93
+ print(
94
+ "Warning:",
95
+ f"该爬虫类没有继承于 {type(self)},将不会进行此中间件环境的启用",
96
+ )
97
+ return
98
+
99
+ print("spider_opened, start the initialization of async playwright api object.")
100
+ self.pm = async_playwright()
101
+ self.pr = await self.pm.start()
102
+ self.browser = await self.pr.chromium.launch(**self.c.spider.info)
103
+ self.context = await self.browser.new_context(no_viewport=True)
104
+
105
+ async def spider_closed(self):
106
+ if not isinstance(self.c.spider, Zed.Spider):
107
+ return
108
+
109
+ print(
110
+ "spider_closed, start the finalizing work of async playwright api object."
111
+ )
112
+ await self.context.close()
113
+ await self.browser.close()
114
+ await self.pr.stop()
115
+ await self.pm.__aexit__()