defmodule PublicSuffixList do
@moduledoc """
Parse DNS domain names using public suffix list from https://publicsuffix.org
"""
@app :public_suffix_list
@input_file Path.join(:code.priv_dir(@app), "public_suffix_list.dat")
@external_resource @input_file
@doc "Parse domain into subdomains, name and suffix"
@spec parse(binary) :: {:ok, {list(binary), binary, binary}} | {:error, :unknown_suffix}
def parse(domain) when is_binary(domain) do
domain
|> String.downcase()
|> String.split(".")
|> Enum.reverse()
|> match_suffix()
end
@doc "Strip subdomain, leaving just name and suffix"
@spec normalize(binary) :: {:ok, binary} | {:error, :unknown_suffix}
def normalize(domain) when is_binary(domain) do
case parse(domain) do
{:ok, {_subdomains, name, suffix}} ->
{:ok, name <> "." <> suffix}
{:error, reason} ->
{:error, reason}
end
end
@doc "Strip subdomain and suffix, leaving just the name"
@spec name(binary) :: {:ok, binary} | {:error, :unknown_suffix}
def name(domain) when is_binary(domain) do
case parse(domain) do
{:ok, {_subdomains, name, _suffix}} ->
{:ok, name}
{:error, reason} ->
{:error, reason}
end
end
# Internal functions
# Build function clauses to match names from public suffix list data
@spec match_suffix(list(binary)) :: {:ok, {list(binary), binary, binary}} | {:error, :unknown_suffix}
@input_file
|> File.read!()
|> String.split("\n")
|> Enum.filter(&(not Regex.match?(~r/^\/\/|^\s*$/, &1)))
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.map(&{&1 |> String.split(".") |> Enum.reverse(), &1})
|> Enum.sort(&>=/2)
|> Enum.map(fn {comps, suffix} ->
args = comps ++ quote(do: [name | rest])
result = quote(do: {:ok, {Enum.reverse(rest), name, unquote(suffix)}})
defp match_suffix(unquote(args)), do: unquote(result)
end)
defp match_suffix(_) do
{:error, :unknown_suffix}
end
end