arhivach-downloader

Download arhivach.vc threads
git clone https://git.ea.contact/arhivach-downloader
Log | Files | Refs | README

commit 3a58d17ca36c0ed13f77c767eda37f0afc1f1e56
parent 21406a80688851194f2659c5bc0ae1cf00c7f906
Author: egor-achkasov <eaachkasov@gmail.com>
Date:   Wed,  6 May 2026 17:32:03 +0000

Drop anyhow; add custom Error enum

Diffstat:
MCargo.toml | 1-
Msrc/bin/cli/main.rs | 262++++++++++++++++++++++++++++++++++++++++---------------------------------------
Msrc/bin/tui/main.rs | 11+++++++----
Msrc/lib/download.rs | 6+++---
Asrc/lib/error.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/export/html/mod.rs | 55++++++++++++++++++++++++++++---------------------------
Msrc/lib/export/mod.rs | 69++++++++++++++++++++++++++++++++++-----------------------------------
Msrc/lib/lib.rs | 196+++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/lib/post.rs | 43+++++++++++++++++++++----------------------
9 files changed, 385 insertions(+), 321 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -20,7 +20,6 @@ name = "arhivach-downloader-tui" path = "src/bin/tui/main.rs" [dependencies] -anyhow = "1.0.102" arboard = "3" clap = { version = "4.5.57", features = ["derive"] } ratatui = "0.29" diff --git a/src/bin/cli/main.rs b/src/bin/cli/main.rs @@ -1,130 +1,132 @@ -use arhivarch_downloader::config::Config; -use arhivarch_downloader::event::Event; -use arhivarch_downloader::export::{html::HtmlExporter, ExporterKind}; - -use clap::{Parser, ValueEnum}; - -use std::path::PathBuf; - -#[derive(Clone, ValueEnum)] -enum ExporterArg { - Html, -} -use std::sync::mpsc::channel; - -fn main() -> anyhow::Result<()> { - let config = parse_args(); - let (tx, rx) = channel::<Event>(); - let handle = std::thread::spawn({ - let config = config.clone(); - move || arhivarch_downloader::run(&config, tx) - }); - - for event in rx { - render_event(&event); - } - - let _ = handle.join().map_err(|e| anyhow::anyhow!("{:?}", e))?; - Ok(()) -} - -pub fn parse_args() -> Config { - #[derive(Parser)] - #[command(about, long_about)] - struct Cli { - /// URL to download - url: String, - - /// Path to download directory - #[arg(short = 'd', long = "dir", value_name = "DIR", default_value = ".", value_hint = clap::ValueHint::DirPath)] - dir: PathBuf, - - /// Exporter - #[arg(short = 'e', long = "exporter", value_name = "EXPORTER", default_value = "html")] - exporter: ExporterArg, - - /// Download thumbnail images, default: false - #[arg(short = 't', long = "thumb", default_value_t = false)] - thumb: bool, - - /// Download files (images, videos, gifs, etc), default: false - #[arg(short = 'f', long = "files", default_value_t = false)] - files: bool, - - /// Resume files and thumbnails downloading instead of overwriting. Useless if neither -t nor -f are set, default: false - #[arg(short = 'r', long = "resume", default_value_t = false)] - resume: bool, - - /// Download retries in case of a error - #[arg(short = 'R', long = "retries", default_value_t = 3)] - download_retries: u32, - } - let cli = Cli::parse(); - - Config { - url: cli.url, - dir: cli.dir, - exporter: match cli.exporter { - ExporterArg::Html => ExporterKind::Html(HtmlExporter), - }, - thumb: cli.thumb, - files: cli.files, - resume: cli.resume, - download_retries: cli.download_retries, - } -} - -fn render_event(event: &Event) { - use std::io::Write; - match event { - Event::GetStarted => { - print!("Fetching thread..."); - std::io::stdout().flush().ok(); - } - Event::GetDone => - println!(" Done."), - Event::GetFailed { error } => - eprintln!("\nFailed to fetch thread: {}", error), - - Event::DownloadAllStarted => - println!("Downloading stuff..."), - Event::DownloadAllDone => - println!("All downloads complete."), - Event::DownloadAllFailed { error } => - eprintln!("Download failed: {}", error), - - Event::DownloadStarted { index, max_index } => { - print!("\r\tDownloading {} / {}...", index, max_index); - std::io::stdout().flush().ok(); - } - Event::DownloadDone { index, max_index } => { - println!("\r\tDownloading {} / {}... Done.", index, max_index); - } - Event::DownloadFailed { url, error } => - eprintln!("\r\tFailed to download {}: {}", url, error), - Event::DownloadSkipped { index, max_index } => - println!("\r\tDownloading {} / {}... Skipped.", index, max_index), - - Event::DownloadFilesStarted => { - println!("Downloading files..."); - std::io::stdout().flush().ok(); - } - Event::DownloadFilesDone => - println!("Done."), - Event::DownloadThumbStarted => { - println!("Downloading thumbnails..."); - std::io::stdout().flush().ok(); - } - Event::DownloadThumbDone => - println!("Done."), - - Event::ExportStarted => { - print!("Exporting..."); - std::io::stdout().flush().ok(); - } - Event::ExportDone => - println!(" Done."), - Event::ExportFailed { error } => - eprintln!("\nExport failed: {}", error), - } -} +use arhivarch_downloader::config::Config; +use arhivarch_downloader::event::Event; +use arhivarch_downloader::export::{html::HtmlExporter, ExporterKind}; + +use clap::{Parser, ValueEnum}; + +use std::path::PathBuf; + +#[derive(Clone, ValueEnum)] +enum ExporterArg { + Html, +} +use std::sync::mpsc::channel; + +fn main() { + let config = parse_args(); + let (tx, rx) = channel::<Event>(); + let handle = std::thread::spawn({ + let config = config.clone(); + move || arhivarch_downloader::run(&config, tx) + }); + + for event in rx { + render_event(&event); + } + + handle.join().map_err(|e| { + eprintln!("ERROR: {:?}", e); + std::process::exit(1); + }).ok(); +} + +pub fn parse_args() -> Config { + #[derive(Parser)] + #[command(about, long_about)] + struct Cli { + /// URL to download + url: String, + + /// Path to download directory + #[arg(short = 'd', long = "dir", value_name = "DIR", default_value = ".", value_hint = clap::ValueHint::DirPath)] + dir: PathBuf, + + /// Exporter + #[arg(short = 'e', long = "exporter", value_name = "EXPORTER", default_value = "html")] + exporter: ExporterArg, + + /// Download thumbnail images, default: false + #[arg(short = 't', long = "thumb", default_value_t = false)] + thumb: bool, + + /// Download files (images, videos, gifs, etc), default: false + #[arg(short = 'f', long = "files", default_value_t = false)] + files: bool, + + /// Resume files and thumbnails downloading instead of overwriting. Useless if neither -t nor -f are set, default: false + #[arg(short = 'r', long = "resume", default_value_t = false)] + resume: bool, + + /// Download retries in case of a error + #[arg(short = 'R', long = "retries", default_value_t = 3)] + download_retries: u32, + } + let cli = Cli::parse(); + + Config { + url: cli.url, + dir: cli.dir, + exporter: match cli.exporter { + ExporterArg::Html => ExporterKind::Html(HtmlExporter), + }, + thumb: cli.thumb, + files: cli.files, + resume: cli.resume, + download_retries: cli.download_retries, + } +} + +fn render_event(event: &Event) { + use std::io::Write; + match event { + Event::GetStarted => { + print!("Fetching thread..."); + std::io::stdout().flush().ok(); + } + Event::GetDone => + println!(" Done."), + Event::GetFailed { error } => + eprintln!("\nFailed to fetch thread: {}", error), + + Event::DownloadAllStarted => + println!("Downloading stuff..."), + Event::DownloadAllDone => + println!("All downloads complete."), + Event::DownloadAllFailed { error } => + eprintln!("Download failed: {}", error), + + Event::DownloadStarted { index, max_index } => { + print!("\r\tDownloading {} / {}...", index, max_index); + std::io::stdout().flush().ok(); + } + Event::DownloadDone { index, max_index } => { + println!("\r\tDownloading {} / {}... Done.", index, max_index); + } + Event::DownloadFailed { url, error } => + eprintln!("\r\tFailed to download {}: {}", url, error), + Event::DownloadSkipped { index, max_index } => + println!("\r\tDownloading {} / {}... Skipped.", index, max_index), + + Event::DownloadFilesStarted => { + println!("Downloading files..."); + std::io::stdout().flush().ok(); + } + Event::DownloadFilesDone => + println!("Done."), + Event::DownloadThumbStarted => { + println!("Downloading thumbnails..."); + std::io::stdout().flush().ok(); + } + Event::DownloadThumbDone => + println!("Done."), + + Event::ExportStarted => { + print!("Exporting..."); + std::io::stdout().flush().ok(); + } + Event::ExportDone => + println!(" Done."), + Event::ExportFailed { error } => + eprintln!("\nExport failed: {}", error), + } +} diff --git a/src/bin/tui/main.rs b/src/bin/tui/main.rs @@ -305,14 +305,14 @@ fn handle_mouse_click(app: &mut App, col: u16, row: u16) { // ── Main loop ───────────────────────────────────────────────────────────────── -fn run_app(terminal: &mut DefaultTerminal, app: &mut App) -> anyhow::Result<()> { +fn run_app(terminal: &mut DefaultTerminal, app: &mut App) -> Result<(), Box<dyn std::error::Error>> { execute!(stdout(), EnableMouseCapture)?; let result = run_loop(terminal, app); execute!(stdout(), DisableMouseCapture)?; result } -fn run_loop(terminal: &mut DefaultTerminal, app: &mut App) -> anyhow::Result<()> { +fn run_loop(terminal: &mut DefaultTerminal, app: &mut App) -> Result<(), Box<dyn std::error::Error>> { loop { app.poll(); terminal.draw(|f| draw(f, app))?; @@ -360,9 +360,12 @@ fn run_loop(terminal: &mut DefaultTerminal, app: &mut App) -> anyhow::Result<()> } } -fn main() -> anyhow::Result<()> { +fn main() { let mut terminal = ratatui::init(); let result = run_app(&mut terminal, &mut App::new()); ratatui::restore(); - result + if let Err(e) = result { + eprintln!("ERROR: {e}"); + std::process::exit(1); + } } diff --git a/src/lib/download.rs b/src/lib/download.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Result}; +use crate::error::{Error, Result}; /// Downloads a URL, retrying up to `tries` times. /// @@ -14,11 +14,11 @@ pub fn download(url: &str, tries: u32) -> Result<ureq::http::Response<ureq::Body match CLIENT.get(url).call() { Ok(response) => return Ok(response), Err(ureq::Error::StatusCode(code)) if code >= 400 && code < 500 => { - return Err(anyhow!("client error: {}", code)); + return Err(Error::HttpClientError(code)); } Err(ureq::Error::StatusCode(_)) => continue, Err(e) => return Err(e.into()), } } - Err(anyhow!("failed to download {} after {} tries", url, tries)) + Err(Error::DownloadFailed { url: url.to_string(), tries }) } diff --git a/src/lib/error.rs b/src/lib/error.rs @@ -0,0 +1,63 @@ +use std::fmt; + +#[derive(Debug)] +pub enum Error { + Io(std::io::Error), + Ureq(ureq::Error), + ParseInt(std::num::ParseIntError), + HttpClientError(u16), + DownloadFailed { url: String, tries: u32 }, + MissingElement(&'static str), + EmptyFile(String), + NoPosts, + UnknownExporter(String), + ChannelSend, +} + +pub type Result<T> = std::result::Result<T, Error>; + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Io(e) => write!(f, "I/O error: {e}"), + Error::Ureq(e) => write!(f, "network error: {e}"), + Error::ParseInt(e) => write!(f, "parse error: {e}"), + Error::HttpClientError(code) => write!(f, "client error: {code}"), + Error::DownloadFailed { url, tries } => { + write!(f, "failed to download {url} after {tries} tries") + } + Error::MissingElement(name) => write!(f, "missing element: {name}"), + Error::EmptyFile(url) => write!(f, "empty file: {url}"), + Error::NoPosts => write!(f, "no posts to export"), + Error::UnknownExporter(name) => write!(f, "unknown exporter: {name}"), + Error::ChannelSend => write!(f, "failed to send event"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Io(e) => Some(e), + Error::Ureq(e) => Some(e), + Error::ParseInt(e) => Some(e), + _ => None, + } + } +} + +impl From<std::io::Error> for Error { + fn from(e: std::io::Error) -> Self { Error::Io(e) } +} + +impl From<ureq::Error> for Error { + fn from(e: ureq::Error) -> Self { Error::Ureq(e) } +} + +impl From<std::num::ParseIntError> for Error { + fn from(e: std::num::ParseIntError) -> Self { Error::ParseInt(e) } +} + +impl<T> From<std::sync::mpsc::SendError<T>> for Error { + fn from(_: std::sync::mpsc::SendError<T>) -> Self { Error::ChannelSend } +} diff --git a/src/lib/export/html/mod.rs b/src/lib/export/html/mod.rs @@ -1,27 +1,28 @@ -use crate::{config::Config, post::Post}; -use anyhow::Result; -use super::Exporter; - -mod render; - -const TEMPLATE: &str = include_str!("template.html"); - -#[derive(Clone)] -pub struct HtmlExporter; - -impl Exporter for HtmlExporter { - fn export(&self, posts: &[Post], config: &Config) -> Result<()> { - anyhow::ensure!(!posts.is_empty(), "No posts to export"); - - std::fs::create_dir_all(&config.dir)?; - let posts_html = posts - .iter() - .map(|p| render::render_post(p, config.files, config.thumb)) - .collect::<Vec<String>>() - .join("\n"); - let index_html = TEMPLATE.replace("{{posts}}", &posts_html); - std::fs::write(config.dir.join("index.html"), index_html)?; - - Ok(()) - } -} +use crate::{config::Config, error::{Error, Result}, post::Post}; +use super::Exporter; + +mod render; + +const TEMPLATE: &str = include_str!("template.html"); + +#[derive(Clone)] +pub struct HtmlExporter; + +impl Exporter for HtmlExporter { + fn export(&self, posts: &[Post], config: &Config) -> Result<()> { + if posts.is_empty() { + return Err(Error::NoPosts); + } + + std::fs::create_dir_all(&config.dir)?; + let posts_html = posts + .iter() + .map(|p| render::render_post(p, config.files, config.thumb)) + .collect::<Vec<String>>() + .join("\n"); + let index_html = TEMPLATE.replace("{{posts}}", &posts_html); + std::fs::write(config.dir.join("index.html"), index_html)?; + + Ok(()) + } +} diff --git a/src/lib/export/mod.rs b/src/lib/export/mod.rs @@ -1,35 +1,34 @@ -pub mod html; - -use super::{config::Config, post::Post}; - -use anyhow::Result; - -use std::str::FromStr; - -#[derive(Clone)] -pub enum ExporterKind { - Html(html::HtmlExporter), -} - -pub trait Exporter { - fn export(&self, posts: &[Post], config: &Config) -> Result<()>; -} - -impl Exporter for ExporterKind { - fn export(&self, posts: &[Post], config: &Config) -> Result<()> { - match self { - ExporterKind::Html(html) => html.export(posts, config), - } - } -} - -impl FromStr for ExporterKind { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result<ExporterKind> { - match s.to_lowercase().as_str() { - "html" => Ok(ExporterKind::Html(html::HtmlExporter {})), - _ => anyhow::bail!("unknown exporter: {}", s), - } - } -} +pub mod html; + +use super::{config::Config, post::Post}; +use crate::error::{Error, Result}; + +use std::str::FromStr; + +#[derive(Clone)] +pub enum ExporterKind { + Html(html::HtmlExporter), +} + +pub trait Exporter { + fn export(&self, posts: &[Post], config: &Config) -> Result<()>; +} + +impl Exporter for ExporterKind { + fn export(&self, posts: &[Post], config: &Config) -> Result<()> { + match self { + ExporterKind::Html(html) => html.export(posts, config), + } + } +} + +impl FromStr for ExporterKind { + type Err = Error; + + fn from_str(s: &str) -> Result<ExporterKind> { + match s.to_lowercase().as_str() { + "html" => Ok(ExporterKind::Html(html::HtmlExporter {})), + _ => Err(Error::UnknownExporter(s.to_string())), + } + } +} diff --git a/src/lib/lib.rs b/src/lib/lib.rs @@ -1,99 +1,97 @@ -pub mod config; -pub mod event; -pub mod export; - -mod download; -mod post; - -use crate::post::{Post, File}; -use crate::export::Exporter; - -use anyhow::{Result, Context}; - -use std::sync::mpsc::Sender; - -pub const BASE_URL: &str = "https://arhivach.vc"; - -pub fn run(config: &config::Config, tx: Sender<event::Event>) -> Result<()> { - tx.send(event::Event::GetStarted)?; - let html = download::download(&config.url, config.download_retries)? - .body_mut() - .read_to_string() - .context("failed to read response body")?; - let posts = Post::parse_posts(&html) - .inspect_err(|e| { let _ = tx.send(event::Event::GetFailed { error: format!("{:#}", e) }); }) - .context("failed to parse posts")?; - tx.send(event::Event::GetDone)?; - - tx.send(event::Event::DownloadAllStarted)?; - run_download(&posts, &config, tx.clone()) - .inspect_err(|e| { let _ = tx.send(event::Event::DownloadAllFailed { error: format!("{:#}", e) }); }) - .context("failed to download files")?; - tx.send(event::Event::DownloadAllDone)?; - - tx.send(event::Event::ExportStarted)?; - config.exporter.export(&posts, config) - .inspect_err(|e| { let _ = tx.send(event::Event::ExportFailed { error: format!("{:#}", e) }); }) - .context("failed to export")?; - tx.send(event::Event::ExportDone)?; - - Ok(()) -} - -/// Download files and thumbnails. Send DownloadStarted, DownloadDone and DownloadFailed events -fn run_download(posts: &[Post], config: &config::Config, tx: Sender<event::Event>) -> Result<()> { - std::fs::create_dir_all(&config.dir)?; - - let download_item = |url: &str, filepath: &std::path::PathBuf| -> Result<()> { - let result = download::download(url, config.download_retries)?; - let mut bytes = Vec::new(); - std::io::Read::read_to_end(&mut result.into_body().as_reader(), &mut bytes)?; - anyhow::ensure!(!bytes.is_empty(), "empty file: {}", url); - std::fs::write(filepath, bytes)?; - Ok(()) - }; - - let download_section = | - subdir: &str, - get_url: fn(&File) -> (&str, &str), - | -> Result<()> { - let dir = config.dir.join(subdir); - std::fs::create_dir_all(&dir)?; - - let mut index: usize = 1; - let max_index: usize = posts.iter().map(|p| p.files.len()).sum(); - for f in posts.iter().flat_map(|p| &p.files) { - tx.send(event::Event::DownloadStarted { index, max_index })?; - let (url, fallback) = get_url(f); - let filename = url.rsplit("/").next().unwrap_or(fallback).trim(); - let filepath = dir.join(filename); - if config.resume && filepath.exists() { - tx.send(event::Event::DownloadSkipped { index, max_index })?; - index += 1; - continue - } - match download_item(url, &filepath) { - Ok(()) => tx.send(event::Event::DownloadDone{ index, max_index })?, - Err(e) => tx.send(event::Event::DownloadFailed { - url: url.to_string(), - error: format!("{:#}", e) - })? - }; - index += 1; - } - Ok(()) - }; - - if config.files { - tx.send(event::Event::DownloadFilesStarted)?; - download_section("files", |f| (&f.url, &f.name_timestamp))?; - tx.send(event::Event::DownloadFilesDone)?; - } - if config.thumb { - tx.send(event::Event::DownloadThumbStarted)?; - download_section("thumb", |f| (&f.url_thumb, &f.name_timestamp))?; - tx.send(event::Event::DownloadThumbDone)?; - } - - Ok(()) -} +pub mod config; +pub mod error; +pub mod event; +pub mod export; + +mod download; +mod post; + +use crate::error::{Error, Result}; +use crate::post::{Post, File}; +use crate::export::Exporter; + +use std::sync::mpsc::Sender; + +pub const BASE_URL: &str = "https://arhivach.vc"; + +pub fn run(config: &config::Config, tx: Sender<event::Event>) -> Result<()> { + tx.send(event::Event::GetStarted)?; + let html = download::download(&config.url, config.download_retries)? + .body_mut() + .read_to_string()?; + let posts = Post::parse_posts(&html) + .inspect_err(|e| { let _ = tx.send(event::Event::GetFailed { error: e.to_string() }); })?; + tx.send(event::Event::GetDone)?; + + tx.send(event::Event::DownloadAllStarted)?; + run_download(&posts, &config, tx.clone()) + .inspect_err(|e| { let _ = tx.send(event::Event::DownloadAllFailed { error: e.to_string() }); })?; + tx.send(event::Event::DownloadAllDone)?; + + tx.send(event::Event::ExportStarted)?; + config.exporter.export(&posts, config) + .inspect_err(|e| { let _ = tx.send(event::Event::ExportFailed { error: e.to_string() }); })?; + tx.send(event::Event::ExportDone)?; + + Ok(()) +} + +/// Download files and thumbnails. Send DownloadStarted, DownloadDone and DownloadFailed events +fn run_download(posts: &[Post], config: &config::Config, tx: Sender<event::Event>) -> Result<()> { + std::fs::create_dir_all(&config.dir)?; + + let download_item = |url: &str, filepath: &std::path::PathBuf| -> Result<()> { + let result = download::download(url, config.download_retries)?; + let mut bytes = Vec::new(); + std::io::Read::read_to_end(&mut result.into_body().as_reader(), &mut bytes)?; + if bytes.is_empty() { + return Err(Error::EmptyFile(url.to_string())); + } + std::fs::write(filepath, bytes)?; + Ok(()) + }; + + let download_section = | + subdir: &str, + get_url: fn(&File) -> (&str, &str), + | -> Result<()> { + let dir = config.dir.join(subdir); + std::fs::create_dir_all(&dir)?; + + let mut index: usize = 1; + let max_index: usize = posts.iter().map(|p| p.files.len()).sum(); + for f in posts.iter().flat_map(|p| &p.files) { + tx.send(event::Event::DownloadStarted { index, max_index })?; + let (url, fallback) = get_url(f); + let filename = url.rsplit("/").next().unwrap_or(fallback).trim(); + let filepath = dir.join(filename); + if config.resume && filepath.exists() { + tx.send(event::Event::DownloadSkipped { index, max_index })?; + index += 1; + continue + } + match download_item(url, &filepath) { + Ok(()) => tx.send(event::Event::DownloadDone{ index, max_index })?, + Err(e) => tx.send(event::Event::DownloadFailed { + url: url.to_string(), + error: e.to_string() + })? + }; + index += 1; + } + Ok(()) + }; + + if config.files { + tx.send(event::Event::DownloadFilesStarted)?; + download_section("files", |f| (&f.url, &f.name_timestamp))?; + tx.send(event::Event::DownloadFilesDone)?; + } + if config.thumb { + tx.send(event::Event::DownloadThumbStarted)?; + download_section("thumb", |f| (&f.url_thumb, &f.name_timestamp))?; + tx.send(event::Event::DownloadThumbDone)?; + } + + Ok(()) +} diff --git a/src/lib/post.rs b/src/lib/post.rs @@ -1,6 +1,5 @@ use super::BASE_URL; - -use anyhow::{Context, Result}; +use crate::error::{Error, Result}; #[derive(Debug, Clone)] pub struct File { @@ -58,7 +57,7 @@ impl Post { html: &str, ) -> Result<Vec<Post>> { let mut posts = Vec::new(); - + let document = scraper::Html::parse_document(html); let selector = scraper::Selector::parse(r#"div.post"#).unwrap(); for node in document.select(&selector) { @@ -70,7 +69,7 @@ impl Post { } /// Parse div class="post" - /// + /// /// Example element: /// ```html /// <div class="post" id="post329274763" postid="329274763"> @@ -89,13 +88,13 @@ impl Post { let post_head = node .select(&SEL_POST_HEAD) .next() - .context("missing post_head")?; + .ok_or(Error::MissingElement("post_head"))?; let head = Post::parse_post_head(post_head)?; let post_comment = node .select(&SEL_POST_IMAGE_BLOCK) .next() - .context("missing post_comment")?; + .ok_or(Error::MissingElement("post_comment"))?; let (files, text) = Post::parse_post_comment(post_comment)?; Ok(Post { @@ -114,7 +113,7 @@ impl Post { /// /// Returns (subject, name, mailto, time, num, id) /// Returns error if no time, num or id is found or if id is not a number - /// + /// /// Example element: /// ```html /// <div class="post_head"> @@ -152,7 +151,7 @@ impl Post { .next() .and_then(|el| el.value().attr("href")) .and_then(|href| href.strip_prefix('#')) - .context("missing post id")? + .ok_or(Error::MissingElement("post id"))? .parse()?; let subject = post_head @@ -175,14 +174,14 @@ impl Post { let time = post_head .select(&SEL_SPAN_POST_TIME) .next() - .context("missing post_time")? + .ok_or(Error::MissingElement("post_time"))? .text() .collect::<String>(); let num = post_head .select(&SEL_SPAN_POST_NUM) .next() - .context("missing post_num")? + .ok_or(Error::MissingElement("post_num"))? .text() .collect::<String>(); @@ -190,9 +189,9 @@ impl Post { } /// Parses the sapn post_comment element from a post element - /// + /// /// Returns (files, text) - /// + /// /// Example element: /// <span class="post_comment"> /// <div class="post_image_block" ...>...</div> (see parse_post_image_block function) (can appear 0 to multiple times) @@ -216,13 +215,13 @@ impl Post { let text = Post::parse_post_comment_body(node .select(&SEL_POST_COMMENT_BODY) .next() - .context("missing post_comment_body")?); + .ok_or(Error::MissingElement("post_comment_body"))?); Ok((files, text)) } /// Parses "post_image_block" element /// Returns File - /// + /// /// Example element: /// ```html /// <div class="post_image_block" id="pib_77_2" pib="77_2" title="537.4 Кб, 946 x 946 @@ -299,7 +298,7 @@ impl Post { /// - References are plaintext (e.g. >>329274789) /// - `<br>` is replaced with \n /// - `<span class="unkfunc">` (greentext) is replaced with >text - /// + /// /// If the text contains a reference (e.g. >>329274789) it looks like this in the element: /// ```html /// <div class="post_comment_body"> @@ -310,7 +309,7 @@ impl Post { /// text1 /// </div> /// ``` - /// + /// /// This example returns: /// ```text /// >>329274893 @@ -342,20 +341,20 @@ impl std::fmt::Display for Post { // Header line let name = self.name.as_deref().unwrap_or("Аноним"); let mailto = self.mailto.as_deref().unwrap_or(""); - + if !mailto.is_empty() { write!(f, "{} ({})", name, mailto)?; } else { write!(f, "{}", name)?; } - + write!(f, " {} {} ID:{}", self.time, self.num, self.id)?; - + // Subject if let Some(ref subject) = self.subject { write!(f, "\n{}", subject)?; } - + // Files if !self.files.is_empty() { write!(f, "\n[Files: {}]", self.files.len())?; @@ -363,12 +362,12 @@ impl std::fmt::Display for Post { write!(f, "\n - {}", file)?; } } - + // Post text if !self.text.is_empty() { write!(f, "\n{}", self.text)?; } - + Ok(()) } }