Usa screenshot di pagina come sfondo heatmap al posto dell’iframe.
Cattura periodica client-side (con consenso), storage ActiveStorage e overlay allineato all’immagine. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -75,7 +75,7 @@ module Admin
|
|||||||
@max_scroll = stats.maximum(:max_scroll_pct).to_i
|
@max_scroll = stats.maximum(:max_scroll_pct).to_i
|
||||||
@avg_scroll = @scroll_samples.positive? ? (@scroll_sum.to_f / @scroll_samples).round : 0
|
@avg_scroll = @scroll_samples.positive? ? (@scroll_sum.to_f / @scroll_samples).round : 0
|
||||||
@grid = AnalyticsPageCell::GRID_SIZE
|
@grid = AnalyticsPageCell::GRID_SIZE
|
||||||
@preview_url = preview_url_for(@page_path)
|
@snapshot = find_snapshot(@page_path, @filters[:device])
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -88,11 +88,14 @@ module Admin
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def preview_url_for(path)
|
def find_snapshot(page_path, device)
|
||||||
return nil if path.blank?
|
scope = AnalyticsPageSnapshot.where(page_path: page_path)
|
||||||
return nil if path.include?(":")
|
if device.present? && AnalyticsEvent::DEVICES.include?(device)
|
||||||
|
snap = scope.find_by(device: device)
|
||||||
|
return snap if snap&.image&.attached?
|
||||||
|
end
|
||||||
|
|
||||||
"#{MatchLiveTv.app_public_url.chomp('/')}#{path}"
|
scope.order(captured_at: :desc).detect { |s| s.image.attached? }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Analytics
|
||||||
|
class SnapshotsController < ActionController::API
|
||||||
|
def create
|
||||||
|
result = Analytics::SnapshotIngest.new(
|
||||||
|
path: params[:path] || params[:page_path],
|
||||||
|
device: params[:device],
|
||||||
|
width: params[:width],
|
||||||
|
height: params[:height],
|
||||||
|
image: params[:image],
|
||||||
|
remote_ip: request.remote_ip
|
||||||
|
).call
|
||||||
|
|
||||||
|
return head :too_many_requests if result.rate_limited
|
||||||
|
return render json: { ok: false, error: result.error }, status: :unprocessable_content unless result.ok
|
||||||
|
|
||||||
|
render json: { ok: true, skipped: result.skipped }, status: :accepted
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AnalyticsPageSnapshot < ApplicationRecord
|
||||||
|
self.table_name = "analytics_page_snapshots"
|
||||||
|
|
||||||
|
has_one_attached :image
|
||||||
|
|
||||||
|
validates :page_path, presence: true
|
||||||
|
validates :device, inclusion: { in: AnalyticsEvent::DEVICES }
|
||||||
|
validates :width, :height, numericality: { greater_than: 0 }
|
||||||
|
validates :captured_at, presence: true
|
||||||
|
end
|
||||||
@@ -21,6 +21,10 @@ module Analytics
|
|||||||
AnalyticsEvent.where("occurred_at < ?", retention.ago).delete_all
|
AnalyticsEvent.where("occurred_at < ?", retention.ago).delete_all
|
||||||
AnalyticsPageCell.where("day < ?", retention.ago.to_date).delete_all
|
AnalyticsPageCell.where("day < ?", retention.ago.to_date).delete_all
|
||||||
AnalyticsPageStat.where("day < ?", retention.ago.to_date).delete_all
|
AnalyticsPageStat.where("day < ?", retention.ago.to_date).delete_all
|
||||||
|
AnalyticsPageSnapshot.where("captured_at < ?", retention.ago).find_each do |snap|
|
||||||
|
snap.image.purge if snap.image.attached?
|
||||||
|
snap.destroy!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Analytics
|
||||||
|
class SnapshotIngest
|
||||||
|
MAX_BYTES = 1_800_000
|
||||||
|
MIN_REFRESH = 12.hours
|
||||||
|
RATE_LIMIT_PER_HOUR = 20
|
||||||
|
|
||||||
|
Result = Struct.new(:ok, :skipped, :rate_limited, :error, keyword_init: true)
|
||||||
|
|
||||||
|
def initialize(path:, device:, width:, height:, image:, remote_ip:)
|
||||||
|
@path = path
|
||||||
|
@device = device.to_s
|
||||||
|
@width = width
|
||||||
|
@height = height
|
||||||
|
@image = image
|
||||||
|
@remote_ip = remote_ip.to_s.presence || "unknown"
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return Result.new(ok: false, skipped: false, rate_limited: true) if rate_limited?
|
||||||
|
|
||||||
|
page_path = PathNormalizer.normalize(@path)
|
||||||
|
return Result.new(ok: false, skipped: true, error: "path") if PathNormalizer.excluded?(page_path)
|
||||||
|
return Result.new(ok: false, skipped: true, error: "device") unless AnalyticsEvent::DEVICES.include?(@device)
|
||||||
|
return Result.new(ok: false, skipped: true, error: "image") unless valid_image?
|
||||||
|
|
||||||
|
width = Integer(@width)
|
||||||
|
height = Integer(@height)
|
||||||
|
return Result.new(ok: false, skipped: true, error: "size") if width < 200 || height < 200 || width > 6000 || height > 20000
|
||||||
|
|
||||||
|
snap = AnalyticsPageSnapshot.find_or_initialize_by(page_path: page_path, device: @device)
|
||||||
|
if snap.persisted? && snap.captured_at.present? && snap.captured_at > MIN_REFRESH.ago && snap.image.attached?
|
||||||
|
return Result.new(ok: true, skipped: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
snap.width = width
|
||||||
|
snap.height = height
|
||||||
|
snap.captured_at = Time.current
|
||||||
|
snap.save!
|
||||||
|
snap.image.purge if snap.image.attached?
|
||||||
|
snap.image.attach(
|
||||||
|
io: image_io,
|
||||||
|
filename: "analytics-#{Digest::SHA1.hexdigest(page_path)[0, 12]}-#{@device}.jpg",
|
||||||
|
content_type: "image/jpeg"
|
||||||
|
)
|
||||||
|
|
||||||
|
Result.new(ok: true, skipped: false)
|
||||||
|
rescue ArgumentError, TypeError, ActiveRecord::RecordInvalid => e
|
||||||
|
Result.new(ok: false, skipped: true, error: e.class.name)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def rate_limited?
|
||||||
|
key = "analytics:snapshot:#{@remote_ip}"
|
||||||
|
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||||
|
count = redis.incr(key)
|
||||||
|
redis.expire(key, 3600) if count == 1
|
||||||
|
count > RATE_LIMIT_PER_HOUR
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn("[analytics] snapshot rate limit unavailable: #{e.class}")
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_image?
|
||||||
|
return false if @image.blank?
|
||||||
|
return false unless @image.respond_to?(:tempfile) || @image.respond_to?(:read)
|
||||||
|
return false if @image.respond_to?(:size) && @image.size.to_i > MAX_BYTES
|
||||||
|
|
||||||
|
content_type = @image.content_type.to_s if @image.respond_to?(:content_type)
|
||||||
|
content_type.blank? || content_type.start_with?("image/")
|
||||||
|
end
|
||||||
|
|
||||||
|
def image_io
|
||||||
|
if @image.respond_to?(:tempfile)
|
||||||
|
@image.tempfile.rewind
|
||||||
|
@image.tempfile
|
||||||
|
else
|
||||||
|
StringIO.new(@image.read)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -95,27 +95,43 @@
|
|||||||
|
|
||||||
<% if @cells.any? %>
|
<% if @cells.any? %>
|
||||||
<div class="admin-heatmap-stage" id="admin-heatmap-stage">
|
<div class="admin-heatmap-stage" id="admin-heatmap-stage">
|
||||||
<% if @preview_url.present? %>
|
<% if @snapshot&.image&.attached? %>
|
||||||
<iframe
|
<div
|
||||||
class="admin-heatmap-frame"
|
class="admin-heatmap-scaler"
|
||||||
src="<%= @preview_url %>"
|
id="admin-heatmap-scaler"
|
||||||
title="<%= t("admin.analytics.show.preview_title") %>"
|
data-design-width="<%= [@snapshot.width, 1].max %>"
|
||||||
loading="lazy"
|
data-design-height="<%= [@snapshot.height, 1].max %>"
|
||||||
referrerpolicy="no-referrer"
|
>
|
||||||
></iframe>
|
<%= image_tag url_for(@snapshot.image),
|
||||||
|
class: "admin-heatmap-shot",
|
||||||
|
id: "admin-heatmap-shot",
|
||||||
|
alt: t("admin.analytics.show.preview_title") %>
|
||||||
|
<canvas
|
||||||
|
id="admin-heatmap"
|
||||||
|
class="admin-heatmap-overlay"
|
||||||
|
data-grid="<%= @grid %>"
|
||||||
|
data-max="<%= [@max_weight, 1].max %>"
|
||||||
|
data-cells="<%= @cells.map { |(x, y), c| { x: x, y: y, c: c } }.to_json %>"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
<p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;">
|
||||||
|
<%= t("admin.analytics.show.snapshot_meta",
|
||||||
|
device: @snapshot.device,
|
||||||
|
at: l(@snapshot.captured_at, format: :short)) %>
|
||||||
|
</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="admin-heatmap-fallback" aria-hidden="true"></div>
|
<div class="admin-heatmap-fallback" aria-hidden="true"></div>
|
||||||
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.preview_unavailable") %></p>
|
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.snapshot_missing") %></p>
|
||||||
|
<canvas
|
||||||
|
id="admin-heatmap"
|
||||||
|
class="admin-heatmap-overlay"
|
||||||
|
data-grid="<%= @grid %>"
|
||||||
|
data-max="<%= [@max_weight, 1].max %>"
|
||||||
|
data-cells="<%= @cells.map { |(x, y), c| { x: x, y: y, c: c } }.to_json %>"
|
||||||
|
></canvas>
|
||||||
<% end %>
|
<% end %>
|
||||||
<canvas
|
|
||||||
id="admin-heatmap"
|
|
||||||
class="admin-heatmap-overlay"
|
|
||||||
data-grid="<%= @grid %>"
|
|
||||||
data-max="<%= [@max_weight, 1].max %>"
|
|
||||||
data-cells="<%= @cells.map { |(x, y), c| { x: x, y: y, c: c } }.to_json %>"
|
|
||||||
></canvas>
|
|
||||||
</div>
|
</div>
|
||||||
<script src="/admin-analytics-heatmap.js?v=2" defer></script>
|
<script src="/admin-analytics-heatmap.js?v=4" defer></script>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="empty"><%= t("admin.analytics.show.no_points") %></p>
|
<p class="empty"><%= t("admin.analytics.show.no_points") %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<link rel="stylesheet" href="/admin.css?v=13">
|
<link rel="stylesheet" href="/admin.css?v=15">
|
||||||
<% if content_for?(:replay_archive_styles) %>
|
<% if content_for?(:replay_archive_styles) %>
|
||||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<script src="/password-toggle.js?v=2" defer></script>
|
<script src="/password-toggle.js?v=2" defer></script>
|
||||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||||
<script src="/confirm-forms.js?v=7" defer></script>
|
<script src="/confirm-forms.js?v=7" defer></script>
|
||||||
<script src="/site-analytics.js?v=3" defer></script>
|
<script src="/site-analytics.js?v=4" defer></script>
|
||||||
<script src="/cookie-consent.js?v=2" defer></script>
|
<script src="/cookie-consent.js?v=2" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<%= render "shared/marketing_footer" %>
|
<%= render "shared/marketing_footer" %>
|
||||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||||
<script src="/confirm-forms.js?v=7" defer></script>
|
<script src="/confirm-forms.js?v=7" defer></script>
|
||||||
<script src="/site-analytics.js?v=3" defer></script>
|
<script src="/site-analytics.js?v=4" defer></script>
|
||||||
<script src="/cookie-consent.js?v=2" defer></script>
|
<script src="/cookie-consent.js?v=2" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -383,9 +383,11 @@ de:
|
|||||||
scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%"
|
scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%"
|
||||||
heatmap_moves: Mausbewegungs-Karte
|
heatmap_moves: Mausbewegungs-Karte
|
||||||
heatmap_clicks: Klickkarte
|
heatmap_clicks: Klickkarte
|
||||||
heatmap_hint: Farbiges Overlay auf der Live-Seite (aktuelles Layout). Grün = wenig, Rot = viel.
|
heatmap_hint: Farbiges Overlay auf dem Seiten-Screenshot. Grün = wenig, Rot = viel.
|
||||||
preview_title: Seitenvorschau
|
preview_title: Seiten-Screenshot
|
||||||
preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id). Nur Overlay.
|
preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id).
|
||||||
|
snapshot_missing: Noch kein Screenshot. Seite mit Analytics-Einwilligung (Desktop) besuchen und erneut versuchen.
|
||||||
|
snapshot_meta: "Screenshot %{device} · %{at}"
|
||||||
no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum.
|
no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum.
|
||||||
heatmap_title: Klickkarte
|
heatmap_title: Klickkarte
|
||||||
no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum.
|
no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum.
|
||||||
|
|||||||
@@ -383,9 +383,11 @@ en:
|
|||||||
scroll_hint: "Average %{avg}% · max %{max}%"
|
scroll_hint: "Average %{avg}% · max %{max}%"
|
||||||
heatmap_moves: Mouse-move map
|
heatmap_moves: Mouse-move map
|
||||||
heatmap_clicks: Click map
|
heatmap_clicks: Click map
|
||||||
heatmap_hint: Colored overlay on the live page (current layout). Green = low, red = high.
|
heatmap_hint: Colored overlay on the page screenshot. Green = low, red = high.
|
||||||
preview_title: Page preview
|
preview_title: Page screenshot
|
||||||
preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id). Overlay only.
|
preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id).
|
||||||
|
snapshot_missing: No screenshot yet. Visit the page on the site with analytics consent (desktop), then retry in a few seconds.
|
||||||
|
snapshot_meta: "Screenshot %{device} · captured %{at}"
|
||||||
no_points: No aggregated points for this layer in the period.
|
no_points: No aggregated points for this layer in the period.
|
||||||
heatmap_title: Click map
|
heatmap_title: Click map
|
||||||
no_clicks: No aggregated clicks for this page in the period.
|
no_clicks: No aggregated clicks for this page in the period.
|
||||||
|
|||||||
@@ -383,9 +383,11 @@ es:
|
|||||||
scroll_hint: "Media %{avg}% · máximo %{max}%"
|
scroll_hint: "Media %{avg}% · máximo %{max}%"
|
||||||
heatmap_moves: Mapa de movimientos
|
heatmap_moves: Mapa de movimientos
|
||||||
heatmap_clicks: Mapa de clics
|
heatmap_clicks: Mapa de clics
|
||||||
heatmap_hint: Superposición de color sobre la página en vivo (layout actual). Verde = poco, rojo = mucho.
|
heatmap_hint: Superposición de color sobre la captura de la página. Verde = poco, rojo = mucho.
|
||||||
preview_title: Vista previa de la página
|
preview_title: Captura de página
|
||||||
preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id). Solo overlay.
|
preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id).
|
||||||
|
snapshot_missing: Aún no hay captura. Visita la página con consentimiento analytics (escritorio) y vuelve a intentarlo.
|
||||||
|
snapshot_meta: "Captura %{device} · %{at}"
|
||||||
no_points: No hay puntos agregados para esta capa en el periodo.
|
no_points: No hay puntos agregados para esta capa en el periodo.
|
||||||
heatmap_title: Mapa de clics
|
heatmap_title: Mapa de clics
|
||||||
no_clicks: No hay clics agregados para esta página en el periodo.
|
no_clicks: No hay clics agregados para esta página en el periodo.
|
||||||
|
|||||||
@@ -383,9 +383,11 @@ fr:
|
|||||||
scroll_hint: "Moyenne %{avg}% · maximum %{max}%"
|
scroll_hint: "Moyenne %{avg}% · maximum %{max}%"
|
||||||
heatmap_moves: Carte des mouvements
|
heatmap_moves: Carte des mouvements
|
||||||
heatmap_clicks: Carte des clics
|
heatmap_clicks: Carte des clics
|
||||||
heatmap_hint: Superposition colorée sur la page live (mise en page actuelle). Vert = faible, rouge = fort.
|
heatmap_hint: Superposition colorée sur la capture d’écran de la page. Vert = faible, rouge = fort.
|
||||||
preview_title: Aperçu de la page
|
preview_title: Capture de page
|
||||||
preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id). Overlay seul.
|
preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id).
|
||||||
|
snapshot_missing: Pas encore de capture. Visitez la page avec consentement analytics (desktop), puis réessayez.
|
||||||
|
snapshot_meta: "Capture %{device} · %{at}"
|
||||||
no_points: Aucun point agrégé pour cette couche sur la période.
|
no_points: Aucun point agrégé pour cette couche sur la période.
|
||||||
heatmap_title: Carte des clics
|
heatmap_title: Carte des clics
|
||||||
no_clicks: Aucun clic agrégé pour cette page sur la période.
|
no_clicks: Aucun clic agrégé pour cette page sur la période.
|
||||||
|
|||||||
@@ -404,9 +404,11 @@ it:
|
|||||||
scroll_hint: "Media %{avg}% · massimo %{max}%"
|
scroll_hint: "Media %{avg}% · massimo %{max}%"
|
||||||
heatmap_moves: Mappa movimenti mouse
|
heatmap_moves: Mappa movimenti mouse
|
||||||
heatmap_clicks: Mappa click
|
heatmap_clicks: Mappa click
|
||||||
heatmap_hint: Overlay colorato sulla pagina live (layout attuale). Verde = poco, rosso = molto.
|
heatmap_hint: Overlay colorato sullo screenshot della pagina. Verde = poco, rosso = molto.
|
||||||
preview_title: Anteprima pagina
|
preview_title: Screenshot pagina
|
||||||
preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id). Mostra solo l’overlay.
|
preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id).
|
||||||
|
snapshot_missing: Nessuno screenshot ancora. Visita la pagina sul sito con consenso analytics (desktop) e riprova tra qualche secondo.
|
||||||
|
snapshot_meta: "Screenshot %{device} · catturato %{at}"
|
||||||
no_points: Nessun punto aggregato per questo livello nel periodo.
|
no_points: Nessun punto aggregato per questo livello nel periodo.
|
||||||
heatmap_title: Mappa click
|
heatmap_title: Mappa click
|
||||||
no_clicks: Nessun click aggregato per questa pagina nel periodo.
|
no_clicks: Nessun click aggregato per questa pagina nel periodo.
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ en:
|
|||||||
s4_2_row3_duration: 24 hours
|
s4_2_row3_duration: 24 hours
|
||||||
s4_2_row3_provider: Google
|
s4_2_row3_provider: Google
|
||||||
s4_2_row4_name_html: "Heatmap / scroll (first-party)"
|
s4_2_row4_name_html: "Heatmap / scroll (first-party)"
|
||||||
s4_2_row4_purpose: Aggregated mouse-move, click and scroll-depth counts by page and device type (no user identifiers)
|
s4_2_row4_purpose: Aggregated mouse-move, click and scroll-depth counts, plus page screenshots for heatmaps (no user identifiers)
|
||||||
s4_2_row4_duration: Aggregates kept up to 30 days
|
s4_2_row4_duration: Aggregates kept up to 30 days
|
||||||
s4_2_row4_provider: Match Live TV (first-party)
|
s4_2_row4_provider: Match Live TV (first-party)
|
||||||
s4_2_p2_html: "You can withdraw consent from the banner or from your browser settings. Google information: %{google_privacy_link}, %{google_optout_link}."
|
s4_2_p2_html: "You can withdraw consent from the banner or from your browser settings. Google information: %{google_privacy_link}, %{google_optout_link}."
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ it:
|
|||||||
s4_2_row3_duration: 24 ore
|
s4_2_row3_duration: 24 ore
|
||||||
s4_2_row3_provider: Google
|
s4_2_row3_provider: Google
|
||||||
s4_2_row4_name_html: "Heatmap / scroll (prima parte)"
|
s4_2_row4_name_html: "Heatmap / scroll (prima parte)"
|
||||||
s4_2_row4_purpose: Conteggi aggregati di movimenti mouse, click e profondità di scroll per pagina e tipo di dispositivo (nessun identificativo utente)
|
s4_2_row4_purpose: Conteggi aggregati di movimenti mouse, click e profondità di scroll, più screenshot della pagina per le heatmaps (nessun identificativo utente)
|
||||||
s4_2_row4_duration: Aggregati fino a 30 giorni
|
s4_2_row4_duration: Aggregati fino a 30 giorni
|
||||||
s4_2_row4_provider: Match Live TV (prima parte)
|
s4_2_row4_provider: Match Live TV (prima parte)
|
||||||
s4_2_p2_html: "Puoi revocare il consenso dal banner o dalle impostazioni del browser. Informazioni Google: %{google_privacy_link}, %{google_optout_link}."
|
s4_2_p2_html: "Puoi revocare il consenso dal banner o dalle impostazioni del browser. Informazioni Google: %{google_privacy_link}, %{google_optout_link}."
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ Rails.application.routes.draw do
|
|||||||
post "internal/validate_publish", to: "webhooks/mediamtx#validate_publish"
|
post "internal/validate_publish", to: "webhooks/mediamtx#validate_publish"
|
||||||
|
|
||||||
post "analytics/events", to: "analytics/events#create"
|
post "analytics/events", to: "analytics/events#create"
|
||||||
|
post "analytics/snapshot", to: "analytics/snapshots#create"
|
||||||
|
|
||||||
namespace :admin do
|
namespace :admin do
|
||||||
get "login", to: "auth#new", as: :login
|
get "login", to: "auth#new", as: :login
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateAnalyticsPageSnapshots < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :analytics_page_snapshots, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
|
||||||
|
t.string :page_path, null: false
|
||||||
|
t.string :device, null: false
|
||||||
|
t.integer :width, null: false, default: 0
|
||||||
|
t.integer :height, null: false, default: 0
|
||||||
|
t.datetime :captured_at, null: false
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :analytics_page_snapshots, %i[page_path device], unique: true, name: "index_analytics_page_snapshots_unique"
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+12
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2026_08_20_210000) do
|
ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
@@ -79,6 +79,17 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_210000) do
|
|||||||
t.index ["day", "page_path", "device", "cell_x", "cell_y"], name: "index_analytics_page_cells_unique", unique: true
|
t.index ["day", "page_path", "device", "cell_x", "cell_y"], name: "index_analytics_page_cells_unique", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "analytics_page_snapshots", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.string "page_path", null: false
|
||||||
|
t.string "device", null: false
|
||||||
|
t.integer "width", default: 0, null: false
|
||||||
|
t.integer "height", default: 0, null: false
|
||||||
|
t.datetime "captured_at", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["page_path", "device"], name: "index_analytics_page_snapshots_unique", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
create_table "analytics_page_stats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "analytics_page_stats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.date "day", null: false
|
t.date "day", null: false
|
||||||
t.string "page_path", null: false
|
t.string "page_path", null: false
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
/*! Admin heatmap overlay renderer (Hotjar-like radial blobs over page preview). */
|
/*! Admin heatmap overlay on captured page screenshot. */
|
||||||
(function () {
|
(function () {
|
||||||
function colorFor(t) {
|
function colorFor(t) {
|
||||||
// green -> yellow -> red
|
|
||||||
if (t < 0.33) {
|
if (t < 0.33) {
|
||||||
var k = t / 0.33;
|
var k = t / 0.33;
|
||||||
return [67 + (255 - 67) * k, 160 + (235 - 160) * k, 71 * (1 - k)];
|
return [67 + (255 - 67) * k, 160 + (235 - 160) * k, 71 * (1 - k)];
|
||||||
@@ -14,11 +13,36 @@
|
|||||||
return [255, 152 * (1 - k3), 0];
|
return [255, 152 * (1 - k3), 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function paint() {
|
function fit() {
|
||||||
var canvas = document.getElementById("admin-heatmap");
|
var canvas = document.getElementById("admin-heatmap");
|
||||||
var stage = document.getElementById("admin-heatmap-stage");
|
var stage = document.getElementById("admin-heatmap-stage");
|
||||||
|
var scaler = document.getElementById("admin-heatmap-scaler");
|
||||||
|
var shot = document.getElementById("admin-heatmap-shot");
|
||||||
if (!canvas || !stage) return;
|
if (!canvas || !stage) return;
|
||||||
|
|
||||||
|
var designW = 1200;
|
||||||
|
var designH = 900;
|
||||||
|
if (scaler) {
|
||||||
|
designW = parseInt(scaler.getAttribute("data-design-width"), 10) || designW;
|
||||||
|
designH = parseInt(scaler.getAttribute("data-design-height"), 10) || designH;
|
||||||
|
}
|
||||||
|
if (shot && shot.naturalWidth > 0) {
|
||||||
|
designW = shot.naturalWidth;
|
||||||
|
designH = shot.naturalHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scaler) {
|
||||||
|
scaler.style.width = designW + "px";
|
||||||
|
scaler.style.height = designH + "px";
|
||||||
|
var stageW = Math.max(stage.clientWidth, 320);
|
||||||
|
var scale = Math.min(1, stageW / designW);
|
||||||
|
scaler.style.transform = "scale(" + scale + ")";
|
||||||
|
stage.style.height = Math.max(420, Math.round(designH * scale)) + "px";
|
||||||
|
} else {
|
||||||
|
designW = Math.max(stage.clientWidth, 320);
|
||||||
|
designH = Math.max(stage.clientHeight, 480);
|
||||||
|
}
|
||||||
|
|
||||||
var grid = parseInt(canvas.getAttribute("data-grid"), 10) || 40;
|
var grid = parseInt(canvas.getAttribute("data-grid"), 10) || 40;
|
||||||
var max = parseInt(canvas.getAttribute("data-max"), 10) || 1;
|
var max = parseInt(canvas.getAttribute("data-max"), 10) || 1;
|
||||||
var cells = [];
|
var cells = [];
|
||||||
@@ -28,19 +52,17 @@
|
|||||||
cells = [];
|
cells = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var width = Math.max(stage.clientWidth, 320);
|
canvas.width = designW;
|
||||||
var height = Math.max(stage.clientHeight, 480);
|
canvas.height = designH;
|
||||||
canvas.width = width;
|
canvas.style.width = designW + "px";
|
||||||
canvas.height = height;
|
canvas.style.height = designH + "px";
|
||||||
canvas.style.width = width + "px";
|
|
||||||
canvas.style.height = height + "px";
|
|
||||||
|
|
||||||
var ctx = canvas.getContext("2d");
|
var ctx = canvas.getContext("2d");
|
||||||
ctx.clearRect(0, 0, width, height);
|
ctx.clearRect(0, 0, designW, designH);
|
||||||
|
|
||||||
var cellW = width / grid;
|
var cellW = designW / grid;
|
||||||
var cellH = height / grid;
|
var cellH = designH / grid;
|
||||||
var radius = Math.max(cellW, cellH) * 1.8;
|
var radius = Math.max(cellW, cellH) * 2.2;
|
||||||
|
|
||||||
cells.forEach(function (cell) {
|
cells.forEach(function (cell) {
|
||||||
var intensity = Math.min(1, cell.c / max);
|
var intensity = Math.min(1, cell.c / max);
|
||||||
@@ -48,9 +70,16 @@
|
|||||||
var cx = (cell.x + 0.5) * cellW;
|
var cx = (cell.x + 0.5) * cellW;
|
||||||
var cy = (cell.y + 0.5) * cellH;
|
var cy = (cell.y + 0.5) * cellH;
|
||||||
var rgb = colorFor(intensity);
|
var rgb = colorFor(intensity);
|
||||||
|
var alpha = 0.28 + intensity * 0.55;
|
||||||
var grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
var grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
||||||
grd.addColorStop(0, "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + (0.2 + intensity * 0.55) + ")");
|
grd.addColorStop(
|
||||||
grd.addColorStop(1, "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ",0)");
|
0,
|
||||||
|
"rgba(" + Math.round(rgb[0]) + "," + Math.round(rgb[1]) + "," + Math.round(rgb[2]) + "," + alpha + ")"
|
||||||
|
);
|
||||||
|
grd.addColorStop(
|
||||||
|
1,
|
||||||
|
"rgba(" + Math.round(rgb[0]) + "," + Math.round(rgb[1]) + "," + Math.round(rgb[2]) + ",0)"
|
||||||
|
);
|
||||||
ctx.fillStyle = grd;
|
ctx.fillStyle = grd;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||||
@@ -58,10 +87,19 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
var shot = document.getElementById("admin-heatmap-shot");
|
||||||
|
fit();
|
||||||
|
if (shot) {
|
||||||
|
if (shot.complete && shot.naturalWidth > 0) fit();
|
||||||
|
else shot.addEventListener("load", fit);
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", fit);
|
||||||
|
}
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", paint);
|
document.addEventListener("DOMContentLoaded", boot);
|
||||||
} else {
|
} else {
|
||||||
paint();
|
boot();
|
||||||
}
|
}
|
||||||
window.addEventListener("resize", paint);
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
+17
-11
@@ -962,22 +962,29 @@ body.admin-body {
|
|||||||
|
|
||||||
.admin-heatmap-stage {
|
.admin-heatmap-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
|
width: 100%;
|
||||||
min-height: 70vh;
|
min-height: 70vh;
|
||||||
max-height: 85vh;
|
max-height: 85vh;
|
||||||
border: 1px solid var(--card-border);
|
border: 1px solid var(--card-border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #111;
|
background: #d7dbe2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-heatmap-frame {
|
.admin-heatmap-scaler {
|
||||||
position: absolute;
|
position: relative;
|
||||||
inset: 0;
|
transform-origin: top left;
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 0;
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-heatmap-shot {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
vertical-align: top;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-heatmap-fallback {
|
.admin-heatmap-fallback {
|
||||||
@@ -1003,9 +1010,8 @@ body.admin-body {
|
|||||||
|
|
||||||
.admin-heatmap-overlay {
|
.admin-heatmap-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
left: 0;
|
||||||
width: 100%;
|
top: 0;
|
||||||
height: 100%;
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var ENDPOINT = "/analytics/events";
|
var ENDPOINT = "/analytics/events";
|
||||||
|
var SNAPSHOT_ENDPOINT = "/analytics/snapshot";
|
||||||
var STORAGE_KEY = "mltv_cookie_consent";
|
var STORAGE_KEY = "mltv_cookie_consent";
|
||||||
var COOKIE_NAME = "mltv_cookie_consent";
|
var COOKIE_NAME = "mltv_cookie_consent";
|
||||||
var FLUSH_MS = 6000;
|
var FLUSH_MS = 6000;
|
||||||
var MAX_QUEUE = 60;
|
var MAX_QUEUE = 60;
|
||||||
var MOVE_THROTTLE_MS = 180;
|
var MOVE_THROTTLE_MS = 180;
|
||||||
var MOVE_GRID = 40;
|
var MOVE_GRID = 40;
|
||||||
|
var SNAPSHOT_DELAY_MS = 4000;
|
||||||
|
|
||||||
var queue = [];
|
var queue = [];
|
||||||
var moveBuckets = {};
|
var moveBuckets = {};
|
||||||
@@ -195,6 +197,87 @@
|
|||||||
}, FLUSH_MS);
|
}, FLUSH_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function snapshotStorageKey() {
|
||||||
|
return "mltv_snap:" + location.pathname + ":" + deviceBucket();
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayKey() {
|
||||||
|
var d = new Date();
|
||||||
|
return d.getUTCFullYear() + "-" + (d.getUTCMonth() + 1) + "-" + d.getUTCDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function alreadyCapturedToday() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(snapshotStorageKey()) === todayKey();
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCapturedToday() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(snapshotStorageKey(), todayKey());
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadHtml2Canvas(cb) {
|
||||||
|
if (window.html2canvas) {
|
||||||
|
cb(window.html2canvas);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var s = document.createElement("script");
|
||||||
|
s.src = "/vendor/html2canvas.min.js";
|
||||||
|
s.async = true;
|
||||||
|
s.onload = function () {
|
||||||
|
if (window.html2canvas) cb(window.html2canvas);
|
||||||
|
};
|
||||||
|
s.onerror = function () { /* ignore */ };
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureSnapshot() {
|
||||||
|
if (!hasAnalyticsConsent() || alreadyCapturedToday()) return;
|
||||||
|
loadHtml2Canvas(function (html2canvas) {
|
||||||
|
var size = pageSize();
|
||||||
|
html2canvas(document.documentElement, {
|
||||||
|
scale: Math.min(1, 1400 / Math.max(size.width, 1)),
|
||||||
|
useCORS: true,
|
||||||
|
allowTaint: false,
|
||||||
|
logging: false,
|
||||||
|
windowWidth: size.width,
|
||||||
|
windowHeight: size.height,
|
||||||
|
scrollX: 0,
|
||||||
|
scrollY: 0
|
||||||
|
})
|
||||||
|
.then(function (canvas) {
|
||||||
|
if (!canvas || !canvas.toBlob) return;
|
||||||
|
canvas.toBlob(
|
||||||
|
function (blob) {
|
||||||
|
if (!blob || blob.size < 8000 || blob.size > 1800000) return;
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append("image", blob, "snapshot.jpg");
|
||||||
|
fd.append("path", location.pathname);
|
||||||
|
fd.append("device", deviceBucket());
|
||||||
|
fd.append("width", String(Math.round(size.width)));
|
||||||
|
fd.append("height", String(Math.round(size.height)));
|
||||||
|
fetch(SNAPSHOT_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
credentials: "same-origin"
|
||||||
|
})
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.ok) markCapturedToday();
|
||||||
|
})
|
||||||
|
.catch(function () { /* ignore */ });
|
||||||
|
},
|
||||||
|
"image/jpeg",
|
||||||
|
0.72
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(function () { /* ignore */ });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
if (started) return;
|
if (started) return;
|
||||||
if (excludedPath(location.pathname)) return;
|
if (excludedPath(location.pathname)) return;
|
||||||
@@ -216,6 +299,7 @@
|
|||||||
flush(true);
|
flush(true);
|
||||||
});
|
});
|
||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
|
setTimeout(captureSnapshot, SNAPSHOT_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.mltvSiteAnalyticsStart = start;
|
window.mltvSiteAnalyticsStart = start;
|
||||||
|
|||||||
+20
File diff suppressed because one or more lines are too long
@@ -39,7 +39,6 @@ RSpec.describe "Admin analytics", type: :request do
|
|||||||
get admin_analytics_page_path, params: { page_path: "/prezzi" }
|
get admin_analytics_page_path, params: { page_path: "/prezzi" }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.body).to include("admin-heatmap")
|
expect(response.body).to include("admin-heatmap")
|
||||||
expect(response.body).to include("admin-heatmap-frame")
|
|
||||||
expect(response.body).to include("/prezzi")
|
expect(response.body).to include("/prezzi")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Analytics snapshots", type: :request do
|
||||||
|
def jpeg_upload
|
||||||
|
path = Rails.root.join("tmp/analytics-snap-test.jpg")
|
||||||
|
FileUtils.mkdir_p(path.dirname)
|
||||||
|
# minimal valid-ish jpeg bytes for ActiveStorage
|
||||||
|
File.binwrite(path, "\xFF\xD8\xFF\xE0#{'x' * 12_000}\xFF\xD9")
|
||||||
|
Rack::Test::UploadedFile.new(path, "image/jpeg")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "accetta uno screenshot e lo associa al path" do
|
||||||
|
post "/analytics/snapshot",
|
||||||
|
params: {
|
||||||
|
path: "/prezzi",
|
||||||
|
device: "desktop",
|
||||||
|
width: 1440,
|
||||||
|
height: 2200,
|
||||||
|
image: jpeg_upload
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:accepted)
|
||||||
|
snap = AnalyticsPageSnapshot.find_by(page_path: "/prezzi", device: "desktop")
|
||||||
|
expect(snap).to be_present
|
||||||
|
expect(snap.image).to be_attached
|
||||||
|
expect(snap.width).to eq(1440)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rifiuta path esclusi" do
|
||||||
|
post "/analytics/snapshot",
|
||||||
|
params: {
|
||||||
|
path: "/admin/analytics",
|
||||||
|
device: "desktop",
|
||||||
|
width: 1440,
|
||||||
|
height: 900,
|
||||||
|
image: jpeg_upload
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_content)
|
||||||
|
expect(AnalyticsPageSnapshot.count).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user