Skip to content

Commit ec2dd15

Browse files
authored
docs: Add guide for running crawler in web server (#1174)
### Description Add guide for running crawler in web server ### Issues - Closes: #1148
1 parent 44beae4 commit ec2dd15

5 files changed

Lines changed: 170 additions & 2 deletions

File tree

‎docs/guides/code_examples/running_in_web_server/__init__.py‎

Whitespace-only changes.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import asyncio
2+
from collections.abc import AsyncIterator
3+
from contextlib import asynccontextmanager
4+
from typing import TypedDict
5+
6+
from fastapi import FastAPI
7+
8+
from crawlee.crawlers import ParselCrawler, ParselCrawlingContext
9+
10+
11+
class State(TypedDict):
12+
"""State available in the app."""
13+
14+
crawler: ParselCrawler
15+
requests_to_results: dict[str, asyncio.Future[dict[str, str]]]
16+
17+
18+
@asynccontextmanager
19+
async def lifespan(app: FastAPI) -> AsyncIterator[State]:
20+
# Start up code that runs once when the app starts
21+
22+
# Results will be stored in this dictionary
23+
requests_to_results = dict[str, asyncio.Future[dict[str, str]]]()
24+
25+
crawler = ParselCrawler(
26+
# Keep the crawler alive even when there are no more requests to process now.
27+
# This makes the crawler wait for more requests to be added later.
28+
keep_alive=True
29+
)
30+
31+
# Define the default request handler, which will be called for every request.
32+
@crawler.router.default_handler
33+
async def request_handler(context: ParselCrawlingContext) -> None:
34+
context.log.info(f'Processing {context.request.url} ...')
35+
title = context.selector.xpath('//title/text()').get() or ''
36+
37+
# Extract data from the page and save it to the result dictionary.
38+
requests_to_results[context.request.unique_key].set_result(
39+
{
40+
'title': title,
41+
}
42+
)
43+
44+
# Start the crawler without awaiting it to finish
45+
crawler.log.info(f'Starting crawler for the {app.title}')
46+
run_task = asyncio.create_task(crawler.run([]))
47+
48+
# Make the crawler and the result dictionary available in the app state
49+
yield {'crawler': crawler, 'requests_to_results': requests_to_results}
50+
51+
# Cleanup code that runs once when the app shuts down
52+
crawler.stop()
53+
# Wait for the crawler to finish
54+
await run_task
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from uuid import uuid4
5+
6+
from fastapi import FastAPI
7+
from starlette.requests import Request
8+
from starlette.responses import HTMLResponse
9+
10+
import crawlee
11+
12+
from .crawler import lifespan
13+
14+
app = FastAPI(lifespan=lifespan, title='Crawler app')
15+
16+
17+
@app.get('/', response_class=HTMLResponse)
18+
def index() -> str:
19+
return """
20+
<!DOCTYPE html>
21+
<html>
22+
<body>
23+
<h1>Scraper server</h1>
24+
<p>To scrape some page, visit "scrape" endpoint with url parameter.
25+
For example:
26+
<a href="/scrape?url=https://www.example.com">
27+
/scrape?url=https://www.example.com
28+
</a>
29+
</p>
30+
</body>
31+
</html>
32+
"""
33+
34+
35+
@app.get('/scrape')
36+
async def scrape_url(request: Request, url: str | None = None) -> dict:
37+
if not url:
38+
return {'url': 'missing', 'scrape result': 'no results'}
39+
40+
# Generate random unique key for the request
41+
unique_key = str(uuid4())
42+
43+
# Set the result future in the result dictionary so that it can be awaited
44+
request.state.requests_to_results[unique_key] = asyncio.Future[dict[str, str]]()
45+
46+
# Add the request to the crawler queue
47+
await request.state.crawler.add_requests(
48+
[crawlee.Request.from_url(url, unique_key=unique_key)]
49+
)
50+
51+
# Wait for the result future to be finished
52+
result = await request.state.requests_to_results[unique_key]
53+
54+
# Clean the result from the result dictionary to free up memory
55+
request.state.requests_to_results.pop(unique_key)
56+
57+
# Return the result
58+
return {'url': url, 'scrape result': result}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
id: running-in-web-server
3+
title: Running in web server
4+
description: Running in web server
5+
---
6+
7+
import ApiLink from '@site/src/components/ApiLink';
8+
import CodeBlock from '@theme/CodeBlock';
9+
10+
import Crawler from '!!raw-loader!./code_examples/running_in_web_server/crawler.py';
11+
import Server from '!!raw-loader!./code_examples/running_in_web_server/server.py';
12+
13+
14+
Most of the time, Crawlee jobs are run as batch jobs. You have a list of URLs you want to scrape every week or you might want to scrape a whole website once per day. After the scrape, you send the data to your warehouse for analytics. Batch jobs are efficient because they can use Crawlee's built-in autoscaling to fully utilize the resources you have available. But sometimes you have a use-case where you need to return scrape data as soon as possible. There might be a user waiting on the other end so every millisecond counts. This is where running Crawlee in a web server comes in.
15+
16+
We will build a simple HTTP server that receives a page URL and returns the page title in the response.
17+
18+
## Set up a web server
19+
20+
There are many popular web server frameworks for Python, such as [Flask](https://flask.palletsprojects.com/en/stable/), [Django](https://www.djangoproject.com/), [Pyramid](https://trypyramid.com/), ... In this guide, we will use the [FastAPI](https://fastapi.tiangolo.com/) to keep things simple.
21+
22+
This will be our core server setup:
23+
24+
<CodeBlock className="language-python" title="server.py">
25+
{Server}
26+
</CodeBlock>
27+
28+
The server has two endpoints.
29+
- `/` - The index is just giving short description of the server with example link to the second endpoint.
30+
- `/scrape` - This is the endpoint that receives a `url` parameter and returns the page title scraped from the URL
31+
32+
To run the example server, make sure that you have installed the [fastapi[standard]](https://fastapi.tiangolo.com/#installation) and from the directory where the example code is located you can use the following command:
33+
```
34+
fastapi dev server.py
35+
```
36+
37+
## Create a crawler
38+
39+
We will create a standard <ApiLink to="class/ParselCrawler">`ParselCrawler`</ApiLink> and use the `keep_alive=true` option to keep the crawler running even if there are no requests currently in the <ApiLink to="class/RequestQueue">`RequestQueue`</ApiLink>. This way it will always be waiting for new requests to come in.
40+
41+
<CodeBlock className="language-python" title="crawler.py">
42+
{Crawler}
43+
</CodeBlock>
44+
45+
Crawler is defined inside of [Lifespan](https://fastapi.tiangolo.com/advanced/events/#lifespan) which is a FastAPI way to run some start up/ teardown code for the app. There are two objects that we want to save to the app state so that they can be accessed in any endpoint through `request.state`:
46+
- `crawler` holds instance of our crawler and allows the app to interact with it.
47+
- `requests_to_results` is dictionary that is used to temporarily register expected results for each request and populate them when they are made available by the crawler.

‎pyproject.toml‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,9 @@ indent-style = "space"
183183
"**/docs/examples/code_examples/*crawler_with_error_snapshotter.py" = [
184184
"PLR2004", # Magic value used in comparison. Ignored for simplicity and readability of example code.
185185
]
186-
187-
186+
"**/docs/guides/code_examples/running_in_web_server/server.py" = [
187+
"TC002", # ruff false positive. Import actually needed during runtime.
188+
]
188189

189190
[tool.ruff.lint.flake8-quotes]
190191
docstring-quotes = "double"
@@ -233,6 +234,8 @@ module = [
233234
"apify", # Example code shows integration of apify and crawlee.
234235
"apify_fingerprint_datapoints", # Untyped and stubs not available
235236
"camoufox", # Example code shows integration of camoufox and crawlee.
237+
"fastapi", # Example code shows running in webserver.
238+
"starlette.*", # Example code shows running in webserver.
236239
"flask", # Example code shows deploy on Google Cloud.
237240
"functions_framework", # Example code shows deploy on Google Cloud.
238241
"jaro", # Untyped and stubs not available
@@ -246,6 +249,12 @@ module = [
246249
]
247250
ignore_missing_imports = true
248251

252+
[[tool.mypy.overrides]]
253+
module = [
254+
"running_in_web_server.*" # False positive when fastapi not available
255+
]
256+
disable_error_code = ["misc"]
257+
249258
[tool.basedpyright]
250259
pythonVersion = "3.9"
251260
typeCheckingMode = "standard"

0 commit comments

Comments
 (0)