feat: add SharePanel component for social sharing with QR code support
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m47s
docker-images / build-and-push (push) Failing after 7s
docker-images / submit-indexnow (push) Has been skipped

- Implemented SharePanel component in `SharePanel.astro` for sharing content on social media platforms.
- Integrated QR code generation for WeChat sharing using the `qrcode` library.
- Added localization support for English and Chinese languages.
- Created utility functions in `seo.ts` for building article summaries and FAQs.
- Introduced API routes for serving IndexNow key and generating full LLM catalog and summaries.
- Enhanced SEO capabilities with structured data for articles and pages.
This commit is contained in:
2026-04-02 14:15:21 +08:00
parent a516be2e91
commit 3628a46ed1
53 changed files with 4390 additions and 91 deletions

View File

@@ -140,6 +140,8 @@ pub struct AdminAnalyticsResponse {
pub recent_events: Vec<AnalyticsRecentEvent>,
pub providers_last_7d: Vec<AnalyticsProviderBucket>,
pub top_referrers: Vec<AnalyticsReferrerBucket>,
pub ai_referrers_last_7d: Vec<AnalyticsReferrerBucket>,
pub ai_discovery_page_views_last_7d: u64,
pub popular_posts: Vec<AnalyticsPopularPost>,
pub daily_activity: Vec<AnalyticsDailyBucket>,
}
@@ -197,16 +199,112 @@ fn format_timestamp(value: DateTime<Utc>) -> String {
value.format("%Y-%m-%d %H:%M").to_string()
}
fn normalize_referrer_source(value: Option<String>) -> String {
fn metadata_string(metadata: Option<&serde_json::Value>, key: &str) -> Option<String> {
metadata
.and_then(|value| value.get(key))
.and_then(|value| value.as_str())
.map(ToString::to_string)
.and_then(|value| trim_to_option(Some(value)))
}
fn parse_path_query_value(path: Option<&str>, key: &str) -> Option<String> {
let path = path.and_then(|value| trim_to_option(Some(value.to_string())))?;
let synthetic_url = if path.starts_with("http://") || path.starts_with("https://") {
path
} else if path.starts_with('/') {
format!("https://local.test{path}")
} else {
format!("https://local.test/{path}")
};
reqwest::Url::parse(&synthetic_url)
.ok()
.and_then(|url| {
url.query_pairs()
.find(|(item_key, _)| item_key == key)
.map(|(_, value)| value.to_string())
})
.and_then(|value| trim_to_option(Some(value)))
}
fn normalize_tracking_source_token(value: Option<String>) -> String {
let Some(value) = trim_to_option(value) else {
return "direct".to_string();
};
reqwest::Url::parse(&value)
let normalized = reqwest::Url::parse(&value)
.ok()
.and_then(|url| url.host_str().map(ToString::to_string))
.filter(|item| !item.trim().is_empty())
.unwrap_or(value)
.trim()
.to_ascii_lowercase();
match normalized.as_str() {
"direct" => "direct".to_string(),
value if value.contains("chatgpt") || value.contains("openai") => {
"chatgpt-search".to_string()
}
value if value.contains("perplexity") => "perplexity".to_string(),
value if value.contains("copilot") || value.contains("bing") => {
"copilot-bing".to_string()
}
value if value.contains("gemini") => "gemini".to_string(),
value if value.contains("google") => "google".to_string(),
value if value.contains("claude") => "claude".to_string(),
value if value.contains("duckduckgo") => "duckduckgo".to_string(),
value if value.contains("kagi") => "kagi".to_string(),
_ => normalized,
}
}
fn normalize_tracking_source(
path: Option<&str>,
referrer: Option<String>,
metadata: Option<&serde_json::Value>,
) -> String {
let preferred = metadata_string(metadata, "landingSource")
.or_else(|| metadata_string(metadata, "landing_source"))
.or_else(|| metadata_string(metadata, "utmSource"))
.or_else(|| metadata_string(metadata, "utm_source"))
.or_else(|| parse_path_query_value(path, "utm_source"))
.or_else(|| metadata_string(metadata, "referrerHost"))
.or_else(|| referrer);
normalize_tracking_source_token(preferred)
}
fn is_ai_discovery_source(value: &str) -> bool {
matches!(
value,
"chatgpt-search" | "perplexity" | "copilot-bing" | "gemini" | "claude"
)
}
fn sorted_referrer_buckets(
breakdown: &HashMap<String, u64>,
predicate: impl Fn(&str) -> bool,
limit: usize,
) -> Vec<AnalyticsReferrerBucket> {
let mut items = breakdown
.iter()
.filter_map(|(referrer, count)| {
predicate(referrer)
.then(|| AnalyticsReferrerBucket {
referrer: referrer.clone(),
count: *count,
})
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.count
.cmp(&left.count)
.then_with(|| left.referrer.cmp(&right.referrer))
});
items.truncate(limit);
items
}
fn header_value(headers: &HeaderMap, key: &str) -> Option<String> {
@@ -550,7 +648,8 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
page_views_last_24h += 1;
}
let referrer = normalize_referrer_source(event.referrer.clone());
let referrer =
normalize_tracking_source(Some(&event.path), event.referrer.clone(), event.metadata.as_ref());
*referrer_breakdown.entry(referrer).or_insert(0) += 1;
}
@@ -637,17 +736,13 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
});
providers_last_7d.truncate(6);
let mut top_referrers = referrer_breakdown
.into_iter()
.map(|(referrer, count)| AnalyticsReferrerBucket { referrer, count })
.collect::<Vec<_>>();
top_referrers.sort_by(|left, right| {
right
.count
.cmp(&left.count)
.then_with(|| left.referrer.cmp(&right.referrer))
});
top_referrers.truncate(8);
let top_referrers = sorted_referrer_buckets(&referrer_breakdown, |_| true, 8);
let ai_referrers_last_7d = sorted_referrer_buckets(&referrer_breakdown, is_ai_discovery_source, 6);
let ai_discovery_page_views_last_7d = referrer_breakdown
.iter()
.filter(|(referrer, _)| is_ai_discovery_source(referrer))
.map(|(_, count)| *count)
.sum::<u64>();
let mut popular_posts = post_breakdown
.into_iter()
@@ -748,6 +843,8 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
recent_events,
providers_last_7d,
top_referrers,
ai_referrers_last_7d,
ai_discovery_page_views_last_7d,
popular_posts,
daily_activity,
})