fastcrawl 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.
- fastcrawl-0.1.0/LICENSE +21 -0
- fastcrawl-0.1.0/PKG-INFO +98 -0
- fastcrawl-0.1.0/README.md +76 -0
- fastcrawl-0.1.0/fastcrawl/__init__.py +10 -0
- fastcrawl-0.1.0/fastcrawl/base_crawler.py +139 -0
- fastcrawl-0.1.0/fastcrawl/base_pipeline.py +60 -0
- fastcrawl-0.1.0/fastcrawl/models/__init__.py +8 -0
- fastcrawl-0.1.0/fastcrawl/models/crawler_settings.py +103 -0
- fastcrawl-0.1.0/fastcrawl/models/crawler_stats.py +58 -0
- fastcrawl-0.1.0/fastcrawl/models/request.py +53 -0
- fastcrawl-0.1.0/fastcrawl/models/response.py +74 -0
- fastcrawl-0.1.0/fastcrawl/types.py +25 -0
- fastcrawl-0.1.0/pyproject.toml +57 -0
fastcrawl-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Ilarion Kuleshov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
fastcrawl-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: fastcrawl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fast and asynchronous web crawling and scraping library for Python.
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: ilarionkuleshov
|
|
7
|
+
Author-email: ilarion.kuleshov@gmail.com
|
|
8
|
+
Requires-Python: >=3.9,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
17
|
+
Requires-Dist: parsel (>=1.9.1,<2.0.0)
|
|
18
|
+
Requires-Dist: pydantic (>=2.10.4,<3.0.0)
|
|
19
|
+
Requires-Dist: pydantic-settings (>=2.7.0,<3.0.0)
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# FastCrawl
|
|
23
|
+
|
|
24
|
+
<p align="left">
|
|
25
|
+
<a href="https://github.com/ilarionkuleshov/fastcrawl/actions/workflows/code-quality.yml/?query=event%3Apush+branch%3Amain">
|
|
26
|
+
<img src="https://github.com/ilarionkuleshov/fastcrawl/actions/workflows/code-quality.yml/badge.svg?event=push&branch=main">
|
|
27
|
+
</a>
|
|
28
|
+
<a href="https://coverage-badge.samuelcolvin.workers.dev/redirect/ilarionkuleshov/fastcrawl">
|
|
29
|
+
<img src="https://coverage-badge.samuelcolvin.workers.dev/ilarionkuleshov/fastcrawl.svg">
|
|
30
|
+
</a>
|
|
31
|
+
<a href="https://pypi.org/project/fastcrawl">
|
|
32
|
+
<img src="https://img.shields.io/pypi/v/fastcrawl?color=%2334D058">
|
|
33
|
+
</a>
|
|
34
|
+
<a href="https://pypi.org/project/fastcrawl">
|
|
35
|
+
<img src="https://img.shields.io/pypi/pyversions/fastcrawl.svg?color=%2334D058">
|
|
36
|
+
</a>
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
FastCrawl is a Python library for web crawling and scraping, inspired by [Scrapy](https://github.com/scrapy/scrapy) but designed to run seamlessly in asynchronous applications. Built on top of [Httpx](https://github.com/encode/httpx), it provides a lightweight foundation for creating custom crawlers based on the `BaseCrawler` class. The library supports defining custom Pipelines for processing scraped items, which can be easily implemented by extending the `BasePipeline` class. While its functionality is still growing, FastCrawl offers flexible settings options for the crawler, HTTP client, requests, and more.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
FastCrawl is available on PyPI and can be installed using pip:
|
|
44
|
+
```bash
|
|
45
|
+
pip install fastcrawl
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
Here is a simple example of how to create a custom crawler using FastCrawl:
|
|
51
|
+
```python
|
|
52
|
+
import asyncio
|
|
53
|
+
from typing import AsyncIterator
|
|
54
|
+
|
|
55
|
+
from pydantic import BaseModel
|
|
56
|
+
from fastcrawl import BaseCrawler, BasePipeline, Request, Response, CrawlerSettings
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ExampleItem(BaseModel):
|
|
60
|
+
title: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ExamplePipeline(BasePipeline[ExampleItem]):
|
|
64
|
+
async def process_item(self, item: ExampleItem) -> ExampleItem | None:
|
|
65
|
+
self.logger.info(f"Processing item: {item}")
|
|
66
|
+
return item
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ExampleCrawler(BaseCrawler):
|
|
70
|
+
settings = CrawlerSettings(
|
|
71
|
+
pipelines=[ExamplePipeline()],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def generate_requests(self) -> AsyncIterator[Request]:
|
|
75
|
+
yield Request(url="http://example.com/", callback=self.parse)
|
|
76
|
+
|
|
77
|
+
async def parse(self, response: Response) -> AsyncIterator[ExampleItem]:
|
|
78
|
+
title = response.selector.xpath(".//h1/text()").get() or "unknown"
|
|
79
|
+
yield ExampleItem(title=title)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
asyncio.run(ExampleCrawler().run())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
In this example, we define a custom `ExampleItem` model, a `ExamplePipeline` for processing scraped items, and an `ExampleCrawler` that generates requests and parses responses.
|
|
86
|
+
|
|
87
|
+
Crawler can be set up using the `settings` class attribute or by passing a `CrawlerSettings` instance to the constructor. See the model definition for all available settings.
|
|
88
|
+
|
|
89
|
+
Method `generate_requests` is executed once at the beginning of the crawl and should yield `Request` objects to start the crawl. Each request should have a callback function that will be called with the `Response` object when the request is completed.
|
|
90
|
+
|
|
91
|
+
In request callbacks, you can use the `Response` object to extract data using XPath selectors or other methods. Also you can yield another requests to follow links or scrape paginated content.
|
|
92
|
+
|
|
93
|
+
In pipelines, you can implement custom logic for processing items, such as saving them to a database, sending them to a message queue, or logging them. When defining a pipeline, you specify the type of item it will work with. The example specifies the `ExampleItem` pydantic model, but you can use any type you need. If the crawler returned an item of a different type, the pipeline would be skipped for that item.
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
This project is licensed under the MIT License.
|
|
98
|
+
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# FastCrawl
|
|
2
|
+
|
|
3
|
+
<p align="left">
|
|
4
|
+
<a href="https://github.com/ilarionkuleshov/fastcrawl/actions/workflows/code-quality.yml/?query=event%3Apush+branch%3Amain">
|
|
5
|
+
<img src="https://github.com/ilarionkuleshov/fastcrawl/actions/workflows/code-quality.yml/badge.svg?event=push&branch=main">
|
|
6
|
+
</a>
|
|
7
|
+
<a href="https://coverage-badge.samuelcolvin.workers.dev/redirect/ilarionkuleshov/fastcrawl">
|
|
8
|
+
<img src="https://coverage-badge.samuelcolvin.workers.dev/ilarionkuleshov/fastcrawl.svg">
|
|
9
|
+
</a>
|
|
10
|
+
<a href="https://pypi.org/project/fastcrawl">
|
|
11
|
+
<img src="https://img.shields.io/pypi/v/fastcrawl?color=%2334D058">
|
|
12
|
+
</a>
|
|
13
|
+
<a href="https://pypi.org/project/fastcrawl">
|
|
14
|
+
<img src="https://img.shields.io/pypi/pyversions/fastcrawl.svg?color=%2334D058">
|
|
15
|
+
</a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
FastCrawl is a Python library for web crawling and scraping, inspired by [Scrapy](https://github.com/scrapy/scrapy) but designed to run seamlessly in asynchronous applications. Built on top of [Httpx](https://github.com/encode/httpx), it provides a lightweight foundation for creating custom crawlers based on the `BaseCrawler` class. The library supports defining custom Pipelines for processing scraped items, which can be easily implemented by extending the `BasePipeline` class. While its functionality is still growing, FastCrawl offers flexible settings options for the crawler, HTTP client, requests, and more.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
FastCrawl is available on PyPI and can be installed using pip:
|
|
23
|
+
```bash
|
|
24
|
+
pip install fastcrawl
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
Here is a simple example of how to create a custom crawler using FastCrawl:
|
|
30
|
+
```python
|
|
31
|
+
import asyncio
|
|
32
|
+
from typing import AsyncIterator
|
|
33
|
+
|
|
34
|
+
from pydantic import BaseModel
|
|
35
|
+
from fastcrawl import BaseCrawler, BasePipeline, Request, Response, CrawlerSettings
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ExampleItem(BaseModel):
|
|
39
|
+
title: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ExamplePipeline(BasePipeline[ExampleItem]):
|
|
43
|
+
async def process_item(self, item: ExampleItem) -> ExampleItem | None:
|
|
44
|
+
self.logger.info(f"Processing item: {item}")
|
|
45
|
+
return item
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ExampleCrawler(BaseCrawler):
|
|
49
|
+
settings = CrawlerSettings(
|
|
50
|
+
pipelines=[ExamplePipeline()],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def generate_requests(self) -> AsyncIterator[Request]:
|
|
54
|
+
yield Request(url="http://example.com/", callback=self.parse)
|
|
55
|
+
|
|
56
|
+
async def parse(self, response: Response) -> AsyncIterator[ExampleItem]:
|
|
57
|
+
title = response.selector.xpath(".//h1/text()").get() or "unknown"
|
|
58
|
+
yield ExampleItem(title=title)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
asyncio.run(ExampleCrawler().run())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
In this example, we define a custom `ExampleItem` model, a `ExamplePipeline` for processing scraped items, and an `ExampleCrawler` that generates requests and parses responses.
|
|
65
|
+
|
|
66
|
+
Crawler can be set up using the `settings` class attribute or by passing a `CrawlerSettings` instance to the constructor. See the model definition for all available settings.
|
|
67
|
+
|
|
68
|
+
Method `generate_requests` is executed once at the beginning of the crawl and should yield `Request` objects to start the crawl. Each request should have a callback function that will be called with the `Response` object when the request is completed.
|
|
69
|
+
|
|
70
|
+
In request callbacks, you can use the `Response` object to extract data using XPath selectors or other methods. Also you can yield another requests to follow links or scrape paginated content.
|
|
71
|
+
|
|
72
|
+
In pipelines, you can implement custom logic for processing items, such as saving them to a database, sending them to a message queue, or logging them. When defining a pipeline, you specify the type of item it will work with. The example specifies the `ExampleItem` pydantic model, but you can use any type you need. If the crawler returned an item of a different type, the pipeline would be skipped for that item.
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
This project is licensed under the MIT License.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import AsyncIterator, Optional
|
|
5
|
+
|
|
6
|
+
from httpx import AsyncClient, Limits
|
|
7
|
+
|
|
8
|
+
from fastcrawl.models import CrawlerSettings, CrawlerStats, Request, Response
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseCrawler(ABC):
|
|
12
|
+
"""Base for all crawlers.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
settings (Optional[CrawlerSettings]): Settings for the crawler.
|
|
16
|
+
If not provided, the default settings will be used. Default is None.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
logger (logging.Logger): Logger for the crawler.
|
|
20
|
+
settings (CrawlerSettings): Settings for the crawler. Override it to set custom settings.
|
|
21
|
+
stats (CrawlerStats): Statistics for the crawler.
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
logger: logging.Logger
|
|
26
|
+
settings: CrawlerSettings = CrawlerSettings()
|
|
27
|
+
stats: CrawlerStats
|
|
28
|
+
|
|
29
|
+
_queue: asyncio.Queue
|
|
30
|
+
_http_client: AsyncClient
|
|
31
|
+
|
|
32
|
+
def __init__(self, settings: Optional[CrawlerSettings] = None) -> None:
|
|
33
|
+
if settings:
|
|
34
|
+
self.settings = settings
|
|
35
|
+
|
|
36
|
+
if self.settings.setup_logging:
|
|
37
|
+
self._setup_logging()
|
|
38
|
+
self.logger = logging.getLogger(self.__class__.__name__)
|
|
39
|
+
self.stats = CrawlerStats()
|
|
40
|
+
self._queue = asyncio.Queue()
|
|
41
|
+
self._http_client = AsyncClient(**self._get_http_client_kwargs())
|
|
42
|
+
|
|
43
|
+
def _setup_logging(self) -> None:
|
|
44
|
+
"""Sets up logging for the crawler."""
|
|
45
|
+
logging.basicConfig(
|
|
46
|
+
level=self.settings.logging.level,
|
|
47
|
+
format=self.settings.logging.format,
|
|
48
|
+
)
|
|
49
|
+
logging.getLogger("asyncio").setLevel(self.settings.logging.level_asyncio)
|
|
50
|
+
logging.getLogger("httpx").setLevel(self.settings.logging.level_httpx)
|
|
51
|
+
logging.getLogger("httpcore").setLevel(self.settings.logging.level_httpcore)
|
|
52
|
+
|
|
53
|
+
def _get_http_client_kwargs(self):
|
|
54
|
+
kwargs = self.settings.http_client.model_dump()
|
|
55
|
+
kwargs["params"] = kwargs.pop("query_params")
|
|
56
|
+
kwargs["trust_env"] = False
|
|
57
|
+
kwargs["limits"] = Limits(
|
|
58
|
+
max_connections=kwargs.pop("max_connections"),
|
|
59
|
+
max_keepalive_connections=kwargs.pop("max_keepalive_connections"),
|
|
60
|
+
keepalive_expiry=kwargs.pop("keepalive_expiry"),
|
|
61
|
+
)
|
|
62
|
+
return kwargs
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
async def generate_requests(self) -> AsyncIterator[Request]:
|
|
66
|
+
"""Yields requests to be processed."""
|
|
67
|
+
if False: # pylint: disable=W0125 # pragma: no cover
|
|
68
|
+
yield Request(url="https://example.com/", callback=lambda _: None) # just a stub for mypy
|
|
69
|
+
|
|
70
|
+
async def run(self) -> None:
|
|
71
|
+
"""Runs the crawler."""
|
|
72
|
+
self.logger.info("Running crawler with settings: %s", self.settings.model_dump_json(indent=2))
|
|
73
|
+
self.stats.start_crawling()
|
|
74
|
+
for pipeline in self.settings.pipelines:
|
|
75
|
+
await pipeline.on_crawler_start()
|
|
76
|
+
|
|
77
|
+
async for request in self.generate_requests():
|
|
78
|
+
await self._queue.put(request)
|
|
79
|
+
|
|
80
|
+
workers = [asyncio.create_task(self._worker()) for _ in range(self.settings.workers)]
|
|
81
|
+
await self._queue.join()
|
|
82
|
+
for worker in workers:
|
|
83
|
+
worker.cancel()
|
|
84
|
+
|
|
85
|
+
await self._http_client.aclose()
|
|
86
|
+
|
|
87
|
+
for pipeline in self.settings.pipelines:
|
|
88
|
+
await pipeline.on_crawler_finish()
|
|
89
|
+
self.stats.finish_crawling()
|
|
90
|
+
self.logger.info("Crawling finished with stats: %s", self.stats.model_dump_json(indent=2))
|
|
91
|
+
|
|
92
|
+
async def _worker(self) -> None:
|
|
93
|
+
"""Worker to process requests from the queue."""
|
|
94
|
+
while True:
|
|
95
|
+
request = await self._queue.get()
|
|
96
|
+
try:
|
|
97
|
+
await self._process_request(request)
|
|
98
|
+
except Exception as exc: # pylint: disable=W0718
|
|
99
|
+
self.logger.error("Error processing request %s: %s", request, exc)
|
|
100
|
+
finally:
|
|
101
|
+
self._queue.task_done()
|
|
102
|
+
|
|
103
|
+
async def _process_request(self, request: Request) -> None:
|
|
104
|
+
"""Executes the request, callback and processes the results.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
request (Request): The request to process.
|
|
108
|
+
|
|
109
|
+
"""
|
|
110
|
+
self.logger.debug("Processing request: %s", request)
|
|
111
|
+
self.stats.add_request()
|
|
112
|
+
|
|
113
|
+
request_kwargs = request.model_dump(exclude_none=True, exclude={"callback"})
|
|
114
|
+
if "query_params" in request_kwargs:
|
|
115
|
+
request_kwargs["params"] = request_kwargs.pop("query_params")
|
|
116
|
+
if "form_data" in request_kwargs:
|
|
117
|
+
request_kwargs["data"] = request_kwargs.pop("form_data")
|
|
118
|
+
if "json_data" in request_kwargs:
|
|
119
|
+
request_kwargs["json"] = request_kwargs.pop("json_data")
|
|
120
|
+
httpx_response = await self._http_client.request(**request_kwargs)
|
|
121
|
+
|
|
122
|
+
response = await Response.from_httpx_response(httpx_response, request)
|
|
123
|
+
self.logger.debug("Got response: %s", response)
|
|
124
|
+
self.stats.add_response(response.status_code)
|
|
125
|
+
|
|
126
|
+
callback_result = request.callback(response)
|
|
127
|
+
if hasattr(callback_result, "__aiter__"):
|
|
128
|
+
async for item in callback_result:
|
|
129
|
+
if isinstance(item, Request):
|
|
130
|
+
await self._queue.put(item)
|
|
131
|
+
elif item is not None:
|
|
132
|
+
for pipeline in self.settings.pipelines:
|
|
133
|
+
item = await pipeline.process_item_with_check(item)
|
|
134
|
+
if item is None:
|
|
135
|
+
break
|
|
136
|
+
else:
|
|
137
|
+
self.stats.add_item()
|
|
138
|
+
else:
|
|
139
|
+
await callback_result
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any, Generic, Optional, TypeVar, get_args
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BasePipeline(ABC, Generic[T]):
|
|
9
|
+
"""Base for all pipelines.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
logger (logging.Logger): Logger for the crawler.
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
logger: logging.Logger
|
|
17
|
+
_expected_type: type[T]
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self.logger = logging.getLogger(self.__class__.__name__)
|
|
21
|
+
self._expected_type = get_args(self.__orig_bases__[0])[0] # type: ignore[attr-defined] # pylint: disable=E1101
|
|
22
|
+
|
|
23
|
+
async def process_item_with_check(self, item: Any) -> Any:
|
|
24
|
+
"""Processes an item with type checking.
|
|
25
|
+
|
|
26
|
+
Note:
|
|
27
|
+
If the item is not an instance of the expected type, it will be returned as is.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
item (Any): Item to process.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Any: Processed item or the item itself.
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
if not isinstance(item, self._expected_type):
|
|
37
|
+
return item
|
|
38
|
+
return await self.process_item(item)
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def process_item(self, item: T) -> Optional[T]:
|
|
42
|
+
"""Processes an item returned by the crawler.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
item (T): Item to process.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
T: Processed item.
|
|
49
|
+
None: If the item should be dropped and not passed to the next pipelines.
|
|
50
|
+
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
async def on_crawler_start(self) -> None:
|
|
54
|
+
"""Called when the crawler starts."""
|
|
55
|
+
|
|
56
|
+
async def on_crawler_finish(self) -> None:
|
|
57
|
+
"""Called when the crawler finishes."""
|
|
58
|
+
|
|
59
|
+
def __str__(self) -> str:
|
|
60
|
+
return f"<{self.__class__.__name__}[{self._expected_type.__name__}]>"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from typing import Annotated, Optional, Union
|
|
2
|
+
|
|
3
|
+
from dotenv import find_dotenv
|
|
4
|
+
from httpx import URL
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
from pydantic.functional_serializers import PlainSerializer
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
from fastcrawl.base_pipeline import BasePipeline
|
|
10
|
+
from fastcrawl.types import Auth, Cookies, Headers, QueryParams
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CrawlerLoggingSettings(BaseModel):
|
|
14
|
+
"""Crawler logging settings model.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
level (str): Logging level for the crawler. Default is "INFO".
|
|
18
|
+
format (str): Logging format for the crawler.
|
|
19
|
+
Default is "%(asctime)s [%(name)s] %(levelname)s: %(message)s".
|
|
20
|
+
level_asyncio (str): Logging level for asyncio library. Default is "WARNING".
|
|
21
|
+
level_httpx (str): Logging level for httpx library. Default is "WARNING".
|
|
22
|
+
level_httpcore (str): Logging level for httpcore library. Default is "WARNING".
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
level: str = "INFO"
|
|
27
|
+
format: str = "%(asctime)s [%(name)s] %(levelname)s: %(message)s"
|
|
28
|
+
level_asyncio: str = "WARNING"
|
|
29
|
+
level_httpx: str = "WARNING"
|
|
30
|
+
level_httpcore: str = "WARNING"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CrawlerHttpClientSettings(BaseModel):
|
|
34
|
+
"""Crawler HTTP client settings model.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
base_url (Union[URL, str]): Base URL for the HTTP client. Default is "".
|
|
38
|
+
auth (Optional[Auth]): Authentication for the HTTP client. Default is None.
|
|
39
|
+
query_params (Optional[QueryParams]): Query parameters for the HTTP client. Default is None.
|
|
40
|
+
headers (Optional[Headers]): Headers for the HTTP client. Default is None.
|
|
41
|
+
cookies (Optional[Cookies]): Cookies for the HTTP client. Default is None.
|
|
42
|
+
verify (bool): Whether to verify SSL certificates. Default is True.
|
|
43
|
+
http1 (bool): Whether to use HTTP/1.1. Default is True.
|
|
44
|
+
http2 (bool): Whether to use HTTP/2. Default is False.
|
|
45
|
+
proxy (Optional[Union[URL, str]]): Proxy for the HTTP client. Default is None.
|
|
46
|
+
timeout (float): Timeout for the HTTP client. Default is 5.0.
|
|
47
|
+
max_connections (Optional[int]): Specifies the maximum number of concurrent connections allowed. Default is 100.
|
|
48
|
+
max_keepalive_connections (Optional[int]): The maximum number of keep-alive connections the pool can maintain.
|
|
49
|
+
Must not exceed `max_connections`. Default is 20.
|
|
50
|
+
keepalive_expiry (Optional[float]): The maximum duration in seconds that a keep-alive
|
|
51
|
+
connection can remain idle. Default is 5.0.
|
|
52
|
+
follow_redirects (bool): Whether to follow redirects. Default is False.
|
|
53
|
+
max_redirects (int): Maximum number of redirects to follow. Default is 20.
|
|
54
|
+
default_encoding (str): Default encoding for the HTTP client. Default is "utf-8".
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
base_url: Union[URL, str] = ""
|
|
59
|
+
auth: Optional[Auth] = None
|
|
60
|
+
query_params: Optional[QueryParams] = None
|
|
61
|
+
headers: Optional[Headers] = None
|
|
62
|
+
cookies: Optional[Cookies] = None
|
|
63
|
+
verify: bool = True
|
|
64
|
+
http1: bool = True
|
|
65
|
+
http2: bool = False
|
|
66
|
+
proxy: Optional[Union[URL, str]] = None
|
|
67
|
+
timeout: float = 5.0
|
|
68
|
+
max_connections: Optional[int] = 100
|
|
69
|
+
max_keepalive_connections: Optional[int] = 20
|
|
70
|
+
keepalive_expiry: Optional[float] = 5.0
|
|
71
|
+
follow_redirects: bool = False
|
|
72
|
+
max_redirects: int = 20
|
|
73
|
+
default_encoding: str = "utf-8"
|
|
74
|
+
|
|
75
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CrawlerSettings(BaseSettings):
|
|
79
|
+
"""Crawler settings model.
|
|
80
|
+
|
|
81
|
+
Attributes:
|
|
82
|
+
workers (int): Number of workers to process requests. Default is 15.
|
|
83
|
+
pipelines (list[BasePipeline]): List of pipelines to process responses.
|
|
84
|
+
Pipelines will be executed in the order they are defined. Default is [].
|
|
85
|
+
setup_logging (bool): Whether to setup logging for the crawler. Default is True.
|
|
86
|
+
logging (CrawlerLoggingSettings): Logging settings for the crawler. Default is CrawlerLoggingSettings().
|
|
87
|
+
http_client (CrawlerHttpClientSettings): HTTP client settings for the crawler.
|
|
88
|
+
Default is CrawlerHttpClientSettings().
|
|
89
|
+
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
workers: int = 15
|
|
93
|
+
pipelines: list[Annotated[BasePipeline, PlainSerializer(str)]] = []
|
|
94
|
+
setup_logging: bool = True
|
|
95
|
+
logging: CrawlerLoggingSettings = CrawlerLoggingSettings()
|
|
96
|
+
http_client: CrawlerHttpClientSettings = CrawlerHttpClientSettings()
|
|
97
|
+
|
|
98
|
+
model_config = SettingsConfigDict(
|
|
99
|
+
env_file=find_dotenv(),
|
|
100
|
+
env_prefix="fastcrawl_",
|
|
101
|
+
env_nested_delimiter="__",
|
|
102
|
+
extra="ignore",
|
|
103
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CrawlerStats(BaseModel):
|
|
8
|
+
"""Crawler statistics model.
|
|
9
|
+
|
|
10
|
+
Note:
|
|
11
|
+
To update statistics while crawling, use corresponding methods.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
started_at (Optional[datetime]): The time when the crawling started. Default is None.
|
|
15
|
+
finished_at (Optional[datetime]): The time when the crawling finished. Default is None.
|
|
16
|
+
requests (Optional[int]): The number of requests made during the crawling. Default is None.
|
|
17
|
+
responses_by_codes (Optional[dict[int, int]]): The number of responses by status code. Default is None.
|
|
18
|
+
items (Optional[int]): The number of items crawled. Default is None.
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
started_at: Optional[datetime] = None
|
|
23
|
+
finished_at: Optional[datetime] = None
|
|
24
|
+
requests: Optional[int] = None
|
|
25
|
+
responses_by_codes: Optional[dict[int, int]] = None
|
|
26
|
+
items: Optional[int] = None
|
|
27
|
+
|
|
28
|
+
def start_crawling(self) -> None:
|
|
29
|
+
"""Sets the time when the crawling started."""
|
|
30
|
+
self.started_at = datetime.now()
|
|
31
|
+
|
|
32
|
+
def finish_crawling(self) -> None:
|
|
33
|
+
"""Sets the time when the crawling finished."""
|
|
34
|
+
self.finished_at = datetime.now()
|
|
35
|
+
|
|
36
|
+
def add_request(self) -> None:
|
|
37
|
+
"""Increases the number of requests made during the crawling."""
|
|
38
|
+
if self.requests:
|
|
39
|
+
self.requests += 1
|
|
40
|
+
else:
|
|
41
|
+
self.requests = 1
|
|
42
|
+
|
|
43
|
+
def add_response(self, status_code: int) -> None:
|
|
44
|
+
"""Increases the number of responses by status code."""
|
|
45
|
+
if self.responses_by_codes is None:
|
|
46
|
+
self.responses_by_codes = {}
|
|
47
|
+
|
|
48
|
+
if status_code in self.responses_by_codes:
|
|
49
|
+
self.responses_by_codes[status_code] += 1
|
|
50
|
+
else:
|
|
51
|
+
self.responses_by_codes[status_code] = 1
|
|
52
|
+
|
|
53
|
+
def add_item(self) -> None:
|
|
54
|
+
"""Increases the number of items crawled."""
|
|
55
|
+
if self.items:
|
|
56
|
+
self.items += 1
|
|
57
|
+
else:
|
|
58
|
+
self.items = 1
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from typing import Optional, Union
|
|
2
|
+
|
|
3
|
+
from httpx import URL
|
|
4
|
+
from pydantic import BaseModel, ConfigDict
|
|
5
|
+
|
|
6
|
+
from fastcrawl.types import (
|
|
7
|
+
Auth,
|
|
8
|
+
Cookies,
|
|
9
|
+
Files,
|
|
10
|
+
FormData,
|
|
11
|
+
Headers,
|
|
12
|
+
JsonData,
|
|
13
|
+
QueryParams,
|
|
14
|
+
RequestCallback,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Request(BaseModel):
|
|
19
|
+
"""Request model.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
method (str): HTTP method. Default is "GET".
|
|
23
|
+
url (Union[URL, str]): URL to request.
|
|
24
|
+
callback (RequestCallback): Callback to process the response.
|
|
25
|
+
query_params (Optional[QueryParams]): Query parameters for the URL. Default is None.
|
|
26
|
+
headers (Optional[Headers]): Headers for the request. Default is None.
|
|
27
|
+
cookies (Optional[Cookies]): Cookies for the request. Default is None.
|
|
28
|
+
form_data (Optional[FormData]): Form data for the request. Default is None.
|
|
29
|
+
json_data (Optional[JsonData]): JSON data for the request. Default is None.
|
|
30
|
+
files (Optional[Files]): Files for the request. Default is None.
|
|
31
|
+
auth (Optional[Auth]): Authentication credentials. Default is None.
|
|
32
|
+
timeout (Optional[float]): Timeout for the request in seconds. Default is None.
|
|
33
|
+
follow_redirects (Optional[bool]): Whether to follow redirects. Default is None.
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
method: str = "GET"
|
|
38
|
+
url: Union[URL, str]
|
|
39
|
+
callback: RequestCallback
|
|
40
|
+
query_params: Optional[QueryParams] = None
|
|
41
|
+
headers: Optional[Headers] = None
|
|
42
|
+
cookies: Optional[Cookies] = None
|
|
43
|
+
form_data: Optional[FormData] = None
|
|
44
|
+
json_data: Optional[JsonData] = None
|
|
45
|
+
files: Optional[Files] = None
|
|
46
|
+
auth: Optional[Auth] = None
|
|
47
|
+
timeout: Optional[float] = None
|
|
48
|
+
follow_redirects: Optional[bool] = None
|
|
49
|
+
|
|
50
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
51
|
+
|
|
52
|
+
def __str__(self) -> str:
|
|
53
|
+
return f"<{self.__class__.__name__}({self.method}, {self.url})>"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from httpx import URL
|
|
5
|
+
from httpx import Response as HttpxResponse
|
|
6
|
+
from httpx import ResponseNotRead
|
|
7
|
+
from parsel import Selector
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, PrivateAttr
|
|
9
|
+
|
|
10
|
+
from fastcrawl.models.request import Request
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Response(BaseModel):
|
|
14
|
+
"""Response model.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
url (URL): URL of the response.
|
|
18
|
+
status_code (int): Status code of the response.
|
|
19
|
+
content (bytes): Content of the response.
|
|
20
|
+
text (str): Text of the response.
|
|
21
|
+
headers (Optional[dict[str, str]]): Headers of the response. Default is None.
|
|
22
|
+
cookies (Optional[dict[str, str]]): Cookies of the response. Default is None.
|
|
23
|
+
request (Request): Request used to fetch the response.
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
url: URL
|
|
28
|
+
status_code: int
|
|
29
|
+
content: bytes
|
|
30
|
+
text: str
|
|
31
|
+
headers: Optional[dict[str, str]] = None
|
|
32
|
+
cookies: Optional[dict[str, str]] = None
|
|
33
|
+
request: Request
|
|
34
|
+
_cached_selector: Optional[Selector] = PrivateAttr(default=None)
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
async def from_httpx_response(cls, httpx_response: HttpxResponse, request: Request) -> "Response":
|
|
40
|
+
"""Returns new instance from an httpx response.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
httpx_response (HttpxResponse): Response from httpx.
|
|
44
|
+
request (Request): Request used to fetch the response.
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
content = httpx_response.content
|
|
49
|
+
except ResponseNotRead:
|
|
50
|
+
content = await httpx_response.aread()
|
|
51
|
+
|
|
52
|
+
return cls(
|
|
53
|
+
url=httpx_response.url,
|
|
54
|
+
status_code=httpx_response.status_code,
|
|
55
|
+
content=content,
|
|
56
|
+
text=httpx_response.text,
|
|
57
|
+
headers=dict(httpx_response.headers),
|
|
58
|
+
cookies=dict(httpx_response.cookies),
|
|
59
|
+
request=request,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def get_json_data(self) -> Any:
|
|
63
|
+
"""Returns JSON data from the response."""
|
|
64
|
+
return json.loads(self.text)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def selector(self) -> Selector:
|
|
68
|
+
"""Selector for xpath and css queries."""
|
|
69
|
+
if self._cached_selector is None:
|
|
70
|
+
self._cached_selector = Selector(text=self.text)
|
|
71
|
+
return self._cached_selector
|
|
72
|
+
|
|
73
|
+
def __str__(self) -> str:
|
|
74
|
+
return f"<{self.__class__.__name__}({self.status_code}, {self.url})>"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import (
|
|
2
|
+
TYPE_CHECKING,
|
|
3
|
+
Any,
|
|
4
|
+
AsyncIterator,
|
|
5
|
+
Callable,
|
|
6
|
+
Coroutine,
|
|
7
|
+
Mapping,
|
|
8
|
+
Optional,
|
|
9
|
+
Sequence,
|
|
10
|
+
Union,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from fastcrawl.models import Response # pragma: no cover
|
|
15
|
+
|
|
16
|
+
PrimitiveData = Optional[Union[str, int, float, bool]]
|
|
17
|
+
|
|
18
|
+
RequestCallback = Callable[["Response"], Union[Coroutine[Any, Any, Optional[AsyncIterator[Any]]], AsyncIterator[Any]]]
|
|
19
|
+
QueryParams = Mapping[str, Union[PrimitiveData, Sequence[PrimitiveData]]]
|
|
20
|
+
Headers = Mapping[str, str]
|
|
21
|
+
Cookies = Mapping[str, str]
|
|
22
|
+
FormData = Mapping[str, Any]
|
|
23
|
+
JsonData = Any
|
|
24
|
+
Files = Mapping[str, bytes]
|
|
25
|
+
Auth = tuple[str, str]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "fastcrawl"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Fast and asynchronous web crawling and scraping library for Python."
|
|
5
|
+
authors = ["ilarionkuleshov <ilarion.kuleshov@gmail.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
|
|
9
|
+
[tool.poetry.dependencies]
|
|
10
|
+
python = "^3.9"
|
|
11
|
+
httpx = "^0.28.1"
|
|
12
|
+
pydantic = "^2.10.4"
|
|
13
|
+
parsel = "^1.9.1"
|
|
14
|
+
pydantic-settings = "^2.7.0"
|
|
15
|
+
|
|
16
|
+
[tool.poetry.group.dev.dependencies]
|
|
17
|
+
isort = "^5.13.2"
|
|
18
|
+
autoflake = "^2.3.1"
|
|
19
|
+
black = "^24.10.0"
|
|
20
|
+
flake8 = "^7.1.1"
|
|
21
|
+
flake8-pyproject = "^1.2.3"
|
|
22
|
+
pylint = "^3.3.3"
|
|
23
|
+
mypy = "^1.14.0"
|
|
24
|
+
pytest = "^8.3.4"
|
|
25
|
+
pytest-asyncio = "^0.25.0"
|
|
26
|
+
coverage = "^7.6.10"
|
|
27
|
+
freezegun = "^1.5.1"
|
|
28
|
+
pytest-httpx = "^0.35.0"
|
|
29
|
+
smokeshow = "^0.4.0"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["poetry-core"]
|
|
33
|
+
build-backend = "poetry.core.masonry.api"
|
|
34
|
+
|
|
35
|
+
[tool.isort]
|
|
36
|
+
profile = "black"
|
|
37
|
+
|
|
38
|
+
[tool.autoflake]
|
|
39
|
+
remove-all-unused-imports = true
|
|
40
|
+
recursive = true
|
|
41
|
+
remove-unused-variables = true
|
|
42
|
+
ignore-init-module-imports = true
|
|
43
|
+
in-place = true
|
|
44
|
+
|
|
45
|
+
[tool.black]
|
|
46
|
+
line-length = 120
|
|
47
|
+
|
|
48
|
+
[tool.flake8]
|
|
49
|
+
max-line-length = 120
|
|
50
|
+
per-file-ignores = "__init__.py:F401"
|
|
51
|
+
|
|
52
|
+
[tool.pylint]
|
|
53
|
+
max-line-length = 120
|
|
54
|
+
disable="C0114"
|
|
55
|
+
|
|
56
|
+
[tool.coverage.html]
|
|
57
|
+
directory = ".coverage_html"
|