Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions src/core/attributes_handler.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
//! The [`AttributesHandler`] map returned by
//! [`Selector::attrib`](crate::parser::Selector::attrib): an element's
//! attributes in document order.

use crate::core::TextHandler;
use indexmap::IndexMap;

/// A read-only mapping of HTML element attributes.
/// All values are wrapped in TextHandler for regex/json capabilities.
///
/// Attributes keep their document order (backed by an `IndexMap`), and all
/// values are wrapped in [`TextHandler`] for regex/JSON helpers. Indexing
/// by key (`attrs["href"]`) panics on a missing key — use
/// [`AttributesHandler::get`] for a fallible lookup.
#[derive(Debug, Clone)]
pub struct AttributesHandler {
inner: IndexMap<String, TextHandler>,
}

impl AttributesHandler {
/// Build a handler from `(name, value)` pairs, preserving their order.
pub fn new(map: impl IntoIterator<Item = (String, String)>) -> Self {
let inner: IndexMap<String, TextHandler> = map
.into_iter()
Expand All @@ -17,30 +26,43 @@ impl AttributesHandler {
Self { inner }
}

/// Look up an attribute value by name (case-sensitive).
#[must_use]
pub fn get(&self, key: &str) -> Option<&TextHandler> {
self.inner.get(key)
}
/// Whether an attribute with this name exists.
#[must_use]
pub fn contains_key(&self, key: &str) -> bool {
self.inner.contains_key(key)
}
/// Number of attributes.
#[must_use]
pub fn len(&self) -> usize {
self.inner.len()
}
/// Whether the element has no attributes.
#[must_use]
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
/// Iterate over attribute names, in document order.
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.inner.keys().map(|k| k.as_str())
}
/// Iterate over attribute values, in document order.
pub fn values(&self) -> impl Iterator<Item = &TextHandler> {
self.inner.values()
}

/// Iterate over `(name, value)` pairs, in document order.
pub fn iter(&self) -> impl Iterator<Item = (&str, &TextHandler)> {
self.inner.iter().map(|(k, v)| (k.as_str(), v))
}

/// Search for attributes whose values match a keyword (exact or partial).
/// Iterate over the attributes whose value equals `keyword` — or
/// merely contains it when `partial` is set — yielding the matching
/// `(name, value)` pairs.
pub fn search_values<'a>(
&'a self,
keyword: &'a str,
Expand All @@ -60,7 +82,9 @@ impl AttributesHandler {
})
}

/// Serialize attributes to JSON string.
/// Serialize the attributes to a JSON object string, keys in document
/// order (empty string on the unlikely event of a serialization error).
#[must_use]
pub fn json_string(&self) -> String {
let map: IndexMap<&str, &str> = self
.inner
Expand Down
5 changes: 5 additions & 0 deletions src/core/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
//! Shared value types used across the parser and spiders: string wrappers
//! with scraping helpers ([`TextHandler`], [`TextHandlers`]), a read-only
//! attribute map ([`AttributesHandler`]), and the SQLite storage backing
//! adaptive element relocation ([`storage::SqliteStorage`]).

pub mod attributes_handler;
pub mod storage;
pub mod text_handler;
Expand Down
25 changes: 22 additions & 3 deletions src/core/storage.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
//! SQLite persistence for adaptive element relocation: element snapshots
//! saved by [`Selector::save`](crate::parser::Selector::save) live here,
//! keyed by page URL plus a hashed identifier.

use rusqlite::{params, Connection, OptionalExtension};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard};

/// SQLite-backed storage for adaptive element relocation.
///
/// One `SqliteStorage` is scoped to a single page URL (lowercased at
/// construction): rows are keyed by `(url, identifier)`, so the same
/// identifier can be reused across different pages without collisions. The
/// connection is guarded by a mutex, making the storage safe to share
/// across threads.
pub struct SqliteStorage {
conn: Mutex<Connection>,
url: String,
db_path: String,
}

