Commit ba17fd8c authored by Mikko Ahlroth's avatar Mikko Ahlroth

Progress

parent 21543a63
......@@ -37,3 +37,9 @@ config :tilastokeskus,
config :tilastokeskus, Tilastokeskus.Archive.Repo,
url: "ecto://tilastokeskus:tilastokeskus@localhost/tilastokeskus",
types: Tilastokeskus.Archive.PostgresTypes
config :ua_inspector,
# This is for the DB download mix tasks
database_path: "priv/ua",
# This is for runtime configuration
init: {Tilastokeskus.Reception.UADetector, :init}
defmodule Tilastokeskus.Archive.Schemas.Events do
defmodule Tilastokeskus.Archive.Schemas.Event do
@moduledoc """
An event that can be a pageview, or some other action by the user or backend.
"""
require Tilastokeskus.Archive.Schemas.EventCommons
use Ecto.Schema
import Ecto.Changeset
schema "events" do
Tilastokeskus.Archive.Schemas.EventCommons.fields()
end
@doc """
Get a changeset for creating a new event.
"""
@spec changeset(Ecto.Schema.t(), map) :: Ecto.Changeset.t()
def changeset(data, params \\ %{}) do
data
|> cast(params, [:at, :type, :addr, :extra])
|> validate_required([:at])
|> put_assoc(:session, params[:session])
end
end
......@@ -5,10 +5,20 @@ defmodule Tilastokeskus.Archive.Schemas.PageView do
require Tilastokeskus.Archive.Schemas.EventCommons
use Ecto.Schema
import Ecto.Changeset
schema "events" do
Tilastokeskus.Archive.Schemas.EventCommons.fields()
# Full request path including query string
field(:path, :string)
# Request path without query string
field(:path_noq, :string)
# Request host header
field(:host, :string)
# Full HTTP referrer
field(:referrer, :string)
# Referrer without query string
......@@ -18,9 +28,12 @@ defmodule Tilastokeskus.Archive.Schemas.PageView do
# Full user agent string
field(:ua, :string)
# User agent scrubbed to only contain the user agent name and version
field(:scrubbed_ua, :string)
field(:scrubbed_ua_version, :string)
field(:ua_name, :string)
field(:ua_version, :string)
field(:os_name, :string)
field(:os_version, :string)
field(:device_type, :string)
# Screen size
field(:screen_w, :integer)
......@@ -35,4 +48,37 @@ defmodule Tilastokeskus.Archive.Schemas.PageView do
field(:loc_city, :string)
field(:loc_country, :string)
end
@doc """
Get the event type for page views.
"""
@spec event_type() :: String.t()
def event_type(), do: "page_view"
@spec changeset(Ecto.Schema.t(), map) :: Ecto.Changeset.t()
def changeset(data, params \\ %{}) do
Tilastokeskus.Archive.Schemas.Event.changeset(data, params)
|> cast(params, [
:path,
:path_noq,
:host,
:referrer,
:referrer_noq,
:referrer_domain,
:ua,
:ua_name,
:ua_version,
:os_name,
:os_version,
:device_type,
:screen_w,
:screen_h,
:tz_offset,
:loc_lat,
:loc_lon,
:loc_city,
:loc_country
])
|> put_change(:type, event_type())
end
end
......@@ -4,10 +4,28 @@ defmodule Tilastokeskus.Archive.Schemas.Session do
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type Ecto.UUID
schema "sessions" do
field(:opened_at, :utc_datetime)
end
@doc """
Get a changeset for creating a new session.
"""
@spec changeset(Ecto.Schema.t(), map) :: Ecto.Changeset.t()
def changeset(data, params \\ %{}) do
data
|> cast(params, [])
|> put_change(:opened_at, DateTime.utc_now())
end
@doc """
Maximum valid time of a session in seconds, after this time a new session is created
"""
@spec max_session_validity() :: integer
def max_session_validity(), do: 60 * 15
end
defmodule Tilastokeskus.Archive.Utils.PageView do
@moduledoc """
Pageview related utilities.
"""
alias Tilastokeskus.Archive.Repo
alias Tilastokeskus.Archive.Schemas.PageView
@doc """
Create new pageview from changeset.
"""
@spec create(Ecto.Changeset.t()) :: {:ok, %PageView{}} | {:error, term}
def create(changeset) do
Repo.insert(changeset)
end
end
defmodule Tilastokeskus.Archive.Utils.Session do
@moduledoc """
Session related utilities.
"""
import Ecto.Query, only: [from: 2]
alias Tilastokeskus.Archive.Repo
alias Tilastokeskus.Archive.Schemas.{Session, Event}
@doc """
Return a non-expired session by ID, if one exists, otherwise returns nil. Event model is used
to check for the latest hit of the session to see if it's expired.
"""
@spec find_by_id(String.t()) :: %Session{} | nil
def find_by_id(id) do
res =
from(
s in Session,
left_join: e in Event,
on: e.session_id == s.id,
where: s.id == ^id,
group_by: s.id,
select: {s, max(e.at)}
)
|> Repo.one()
case res do
nil ->
nil
{session, at} ->
now = DateTime.utc_now()
# If session has no hits for some reason, use opened_at date in place of "at"
at = if not is_nil(at), do: at, else: session.opened_at
if DateTime.diff(now, at) <= Session.max_session_validity() do
session
else
nil
end
end
end
@doc """
Create new session.
"""
@spec create() :: {:ok, %Session{}} | {:error, term}
def create() do
cset = Session.changeset(%Session{}, %{})
Repo.insert(cset)
end
end
......@@ -3,6 +3,9 @@ defmodule Tilastokeskus.Reception.Router do
use Raxx.Logger
use Raxx.Router, [
{%{method: :POST, path: ["track"]}, Tilastokeskus.Reception.Routes.PageView},
{_, Tilastokeskus.Reception.Routes.NotFound}
]
use Raxx.Static, "../../priv/static"
end
defmodule Tilastokeskus.Reception.Routes.PageView do
use Raxx.Server
alias Tilastokeskus.Archive.Schemas.PageView
require Logger
@impl Raxx.Server
def handle_request(req, _state) do
at = DateTime.utc_now()
addr = get_addr(req)
ua = parse_ua(req)
{referrer, referrer_noq, referrer_domain} = parse_referrer(req)
body = URI.decode_query(req.body || "")
{path, path_noq, host} = parse_url(body)
# Run in one transaction to avoid multiple DB checkouts
{:ok, response} =
Tilastokeskus.Archive.Repo.transaction(fn ->
session = get_session(req)
cset =
PageView.changeset(
%PageView{},
%{
at: at,
session: session,
addr: addr,
path: path,
path_noq: path_noq,
host: host,
referrer: referrer,
referrer_noq: referrer_noq,
referrer_domain: referrer_domain,
ua: unknown_2_str(ua.user_agent),
ua_name: unknown_2_str(ua.client.name),
ua_version: unknown_2_str(ua.client.version),
os_name: unknown_2_str(ua.os.name),
os_version: unknown_2_str(ua.os.version),
device_type: unknown_2_str(ua.device.type)
}
)
case Tilastokeskus.Archive.Utils.PageView.create(cset) do
{:ok, _} ->
response(200)
|> set_header("content-type", "application/json")
|> set_body(Jason.encode!(%{ok: "OK"}))
|> Raxx.Session.SignedCookie.embed(
session.id,
Tilastokeskus.Reception.Session.config()
)
{:error, err} ->
Logger.error("Error saving pageview: #{inspect(err)}")
response(500)
|> set_header("content-type", "application/json")
|> set_body(Jason.encode!(%{error: "ERROR"}))
end
end)
response
end
defp get_addr(req) do
case Raxx.get_header(req, "x-forwarded-for") do
nil ->
get_ace_ip()
ip ->
String.split(ip, ",")
|> hd()
|> String.to_charlist()
|> :inet.parse_address()
|> case do
{:ok, ip} -> ip
_ -> nil
end
end
end
defp get_ace_ip() do
with info <- Process.get(Ace.HTTP.Channel),
{:tcp, socket} <- info.socket,
{:ok, {ip, _}} <- :inet.peername(socket) do
ip
else
_ -> nil
end
end
defp get_session(req) do
case get_session_id(req) do
{:ok, uuid} ->
case Tilastokeskus.Archive.Utils.Session.find_by_id(uuid) do
nil ->
new_session()
s ->
s
end
{:error, _} ->
new_session()
end
end
defp get_session_id(req) do
config = Tilastokeskus.Reception.Session.config()
Raxx.Session.SignedCookie.extract(req, config)
end
defp new_session() do
{:ok, session} = Tilastokeskus.Archive.Utils.Session.create()
session
end
defp parse_ua(req) do
Raxx.get_header(req, "user-agent", nil)
|> UAInspector.parse()
end
defp parse_referrer(req) do
referrer = Raxx.get_header(req, "referer", nil)
case referrer do
nil ->
{nil, nil, nil}
referrer ->
referrer_uri = URI.parse(referrer)
referrer_noq =
case String.split(referrer, "?") do
[h | _] -> h
_ -> referrer
end
{
referrer,
referrer_noq,
referrer_uri.authority
}
end
end
defp parse_url(%{} = body) do
case Map.get(body, "url") do
nil ->
{nil, nil, nil}
url ->
parsed = URI.parse(url)
{
if(not is_nil(parsed.query), do: "#{parsed.path}?#{parsed.query}", else: parsed.path),
parsed.path,
parsed.authority
}
end
end
defp unknown_2_str(:unknown), do: "n/a"
defp unknown_2_str(val), do: val
end
defmodule Tilastokeskus.Reception.Session do
@moduledoc """
Raxx session configuration for storing tracking session in client's cookies.
"""
@doc """
Validate that the necessary environment variables are setup for creating Raxx config.
"""
@spec validate_config() :: :ok | no_return
def validate_config() do
case System.get_env("RAXX_SECRET") do
nil ->
raise "Missing environment variable RAXX_SECRET that is required for signing client cookies."
_secret ->
:ok
end
end
@doc """
Raxx SignedCookie config for generating cookies for client.
"""
def config() do
secret = System.get_env("RAXX_SECRET")
Raxx.Session.SignedCookie.config(secret: secret, cookie_name: "tilastokeskus.session")
end
end
defmodule Tilastokeskus.Reception.UADetector do
@moduledoc """
Configuration for `:ua_inspector` and UA utils.
"""
@doc """
Configure `:ua_inspector`.
"""
@spec init() :: :ok
def init() do
priv_dir = Application.app_dir(:tilastokeskus, "priv") |> Path.join("ua")
Application.put_env(:ua_inspector, :database_path, priv_dir)
end
end
......@@ -6,6 +6,8 @@ defmodule Tilastokeskus.Application do
use Application
def start(_type, _args) do
:ok = Tilastokeskus.Reception.Session.validate_config()
port = (System.get_env("PORT") || "1971") |> String.to_integer()
# List all child processes to be supervised
......
......@@ -23,10 +23,12 @@ defmodule Tilastokeskus.MixProject do
defp deps do
[
{:raxx, "~> 0.15.4"},
{:raxx_static, "~> 0.6.1"},
{:ace, "~> 0.16.5"},
{:postgrex, ">= 0.0.0"},
{:ecto, "~> 2.2"},
{:jason, "~> 1.0"}
{:jason, "~> 1.0"},
{:ua_inspector, "~> 0.17"}
]
end
end
%{
"ace": {:hex, :ace, "0.16.5", "725f4511768bba7e083d3c93d8e499259101e49c0a9497252ce54a79a91e96e8", [:mix], [{:hpack, "~> 0.2.3", [hex: :hpack_erl, repo: "hexpm", optional: false]}, {:raxx, "~> 0.15.2", [hex: :raxx, repo: "hexpm", optional: false]}], "hexpm"},
"certifi": {:hex, :certifi, "2.3.1", "d0f424232390bf47d82da8478022301c561cf6445b5b5fb6a84d49a9e76d2639", [:rebar3], [{:parse_trans, "3.2.0", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
"cookie": {:hex, :cookie, "0.1.1", "89438362ee0f0ed400e9f076d617d630f82d682e3fbcf767072a46a6e1ed5781", [:mix], [], "hexpm"},
"db_connection": {:hex, :db_connection, "1.1.3", "89b30ca1ef0a3b469b1c779579590688561d586694a3ce8792985d4d7e575a61", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
"decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], [], "hexpm"},
"ecto": {:hex, :ecto, "2.2.10", "e7366dc82f48f8dd78fcbf3ab50985ceeb11cb3dc93435147c6e13f2cda0992e", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
"hackney": {:hex, :hackney, "1.12.1", "8bf2d0e11e722e533903fe126e14d6e7e94d9b7983ced595b75f532e04b7fdc7", [:rebar3], [{:certifi, "2.3.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
"hpack": {:hex, :hpack_erl, "0.2.3", "17670f83ff984ae6cd74b1c456edde906d27ff013740ee4d9efaa4f1bf999633", [:rebar3], [], "hexpm"},
"idna": {:hex, :idna, "5.1.1", "cbc3b2fa1645113267cc59c760bafa64b2ea0334635ef06dbac8801e42f7279c", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
"jason": {:hex, :jason, "1.0.0", "0f7cfa9bdb23fed721ec05419bcee2b2c21a77e926bce0deda029b5adc716fe2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
"mime": {:hex, :mime, "1.3.0", "5e8d45a39e95c650900d03f897fbf99ae04f60ab1daa4a34c7a20a5151b7a5fe", [:mix], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.2.0", "2adfa4daf80c14dc36f522cf190eb5c4ee3e28008fc6394397c16f62a26258c2", [:rebar3], [], "hexpm"},
"poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"},
"postgrex": {:hex, :postgrex, "0.13.5", "3d931aba29363e1443da167a4b12f06dcd171103c424de15e5f3fc2ba3e6d9c5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"},
"raxx": {:hex, :raxx, "0.15.4", "62e4a487e55e9469d1ba2d5de590672167f51eaac310f027f3f871018cb6a9fc", [:mix], [{:cookie, "~> 0.1.0", [hex: :cookie, repo: "hexpm", optional: false]}], "hexpm"},
"raxx_static": {:hex, :raxx_static, "0.6.1", "8b48254fc3d1b8b1e473b7c307fbba0ae767c60482754ce823c664544c85d729", [:mix], [{:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:raxx, "~> 0.15.2", [hex: :raxx, repo: "hexpm", optional: false]}], "hexpm"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"},
"ua_inspector": {:hex, :ua_inspector, "0.17.0", "5f943d6a61baf520f79fc04f85eb0dc35ea851634453df25f0040dbd0672a2b6", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.0", [hex: :poolboy, repo: "hexpm", optional: false]}, {:yamerl, "~> 0.6", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"},
"yamerl": {:hex, :yamerl, "0.7.0", "e51dba652dce74c20a88294130b48051ebbbb0be7d76f22de064f0f3ccf0aaf5", [:rebar3], [], "hexpm"},
}
......@@ -8,7 +8,7 @@ defmodule Tilastokeskus.Archive.Repo.Migrations.Initial do
end
create table(:events, primary_key: false) do
add(:id, :bigint, primary_key: true)
add(:id, :bigserial, primary_key: true)
add(:at, :timestamp, null: false)
add(
......
defmodule Tilastokeskus.Archive.Repo.Migrations.AddOsInfo do
use Ecto.Migration
def change do
rename(table(:events), :scrubbed_ua, to: :ua_name)
rename(table(:events), :scrubbed_ua_version, to: :ua_version)
alter table(:events) do
add(:os_name, :text, null: true)
add(:os_version, :text, null: true)
add(:device_type, :text, null: true)
end
create(index(:events, :device_type))
end
end
defmodule Tilastokeskus.Archive.Repo.Migrations.AddTarget do
use Ecto.Migration
def change do
alter table(:events) do
add(:path, :text, null: true)
add(:path_noq, :text, null: true)
add(:host, :text, null: true)
end
end
end
This diff is collapsed.
###############
# Device Detector - The Universal Device Detection library for parsing User Agents
#
# @link http://piwik.org
# @license http://www.gnu.org/licenses/lgpl.html LGPL v3 or later
###############
- regex: 'NetFront'
name: 'NetFront'
- regex: 'Edge'
name: 'Edge'
- regex: 'Trident'
name: 'Trident'
- regex: 'Blink'
name: 'Blink'
- regex: '(?:Apple)?WebKit'
name: 'WebKit'
- regex: 'Presto'
name: 'Presto'
- regex: '(?<!like )Gecko'
name: 'Gecko'
- regex: 'KHTML'
name: 'KHTML'
- regex: 'NetSurf'
name: 'NetSurf'
This diff is collapsed.
###############
# Device Detector - The Universal Device Detection library for parsing User Agents
#
# @link http://piwik.org
# @license http://www.gnu.org/licenses/lgpl.html LGPL v3 or later
###############
- regex: 'Akregator(?:/(\d+[\.\d]+))?'
name: 'Akregator'
version: '$1'
url: 'http://userbase.kde.org/Akregator'
type: 'Feed Reader'
- regex: 'Apple-PubSub(?:/(\d+[\.\d]+))?'
name: 'Apple PubSub'
version: '$1'
url: 'https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man1/pubsub.1.html'
type: 'Feed Reader'
- regex: 'BashPodder'
name: 'BashPodder'
version: ''
url: 'http://lincgeek.org/bashpodder/'
type: 'Feed Reader'
- regex: 'Downcast/([\d\.]+)'
name: 'Downcast'
version: '$1'
url: 'http://downcastapp.com/'
type: 'Feed Reader App'
- regex: 'FeedDemon(?:/(\d+[\.\d]+))?'
name: 'FeedDemon'
version: '$1'
url: 'http://www.feeddemon.com/'
type: 'Feed Reader'
- regex: 'Feeddler(?:RSS|PRO)(?:[/ ](\d+[\.\d]+))?'
name: 'Feeddler RSS Reader'
version: '$1'
url: 'http://www.chebinliu.com/projects/iphone/feeddler-rss-reader/'
type: 'Feed Reader App'
- regex: 'gPodder/([\d\.]+)'
name: 'gPodder'
version: '$1'
url: 'http://gpodder.org/'
type: 'Feed Reader App'
- regex: 'Instacast/(\d+) CFNetwork/([\d\.]+)'
name: 'Instacast'
version: '$1'
url: 'http://vemedio.com/products/instacast-mac'
type: 'Feed Reader App'
- regex: 'JetBrains Omea Reader(?:[/ ](\d+[\.\d]+))?'
name: 'JetBrains Omea Reader'
version: '$1'
url: 'http://www.jetbrains.com/omea/reader/'
type: 'Feed Reader'
- regex: 'Liferea(?:[/ ](\d+[\.\d]+))?'
name: 'Liferea'
version: '$1'
url: 'http://liferea.sf.net/'
type: 'Feed Reader'
- regex: 'NetNewsWire(?:[/ ](\d+[\.\d]+))?'
name: 'NetNewsWire'
version: '$1'
url: 'http://netnewswireapp.com/'
type: 'Feed Reader'
- regex: 'NewsBlur (?:iPhone|iPad) App(?: v(\d+[\.\d]+))?'
name: 'NewsBlur Mobile App'
version: '$1'
url: 'http://www.newsblur.com'
type: 'Feed Reader App'
- regex: 'NewsBlur(?:/(\d+[\.\d]+))'
name: 'NewsBlur'
version: '$1'
url: 'http://www.newsblur.com'
type: 'Feed Reader'
- regex: 'newsbeuter(?:[/ ](\d+[\.\d]+))?'
name: 'Newsbeuter'
version: '$1'
url: 'http://www.newsbeuter.org/'
type: 'Feed Reader'
- regex: 'PritTorrent/([\d\.]+)'
name: 'PritTorrent'
version: '$1'
url: 'http://bitlove.org'
type: 'Feed Reader'
- regex: 'Pulp[/ ](\d+[\.\d]+)'
name: 'Pulp'
version: '$1'
url: 'http://www.acrylicapps.com/pulp/'
type: 'Feed Reader App'
- regex: 'ReadKit(?:[/ ](\d+[\.\d]+))?'
name: 'ReadKit'
version: '$1'
url: 'http://readkitapp.com/'
type: 'Feed Reader App'
- regex: 'Reeder(?:[/ ](\d+[\.\d]+))?'
name: 'Reeder'
version: '$1'
url: 'http://reederapp.com/'
type: 'Feed Reader App'
- regex: 'RSSBandit(?:[/ ](\d+[\.\d]+))?'
name: 'RSS Bandit'
version: '$1'
url: 'http://www.rssbandit.org)'
type: 'Feed Reader'
- regex: 'RSS Junkie(?:[/ ](\d+[\.\d]+))?'
name: 'RSS Junkie'
version: '$1'
url: 'https://play.google.com/store/apps/details?id=com.bitpowder.rssjunkie'
type: 'Feed Reader App'
- regex: 'RSSOwl(?:[/ ](\d+[\.\d]+))?'
name: 'RSSOwl'
version: '$1'
url: 'http://www.rssowl.org/'
type: 'Feed Reader'
- regex: 'Stringer'
name: 'Stringer'
version: ''
url: 'https://github.com/swanson/stringer'
type: 'Feed Reader'
###############
# Device Detector - The Universal Device Detection library for parsing User Agents
#
# @link http://piwik.org
# @license http://www.gnu.org/licenses/lgpl.html LGPL v3 or later
###############
- regex: 'Wget(?:/(\d+[\.\d]+))?'
name: 'Wget'