web-scout-ai
Web research that actually reads the sources.
web-scout-ai is an agentic Python research pipeline that searches the web, opens and extracts the sources it finds, follows up when evidence is incomplete, and returns a cited answer together with a full audit trail of what it could — and could not — read.
Give it a question or a URL:
from web_scout import run_web_research
result = await run_web_research(
"Indonesia biodiversity hotspots current status and recent trend"
)
print(result.synthesis)
print(f"Read {len(result.scraped)} sources across {len(result.queries)} searches")
Instead of treating search snippets as evidence, web-scout-ai tries to read the underlying source itself before allowing it to support the final answer.
Example output (excerpt)
Query: Indonesia biodiversity hotspots current status and recent trend
Indonesia remains one of the world's most important centres of biodiversity, but the available evidence indicates a negative overall status and trend. The UNEP country fiche describes Indonesia as one of 17 megadiverse countries, containing two globally recognised biodiversity hotspots; it reports approximately 31,750 plant species, 732 mammals, 1,711 birds, 750 reptiles, 403 amphibians and 1,236 freshwater fish. Biodiversity / Indonesia | Interactive Country Fiches
A 2024 review reports that, according to Ministry of Environment and Forestry data for 2020, Indonesia retained 86.9 million hectares of forest, including 45.3 million hectares of primary forest. In Sundaland, 70.6% of the region had an intense human-footprint score (2009 dataset); among 308 lowland-forest bird species assessed, 273, or 88.6%, experienced a reduction in suitable range between 2000 and 2015. Forest fragmentation in Indonesia, Severe human pressures in the Sundaland biodiversity hotspot
The most recent forest-loss signal is mixed but concerning. WRI/University of Maryland analysis reports that Indonesia's tropical primary-forest loss increased 14% from 2024 to 2025, although it remained below the high levels of the mid-2010s. A global land-use analysis covering 1995–2022 found that Indonesia accounted for 22% of global biodiversity losses attributed to land-use change, the largest national share in the study. Tropical rainforest loss slowed in 2025, Biodiversity impacts of recent land-use change
Comparisons should be made cautiously because the sources use different indicators and years: human-footprint data end in 2009, bird-range data in 2015, several forest assessments in 2020–2022, and primary-forest-loss data in 2025.
This is a fraction of what that call returned. The full synthesis was 10,055 characters; result.scraped also held extracted evidence from all 9 sources (~34,000 characters)—including a ScienceDirect review, a Conservation Biology paper, a Nature paper, a CEPF ecosystem profile, an interactive UNEP country fiche, and WRI's 2025 forest-loss analysis—plus an audit trail of the 3 searches, 1 irrelevant fetch, and 13 snippet-only hits that were not used as citations. The run took 188.7 seconds. Results and timing vary with the query, sources, model, and network.
What it actually does
web-scout-ai is an async Python research pipeline, not a search-result wrapper. It searches, opens the selected sources, routes each response to the right extractor, evaluates whether the collected evidence is sufficient, and synthesizes only from sources it successfully read.
| Input encountered | What the pipeline does |
|---|---|
| Static HTML | Uses a fast HTTP fetch and converts the page to clean Markdown |
| JS-heavy pages and SPA shells | Escalates to a stealth Chromium browser and can interact with page controls |
| Cloudflare or similar bot challenges | Retries with Scrapling's stealth browser and Cloudflare challenge handling |
| PDF, DOCX, PPTX, XLSX | Downloads and converts the document with Docling |
| Scanned PDFs, charts, maps, images | Uses the configured vision model when text extraction is insufficient |
| JSON endpoints | Converts structured payloads into readable evidence |
| List, index, and database pages | Ranks detail links, follows them, and can take one pagination hop |
| Thin or incomplete evidence | Scrapes promising backlog URLs or generates targeted follow-up searches |
The browser fallback can pass many JS-gated and Cloudflare-protected pages, but it is not a guarantee: sites can still block automation. Those URLs are reported in result.bot_detected instead of disappearing or being presented as evidence. Use the package only where you have permission and in accordance with the source site's terms.
Quick start
1. Install
pip install web-scout-ai
web-scout-setup
web-scout-setup installs the Patchright-managed Chromium browser and its system dependencies. It may request sudo for OS-level browser libraries.
2. Configure keys
The default research models run on Amazon Bedrock Mantle. Vision fallback still uses Gemini, and open-web discovery uses Serper:
export AWS_BEARER_TOKEN_BEDROCK="your-bedrock-api-key"
export GEMINI_API_KEY="your-gemini-api-key"
export SERPER_API_KEY="your-serper-api-key"
BEDROCK_MANTLE_API_KEY is accepted as an alias for the bearer token. OpenAI models on Mantle, including GPT-5.6 and GPT-6 Luna, use LiteLLM's route in us-east-1.
To use the Exa search backend instead (search_backend="exa"):
export EXA_API_KEY="your-exa-api-key"
Direct-URL mode does not use a search backend, so it only needs the API key for your configured model provider.
3. Run research
import asyncio
from web_scout import run_web_research
async def main():
result = await run_web_research(
query="What are the main threats to coral reefs worldwide?",
cache=True,
)
print(result.synthesis)
print("\nSources read:")
for source in result.scraped:
print(f"- {source.title or source.url}: {source.url}")
if result.bot_detected or result.scrape_failed:
print(
f"\nCould not read "
f"{len(result.bot_detected) + len(result.scrape_failed)} source(s)"
)
asyncio.run(main())
Three ways to use it
Open-web research
Generate several searches, read the strongest results in parallel, evaluate coverage, and search again if important evidence is missing.
result = await run_web_research(
query="What is driving the global adaptation finance gap?",
)
Domain-restricted research
Keep discovery and hub deepening focused on authoritative domains.
result = await run_web_research(
query="Latest evidence on sea-level rise",
include_domains=["ipcc.ch", "nasa.gov"],
)
Direct URL extraction
Skip search and start from a page, document, API endpoint, image, or database listing.
result = await run_web_research(
query="Extract the recommended adaptation measures and supporting evidence",
direct_url="https://example.org/report.pdf",
)
For a document, the pipeline reads that document without wandering into site navigation. For a list or database page, it can rank and follow relevant records and their linked primary documents.
The return value is an audit trail
run_web_research() returns a typed WebResearchResult:
class WebResearchResult(BaseModel):
synthesis: str
scraped: list[UrlEntry]
scrape_failed: list[UrlEntry]
blocked_by_policy: list[UrlEntry]
source_http_error: list[UrlEntry]
scraped_irrelevant: list[UrlEntry]
bot_detected: list[UrlEntry]
snippet_only: list[UrlEntry]
queries: list[SearchQuery]
| Field | Meaning |
|---|---|
synthesis |
Final answer with inline Markdown citations |
scraped |
Sources successfully read and extracted; content contains the query-relevant evidence |
scrape_failed |
Extraction attempts that failed for an unclassified reason |
blocked_by_policy |
Sources skipped because their domain is in exclude_domains |
source_http_error |
Source-side HTTP or network failures |
scraped_irrelevant |
Pages fetched successfully but not useful for the query |
bot_detected |
Sources that still returned a bot-protection wall |
snippet_only |
Search results discovered but not opened; snippets are never valid citation targets |
queries |
Every search query executed, its result count, and domain restrictions |
UrlEntry contains url, title, and content. SearchQuery contains query, num_results_returned, and domains_restricted.
The synthesizer is instructed to use scraped evidence only. A deterministic final check rejects citations to invented or snippet-only URLs and retries the synthesis with feedback.
Research depth
# Faster default
await run_web_research(query="...", research_depth="standard")
# More searches and sources, with a stricter coverage threshold
await run_web_research(query="...", research_depth="deep")
| Budget | Standard | Deep |
|---|---|---|
| Maximum search iterations | 2 | 3 |
| Initial search queries | 3 | 5 |
| Follow-up search queries | 2 | 4 |
| URLs selected in the first round | 6 | 12 |
| URLs selected in a follow-up round | 4 | 8 |
| Hub/detail-page cap | 10 | 15 |
The coverage evaluator can stop early when the evidence already answers the question. You can add your own acceptance conditions with coverage_criteria:
result = await run_web_research(
query="Compare national methane policies",
coverage_criteria="Include at least one primary government source per country.",
)
Custom extractor guidance
Use extractor_guidance to refine what each per-source extractor keeps and how it
organizes evidence. The guidance applies only to extraction; it does not alter search
query generation, coverage evaluation, or final synthesis.
result = await run_web_research(
query="Tunisia agricultural trends",
extractor_guidance="""
Country of interest: Tunisia
- Keep findings explicitly about Tunisia.
- Also keep regional findings whose stated scope includes Tunisia, even when the
source does not name Tunisia; label them with the source's scope, such as
"North Africa:".
- Discard purely global findings and findings about geographies that do not
include Tunisia.
""",
)
Guidance augments rather than replaces WebScout's extractor instructions. The base contract always takes precedence for source grounding, tool constraints, output fields, page-type handling, and the exact no-evidence sentinel.
Configuration
The defaults are Bedrock Mantle GPT-6 Luna for research and extraction, TypeSafe Jev for follow-up link selection, and gemini/gemini-3.7-flash for vision. Model IDs follow LiteLLM provider naming, so each stage can use a different provider or model. Pass followup_backend="luna" to use the Agents SDK model in models["followup_selector"] instead of Jev. Jev reads TYPESAFE_API_KEY.
models = {
# Used as the fallback for query generation, coverage, and synthesis
"web_researcher": "openai/gpt-4o-mini",
# Reads and extracts individual sources
"content_extractor": "gemini/gemini-2.0-flash",
# Optional stage-specific choices
"query_generator": "openai/gpt-4o-mini",
"coverage_evaluator": "openai/gpt-4o-mini",
"synthesiser": "openai/gpt-4o-mini",
"followup_selector": "openai/gpt-4o-mini", # used when followup_backend="luna"
"vision_fallback": "gemini/gemini-2.0-flash",
}
result = await run_web_research(query="...", models=models)
# Or keep Luna for link selection:
# result = await run_web_research(query="...", followup_backend="luna", models=models)
Provider credentials are read from their standard environment variables, such as OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, TYPESAFE_API_KEY, or AWS credentials for Bedrock.
Public API
Fetch one URL and get a FetchResult. This uses the same Scrapling fetcher as the research pipeline (fast HTTP, then a stealth browser when the page looks blocked or JavaScript-rendered):
from web_scout import PDF_MAGIC_BYTES, fetch
result = await fetch("https://example.org/report.pdf")
page = await fetch("https://example.org/article", wait_for="article")
result.body is the raw response bytes for binary payloads such as PDFs and images. HTML and other text responses leave body as None and put the decoded page in result.html_content. Failures do not raise: result.error is set when the URL is rejected or the fetch fails, and result.status is outside the 2xx range for HTTP errors. Pass exclude_domains to skip hosts before any network call.
PDF_MAGIC_BYTES is b"%PDF", the four-byte header every PDF file starts with. Check the body against it when you need to know whether the fetch returned a PDF. That is more reliable than result.content_type, which download URLs often report as application/octet-stream:
is_pdf = bool(result.body and result.body.startswith(PDF_MAGIC_BYTES))
if is_pdf:
pdf_bytes = result.body
fetch_pdf is a thin wrapper around fetch that does this check and returns the PDF bytes. It raises RuntimeError when the fetch fails or the body does not start with PDF_MAGIC_BYTES:
from web_scout import fetch_pdf
pdf_bytes = await fetch_pdf("https://example.org/report.pdf")
result = await run_web_research(
query="latest IPCC findings on sea-level rise",
models=None, # optional; GPT-6 Luna defaults, Gemini vision
search_backend="serper", # "serper" (Google via serper.dev) or "exa" (exa.ai)
research_depth="standard", # "standard", "deep", or a custom dict
include_domains=["ipcc.ch"], # optional discovery restriction
direct_url=None, # optional; skips search when set
domain_expertise="climate science", # optional prompt context
exclude_domains=None, # optional blocklist; nothing is blocked
# by default. See "Domain policy" below.
max_pdf_pages=50, # pages converted from each PDF
max_content_chars=30_000, # characters passed to the extractor per source
cache=False, # process-local source cache
coverage_criteria=None, # extra evidence requirements
extractor_guidance=None, # optional per-source extraction guidance
)
Domain policy
Nothing is blocked by default: exclude_domains=None means every domain is eligible for search and scraping. To block domains, pass a list explicitly. A curated list of platforms that waste the scrape budget (social/video sites with no scrapeable text, bot-blocked search engines, hard-paywalled publishers) is exported as RECOMMENDED_EXCLUDE_DOMAINS:
from web_scout import run_web_research, RECOMMENDED_EXCLUDE_DOMAINS
# Opt in to the curated list
result = await run_web_research(
query="...",
exclude_domains=RECOMMENDED_EXCLUDE_DOMAINS,
)
# Curated list plus your own additions
result = await run_web_research(
query="...",
exclude_domains=[*RECOMMENDED_EXCLUDE_DOMAINS, "statista.com"],
)
Exclusions are applied natively by search backends that support them (Exa's excludeDomains); backends without native support (Serper) rely on the post-search URL filter, which always runs. Contradictions fail fast: a domain appearing in both include_domains and exclude_domains, or a direct_url whose host is excluded, raises ValueError.
Source caching
With cache=True, successful raw source artifacts are reused by later run_web_research() calls in the same Python process. Pages and documents are not fetched or converted again, but query-specific extraction and synthesis still run each time.
The cache is in-memory only. Failed scrapes, final answers, query-specific summaries, and click-driven browser sessions are not cached.
Pipeline, in one view
question
│
├─ generate targeted searches ─ search in parallel ─ select diverse URLs
│ │
│ ▼
│ static HTML ────────┐ fetch in parallel
│ JS / bot challenge ─┤ │
│ documents ──────────┼─ route + extract ◀───────┘
│ JSON / images ──────┘ │
│ ▼
└─ follow-up search ◀── evaluate evidence coverage
│ sufficient
▼
grounded synthesis
│
▼
citation validation
Where it fits
Use web-scout-ai when your application needs the contents of real pages and documents—not only search snippets—and you want the source successes and failures returned as structured data.
It is intentionally a bounded research component. If you only need search links, use a search API directly. If you need an open-ended autonomous research process with human checkpoints, put this package inside a broader agent workflow.
Requirements
- Python 3.13
- An API key for the configured LLM provider
- A Serper or Exa API key for search mode
- Chromium setup for rendered pages, interactive sites, and browser fallbacks
Classification and resource reuse
Semantic classification defaults to TypeSafe Jev and requires TYPESAFE_API_KEY. Set WEB_SCOUT_CLASSIFICATION_BACKEND=gpt to use the existing GPT classifiers instead. Query generation, factual extraction, visual descriptions and synthesis continue to use your configured generative models. Existing follow-up backend selection remains available.
CPU PDF pipelines initialize on the first document and are reused. Browser and HTTP resources are reused on the same event loop; after requests finish, call await web_scout.scraping.close_resources() at application shutdown. See the performance report for measurements, limits, benchmark commands and compatibility details.
Contributing
The main extension point is SearchBackend. New backends should implement the async search() contract and return normalized results (title, url, snippet).
Bug reports and focused pull requests are welcome at github.com/RSO9192/web-scout-ai.
License
Metadata
Release files for web-scout-ai 1.8.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| web_scout_ai-1.8.1.tar.gz | 134.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| web_scout_ai-1.8.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 288.5 kB
Release files / web_scout_ai-1.8.1.tar.gz
| Download URL | web_scout_ai-1.8.1.tar.gz |
|---|---|
| Size | 134.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a706d7bf4e0e1ae9fb0e89397e62556cf77c71e1b0e67d687e47ebafb77397bb
|
|
BLAKE2b-256 checksum How to use checksums |
d77fe58c7bc201dd99f241bf8eec33368a29a0c33ba37bb676a27a0cfe2a34ab
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / web_scout_ai-1.8.1-py3-none-any.whl
| Download URL | web_scout_ai-1.8.1-py3-none-any.whl |
|---|---|
| Size | 154.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
310f9e0cf91912d42af43a18d158531db425513a0d1274aaa984f8330c4cd016
|
|
BLAKE2b-256 checksum How to use checksums |
8b77c224e3accfdc0cd57ad982488b4de88be6413587204895e292dd4bc5d66b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|