Packages

A review webscraping library tailored for dealerrater.com

Current section

Files

Jump to
evaluate_review lib evaluate_review.ex
Raw

lib/evaluate_review.ex

defmodule EvaluateReview do
@moduledoc """
Documentation for `EvaluateReview`.
"""
@doc """
cache review results
# TODO encode this as JSON rather than binary
credit to https://elixirforum.com/u/benwilson512
Caches review lists as binary data so as to avoid unnecessary
web scraping and to minimize suspicion
"""
@spec cache(list[tuple], String.t()) :: :ok
def cache(review_list, filename) do
bytes = :erlang.term_to_binary(review_list)
{:ok, cache} = File.open(filename, [:write])
IO.binwrite(cache, bytes)
end
@doc """
load cache review results
credit to https://elixirforum.com/u/benwilson512
"""
@spec load_from_cache(String.t()) :: list[tuple]
def load_from_cache(filename) do
bytes = File.read!(filename)
review_list = :erlang.binary_to_term(bytes)
review_list
end
@doc """
Read Json from File
credit to https://elixirforum.com/u/idi527
## Examples
iex> filename = "/tmp/test.json"
iex> EvaluateReview.read_json(filename)
"""
@spec read_json(String.t()) :: map
def read_json(filename) do
with {:ok, body} <- File.read(filename), {:ok, json} <- Jason.decode(body), do: {:ok, json}
end
@doc """
Uses Floki to recursively match a given list of selectors. Often times css selectors are used for
many different tags on a page. The combination of several helps the user to narrow down their
selection to a single tag.
"""
@spec match_selectors(list, list) :: list[{tuple, tuple}]
def match_selectors([head | tail], document) do
result = Floki.find(document, head)
match_selectors(tail, result)
end
def match_selectors([], result) do
result
end
@doc ~S"""
Scrape Dealer Rater Reviews
This function attempts to scrape reviews from the passed in url and returns a list of tuples,
the first element being the review itself and the second element containing the username of
the reviewer.
The simple css selectors employed are .review-content for the content of the review itself,
and the combination of .italic and .font-18 for the username of the reviewer. This is an
intentionally chosen shortcut. A slightly more robust approach might use the .review-container
selector instead since it would seem less likely to change. I found it relatively bloated and
so opted for a quicker approach that felt more elegant.
A future approach might include user-defined selectors rather than hard coded ones, but as
the use case is currently very narrowly defined (solely scraping reviews from deallerrater.com)
this approach seemed unnecessarily complicated.
## Examples
iex> url = "https://web.archive.org/web/20201127110830/https://www.dealerrater.com/dealer/McKaig-Chevrolet-Buick-A-Dealer-For-The-People-dealer-reviews-23685/"
iex> reviews = EvaluateReview.scrape(url, [])
iex> reviews |> Enum.with_index() |> Enum.each(fn {{a, b},_} -> IO.puts("review: #{a}, reviewer: #{b}") end)
"""
@defaults %{review: [".review-content"], reviewer: [".italic", ".font-18"]}
@spec scrape(String.t(), map) :: list[tuple]
def scrape(url, selectors) do
case HTTPoison.get(url) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
{:ok, document} = Floki.parse_document(body)
%{review: review, reviewer: reviewer} = Enum.into(selectors, @defaults)
reviews = match_selectors(review, document)
reviewers = match_selectors(reviewer, document)
zipped_reviews = Enum.zip(reviews, reviewers)
stripped_reviews =
zipped_reviews
|> Enum.map(fn {{_HTML_tag, _class_info, review_content},
{_tag, _class, reviewer_content}} ->
{review_content, reviewer_content}
end)
stripped_reviews
{:ok, %HTTPoison.Response{status_code: 404}} ->
IO.puts("Not found :(")
{:error, %HTTPoison.Error{reason: reason}} ->
IO.inspect(reason)
end
end
@doc """
Scrape a list of urls
"""
@spec scrape_n(list[String.t()], map) :: list[tuple]
def scrape_n(urls, selectors) do
s_map = Enum.into(selectors, @defaults)
reviews = List.flatten(Enum.map(urls, fn x -> scrape(x, s_map) end))
reviews
end
@spec defaultSuspector(tuple) :: tuple
defp defaultSuspector({a, b}) do
countExclamation = to_string(a) |> String.graphemes() |> Enum.count(&(&1 == "!"))
{{a, b}, countExclamation}
end
@doc """
Classify overly positive reviews
Takes a list of reviews in the format produced by EvaluateReview.scrape(url, [])
Produces a list of the top three offenders ordered by severity
Current criteria for a suspicious review is simply based on a count of the number
of exclamation points included in the review
Tried passing the defaultSuspector function into suspect_reviews. Unfortunately,
there's no way I could find to define function is this module AND make the function
available in such a manner
https://elixirforum.com/t/proposal-private-modules-general-discussion/19374/154
As such, a user could define their own suspector functions and pass them to
suspect_review, but I can't seem to define them within this module
## Examples
iex> url = "https://web.archive.org/web/20201127110830/https://www.dealerrater.com/dealer/McKaig-Chevrolet-Buick-A-Dealer-For-The-People-dealer-reviews-23685/"
iex> reviews = EvaluateReview.scrape(url, [])
iex> top3 = EvaluateReview.suspect_reviews(reviews)
iex> IO.inspect(top3)
"""
@spec suspect_reviews(list[tuple], function) :: list[tuple]
def suspect_reviews(reviews, suspector \\ nil) do
rated_reviews =
if(is_function(suspector) == false) do
reviews |> Enum.map(fn {a, b} -> defaultSuspector({a, b}) end)
else
reviews |> Enum.map(fn {a, b} -> suspector.({a, b}) end)
end
top_three =
Enum.reverse(
rated_reviews
|> List.keysort(1)
|> Enum.take(-3)
)
top_three
end
end