Current section

Files

Jump to
oeis lib oeis.ex
Raw

lib/oeis.ex

defmodule OEIS do
alias Jason
alias OEIS.Sequence
@moduledoc """
A client for the On-Line Encyclopedia of Integer Sequences (OEIS).
"""
@base_url "https://oeis.org"
@doc """
Searches the OEIS database.
This function is the main entry point for querying the OEIS. It can be called
with a keyword list of search parameters, a raw list of integers (treated as
a sequence), or a raw string.
When called with a string, it will be treated as an OEIS ID if it matches
the `A` number format (e.g., `"A000055"`), otherwise it will be treated as a
comma-separated sequence (e.g., `"1,2,3,4"`).
On success, returns `{:ok, [list_of_sequences]}` where `list_of_sequences`
is a list of `OEIS.Sequence` structs. If no results are found, it returns
an empty list: `{:ok, []}`.
If a query returns a full page of results (currently 10 sequences for general
searches), it implies there might be more results available. In such cases,
a `{:partial, message, [list_of_sequences], response_map}` tuple is returned, indicating
that the query might need refinement or pagination (`:start` option).
## Parameters
When called with a keyword list, the following keys are accepted:
* `:sequence` (list of integers or a comma-separated string): A list of terms
in the sequence to search for.
* Example: `[1, 2, 3, 6, 11, 23]` or `"1,2,3,6,11,23"`
* `:id` (string): An OEIS A-number to search for.
* Example: `"A000055"`
* `:keyword` (string): A keyword to filter results.
* Example: `"core"`
* `:author` (string): An author's name to filter results. The search is
automatically made greedy by surrounding the name with `*`.
* Example: `"Sloane"`
* `:query` (string): A general query string for other search terms.
* Example: `"number of partitions"`
* `:start` (integer): The starting index for results, used for pagination.
* Default is 0.
## Examples
iex> OEIS.search("A000045")
{:ok, [%OEIS.Sequence{id: "A000045", name: "Fibonacci numbers." <> _}]}
iex> OEIS.search([1, 2, 3, 5, 8])
{:ok, [%OEIS.Sequence{id: "A000045", name: "Fibonacci numbers." <> _}]}
iex> OEIS.search(query: "non-existent query")
{:ok, []}
iex> OEIS.search(author: "Sloane", keyword: "core", start: 10)
{:ok, list_of_sequences}
iex> {:partial, _message, sequences, _response_map} = OEIS.search(sequence: [1, 2, 3])
iex> length(sequences)
10
# A broad query like "prime number" often returns no results from the JSON API,
# which translates to an empty list `{:ok, []}`. The OEIS JSON API
# currently does not provide a distinct indicator for "too many results"
# versus "no results" in its JSON response.
iex> OEIS.search(query: "prime number")
{:ok, []}
"""
def search(opts) do
case opts do
num when is_integer(num) ->
do_search(id: "A" <> String.pad_leading(to_string(num), 6, "0"))
str when is_binary(str) ->
handle_string_search(str)
list when is_list(list) ->
case Keyword.keyword?(list) do
true -> do_search(list)
false -> do_search(sequence: list)
end
_ ->
{:error, {:bad_param, "Input must be a keyword list, a list of integers, or a string."}}
end
end
defp handle_string_search(<<"A", _id_num::binary-size(6)>> = a_number) do
do_search(id: a_number)
end
defp handle_string_search(str) do
case Integer.parse(str) do
{num, ""} ->
do_search(id: "A" <> String.pad_leading(to_string(num), 6, "0"))
# Not an integer, treat as sequence
_ ->
do_search(sequence: str)
end
end
defp do_search(opts) do
case Keyword.fetch(opts, :id) do
{:ok, id} ->
case make_id_request(id) do
{:ok, parsed_json_or_nil} -> handle_oeis_response(parsed_json_or_nil)
err -> err
end
# This is the general search branch
:error ->
result =
with {:ok, query_params} <- build_query_string(opts),
search_url = Path.join(@base_url, "/search"),
{:ok, parsed_json_or_nil} <- make_request(search_url, query_params) do
handle_oeis_response(parsed_json_or_nil)
end
case result do
{:ok, _} = ok -> ok
{:partial, _message, _sequences, _map} = partial -> partial
{:error, _} = error -> error
end
end
end
defp make_id_request(id) do
url = Path.join(@base_url, "#{id}?fmt=json")
case Req.get(url) do
{:ok, %Req.Response{status: 200, body: body}} ->
# body is already decoded by Req (could be nil, a map, or a list)
{:ok, body}
{:ok, %Req.Response{status: status, body: body}} ->
{:error, {:http_error, "HTTP Error: #{status} - #{inspect(body)}"}}
{:error, reason} ->
{:error, {:http_error, reason}}
end
end
defp build_query_string(opts) do
with {:ok, query_map} <- build_base_query_map(opts) do
handle_start_param(query_map, Keyword.get(opts, :start))
end
end
defp build_base_query_map(opts) do
terms =
with terms <- [] do
terms
|> add_sequence_term(Keyword.get(opts, :sequence))
|> add_id_term(Keyword.get(opts, :id))
|> add_keyword_term(Keyword.get(opts, :keyword))
|> add_author_term(Keyword.get(opts, :author))
|> add_general_query_term(Keyword.get(opts, :query))
|> add_comment_term(Keyword.get(opts, :comment))
|> add_ref_term(Keyword.get(opts, :ref))
|> add_link_term(Keyword.get(opts, :link))
|> add_formula_term(Keyword.get(opts, :formula))
|> add_example_term(Keyword.get(opts, :example))
|> add_name_term(Keyword.get(opts, :name))
|> add_xref_term(Keyword.get(opts, :xref))
end
case terms do
{:error, _} = err ->
err
terms when is_list(terms) ->
if Enum.empty?(terms) do
{:error,
{:bad_param,
"At least one of :sequence, :id, :keyword, :author, or :query must be provided."}}
else
q_value = Enum.join(terms, " ")
{:ok, %{q: q_value, fmt: "json"}}
end
end
end
defp handle_start_param(query_map, nil), do: {:ok, query_map}
defp handle_start_param(query_map, start_param)
when is_integer(start_param) and start_param >= 0 do
{:ok, Map.put(query_map, :start, start_param)}
end
defp handle_start_param(_query_map, _),
do: {:error, {:bad_param, ":start must be a non-negative integer."}}
defp add_sequence_term({:error, _} = err, _), do: err
defp add_sequence_term(terms, nil), do: terms
defp add_sequence_term(terms, sequence) when is_list(sequence) do
case Enum.all?(sequence, &is_integer/1) do
true ->
[Enum.map_join(sequence, ",", &to_string/1) | terms]
false ->
{:error,
{:bad_param,
"Sequence must be a list of integers or a comma-separated string of integers."}}
end
end
defp add_sequence_term(terms, sequence) when is_binary(sequence) do
case parse_integer_string(sequence) do
{:ok, int_list} -> [Enum.join(int_list, ",") | terms]
_ -> {:error, {:bad_param, "Sequence string must be a comma-separated list of integers."}}
end
end
defp add_sequence_term(_, _),
do:
{:error,
{:bad_param,
"Sequence must be a list of integers or a comma-separated string of integers."}}
defp parse_integer_string(sequence_str) do
result =
sequence_str
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.map(&Integer.parse/1)
if Enum.all?(result, &match?({_val, ""}, &1)) do
{:ok, Enum.map(result, fn {val, ""} -> val end)}
else
{:error, :not_an_integer_list_string}
end
end
defp add_id_term({:error, _} = err, _), do: err
defp add_id_term(terms, nil), do: terms
defp add_id_term(terms, <<"A", _id_num::binary-size(6)>> = id) do
["id:" <> id | terms]
end
defp add_id_term(_terms, _id),
do:
{:error,
{:bad_param,
"ID must be a string starting with 'A' and 7 characters long (e.g., 'A000001')."}}
defp add_keyword_term({:error, _} = err, _), do: err
defp add_keyword_term(terms, nil), do: terms
defp add_keyword_term(terms, keyword) when is_binary(keyword) do
["keyword:" <> keyword | terms]
end
defp add_keyword_term(_, _), do: {:error, {:bad_param, "Keyword must be a string."}}
defp add_author_term({:error, _} = err, _), do: err
defp add_author_term(terms, nil), do: terms
defp add_author_term(terms, author) when is_binary(author) do
["author:*" <> author <> "*" | terms]
end
defp add_author_term(_, _), do: {:error, {:bad_param, "Author must be a string."}}
defp add_general_query_term({:error, _} = err, _), do: err
defp add_general_query_term(terms, nil), do: terms
defp add_general_query_term(terms, query_str) when is_binary(query_str) do
[query_str | terms]
end
defp add_general_query_term(_, _), do: {:error, {:bad_param, "General query must be a string."}}
defp add_comment_term({:error, _} = err, _), do: err
defp add_comment_term(terms, nil), do: terms
defp add_comment_term(terms, comment) when is_binary(comment),
do: ["comment:" <> comment | terms]
defp add_comment_term(_, _), do: {:error, {:bad_param, "Comment must be a string."}}
defp add_ref_term({:error, _} = err, _), do: err
defp add_ref_term(terms, nil), do: terms
defp add_ref_term(terms, ref) when is_binary(ref), do: ["ref:" <> ref | terms]
defp add_ref_term(_, _), do: {:error, {:bad_param, "Ref must be a string."}}
defp add_link_term({:error, _} = err, _), do: err
defp add_link_term(terms, nil), do: terms
defp add_link_term(terms, link) when is_binary(link), do: ["link:" <> link | terms]
defp add_link_term(_, _), do: {:error, {:bad_param, "Link must be a string."}}
defp add_formula_term({:error, _} = err, _), do: err
defp add_formula_term(terms, nil), do: terms
defp add_formula_term(terms, formula) when is_binary(formula),
do: ["formula:" <> formula | terms]
defp add_formula_term(_, _), do: {:error, {:bad_param, "Formula must be a string."}}
defp add_example_term({:error, _} = err, _), do: err
defp add_example_term(terms, nil), do: terms
defp add_example_term(terms, example) when is_binary(example),
do: ["example:" <> example | terms]
defp add_example_term(_, _), do: {:error, {:bad_param, "Example must be a string."}}
defp add_name_term({:error, _} = err, _), do: err
defp add_name_term(terms, nil), do: terms
defp add_name_term(terms, name) when is_binary(name), do: ["name:" <> name | terms]
defp add_name_term(_, _), do: {:error, {:bad_param, "Name must be a string."}}
defp add_xref_term({:error, _} = err, _), do: err
defp add_xref_term(terms, nil), do: terms
defp add_xref_term(terms, xref) when is_binary(xref), do: ["xref:" <> xref | terms]
defp add_xref_term(_, _), do: {:error, {:bad_param, "Xref must be a string."}}
defp make_request(url, query_params) do
case Req.get(url, params: query_params) do
{:ok, %Req.Response{status: 200, body: body}} ->
# body is already decoded by Req (could be nil, a map, or a list)
{:ok, body}
{:ok, %Req.Response{status: status, body: body}} ->
{:error, {:http_error, "HTTP Error: #{status} - #{inspect(body)}"}}
{:error, reason} ->
{:error, {:http_error, reason}}
end
end
defp handle_oeis_response(nil), do: {:ok, []}
# Case for when the OEIS API returns a list of results (general search).
defp handle_oeis_response(results) when is_list(results) and length(results) == 10 do
sequences = Enum.map(results, &map_to_sequence/1)
warning_message =
"[OEIS] Your query returned a full page of results. More results might be available. Consider refining your query or using the :start option for pagination."
# Include the raw results if desired
{:partial, warning_message, sequences, %{"results" => results}}
end
defp handle_oeis_response(results) when is_list(results) do
sequences = Enum.map(results, &map_to_sequence/1)
{:ok, sequences}
end
# Case for when the OEIS API returns a single sequence object (map) for direct A-number lookups.
defp handle_oeis_response(%{"id" => _} = single_result) do
{:ok, [map_to_sequence(single_result)]}
end
defp handle_oeis_response(other), do: {:error, {:unknown_response_format, other}}
defp map_to_sequence(result) do
data = Map.get(result, "data", "")
{_ok, data_list} = parse_integer_string(data)
created =
with created_str when is_binary(created_str) <- Map.get(result, "created"),
{:ok, dt, _} <- DateTime.from_iso8601(created_str) do
dt
else
_ -> nil
end
time =
with time_str when is_binary(time_str) <- Map.get(result, "time"),
{:ok, dt, _} <- DateTime.from_iso8601(time_str) do
dt
else
_ -> nil
end
%Sequence{
id: "A" <> String.pad_leading(to_string(Map.get(result, "number")), 6, "0"),
number: Map.get(result, "number"),
name: Map.get(result, "name"),
data: data_list,
comment: Map.get(result, "comment"),
reference: Map.get(result, "reference"),
formula: Map.get(result, "formula"),
example: Map.get(result, "example"),
link: extract_links_from_result(result),
author: extract_author(result),
created: created,
time: time
}
end
defp extract_author(result) do
author_regex = ~r/_([A-Za-z.\s]+?)_/
comments = Map.get(result, "comment", [])
references = Map.get(result, "reference", [])
all_texts =
if(is_list(comments), do: comments, else: []) ++
if is_list(references), do: references, else: []
authors =
Enum.flat_map(all_texts, fn text ->
case Regex.run(author_regex, to_string(text)) do
[_whole, author] -> [String.trim(author)]
_ -> []
end
end)
|> Enum.uniq()
|> Enum.sort()
if Enum.empty?(authors) do
nil
else
Enum.join(authors, ", ")
end
end
defp extract_links_from_result(result) do
href_regex = ~r/href="([^"]*)">([^<]+)<\/a>/
links = Map.get(result, "link", [])
Enum.flat_map(links, &parse_link_string(&1, href_regex))
end
defp parse_link_string(link_str, href_regex) do
case Regex.scan(href_regex, to_string(link_str)) do
matches when matches != [] ->
Enum.map(matches, fn [_, url, text] ->
%{url: format_full_url(url), text: text}
end)
_ ->
[]
end
end
defp format_full_url("/" <> _rest = url), do: "https://oeis.org" <> url
defp format_full_url(url), do: url
end