impl SqliteStorage {
/// Create storage backed by SQLite file. URL is normalized to lowercase.
/// Open (creating if needed) the SQLite database at `db_path`, scoped
/// to `url`. The URL is normalized to lowercase; the database uses WAL
/// journaling and gets its schema created on first use.
pub fn new(db_path: &str, url: &str) -> Result<Self, StorageError> {
let conn = Self::open(db_path)?;
Ok(Self {
Expand Down Expand Up @@ -56,7 +68,8 @@ impl SqliteStorage {
}
}

/// Save element data. Uses INSERT OR REPLACE for upsert.
/// Save element data under `identifier` for this storage's URL,
/// replacing any previous entry (INSERT OR REPLACE upsert).
pub fn save(
&self,
identifier: &str,
Expand All @@ -72,7 +85,9 @@ impl SqliteStorage {
Ok(())
}

/// Retrieve stored element data.
/// Retrieve the element data stored under `identifier` for this
/// storage's URL. Returns `Ok(None)` when nothing was saved yet; real
/// database errors propagate as `Err`.
pub fn retrieve(
&self,
identifier: &str,
Expand Down Expand Up @@ -103,10 +118,14 @@ impl SqliteStorage {
}
}

/// Errors from [`SqliteStorage`] operations.
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
/// The underlying SQLite operation failed (I/O, locking, schema, …).
#[error("SQLite error: {0}")]
Sqlite(#[from] rusqlite::Error),
/// Element data could not be serialized to, or deserialized from, its
/// stored JSON form.
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
Expand Down
63 changes: 60 additions & 3 deletions src/core/text_handler.rs
Original file line number Diff line number Diff line change
@@ -1,68 +1,109 @@
//! The [`TextHandler`] string wrapper returned by every text-extraction
//! method in the crate, with regex, JSON, and cleaning helpers.

use regex::Regex;
use std::fmt;

/// A string wrapper with scraping-specific methods (regex, JSON, cleaning).
///
/// All transforming methods ([`TextHandler::strip`], [`TextHandler::clean`],
/// …) return a new `TextHandler` and leave the original untouched. Use
/// [`TextHandler::as_str`] / [`TextHandler::into_string`] (or the
/// `Display` / `AsRef<str>` impls) to get at the plain string.
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct TextHandler {
value: String,
}

impl TextHandler {
/// Wrap a string value.
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
}
}

/// The wrapped text as a string slice.
#[must_use]
pub fn as_str(&self) -> &str {
&self.value
}

/// Consume the handler and return the owned `String`.
#[must_use]
pub fn into_string(self) -> String {
self.value
}

/// Whether the wrapped text is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.value.is_empty()
}

/// Length of the wrapped text in *bytes* (like [`str::len`]), not
/// characters.
#[must_use]
pub fn len(&self) -> usize {
self.value.len()
}

/// Return a copy with leading and trailing whitespace removed
/// (Python-style name for [`str::trim`]).
#[must_use]
pub fn strip(&self) -> TextHandler {
TextHandler::new(self.value.trim())
}

/// Return a lowercased copy.
#[must_use]
pub fn to_lowercase(&self) -> TextHandler {
TextHandler::new(self.value.to_lowercase())
}

/// Return an uppercased copy.
#[must_use]
pub fn to_uppercase(&self) -> TextHandler {
TextHandler::new(self.value.to_uppercase())
}

/// Whether the text contains the given substring.
#[must_use]
pub fn contains_str(&self, pattern: &str) -> bool {
self.value.contains(pattern)
}

/// Return a copy with every occurrence of `from` replaced by `to`.
#[must_use]
pub fn replace_str(&self, from: &str, to: &str) -> TextHandler {
TextHandler::new(self.value.replace(from, to))
}

/// Whether the text starts with the given prefix.
#[must_use]
pub fn starts_with_str(&self, prefix: &str) -> bool {
self.value.starts_with(prefix)
}

/// Whether the text ends with the given suffix.
#[must_use]
pub fn ends_with_str(&self, suffix: &str) -> bool {
self.value.ends_with(suffix)
}

/// Split the text on a delimiter, wrapping each piece (empty pieces
/// included, like [`str::split`]).
#[must_use]
pub fn split_str(&self, delimiter: &str) -> Vec<TextHandler> {
self.value.split(delimiter).map(TextHandler::new).collect()
}

/// Clean whitespace: remove tabs, CR, LF, collapse spaces. Optionally decode HTML entities.
/// Normalize whitespace: tabs and line feeds become spaces, carriage
/// returns are removed, runs of spaces collapse to one, and the result
/// is trimmed. With `remove_entities`, a fixed set of common HTML
/// entities (`&lt;` `&gt;` `&quot;` `&#39;` `&apos;` `&nbsp;` `&amp;`)
/// is decoded afterwards.
#[must_use]
pub fn clean(&self, remove_entities: bool) -> TextHandler {
let mut s = self.value.replace('\t', " ");
s = s.replace('\r', "");
Expand All @@ -77,12 +118,20 @@ impl TextHandler {
TextHandler::new(s)
}

/// Parse text as JSON.
/// Parse the text as JSON.
pub fn json(&self) -> Result<serde_json::Value, serde_json::Error> {
serde_json::from_str(&self.value)
}

/// Apply regex. Returns captured group 1 if present, else group 0.
/// Apply a regex and return all matches.
///
/// For each match, capture group 1 is returned when the pattern
/// defines capture groups, otherwise the whole match. Flags:
/// `replace_entities` decodes common HTML entities *before* matching,
/// `clean_match` trims each returned match, and `case_sensitive =
/// false` prepends `(?i)` to the pattern. An invalid pattern yields an
/// empty `Vec`.
#[must_use]
pub fn re(
&self,
pattern: &str,
Expand Down Expand Up @@ -119,6 +168,9 @@ impl TextHandler {
results
}

/// Apply a regex and return only the first match (see
/// [`TextHandler::re`] for the flag semantics).
#[must_use]
pub fn re_first(
&self,
pattern: &str,
Expand All @@ -131,10 +183,15 @@ impl TextHandler {
.next()
}

/// Parsel-parity accessor: returns `self` (a single value's `.get()`
/// is itself).
#[must_use]
pub fn get(&self) -> &TextHandler {
self
}

/// Parsel-parity accessor: returns the value as a one-element `Vec`.
#[must_use]
pub fn getall(&self) -> Vec<TextHandler> {
vec![self.clone()]
}
Expand Down
23 changes: 22 additions & 1 deletion src/core/text_handlers.rs
Original file line number Diff line number Diff line change
@@ -1,32 +1,49 @@
//! The [`TextHandlers`] collection: a list of [`TextHandler`] values with
//! batch regex operations, mirroring Parsel's `SelectorList` text API.

use crate::core::TextHandler;

/// A list of TextHandler values with batch operations.
/// A list of [`TextHandler`] values with batch operations.
///
/// Supports indexing (`values[0]`) and iteration by value or reference.
#[derive(Debug, Clone, Default)]
pub struct TextHandlers {
items: Vec<TextHandler>,
}

impl TextHandlers {
/// Create a new `TextHandlers` from a `Vec` of [`TextHandler`].
pub fn new(items: Vec<TextHandler>) -> Self {
Self { items }
}

/// Number of values in the collection.
#[must_use]
pub fn len(&self) -> usize {
self.items.len()
}

/// Whether the collection is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}

/// The first value (cloned), or `default` when the collection is empty.
#[must_use]
pub fn get(&self, default: Option<TextHandler>) -> Option<TextHandler> {
self.items.first().cloned().or(default)
}

/// All values as a slice.
#[must_use]
pub fn getall(&self) -> &[TextHandler] {
&self.items
}

/// Apply a regex to every value and flatten all matches into a new
/// collection (see [`TextHandler::re`] for the flag semantics).
#[must_use]
pub fn re(
&self,
pattern: &str,
Expand All @@ -42,6 +59,10 @@ impl TextHandlers {
TextHandlers::new(items)
}

/// Apply a regex across the values in order and return the first match
/// found in any of them (see [`TextHandler::re`] for the flag
/// semantics).
#[must_use]
pub fn re_first(
&self,
pattern: &str,
Expand Down
Loading