use std::collections::HashMap;
use std::sync::LazyLock;
use crate::data::sources::{KnownSource, SOURCE_PATTERNS, SOURCES, SourceKind};
use crate::http::Url;
use crate::js;
use crate::re::{js_re, test};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Page {
pub hostname: String,
pub path: String,
pub utm: Utm,
pub ref_: String,
pub paid: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Utm {
pub source: String,
pub medium: String,
pub campaign: String,
pub term: String,
pub content: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Attribution {
pub referrer_host: String,
pub referrer_path: String,
pub source: String,
pub channel: &'static str,
}
const CLICK_IDS: [&str; 10] =
["gclid", "gbraid", "wbraid", "dclid", "fbclid", "msclkid", "ttclid", "twclid", "li_fat_id", "yclid"];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Found {
pub name: String,
pub kind: SourceKind,
}
impl From<&KnownSource> for Found {
fn from(s: &KnownSource) -> Found {
Found { name: s.name.to_string(), kind: s.kind }
}
}
struct Maps {
by_host: HashMap<&'static str, &'static KnownSource>,
by_alias: HashMap<&'static str, &'static KnownSource>,
patterns: Vec<(regex::bytes::Regex, Option<&'static str>, SourceKind)>,
}
static MAPS: LazyLock<Maps> = LazyLock::new(|| {
let mut by_host = HashMap::new();
let mut by_alias = HashMap::new();
for source in SOURCES {
for host in source.hosts {
by_host.insert(*host, source);
}
for alias in source.aliases {
by_alias.insert(*alias, source);
}
}
let patterns = SOURCE_PATTERNS.iter().map(|(p, name, kind)| (crate::re::ascii(p), *name, *kind)).collect();
Maps { by_host, by_alias, patterns }
});
fn clip(value: Option<&str>, max: usize) -> String {
js::head16(js::trim(value.unwrap_or("")), max)
}
pub fn strip_www(host: &str) -> String {
let lower = host.to_lowercase();
match lower.strip_prefix("www.") {
Some(rest) => rest.to_string(),
None => lower,
}
}
pub fn source_for_host(host: &str) -> Option<Found> {
let clean = strip_www(host);
let mut candidate = clean.as_str();
while candidate.contains('.') {
if let Some(found) = MAPS.by_host.get(candidate) {
return Some((*found).into());
}
candidate = &candidate[candidate.find('.').map_or(0, |i| i + 1)..];
}
for (pattern, name, kind) in &MAPS.patterns {
if test(pattern, &clean) {
return Some(Found { name: name.map_or_else(|| clean.clone(), str::to_string), kind: *kind });
}
}
None
}
pub fn source_for_alias(value: &str) -> Option<Found> {
let key = js::trim(&value.to_lowercase()).to_string();
if let Some(found) = MAPS.by_alias.get(key.as_str()) {
return Some((*found).into());
}
MAPS.by_host.get(strip_www(&key).as_str()).map(|s| (*s).into())
}
pub fn recorded_path(input: &str) -> Option<String> {
let url = if test(js_re!(r"(?i)^https?://"), input) {
Url::parse(input)
} else {
let path = if input.starts_with('/') { input.to_string() } else { format!("/{input}") };
Url::parse_with_base(&path, "https://x.invalid")
}?;
Some(parse_page(&url).path)
}
pub fn readable_path(path: &str) -> String {
let re = js_re!(r"(?:%[0-9A-Fa-f]{2})+");
let bytes = path.as_bytes();
let mut out = String::new();
let mut last = 0;
for m in re.find_iter(bytes) {
out.push_str(&path[last..m.start()]);
let run = &path[m.start()..m.end()];
match decode_uri_component(run) {
Some(text)
if !text.chars().any(|c| js::is_space(c) || matches!(c, '/' | '?' | '#' | '%') || is_other(c)) =>
{
out.push_str(&text)
}
_ => out.push_str(run),
}
last = m.end();
}
out.push_str(&path[last..]);
out
}
fn is_other(c: char) -> bool {
use std::sync::LazyLock;
static OTHER: LazyLock<regex::Regex> = LazyLock::new(|| regex::Regex::new(r"\p{C}").expect("a valid pattern"));
let mut buf = [0u8; 4];
OTHER.is_match(c.encode_utf8(&mut buf))
}
pub fn decode_uri_component(text: &str) -> Option<String> {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hex = bytes.get(i + 1..i + 3)?;
let s = std::str::from_utf8(hex).ok()?;
if !hex.iter().all(u8::is_ascii_hexdigit) {
return None;
}
out.push(u8::from_str_radix(s, 16).ok()?);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).ok()
}
pub fn encode_uri_component(text: &str) -> String {
const SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'!')
.remove(b'~')
.remove(b'*')
.remove(b'\'')
.remove(b'(')
.remove(b')');
percent_encoding::utf8_percent_encode(text, SET).to_string()
}
pub fn parse_page(url: &Url) -> Page {
let q = url.search_params();
let mut path = url.pathname();
if path.is_empty() {
path = "/".into();
}
let hash = url.hash();
if js::len16(&hash) > 1 {
path.push_str(&hash);
}
Page {
hostname: strip_www(&url.hostname()),
path: js::head16(&path, 1000),
utm: Utm {
source: clip(q.get("utm_source"), 200),
medium: clip(q.get("utm_medium"), 200).to_lowercase(),
campaign: clip(q.get("utm_campaign"), 200),
term: clip(q.get("utm_term"), 200),
content: clip(q.get("utm_content"), 200),
},
ref_: clip(q.get("ref").or_else(|| q.get("source")), 200),
paid: CLICK_IDS.iter().any(|id| q.has(id)),
}
}
pub fn attribute(page: &Page, referrer: &str, internal_hosts: &[String]) -> Attribution {
let mut referrer_host = String::new();
let mut referrer_path = String::new();
if !referrer.is_empty()
&& let Some(url) = Url::parse(referrer)
{
let protocol = url.protocol();
if protocol == "http:" || protocol == "https:" || protocol == "android-app:" {
let host = strip_www(&url.hostname());
if host != page.hostname && !internal_hosts.contains(&host) {
referrer_host = host;
referrer_path =
if protocol == "android-app:" { String::new() } else { js::head16(&url.pathname(), 500) };
}
}
}
let tagged = if !page.utm.source.is_empty() { page.utm.source.as_str() } else { page.ref_.as_str() };
let known = if !tagged.is_empty() {
source_for_alias(tagged)
} else if !referrer_host.is_empty() {
source_for_host(&referrer_host)
} else {
None
};
let source = match &known {
Some(k) => k.name.clone(),
None if !tagged.is_empty() => tagged.to_string(),
None => referrer_host.clone(),
};
let kind = match &known {
Some(k) => Some(k.kind),
None if !referrer_host.is_empty() => source_for_host(&referrer_host).map(|f| f.kind),
None => None,
};
let medium = page.utm.medium.as_str();
let paid = test(
js_re!(r"^(cpc|ppc|paid|paidsearch|paid_search|paid-search|sem|cpm|cpv|display|banner|retargeting)$"),
medium,
);
let email = test(js_re!(r"^(e-?mail|newsletter|mail)$"), medium);
let social = test(
js_re!(
r"^(social|social-network|social-media|sm|social_network|social_media|paid_social|paid-social|paidsocial)$"
),
medium,
);
let channel = if (page.paid || paid) && kind == Some(SourceKind::Search) {
"Paid Search"
} else if kind == Some(SourceKind::Ai) {
"AI"
} else if email || kind == Some(SourceKind::Email) {
"Email"
} else if kind == Some(SourceKind::Search) {
"Organic Search"
} else if social || kind == Some(SourceKind::Social) {
"Social"
} else if !page.utm.source.is_empty() || !page.utm.medium.is_empty() || !page.utm.campaign.is_empty() {
"Campaign"
} else if !referrer_host.is_empty() || !page.ref_.is_empty() {
"Referral"
} else {
"Direct"
};
Attribution { referrer_host, referrer_path, source, channel }
}