content_signals 0.1.1 β†’ 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 511a0ac5433ad420855aad0d53f64839b1b9c05dd46f2a1cca26c86c4898c372
4
- data.tar.gz: f20269439262403253d17dc0873007f5f30a7c94f3d612ad86e781137db3398c
3
+ metadata.gz: 7388e4fede60244368fbc17faba350e4427c9a028a7c4482b526836bc0f9f5ac
4
+ data.tar.gz: 14969ae023b16f9ca1a5db5922abddddf35444b860d2aa0c86211f9c6ebf695b
5
5
  SHA512:
6
- metadata.gz: 890fc8d708a4fdc971e230312507801b914538ff55d9b2236e6002c9508c96872919230501fd06cc07e224fbf7208cdf6504a9158e475737f6d243f33c02c859
7
- data.tar.gz: b05443d219acda5b7d3e41ee8532dea8c130c2b0b97031d91e433adf07e8b4eceeea5c3827cbb716b8d7363c8cb8aea530096cec6e148f95165c1e9bfc8049c7
6
+ metadata.gz: c020e32d9d0f675d11873f5933cef9b100944a9067df91956f05015eedb55b1a5ffe36a1f3337b3033093f0cabdd27547507eacdfc006d3abcbadddc7517152d
7
+ data.tar.gz: 02ce4de9492392cf993858d3f1d0a6b8713c915766357580bbda9c512f96df65c08e580bd8d470b6f2e23e81e83a5b86dab55156ae8f50a5ca518ca8ebf1dbe3
data/README.md CHANGED
@@ -6,15 +6,21 @@ A Rails engine for tracking page views and content engagement with rich analytic
6
6
 
7
7
  ## Features
8
8
 
9
- - πŸ“Š **Page view tracking** with visitor identification
10
- - 🌍 **Geolocation** (country, city, region) via MaxMind GeoLite2
11
- - πŸ“± **Device detection** (mobile, tablet, desktop, hybrid apps)
12
- - 🏒 **Multi-tenant support** (optional)
13
- - πŸ€– **Bot filtering** (automatically excludes crawlers)
14
- - πŸ”„ **Background processing** (non-blocking with ActiveJob)
15
- - πŸ“ˆ **Rich analytics** with time-based scopes and aggregations
16
- - 🎯 **Polymorphic tracking** (works with any model)
17
- - πŸ“² **Hybrid app support** (Capacitor, Cordova, React Native, Flutter)
9
+ - **Page view tracking** with visitor identification
10
+ - **Use cookies** to identify anonymous returning visitors
11
+ - **Use Redis to deduplicate unique views per day** (optional)
12
+ - **Device detection** (mobile, tablet, desktop, hybrid apps)
13
+ - **Bot filtering** (automatically excludes crawlers)
14
+ - **Background processing** (non-blocking with ActiveJob)
15
+ - **Multi-tenant support** (optional)
16
+ - **Rich analytics** with time-based scopes and aggregations
17
+ - **Polymorphic tracking** (works with any model)
18
+ - **Hybrid app support** (Capacitor, Cordova, React Native, Flutter)
19
+
20
+
21
+ ## Further development plans
22
+ - **Geolocation** (country, city, region) via MaxMind GeoLite2
23
+
18
24
 
19
25
  ## Installation
20
26
 
@@ -9,7 +9,9 @@ module ContentSignals
9
9
  :redis_namespace,
10
10
  :maxmind_db_path,
11
11
  :track_bots,
12
- :track_admins
12
+ :track_admins,
13
+ :geoip_provider, # Symbol (:maxmind, :ipinfo, :null) or a provider class
14
+ :geoip_token # API token for online providers (e.g. IPinfo)
13
15
 
14
16
  def initialize
15
17
  @multitenancy = false
@@ -20,6 +22,22 @@ module ContentSignals
20
22
  @maxmind_db_path = default_maxmind_path
21
23
  @track_bots = false
22
24
  @track_admins = false
25
+ @geoip_provider = :maxmind
26
+ @geoip_token = nil
27
+ end
28
+
29
+ # Returns the resolved provider class for IP geolocation.
30
+ # Accepts a symbol (:maxmind, :ipinfo, :null) or any class that responds to .locate(ip).
31
+ def resolved_geoip_provider
32
+ case @geoip_provider
33
+ when :maxmind then ContentSignals::Geoip::MaxmindProvider
34
+ when :ipinfo then ContentSignals::Geoip::IpinfoProvider
35
+ when :null then ContentSignals::Geoip::NullProvider
36
+ when Class then @geoip_provider
37
+ else
38
+ raise ArgumentError, "Unknown geoip_provider: #{@geoip_provider.inspect}. " \
39
+ "Use :maxmind, :ipinfo, :null, or a custom provider class."
40
+ end
23
41
  end
