content_signals 0.1.0 → 0.1.1

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.
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContentSignals
4
+ class PageView < ActiveRecord::Base
5
+ self.table_name = "content_signals_page_views"
6
+
7
+ # Associations
8
+ belongs_to :trackable, polymorphic: true, counter_cache: :page_views_count
9
+ belongs_to :tenant, optional: true if ContentSignals.configuration.multitenancy?
10
+ belongs_to :user, optional: true
11
+
12
+ # Validations
13
+ validates :trackable_type, :trackable_id, :visitor_id, :viewed_at, presence: true
14
+ validates :country_code, length: { maximum: 2 }, allow_nil: true
15
+ validates :device_type, inclusion: { in: %w[desktop mobile tablet] }, allow_nil: true
16
+ validates :app_platform, inclusion: { in: %w[hybrid native] }, allow_nil: true
17
+
18
+ # Default scope for tenant isolation (when multitenancy is enabled)
19
+ if ContentSignals.configuration.multitenancy?
20
+ default_scope -> { where(tenant_id: current_tenant_id) }
21
+
22
+ def self.current_tenant_id
23
+ return nil unless ContentSignals.configuration.multitenancy?
24
+
25
+ method_name = ContentSignals.configuration.current_tenant_method
26
+ return nil unless method_name
27
+
28
+ # Try to get tenant_id from Current, controller, or thread
29
+ if defined?(Current) && Current.respond_to?(method_name)
30
+ Current.send(method_name)
31
+ elsif Thread.current[method_name]
32
+ Thread.current[method_name]
33
+ end
34
+ end
35
+ end
36
+
37
+ # Time period scopes
38
+ scope :today, -> { where("viewed_at >= ?", Time.current.beginning_of_day) }
39
+ scope :yesterday, -> { where(viewed_at: 1.day.ago.all_day) }
40
+ scope :this_week, -> { where("viewed_at >= ?", Time.current.beginning_of_week) }
41
+ scope :last_week, -> { where(viewed_at: 1.week.ago.beginning_of_week..1.week.ago.end_of_week) }
42
+ scope :this_month, -> { where("viewed_at >= ?", Time.current.beginning_of_month) }
43
+ scope :last_month, -> { where(viewed_at: 1.month.ago.beginning_of_month..1.month.ago.end_of_month) }
44
+ scope :last_30_days, -> { where("viewed_at >= ?", 30.days.ago) }
45
+ scope :last_90_days, -> { where("viewed_at >= ?", 90.days.ago) }
46
+ scope :this_year, -> { where("viewed_at >= ?", Time.current.beginning_of_year) }
47
+
48
+ # Device scopes
49
+ scope :mobile, -> { where(device_type: "mobile") }
50
+ scope :desktop, -> { where(device_type: "desktop") }
51
+ scope :tablet, -> { where(device_type: "tablet") }
52
+ scope :app, -> { where(device_type: "hybrid_app") }
53
+ scope :web, -> { where(device_type: %w[desktop mobile tablet]) }
54
+
55
+ # Platform scopes
56
+ scope :from_hybrid_app, -> { where(app_platform: "hybrid") }
57
+ scope :from_native_app, -> { where.not(app_platform: "native") }
58
+ scope :from_website, -> { where(app_platform: nil) }
59
+
60
+ # Location scopes
61
+ scope :from_country, ->(country_code) { where(country_code: country_code.to_s.upcase) }
62
+ scope :from_city, ->(city) { where(city: city) }
63
+ scope :from_region, ->(region) { where(region: region) }
64
+
65
+ # User scopes
66
+ scope :authenticated, -> { where.not(user_id: nil) }
67
+ scope :anonymous, -> { where(user_id: nil) }
68
+
69
+ # Ordering scopes
70
+ scope :recent, -> { order(viewed_at: :desc) }
71
+ scope :oldest, -> { order(viewed_at: :asc) }
72
+
73
+ # Analytics methods
74
+ class << self
75
+ def unique_count(period = :all_time)
76
+ scope = period == :all_time ? self : send(period)
77
+ scope.distinct.count(:visitor_id)
78
+ end
79
+
80
+ def top_countries(limit = 10)
81
+ where.not(country_name: nil)
82
+ .group(:country_code, :country_name)
83
+ .order("count_id DESC")
84
+ .limit(limit)
85
+ .count(:id)
86
+ end
87
+
88
+ def top_cities(limit = 10)
89
+ where.not(city: nil)
90
+ .group(:city, :country_name)
91
+ .order("count_id DESC")
92
+ .limit(limit)
93
+ .count(:id)
94
+ end
95
+
96
+ def top_regions(limit = 10)
97
+ where.not(region: nil)
98
+ .group(:region, :country_name)
99
+ .order("count_id DESC")
100
+ .limit(limit)
101
+ .count(:id)
102
+ end
103
+
104
+ def device_breakdown
105
+ group(:device_type)
106
+ .order("count_id DESC")
107
+ .count(:id)
108
+ end
109
+
110
+ def browser_breakdown(limit = 10)
111
+ where.not(browser: nil)
112
+ .group(:browser)
113
+ .order("count_id DESC")
114
+ .limit(limit)
115
+ .count(:id)
116
+ end
117
+
118
+ def os_breakdown(limit = 10)
119
+ where.not(os: nil)
120
+ .group(:os)
121
+ .order("count_id DESC")
122
+ .limit(limit)
123
+ .count(:id)
124
+ end
125
+
126
+ def platform_breakdown
127
+ group(:app_platform)
128
+ .order("count_id DESC")
129
+ .count(:id)
130
+ end
131
+
132
+ def hourly_distribution
133
+ group("EXTRACT(HOUR FROM viewed_at)::integer")
134
+ .order("EXTRACT(HOUR FROM viewed_at)::integer")
135
+ .count(:id)
136
+ end
137
+
138
+ def daily_distribution(days = 30)
139
+ where("viewed_at >= ?", days.days.ago)
140
+ .group("DATE(viewed_at)")
141
+ .order("DATE(viewed_at)")
142
+ .count(:id)
143
+ end
144
+
145
+ def weekly_distribution(weeks = 12)
146
+ where("viewed_at >= ?", weeks.weeks.ago)
147
+ .group("strftime('%Y-%W', viewed_at)")
148
+ .order("strftime('%Y-%W', viewed_at)")
149
+ .count(:id)
150
+ end
151
+
152
+ def monthly_distribution(months = 12)
153
+ where("viewed_at >= ?", months.months.ago)
154
+ .group("strftime('%Y-%m', viewed_at)")
155
+ .order("strftime('%Y-%m', viewed_at)")
156
+ .count(:id)
157
+ end
158
+
159
+ # For map visualization - returns array of [lat, lng, count]
160
+ def location_heatmap
161
+ where.not(latitude: nil, longitude: nil)
162
+ .group(:latitude, :longitude)
163
+ .count(:id)
164
+ .map { |(lat, lng), count| { lat: lat.to_f, lng: lng.to_f, count: count } }
165
+ end
166
+
167
+ # Referrer analysis
168
+ def top_referrers(limit = 10)
169
+ where.not(referrer: nil)
170
+ .where.not(referrer: "")
171
+ .group(:referrer)
172
+ .order("count_id DESC")
173
+ .limit(limit)
174
+ .count(:id)
175
+ end
176
+
177
+ # Growth metrics
178
+ def growth_rate(period = :last_30_days)
179
+ current_period = send(period).count
180
+
181
+ previous_period_start = case period
182
+ when :today then 1.day.ago.beginning_of_day
183
+ when :this_week then 1.week.ago.beginning_of_week
184
+ when :this_month then 1.month.ago.beginning_of_month
185
+ when :last_30_days then 60.days.ago
186
+ else 1.day.ago
187
+ end
188
+
189
+ previous_period_end = case period
190
+ when :today then 1.day.ago.end_of_day
191
+ when :this_week then 1.week.ago.end_of_week
192
+ when :this_month then 1.month.ago.end_of_month
193
+ when :last_30_days then 30.days.ago
194
+ else Time.current
195
+ end
196
+
197
+ previous_period = where(viewed_at: previous_period_start..previous_period_end).count
198
+
199
+ return 0 if previous_period.zero?
200
+ ((current_period - previous_period).to_f / previous_period * 100).round(2)
201
+ end
202
+ end
203
+
204
+ # Instance methods
205
+ def web_browser?
206
+ app_platform.nil?
207
+ end
208
+
209
+ def hybrid_app?
210
+ app_platform.present?
211
+ end
212
+
213
+ def mobile_device?
214
+ device_type.in?(%w[mobile tablet])
215
+ end
216
+
217
+ def desktop_device?
218
+ device_type == "desktop"
219
+ end
220
+
221
+ def authenticated?
222
+ user_id.present?
223
+ end
224
+
225
+ def anonymous?
226
+ user_id.nil?
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContentSignals
4
+ class DeviceDetectorService
5
+ def self.detect(user_agent, app_platform: nil)
6
+ return nil if user_agent.blank?
7
+ return nil unless defined?(Browser)
8
+
9
+ browser = Browser.new(user_agent)
10
+
11
+ {
12
+ device_type: detect_device_type(browser, app_platform),
13
+ browser: browser.name,
14
+ os: browser.platform.name
15
+ }
16
+ rescue => e
17
+ Rails.logger.error "Device detection error: #{e.message}"
18
+ {
19
+ device_type: app_platform.present? ? 'hybrid_app' : 'desktop',
20
+ browser: 'Unknown',
21
+ os: 'Unknown'
22
+ }
23
+ end
24
+
25
+ def self.detect_device_type(browser, app_platform)
26
+ # If it's from a mobile app, always mark as hybrid_app
27
+ return 'hybrid_app' if app_platform.present?
28
+
29
+ return 'mobile' if browser.device.mobile?
30
+ return 'tablet' if browser.device.tablet?
31
+
32
+ 'desktop'
33
+ end
34
+
35
+ def self.webview?(user_agent)
36
+ return false if user_agent.blank?
37
+
38
+ ua = user_agent.to_s.downcase
39
+ ua.match?(/wv|webview|; wv\)/)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContentSignals
4
+ class PageViewTracker
5
+ def self.track(trackable:, request:, user: nil)
6
+ new(trackable, request, user).track
7
+ end
8
+
9
+ def initialize(trackable, request, user)
10
+ @trackable = trackable
11
+ @user = user
12
+ @request = request
13
+ @visitor_id = identify_visitor
14
+ end
15
+
16
+ def track
17
+ # Track unique view if first time today (optional, requires Redis)
18
+ track_unique_view if unique_today?
19
+
20
+ # Gather all tracking data
21
+ tracking_data = compile_tracking_data
22
+
23
+ # Store detailed view record asynchronously
24
+ # Note: counter_cache on PageView model will automatically increment page_views_count
25
+ ContentSignals::TrackPageViewJob.perform_later(
26
+ @trackable.class.name,
27
+ @trackable.id,
28
+ @user&.id,
29
+ tracking_data
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ def identify_visitor
36
+ # 1. Authenticated user (most reliable)
37
+ return "user_#{@user.id}" if @user
38
+
39
+ # 2. Device ID from mobile app (persistent across app sessions)
40
+ device_id = @request.headers['X-Device-ID'] || @request.params[:device_id]
41
+ return "device_#{device_id}" if device_id.present?
42
+
43
+ # 3. Browser cookie (set by controller concern, read here)
44
+ if @request.respond_to?(:cookie_jar)
45
+ cookie_id = @request.cookie_jar.signed[:visitor_id]
46
+ return cookie_id if cookie_id.present?
47
+ end
48
+
49
+ # 4. Fallback to IP + User Agent hash (for non-cookie scenarios)
50
+ "anon_#{Digest::SHA256.hexdigest("#{@request.ip}:#{@request.user_agent}")[0..15]}"
51
+ end
52
+
53
+ def unique_today?
54
+ return true unless redis_enabled?
55
+
56
+ key = unique_view_key
57
+ !Rails.cache.exist?(key)
58
+ end
59
+
60
+ def track_unique_view
61
+ return unless redis_enabled?
62
+
63
+ key = unique_view_key
64
+ Rails.cache.write(key, true, expires_in: 24.hours)
65
+
66
+ # Also increment daily unique counter
67
+ daily_key = "page_unique_views:#{@trackable.class.name}:#{@trackable.id}:#{Date.current}"
68
+ Rails.cache.increment(daily_key, 1)
69
+ rescue => e
70
+ Rails.logger.error "Failed to track unique view: #{e.message}"
71
+ end
72
+
73
+ def unique_view_key
74
+ tenant_part = current_tenant_id ? "tenant_#{current_tenant_id}:" : ""
75
+ "page_view:#{tenant_part}#{@trackable.class.name}:#{@trackable.id}:visitor_#{@visitor_id}:#{Date.current}"
76
+ end
77
+
78
+ def compile_tracking_data
79
+ location_data = ContentSignals::VisitorLocationService.locate(@request.ip) || {}
80
+ app_context = detect_app_context
81
+ device_data = ContentSignals::DeviceDetectorService.detect(
82
+ @request.user_agent,
83
+ app_platform: app_context[:app_platform]
84
+ ) || {}
85
+
86
+ {
87
+ tenant_id: current_tenant_id,
88
+ visitor_id: @visitor_id,
89
+ ip_address: @request.ip,
90
+ user_agent: @request.user_agent,
91
+ referrer: @request.referrer,
92
+ locale: detect_locale,
93
+ viewed_at: Time.current,
94
+ app_platform: app_context[:app_platform],
95
+ app_version: app_context[:app_version],
96
+ device_id: app_context[:device_id]
97
+ }.merge(location_data).merge(device_data)
98
+ end
99
+
100
+ def detect_app_context
101
+ {
102
+ app_platform: @request.headers['X-App-Platform'] || detect_platform_from_ua,
103
+ app_version: @request.headers['X-App-Version'],
104
+ device_id: @request.headers['X-Device-ID'] || @request.params[:device_id]
105
+ }
106
+ end
107
+
108
+ def detect_platform_from_ua
109
+ ua = @request.user_agent.to_s.downcase
110
+ return 'capacitor' if ua.include?('capacitor')
111
+ return 'cordova' if ua.include?('cordova')
112
+ return 'react_native' if ua.include?('react-native')
113
+ return 'flutter' if ua.include?('flutter')
114
+ return 'webview' if ua.match?(/wv|webview/)
115
+ nil
116
+ end
117
+
118
+ def detect_locale
119
+ accept_language = @request.headers['Accept-Language']
120
+ return I18n.locale.to_s unless accept_language
121
+
122
+ accept_language.split(',').first&.strip || I18n.locale.to_s
123
+ end
124
+
125
+ def current_tenant_id
126
+ return nil unless ContentSignals.configuration.multitenancy?
127
+
128
+ method_name = ContentSignals.configuration.current_tenant_method
129
+ return nil unless method_name
130
+
131
+ # Try to get tenant_id from controller instance
132
+ controller = @request.env['action_controller.instance']
133
+ return nil unless controller
134
+
135
+ controller.send(method_name) if controller.respond_to?(method_name, true)
136
+ rescue => e
137
+ Rails.logger.error "Failed to get tenant_id: #{e.message}"
138
+ nil
139
+ end
140
+
141
+ def redis_enabled?
142
+ ContentSignals.configuration.redis_enabled? && Rails.cache.respond_to?(:redis)
143
+ rescue
144
+ false
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContentSignals
4
+ class VisitorLocationService
5
+ 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
31
+ end
32
+
33
+ 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
78
+ end
79
+ end
80
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ContentSignals
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
@@ -1,8 +1,60 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "content_signals/version"
4
+ require_relative "content_signals/configuration"
5
+
6
+ # Optional dependencies - gracefully handle if not installed
7
+ begin
8
+ require 'browser'
9
+ rescue LoadError
10
+ # Browser gem not installed - device detection will be disabled
11
+ end
12
+
13
+ begin
14
+ require 'maxmind/geoip2'
15
+ rescue LoadError
16
+ # MaxMind gem not installed - geolocation will be disabled
17
+ end
4
18
 
5
19
  module ContentSignals
6
20
  class Error < StandardError; end
7
- # Your code goes here...
21
+
22
+ class << self
23
+ attr_writer :configuration
24
+
25
+ def configuration
26
+ @configuration ||= Configuration.new
27
+ end
28
+
29
+ def configure
30
+ yield(configuration)
31
+ end
32
+
33
+ def reset_configuration!
34
+ @configuration = Configuration.new
35
+ end
36
+ end
37
+ end
38
+
39
+ # Load Rails engine if Rails is available
40
+ require_relative "content_signals/engine" if defined?(Rails::Engine)
41
+
42
+ # Always load models when ActiveRecord is available
43
+ if defined?(ActiveRecord)
44
+ require_relative "content_signals/models/page_view"
45
+ end
46
+
47
+ # Load services
48
+ require_relative "content_signals/services/page_view_tracker"
49
+ require_relative "content_signals/services/visitor_location_service"
50
+ require_relative "content_signals/services/device_detector_service"
51
+
52
+ # Load jobs if ActiveJob is available
53
+ if defined?(ActiveJob)
54
+ require_relative "content_signals/jobs/track_page_view_job"
55
+ end
56
+
57
+ # Load concerns if ActiveSupport is available
58
+ if defined?(ActiveSupport::Concern)
59
+ require_relative "content_signals/concerns/trackable_page_views"
8
60
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/migration'
5
+
6
+ module ContentSignals
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path('templates', __dir__)
12
+
13
+ desc "Creates ContentSignals migration and initializer"
14
+
15
+ def self.next_migration_number(path)
16
+ next_migration_number = current_migration_number(path) + 1
17
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
18
+ end
19
+
20
+ def copy_migration
21
+ migration_template "create_content_signals_page_views.rb.erb",
22
+ "db/migrate/create_content_signals_page_views.rb",
23
+ migration_version: migration_version
24
+ end
25
+
26
+ def copy_initializer
27
+ template "content_signals.rb.erb", "config/initializers/content_signals.rb"
28
+ end
29
+
30
+ def show_readme
31
+ readme "README" if behavior == :invoke
32
+ end
33
+
34
+ private
35
+
36
+ def migration_version
37
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,36 @@
1
+ ===============================================================================
2
+
3
+ ContentSignals has been installed!
4
+
5
+ Next steps:
6
+
7
+ 1. Run migrations:
8
+ rails db:migrate
9
+
10
+ 2. Add page_views_count counter to your trackable models:
11
+ rails generate migration AddPageViewsCountToYourModel page_views_count:integer
12
+
13
+ Then edit the migration to add default value and index:
14
+
15
+ add_column :your_models, :page_views_count, :integer, default: 0, null: false
16
+ add_index :your_models, :page_views_count
17
+
18
+ 3. Include tracking in your controllers:
19
+
20
+ class YourController < ApplicationController
21
+ include ContentSignals::TrackablePageViews
22
+
23
+ def show
24
+ @record = YourModel.find(params[:id])
25
+ # Page views are tracked automatically!
26
+ end
27
+ end
28
+
29
+ 4. (Optional) Configure geolocation:
30
+ - Download MaxMind GeoLite2 database: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
31
+ - Place in db/GeoLite2-City.mmdb
32
+ - Update config/initializers/content_signals.rb
33
+
34
+ For more information, see: https://github.com/yourusername/content_signals
35
+
36
+ ===============================================================================
@@ -0,0 +1,19 @@
1
+ ContentSignals.configure do |config|
2
+ # Multi-tenancy (optional)
3
+ # Enable if your app has multiple tenants/accounts
4
+ config.multitenancy = false
5
+ config.current_tenant_method = :current_tenant_id
6
+
7
+ # Redis (optional, for unique visitor tracking)
8
+ # Requires Redis gem and connection
9
+ config.redis_enabled = true
10
+ config.redis_namespace = "content_signals"
11
+
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")
15
+
16
+ # Tracking preferences
17
+ config.track_bots = false # Set to true to track bot/crawler visits
18
+ config.track_admins = false # Set to true to track admin user visits
19
+ end