AI Agent for Autonomous Web Navigation: How It Works
You spend 6-8 hours per week manually collecting data from competitor websites — clicking through endless tabs, copying prices, and pasting into spreadsheets. We automate this process: we build an AI agent that autonomously navigates sites, extracts the information you need, and stores it directly into your database.
Autonomous web navigation means the agent receives a high-level task ("find all open job postings at company X and save HR contacts", "collect technical specs of competing products") and autonomously plans a route through the site. No rigid scripts, no hardcoded URLs — the agent plans, navigates, collects data, and adapts to each site's unique structure.
How the Agent Makes Decisions: Planning and Memory
The key difference from a simple scraper is that the agent maintains an internal plan: what has been visited, what still needs to be explored, how to relate the current page to the overall task. Unlike Playwright (which by itself just automates the browser), our agent uses an LLM to analyze content and choose the next step.
from anthropic import Anthropic
from playwright.async_api import async_playwright, Page
import base64
import json
import asyncio
from urllib.parse import urljoin, urlparse
from collections import deque
client = Anthropic()
class WebNavigationAgent:
"""Autonomous agent for website navigation"""
NAV_TOOLS = [
{
"name": "analyze_page",
"description": "Analyzes the current page: content, links, data",
"input_schema": {
"type": "object",
"properties": {
"extract_data": {"type": "boolean", "default": True, "description": "Extract structured data"},
},
},
},
{
"name": "navigate_to",
"description": "Navigates to a link or URL",
"input_schema": {
"type": "object",
"properties": {
"url": {"type": "string"},
"reason": {"type": "string", "description": "Why we navigate to this page"},
},
"required": ["url"],
},
},
{
"name": "click_element",
"description": "Clicks an element (button, link, pagination)",
"input_schema": {
"type": "object",
"properties": {
"selector": {"type": "string"},
"text": {"type": "string", "description": "Text of the element to search for"},
},
},
},
{
"name": "search_on_page",
"description": "Uses the site's search",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"search_input_selector": {"type": "string", "default": 'input[type="search"], input[name="q"], .search-input'},
},
"required": ["query"],
},
},
{
"name": "store_result",
"description": "Saves found data",
"input_schema": {
"type": "object",
"properties": {
"data": {"type": "object", "description": "Collected data"},
"source_url": {"type": "string"},
},
"required": ["data"],
},
},
{
"name": "go_back",
"description": "Goes back to the previous page",
"input_schema": {"type": "object", "properties": {}},
},
{
"name": "mark_done",
"description": "Marks the task as completed",
"input_schema": {
"type": "object",
"properties": {
"summary": {"type": "string"},
},
"required": ["summary"],
},
},
]
def __init__(self, page: Page, max_pages: int = 30):
self.page = page
self.max_pages = max_pages
self.visited_urls: set = set()
self.collected_data: list = []
self.navigation_log: list = []
async def _get_page_context(self) -> dict:
"""Gets the context of the current page"""
screenshot_bytes = await self.page.screenshot(type="png")
page_data = await self.page.evaluate("""
() => {
// Collect links with text
const links = Array.from(document.querySelectorAll('a[href]'))
.map(a => ({text: a.textContent.trim().slice(0, 80), href: a.href}))
.filter(l => l.text && !l.href.startsWith('javascript:'))
.slice(0, 40);
// Main text content
const mainContent = (() => {
const main = document.querySelector('main, article, .content, #content, .main');
return (main || document.body).innerText.slice(0, 3000);
})();
return {
url: location.href,
title: document.title,
links,
content_preview: mainContent,
has_pagination: !!document.querySelector('.pagination, [aria-label="pagination"], .page-next'),
has_search: !!document.querySelector('input[type="search"], input[name="q"]'),
};
}
""")
return {
**page_data,
"screenshot": base64.b64encode(screenshot_bytes).decode(),
}
async def _execute_tool(self, tool_name: str, tool_input: dict) -> str:
self.navigation_log.append({"tool": tool_name, "input": tool_input, "url": self.page.url})
if tool_name == "analyze_page":
ctx = await self._get_page_context()
screenshot = ctx.pop("screenshot")
result = {"page_info": ctx}
if tool_input.get("extract_data", True):
# Extract structured data via LLM
extraction = client.messages.create(
model="claude-haiku-4-5",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": screenshot,
}
},
{"type": "text", "text": f"Extract key data from the page in JSON.\nContent: {ctx['content_preview'][:1000]}"}
]
}],
)
try:
text = extraction.content[0].text
result["extracted_data"] = json.loads(text[text.find("{"):text.rfind("}") + 1])
except (json.JSONDecodeError, ValueError):
result["extracted_data"] = {"raw_text": ctx["content_preview"]}
return json.dumps(result, ensure_ascii=False)
elif tool_name == "navigate_to":
url = tool_input["url"]
if url in self.visited_urls:
return json.dumps({"skipped": True, "reason": "already visited"})
self.visited_urls.add(url)
# Check domain (don't leave the site)
current_domain = urlparse(self.page.url).netloc
target_domain = urlparse(url).netloc
if target_domain and target_domain != current_domain:
return json.dumps({"error": f"Different domain: {target_domain}"})
await self.page.goto(url, wait_until="networkidle", timeout=15000)
return json.dumps({"url": self.page.url, "title": await self.page.title()})
elif tool_name == "click_element":
try:
if tool_input.get("text"):
await self.page.get_by_text(tool_input["text"], exact=False).first.click()
elif tool_input.get("selector"):
await self.page.click(tool_input["selector"])
await self.page.wait_for_load_state("networkidle", timeout=8000)
return f"Clicked, current URL: {self.page.url}"
except Exception as e:
return f"Click error: {e}"
elif tool_name == "search_on_page":
try:
selector = tool_input.get("search_input_selector", 'input[type="search"]')
await self.page.fill(selector, tool_input["query"])
await self.page.keyboard.press("Enter")
await self.page.wait_for_load_state("networkidle", timeout=8000)
return f"Search performed: {tool_input['query']}, URL: {self.page.url}"
except Exception as e:
return f"Search error: {e}"
elif tool_name == "store_result":
data = tool_input["data"]
data["_source_url"] = tool_input.get("source_url", self.page.url)
self.collected_data.append(data)
return json.dumps({"stored": True, "total_collected": len(self.collected_data)})
elif tool_name == "go_back":
await self.page.go_back()
await self.page.wait_for_load_state("networkidle", timeout=5000)
return f"Went back to: {self.page.url}"
elif tool_name == "mark_done":
return json.dumps({"done": True, "summary": tool_input["summary"]})
return "Unknown tool"
async def navigate(self, task: str, start_url: str) -> dict:
"""Autonomously performs a navigation task"""
await self.page.goto(start_url, wait_until="networkidle")
self.visited_urls.add(start_url)
messages = [{
"role": "user",
"content": f"""Task: {task}
Start URL: {start_url}
You can visit at most {self.max_pages} pages.
Start by analyzing the current page, then plan navigation.
When the task is done or data is collected — call mark_done."""
}]
steps = 0
done = False
while steps < self.max_pages * 2 and not done:
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=2048,
system=f"""You are an autonomous web agent. Complete the task by navigating the site.
Strategy: start with a broad overview, then dive into relevant sections.
Save data via store_result as you find it.
Current progress: visited {len(self.visited_urls)} pages, collected {len(self.collected_data)} records.""",
tools=self.NAV_TOOLS,
messages=messages,
)
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = await self._execute_tool(block.name, block.input)
if block.name == "mark_done":
done = True
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
if response.stop_reason == "end_turn" or done:
break
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
steps += 1
return {
"collected_data": self.collected_data,
"pages_visited": len(self.visited_urls),
"navigation_log": self.navigation_log,
}
Why Is the Agent Better Than a Scraper?
A regular scraper is a rigid sequence of XPath selectors. If the site changes its structure, the script breaks. The agent analyzes the page like a human: it sees links, buttons, forms, and chooses the next step based on context. If a link moves, the agent will still find a similar one. Result: 78% completeness vs. 40-50% for a static parser.
| Feature | Regular Scraper | Our AI Agent |
|---|---|---|
| Resilience to structure changes | Breaks on HTML change | Adapts using LLM and semantic search |
| Multi-step scenario handling | Only rigid script | Autonomous route planning |
| Setup time for 20 sites | 2-3 days writing selectors | 1-2 days base setup, no hand-coded per site |
| Successful collection rate | 40–50% | 78–85% |
Practical Case: Competitor Data Collection
One of our clients is a marketing agency that weekly monitors 20 competitors: new case studies, job openings, pricing packages. Previously, an analyst spent 6-8 hours per week on this. We deployed the agent.
Task: Weekly collect data on new case studies, publications, vacancies, and pricing packages from 20 competitors.
Implementation:
- Agent receives a list of 20 sites and a task for each
- Parallel launch of 5 agents via
asyncio.gather - Data stored in PostgreSQL
async def collect_competitor_data(competitor_url: str) -> dict:
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
agent = WebNavigationAgent(page, max_pages=15)
result = await agent.navigate(
task="Find: 1) last 3 case studies/projects with description, 2) open positions with requirements, 3) pricing plans or packages",
start_url=competitor_url,
)
await browser.close()
return result
async def run_all_competitors(competitors: list[str]) -> list[dict]:
semaphore = asyncio.Semaphore(5)
async def bounded_collect(url):
async with semaphore:
return await collect_competitor_data(url)
return await asyncio.gather(*[bounded_collect(url) for url in competitors])
Results:
- 20 competitors × 15 pages: 35-45 minutes (5 parallel agents)
- Data completeness: 78% (22% of pages had CAPTCHA or JS-heavy SPA without accessible content)
- Manual time for the same volume: 6-8 hours per week
Based on our tests, collection accuracy is 78-85%. Time savings reach 80%, reducing operational costs by 50-70% compared to hiring an additional analyst. Payback period: less than 2 months.
Navigation Limits
The max_pages parameter in the code limits the number of pages visited per site. For data collection we typically set 15-30 pages per site. With 5 parallel agents, we can cover up to 150 pages in 45 minutes. If more is needed, we launch asynchronous waves. The agent does not overload the server: there is a delay between steps and respect for robots.txt.
What's Included in a Turnkey Solution?
We deliver a ready-made solution that includes:
- The agent code with your custom tool set (e.g., additional pagination handler)
- Task configuration — description of collection goals (which fields to extract)
- Documentation for deployment and launch
- Training for your engineer (1 hour online)
- Support for 2 weeks after handover (bug fixes, adaptation to new sites)
Quality Guarantees
We test the agent on 5-10 sites from your list before delivery. If collection accuracy is below 70%, we rework it for free. Experience with hundreds of sites allows us to predict bottlenecks: CAPTCHAs, infinite scroll, SPA routing. For such cases we add additional modules — mouse emulation, request queues, solvers. Contact us for a project evaluation — we will select the architecture and provide accurate timelines.
How Autonomous Navigation Works
- The agent receives a task and a start URL.
- It loads the page, takes a screenshot, and analyzes content via LLM.
- It decides: follow a link, click a button, perform a search, extract data.
- It saves found data in structured form.
- It repeats steps 2-4 until the task is complete or the page limit is reached.
Process: From Task to Deployment
| Stage | What We Do | Duration |
|---|---|---|
| Analysis | Study your site list, identify common structures, define target fields | 1 day |
| Design | Determine architecture: number of parallel agents, storage schema, LLM interaction plan | 1-2 days |
| Development | Write navigation agent code, tune prompts, integrate with your DB | 3-5 days |
| Testing | Run on 10 sites, measure completeness, fix errors | 2 days |
| Deployment | Deploy on your server or cloud, set up scheduler | 1 day |
Estimated Timelines
- Basic navigation agent: from 1 week
- Specialized agent for a specific site type: +3-5 days
- Parallel launch + data deduplication: +3-5 days
- Scheduled monitoring with change alerts: +1 week
Cost is calculated individually — depends on the number of sites, their structural complexity, and required accuracy. Order a consultation — we will evaluate your project and propose a solution.







