use std::collections::HashMap;
use std::fs;
use std::io::{self, Write};
use std::path::Path;
use std::time::Instant;
#[derive(Debug, Clone, Default)]
pub struct ItemList {
items: Vec<serde_json::Value>,
}
impl ItemList {
pub fn new() -> Self {
Self { items: Vec::new() }
}
pub fn push(&mut self, item: serde_json::Value) {
self.items.push(item);
}
#[must_use]
pub fn len(&self) -> usize {
self.items.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn to_json(&self, path: &Path, indent: usize) -> io::Result<()> {
let buf = if indent > 0 {
serde_json::to_vec_pretty(&self.items).map_err(io::Error::other)?
} else {
serde_json::to_vec(&self.items).map_err(io::Error::other)?
};
fs::write(path, buf)
}
pub fn to_jsonl(&self, path: &Path) -> io::Result<()> {
let mut file = fs::File::create(path)?;
for item in &self.items {
let line = serde_json::to_string(item).map_err(io::Error::other)?;
writeln!(file, "{}", line)?;
}
Ok(())
}
pub fn to_csv(&self, path: &Path) -> io::Result<()> {
let columns = self.union_of_keys();
let mut out = String::new();
let write_row = |fields: &mut dyn Iterator<Item = String>, out: &mut String| {
let mut first = true;
for field in fields {
if !first {
out.push(',');
}
first = false;
out.push_str(&csv_escape(&field));
}
out.push_str("\r\n");
};
write_row(&mut columns.iter().cloned(), &mut out);
for item in &self.items {
let obj = match item.as_object() {
Some(o) => o,
None => continue,
};
write_row(
&mut columns
.iter()
.map(|key| obj.get(key).map(flatten_value).unwrap_or_default()),
&mut out,
);
}
fs::write(path, out)
}
pub fn to_xml(&self, path: &Path) -> io::Result<()> {
let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<items>\n");
for item in &self.items {
let obj = match item.as_object() {
Some(o) => o,
None => continue,
};
out.push_str(" <item>\n");
for (key, value) in obj {
let tag = xml_element_name(key);
out.push_str(" <");
out.push_str(&tag);
out.push('>');
out.push_str(&xml_escape(&flatten_value(value)));
out.push_str("</");
out.push_str(&tag);
out.push_str(">\n");
}
out.push_str(" </item>\n");
}
out.push_str("</items>\n");
fs::write(path, out)
}
fn union_of_keys(&self) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut columns = Vec::new();
for item in &self.items {
if let Some(obj) = item.as_object() {
for key in obj.keys() {
if seen.insert(key.clone()) {
columns.push(key.clone());
}
}
}
}
columns
}
}
fn flatten_value(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => String::new(),
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
nested => nested.to_string(),
}
}
fn csv_escape(field: &str) -> String {
if field.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field.to_string()
}
}
fn xml_escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
'\t' | '\n' | '\r' => out.push(c),
'\u{FFFE}' | '\u{FFFF}' => {}
c if c.is_control() => {}
c => out.push(c),
}
}
out
}
fn xml_element_name(key: &str) -> String {
let mut name: String = key
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' {
c
} else {
'_'
}
})
.collect();
if name.is_empty() {
name.push('_');
}
let first = name.chars().next().expect("non-empty");
if !(first.is_ascii_alphabetic() || first == '_') {
name.insert(0, '_');
}
name
}
impl IntoIterator for ItemList {
type Item = serde_json::Value;
type IntoIter = std::vec::IntoIter<serde_json::Value>;
fn into_iter(self) -> Self::IntoIter {
self.items.into_iter()
}
}
#[derive(Debug, Clone)]
pub struct CrawlStats {
pub requests_count: u64,
pub concurrent_requests: u32,
pub concurrent_requests_per_domain: u32,
pub failed_requests_count: u64,
pub offsite_requests_count: u64,
pub robots_disallowed_count: u64,
pub cache_hits: u64,
pub cache_misses: u64,
pub response_bytes: u64,
pub items_scraped: u64,
pub items_dropped: u64,
pub blocked_requests_count: u64,
pub download_delay: f64,
pub start_time: Option<Instant>,
pub end_time: Option<Instant>,
pub response_status_count: HashMap<u16, u64>,
pub domains_response_bytes: HashMap<String, u64>,
pub sessions_requests_count: HashMap<String, u64>,
pub custom_stats: HashMap<String, serde_json::Value>,
}
impl Default for CrawlStats {
fn default() -> Self {
Self {
requests_count: 0,
concurrent_requests: 0,
concurrent_requests_per_domain: 0,
failed_requests_count: 0,
offsite_requests_count: 0,
robots_disallowed_count: 0,
cache_hits: 0,
cache_misses: 0,
response_bytes: 0,
items_scraped: 0,
items_dropped: 0,
blocked_requests_count: 0,
download_delay: 0.0,
start_time: None,
end_time: None,
response_status_count: HashMap::new(),
domains_response_bytes: HashMap::new(),
sessions_requests_count: HashMap::new(),
custom_stats: HashMap::new(),
}
}
}
impl CrawlStats {
pub fn increment_requests_count(&mut self) {
self.requests_count += 1;
}
pub fn increment_status(&mut self, status: u16) {
*self.response_status_count.entry(status).or_insert(0) += 1;
}
pub fn increment_response_bytes(&mut self, bytes: u64) {
self.response_bytes += bytes;
}
#[must_use]
pub fn elapsed_seconds(&self) -> f64 {
match (self.start_time, self.end_time) {
(Some(start), Some(end)) => end.duration_since(start).as_secs_f64(),
(Some(start), None) => start.elapsed().as_secs_f64(),
_ => 0.0,
}
}
#[must_use]
pub fn requests_per_second(&self) -> f64 {
let elapsed = self.elapsed_seconds();
if elapsed > 0.0 {
self.requests_count as f64 / elapsed
} else {
0.0
}
}
}
#[derive(Debug)]
pub struct CrawlResult {
pub stats: CrawlStats,
pub items: ItemList,
pub paused: bool,
}
impl CrawlResult {
#[must_use]
pub fn completed(&self) -> bool {
!self.paused
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
fn sample_items() -> ItemList {
let mut items = ItemList::new();
items.push(json!({
"title": "Plain",
"price": 9.99,
"tags": ["a", "b"],
}));
items.push(json!({
"title": "Quote \"and\", comma",
"sku": "X-1",
"meta": {"color": "red"},
}));
items
}
#[test]
fn csv_unions_heterogeneous_keys_and_escapes() {
let dir = tempdir().unwrap();
let path = dir.path().join("items.csv");
sample_items().to_csv(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines[0], "title,price,tags,sku,meta");
assert_eq!(lines[1], "Plain,9.99,\"[\"\"a\"\",\"\"b\"\"]\",,");
assert_eq!(
lines[2],
"\"Quote \"\"and\"\", comma\",,,X-1,\"{\"\"color\"\":\"\"red\"\"}\""
);
}
#[test]
fn csv_skips_non_object_items() {
let mut items = ItemList::new();
items.push(json!("just a string"));
items.push(json!({"k": "v"}));
let dir = tempdir().unwrap();
let path = dir.path().join("items.csv");
items.to_csv(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content.lines().count(), 2, "header + one object row");
}
#[test]
fn xml_escapes_text_and_sanitizes_element_names() {
let mut items = ItemList::new();
items.push(json!({
"title": "a < b & c > 'd'",
"weird key!": "v",
"1st": "leading digit",
"": "empty key",
}));
let dir = tempdir().unwrap();
let path = dir.path().join("items.xml");
items.to_xml(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
assert!(content.contains("<title>a < b & c > 'd'</title>"));
assert!(content.contains("<weird_key_>v</weird_key_>"));
assert!(content.contains("<_1st>leading digit</_1st>"));
assert!(content.contains("<_>empty key</_>"));
assert!(content.trim_end().ends_with("</items>"));
}
#[test]
fn xml_strips_invalid_control_characters() {
let mut items = ItemList::new();
items.push(json!({
"text": "bell\u{0007} null\u{0000} keep\ttab\nnewline \u{FFFF}end",
}));
let dir = tempdir().unwrap();
let path = dir.path().join("items.xml");
items.to_xml(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("<text>bell null keep\ttab\nnewline end</text>"));
assert!(!content.contains('\u{0007}'));
assert!(!content.contains('\u{FFFF}'));
}
#[test]
fn csv_rows_end_with_crlf() {
let mut items = ItemList::new();
items.push(json!({"k": "v"}));
let dir = tempdir().unwrap();
let path = dir.path().join("crlf.csv");
items.to_csv(&path).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(bytes, b"k\r\nv\r\n");
}
#[test]
fn xml_nested_values_become_json_strings() {
let mut items = ItemList::new();
items.push(json!({"meta": {"a": 1}, "none": null}));
let dir = tempdir().unwrap();
let path = dir.path().join("items.xml");
items.to_xml(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("<meta>{"a":1}</meta>"));
assert!(content.contains("<none></none>"));
}
#[test]
fn empty_item_list_exports_header_only_csv_and_empty_xml() {
let items = ItemList::new();
let dir = tempdir().unwrap();
let csv = dir.path().join("empty.csv");
let xml = dir.path().join("empty.xml");
items.to_csv(&csv).unwrap();
items.to_xml(&xml).unwrap();
assert_eq!(std::fs::read_to_string(&csv).unwrap(), "\r\n");
let xml_content = std::fs::read_to_string(&xml).unwrap();
assert!(xml_content.contains("<items>\n</items>"));
}
}