mirror of
https://codeberg.org/gigirassy/riptide
synced 2026-08-30 15:27:38 +00:00
Add src/main.rs
This commit is contained in:
+201
@@ -0,0 +1,201 @@
|
||||
use std::{
|
||||
collections::{HashSet, VecDeque},
|
||||
fs,
|
||||
io::Read,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, ValueEnum};
|
||||
use flate2::read::GzDecoder;
|
||||
use futures::stream::{self, StreamExt};
|
||||
use mimalloc::MiMalloc;
|
||||
use reqwest::{header, Client};
|
||||
use roxmltree::Document;
|
||||
use tokio::time::timeout;
|
||||
use url::Url;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about)]
|
||||
struct Args {
|
||||
#[arg(long = "sitemap", required = true)]
|
||||
sitemaps: Vec<String>,
|
||||
|
||||
#[arg(long, default_value = "urls.txt")]
|
||||
output: String,
|
||||
|
||||
#[arg(long, value_enum, default_value_t = SchemeOpt::Https)]
|
||||
scheme: SchemeOpt,
|
||||
|
||||
#[arg(long, default_value_t = 8)]
|
||||
workers: usize,
|
||||
|
||||
#[arg(long, default_value = "riptide 0.1 https://codeberg.org/gigirassy/riptide/")]
|
||||
user_agent: String,
|
||||
|
||||
#[arg(long, default_value_t = 30)]
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum SchemeOpt {
|
||||
Http,
|
||||
Https,
|
||||
}
|
||||
|
||||
impl SchemeOpt {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
SchemeOpt::Http => "http",
|
||||
SchemeOpt::Https => "https",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ParsedSitemap {
|
||||
Index(Vec<String>),
|
||||
UrlSet(Vec<String>),
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
let client = Client::builder()
|
||||
.user_agent(args.user_agent)
|
||||
.timeout(Duration::from_secs(args.timeout_secs))
|
||||
.redirect(reqwest::redirect::Policy::limited(10))
|
||||
.build()
|
||||
.context("failed to build HTTP client")?;
|
||||
|
||||
let mut queue: VecDeque<Url> = VecDeque::new();
|
||||
for s in &args.sitemaps {
|
||||
queue.push_back(Url::parse(s).with_context(|| format!("invalid sitemap URL: {s}"))?);
|
||||
}
|
||||
|
||||
let mut visited_sitemaps: HashSet<String> = HashSet::new();
|
||||
let mut extracted_urls: HashSet<String> = HashSet::new();
|
||||
let workers = args.workers.max(1);
|
||||
|
||||
while !queue.is_empty() {
|
||||
let mut batch = Vec::new();
|
||||
|
||||
while batch.len() < workers {
|
||||
let Some(next) = queue.pop_front() else {
|
||||
break;
|
||||
};
|
||||
|
||||
let key = next.as_str().to_string();
|
||||
if visited_sitemaps.insert(key) {
|
||||
batch.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
if batch.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let results = stream::iter(batch.into_iter().map(|sitemap_url| {
|
||||
let client = client.clone();
|
||||
async move { fetch_and_parse(&client, sitemap_url).await }
|
||||
}))
|
||||
.buffer_unordered(workers)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
for result in results {
|
||||
let (parsed, base_url) = result?;
|
||||
|
||||
match parsed {
|
||||
ParsedSitemap::Index(children) => {
|
||||
for child in children {
|
||||
if let Ok(url) = resolve_url(&base_url, &child) {
|
||||
queue.push_back(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
ParsedSitemap::UrlSet(urls) => {
|
||||
for raw in urls {
|
||||
if let Ok(url) = resolve_url(&base_url, &raw) {
|
||||
let normalized = force_scheme(url, args.scheme);
|
||||
extracted_urls.insert(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out: Vec<String> = extracted_urls.into_iter().collect();
|
||||
out.sort_unstable();
|
||||
|
||||
fs::write(&args.output, out.join("\n") + "\n")
|
||||
.with_context(|| format!("failed to write {}", args.output))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_and_parse(client: &Client, sitemap_url: Url) -> Result<(ParsedSitemap, Url)> {
|
||||
let resp = timeout(Duration::from_secs(30), client.get(sitemap_url.clone()).send())
|
||||
.await
|
||||
.context("request timed out")?
|
||||
.context("request failed")?
|
||||
.error_for_status()
|
||||
.context("server returned an error status")?;
|
||||
|
||||
let final_url = resp.url().clone();
|
||||
let bytes = resp.bytes().await.context("failed to read response body")?;
|
||||
|
||||
let xml = decode_sitemap_body(&bytes, &final_url)?;
|
||||
let doc = Document::parse(&xml).context("failed to parse sitemap XML")?;
|
||||
let root = doc.root_element().tag_name().name();
|
||||
|
||||
let locs = doc
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("loc"))
|
||||
.filter_map(|n| n.text())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let parsed = match root {
|
||||
"sitemapindex" => ParsedSitemap::Index(locs),
|
||||
"urlset" => ParsedSitemap::UrlSet(locs),
|
||||
other => bail!("unexpected root element: {other}"),
|
||||
};
|
||||
|
||||
Ok((parsed, final_url))
|
||||
}
|
||||
|
||||
fn resolve_url(base: &Url, raw: &str) -> Result<Url> {
|
||||
if let Ok(url) = Url::parse(raw) {
|
||||
return Ok(url);
|
||||
}
|
||||
Ok(base.join(raw)?)
|
||||
}
|
||||
|
||||
fn force_scheme(mut url: Url, scheme: SchemeOpt) -> String {
|
||||
let _ = url.set_scheme(scheme.as_str());
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
fn decode_sitemap_body(bytes: &[u8], url: &Url) -> Result<String> {
|
||||
if let Ok(s) = std::str::from_utf8(bytes) {
|
||||
return Ok(s.to_string());
|
||||
}
|
||||
|
||||
if url.path().ends_with(".gz") || bytes.starts_with(&[0x1f, 0x8b]) {
|
||||
let mut decoder = GzDecoder::new(bytes);
|
||||
let mut s = String::new();
|
||||
decoder
|
||||
.read_to_string(&mut s)
|
||||
.context("failed to decompress gzip sitemap")?;
|
||||
return Ok(s);
|
||||
}
|
||||
|
||||
Ok(String::from_utf8(bytes.to_vec()).context("sitemap body is not valid UTF-8")?)
|
||||
}
|
||||
Reference in New Issue
Block a user