Diff -- /var/www/vhosts/3dshawn.com/abforge.3dshawn.com/_funnel_discovery_worker.pl
Diff

/var/www/vhosts/3dshawn.com/abforge.3dshawn.com/_funnel_discovery_worker.pl

added on local at 2026-07-01 21:14:30

Added
+131
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to df9af5809e4f
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# ABForge -- _funnel_discovery_worker.pl
4#
5# Runs periodically (cron: hourly). For every active site with tracking
6# data, looks at the top-N most common page sequences in the last 30
7# days and materializes each as a funnel row with source='auto'. The
8# customer can then rename, edit, or hide these in /funnels.cgi.
9#
10# Idempotency: keyed by (site_id, slug). A slug is a normalized digest
11# of the step sequence, so the same sequence never gets duplicated even
12# across runs.
13#
14# Hide-persistence: if a user hides an auto funnel, is_hidden=1 stays
15# set even when this worker sees the same slug again -- we use
16# INSERT ... ON DUPLICATE KEY UPDATE name=VALUES(name) and never touch
17# is_hidden after the initial insert.
18#======================================================================
19use strict;
20use warnings;
21use lib '/var/www/vhosts/3dshawn.com/abforge.3dshawn.com';
22use MODS::DBConnect;
23use MODS::ABForge::Config;
24use Digest::MD5 qw(md5_hex);
25
26my $db = MODS::DBConnect->new;
27my $cfg = MODS::ABForge::Config->new;
28my $DB = $cfg->settings('database_name');
29my $dbh = $db->db_connect;
30die "no dbh\n" unless $dbh;
31
32my @sites = $db->db_readwrite_multiple($dbh, qq~
33 SELECT id FROM `${DB}`.sites WHERE status IN ('active','paused')
34~, __FILE__, __LINE__);
35
36my $inserted = 0;
37my $skipped = 0;
38
39foreach my $s (@sites) {
40 my $sid = int($s->{id});
41
42 # Pull the last 30 days of sessions, materialize their page paths.
43 my @sessions = $db->db_readwrite_multiple($dbh, qq~
44 SELECT s.id,
45 GROUP_CONCAT(e.page_path ORDER BY e.occurred_at SEPARATOR '\x1f') AS seq
46 FROM `${DB}`.mkt_tracking_sessions s
47 JOIN `${DB}`.mkt_tracking_events e ON e.session_id = s.id
48 WHERE s.storefront_id = $sid
49 AND e.event_type = 'page_view'
50 AND e.occurred_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
51 GROUP BY s.id
52 HAVING seq IS NOT NULL AND seq != ''
53 ~, __FILE__, __LINE__);
54
55 # Bucket sequences by 2..4-step prefixes so we surface both short
56 # and medium funnels. Skip single-page paths (bounces) -- they're
57 # not funnels.
58 my %counts; # slug => { steps=>[...], name=>'...', n=>N }
59 foreach my $r (@sessions) {
60 my @pages = split /\x1f/, ($r->{seq} // '');
61 next if scalar(@pages) < 2;
62
63 # Deduplicate consecutive identical pages (F5 refresh spam)
64 my @clean;
65 foreach my $p (@pages) { push @clean, $p if !@clean || $clean[-1] ne $p; }
66 next if scalar(@clean) < 2;
67
68 for my $len (2..($#clean < 3 ? $#clean : 3)) {
69 last if $len > scalar(@clean) - 1;
70 my @slice = @clean[0..$len];
71 my $key = join('|', @slice);
72 my $slug = 'auto_' . substr(md5_hex($sid . ':' . $key), 0, 16);
73 $counts{$slug} ||= { steps => \@slice, name => _name_from_slice(\@slice), n => 0 };
74 $counts{$slug}{n}++;
75 }
76 }
77
78 # Keep only the top 8 sequences per site so the funnel list stays useful
79 my @top = sort { $counts{$b}{n} <=> $counts{$a}{n} } keys %counts;
80 @top = @top[0..7] if scalar(@top) > 8;
81
82 foreach my $slug (@top) {
83 my $entry = $counts{$slug};
84 next if $entry->{n} < 3; # need at least 3 sessions to bother
85
86 my $name = $entry->{name}; $name =~ s/'/''/g;
87 my $steps_j = _steps_json($entry->{steps}); $steps_j =~ s/'/''/g;
88 my $slug_q = $slug; $slug_q =~ s/'/''/g;
89
90 # Insert-or-noop: if a funnel with this (site_id, slug) already
91 # exists, leave it alone (respecting user's hide + rename).
92 my $r = $db->db_readwrite($dbh, qq~
93 SELECT id, is_hidden FROM `${DB}`.funnels
94 WHERE site_id='$sid' AND slug='$slug_q' LIMIT 1
95 ~, __FILE__, __LINE__);
96 if ($r && $r->{id}) { $skipped++; next; }
97
98 $db->db_readwrite($dbh, qq~
99 INSERT INTO `${DB}`.funnels
100 (site_id, name, steps, is_active, is_hidden, source, slug, created_at)
101 VALUES
102 ('$sid', '$name', '$steps_j', 1, 0, 'auto', '$slug_q', NOW())
103 ~, __FILE__, __LINE__);
104 $inserted++;
105 }
106}
107
108$db->db_disconnect($dbh);
109
110print "discovery: inserted=$inserted skipped=$skipped\n";
111
112sub _name_from_slice {
113 my ($slice) = @_;
114 return join(' -> ', map { my $p = $_; $p =~ s{^/}{}; $p = 'Home' if !length $p; substr($p, 0, 40) } @$slice);
115}
116
117sub _steps_json {
118 my ($slice) = @_;
119 my @parts;
120 my $idx = 0;
121 foreach my $p (@$slice) {
122 $idx++;
123 my $label = $p; $label =~ s{^/}{}; $label = 'Home' if !length $label;
124 $label = substr($label, 0, 60);
125 # Minimal JSON escape: backslash + quote
126 my $p_esc = $p; $p_esc =~ s/\\/\\\\/g; $p_esc =~ s/"/\\"/g;
127 my $l_esc = $label; $l_esc =~ s/\\/\\\\/g; $l_esc =~ s/"/\\"/g;
128 push @parts, qq~{"kind":"path","match":"$p_esc","label":"$l_esc"}~;
129 }
130 return '[' . join(',', @parts) . ']';
131}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help