feat: add VisitorNickname class for deterministic visitor identification

Stripe-style memorable names (e.g. "Mossy Jackal") generated from IP hash.
Includes behavior tag system based on page view history (cart-abandoner,
demo-downloader, product-browser, returning, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 15:12:58 +00:00
parent 15f436ff47
commit ffb57aeece

241
src/VisitorNickname.php Normal file
View File

@@ -0,0 +1,241 @@
<?php
/**
* Visitor Nickname Generator
*
* Generates deterministic, memorable nicknames for visitors (Stripe-style)
* and behavior tags based on session activity.
*
* @author mypresta.rocks
* @copyright mypresta.rocks
* @license Commercial License
*/
namespace MyPrestaRocks\Session;
class VisitorNickname
{
/**
* Adjective pool (100 words) — vivid, neutral, memorable.
*/
private const ADJECTIVES = [
'amber', 'arctic', 'azure', 'blazing', 'bold',
'bright', 'bronze', 'calm', 'cedar', 'charcoal',
'clever', 'cobalt', 'copper', 'coral', 'cosmic',
'crimson', 'crystal', 'daring', 'deft', 'dusky',
'eager', 'electric', 'emerald', 'fading', 'fierce',
'flint', 'frosty', 'gentle', 'gilded', 'glacial',
'golden', 'granite', 'hazel', 'hidden', 'hollow',
'hushed', 'iron', 'ivory', 'jade', 'keen',
'lapis', 'lavender', 'lemon', 'lilac', 'lunar',
'marble', 'marine', 'mellow', 'midnight', 'misty',
'mossy', 'noble', 'obsidian', 'ochre', 'olive',
'onyx', 'opal', 'orchid', 'pale', 'pearl',
'phantom', 'pine', 'plum', 'polar', 'prism',
'quartz', 'quiet', 'rapid', 'raven', 'roaming',
'rose', 'ruby', 'rustic', 'sable', 'sage',
'scarlet', 'shadow', 'silent', 'silver', 'slate',
'solar', 'steel', 'storm', 'swift', 'tawny',
'teal', 'terra', 'thorn', 'thunder', 'topaz',
'twilight', 'umber', 'velvet', 'vermillion', 'violet',
'vivid', 'walnut', 'wild', 'willow', 'zinc',
];
/**
* Noun pool (100 words) — animals + nature objects, easy to visualise.
*/
private const NOUNS = [
'badger', 'bear', 'beetle', 'bison', 'bobcat',
'brook', 'canyon', 'caribou', 'cedar', 'cheetah',
'cliff', 'cobra', 'condor', 'coral', 'cougar',
'crane', 'creek', 'crow', 'dagger', 'dawn',
'deer', 'dingo', 'dove', 'drift', 'dusk',
'eagle', 'ember', 'falcon', 'fern', 'finch',
'flame', 'flint', 'fox', 'frost', 'gale',
'gazelle', 'glacier', 'grove', 'gull', 'hare',
'hawk', 'heron', 'hollow', 'horizon', 'hound',
'ibis', 'jackal', 'jaguar', 'jay', 'kestrel',
'lark', 'leaf', 'leopard', 'lynx', 'maple',
'marsh', 'meadow', 'mesa', 'mist', 'moon',
'moth', 'mountain', 'newt', 'oak', 'orca',
'osprey', 'otter', 'owl', 'panther', 'peak',
'pelican', 'pine', 'plover', 'pond', 'puma',
'quail', 'raven', 'reef', 'ridge', 'robin',
'salmon', 'shade', 'shark', 'sparrow', 'spider',
'stag', 'stone', 'stork', 'storm', 'summit',
'swift', 'thorn', 'tiger', 'trail', 'trout',
'viper', 'warden', 'wolf', 'wren', 'zenith',
];
/**
* Generate a deterministic nickname from an IP address.
*
* Same IP always produces the same name. The hash is stable
* across PHP versions and platforms (crc32 + modulo).
*
* @param string $ip IP address (v4 or v6)
* @return string e.g. "Vermillion Falcon"
*/
public static function generate(string $ip): string
{
// Use crc32 for speed — we just need distribution, not security
$hash = crc32($ip);
// Make it unsigned
$hash = $hash & 0xFFFFFFFF;
$adjIdx = $hash % count(self::ADJECTIVES);
$nounIdx = (int) floor($hash / count(self::ADJECTIVES)) % count(self::NOUNS);
return ucfirst(self::ADJECTIVES[$adjIdx]) . ' ' . ucfirst(self::NOUNS[$nounIdx]);
}
/**
* Generate behavior tags for a visitor based on their session data.
*
* @param array $sessionData Aggregated session row with keys:
* - ip_address, total_pages, session_count, page_types (comma-separated list),
* - has_cart, has_checkout, has_order, has_account, has_search, has_demo,
* - landing_page_type, source_type, id_customer
* @return array List of short tag strings, e.g. ['cart-abandoner', 'product-browser']
*/
public static function behaviorTags(array $d): array
{
$tags = [];
// Customer status
if (!empty($d['id_customer'])) {
$tags[] = 'customer';
}
// Conversion funnel
if (!empty($d['has_order'])) {
$tags[] = 'buyer';
} elseif (!empty($d['has_checkout'])) {
$tags[] = 'checkout-started';
} elseif (!empty($d['has_cart'])) {
$tags[] = 'cart-abandoner';
}
// Demo activity
if (!empty($d['has_demo'])) {
$tags[] = 'demo-downloader';
}
// Browsing depth
$pages = (int) ($d['total_pages'] ?? 0);
if ($pages >= 10) {
$tags[] = 'deep-browser';
} elseif ($pages >= 5) {
$tags[] = 'engaged';
}
// Content affinity (from page_types string: comma-separated type IDs)
$types = [];
if (!empty($d['page_types'])) {
$types = array_map('intval', explode(',', $d['page_types']));
$types = array_unique($types);
}
if (in_array(3, $types)) {
$tags[] = 'product-browser';
}
if (in_array(9, $types)) {
$tags[] = 'searcher';
}
if (in_array(4, $types)) {
$tags[] = 'content-reader';
}
// Returning visitor
$sessions = (int) ($d['session_count'] ?? 0);
if ($sessions >= 3) {
$tags[] = 'returning';
}
// First visit flag
if (!empty($d['is_first_visit']) && $sessions <= 1) {
$tags[] = 'first-visit';
}
return $tags;
}
/**
* Get behavior tags for a visitor by IP address (queries DB).
*
* @param string $ip
* @param int $hoursBack How far back to look (default 72h)
* @return array
*/
public static function getBehaviorTagsForIP(string $ip, int $hoursBack = 72): array
{
$db = \Db::getInstance((bool) _PS_USE_SQL_SLAVE_);
$prefix = _DB_PREFIX_;
$row = $db->getRow(
"SELECT
s.ip_address,
MAX(s.id_customer) as id_customer,
SUM(s.pages_viewed) as total_pages,
COUNT(*) as session_count,
MAX(s.is_first_visit) as is_first_visit,
GROUP_CONCAT(DISTINCT pv.page_type) as page_types,
MAX(CASE WHEN pv.page_type = 5 THEN 1 ELSE 0 END) as has_cart,
MAX(CASE WHEN pv.page_type = 6 THEN 1 ELSE 0 END) as has_checkout,
MAX(CASE WHEN pv.page_type = 7 THEN 1 ELSE 0 END) as has_order,
MAX(CASE WHEN pv.page_type = 8 THEN 1 ELSE 0 END) as has_account,
MAX(CASE WHEN pv.page_type = 9 THEN 1 ELSE 0 END) as has_search,
MAX(CASE WHEN pv.controller LIKE '%demo%' OR pv.controller LIKE '%Demo%' THEN 1 ELSE 0 END) as has_demo
FROM `{$prefix}mpr_sessions` s
LEFT JOIN `{$prefix}mpr_page_views` pv ON s.id_session = pv.id_session
WHERE s.ip_address = '" . pSQL($ip) . "'
AND s.date_add >= DATE_SUB(NOW(), INTERVAL {$hoursBack} HOUR)
GROUP BY s.ip_address"
);
if (!$row) {
return [];
}
return self::behaviorTags($row);
}
/**
* Render behavior tags as HTML badges.
*
* @param array $tags
* @return string HTML
*/
public static function renderTagBadges(array $tags): string
{
if (empty($tags)) {
return '';
}
$colors = [
'buyer' => '#10b981',
'checkout-started' => '#f59e0b',
'cart-abandoner' => '#ef4444',
'demo-downloader' => '#8b5cf6',
'deep-browser' => '#3b82f6',
'engaged' => '#06b6d4',
'product-browser' => '#6366f1',
'searcher' => '#ec4899',
'content-reader' => '#14b8a6',
'returning' => '#f97316',
'first-visit' => '#84cc16',
'customer' => '#10b981',
];
$html = '';
foreach ($tags as $tag) {
$color = $colors[$tag] ?? '#9ca3af';
$html .= '<span style="display:inline-block;padding:1px 6px;border-radius:3px;'
. 'font-size:10px;font-weight:600;line-height:16px;'
. 'background:' . $color . '1a;color:' . $color . ';margin-right:3px;">'
. $tag . '</span>';
}
return $html;
}
}