feat: improve SEO meta tags, robots.txt, sitemap.xml, and table accessibility

This commit is contained in:
NetworkCats
2026-03-03 03:14:40 +08:00
parent f480131809
commit 5bc601e6e5
5 changed files with 130 additions and 11 deletions
+40 -1
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
use askama::Template;
use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{Html, IntoResponse, Response};
use crate::cli_detect::is_cli_user_agent;
@@ -249,3 +249,42 @@ pub async fn tor_handler(
let proxy = db::lookup_proxy(&reader, ip).ok_or(AppError::DbLookupFailed)?;
Ok(format!("{}\n", proxy.is_tor))
}
pub async fn robots_txt(State(state): State<AppState>) -> Response {
let body = format!(
"User-agent: *\n\
Allow: /\n\
Disallow: /json\n\
Disallow: /ip\n\
Disallow: /asn\n\
Disallow: /org\n\
Disallow: /country\n\
Disallow: /city\n\
Disallow: /proxy\n\
Disallow: /vpn\n\
Disallow: /hosting\n\
Disallow: /tor\n\
Disallow: /health\n\
\n\
Sitemap: https://{}/sitemap.xml\n",
state.site_domain
);
([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], body).into_response()
}
pub async fn sitemap_xml(State(state): State<AppState>) -> Response {
let body = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n\
\x20 <url>\n\
\x20 <loc>https://{}/</loc>\n\
\x20 </url>\n\
</urlset>\n",
state.site_domain
);
(
[(header::CONTENT_TYPE, "application/xml; charset=utf-8")],
body,
)
.into_response()
}
+2
View File
@@ -8,6 +8,8 @@ pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/", get(handlers::root))
.route("/health", get(handlers::health))
.route("/robots.txt", get(handlers::robots_txt))
.route("/sitemap.xml", get(handlers::sitemap_xml))
.route("/json", get(handlers::json_handler))
.route("/ip", get(handlers::ip_handler))
.route("/asn", get(handlers::asn_handler))
+6 -2
View File
@@ -136,6 +136,7 @@ main {
border-collapse: collapse;
table-layout: fixed;
th,
td {
padding-block: 10px;
border-block-start: 1px solid var(--color-separator);
@@ -144,15 +145,17 @@ main {
overflow-wrap: break-word;
}
tr:last-child th,
tr:last-child td {
border-block-end: 1px solid var(--color-separator);
}
.label {
th {
inline-size: 42%;
padding-inline: 24px 32px;
font-weight: 600;
color: var(--color-label);
text-align: start;
}
.bool-true {
@@ -178,13 +181,14 @@ main {
margin-block: 20px 16px;
}
.info-table th,
.info-table td {
font-size: 14px;
}
}
@media (max-width: 360px) {
.info-table .label {
.info-table th {
inline-size: 36%;
padding-inline: 12px 16px;
}
+20 -8
View File
@@ -4,6 +4,17 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>What is my IP address? - {{ site_domain }}</title>
<meta name="description" content="Find your public IP address instantly. Look up geolocation, ASN, organization, and proxy/VPN/Tor detection for any IP address.">
<link rel="canonical" href="https://{{ site_domain }}/">
<meta name="theme-color" content="#007AFF" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0A84FF" media="(prefers-color-scheme: dark)">
<meta property="og:title" content="What is my IP address? - {{ site_domain }}">
<meta property="og:description" content="Find your public IP address instantly. Look up geolocation, ASN, organization, and proxy/VPN/Tor detection for any IP address.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://{{ site_domain }}/">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="What is my IP address? - {{ site_domain }}">
<meta name="twitter:description" content="Find your public IP address instantly. Look up geolocation, ASN, organization, and proxy/VPN/Tor detection for any IP address.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;600&display=swap">
@@ -23,6 +34,7 @@
placeholder="Type to search IP data"
value="{{ query }}"
autocomplete="off"
autocapitalize="none"
spellcheck="false"
>
</div>
@@ -33,35 +45,35 @@
<table class="info-table" aria-label="IP address information">
<tbody>
<tr>
<td class="label">ASN</td>
<th scope="row">ASN</th>
<td>{{ asn }}</td>
</tr>
<tr>
<td class="label">Org</td>
<th scope="row">Org</th>
<td>{{ org }}</td>
</tr>
<tr>
<td class="label">Country</td>
<th scope="row">Country</th>
<td>{{ country }}</td>
</tr>
<tr>
<td class="label">City</td>
<th scope="row">City</th>
<td>{{ city }}</td>
</tr>
<tr>
<td class="label">Proxy</td>
<th scope="row">Proxy</th>
<td>{% if is_proxy %}<i class="icon-check bool-true" aria-hidden="true"></i><span class="sr-only">Yes</span>{% else %}<i class="icon-minus bool-false" aria-hidden="true"></i><span class="sr-only">No</span>{% endif %}</td>
</tr>
<tr>
<td class="label">VPN</td>
<th scope="row">VPN</th>
<td>{% if is_vpn %}<i class="icon-check bool-true" aria-hidden="true"></i><span class="sr-only">Yes</span>{% else %}<i class="icon-minus bool-false" aria-hidden="true"></i><span class="sr-only">No</span>{% endif %}</td>
</tr>
<tr>
<td class="label">Hosting</td>
<th scope="row">Hosting</th>
<td>{% if is_hosting %}<i class="icon-check bool-true" aria-hidden="true"></i><span class="sr-only">Yes</span>{% else %}<i class="icon-minus bool-false" aria-hidden="true"></i><span class="sr-only">No</span>{% endif %}</td>
</tr>
<tr>
<td class="label">Tor</td>
<th scope="row">Tor</th>
<td>{% if is_tor %}<i class="icon-check bool-true" aria-hidden="true"></i><span class="sr-only">Yes</span>{% else %}<i class="icon-minus bool-false" aria-hidden="true"></i><span class="sr-only">No</span>{% endif %}</td>
</tr>
</tbody>
+62
View File
@@ -412,3 +412,65 @@ async fn field_endpoints_without_ip_or_header_return_400() {
);
}
}
// --- SEO and accessibility tests ---
#[tokio::test]
async fn robots_txt_returns_valid_response() {
let app = build_test_app();
let (status, body) = get(&app, "/robots.txt").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("User-agent: *"));
assert!(body.contains("Disallow: /json"));
assert!(body.contains("Disallow: /health"));
assert!(body.contains("Sitemap: https://test.example.com/sitemap.xml"));
}
#[tokio::test]
async fn sitemap_xml_returns_valid_response() {
let app = build_test_app();
let (status, body) = get(&app, "/sitemap.xml").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("<?xml version=\"1.0\""));
assert!(body.contains("<loc>https://test.example.com/</loc>"));
}
#[tokio::test]
async fn html_contains_seo_meta_tags() {
let app = build_test_app();
let (status, body) = get_with_headers(
&app,
"/?ip=45.77.77.77",
vec![(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
)],
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("<meta name=\"description\""));
assert!(body.contains("<link rel=\"canonical\""));
assert!(body.contains("<meta property=\"og:title\""));
assert!(body.contains("<meta property=\"og:description\""));
assert!(body.contains("<meta property=\"og:type\" content=\"website\""));
assert!(body.contains("<meta name=\"twitter:card\" content=\"summary\""));
assert!(body.contains("<meta name=\"theme-color\""));
}
#[tokio::test]
async fn html_uses_th_for_row_headers() {
let app = build_test_app();
let (status, body) = get_with_headers(
&app,
"/?ip=45.77.77.77",
vec![(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
)],
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("<th scope=\"row\">ASN</th>"));
assert!(body.contains("<th scope=\"row\">Country</th>"));
assert!(body.contains("autocapitalize=\"none\""));
}