Skip to content

Commit 336dd62

Browse files
committed
Improve code examples
1 parent fd91257 commit 336dd62

7 files changed

Lines changed: 402 additions & 112 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ The full SDK documentation lives at **[docs.apify.com/sdk/python](https://docs.a
199199
| [Overview](https://docs.apify.com/sdk/python/docs/overview) | What the SDK is, what Actors are, and how the pieces fit together. |
200200
| [Quick start](https://docs.apify.com/sdk/python/docs/quick-start) | Create, run, and deploy your first Python Actor. |
201201
| [Concepts](https://docs.apify.com/sdk/python/docs/concepts/actor-lifecycle) | Actor lifecycle, input, storages, events, proxy management, interacting with other Actors, webhooks, accessing the Apify API, logging, configuration, and pay-per-event. |
202-
| [Guides](https://docs.apify.com/sdk/python/docs/guides/beautifulsoup-httpx) | Integrations with BeautifulSoup, Parsel, Playwright, Selenium, Crawlee, Scrapy, Scrapling, Crawl4AI, and Browser Use, plus hosting AI agents, building MCP servers, running a web server, validating input with Pydantic, and using uv. |
202+
| [Guides](https://docs.apify.com/sdk/python/docs/guides/beautifulsoup-httpx) | Integrations with BeautifulSoup, Parsel, Playwright, Selenium, Crawlee, Scrapy, Scrapling, Crawl4AI, and Browser Use, plus using uv, validating input with Pydantic, running a web server, building MCP servers, and hosting AI agents. |
203203
| [Upgrading](https://docs.apify.com/sdk/python/docs/upgrading/upgrading-to-v4) | Migrating between major versions. |
204204
| [API reference](https://docs.apify.com/sdk/python/reference) | Generated reference for every class and method. |
205205
| [Changelog](https://docs.apify.com/sdk/python/docs/changelog) | Release history and breaking changes. |

docs/03_guides/14_ai_agents.mdx

Lines changed: 63 additions & 33 deletions
Large diffs are not rendered by default.

docs/03_guides/code/14_crewai.py

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,56 +2,98 @@
22
import os
33

44
from crewai import LLM, Agent, Crew, Task
5-
from crewai.tools import tool
5+
from crewai_tools import ApifyActorsTool
6+
from pydantic import BaseModel
67

78
from apify import Actor
89

910
OPENROUTER_BASE_URL = 'https://openrouter.apify.actor/api/v1'
1011

1112
# On a fresh container, CrewAI shows a one-time trace-consent prompt that blocks on
12-
# stdin. `CREWAI_TESTING=true` is the only flag that suppresses it.
13+
# stdin. `CREWAI_TESTING=true` suppresses it.
1314
os.environ.setdefault('CREWAI_TESTING', 'true')
1415

16+
# The Crawlee docs page the crew reads when the input has no `url`.
17+
DEFAULT_URL = 'https://crawlee.dev/python/docs/guides/architecture-overview'
1518

16-
@tool('Average')
17-
def average(numbers: list[float]) -> float:
18-
"""Return the arithmetic mean of a list of numbers."""
19-
return sum(numbers) / len(numbers)
19+
20+
class ActorInput(BaseModel):
21+
"""The Actor input, validated with default values."""
22+
23+
url: str = DEFAULT_URL
24+
model: str = 'openai/gpt-5.4-mini'
25+
26+
27+
class Crawler(BaseModel):
28+
"""One crawler class that Crawlee provides."""
29+
30+
name: str
31+
built_on: str
32+
best_for: str
33+
34+
35+
class CrawlerGuide(BaseModel):
36+
"""The structured guide the crew distills from the docs page."""
37+
38+
crawlers: list[Crawler]
2039

2140

2241
async def main() -> None:
2342
async with Actor:
24-
actor_input = await Actor.get_input() or {}
25-
query = actor_input.get('query', 'What is the average of 12, 18, and 30?')
26-
model = actor_input.get('model', 'openai/gpt-4o-mini')
43+
# Parse the Actor input into the typed model, filling in defaults.
44+
actor_input = ActorInput.model_validate(await Actor.get_input() or {})
45+
url = actor_input.url
46+
model = actor_input.model
2747

2848
# Route the LLM through the Apify OpenRouter proxy (no provider key needed).
29-
# The `openai/` prefix selects CrewAI's OpenAI-compatible client. The rest
30-
# is the OpenRouter model slug sent to the proxy.
49+
# The `openai/` prefix selects CrewAI's OpenAI-compatible client.
3150
llm = LLM(
3251
model=f'openai/{model}',
3352
base_url=OPENROUTER_BASE_URL,
3453
api_key=os.environ['APIFY_TOKEN'],
3554
)
3655

37-
# A one-agent crew: an analyst that answers using the `average` tool.
38-
analyst = Agent(
39-
role='Data Analyst',
40-
goal='Answer numeric questions accurately.',
41-
backstory='An analyst who turns raw numbers into clear answers.',
42-
tools=[average],
56+
# `ApifyActorsTool` exposes any Apify Actor as a CrewAI tool. Here it wraps the
57+
# RAG Web Browser to fetch the page as clean Markdown.
58+
researcher = Agent(
59+
role='Documentation researcher',
60+
goal='Read the Crawlee docs and note every crawler it describes.',
61+
backstory='A researcher who reads technical docs closely.',
62+
tools=[ApifyActorsTool('apify/rag-web-browser')],
63+
llm=llm,
64+
)
65+
writer = Agent(
66+
role='Technical writer',
67+
goal='Turn research notes into a clear, structured crawler guide.',
68+
backstory='A writer who distills docs into comparison tables.',
4369
llm=llm,
4470
)
45-
task = Task(
46-
description=query,
47-
expected_output='A short, readable answer to the query.',
48-
agent=analyst,
71+
72+
research = Task(
73+
description=f'Scrape {url} and list the crawlers the page covers.',
74+
expected_output='Notes on each crawler: name, what it builds on, its use.',
75+
agent=researcher,
76+
)
77+
# `context=[research]` feeds the researcher's notes to the writer, and
78+
# `output_pydantic` makes the final task return a validated `CrawlerGuide`.
79+
write = Task(
80+
description=(
81+
'From the notes, compile each crawler with what it is built on '
82+
'and what it is best for.'
83+
),
84+
expected_output='A list of crawlers with name, built_on, and best_for.',
85+
agent=writer,
86+
context=[research],
87+
output_pydantic=CrawlerGuide,
4988
)
5089

51-
# `kickoff_async` keeps the Actor's event loop responsive.
52-
result = await Crew(agents=[analyst], tasks=[task]).kickoff_async()
53-
Actor.log.info(f'Crew result:\n{result.raw}')
54-
await Actor.push_data({'query': query, 'answer': result.raw})
90+
# `kickoff_async` runs the crew without blocking the Actor's event loop.
91+
crew = Crew(agents=[researcher, writer], tasks=[research, write])
92+
guide = (await crew.kickoff_async()).pydantic
93+
if guide is None:
94+
raise RuntimeError('The crew did not return a structured CrawlerGuide.')
95+
Actor.log.info(f'Crawler guide:\n{guide.model_dump_json(indent=2)}')
96+
await Actor.push_data([crawler.model_dump() for crawler in guide.crawlers])
5597

5698

5799
if __name__ == '__main__':

docs/03_guides/code/14_langgraph.py

Lines changed: 79 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,104 @@
11
import asyncio
22
import os
3+
from functools import partial
4+
from typing import TypedDict
35

4-
from langchain.agents import create_agent
5-
from langchain_core.tools import tool
6+
from langchain_core.runnables import Runnable
67
from langchain_openai import ChatOpenAI
8+
from langgraph.graph import END, START, StateGraph
9+
from pydantic import BaseModel
710

811
from apify import Actor
912

1013
OPENROUTER_BASE_URL = 'https://openrouter.apify.actor/api/v1'
14+
MIN_KEY_POINTS = 3
15+
MAX_REVISIONS = 2
1116

1217

13-
@tool
14-
def sum_numbers(numbers: list[int]) -> int:
15-
"""Return the sum of a list of numbers."""
16-
return sum(numbers)
18+
class ActorInput(BaseModel):
19+
"""The Actor input, validated with default values."""
20+
21+
url: str = 'https://crawlee.dev'
22+
model: str = 'openai/gpt-5.4-mini'
23+
24+
25+
class PageSummary(BaseModel):
26+
"""The structured summary the agent extracts from a web page."""
27+
28+
title: str
29+
summary: str
30+
key_points: list[str]
31+
target_audience: str
32+
33+
34+
class State(TypedDict):
35+
"""The state that flows between the graph's nodes."""
36+
37+
url: str
38+
page_text: str
39+
summary: PageSummary
40+
revisions: int
41+
42+
43+
async def fetch(state: State) -> dict:
44+
"""Node: scrape the page to clean Markdown with the RAG Web Browser Actor."""
45+
run_input = {'query': state['url'], 'outputFormats': ['markdown']}
46+
run = await Actor.call('apify/rag-web-browser', run_input=run_input)
47+
dataset = Actor.apify_client.dataset(run.default_dataset_id)
48+
items = (await dataset.list_items()).items
49+
if not items or not items[0].get('markdown'):
50+
raise RuntimeError(f'RAG Web Browser returned no content for {state["url"]}.')
51+
return {'page_text': items[0]['markdown']}
52+
53+
54+
async def summarize(state: State, structured_llm: Runnable) -> dict:
55+
"""Node: summarize the page, asking for more depth on a re-run."""
56+
hint = ''
57+
if state['revisions']:
58+
hint = f' List at least {MIN_KEY_POINTS} distinct key points.'
59+
prompt = f'Summarize this page.{hint}\n\n{state["page_text"]}'
60+
summary = await structured_llm.ainvoke(prompt)
61+
return {'summary': summary, 'revisions': state['revisions'] + 1}
62+
63+
64+
def route(state: State) -> str:
65+
"""Edge: loop back for another pass while the summary is thin."""
66+
thin = len(state['summary'].key_points) < MIN_KEY_POINTS
67+
if thin and state['revisions'] < MAX_REVISIONS:
68+
return 'summarize'
69+
return END
1770

1871

1972
async def main() -> None:
2073
async with Actor:
21-
actor_input = await Actor.get_input() or {}
22-
query = actor_input.get('query', 'What is the sum of 128, 64, and 32?')
23-
model = actor_input.get('model', 'openai/gpt-4o-mini')
74+
# Parse the Actor input into the typed model, filling in defaults.
75+
actor_input = ActorInput.model_validate(await Actor.get_input() or {})
76+
url = actor_input.url
77+
model = actor_input.model
2478

2579
# Route the LLM through the Apify OpenRouter proxy (no provider key needed).
2680
llm = ChatOpenAI(
2781
model=model,
2882
base_url=OPENROUTER_BASE_URL,
2983
api_key=os.environ['APIFY_TOKEN'],
3084
)
31-
# The agent decides on its own when to call the `sum_numbers` tool.
32-
agent = create_agent(llm, tools=[sum_numbers])
85+
# `with_structured_output` makes the node return a validated `PageSummary`.
86+
structured_llm = llm.with_structured_output(PageSummary)
87+
88+
# Wire the nodes into a graph. Its conditional edge loops back into `summarize`
89+
# until the summary is detailed enough. `partial` binds `structured_llm` to it.
90+
graph = StateGraph(State)
91+
graph.add_node('fetch', fetch)
92+
graph.add_node('summarize', partial(summarize, structured_llm=structured_llm))
93+
graph.add_edge(START, 'fetch')
94+
graph.add_edge('fetch', 'summarize')
95+
graph.add_conditional_edges('summarize', route)
96+
agent = graph.compile()
3397

34-
result = await agent.ainvoke({'messages': [('user', query)]})
35-
answer = result['messages'][-1].content
36-
Actor.log.info(f'Agent answer:\n{answer}')
37-
await Actor.push_data({'query': query, 'answer': answer})
98+
result = await agent.ainvoke({'url': url, 'revisions': 0})
99+
summary = result['summary']
100+
Actor.log.info(f'Page summary:\n{summary.model_dump_json(indent=2)}')
101+
await Actor.push_data({'url': url, **summary.model_dump()})
38102

39103

40104
if __name__ == '__main__':

docs/03_guides/code/14_llamaindex.py

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,88 @@
11
import asyncio
22
import os
33

4-
from llama_index.core.agent import ReActAgent
5-
from llama_index.core.tools import FunctionTool
4+
from llama_index.core import Document, VectorStoreIndex
5+
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
66
from llama_index.llms.openai_like import OpenAILike
7+
from llama_index.readers.apify import ApifyActor
8+
from pydantic import BaseModel
79

810
from apify import Actor
911

1012
OPENROUTER_BASE_URL = 'https://openrouter.apify.actor/api/v1'
13+
EMBED_MODEL = 'BAAI/bge-small-en-v1.5'
1114

1215

13-
def word_count(text: str) -> int:
14-
"""Return the number of words in the given text."""
15-
return len(text.split())
16+
class ActorInput(BaseModel):
17+
"""The Actor input, validated with default values."""
18+
19+
urls: list[str] = [
20+
'https://docs.apify.com/platform/actors',
21+
'https://docs.apify.com/platform/storage/dataset',
22+
'https://docs.apify.com/platform/proxy',
23+
]
24+
question: str = 'How does Apify proxy work?'
25+
model: str = 'openai/gpt-5.4-mini'
26+
27+
28+
class Answer(BaseModel):
29+
"""The grounded answer the query engine returns."""
30+
31+
answer: str
32+
key_facts: list[str]
33+
34+
35+
def to_document(item: dict) -> Document:
36+
"""Map a Website Content Crawler item to a `Document` tagged with its URL."""
37+
return Document(text=item['text'], metadata={'url': item['url']})
1638

1739

1840
async def main() -> None:
1941
async with Actor:
20-
actor_input = await Actor.get_input() or {}
21-
query = actor_input.get('query', 'How many words are in "Apify runs Actors"?')
22-
model = actor_input.get('model', 'openai/gpt-4o-mini')
42+
# Parse the Actor input into the typed model, filling in defaults.
43+
actor_input = ActorInput.model_validate(await Actor.get_input() or {})
44+
urls = actor_input.urls
45+
question = actor_input.question
46+
model = actor_input.model
2347

2448
# Route the LLM through the Apify OpenRouter proxy (no provider key needed).
25-
# `OpenAILike` is the LlamaIndex class for OpenAI-compatible endpoints.
2649
llm = OpenAILike(
2750
model=model,
2851
api_base=OPENROUTER_BASE_URL,
2952
api_key=os.environ['APIFY_TOKEN'],
3053
is_chat_model=True,
3154
)
32-
agent = ReActAgent(tools=[FunctionTool.from_defaults(fn=word_count)], llm=llm)
55+
# Embeddings run locally, so the proxy needs no embeddings endpoint.
56+
embed_model = HuggingFaceEmbedding(model_name=EMBED_MODEL)
57+
58+
# Scrape the pages with the Website Content Crawler Actor and wrap each one
59+
# in a `Document`. LlamaIndex then chunks and embeds them, so the query engine
60+
# retrieves only the relevant passages.
61+
reader = ApifyActor(apify_api_token=os.environ['APIFY_TOKEN'])
62+
run_input = {'startUrls': [{'url': url} for url in urls], 'maxCrawlDepth': 0}
63+
documents = await asyncio.to_thread(
64+
reader.load_data,
65+
actor_id='apify/website-content-crawler',
66+
run_input=run_input,
67+
dataset_mapping_function=to_document,
68+
)
69+
index = VectorStoreIndex.from_documents(documents, embed_model=embed_model)
70+
71+
# `output_cls` returns a validated `Answer`. The response still carries the
72+
# retrieved `source_nodes`, so the answer can cite the pages it came from.
73+
query_engine = index.as_query_engine(
74+
llm=llm,
75+
output_cls=Answer,
76+
response_mode='compact',
77+
similarity_top_k=4,
78+
)
79+
response = await query_engine.aquery(question)
3380

34-
response = await agent.run(user_msg=query)
35-
Actor.log.info(f'Agent answer:\n{response}')
36-
await Actor.push_data({'query': query, 'answer': str(response)})
81+
answer = response.response
82+
sources = [node.node.metadata['url'] for node in response.source_nodes]
83+
record = {'question': question, **answer.model_dump(), 'sources': sources}
84+
Actor.log.info(f'Answer:\n{answer.model_dump_json(indent=2)}')
85+
await Actor.push_data(record)
3786

3887

3988
if __name__ == '__main__':

0 commit comments

Comments
 (0)