use std::collections::HashMap;
use chromiumoxide::cdp::browser_protocol::network::ResourceType;
use url::Url;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedPage {
pub html: String,
pub observed_resources: Vec<ObservedResource>,
pub diagnostics: Vec<String>,
}
impl RenderedPage {
#[must_use]
pub fn html_only(html: String) -> Self {
Self {
html,
observed_resources: Vec::new(),
diagnostics: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObservedResource {
pub url: String,
pub method: String,
pub kind: ObservedResourceKind,
pub status: Option<u16>,
pub mime_type: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ObservedResourceKind {
Document,
Stylesheet,
Image,
Media,
Font,
Script,
TextTrack,
Manifest,
Prefetch,
Other,
}
#[derive(Debug, Clone)]
pub(crate) struct ObservedRequest {
pub(crate) url: String,
pub(crate) method: String,
pub(crate) kind: Option<ObservedResourceKind>,
}
#[derive(Debug, Clone)]
pub(crate) struct ObservedResponse {
pub(crate) url: String,
pub(crate) kind: ObservedResourceKind,
pub(crate) status: Option<u16>,
pub(crate) mime_type: Option<String>,
}
#[derive(Debug, Default)]
pub(crate) struct RuntimeResourceCollector {
requests: HashMap<String, ObservedRequest>,
responses: HashMap<String, ObservedResponse>,
resources: HashMap<String, ObservedResource>,
}
pub(crate) fn observed_kind_from_cdp(kind: &ResourceType) -> Option<ObservedResourceKind> {
match kind {
ResourceType::Document => Some(ObservedResourceKind::Document),
ResourceType::Stylesheet => Some(ObservedResourceKind::Stylesheet),
ResourceType::Image => Some(ObservedResourceKind::Image),
ResourceType::Media => Some(ObservedResourceKind::Media),
ResourceType::Font => Some(ObservedResourceKind::Font),
ResourceType::Script => Some(ObservedResourceKind::Script),
ResourceType::TextTrack => Some(ObservedResourceKind::TextTrack),
ResourceType::Prefetch => Some(ObservedResourceKind::Prefetch),
ResourceType::Manifest => Some(ObservedResourceKind::Manifest),
ResourceType::Other => Some(ObservedResourceKind::Other),
ResourceType::Xhr
| ResourceType::Fetch
| ResourceType::EventSource
| ResourceType::WebSocket
| ResourceType::SignedExchange
| ResourceType::Ping
| ResourceType::CspViolationReport
| ResourceType::Preflight => None,
}
}
impl RuntimeResourceCollector {
pub(crate) fn record_request(&mut self, request_id: String, request: ObservedRequest) {
if accepts_request(&request) {
if let Some(response) = self.responses.remove(&request_id) {
insert_resource(&mut self.resources, &request, response);
}
self.requests.insert(request_id, request);
}
}
pub(crate) fn record_response(&mut self, request_id: &str, response: ObservedResponse) {
if !accepts_response(&response) {
return;
}
let Some(request) = self.requests.get(request_id) else {
self.responses.insert(request_id.to_string(), response);
return;
};
if !accepts_request(request) {
return;
}
insert_resource(&mut self.resources, request, response);
}
pub(crate) fn finish(self) -> Vec<ObservedResource> {
sort_resources(self.resources.into_values().collect())
}
pub(crate) fn snapshot(&self) -> Vec<ObservedResource> {
sort_resources(self.resources.values().cloned().collect())
}
}
fn sort_resources(mut resources: Vec<ObservedResource>) -> Vec<ObservedResource> {
resources.sort_by(|left, right| left.url.cmp(&right.url));
resources
}
fn insert_resource(
resources: &mut HashMap<String, ObservedResource>,
request: &ObservedRequest,
response: ObservedResponse,
) {
resources
.entry(response.url.clone())
.and_modify(|resource| {
resource.kind = response.kind;
resource.status = response.status;
resource.mime_type.clone_from(&response.mime_type);
})
.or_insert_with(|| ObservedResource {
url: response.url,
method: request.method.clone(),
kind: response.kind,
status: response.status,
mime_type: response.mime_type,
});
}
const fn accepts_kind(kind: ObservedResourceKind) -> bool {
match kind {
ObservedResourceKind::Document
| ObservedResourceKind::Stylesheet
| ObservedResourceKind::Image
| ObservedResourceKind::Media
| ObservedResourceKind::Font
| ObservedResourceKind::Script
| ObservedResourceKind::TextTrack
| ObservedResourceKind::Manifest
| ObservedResourceKind::Prefetch
| ObservedResourceKind::Other => true,
}
}
fn accepts_request(request: &ObservedRequest) -> bool {
request.method.eq_ignore_ascii_case("GET")
&& is_http_url(&request.url)
&& request
.kind
.map_or_else(|| accepts_static_extension(&request.url), accepts_kind)
}
fn accepts_response(response: &ObservedResponse) -> bool {
response
.status
.is_some_and(|status| (200..300).contains(&status))
}
fn is_http_url(raw_url: &str) -> bool {
Url::parse(raw_url).is_ok_and(|url| matches!(url.scheme(), "http" | "https"))
}
fn classify_url(raw_url: &str) -> ObservedResourceKind {
let Ok(url) = Url::parse(raw_url) else {
return ObservedResourceKind::Other;
};
let path = url.path().to_ascii_lowercase();
if matches!(extension(&path), Some("js" | "mjs" | "cjs")) {
return ObservedResourceKind::Script;
}
if matches!(extension(&path), Some("css")) {
return ObservedResourceKind::Stylesheet;
}
if matches!(
extension(&path),
Some("avif" | "gif" | "ico" | "jpeg" | "jpg" | "png" | "svg" | "webp")
) {
return ObservedResourceKind::Image;
}
if matches!(
extension(&path),
Some("woff" | "woff2" | "ttf" | "otf" | "eot")
) {
return ObservedResourceKind::Font;
}
if matches!(
extension(&path),
Some("mp3" | "mp4" | "ogg" | "webm" | "wav")
) {
return ObservedResourceKind::Media;
}
if matches!(extension(&path), Some("webmanifest" | "manifest")) {
return ObservedResourceKind::Manifest;
}
ObservedResourceKind::Other
}
fn accepts_static_extension(raw_url: &str) -> bool {
!matches!(classify_url(raw_url), ObservedResourceKind::Other)
}
fn extension(path: &str) -> Option<&str> {
path.rsplit_once('.').map(|(_, extension)| extension)
}
#[cfg(test)]
#[path = "runtime_tests.rs"]
mod tests;