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.
- checksums.yaml +4 -4
- data/.rubocop.yml +48 -0
- data/.rubocop_todo.yml +146 -0
- data/COUNTERS.MD +1522 -0
- data/README.md +400 -8
- data/READY_TO_GEM.md +375 -0
- data/STRUCTURE.md +108 -0
- data/lib/content_signals/concerns/trackable_page_views.rb +88 -0
- data/lib/content_signals/configuration.rb +41 -0
- data/lib/content_signals/engine.rb +33 -0
- data/lib/content_signals/jobs/track_page_view_job.rb +43 -0
- data/lib/content_signals/models/page_view.rb +229 -0
- data/lib/content_signals/services/device_detector_service.rb +42 -0
- data/lib/content_signals/services/page_view_tracker.rb +147 -0
- data/lib/content_signals/services/visitor_location_service.rb +80 -0
- data/lib/content_signals/version.rb +1 -1
- data/lib/content_signals.rb +53 -1
- data/lib/generators/content_signals/install_generator.rb +41 -0
- data/lib/generators/content_signals/templates/README +36 -0
- data/lib/generators/content_signals/templates/content_signals.rb.erb +19 -0
- data/lib/generators/content_signals/templates/create_content_signals_page_views.rb.erb +48 -0
- metadata +31 -14
|
@@ -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
|
data/lib/content_signals.rb
CHANGED
|
@@ -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
|
-
|
|
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
|