24
42
 
25
43
  def multitenancy?
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContentSignals
4
+ module Geoip
5
+ # Base interface for IP geolocation providers.
6
+ # All providers must implement `.locate(ip_address)` and return either:
7
+ # - A Hash with keys: :country_code, :country_name, :city, :region, :latitude, :longitude
8
+ # - nil (when IP is local, lookup fails, or provider is disabled)
9
+ class BaseProvider
10
+ LOCAL_IP_PREFIXES = %w[127. 192.168. 10. 172. fc00: fe80:].freeze
11
+ LOCAL_IP_EXACT = %w[::1 localhost].freeze
12
+
13
+ def self.locate(ip_address)
14
+ raise NotImplementedError, "#{name}.locate must be implemented"
15
+ end
16
+
17
+ def self.local_ip?(ip)
18
+ return true if ip.nil?
19
+
20
+ ip_str = ip.to_s
21
+ LOCAL_IP_EXACT.include?(ip_str) ||
22
+ LOCAL_IP_PREFIXES.any? { |prefix| ip_str.start_with?(prefix) }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContentSignals
4
+ module Geoip
5
+ # Online geolocation via IPinfo.io API.
6
+ # Requires: IPINFO_TOKEN env var (or config.geoip_token)
7
+ # Requires the http gem (gem "http") or net/http (built-in, used as fallback).
8
+ class IpinfoProvider < BaseProvider
9
+ API_BASE = "https://ipinfo.io"
10
+
11
+ def self.locate(ip_address)
12
+ return nil if ip_address.blank? || local_ip?(ip_address)
13
+
14
+ token = ContentSignals.configuration.geoip_token || ENV["IPINFO_TOKEN"]
15
+ return nil unless token.present?
16
+
17
+ data = fetch(ip_address, token)
18
+ return nil unless data && data["country"]
19
+
20
+ lat, lng = data["loc"]&.split(",")&.map(&:to_f)
21
+
22
+ {
23
+ country_code: data["country"],
24
+ country_name: country_name(data["country"]),
25
+ city: data["city"],
26
+ region: data["region"],
27
+ latitude: lat,
28
+ longitude: lng
29
+ }
30
+ rescue => e
31
+ Rails.logger.error "IPinfo geolocation error: #{e.message}"
32
+ nil
33
+ end
34
+
35
+ def self.fetch(ip, token)
36
+ uri = URI("#{API_BASE}/#{ip}/json?token=#{token}")
37
+ response = Net::HTTP.get_response(uri)
38
+ JSON.parse(response.body)
39
+ end
40
+
41
+ def self.country_name(code)
42
+ return nil unless defined?(ISO3166)
43
+
44
+ ISO3166::Country[code]&.name
45
+ rescue
46
+ nil
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContentSignals
4
+ module Geoip
5
+ # Offline geolocation using a local MaxMind GeoLite2-City .mmdb file.
6
+ # Requires the maxminddb gem.
7
+ # Download/update the database: bundle exec rake app:stejar:geoip:update
8
+ class MaxmindProvider < BaseProvider
9
+ def self.locate(ip_address)
10
+ return nil if ip_address.blank? || local_ip?(ip_address)
11
+
12
+ db_path = ContentSignals.configuration.maxmind_db_path
13
+ return nil unless db_path && File.exist?(db_path.to_s)
14
+
15
+ db = @db ||= MaxMindDB.new(db_path.to_s)
16
+ result = db.lookup(ip_address)
17
+ return nil unless result&.found?
18
+
19
+ {
20
+ country_code: result.country.iso_code,
21
+ country_name: result.country.name,
22
+ city: result.city.name,
23
+ region: result.subdivisions.most_specific&.name,
24
+ latitude: result.location.latitude,
25
+ longitude: result.location.longitude
26
+ }
27
+ rescue MaxMindDB::Error => e
28
+ Rails.logger.error "MaxMind lookup error: #{e.message}"
29
+ nil
30
+ rescue => e
31
+ Rails.logger.error "MaxMind geolocation error: #{e.message}"
32
+ nil
33
+ end
34
+
35
+ # Reset cached DB handle (useful after db file is updated)
36
+ def self.reset!
37
+ @db = nil
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContentSignals
4
+ module Geoip
5
+ # No-op provider β€” disables geolocation entirely.
6
+ # Use when you don't want any IP lookup performed.
7
+ class NullProvider < BaseProvider
8
+ def self.locate(_ip_address)
9
+ nil
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContentSignals
4
+ # Downloads/refreshes the MaxMind GeoLite2-City database from the public mirror.
5
+ # Only runs when geoip_provider is :maxmind and maxmind_db_path is configured.
6
+ # Schedule via config/recurring.yml (added by `rails generate content_signals:install`).
7
+ class UpdateGeoipDatabaseJob < ApplicationJob
8
+ queue_as :default
9
+
10
+ MIRROR_URL = "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb"
11
+
12
+ def perform
13
+ config = ContentSignals.configuration
14
+
15
+ unless config.geoip_provider == :maxmind
16
+ Rails.logger.info "[ContentSignals] UpdateGeoipDatabaseJob skipped: geoip_provider is not :maxmind"
17
+ return
18
+ end
19
+
20
+ db_path = config.maxmind_db_path
21
+ unless db_path.present?
22
+ Rails.logger.warn "[ContentSignals] UpdateGeoipDatabaseJob skipped: maxmind_db_path not configured"
23
+ return
24
+ end
25
+
26
+ download(db_path.to_s)
27
+ end
28
+
29
+ private
30
+
31
+ def download(db_path)
32
+ require "open-uri"
33
+ require "fileutils"
34
+
35
+ FileUtils.mkdir_p(File.dirname(db_path))
36
+
37
+ tmp_path = "#{db_path}.tmp"
38
+
39
+ Rails.logger.info "[ContentSignals] Downloading GeoLite2-City from mirror..."
40
+
41
+ URI.open(MIRROR_URL, "rb", # rubocop:disable Security/Open
42
+ "User-Agent" => "Mozilla/5.0",
43
+ read_timeout: 120,
44
+ open_timeout: 15) do |remote|
45
+ File.binwrite(tmp_path, remote.read)
46
+ end
47
+
48
+ File.rename(tmp_path, db_path)
49
+
50
+ size_mb = (File.size(db_path) / 1_048_576.0).round(1)
51
+ Rails.logger.info "[ContentSignals] GeoLite2-City updated: #{db_path} (#{size_mb} MB)"
52
+
53
+ # Bust the cached DB handle so next lookup uses the fresh file
54
+ ContentSignals::Geoip::MaxmindProvider.reset!
55
+ rescue => e
56
+ File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
57
+ Rails.logger.error "[ContentSignals] GeoLite2 update failed: #{e.message}"
58
+ raise
59
+ end
60
+ end
61
+ end
@@ -44,6 +44,16 @@ module ContentSignals
44
44
  if @request.respond_to?(:cookie_jar)
