This commit is contained in:
Maieul BOYER 2026-02-06 23:24:21 +01:00
parent 8afdd208c6
commit 8e82ec9523
Signed by: maix
SSH key fingerprint: SHA256:iqCzqFFF5KjRixmDExqbAltCIj9ndlBWIGJf3t9Ln9g
23 changed files with 3345 additions and 0 deletions

5
froxy-scraper/src/lib.rs Normal file
View file

@ -0,0 +1,5 @@
#![allow(dead_code)]
pub mod types;
pub mod state;
pub mod parsing;

View file

@ -0,0 +1,121 @@
use scraper::Selector;
use url::Url;
use crate::types::{ClusterLocation, ClusterLocationData, Relation};
macro_rules! sel {
($name:ident, $value:literal) => {
static $name: std::sync::LazyLock<Selector> =
std::sync::LazyLock::new(|| Selector::parse($value).unwrap());
};
}
sel!(
LOCATION_SEL,
"div.container-fluid.mt-2.scroll.p-0 > table.grid.text-center > tbody > tr > td"
);
sel!(IMAGE_SEL, "img");
pub fn get_cluster_location(
cluster_name: impl AsRef<str>,
page: impl AsRef<str>,
) -> crate::types::CluserInformation {
let page = page.as_ref();
let doc = scraper::Html::parse_document(page);
let tds = doc.select(&LOCATION_SEL);
let mut out = crate::types::CluserInformation {
cluster_name: cluster_name.as_ref().into(),
locations: Vec::with_capacity(128),
};
for td in tds {
let pos_login = td.attr("data-pos").and_then(|pos| {
td.attr("data-login")
.map(|login| (pos.trim(), login.trim()))
});
let is_dead = td
.value()
.has_class("dead", scraper::CaseSensitivity::AsciiCaseInsensitive);
let is_warn = td
.value()
.has_class("attention", scraper::CaseSensitivity::AsciiCaseInsensitive);
match (pos_login, is_dead, is_warn) {
(Some((pos, login)), d, w) if login.is_empty() && (d || w) => {
out.locations.push(match (d, w) {
(true, _) => ClusterLocation {
location: pos.into(),
data: ClusterLocationData::Normal {
status: crate::types::ClusterLocationStatus::Dead,
},
},
(false, true) => ClusterLocation {
location: pos.into(),
data: ClusterLocationData::Normal {
status: crate::types::ClusterLocationStatus::Damaged,
},
},
// this should never happen
_ => ClusterLocation {
location: pos.into(),
data: ClusterLocationData::Empty,
},
})
}
(Some((pos, login)), _, _) if !login.trim().is_empty() => {
let image = td
.select(&IMAGE_SEL)
.next()
.and_then(|i| i.attr("src"))
.and_then(|s| Url::parse(s).ok());
let is_me = td
.value()
.has_class("me", scraper::CaseSensitivity::AsciiCaseInsensitive);
let is_focus = td
.value()
.has_class("focus", scraper::CaseSensitivity::AsciiCaseInsensitive);
let is_friend = td
.value()
.has_class("friend", scraper::CaseSensitivity::AsciiCaseInsensitive);
let is_close_friend = td.value().has_class(
"close_friend",
scraper::CaseSensitivity::AsciiCaseInsensitive,
);
let is_pooled = td
.value()
.has_class("pooled", scraper::CaseSensitivity::AsciiCaseInsensitive);
let mut relation = Relation::None;
if is_me {
relation = Relation::Me;
}
if is_friend {
relation = Relation::Friend;
}
if is_close_friend {
relation = Relation::CloseFriend;
}
if is_pooled {
relation = Relation::Pooled;
}
if is_focus {
relation = Relation::Focus;
}
out.locations.push(ClusterLocation {
location: pos.into(),
data: ClusterLocationData::User {
login: login.into(),
relation,
image,
},
})
}
(_, _, _) => {}
}
}
out
}

138
froxy-scraper/src/state.rs Normal file
View file

