footfall 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/README.md +102 -0
- data/lib/footfall/database.rb +32 -0
- data/lib/footfall/middleware.rb +118 -0
- data/lib/footfall/report.rb +104 -0
- data/lib/footfall/version.rb +3 -0
- data/lib/footfall.rb +9 -0
- metadata +126 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: cd91000a047937d1598600d2b06cf063db586d468841895158055b2d7a520f7f
|
|
4
|
+
data.tar.gz: 6e5615676b235a42b9ca63bc9bb3d801e710c4e821c8dfe31424c5f0c199928a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 577732a8ccaad0e9d2a942a3e6f2cf7866399e2381fc3ce48d256d9eb3be5a24a3bcf2b1bb68d52979137f9294693006fe8fd0e2c3fdb1d10419590329503958
|
|
7
|
+
data.tar.gz: cebb8fd907bb3f3556dfcebc0000781ccb2f3a226e9c5def5722e007f0ea451feda02700e8c2f31587dd37129baa0f43bd4e3bbfb969d8ee2e8410767af44e61
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-10-09
|
|
4
|
+
|
|
5
|
+
- First version, extracted from mini-audition.com's own counter: the middleware, the
|
|
6
|
+
SQLite schema and the terminal report. Options for where downloads live, the site's
|
|
7
|
+
host, a proxy's client address header and the bot pattern; the report's download columns
|
|
8
|
+
and feed pattern are configurable. It never changes a table it didn't make: give it a
|
|
9
|
+
file of its own.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oleh Khomei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# footfall
|
|
2
|
+
|
|
3
|
+
Cookieless visitor, page view and download counts for a Rack app, in a SQLite file. Footfall
|
|
4
|
+
is what a shop counts at its door: people coming in, not who they are.
|
|
5
|
+
|
|
6
|
+
- **Pages, visitors, referrers and downloads.** A page view is a GET of HTML (or an Atom or
|
|
7
|
+
RSS feed) answered with 200; a download, a GET under `/download/` that sends the whole
|
|
8
|
+
file, counted once even when it's resumed in pieces.
|
|
9
|
+
- **Nobody can be picked out.** A visitor is a hash of their address and browser, salted
|
|
10
|
+
anew every day; the salt is forgotten the next day, so a day's visitors can be counted but
|
|
11
|
+
not followed from day to day, or traced back. No cookies, no IP addresses, and only the
|
|
12
|
+
site a visitor came from, not the full referrer.
|
|
13
|
+
- **Bots left out:** crawlers, link previews, monitors and command-line tools.
|
|
14
|
+
- **One file, no service.** SQLite in WAL mode, next to your app; a report for the terminal.
|
|
15
|
+
- **Never breaks a page.** A counting error is logged and the response goes out as it was.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# Gemfile
|
|
21
|
+
gem "footfall"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Count
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
# config.ru
|
|
28
|
+
require "footfall"
|
|
29
|
+
|
|
30
|
+
# Outermost, so it sees every response, downloads too. Without a path it counts nothing,
|
|
31
|
+
# which suits development and tests.
|
|
32
|
+
use Footfall::Middleware, path: ENV["FOOTFALL_PATH"]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
|
|
37
|
+
| Option | Default | |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `path:` | (required) | The SQLite file, made with its folder; `nil` turns counting off. |
|
|
40
|
+
| `downloads:` | `"/download/"` | The path prefix of files counted as downloads; `nil` for none. |
|
|
41
|
+
| `host:` | the request's host | This site, whose own links aren't referrers (`www.` is ignored). |
|
|
42
|
+
| `client_ip_header:` | none | The header a proxy puts the visitor's address in, such as Cloudflare's `"CF-Connecting-IP"`. Only behind such a proxy: anyone can send it. |
|
|
43
|
+
| `bots:` | `Footfall::Middleware::BOTS` | A regexp of user agents left out. |
|
|
44
|
+
|
|
45
|
+
In Rails, `config.middleware.insert_before 0, Footfall::Middleware, path: …`.
|
|
46
|
+
|
|
47
|
+
## Report
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
require "footfall"
|
|
51
|
+
|
|
52
|
+
Footfall::Report.new("storage/footfall.sqlite3",
|
|
53
|
+
title: "example.com",
|
|
54
|
+
downloads: { "App downloads" => "%.dmg", "Plugin installs" => "Plugin-%" }
|
|
55
|
+
).print(days: 30)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
example.com, the last 30 days (since 2026-09-10); bots left out
|
|
60
|
+
|
|
61
|
+
Totals
|
|
62
|
+
Visitors (a day each) Page views App downloads Plugin installs
|
|
63
|
+
--------------------- ---------- ------------- ---------------
|
|
64
|
+
412 903 57 12
|
|
65
|
+
|
|
66
|
+
By day
|
|
67
|
+
Day Visitors Views App downloads
|
|
68
|
+
…
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Then the busiest pages, the sites visitors came from, every download, and the feed's
|
|
72
|
+
readers on the latest day. `downloads:` names a column in the totals per kind of file, as
|
|
73
|
+
SQL `LIKE` patterns of the file names; the first is also the day by day column. Feeds
|
|
74
|
+
(`feeds:`, `"%/feed.xml"` by default) are counted apart from pages, since readers poll them.
|
|
75
|
+
|
|
76
|
+
A small script makes it a command, for example for `kamal app exec`:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
#!/usr/bin/env ruby
|
|
80
|
+
require "bundler/setup"
|
|
81
|
+
require "footfall"
|
|
82
|
+
|
|
83
|
+
Footfall::Report.new(ENV.fetch("FOOTFALL_PATH"), title: "example.com")
|
|
84
|
+
.print(days: Integer(ARGV.first || 30))
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## The data
|
|
88
|
+
|
|
89
|
+
Three tables, for queries of your own:
|
|
90
|
+
|
|
91
|
+
- `views`: `day`, `path`, `referrer` (a host, or `""`), `visitor`, `count`, one row per day,
|
|
92
|
+
page, referrer and visitor;
|
|
93
|
+
- `downloads`: `day`, `file`, `visitor`, `count`;
|
|
94
|
+
- `salts`: today's salt only.
|
|
95
|
+
|
|
96
|
+
Footfall only creates tables that aren't there and never alters existing ones; give it a
|
|
97
|
+
file of its own. Days are in the server's time zone. Visitors are only comparable within a day: count
|
|
98
|
+
`DISTINCT day || visitor` for "visitors, a day each".
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "sqlite3"
|
|
3
|
+
|
|
4
|
+
module Footfall
|
|
5
|
+
# The SQLite file: a row per day, page, referring site and visitor for page views, and per
|
|
6
|
+
# day, file and visitor for downloads, each with how many times; and the day's salt.
|
|
7
|
+
module Database
|
|
8
|
+
SCHEMA = <<~SQL
|
|
9
|
+
CREATE TABLE IF NOT EXISTS salts (day TEXT PRIMARY KEY, salt TEXT NOT NULL);
|
|
10
|
+
CREATE TABLE IF NOT EXISTS views (
|
|
11
|
+
day TEXT NOT NULL, path TEXT NOT NULL, referrer TEXT NOT NULL,
|
|
12
|
+
visitor TEXT NOT NULL, count INTEGER NOT NULL DEFAULT 1,
|
|
13
|
+
PRIMARY KEY (day, path, referrer, visitor));
|
|
14
|
+
CREATE TABLE IF NOT EXISTS downloads (
|
|
15
|
+
day TEXT NOT NULL, file TEXT NOT NULL, visitor TEXT NOT NULL,
|
|
16
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
17
|
+
PRIMARY KEY (day, file, visitor));
|
|
18
|
+
SQL
|
|
19
|
+
|
|
20
|
+
# For counting: made (with its folder) if it isn't there yet. Readonly for reports.
|
|
21
|
+
def self.open(path, readonly: false)
|
|
22
|
+
return SQLite3::Database.new(path, readonly: true) if readonly
|
|
23
|
+
|
|
24
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
25
|
+
db = SQLite3::Database.new(path)
|
|
26
|
+
db.busy_timeout = 2_000
|
|
27
|
+
db.execute("PRAGMA journal_mode = WAL")
|
|
28
|
+
db.execute_batch(SCHEMA)
|
|
29
|
+
db
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
require "date"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "rack"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Footfall
|
|
8
|
+
# Counts page views and downloads, after the app has answered. A page view is a GET of
|
|
9
|
+
# a page (HTML, or an Atom or RSS feed) answered with 200; a download, a GET under
|
|
10
|
+
# `downloads` that sends the whole file (200, or 206 from its first byte), so redirects
|
|
11
|
+
# and the later pieces of a resumed download aren't counted again.
|
|
12
|
+
#
|
|
13
|
+
# Nothing that identifies anyone is kept: a visitor is a hash of their IP address and
|
|
14
|
+
# browser with a salt that's new every day and forgotten the next. No cookies, no
|
|
15
|
+
# addresses, no full referrers (only the site a visitor came from). Bots are left out.
|
|
16
|
+
class Middleware
|
|
17
|
+
# Crawlers, link previews, monitors and command-line tools.
|
|
18
|
+
BOTS = /bot|crawl|spider|slurp|preview|monitor|curl|wget|python|ruby|go-http|java|
|
|
19
|
+
httpclient|headless|lighthouse|facebookexternalhit|embedly|whatsapp|telegram/xi
|
|
20
|
+
|
|
21
|
+
PAGES = %r{\A(text/html|application/(atom|rss)\+xml)\b}
|
|
22
|
+
|
|
23
|
+
# `path`: the SQLite file; nil turns counting off.
|
|
24
|
+
# `downloads`: the path prefix of files to count as downloads; nil for none.
|
|
25
|
+
# `host`: this site, whose own links aren't counted as referrers (the request's host
|
|
26
|
+
# unless given).
|
|
27
|
+
# `client_ip_header`: the header a proxy in front puts the visitor's address in, such
|
|
28
|
+
# as Cloudflare's "CF-Connecting-IP"; without one, the request's own address. Only
|
|
29
|
+
# set it behind such a proxy: anyone can send the header (it only skews the counts).
|
|
30
|
+
def initialize(app, path:, downloads: "/download/", host: nil, client_ip_header: nil,
|
|
31
|
+
bots: BOTS, today: -> { Date.today })
|
|
32
|
+
@app = app
|
|
33
|
+
@downloads = downloads
|
|
34
|
+
@host = host && site(host)
|
|
35
|
+
@client_ip = client_ip_header && "HTTP_#{client_ip_header.upcase.tr("-", "_")}"
|
|
36
|
+
@bots = bots
|
|
37
|
+
@today = today
|
|
38
|
+
@lock = Mutex.new
|
|
39
|
+
@db = path && Database.open(path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def call(env)
|
|
43
|
+
status, headers, body = @app.call(env)
|
|
44
|
+
record(Rack::Request.new(env), status, headers) if @db
|
|
45
|
+
[ status, headers, body ]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Never lets counting break a page.
|
|
51
|
+
def record(request, status, headers)
|
|
52
|
+
return unless request.get?
|
|
53
|
+
agent = request.user_agent.to_s
|
|
54
|
+
return if agent.empty? || @bots.match?(agent)
|
|
55
|
+
|
|
56
|
+
path = request.path_info
|
|
57
|
+
if @downloads && path.start_with?(@downloads)
|
|
58
|
+
download(request, path, status)
|
|
59
|
+
elsif status == 200 && PAGES.match?(headers["content-type"].to_s)
|
|
60
|
+
upsert("views", day: today, path: path, referrer: referrer(request),
|
|
61
|
+
visitor: visitor(request))
|
|
62
|
+
end
|
|
63
|
+
rescue StandardError => error
|
|
64
|
+
warn "footfall: #{error.class}: #{error.message}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def download(request, path, status)
|
|
68
|
+
range = request.get_header("HTTP_RANGE").to_s
|
|
69
|
+
return unless status == 200 || (status == 206 && range.match?(/\Abytes=0-/))
|
|
70
|
+
|
|
71
|
+
upsert("downloads", day: today, file: File.basename(path), visitor: visitor(request))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The site a visitor came from ("news.ycombinator.com"), or "" for none or this one.
|
|
75
|
+
def referrer(request)
|
|
76
|
+
from = site(URI.parse(request.referer.to_s).host.to_s)
|
|
77
|
+
from == (@host || site(request.host.to_s)) ? "" : from
|
|
78
|
+
rescue URI::InvalidURIError
|
|
79
|
+
""
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def site(host) = host.downcase.delete_prefix("www.")
|
|
83
|
+
|
|
84
|
+
def visitor(request)
|
|
85
|
+
address = (@client_ip && request.get_header(@client_ip)) || request.ip
|
|
86
|
+
Digest::SHA256.hexdigest("#{salt}|#{address}|#{request.user_agent}")[0, 16]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def today = @today.call.iso8601
|
|
90
|
+
|
|
91
|
+
# Today's salt, made the first time it's needed; the days before are forgotten then.
|
|
92
|
+
def salt
|
|
93
|
+
day = today
|
|
94
|
+
return @salt if @salt_day == day
|
|
95
|
+
|
|
96
|
+
@lock.synchronize do
|
|
97
|
+
@db.execute("DELETE FROM salts WHERE day < ?", [ day ])
|
|
98
|
+
@db.execute("INSERT OR IGNORE INTO salts (day, salt) VALUES (?, ?)",
|
|
99
|
+
[ day, SecureRandom.hex(16) ])
|
|
100
|
+
@salt = @db.get_first_value("SELECT salt FROM salts WHERE day = ?", [ day ])
|
|
101
|
+
@salt_day = day
|
|
102
|
+
end
|
|
103
|
+
@salt
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def upsert(table, values)
|
|
107
|
+
# As text: Rack's paths and headers can be binary strings, which SQLite would keep
|
|
108
|
+
# as blobs, never equal to text in a query.
|
|
109
|
+
values = values.transform_values { |value| value.to_s.dup.force_encoding(Encoding::UTF_8).scrub }
|
|
110
|
+
marks = ([ "?" ] * values.size).join(", ")
|
|
111
|
+
@lock.synchronize do
|
|
112
|
+
@db.execute(
|
|
113
|
+
"INSERT INTO #{table} (#{values.keys.join(", ")}) VALUES (#{marks}) " \
|
|
114
|
+
"ON CONFLICT DO UPDATE SET count = count + 1", values.values)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
require "date"
|
|
2
|
+
|
|
3
|
+
module Footfall
|
|
4
|
+
# The counts in the terminal: totals, the last two weeks day by day, the busiest pages,
|
|
5
|
+
# the sites visitors came from, every download, and feed readers. Feeds are counted
|
|
6
|
+
# apart from pages, since readers poll them.
|
|
7
|
+
#
|
|
8
|
+
# Footfall::Report.new("stats.sqlite3", title: "example.com",
|
|
9
|
+
# downloads: { "App" => "%.dmg", "Plugins" => "%.zip" }).print(days: 30)
|
|
10
|
+
class Report
|
|
11
|
+
# `downloads`: a column in the totals per kind of file, as SQL LIKE patterns of their
|
|
12
|
+
# names; the first is also the day by day column.
|
|
13
|
+
# `feeds`: a LIKE pattern of the feeds' paths.
|
|
14
|
+
def initialize(path, title: nil, downloads: { "Downloads" => "%" }, feeds: "%/feed.xml",
|
|
15
|
+
io: $stdout)
|
|
16
|
+
@db = Database.open(path, readonly: true)
|
|
17
|
+
@title = title
|
|
18
|
+
@downloads = downloads
|
|
19
|
+
@feeds = feeds
|
|
20
|
+
@io = io
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def print(days: 30, today: Date.today)
|
|
24
|
+
since = (today - days + 1).iso8601
|
|
25
|
+
@io.puts [ @title, "the last #{days} days (since #{since}); bots left out" ].compact.join(", ")
|
|
26
|
+
totals(since)
|
|
27
|
+
by_day(since)
|
|
28
|
+
pages(since)
|
|
29
|
+
referrers(since)
|
|
30
|
+
downloads
|
|
31
|
+
@io.puts "\nFeed readers (the latest day): #{feed_readers || 0}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def totals(since)
|
|
37
|
+
visitors, views = @db.get_first_row(<<~SQL, [ since, @feeds ])
|
|
38
|
+
SELECT COUNT(DISTINCT day || visitor), COALESCE(SUM(count), 0)
|
|
39
|
+
FROM views WHERE day >= ? AND path NOT LIKE ?
|
|
40
|
+
SQL
|
|
41
|
+
files = @downloads.values.map do |pattern|
|
|
42
|
+
@db.get_first_value(
|
|
43
|
+
"SELECT COUNT(*) FROM downloads WHERE day >= ? AND file LIKE ?", [ since, pattern ])
|
|
44
|
+
end
|
|
45
|
+
table("Totals", [ [ visitors, views, *files ] ],
|
|
46
|
+
[ "Visitors (a day each)", "Page views", *@downloads.keys ])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def by_day(since)
|
|
50
|
+
rows = @db.execute(<<~SQL, [ @downloads.values.first, since, @feeds ])
|
|
51
|
+
SELECT day, COUNT(DISTINCT visitor), SUM(count),
|
|
52
|
+
(SELECT COUNT(*) FROM downloads d WHERE d.day = views.day AND d.file LIKE ?)
|
|
53
|
+
FROM views WHERE day >= ? AND path NOT LIKE ?
|
|
54
|
+
GROUP BY day ORDER BY day DESC LIMIT 14
|
|
55
|
+
SQL
|
|
56
|
+
table("By day", rows, [ "Day", "Visitors", "Views", @downloads.keys.first ])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def pages(since)
|
|
60
|
+
rows = @db.execute(<<~SQL, [ since, @feeds ])
|
|
61
|
+
SELECT path, COUNT(DISTINCT day || visitor), SUM(count)
|
|
62
|
+
FROM views WHERE day >= ? AND path NOT LIKE ?
|
|
63
|
+
GROUP BY path ORDER BY 2 DESC LIMIT 15
|
|
64
|
+
SQL
|
|
65
|
+
table("Pages", rows, [ "Page", "Visitors", "Views" ])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def referrers(since)
|
|
69
|
+
rows = @db.execute(<<~SQL, [ since, @feeds ])
|
|
70
|
+
SELECT referrer, COUNT(DISTINCT day || visitor)
|
|
71
|
+
FROM views WHERE day >= ? AND referrer != '' AND path NOT LIKE ?
|
|
72
|
+
GROUP BY referrer ORDER BY 2 DESC LIMIT 15
|
|
73
|
+
SQL
|
|
74
|
+
table("Where they came from", rows, [ "Site", "Visitors" ])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def downloads
|
|
78
|
+
rows = @db.execute(<<~SQL)
|
|
79
|
+
SELECT file, COUNT(*), MAX(day) FROM downloads GROUP BY file ORDER BY MAX(day) DESC, file
|
|
80
|
+
SQL
|
|
81
|
+
table("Downloads (all time)", rows, [ "File", "Downloads", "Last" ])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def feed_readers
|
|
85
|
+
@db.get_first_value(<<~SQL, [ @feeds, @feeds ])
|
|
86
|
+
SELECT COUNT(DISTINCT visitor) FROM views
|
|
87
|
+
WHERE path LIKE ? AND day = (SELECT MAX(day) FROM views WHERE path LIKE ?)
|
|
88
|
+
SQL
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def table(title, rows, headers)
|
|
92
|
+
@io.puts "\n#{title}"
|
|
93
|
+
return @io.puts(" (none)") if rows.empty?
|
|
94
|
+
|
|
95
|
+
all = [ headers ] + rows.map { |row| row.map(&:to_s) }
|
|
96
|
+
widths = headers.each_index.map { |i| all.map { |row| row[i].length }.max }
|
|
97
|
+
all.each_with_index do |row, index|
|
|
98
|
+
cells = row.each_with_index.map { |cell, i| i.zero? ? cell.ljust(widths[i]) : cell.rjust(widths[i]) }
|
|
99
|
+
@io.puts " #{cells.join(" ")}"
|
|
100
|
+
@io.puts " #{widths.map { |width| "-" * width }.join(" ")}" if index.zero?
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/footfall.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
require "footfall/version"
|
|
2
|
+
require "footfall/database"
|
|
3
|
+
require "footfall/middleware"
|
|
4
|
+
require "footfall/report"
|
|
5
|
+
|
|
6
|
+
# Cookieless visitor, page view and download counts for a Rack app, in a SQLite file:
|
|
7
|
+
# Footfall::Middleware counts them, Footfall::Report prints them.
|
|
8
|
+
module Footfall
|
|
9
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: footfall
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Oleh Khomei
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: sqlite3
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: minitest
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rack-test
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rake
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
description: |
|
|
83
|
+
Footfall is what a shop counts at its door: people coming in, not who they
|
|
84
|
+
are. Rack middleware that counts page views, visitors, the sites they came
|
|
85
|
+
from and file downloads into a SQLite file, and a report for the terminal.
|
|
86
|
+
No cookies, no IP addresses, no full referrers: a visitor is a hash salted
|
|
87
|
+
anew every day and the salt forgotten the next, so a day's visitors can be
|
|
88
|
+
counted but not followed from day to day or traced back. Bots are left out.
|
|
89
|
+
executables: []
|
|
90
|
+
extensions: []
|
|
91
|
+
extra_rdoc_files: []
|
|
92
|
+
files:
|
|
93
|
+
- CHANGELOG.md
|
|
94
|
+
- LICENSE
|
|
95
|
+
- README.md
|
|
96
|
+
- lib/footfall.rb
|
|
97
|
+
- lib/footfall/database.rb
|
|
98
|
+
- lib/footfall/middleware.rb
|
|
99
|
+
- lib/footfall/report.rb
|
|
100
|
+
- lib/footfall/version.rb
|
|
101
|
+
homepage: https://github.com/varyform/footfall
|
|
102
|
+
licenses:
|
|
103
|
+
- MIT
|
|
104
|
+
metadata:
|
|
105
|
+
source_code_uri: https://github.com/varyform/footfall
|
|
106
|
+
changelog_uri: https://github.com/varyform/footfall/blob/main/CHANGELOG.md
|
|
107
|
+
rubygems_mfa_required: 'true'
|
|
108
|
+
rdoc_options: []
|
|
109
|
+
require_paths:
|
|
110
|
+
- lib
|
|
111
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - ">="
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '3.2'
|
|
116
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
117
|
+
requirements:
|
|
118
|
+
- - ">="
|
|
119
|
+
- !ruby/object:Gem::Version
|
|
120
|
+
version: '0'
|
|
121
|
+
requirements: []
|
|
122
|
+
rubygems_version: 4.0.20
|
|
123
|
+
specification_version: 4
|
|
124
|
+
summary: Cookieless visitor, page view and download counts for a Rack app, in a SQLite
|
|
125
|
+
file.
|
|
126
|
+
test_files: []
|