45
45
  cookie_id = @request.cookie_jar.signed[:visitor_id]
46
46
  return cookie_id if cookie_id.present?
47
+
48
+ # Generate and set new visitor cookie if none exists
49
+ new_visitor_id = "visitor_#{SecureRandom.uuid}"
50
+ @request.cookie_jar.signed[:visitor_id] = {
51
+ value: new_visitor_id,
52
+ expires: 2.years.from_now,
53
+ httponly: true,
54
+ same_site: :lax
55
+ }
56
+ return new_visitor_id
47
57
  end
48
58
 
49
59
  # 4. Fallback to IP + User Agent hash (for non-cookie scenarios)
@@ -1,80 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ContentSignals
4
+ # Thin dispatcher: delegates IP geolocation to the configured provider.
5
+ # Switch providers via ContentSignals.configure { |c| c.geoip_provider = :ipinfo }
4
6
  class VisitorLocationService
5
7
  def self.locate(ip_address)
6
- return nil if ip_address.blank? || local_ip?(ip_address)
7
-
8
- db_path = ContentSignals.configuration.maxmind_db_path
9
- return nil unless db_path && File.exist?(db_path)
10
-
11
- # Initialize database connection (cached in production)
12
- db = @db ||= MaxMindDB.new(db_path.to_s)
13
- result = db.lookup(ip_address)
14
-
15
- return nil unless result&.found?
16
-
17
- {
18
- country_code: result.country.iso_code,
19
- country_name: result.country.name,
20
- city: result.city.name,
21
- region: result.subdivisions.most_specific&.name,
22
- latitude: result.location.latitude,
23
- longitude: result.location.longitude
24
- }
25
- rescue MaxMindDB::Error => e
26
- Rails.logger.error "MaxMind lookup error: #{e.message}"
27
- nil
28
- rescue => e
29
- Rails.logger.error "Geolocation error: #{e.message}"
30
- nil
8
+ ContentSignals.configuration.resolved_geoip_provider.locate(ip_address)
31
9
  end
