Files
WebScraper/src/scraper/protonvpn_extension.rs
2025-12-09 14:57:18 +01:00

352 lines
12 KiB
Rust

// src/scraper/protonvpn_extension.rs
//! ProtonVPN-Chrome-Extension Automater
//!
//! Automatisiert Interaktionen mit der ProtonVPN-Extension im Browser:
//! - Verbindung trennen/verbinden
//! - Server auswählen
//! - VPN-Status überprüfen
//! - Externe IP-Adresse abrufen
use anyhow::{anyhow, Context, Result};
use fantoccini::Client;
use tokio::time::{sleep, Duration};
use tracing::{debug, info, warn};
/// Automater für die ProtonVPN-Chrome-Extension
pub struct ProtonVpnAutomater {
/// Chrome-Extension ID (Standardwert: offizielle ProtonVPN)
extension_id: String,
}
impl ProtonVpnAutomater {
/// Erstellt einen neuen ProtonVPN-Automater
///
/// # Arguments
/// * `extension_id` - Die Extension-ID (z.B. "ghmbeldphafepmbegfdlkpapadhbakde")
pub fn new(extension_id: String) -> Self {
Self { extension_id }
}
/// Trennt die Verbindung zur ProtonVPN
///
/// # Arguments
/// * `client` - Der Fantoccini WebDriver Client
///
/// # Returns
/// Ok wenn erfolgreich, oder Err mit Kontext
pub async fn disconnect(&self, client: &Client) -> Result<()> {
info!("🔌 Disconnecting from ProtonVPN");
let extension_url = format!("chrome-extension://{}/popup.html", self.extension_id);
client
.goto(&extension_url)
.await
.context("Failed to navigate to ProtonVPN extension popup")?;
sleep(Duration::from_millis(500)).await;
// Versuchen, "Disconnect"-Button zu finden und zu klicken
match self.find_and_click_button(client, "disconnect").await {
Ok(_) => {
sleep(Duration::from_secs(2)).await;
info!("✓ Successfully disconnected from ProtonVPN");
Ok(())
}
Err(e) => {
warn!(
"Disconnect button not found (may be already disconnected): {}",
e
);
Ok(()) // Weiter auch wenn Button nicht found
}
}
}
/// Verbindung zu einem spezifischen ProtonVPN-Server herstellen
///
/// # Arguments
/// * `client` - Der Fantoccini WebDriver Client
/// * `server` - Server-Name (z.B. "US-Free#1", "UK-Free#1")
///
/// # Returns
/// Ok wenn erfolgreich verbunden, Err wenn Timeout oder Fehler
pub async fn connect_to_server(&self, client: &Client, server: &str) -> Result<()> {
info!("🔗 Connecting to ProtonVPN server: {}", server);
let extension_url = format!("chrome-extension://{}/popup.html", self.extension_id);
client
.goto(&extension_url)
.await
.context("Failed to navigate to ProtonVPN extension")?;
sleep(Duration::from_millis(500)).await;
// Server-Liste öffnen (optional, falls UI das erfordert)
let _ = self.find_and_click_button(client, "server").await;
sleep(Duration::from_millis(300)).await;
// Auf spezifischen Server klicken
let _ = self.find_and_click_button(client, server).await;
sleep(Duration::from_millis(300)).await;
// "Connect"-Button klicken
self.find_and_click_button(client, "connect")
.await
.context(format!(
"Failed to find or click Connect button for server {}",
server
))?;
debug!("Waiting for VPN connection to establish...");
// Warten bis verbunden (max 15 Sekunden, Polling alle 500ms)
for attempt in 0..30 {
sleep(Duration::from_millis(500)).await;
if self.is_connected(client).await.unwrap_or(false) {
info!(
"✓ Successfully connected to {} after {} ms",
server,
attempt * 500
);
return Ok(());
}
if attempt % 6 == 0 {
debug!("Still waiting for connection... ({} sec)", attempt / 2);
}
}
Err(anyhow!(
"Failed to connect to ProtonVPN server '{}' within 15 seconds",
server
))
}
/// Prüft, ob ProtonVPN aktuell verbunden ist
///
/// # Arguments
/// * `client` - Der Fantoccini WebDriver Client
///
/// # Returns
/// `true` wenn verbunden, `false` wenn getrennt oder Status unklar
pub async fn is_connected(&self, client: &Client) -> Result<bool> {
let extension_url = format!("chrome-extension://{}/popup.html", self.extension_id);
client
.goto(&extension_url)
.await
.context("Failed to navigate to extension popup")?;
sleep(Duration::from_millis(200)).await;
let page_source = client
.source()
.await
.context("Failed to get page source from extension")?;
// Prüfe auf verschiedene Indikatoren für "verbunden"-Status
// Diese können sich zwischen Extension-Versionen ändern
let is_connected = page_source.contains("Connected")
|| page_source.contains("connected")
|| page_source.contains("status-connected")
|| page_source.contains("connected-state")
|| page_source.contains("vpn-status-connected");
debug!(
"VPN connection status: {}",
if is_connected {
"connected"
} else {
"disconnected"
}
);
Ok(is_connected)
}
/// Holt die aktuelle externe IP-Adresse
///
/// Navigiert zu einer öffentlichen IP-Check-Webseite und extrahiert die IP.
///
/// # Arguments
/// * `client` - Der Fantoccini WebDriver Client
///
/// # Returns
/// Die externe IPv4-Adresse als String
pub async fn get_current_ip(&self, client: &Client) -> Result<String> {
info!("📍 Checking current external IP address");
// Navigiere zu whatismyipaddress.com
client
.goto("https://whatismyipaddress.com/")
.await
.context("Failed to navigate to whatismyipaddress.com")?;
sleep(Duration::from_secs(2)).await;
let page_source = client
.source()
.await
.context("Failed to get page source from IP check site")?;
// Extrahiere IPv4-Adresse - auf verschiedene HTML-Strukturen prüfen
if let Some(ip) = self.extract_ipv4(&page_source) {
info!("Current external IP: {}", ip);
return Ok(ip);
}
// Fallback: Versuche icanhazip.com (gibt nur IP zurück)
debug!("Failed to extract IP from whatismyipaddress.com, trying fallback...");
self.get_current_ip_fallback(client).await
}
/// Fallback IP-Check mit alternativer Seite
async fn get_current_ip_fallback(&self, client: &Client) -> Result<String> {
client
.goto("https://icanhazip.com/")
.await
.context("Failed to navigate to icanhazip.com")?;
sleep(Duration::from_secs(1)).await;
let page_source = client
.source()
.await
.context("Failed to get page source from icanhazip.com")?;
let ip = page_source.trim().to_string();
// Validiere einfach dass es IP-ähnlich aussieht
if ip.contains('.') && ip.len() > 7 && ip.len() < 16 {
info!("Current external IP (from fallback): {}", ip);
return Ok(ip);
}
Err(anyhow!("Failed to extract IP from all fallback sources"))
}
/// Hilfsfunktion zum Finden und Klicken von Buttons
///
/// # Arguments
/// * `client` - Der Fantoccini WebDriver Client
/// * `text` - Der Text oder Daten-Attribut des Buttons
///
/// # Returns
/// Ok wenn Button gefunden und geklickt, Err sonst
async fn find_and_click_button(&self, client: &Client, text: &str) -> Result<()> {
let lower_text = text.to_lowercase();
// Mehrere XPath-Strategien für verschiedene UI-Implementierungen
let xpath_strategies = vec![
// Text-basiert (case-insensitive)
format!(
"//button[contains(translate(text(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{}')]",
lower_text
),
// Daten-Attribut
format!("//*[@data-action='{}']", lower_text),
format!("//*[@data-button='{}']", lower_text),
// Aria-Label
format!("//*[@aria-label='{}']", text),
// Span/Div als Button (Fallback)
format!(
"//*[contains(translate(text(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{}')][@role='button']",
lower_text
),
];
for xpath in xpath_strategies {
if let Ok(element) = client.find(fantoccini::Locator::XPath(&xpath)).await {
element
.click()
.await
.context(format!("Failed to click element with text '{}'", text))?;
debug!("Clicked button: '{}'", text);
return Ok(());
}
}
Err(anyhow!(
"Button '{}' not found with any XPath strategy",
text
))
}
/// Extrahiert IPv4-Adresse aus HTML-Quelle
fn extract_ipv4(&self, html: &str) -> Option<String> {
// Regex für IPv4: xxx.xxx.xxx.xxx
let parts: Vec<&str> = html.split(|c: char| !c.is_numeric() && c != '.').collect();
for part in parts {
if self.is_valid_ipv4(part) {
return Some(part.to_string());
}
}
// Fallback: Suche nach HTML-Strukturen wie <span>192.168.1.1</span>
if let Some(start) = html.find("IPv4") {
let section = &html[start..];
if let Some(ip_start) = section.find(|c: char| c.is_numeric()) {
if let Some(ip_end) =
section[ip_start..].find(|c: char| !c.is_numeric() && c != '.')
{
let ip = &section[ip_start..ip_start + ip_end];
if self.is_valid_ipv4(ip) {
return Some(ip.to_string());
}
}
}
}
None
}
/// Validiert ob ein String eine gültige IPv4-Adresse ist
fn is_valid_ipv4(&self, ip: &str) -> bool {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() != 4 {
return false;
}
parts.iter().all(|part| part.parse::<u8>().is_ok())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ipv4_validation() {
let automater = ProtonVpnAutomater::new("test-ext-id".to_string());
assert!(automater.is_valid_ipv4("192.168.1.1"));
assert!(automater.is_valid_ipv4("8.8.8.8"));
assert!(automater.is_valid_ipv4("255.255.255.255"));
assert!(!automater.is_valid_ipv4("256.1.1.1")); // Out of range
assert!(!automater.is_valid_ipv4("192.168.1")); // Too few parts
assert!(!automater.is_valid_ipv4("192.168.1.1.1")); // Too many parts
assert!(!automater.is_valid_ipv4("192.168.1.abc")); // Non-numeric
}
#[test]
fn test_extract_ipv4() {
let automater = ProtonVpnAutomater::new("test-ext-id".to_string());
let html = "<span>Your IP is 192.168.1.1 today</span>";
assert_eq!(
automater.extract_ipv4(html),
Some("192.168.1.1".to_string())
);
let html2 = "IPv4: 8.8.8.8";
assert_eq!(automater.extract_ipv4(html2), Some("8.8.8.8".to_string()));
let html3 = "No IP here";
assert_eq!(automater.extract_ipv4(html3), None);
}
}