~electric/shop-oregon-local

f78a9478652dc78459f420e03d87b418fd629518 — Micah D 5 months ago 7a7e3e8
Add more search providers + caching + search options
M lib/shop_local/application.ex => lib/shop_local/application.ex +2 -1
@@ 12,9 12,10 @@ defmodule ShopLocal.Application do
      # Start the PubSub system
      {Phoenix.PubSub, name: ShopLocal.PubSub},
      # Start the Endpoint (http/https)
      ShopLocalWeb.Endpoint
      ShopLocalWeb.Endpoint,
      # Start a worker by calling: ShopLocal.Worker.start_link(arg)
      # {ShopLocal.Worker, arg}
      ShopLocal.CacheSupervisor
    ]

    # See https://hexdocs.pm/elixir/Supervisor.html

A lib/shop_local/brick_circuit_search_provider.ex => lib/shop_local/brick_circuit_search_provider.ex +42 -0
@@ 0,0 1,42 @@
defmodule ShopLocal.BrickCircuitSearchProvider do
  import ShopLocal.SearchProvider

  def search(name, s, opts \\ []) do
    search_with_cache(name, s, &fetch/1, opts)
  end

  def fetch(s) do
    case HTTPoison.get("https://www.brick-circuit.com/app/store/api/v13/editor/users/131339162/sites/744569820167232651/products?page=1&per_page=10&sort_by=score&sort_order=desc&q=#{s}&include=images,media_files", [
      "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
      "Accept": "application/json, text/javascript, */*; q=0.01",
      "Origin": "https://www.brick-circuit.com",
      "Referer": "https://www.brick-circuit.com/s/search?q=#{s}",
    ]) do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
          body = Poison.decode!(body)
          results = Enum.map(body["data"], fn(k) -> 
            price = k["price"]["low"] / 1

            link = HtmlSanitizeEx.strip_tags(k["site_link"])
            link = case String.starts_with?(link, "http") do
              false -> "https://www.brick-circuit.com/" <> link
              true ->  link
            end

            %{
              provider: "Brick Circuit",
              name: HtmlSanitizeEx.strip_tags(k["name"]),
              desc: HtmlSanitizeEx.strip_tags(k["short_description"]),
              price: price,
              link: link,
              image_src: HtmlSanitizeEx.strip_tags(k["thumbnail"]["data"]["absolute_url"])
            } 
        end)
        {:ok, results}
      {:ok, %HTTPoison.Response{status_code: 404}} ->
      {:error, "Not found." }
      {:error, %HTTPoison.Error{reason: reason}} ->
      {:error, reason}
    end
  end
end

A lib/shop_local/browsers_corvallis_search_provider.ex => lib/shop_local/browsers_corvallis_search_provider.ex +92 -0
@@ 0,0 1,92 @@
defmodule ShopLocal.BrowsersCorvallisSearchProvider do
  import ShopLocal.SearchProvider

  def search(name, s, opts \\ []) do
    search_with_cache(name, s, &fetch/1, opts)
  end

  def fetch(s) do
    case HTTPoison.get("https://www.biblio.com/search.php?keyisbn=#{s}&dealer_id=5710") do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        {:ok, document} = Floki.parse_document(body)
        s_lower = String.trim(URI.decode_www_form(String.downcase(s)))
        s_parts = String.split(s_lower, " ")
        last_first = "#{List.last(s_parts)}, #{List.first(s_parts)}"
        results = document
                  |> Floki.find(".results .item")
                  |> Enum.map(fn (k) -> 
                    name = k
                           |> Floki.find(".title a")
                           |> Floki.text
                    link = k
                           |> Floki.find(".title a") 
                           |> Floki.attribute("href")
                           |> List.last

                    image_src = k
                                |> Floki.find("meta[itemprop=\"image\"]")
                                |> Floki.attribute("content")
                                |> List.first

                    image_src = case image_src do
                      nil -> "https://d3525k1ryd2155.cloudfront.net/i/en20/no-book-image.png"
                      i -> i
                    end

                    author = k
                           |> Floki.find("meta[itemprop=\"author\"]")
                           |> Floki.attribute("content")
                           |> List.first
                           |> String.trim 

                    desc = k
                           |> Floki.find(".item-description .text")
                           |> List.last
                           |> Floki.text
                           |> String.trim

                    desc = " Author: #{author}.\n #{desc}"

                    price_result = k
                      |> Floki.find(".item-price")
                      |> Floki.text
                      |> String.trim
                      |> String.replace(["$", "€"], "")
                      |> String.split("/")
                      |> List.first
                      |> Float.parse

                    price = case price_result do
                      {res, _rem} -> res
                      :error -> nil
                    end

                    link = case String.starts_with?(link, "http") do
                      false -> "https://www.biblio.com" <> link
                      true ->  link
                    end

                      %{
                        provider: "Browsers' Books (Corvallis)",
                        name: name,
                        desc: desc,
                        price: price,
                        link: link,
                        image_src: image_src
                      } 
                  end)
                  |> Enum.filter(fn 
                    %{ name: "" } -> false
                    %{ image_src: "" } -> false
                    item -> String.contains?(String.downcase(item.desc), s_lower)
                      || String.contains?(String.downcase(item.name), s_lower)
                      || String.contains?(String.downcase(item.desc), last_first)
                  end)
        {:ok, results}
        {:ok, %HTTPoison.Response{status_code: 404}} ->
        {:error, "Not found." }
        {:error, %HTTPoison.Error{reason: reason}} ->
        {:error, reason}
    end
  end
end

M lib/shop_local/bursts_chocolates_search_provider.ex => lib/shop_local/bursts_chocolates_search_provider.ex +15 -2
@@ 1,6 1,12 @@
defmodule ShopLocal.BurstsChocolatesSearchProvider do

  def search(s) do
  import ShopLocal.SearchProvider

  def search(name, s, opts \\ []) do
    search_with_cache(name, s, &fetch/1, opts)
  end

  def fetch(s) do
    case HTTPoison.get("https://burstschocolates.com/?target=search&mode=search&substring=#{s}") do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        {:ok, document} = Floki.parse_document(body)


@@ 19,9 25,16 @@ defmodule ShopLocal.BurstsChocolatesSearchProvider do
                                |> Floki.find(".product-thumbnail img")
                                |> Floki.attribute("src")

                    price = k
                    price_parts = k
                      |> Floki.find("span.price.product-price")
                      |> Floki.text
                      |> String.replace("$", "")
                      |> Float.parse

                    price = case price_parts do
                      {res, _} -> res
                      :error -> nil
                    end

                    link = case String.starts_with?(link, "http") do
                      false -> "https://burstschocolates.com" <> link