32
10
 
11
+ # Convenience: is this a local/private IP?
33
12
  def self.local_ip?(ip)
34
- return true if ip.nil?
35
-
36
- ip_str = ip.to_s
37
- ip_str.start_with?('127.', '192.168.', '10.', '172.', 'fc00:', 'fe80:') ||
38
- ip_str == '::1' ||
39
- ip_str == 'localhost'
40
- end
41
-
42
- # Fallback to API if database not available (optional)
43
- def self.locate_via_api(ip_address)
44
- return nil if ip_address.blank? || local_ip?(ip_address)
45
- return nil unless defined?(HTTP)
46
-
47
- token = ENV['IPINFO_TOKEN']
48
- return nil unless token
49
-
50
- # Using IPinfo.io as fallback
51
- response = HTTP.get("https://ipinfo.io/#{ip_address}/json", params: { token: token })
52
- data = JSON.parse(response.body)
53
-
54
- return nil unless data['country']
55
-
56
- lat, lng = data['loc']&.split(',')&.map(&:to_f)
57
-
58
- {
59
- country_code: data['country'],
60
- country_name: country_name_from_code(data['country']),
61
- city: data['city'],
62
- region: data['region'],
63
- latitude: lat,
64
- longitude: lng
65
- }
66
- rescue => e
67
- Rails.logger.error "API geolocation error: #{e.message}"
68
- nil
69
- end
70
-
71
- def self.country_name_from_code(code)
72
- return nil unless defined?(ISO3166)
73
-
74
- country = ISO3166::Country[code]
75
- country&.name
76
- rescue
77
- nil
13
+ Geoip::BaseProvider.local_ip?(ip)
78
14
  end
79
15
  end
80
16
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ContentSignals
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
@@ -5,17 +5,20 @@ require_relative "content_signals/configuration"
5
5
 
6
6
  # Optional dependencies - gracefully handle if not installed
7
7
  begin
8
- require 'browser'
8
+ require "browser"
9
9
  rescue LoadError
10
10
  # Browser gem not installed - device detection will be disabled
11
11
  end
12
12
 
13
13
  begin
14
- require 'maxmind/geoip2'
14
+ require "maxminddb"
15
15
  rescue LoadError
16
- # MaxMind gem not installed - geolocation will be disabled
16
+ # maxminddb gem not installed - MaxmindProvider will be disabled
17
17
  end
18
18
 
19
+ require "net/http"
20
+ require "json"
21
+
19
22
  module ContentSignals
20
23
  class Error < StandardError; end
21
24
 
@@ -44,6 +47,12 @@ if defined?(ActiveRecord)
44
47
  require_relative "content_signals/models/page_view"
45
48
  end
46
49
 
50
+ # Load geoip providers
51
+ require_relative "content_signals/geoip/base_provider"
52
+ require_relative "content_signals/geoip/maxmind_provider"
53
+ require_relative "content_signals/geoip/ipinfo_provider"
54
+ require_relative "content_signals/geoip/null_provider"
55
+
47
56
  # Load services
48
57
  require_relative "content_signals/services/page_view_tracker"
49
58
  require_relative "content_signals/services/visitor_location_service"
@@ -52,6 +61,7 @@ require_relative "content_signals/services/device_detector_service"
52
61
  # Load jobs if ActiveJob is available
53
62
  if defined?(ActiveJob)
54
63
  require_relative "content_signals/jobs/track_page_view_job"
64
+ require_relative "content_signals/jobs/update_geoip_database_job"
55
65
  end
56
66
 
57
67
  # Load concerns if ActiveSupport is available
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rails/generators'
4
- require 'rails/generators/migration'
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
5
 
6
6
  module ContentSignals
7
7
  module Generators
8
8
  class InstallGenerator < Rails::Generators::Base
9
9
  include Rails::Generators::Migration
10
10
 
11
- source_root File.expand_path('templates', __dir__)
11
+ source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Creates ContentSignals migration and initializer"
13
+ desc "Creates ContentSignals migration, initializer, and recurring job schedule"
14
14
 
15
15
  def self.next_migration_number(path)
16
16
  next_migration_number = current_migration_number(path) + 1
