use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::time::Duration;
const SCOPE: &str = "https://www.googleapis.com/auth/webmasters.readonly";
fn config_path() -> Result<std::path::PathBuf> {
let home = std::env::var("HOME").context("HOME not set")?;
Ok(std::path::PathBuf::from(format!(
"{}/.config/jev-seo/gsc.json",
home
)))
}
#[derive(Debug, Serialize, Deserialize)]
struct TokenStore {
refresh_token: String,
}
fn save_refresh(token: &str) -> Result<()> {
let path = config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)?;
use std::io::Write;
f.write_all(
serde_json::to_string_pretty(&TokenStore {
refresh_token: token.to_string(),
})?
.as_bytes(),
)?;
Ok(())
}
#[cfg(not(unix))]
{
std::fs::write(
&path,
serde_json::to_string_pretty(&TokenStore {
refresh_token: token.to_string(),
})?,
)?;
Ok(())
}
}
fn load_refresh() -> Result<String> {
let raw = std::fs::read_to_string(config_path()?)?;
Ok(serde_json::from_str::<TokenStore>(&raw)?.refresh_token)
}
fn client_pair() -> Result<(String, String)> {
Ok((
std::env::var("GOOGLE_CLIENT_ID").context("GOOGLE_CLIENT_ID not set")?,
std::env::var("GOOGLE_CLIENT_SECRET").context("GOOGLE_CLIENT_SECRET not set")?,
))
}
fn access_token() -> Result<String> {
let (id, secret) = client_pair()?;
let refresh = load_refresh()?;
let body: serde_json::Value = ureq::post("https://oauth2.googleapis.com/token")
.timeout(Duration::from_secs(15))
.send_json(serde_json::json!({
"client_id": id,
"client_secret": secret,
"refresh_token": refresh,
"grant_type": "refresh_token",
}))
.context("token refresh failed")?
.into_json()?;
body.get("access_token")
.and_then(|v| v.as_str())
.map(str::to_string)
.context("no access_token in refresh response")
}
pub fn auth_start() -> Result<String> {
let (id, _secret) = client_pair()?;
let body: serde_json::Value = ureq::post("https://oauth2.googleapis.com/device/code")
.timeout(Duration::from_secs(15))
.send_json(serde_json::json!({ "client_id": id, "scope": SCOPE }))
.context("device flow start failed")?
.into_json()?;
let url = body
.get("verification_url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let code = body
.get("user_code")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let device = body
.get("device_code")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
println!("Open: {}", url);
println!("Enter: {}", code);
println!("Then: jev-seo gsc auth --code {}", device);
Ok(device)
}
pub fn auth_poll(device_code: &str) -> Result<()> {
let (id, secret) = client_pair()?;
for _ in 0..30 {
let body: serde_json::Value = match ureq::post("https://oauth2.googleapis.com/token")
.timeout(Duration::from_secs(15))
.send_json(serde_json::json!({
"client_id": id,
"client_secret": secret,
"device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
})) {
Ok(r) => r.into_json()?,
Err(ureq::Error::Status(400, r)) => r.into_json().unwrap_or(serde_json::Value::Null),
Err(e) => anyhow::bail!("token poll failed: {}", e),
};
if let Some(refresh) = body.get("refresh_token").and_then(|v| v.as_str()) {
save_refresh(refresh)?;
println!("Search Console linked. Token stored with owner-only permissions.");
return Ok(());
}
let err = body
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
if err != "authorization_pending" && err != "slow_down" {
anyhow::bail!("auth failed: {}", err);
}
std::thread::sleep(Duration::from_secs(5));
}
anyhow::bail!("approval timed out, rerun auth")
}
fn api_get(path: &str, token: &str) -> Result<serde_json::Value> {
Ok(ureq::get(&format!("https://www.googleapis.com{}", path))
.timeout(Duration::from_secs(15))
.set("Authorization", &format!("Bearer {}", token))
.call()?
.into_json()?)
}
pub fn sites() -> Result<Vec<String>> {
let token = access_token()?;
let body = api_get("/webmasters/v3/sites", &token)?;
Ok(body
.get("siteEntry")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|e| {
e.get("siteUrl")
.and_then(|u| u.as_str())
.map(str::to_string)
})
.collect()
})
.unwrap_or_default())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GscRow {
pub query: String,
pub clicks: f64,
pub impressions: f64,
pub ctr: f64,
pub position: f64,
}
pub fn top_queries(site: &str, limit: usize) -> Result<Vec<GscRow>> {
let token = access_token()?;
let end = chrono_now_days_ago(0);
let start = chrono_now_days_ago(28);
let url = format!(
"/webmasters/v3/sites/{}/searchAnalytics/query",
urlencoding(site)
);
let body: serde_json::Value = ureq::post(&format!("https://www.googleapis.com{}", url))
.timeout(Duration::from_secs(20))
.set("Authorization", &format!("Bearer {}", token))
.send_json(serde_json::json!({
"startDate": start,
"endDate": end,
"dimensions": ["query"],
"rowLimit": limit.clamp(1, 100),
"orderBy": [{ "fieldName": "clicks", "sortOrder": "DESCENDING" }],
}))
.context("searchAnalytics query failed")?
.into_json()?;
Ok(body
.get("rows")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.map(|r| GscRow {
query: r
.get("keys")
.and_then(|k| k.get(0))
.and_then(|q| q.as_str())
.unwrap_or("")
.to_string(),
clicks: r.get("clicks").and_then(|v| v.as_f64()).unwrap_or(0.0),
impressions: r.get("impressions").and_then(|v| v.as_f64()).unwrap_or(0.0),
ctr: r.get("ctr").and_then(|v| v.as_f64()).unwrap_or(0.0),
position: r.get("position").and_then(|v| v.as_f64()).unwrap_or(0.0),
})
.collect()
})
.unwrap_or_default())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GapRow {
pub query: String,
pub impressions: f64,
pub clicks: f64,
pub ctr: f64,
pub position: f64,
pub cited_before: Option<bool>,
pub gap_score: f64,
}
pub fn inspect(site: &str, url: &str) -> Result<String> {
let token = access_token()?;
let body: serde_json::Value =
ureq::post("https://searchconsole.googleapis.com/v1/urlInspection/index:inspect")
.timeout(Duration::from_secs(20))
.set("Authorization", &format!("Bearer {}", token))
.send_json(serde_json::json!({
"inspectionUrl": url,
"siteUrl": site,
}))
.context("urlInspection inspect failed")?
.into_json()?;
Ok(
serde_json::to_string_pretty(body.get("inspectionResult").unwrap_or(&body))
.unwrap_or_default(),
)
}
pub fn movers(site: &str, limit: usize) -> Result<(Vec<GscRow>, Vec<GscRow>)> {
let rows = top_queries(site, limit)?;
let mut winners: Vec<GscRow> = rows
.iter()
.filter(|r| r.position <= 10.0)
.cloned()
.collect();
let mut losers: Vec<GscRow> = rows.iter().filter(|r| r.position > 10.0).cloned().collect();
winners.sort_by(|a, b| {
b.clicks
.partial_cmp(&a.clicks)
.unwrap_or(std::cmp::Ordering::Equal)
});
losers.sort_by(|a, b| {
b.impressions
.partial_cmp(&a.impressions)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok((winners, losers))
}
pub fn gap_score(impressions: f64, position: f64) -> f64 {
impressions * position.max(1.0) / 10.0
}
pub fn gap(site: &str, limit: usize) -> Result<Vec<GapRow>> {
let mut rows: Vec<GapRow> = top_queries(site, limit)?
.into_iter()
.map(|r| {
let cited = crate::rank::DbStore::open()
.ok()
.and_then(|db| db.last_cite_for_term(&r.query));
GapRow {
gap_score: gap_score(r.impressions, r.position),
query: r.query,
impressions: r.impressions,
clicks: r.clicks,
ctr: r.ctr,
position: r.position,
cited_before: cited,
}
})
.collect();
rows.sort_by(|a, b| {
b.gap_score
.partial_cmp(&a.gap_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(rows)
}
pub(crate) fn urlencoding(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{:02X}", b));
}
}
out
}
pub(crate) fn chrono_now_days_ago(days: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let target = now.saturating_sub(days as u64 * 86400);
let days_total = target / 86400;
let z = days_total as i64 + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
format!("{:04}-{:02}-{:02}", y + if m <= 2 { 1 } else { 0 }, m, d)
}