@ -0,0 +1,138 @@
use std::borrow::Cow;
use reqwest::{Method, Response};
use secrecy::{ExposeSecret, SecretString};
use url::UrlQuery;
macro_rules! const_from_env {
($const:ident, $env:literal) => {
const $const: Option<&'static str> = option_env!($env);
};
($const:ident, $env:literal, $default:expr) => {
const $const: &'static str = match option_env!($env) {
Some(s) => s,
None => $default,
};
};
}
const_from_env!(USER_AGENT_APP, "FROXY_UA_APP_NAME", "Froxy");
const_from_env!(USER_AGENT_VERSION, "FROXY_UA_APP_VERSION", {
const_from_env!(CARGO_PKG_VERSION, "CARGO_PKG_VERSION", "0.1-alpha");
CARGO_PKG_VERSION
});
const_from_env!(
USER_AGENT_DESC,
"FROXY_UA_DESC",
"(by maiboyer; for maiboyer)"
);
pub const DEFAULT_USER_AGENT: &str =
constcat::concat!(USER_AGENT_APP, "/", USER_AGENT_VERSION, USER_AGENT_DESC);
#[derive(Debug, Clone)]
pub struct State {
client: reqwest::Client,
base_url: url::Url,
secret_cookie: secrecy::SecretString,
user_agent: std::borrow::Cow<'static, str>,
}
impl State {
pub fn new(secret_cookie: impl AsRef<str>) -> Self {
let user_agent = DEFAULT_USER_AGENT;
let base_url = "https://friends.42paris.fr";
Self::with_info(
secret_cookie,
user_agent.into(),
url::Url::parse(base_url).expect("default base url not valid"),
)
}
pub fn with_info(
secret_cookie: impl AsRef<str>,
user_agent: std::borrow::Cow<'static, str>,
base_url: url::Url,
) -> Self {
let client = reqwest::Client::builder()
.user_agent(&*user_agent)
.build()
.unwrap();
Self {
client,
base_url,
secret_cookie: secret_cookie.as_ref().into(),
user_agent,
}
}
pub fn client(&self) -> &reqwest::Client {
&self.client
}
pub fn base_url(&self) -> &url::Url {
&self.base_url
}
pub fn cookie(&self) -> &SecretString {
&self.secret_cookie
}
pub fn user_agent(&self) -> &str {
&self.user_agent
}
}
impl State {
pub async fn request(
&self,
method: Method,
url: impl AsRef<str>,
qs: Option<&str>,
body: Option<Vec<u8>>,
) -> Result<Response, reqwest::Error> {
let mut u = self.base_url.clone();
u.set_path(url.as_ref());
u.set_query(qs);
let builder = self
.client
.request(method, u)
.header("Cookie", self.secret_cookie.expose_secret());
let builder = if let Some(body) = body {
builder.body(body)
} else {
builder
};
builder.send().await
}
pub async fn get(
&self,
url: impl AsRef<str>,
qs: Option<&str>,
) -> Result<Response, reqwest::Error> {
self.request(Method::GET, url.as_ref(), qs, None).await
}
pub async fn post(
&self,
url: impl AsRef<str>,
qs: Option<&str>,
) -> Result<Response, reqwest::Error> {
self.request(Method::POST, url.as_ref(), qs, None).await
}
pub async fn post_with_body(
&self,
url: impl AsRef<str>,
qs: Option<&str>,
body: Vec<u8>,
) -> Result<Response, reqwest::Error> {
self.request(Method::POST, url.as_ref(), qs, Some(body))
.await
}
}

View file

@ -0,0 +1,48 @@
#[non_exhaustive]
#[derive(Debug, Clone, Default, Eq, PartialEq)]
pub enum ClusterLocationStatus {
#[doc(alias = "red")]
Dead,
#[doc(alias = "orange")]
Damaged,
#[doc(alias = "good")]
#[default]
Ok,
}
#[derive(Debug, Clone, Default)]
pub enum Relation {
CloseFriend,
Friend,
Pooled,
Me,
Focus,
#[default]
None,
}
#[derive(Debug, Clone, Default)]
pub enum ClusterLocationData {
User {
login: smol_str::SmolStr,
image: Option<url::Url>,
relation: Relation,
},
Normal {
status: ClusterLocationStatus,
},
#[default]
Empty,
}
#[derive(Debug, Clone)]
pub struct ClusterLocation {
pub location: smol_str::SmolStr,
pub data: ClusterLocationData,
}
#[derive(Debug, Clone)]
pub struct CluserInformation {
pub cluster_name: smol_str::SmolStr,
pub locations: Vec<ClusterLocation>,
}