A lib/shop_local/cache_provider.ex => lib/shop_local/cache_provider.ex +105 -0
@@ 0,0 1,105 @@
defmodule ShopLocal.CacheProvider do
  use GenServer

  def start_link(opts) do
    GenServer.start_link(__MODULE__, :ok, opts)
  end

  def add(pid, elements, search) do
    GenServer.cast(pid, {:add, elements, search})
  end

  def search(pid, s) do
    GenServer.call(pid, {:search, s})
  end

  defp search_map(cache, s_params) do
    Enum.filter_map(cache,
      fn ({ key, value }) ->
        Enum.all?(s_params, &(
          String.contains?(String.downcase(value.search), &1)
          || String.contains?(String.downcase(value.desc), &1)
          || String.contains?(String.downcase(value.name), &1)
        ))
      end,
      fn ({ key, value }) -> value end)
  end

  # If we miss x number of searches, stop asking for a while
  @no_search_threshold 2

  defp missed?(missed, s_params) do
    missed
    |> Enum.filter_map(
      fn ({ word, _count }) ->
        Enum.member?(s_params, word)
      end,
      fn ({ _word, count }) -> count end)
    |> Enum.any?(&(&1 > @no_search_threshold))
  end

  defp add_missed(missed, s_params) do
    s_params
    |> Enum.reduce(missed, fn(k, acc) ->
      new_count = if Map.has_key?(acc, k), do: acc[k] + 1, else: 1
      Map.put(acc, k, new_count)
    end)
  end

  # How long to keep cache records for
  @cache_for_seconds 18*60*60

  @impl true
  def init(:ok) do
    Process.send_after(self(), :clean, @cache_for_seconds*1000)
    {:ok, {%{}, %{}}}
  end

  @impl true
  def handle_cast({:add, entries, s}, {map, missed}) do
    s = URI.decode_www_form(String.downcase(s))
    if Enum.empty?(entries) do
        s_params = String.split(s, " ")
        missed = add_missed(missed, s_params)
        {:noreply, {map, missed}}
    else
        map = Enum.reduce(entries, map, fn(k, acc) -> 
          updated = k |> Map.put(:update_date, DateTime.now!("Etc/UTC"))
                      |> Map.put(:search, s)
          Map.put(acc, k.link, updated)
        end)
        {:noreply, {map, missed}}
    end
  end

  @impl true
  def handle_call({:search, s}, _from, {map, missed}) do
    s = String.downcase(s)
    s_params = String.split(URI.decode_www_form(s), " ")
    if missed?(missed, s_params) do
      {:reply, {:found, []}, {map, missed}}
    else
      found = search_map(map, s_params)
      if Enum.empty?(found) do
        {:reply, {:not_found}, {map, missed}}
      else
        {:reply, {:found, found}, {map, missed}}
      end
    end
  end

  @impl true
  def handle_info(:clean, {map, missed}) do
    keys = Enum.filter_map(map, fn ({_key, value}) ->
      now = DateTime.now!("Etc/UTC")
      DateTime.diff(now, value.update_date) < @cache_for_seconds
    end, fn ({key, _value}) -> key end)
    map = Map.drop(map, keys)

    missed = Enum.reduce(missed, missed, fn ({key, value}, acc) ->
      Map.put(acc, key, max(value - 1, 0))
    end)
    Process.send_after(self(), :clean, @cache_for_seconds*1000)
    {:noreply, {map, missed}}
  end
end

A lib/shop_local/cache_supervisor.ex => lib/shop_local/cache_supervisor.ex +19 -0
@@ 0,0 1,19 @@
defmodule ShopLocal.CacheSupervisor do
  use Supervisor
  alias ShopLocal.ProviderCollection
  alias ShopLocal.CacheProvider

  def start_link(opts) do
    Supervisor.start_link(__MODULE__, :ok, opts)
  end

  @children Enum.map(ProviderCollection.providers, fn(k) ->
    cache_name = :"#{k.name}_cache"
    Supervisor.child_spec({ CacheProvider, name: cache_name }, id: cache_name)
  end)

  @impl true
  def init(:ok) do
    Supervisor.init(@children, strategy: :one_for_one)
  end
end

M lib/shop_local/clothing_tree_search_provider.ex => lib/shop_local/clothing_tree_search_provider.ex +18 -3
@@ 1,6 1,11 @@
defmodule ShopLocal.ClothingTreeSearchProvider do
  import ShopLocal.SearchProvider

  def search(s) do
  def search(name, s, opts \\ []) do
    search_with_cache(name, s, &fetch/1, opts)
  end

  def fetch(s) do
    case HTTPoison.get("https://www.clothes-tree.com/search?q=#{s}") do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        {:ok, document} = Floki.parse_document(body)


