Packages
nous
0.13.3
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.8
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.17
0.12.16
0.12.15
0.12.14
0.12.13
0.12.12
0.12.11
0.12.9
0.12.7
0.12.6
0.12.5
0.12.3
0.12.2
0.12.0
0.11.3
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.5.0
AI agent framework for Elixir with multi-provider LLM support
Current section
Files
Jump to
Current section
Files
lib/nous/tools/web_fetch.ex
if Code.ensure_loaded?(Floki) do
defmodule Nous.Tools.WebFetch do
@moduledoc """
Tool for fetching and extracting readable content from web pages.
Uses Req for HTTP and Floki for HTML parsing. Strips scripts, styles,
and navigation to extract the main content.
## Dependencies
Requires the `floki` package in your mix.exs:
{:floki, "~> 0.36"}
## Usage
agent = Agent.new("openai:gpt-4",
tools: [&WebFetch.fetch_page/2]
)
"""
@doc """
Fetch a web page and extract its readable content.
## Arguments
- url: The URL to fetch (required)
- selector: Optional CSS selector to extract specific content
## Returns
A map with url, title, content, word_count, and fetched_at.
"""
def fetch_page(_ctx, args) do
url = Map.get(args, "url") || ""
selector = Map.get(args, "selector")
if url == "" do
%{success: false, error: "URL is required"}
else
case do_fetch(url, selector) do
{:ok, result} -> Map.put(result, :success, true)
{:error, reason} -> %{success: false, error: reason, url: url}
end
end
end
@doc false
def do_fetch(url, selector \\ nil) do
with {:ok, body} <- fetch_url(url),
{:ok, parsed} <- parse_html(body),
content <- extract_content(parsed, selector) do
title = extract_title(parsed)
{:ok,
%{
url: url,
title: title,
content: content,
word_count: content |> String.split(~r/\s+/) |> length(),
fetched_at: DateTime.utc_now() |> DateTime.to_iso8601()
}}
end
end
defp fetch_url(url) do
try do
case Req.get(url,
connect_options: [timeout: 10_000],
receive_timeout: 15_000,
max_redirects: 5,
headers: [
{"user-agent",
"Mozilla/5.0 (compatible; NousBot/1.0; +https://github.com/nyo16/nous)"},
{"accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}
]
) do
{:ok, %{status: status, body: body}} when status in 200..299 ->
{:ok, body}
{:ok, %{status: status}} ->
{:error, "HTTP #{status}"}
{:error, reason} ->
{:error, "Request failed: #{inspect(reason)}"}
end
rescue
e -> {:error, "Request error: #{Exception.message(e)}"}
end
end
defp parse_html(body) when is_binary(body) do
case Floki.parse_document(body) do
{:ok, doc} -> {:ok, doc}
{:error, reason} -> {:error, "HTML parse error: #{inspect(reason)}"}
end
end
defp parse_html(_), do: {:error, "Invalid response body"}
defp extract_content(doc, selector) when is_list(doc) do
# Remove script, style, nav, header, footer elements
cleaned =
doc
|> remove_elements(["script", "style", "nav", "header", "footer", "aside", "noscript"])
# Apply CSS selector if provided
content_nodes =
if selector do
Floki.find(cleaned, selector)
else
# Try common content selectors
find_main_content(cleaned)
end
content_nodes
|> Floki.text(sep: " ")
|> clean_text()
end
defp extract_title(doc) when is_list(doc) do
case Floki.find(doc, "title") do
[{_, _, children} | _] -> Floki.text([{"span", [], children}]) |> String.trim()
_ -> nil
end
end
defp find_main_content(doc) do
# Try common content containers in order of specificity
selectors = ["article", "main", "[role=main]", ".content", "#content", ".post", ".article"]
Enum.find_value(selectors, doc, fn selector ->
case Floki.find(doc, selector) do
[] -> nil
nodes -> nodes
end
end)
end
defp remove_elements(doc, tag_names) when is_list(doc) do
Enum.reduce(tag_names, doc, fn tag, acc ->
Floki.filter_out(acc, tag)
end)
end
defp clean_text(text) do
text
|> String.replace(~r/\s+/, " ")
|> String.replace(~r/\n{3,}/, "\n\n")
|> String.trim()
end
end
else
defmodule Nous.Tools.WebFetch do
@moduledoc """
Tool for fetching and extracting readable content from web pages.
Requires the `floki` package. Add `{:floki, "~> 0.36"}` to your deps.
"""
def fetch_page(_ctx, _args) do
%{success: false, error: "Floki is required. Add {:floki, \"~> 0.36\"} to your deps."}
end
@doc false
def do_fetch(_url, _selector \\ nil) do
{:error, "Floki is required. Add {:floki, \"~> 0.36\"} to your deps."}
end
end
end