@@ -19,14 +19,42 @@ module ContentSignals
19
19
 
20
20
  def copy_migration
21
21
  migration_template "create_content_signals_page_views.rb.erb",
22
- "db/migrate/create_content_signals_page_views.rb",
23
- migration_version: migration_version
22
+ "db/migrate/create_content_signals_page_views.rb",
23
+ migration_version: migration_version
24
24
  end
25
25
 
26
26
  def copy_initializer
27
27
  template "content_signals.rb.erb", "config/initializers/content_signals.rb"
28
28
  end
29
29
 
30
+ def inject_recurring_job
31
+ recurring_yml = "config/recurring.yml"
32
+
33
+ if File.exist?(File.join(destination_root, recurring_yml))
34
+ content = File.read(File.join(destination_root, recurring_yml))
35
+
36
+ if content.include?("ContentSignals::UpdateGeoipDatabaseJob")
37
+ say_status :skip, "recurring.yml already contains ContentSignals::UpdateGeoipDatabaseJob", :yellow
38
+ else
39
+ append_to_file recurring_yml, <<~YAML
40
+
41
+ # ContentSignals β€” refresh GeoLite2-City database weekly (MaxMind updates Tuesdays)
42
+ content_signals_update_geoip_database:
43
+ class: ContentSignals::UpdateGeoipDatabaseJob
44
+ schedule: every week at 4am
45
+ YAML
46
+ say_status :append, recurring_yml, :green
47
+ end
48
+ else
49
+ create_file recurring_yml, <<~YAML
50
+ # ContentSignals β€” refresh GeoLite2-City database weekly (MaxMind updates Tuesdays)
51
+ content_signals_update_geoip_database:
52
+ class: ContentSignals::UpdateGeoipDatabaseJob
53
+ schedule: every week at 4am
54
+ YAML
55
+ end
56
+ end
57
+
30
58
  def show_readme
31
59
  readme "README" if behavior == :invoke
32
60
  end
@@ -1,3 +1,5 @@
1
+ require "content_signals"
2
+
1
3
  ContentSignals.configure do |config|
2
4
  # Multi-tenancy (optional)
3
5
  # Enable if your app has multiple tenants/accounts
@@ -9,9 +11,19 @@ ContentSignals.configure do |config|
9
11
  config.redis_enabled = true
10
12
  config.redis_namespace = "content_signals"
11
13
 
12
- # MaxMind GeoLite2 database path (optional, for geolocation)
13
- # Download from: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
14
- # config.maxmind_db_path = Rails.root.join("db", "GeoLite2-City.mmdb")
14
+ # IP Geolocation provider
15
+ # :maxmind β€” offline, fast, no API calls. Requires local .mmdb file (default)
16
+ # :ipinfo β€” online, no local file needed. Requires IPINFO_TOKEN env var
17
+ # :null β€” disables geolocation entirely
18
+ config.geoip_provider = :maxmind
19
+
20
+ # MaxMind GeoLite2 database path (used when geoip_provider = :maxmind)
21
+ # Initial download: bundle exec rake app:stejar:geoip:update (or run the job manually)
22
+ # Auto-updated weekly via ContentSignals::UpdateGeoipDatabaseJob (see config/recurring.yml)
23
+ config.maxmind_db_path = Rails.root.join("db", "GeoLite2-City.mmdb")
24
+
25
+ # IPinfo API token (used when geoip_provider = :ipinfo)
26
+ # config.geoip_token = ENV["IPINFO_TOKEN"]
15
27
 
16
28
  # Tracking preferences
17
29
  config.track_bots = false # Set to true to track bot/crawler visits
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: content_signals
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - IonuΘ› Munteanu Alexandru
@@ -116,7 +116,12 @@ files:
116
116
  - lib/content_signals/concerns/trackable_page_views.rb
117
117
  - lib/content_signals/configuration.rb
118
118
  - lib/content_signals/engine.rb
119
+ - lib/content_signals/geoip/base_provider.rb
120
+ - lib/content_signals/geoip/ipinfo_provider.rb
121
+ - lib/content_signals/geoip/maxmind_provider.rb
122
+ - lib/content_signals/geoip/null_provider.rb
119
123
  - lib/content_signals/jobs/track_page_view_job.rb
124
+ - lib/content_signals/jobs/update_geoip_database_job.rb
120
125
  - lib/content_signals/models/page_view.rb
121
126
  - lib/content_signals/services/device_detector_service.rb
122
127
  - lib/content_signals/services/page_view_tracker.rb