@@ 16,10 21,20 @@ defmodule ShopLocal.ClothingTreeSearchProvider do
                    image_src = k
                                |> Floki.find("img.product-item__image")
                                |> Floki.attribute("src")
                                |> List.first

                    price = case Floki.find(k, ".product-item__meta__inner p:last-child") do
                      [{_tag, _attrs, price_children}] -> List.last(price_children)
                      _ -> "$XXX"
                      [{_tag, _attrs, price_children}] ->
                        price_parts =price_children
                                     |> List.last
                                     |> String.trim 
                                     |> String.replace("$", "") |> Float.parse

                        case price_parts do
                          {res, _} -> res
                          :error -> nil
                        end
                      _ -> nil
                    end

                      %{

M lib/shop_local/combined_search.ex => lib/shop_local/combined_search.ex +22 -29
@@ 1,51 1,44 @@
defmodule ShopLocal.CombinedSearch do
  alias ShopLocal.PegasusSearchProvider
  alias ShopLocal.ToyFactorySearchProvider
  alias ShopLocal.ClothingTreeSearchProvider
  alias ShopLocal.BurstsChocolatesSearchProvider
  alias ShopLocal.OregonCoffeeAndTeaSearchProvider
  alias ShopLocal.ConundrumHouseSearchProvider
  alias ShopLocal.RobnettsSearchProvider
  alias ShopLocal.RestyleCorvallisSearchProvider

  alias ShopLocal.CacheProvider
  alias ShopLocal.ProviderCollection

  require Logger

  def listen(0, results) do
    results
  end
  def listen(0, results), do: results
  def listen(count, results) do
    receive do
      {:ok, res} -> 
        results = Enum.concat(results, res)
        listen(count - 1, results)
      {:error, message} ->
        Logger.warn(message)
        Logger.warn("#{message}")
        listen(count - 1, results)
    after
      10_000 ->
        Logger.warn("10s timeout")
        Logger.warn("15s timeout")
        results
    end
  end

  @providers [
    ToyFactorySearchProvider,
    PegasusSearchProvider,
    ClothingTreeSearchProvider,
    BurstsChocolatesSearchProvider,
    OregonCoffeeAndTeaSearchProvider,
    ConundrumHouseSearchProvider,
    RobnettsSearchProvider,
    RestyleCorvallisSearchProvider
  ]
  @provider_len Enum.count(@providers)

  def search(s) do
    parent = self()
    tasks = Enum.map(@providers, fn(k) -> 
      Task.start(fn -> send(parent, k.search(s)) end)
  def search(s, fields) do
    pid = self()
    locations = Enum.filter(ProviderCollection.locations, fn(k) -> 
      fields[Atom.to_string(k)] != "false"
    end)
    active_tags = Enum.filter(ProviderCollection.tags, fn(k) -> 
      fields[Atom.to_string(k)] != "false"
    end)
    opts = [use_cache: Map.get(fields, "use_cache", true)]
    provider_infos = ProviderCollection.matching_providers(locations, active_tags)
    provider_len = Enum.count(provider_infos)
    tasks = Enum.map(provider_infos, fn(k) -> 
      Task.start(fn ->
        send(pid, k.provider.search(k.name, s, opts))
      end)
    end)
    results = listen(@provider_len, [])
    results = listen(provider_len, [])
              |> Enum.map(fn(k) -> Map.put(k, :desc, String.slice(k.desc, 0, 400)) end)
              |> Enum.shuffle
    {:ok, results}

M lib/shop_local/conundrum_house_search_provider.ex => lib/shop_local/conundrum_house_search_provider.ex +17 -3
@@ 1,6 1,11 @@
defmodule ShopLocal.ConundrumHouseSearchProvider do
  import ShopLocal.SearchProvider

  def search(s) do
  def search(name, s, opts \\ []) do
    search_with_cache(name, s, &fetch/1, opts)
  end

  def fetch(s) do
    case HTTPoison.get("https://conundrum.house/search?q=#{s}") do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        {:ok, document} = Floki.parse_document(body)


@@ 23,10 28,19 @@ defmodule ShopLocal.ConundrumHouseSearchProvider do
                    image_src = k
                                |> Floki.find("img.list-view-item__image")
                                |> Floki.attribute("src")
                                |> List.first

                    price = k
                    price_parts = k
                      |> Floki.find(".price-item.price-item--regular")
                      |> Floki.text
                      |> String.trim
                      |> String.replace("$", "")
                      |> Float.parse

                    price = case price_parts do
                      {res, _} -> res
                      :error -> nil
                    end

                    %{
                      provider: "Conundrum House",


@@ 39,7 53,7 @@ defmodule ShopLocal.ConundrumHouseSearchProvider do
                  end)
                  |> Enum.filter(fn 
                    %{ name: "" } -> false
                    _ -> true
                    %{ image_src: i } -> i != nil && String.trim(i) != ""
                  end)
        {:ok, results}
        {:ok, %HTTPoison.Response{status_code: 404}} ->

A lib/shop_local/grass_roots_search_provider.ex => lib/shop_local/grass_roots_search_provider.ex +118 -0
@@ 0,0 1,118 @@
defmodule ShopLocal.GrassRootsSearchProvider do
  import ShopLocal.SearchProvider

  def search(name, s, opts \\ []) do
    search_with_cache(name, s, &fetch/1, opts)
  end

  def get_json(body) do
    # var a = document.querySelector('#share_with_webstores_dialog ~ script:not([src])').innerHTML.split(';\n').map(t => trim(t)).filter(t => t.startsWith('var tl'))[0]
    # JSON.parse(a.slice(a.indexOf("{")))
    {:ok, document} = Floki.parse_document(body)
    {"script", _, script} = document
    |> Floki.find("#share_with_webstores_dialog ~ script:not([src])")
    |> List.last

    json_string = script
    |> List.first
    |> String.split(";\n")
    |> Enum.map(&String.trim/1)
    |> Enum.find(&String.starts_with?(&1, "var tl"))
    |> String.replace(~r/^\s*var\s+tl\s*=\s*{/, "")
                                         

    json_string = "{" <> json_string
    json_string 
    |> Poison.decode!
  end

  def fetch(s) do
    case HTTPoison.get("https://grassrootsbookstore.com/?searchtype=keyword&qs=#{s}&qs_file=&q=h.tviewer&using_sb=status&qsb=keyword") do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        json = get_json(body)
        s_lower = String.trim(URI.decode_www_form(String.downcase(s)))
        s_parts = String.split(s_lower, " ")
        last_first = "#{List.last(s_parts)}, #{List.first(s_parts)}"
        results = json["rs"]
                  |> Enum.map(fn (%{ "html" => html }) -> 
                    {:ok, k} = Floki.parse_document(html)
                    name = k
                           |> Floki.find(".title_description a.atogsrus")
                           |> Floki.text
                    link = k
                           |> Floki.find(".title_image a.moreinfo") 
                           |> Floki.attribute("href")
                           |> List.first

                    image_src = k
                                |> Floki.find(".title_image img")
                                |> Floki.attribute("src")
                                |> List.first

                    image_src = "https://grassrootsbookstore.com/" <> image_src

                    author_c = k
                               |> Floki.find(".title_description *:not(a, table, div, br) .togsrus")
                               |> Enum.reduce([], fn ({_t, _a, author_c }, acc) -> 
                                 acc ++ author_c
                               end)

                    author = author_c
                             |> Enum.map(&(&1 |> Floki.text |> String.replace("|", "") |> String.trim))
                             |> Enum.filter(&(&1 != ""))
                             |> Enum.join(", ")

                    desc = k
                           |> Floki.find(".title_description *:not(a, table, div, br)")
                           |> Enum.map(fn (l) -> 
                              l |> Floki.text
                                |> String.replace("|", "")
                                |> String.trim
                           end)
                           |> Enum.filter(&(&1 != "" && &1 != author && !String.starts_with?(&1, "$")))
                           |> Enum.join(" | ")

                    desc = "Author: #{author} | #{desc}"

                    price_result = k
                      |> Floki.find(".full-title-stock div")
                      |> Enum.filter(&Floki.text(&1) |> String.contains?("$"))
                      |> Floki.text
                      |> String.trim
                      |> String.replace("$", "")
                      |> Float.parse

                    price = case price_result do
                      {res, _rem} -> res
                      :error -> nil
                    end

                    link = case String.starts_with?(link, "http") do
                      false -> "https://grassrootsbookstore.com/" <> link
                      true ->  link
                    end

                      %{
                        provider: "Grass Roots",
                        name: name,
                        desc: desc,
                        price: price,
                        link: link,
                        image_src: image_src
                      } 
                  end)
                  |> Enum.filter(fn 
                    %{ name: "" } -> false
                    %{ image_src: "" } -> false
                    item -> String.contains?(String.downcase(item.desc), s_lower)
                      || String.contains?(String.downcase(item.name), s_lower)
                      || String.contains?(String.downcase(item.desc), last_first)
                  end)
        {:ok, results}
        {:ok, %HTTPoison.Response{status_code: 404}} ->
        {:error, "Not found." }
        {:error, %HTTPoison.Error{reason: reason}} ->
        {:error, reason}
    end
  end
end

A lib/shop_local/merri_artist_search_provider.ex => lib/shop_local/merri_artist_search_provider.ex +45 -0
@@ 0,0 1,45 @@
defmodule ShopLocal.MerriArtistSearchProvider do
  import ShopLocal.SearchProvider

  def search(name, s, opts \\ []) do
    search_with_cache(name, s, &fetch/1, opts)
  end

  def fetch(s) do
    case HTTPoison.get("https://www.searchanise.com/getresults?api_key=9t0H6I3R7m&q=#{s}&sortBy=relevance&sortOrder=desc&startIndex=0&maxResults=15&items=true&pageStartIndex=0&pagesMaxResults=20", [
      "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
      "Accept": "application/json, text/javascript, */*; q=0.01",
      "Origin": "https://merriartist.com",
      "Referer": "https://merriartist.com/pages/search-results-page?q=#{s}",
    ]) do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        body = Poison.decode!(body)
        results = Enum.map(body["items"], fn(k) -> 
          price_parts = k["price"]
                        |> HtmlSanitizeEx.strip_tags
                        |> String.trim
                        |> String.replace("$", "")
                        |> Float.parse

          price = case price_parts do
            {res, _rem} -> res
            :error -> nil
          end

            %{
              provider: "The Merri Artist",
              name: HtmlSanitizeEx.strip_tags(k["title"]),
              desc: HtmlSanitizeEx.strip_tags(k["description"]),
              price: price,
              link: HtmlSanitizeEx.strip_tags(k["link"]),
              image_src: HtmlSanitizeEx.strip_tags(k["image_link"])
            } 
        end)
        {:ok, results}
      {:ok, %HTTPoison.Response{status_code: 404}} ->
      {:error, "Not found." }
      {:error, %HTTPoison.Error{reason: reason}} ->
      {:error, reason}
    end
  end
end

M lib/shop_local/oregon_coffee_and_tea_search_provider.ex => lib/shop_local/oregon_coffee_and_tea_search_provider.ex +4 -30
@@ 1,34 1,8 @@
defmodule ShopLocal.OregonCoffeeAndTeaSearchProvider do
  import ShopLocal.SearchProvider
  import ShopLocal.SquarespaceMetaProvider

  def search(s) do
    case HTTPoison.get("https://oregoncoffeeandtea.com/api/search/GeneralSearch?q=#{s}&p=0") do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        body = Poison.decode!(body)
        results = body["items"]
                  |> Enum.filter(fn
                    %{"recordTypeName" => "store-item"} -> true
                    _ -> false
                  end)
                  |> Enum.map(fn(k) -> 
                    link = HtmlSanitizeEx.strip_tags(k["itemUrl"])
                    link = case String.starts_with?(link, "http") do
                      false -> "https://oregoncoffeeandtea.com" <> link
                      true ->  link
                    end
                      %{
                        provider: "Oregon Coffee & Tea",
                        name: HtmlSanitizeEx.strip_tags(k["title"]),
                        desc: HtmlSanitizeEx.strip_tags(k["exerpt"]),
                        price: "$XXX",
                        link: link,
                        image_src: HtmlSanitizeEx.strip_tags(k["imageUrl"])
                      } 
                  end)
        {:ok, results}
      {:ok, %HTTPoison.Response{status_code: 404}} ->
        {:error, "Not found." }
      {:error, %HTTPoison.Error{reason: reason}} ->
        {:error, reason}
    end
  def search(name, s, opts \\ []) do
    search(name, s, "https://oregoncoffeeandtea.com", "Oregon Coffee & Tea", opts)
  end
end

A lib/shop_local/peak_sports_search_provider.ex => lib/shop_local/peak_sports_search_provider.ex +99 -0
@@ 0,0 1,99 @@
defmodule ShopLocal.PeakSportsSearchProvider do
  import ShopLocal.SearchProvider

  def search(name, s, opts \\ []) do
    search_with_cache(name, s, &fetch/1, opts)
  end

  def fetch(s) do
    fetch_url("https://www.peaksportscorvallis.com/sitesearch.cfm?search=#{s}")
  end

  def format_link(link) do
    case String.starts_with?(link, "http") do
      false -> "https://www.peaksportscorvallis.com" <> link
      true ->  link
    end
  end

  @max_redirects 2
  def fetch_url(url, count \\ @max_redirects) do
    case HTTPoison.get(url) do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        {:ok, document} = Floki.parse_document(body)
        results = document
                  |> Floki.find(".seSearchProductsContainer .seProduct")
                  |> Enum.map(fn (k) -> 
                    brand_name = k
                           |> Floki.find(".seProductTitle .seBrandName")
                           |> Floki.text
                           |> String.trim

                    title = k
                           |> Floki.find(".seProductTitle .seCleanTitle")
                           |> Floki.text
                           |> String.trim

                    name = "#{brand_name} - #{title}"
                    link = k
                           |> Floki.find("a.seProductAnchor") 
                           |> Floki.attribute("href")
                           |> List.first
                           |> format_link

                    image_src = k
                                |> Floki.find("img.seResultImage")
                                |> Floki.attribute("src")
                                |> List.first

                    desc = k
                           |> Floki.find(".item-description .text")
                           |> List.last
                           |> Floki.text
                           |> String.trim

                    price_result = k
                      |> Floki.find(".seProductPrice")
                      |> Floki.text
                      |> String.trim
                      |> String.replace(["$", "€"], "")
                      |> String.split(" ")
                      |> List.first
                      |> Float.parse

                    price = case price_result do
                      {res, _rem} -> res
                      :error -> nil
                    end

                      %{
                        provider: "Peak Sports",
                        name: name,
                        desc: desc,
                        price: price,
                        link: link,
                        image_src: image_src
                      } 
                  end)
                  |> Enum.filter(fn 
                    %{ name: "" } -> false
                    %{ image_src: "" } -> false
                    _ -> true
                  end)
          {:ok, results}
        {:ok, %HTTPoison.Response{status_code: 302, headers: headers}} ->
          case List.keyfind(headers, "Location", 0) do
            {"Location", u} -> if count > 0 do
                fetch_url(format_link(u), count-1)
            else
                {:error, "Too many redirects"}
            end
            _ -> {:error, "Redirected to invalid url"}
          end
        {:ok, %HTTPoison.Response{status_code: 404}} ->
          {:error, "Not found." }
        {:error, %HTTPoison.Error{reason: reason}} ->
          {:error, reason}
    end
  end
end

M lib/shop_local/pegasus_search_provider.ex => lib/shop_local/pegasus_search_provider.ex +27 -10
@@ 1,23 1,40 @@
defmodule ShopLocal.PegasusSearchProvider do
  import ShopLocal.SearchProvider

  def search(s) do
  def search(name, s, opts \\ []) do
    search_with_cache(name, s, &fetch/1, opts)
  end

  def fetch(s) do
    case HTTPoison.post("https://pegasusgames.com/shop/search", "s=#{s}&resultsPerPage=10", [
      "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
      "Accept": "application/json, text/javascript, */*; q=0.01",
      "Accept-Encoding": "gzip, deflate, br",
      "Origin": "https://pegasusgames.com",
      "Referer": "https://pegasusgames.com/shop/search?controller=search&s=search",
    ]) do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        body = Poison.decode!(body)
        results = Enum.map(body["products"], fn(k) -> %{
          provider: "Pegasus Games",
          name: HtmlSanitizeEx.strip_tags(k["name"]),
          desc: HtmlSanitizeEx.strip_tags(k["description_short"]),
          price: HtmlSanitizeEx.strip_tags(k["price"]),
          link: HtmlSanitizeEx.strip_tags(k["link"]),
          image_src: HtmlSanitizeEx.strip_tags(k["cover"]["small"]["url"])
        } end)
        results = Enum.map(body["products"], fn(k) -> 
          price_parts = k["price"]
                        |> HtmlSanitizeEx.strip_tags
                        |> String.trim
                        |> String.replace("$", "")
                        |> Float.parse

          price = case price_parts do
            {res, _rem} -> res
            :error -> nil
          end

          %{
            provider: "Pegasus Games",
            name: HtmlSanitizeEx.strip_tags(k["name"]),
            desc: HtmlSanitizeEx.strip_tags(k["description_short"]),
            price: price,
            link: HtmlSanitizeEx.strip_tags(k["link"]),
            image_src: HtmlSanitizeEx.strip_tags(k["cover"]["small"]["url"])
          } 
        end)
        {:ok, results}
      {:ok, %HTTPoison.Response{status_code: 404}} ->
        {:error, "Not found." }

A lib/shop_local/provider_collection.ex => lib/shop_local/provider_collection.ex +136 -0
@@ 0,0 1,136 @@
defmodule ShopLocal.ProviderCollection do

  alias ShopLocal.PegasusSearchProvider
  alias ShopLocal.ToyFactorySearchProvider
  alias ShopLocal.ClothingTreeSearchProvider
  alias ShopLocal.BurstsChocolatesSearchProvider
  alias ShopLocal.OregonCoffeeAndTeaSearchProvider
  alias ShopLocal.ConundrumHouseSearchProvider
  alias ShopLocal.RobnettsSearchProvider
  alias ShopLocal.RestyleCorvallisSearchProvider
  alias ShopLocal.RestyleAlbanySearchProvider
  alias ShopLocal.MerriArtistSearchProvider
  alias ShopLocal.BrowsersCorvallisSearchProvider
  alias ShopLocal.BrickCircuitSearchProvider
  alias ShopLocal.PeakSportsSearchProvider
  alias ShopLocal.GrassRootsSearchProvider


  @type provider_info :: %{
    name: atom(),
    provider: ShopLocal.SearchProviderBehavior,
    tags: [atom()],
    location: atom()
  }

  @providers [
    %{name: :toy_factory,
      provider: ToyFactorySearchProvider,
      tags: [:toys_and_games],
      location: :corvallis},

    %{name: :pegasus,
      provider: PegasusSearchProvider,
      tags: [:toys_and_games],
      location: :corvallis},

    %{name: :clothing_tree,
      provider: ClothingTreeSearchProvider,
      tags: [:clothes],
      location: :corvallis},

    %{name: :bursts,
      provider: BurstsChocolatesSearchProvider,
      tags: [:treats],
      location: :corvallis},

    %{name: :oregon_coffee,
      provider: OregonCoffeeAndTeaSearchProvider,
      tags: [:treats],
      location: :corvallis},

    %{name: :conundrum,
      provider: ConundrumHouseSearchProvider,
      tags: [:toys_and_games],
      location: :corvallis
    },

    %{name: :robnetts,
      provider: RobnettsSearchProvider,
      tags: [:hardware_and_tools],
      location: :corvallis
    },

    %{name: :restyle_corvallis,
      provider: RestyleCorvallisSearchProvider,
      tags: [:clothes],
      location: :corvallis
    },

    %{name: :restyle_albany,
      provider: RestyleAlbanySearchProvider,
      tags: [:clothes],
      location: :albany
    },

    %{name: :merri_artist,
      provider: MerriArtistSearchProvider,
      tags: [:art_supplies],
      location: :mcminnville
    },

    %{name: :browsers_corvallis,
      provider: BrowsersCorvallisSearchProvider,
      tags: [:books],
      location: :corvallis
    },

    %{name: :brick_circuit,
      provider: BrickCircuitSearchProvider,
      tags: [:toys_and_games],
      location: :albany
    },

    %{name: :peak_sports,
      provider: PeakSportsSearchProvider,
      tags: [:sporting_goods],
      location: :corvallis
    },

    %{name: :grass_roots,
      provider: GrassRootsSearchProvider,
      tags: [:books],
      location: :corvallis
    }
    ]

  @tags Map.keys(Enum.reduce(@providers, %{}, fn(k, acc) ->
    Enum.reduce(k.tags, acc, &Map.put(&2, &1, true))
  end));

  @locations Map.keys(Enum.reduce(@providers, %{}, fn(k, acc) ->
    Map.put(acc, k.location, true)
  end));

  @provider_len Enum.count(@providers)

  @spec providers() :: [provider_info]
  def providers(), do: @providers

  @spec matching_providers([atom()], [atom()]) :: [provider_info]
  def matching_providers(locations, active_tags) do
    Enum.filter(@providers, fn (k) -> 
      Enum.member?(locations, k.location)
      && Enum.any?(active_tags, &Enum.member?(k.tags, &1))
    end)
  end

  def tags(), do: @tags

  def locations(), do: @locations

end

defmodule ShopLocal.SearchProviderBehavior do
  @callback search(name :: atom(), search :: String.t()) :: any
end

A lib/shop_local/restyle_albany_search_provider.ex => lib/shop_local/restyle_albany_search_provider.ex +8 -0
@@ 0,0 1,8 @@
defmodule ShopLocal.RestyleAlbanySearchProvider do
  import ShopLocal.SearchProvider
  import ShopLocal.SquarespaceMetaProvider

  def search(name, s, opts \\ []) do
    search(name, s, "https://www.restylealbany.com", "ReStyle Albany", opts)
  end
end

M lib/shop_local/restyle_corvallis_search_provider.ex => lib/shop_local/restyle_corvallis_search_provider.ex +4 -30
@@ 1,34 1,8 @@
defmodule ShopLocal.RestyleCorvallisSearchProvider do
  import ShopLocal.SearchProvider
  import ShopLocal.SquarespaceMetaProvider

  def search(s) do
    case HTTPoison.get("https://www.restylecorvallis.com/api/search/GeneralSearch?&q=#{s}&p=0") do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        body = Poison.decode!(body)
        results = body["items"]
                  |> Enum.filter(fn
                    %{"recordTypeName" => "store-item"} -> true
                    _ -> false
                  end)
                  |> Enum.map(fn(k) -> 
                    link = HtmlSanitizeEx.strip_tags(k["itemUrl"])
                    link = case String.starts_with?(link, "http") do
                      false -> "https://www.restylecorvallis.com" <> link
                      true ->  link
                    end
                      %{
                        provider: "Restyle (Corvallis)",
                        name: HtmlSanitizeEx.strip_tags(k["title"]),
                        desc: HtmlSanitizeEx.strip_tags(k["exerpt"]),
                        price: "$XXX",
                        link: link,
                        image_src: HtmlSanitizeEx.strip_tags(k["imageUrl"])
                      } 
                  end)
        {:ok, results}
      {:ok, %HTTPoison.Response{status_code: 404}} ->
        {:error, "Not found." }
      {:error, %HTTPoison.Error{reason: reason}} ->
        {:error, reason}
    end
  def search(name, s, opts \\ []) do
    search(name, s, "https://www.restylecorvallis.com", "ReStyle Corvallis",  opts)
  end
end

M lib/shop_local/robnetts_search_provider.ex => lib/shop_local/robnetts_search_provider.ex +32 -21
@@ 1,6 1,11 @@
defmodule ShopLocal.RobnettsSearchProvider do
  import ShopLocal.SearchProvider

  def search(s) do
  def search(name, s, opts \\ []) do
    search_with_cache(name, s, &fetch/1, opts)
  end

  def fetch(s) do
    case HTTPoison.get("https://www.catalog-display.com/index.aspx?StId=1554&ShId=097fae5a200343bbb7228355c8b1597a&tab=21&Qtype=3&SearchBy=KeyWord&Val=#{s}") do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        {:ok, document} = Floki.parse_document(body)


@@ 18,42 23,48 @@ defmodule ShopLocal.RobnettsSearchProvider do
                    image_src = k
                                |> Floki.find("a.link img")
                                |> Floki.attribute("src")
                                |> List.first

                    desc = k
                           |> Floki.find("*[id$=\"lblVendor\"],")
                           |> Floki.text

                    price = k
                      |> Floki.find(".cprice")
                      |> Floki.text
                      |> String.replace(["Price", "BAG"], "")
                      |> String.replace("USD ", "$")
                      |> String.split("/")
                      |> List.first
                    price_result = k
                                   |> Floki.find(".cprice")
                                   |> Floki.text
                                   |> String.trim
                                   |> String.replace(["USD ", "$", "Price", "BAG"], "")
                                   |> String.split("/")
                                   |> List.first
                                   |> Float.parse

                    price = case price_result do
                      {res, _rem} -> res
                      :error -> nil
                    end

                    link = case String.starts_with?(link, "http") do
                      false -> "https://www.catalog-display.com" <> link
                      true ->  link
                    end

                      %{
                        provider: "Robnetts",
                        name: name,
                        desc: desc,
                        price: price,
                        link: link,
                        image_src: image_src
                      } 
                    %{
                      provider: "Robnetts",
                      name: name,
                      desc: desc,
                      price: price,
                      link: link,
                      image_src: image_src
                    } 
                  end)
                  |> Enum.filter(fn 
                    %{ name: "" } -> false
                    _ -> true
                    %{ image_src: "" } -> false
                    res -> !String.ends_with?(res.image_src, "0000000.jpg")
                  end)
        {:ok, results}
        {:ok, %HTTPoison.Response{status_code: 404}} ->
        {:error, "Not found." }
        {:error, %HTTPoison.Error{reason: reason}} ->
        {:error, reason}
        {:ok, %HTTPoison.Response{status_code: 404}} -> {:error, "Not found." }
        {:error, %HTTPoison.Error{reason: reason}} -> {:error, reason}
    end
  end
end

A lib/shop_local/search_provider.ex => lib/shop_local/search_provider.ex +23 -0
@@ 0,0 1,23 @@
defmodule ShopLocal.SearchProvider do
  alias ShopLocal.CacheProvider

  def search_with_cache(name, s, fun, opts \\ []) do
    cache_name = :"#{name}_cache"
    use_cache = case List.keyfind(opts, :use_cache, 0) do
        {:use_cache, true} -> true
        {:use_cache, "true"} -> true
        _ -> false
    end
    case {use_cache, CacheProvider.search(cache_name, s)} do
      {true, {:found, results}} ->
        {:ok, results}
      _ ->
        case fun.(s) do
          {:ok, results} ->
            CacheProvider.add(cache_name, results, s)
            {:ok, results}
          err -> err
        end
    end
  end
end

A lib/shop_local/search_query.ex => lib/shop_local/search_query.ex +42 -0
@@ 0,0 1,42 @@
defmodule ShopLocal.SearchQuery do
#   use Ecto.Schema
#   import Ecto.Changeset
#   alias ShopLocal.ProviderCollection
#   alias ShopLocal.SearchQuery
# 
#   
#   @schema %{
#     search: :string,
#     toys: :boolean,
#     games: :boolean,
#     clothes: :boolean,
#     treats: :boolean,
#     candy: :boolean,
#     coffee: :boolean,
#     tea: :boolean,
#     experiences: :boolean,
#     corvallis: :boolean,
#     albany: :boolean
#   }
# 
#   schema "search_query" do
#     field :search, :string
#     field :toys, :boolean, default: true
#     field :games, :boolean, default: true 
#     field :clothes, :boolean, default: true
#     field :treats, :boolean, default: true
#     field :candy, :boolean, default: true
#     field :coffee, :boolean, default: true
#     field :tea, :boolean, default: true
#     field :experiences, :boolean, default: true
#     field :corvallis, :boolean, default: true
#     field :albany, :boolean, default: true
#   end
# 
#   @members ProviderCollection.locations ++ ProviderCollection.locations ++ [:search]
# 
#   def changeset(search_q \\ %SearchQuery{}, attrs \\ %{}) do
#     search_q
#     |> cast(attrs, @members)
#   end
end

A lib/shop_local/squarespace_meta_provider.ex => lib/shop_local/squarespace_meta_provider.ex +39 -0
@@ 0,0 1,39 @@
defmodule ShopLocal.SquarespaceMetaProvider do
  import ShopLocal.SearchProvider

  def search(name, s, url, provider_name, opts \\ []) do
    search_with_cache(name, s, fn(s) -> fetch(s, url, provider_name) end, opts)
  end

  def fetch(s, url, provider_name) do
    case HTTPoison.get("#{url}/api/search/GeneralSearch?&q=#{s}&p=0") do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        body = Poison.decode!(body)
        results = body["items"]
                  |> Enum.filter(fn
                    %{"recordTypeName" => "store-item"} -> true
                    _ -> false
                  end)
                  |> Enum.map(fn(k) -> 
                    link = HtmlSanitizeEx.strip_tags(k["itemUrl"])
                    link = case String.starts_with?(link, "http") do
                      false -> url <> link
                      true ->  link
                    end
                      %{
                        provider: provider_name,
                        name: HtmlSanitizeEx.strip_tags(k["title"]),
                        desc: HtmlSanitizeEx.strip_tags(k["exerpt"]),
                        price: nil,
                        link: link,
                        image_src: HtmlSanitizeEx.strip_tags(k["imageUrl"])
                      } 
                  end)
        {:ok, results}
      {:ok, %HTTPoison.Response{status_code: 404}} ->
        {:error, "Not found." }
      {:error, %HTTPoison.Error{reason: reason}} ->
        {:error, reason}
    end
  end
end

M lib/shop_local/toy_factory_search_provider.ex => lib/shop_local/toy_factory_search_provider.ex +14 -2
@@ 1,6 1,11 @@
defmodule ShopLocal.ToyFactorySearchProvider do
  import ShopLocal.SearchProvider

  def search(s) do
  def search(name, s, opts \\ []) do
    search_with_cache(name, s, &fetch/1, opts)
  end

  def fetch(s) do
    case HTTPoison.get("https://www.thetoyfactory.us/?s=#{s}") do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        {:ok, document} = Floki.parse_document(body)


@@ 23,15 28,22 @@ defmodule ShopLocal.ToyFactorySearchProvider do
                    image_src = k
                                |> Floki.find(".post-image img")
                                |> Floki.attribute("src")
                                |> List.first
                                |> String.trim
                    %{
                      provider: "Toy Factory",
                      name: name,
                      desc: desc,
                      price: "$XXX",
                      price: nil,
                      link: link,
                      image_src: image_src
                    } 
                  end)
                  |> Enum.filter(fn
                    %{image_src: ""} -> false
                    %{image_src: nil} -> false
                    _ -> true
                  end)
      {:ok, results}
      {:ok, %HTTPoison.Response{status_code: 404}} ->
      {:error, "Not found." }

M lib/shop_local_web/controllers/page_controller.ex => lib/shop_local_web/controllers/page_controller.ex +27 -6
@@ 2,20 2,41 @@ defmodule ShopLocalWeb.PageController do
  use ShopLocalWeb, :controller
  alias ShopLocalWeb.PageController
  alias ShopLocal.CombinedSearch
  alias ShopLocal.ProviderCollection

  require Logger

  @locations ProviderCollection.locations
  @tags ProviderCollection.tags
  @fields Enum.reduce(
    ProviderCollection.locations
    ++ ProviderCollection.tags,
    %{},
    &(Map.put(&2, Atom.to_string(&1), true))
  )

  def index(conn, _params) do
    render(conn, "index.html")
    render(conn, "index.html", %{ search: @fields })
  end

  def search(conn, %{ "search" => s }) do
    case CombinedSearch.search(URI.encode_www_form(s)) do
  def search(conn, %{ "search" => "" } = fields) do
    render(conn, "index.html", %{results: [], search: fields })
  end
  def search(conn, %{ "search" => s } = fields) do
    case CombinedSearch.search(URI.encode_www_form(s), fields) do
      { :ok, results } ->
        render(conn, "index.html", %{results: results})
        render(conn, "index.html", %{results: results, search: fields})
      { :error, message } ->
        Logger.warn(message);
        render(conn, "index.html", %{results: []})
        Logger.warn("#{message}");
        render(conn, "index.html", %{results: [], search: fields})
    end
  end
  def search(conn, _) do
    render(conn, "index.html", %{results: [], search: @fields })
  end

  def about(conn, _) do
    render(conn, "about.html")
  end

end

M lib/shop_local_web/router.ex => lib/shop_local_web/router.ex +2 -1
@@ 23,7 23,8 @@ defmodule ShopLocalWeb.Router do
    pipe_through :browser

    get "/", PageController, :index
    post "/", PageController, :search
    get "/search", PageController, :search
    get "/about", PageController, :about
  end

  scope "/.well-known/acme-challenge", ShopLocalWeb do

M lib/shop_local_web/templates/layout/app.html.eex => lib/shop_local_web/templates/layout/app.html.eex +1 -0
@@ 13,6 13,7 @@
      <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
      <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
      <%= @inner_content %>
      <footer>Source code available <a href="https://git.sr.ht/~electric/shop-oregon-local">here</a></footer>
    </main>
  </body>
</html>

A lib/shop_local_web/templates/page/about.html.eex => lib/shop_local_web/templates/page/about.html.eex +36 -0
@@ 0,0 1,36 @@
<section class="row">
  <article class="column">
    <a href="/">&lt; back</a>
    <h2>About</h2>
    <h3>This is a simple meta-search engine for ecommerce in the Willamette Valley.</h3>
    <p>
    The idea was born out of the frustration of trying to check if there was a "local" way to buy a particular board game. Since there was no simple way to check this, I decided that it was time to create a search aggregator.
    </p>
    <p>
    Amazon, Wallmart and other large ecommerce platforms have an indisputable edge given most people don't even know where to start if they want to shop locally online. Search engines are full of ads and non-local product results. This website is here to remove the barriers that consumers might face shopping for local products online.
    </p>

    <p>
    Currently the central focus is on Corvallis and Albany, but I'm looking to expand to other local businesses in other areas, provided there is interest and I have time.
    </p>
    <h3>How it works</h3>
    <p>
    I have curated a list of small local businesses with websites that can be searched.
    When you make a search :
    <ol>
      <li>We check if we have cached results that match (no need to make duplicate requests)</li>
      <li>Make requests to all businesses enabled in the search options searching for your query</li>
      <li>We aggregate all the results and shuffle them for good measure</li>
      <li>Finally, we display all the results to you!</li>
    </ol>
    <h3>Contact me</h3>
    <p>
    My name is Micah, you can find my contact information on my <a href="https://micah.dvyld.com">personal website</a>.
    </p>


    <h3>License</h3>
    All the code that runs this website is open-source and licensed under <a href="https://www.gnu.org/licenses/agpl-3.0.en.html">AGPL</a>.
    </p>
  </article>
</section>

M lib/shop_local_web/templates/page/index.html.eex => lib/shop_local_web/templates/page/index.html.eex +71 -7
@@ 1,7 1,39 @@
<section class="phx-hero">
  <h3>Shop Local</h3>
  <%= form_for @conn, Routes.page_path(@conn, :search), fn f -> %>
  <%= form_for @conn, Routes.page_path(@conn, :search), [method: "get", class: "optional-checkboxes"], fn f -> %>
    <%= text_input f, :search, placeholder: "What are you looking for?" %>

    <div class="toggle" toggle-for=".search-options">
      <div selected>+ Search Options</div>
      <div>- Search Options</div>
    </div>
    <div class="search-options" display-type="flex">
      <div class="location-options">
        <h4>Store Locations</h4>
        <%= for l <- locations do %>
          <div class="label-group">
            <%= checkbox f, l, checked: as_bool(@search[l]) %>
            <%= label f, l, to_label(l) %>
          </div>
        <% end %>
      </div>
      <div class="tag-options">
        <h4>Store Categories</h4>
        <%= for t <- tags do %>
          <div class="label-group">
            <%= checkbox f, t, checked: as_bool(@search[t]) %>
            <%= label f, t, to_label(t) %>
          </div>
        <% end %>
      </div>
      <div class="other-options">
        <h4>Other Options</h4>
        <div class="label-group">
          <%= checkbox f, :use_cache, checked: as_bool(@search["use_cache"]) %>
          <%= label f, :use_cache, to_label("use_cache") %>
        </div>
      </div>
    </div>
    <%= submit "Search" %>
  <% end %>
</section>


@@ 12,7 44,7 @@
      <li>
        <a href="<%= i.link %>">
          <b><%= i.name %></b>
          <i><%= i.price %></i>
          <i><%= view_price(i.price) %></i>
          &mdash; <%= i.provider %>
          <div class="product-content">
            <div class="product-image-container">


@@ 40,37 72,69 @@
    <h2>Regions</h2>
    <ul>
      <li>
        <a href="https://business.albanychamber.com/list/">Albany</a>
      </li>
      <li>
        <a href="https://www.visitcorvallis.com/shopping">Corvallis</a>
      </li>
      <li>
        <a href="https://cm.mcminnville.org/list">McMinnville</a>
      </li>
    </ul>
  </article>
  <article class="column">
    <h2>Stores</h2>
    <ul>
      <li>
        <a href="https://pegasusgames.com">Pegasus Games</a>
        <a href="https://burstschocolates.com/">Bursts Chocolates</a>
      </li>
      <li>
        <a href="https://www.clothes-tree.com/">The Clothes Tree</a>
        <a href="https://www.browsersbookstore.com/">Browsers Bookstore (Corvallis)</a>
      </li>
      <li>
        <a href="https://www.thetoyfactory.us">The Toy Factory</a>
        <a href="https://grassrootsbookstore.com/">Grassroots Bookstore</a>
      </li>
      <li>
        <a href="https://www.restylecorvallis.com/shop">Restyle Corvallis</a>
        <a href="https://merriartist.com/">The Merri Artist</a>
      </li>
      <li>
        <a href="https://burstschocolates.com/">Bursts Chocolates</a>
        <a href="https://www.clothes-tree.com/">The Clothes Tree</a>
      </li>
      <li>
        <a href="https://conundrum.house/">Conundrum House</a>
      </li>
      <li>
        <a href="https://merriartist.com/">The Merri Artist</a>
      </li>
      <li>
        <a href="https://oregoncoffeeandtea.com/">Oregon Coffee &amp; Tea</a>
      </li>
      <li>
        <a href="https://www.peaksportscorvallis.com">Peak Sports</a>
      </li>
      <li>
        <a href="https://pegasusgames.com">Pegasus Games</a>
      </li>
      <li>
        <a href="https://www.restylealbany.com/">ReStyle Albany</a>
      </li>
      <li>
        <a href="https://www.restylecorvallis.com/">ReStyle Corvallis</a>
      </li>
      <li>
        <a href="http://www.robnettshardware.com/">Robnett's Hardware</a>
      </li>
      <li>
        <a href="https://www.thetoyfactory.us">The Toy Factory</a>
      </li>
    </ul>
  </article>
  <article class="column">
    <h2>More Info</h2>
    <ul>
      <li>
        <a href="/about">About this site</a>
      </li>
    </ul>
  </article>
</section>

M lib/shop_local_web/views/page_view.ex => lib/shop_local_web/views/page_view.ex +29 -0
@@ 1,3 1,32 @@
defmodule ShopLocalWeb.PageView do
  use ShopLocalWeb, :view
  alias ShopLocal.ProviderCollection

  @locations ProviderCollection.locations
  @tags ProviderCollection.tags

  def locations, do: Enum.map(@locations, &Atom.to_string(&1))
  def tags, do: Enum.map(@tags, &Atom.to_string(&1))

  @spec to_label(atom()) :: String.t()
  def to_label(a), do: a
          |> String.capitalize
          |> String.replace("and", "&")
          |> String.replace("_", " ")

  def as_bool(a) do
    case a do
      false -> false
      "false" -> false
      _ -> true
    end
  end

  def view_price(a) do
    if a == nil do
      "$___"
    else
      "$" <> Float.to_string(a, decimals: 2)
    end
  end
end

M mix.exs => mix.exs +1 -1
@@ 5,7 5,7 @@ defmodule ShopLocal.MixProject do
    [
      app: :shop_local,
      version: "0.1.0",
      elixir: "~> 1.7",
      elixir: "~> 1.9",
      elixirc_paths: elixirc_paths(Mix.env()),
      compilers: [:phoenix, :gettext] ++ Mix.compilers(),
      start_permanent: Mix.env() == :prod,

M mix.lock => mix.lock +3 -0
@@ 4,6 4,8 @@
  "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"},
  "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"},
  "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"},
  "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
  "ecto": {:hex, :ecto, "3.5.5", "48219a991bb86daba6e38a1e64f8cea540cded58950ff38fbc8163e062281a07", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "98dd0e5e1de7f45beca6130d13116eae675db59adfa055fb79612406acf6f6f1"},
  "file_system": {:hex, :file_system, "0.2.9", "545b9c9d502e8bfa71a5315fac2a923bd060fd9acb797fe6595f54b0f975fd32", [:mix], [], "hexpm", "3cf87a377fe1d93043adeec4889feacf594957226b4f19d5897096d6f61345d8"},
  "floki": {:hex, :floki, "0.29.0", "b1710d8c93a2f860dc2d7adc390dd808dc2fb8f78ee562304457b75f4c640881", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "008585ce64b9f74c07d32958ec9866f4b8a124bf4da1e2941b28e41384edaaad"},
  "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"},


@@ 19,6 21,7 @@
  "mochiweb": {:hex, :mochiweb, "2.20.1", "e4dbd0ed716f076366ecf62ada5755a844e1d95c781e8c77df1d4114be868cdf", [:rebar3], [], "hexpm", "d1aeee7870470d2fa9eae0b3d5ab6c33801aa2d82b10e9dade885c5c921b36aa"},
  "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"},
  "phoenix": {:hex, :phoenix, "1.5.6", "8298cdb4e0f943242ba8410780a6a69cbbe972fef199b341a36898dd751bdd66", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0dc4d39af1306b6aa5122729b0a95ca779e42c708c6fe7abbb3d336d5379e956"},
  "phoenix_ecto": {:hex, :phoenix_ecto, "4.2.1", "13f124cf0a3ce0f1948cf24654c7b9f2347169ff75c1123f44674afee6af3b03", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "478a1bae899cac0a6e02be1deec7e2944b7754c04e7d4107fc5a517f877743c0"},
  "phoenix_html": {:hex, :phoenix_html, "2.14.2", "b8a3899a72050f3f48a36430da507dd99caf0ac2d06c77529b1646964f3d563e", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "58061c8dfd25da5df1ea0ca47c972f161beb6c875cd293917045b92ffe1bf617"},
  "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.3.6", "6d031e9e5fa8c671e582539e8acd549c4d6e0e90aa704f6644a4a1f5fb334608", [:mix], [{:ecto_psql_extras, "~> 0.4.1 or ~> 0.5", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.1 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.14.3", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.4.0 or ~> 0.5.0 or ~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "55c891eb9cb344d6685c21f452806f54be6a660bbc090c94f65f287e8b4de002"},
  "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.0", "f35f61c3f959c9a01b36defaa1f0624edd55b87e236b606664a556d6f72fd2e7", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "02c1007ae393f2b76ec61c1a869b1e617179877984678babde131d716f95b582"},