I wrote a web scraper library to parse articles from HTML documents where they are encoded in a JSON object in JavaScript element.
I wrote the example HTML file and main.rs
so that you can test the library here for review. The subject of the review should only be lib.rs
.
./Cargo.toml
[package]
name = "webscraper"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
build_html = "2.4.0"
once_cell = "1.18.0"
rslint_parser = "0.3.1"
serde = "1.0.164"
serde_derive = "1.0.164"
serde_json = "1.0.96"
scraper = "0.16.0"
./src/lib.js
use build_html::{Container, ContainerType, HtmlContainer, HtmlPage};
use once_cell::sync::Lazy;
use rslint_parser::{parse_text, SyntaxKind::SEMICOLON};
use scraper::{ElementRef, Html, Selector};
use serde_derive::Deserialize;
use std::fmt::{Display, Formatter};
use std::str::FromStr;
const LEADING_JS_TOKENS: [&str; 2] = ["article", "="];
static SCRIPT_SELECTOR: Lazy<Selector> =
Lazy::new(|| Selector::parse("script#article").expect("could not build script selector"));
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct Article {
headline: String,
#[serde(rename = "sub_headline")]
subtitle: String,
elements: Vec<Element>,
}
impl build_html::Html for Article {
fn to_html_string(&self) -> String {
let mut div = Container::new(ContainerType::Div)
.with_attributes([("class", "w3-container")])
.with_header(1, &self.headline)
.with_header(2, &self.subtitle);
for element in &self.elements {
element.add_to_html(&mut div);
}
HtmlPage::new()
.with_title(&self.headline)
.with_head_link("w3.css", "stylesheet")
.with_meta([
("name", "viewport"),
("content", "width=device-width, initial-scale=1.0"),
])
.with_container(div)
.to_html_string()
}
}
impl FromStr for Article {
type Err = serde_json::Error;
fn from_str(json: &str) -> Result<Self, Self::Err> {
serde_json::from_str::<Self>(json)
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct Element {
#[serde(rename = "type")]
typ: String,
text: Option<String>,
}
impl Element {
pub fn add_to_html(&self, parent: &mut impl HtmlContainer) {
if let Some(ref text) = self.text {
match self.typ.as_str() {
"header" => parent.add_header(3, text),
"text" => parent.add_paragraph_attr(text, [("align", "justify")]),
_ => (),
};
}
}
}
#[derive(Debug)]
pub enum ScrapingError {
CouldNotDeserializeJSON(serde_json::Error),
JSONNotFound,
ScriptElementNotFound,
}
impl Display for ScrapingError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
Self::CouldNotDeserializeJSON(_) => "Could not deserialize JSON string",
Self::JSONNotFound => "Could not find JSON object",
Self::ScriptElementNotFound => "Could not find the script element",
}
)
}
}
impl build_html::Html for ScrapingError {
fn to_html_string(&self) -> String {
let mut container = Container::new(ContainerType::Div)
.with_attributes([("class", "w3-container w3-center")])
.with_header(1, self.to_string());
if let Self::CouldNotDeserializeJSON(error) = self {
container.add_paragraph(format!("{}", error))
}
HtmlPage::new()
.with_title("Error parsing article!")
.with_head_link("w3.css", "stylesheet")
.with_container(container)
.to_html_string()
}
}
pub fn scrape(text: &str) -> Result<Article, ScrapingError> {
Article::from_str(
&extract_json(
find_script(&Html::parse_document(text)).ok_or(ScrapingError::ScriptElementNotFound)?,
)
.ok_or(ScrapingError::JSONNotFound)?,
)
.map_err(ScrapingError::CouldNotDeserializeJSON)
}
fn extract_json(script: ElementRef) -> Option<String> {
for element in parse_text(&script.text().collect::<String>(), 0)
.syntax()
.children()
{
if element
.descendants_with_tokens()
.filter_map(|element| element.into_token())
.zip(LEADING_JS_TOKENS)
.all(|(token, name)| token.text() == name)
{
return Some(
element
.descendants_with_tokens()
.filter_map(|element| element.into_token())
.skip(LEADING_JS_TOKENS.len())
.take_while(|token| token.kind() != SEMICOLON)
.map(|token| token.to_string())
.collect(),
);
}
}
None
}
fn find_script(html: &Html) -> Option<ElementRef> {
html.select(&SCRIPT_SELECTOR).next()
}
./src/main.rs
use std::fs::read_to_string;
use webscraper::scrape;
const HTML_FILE: &str = "./example.html";
fn main() {
let html = read_to_string(HTML_FILE).expect("Could not read HTML file.");
let article = scrape(html.as_str()).expect("Could not scrape article.");
println!("{:?}", article);
}
./example.html
<!DOCTYPE html>
<script id="article">
const STUFF_I_DO_NOT_NEED = "blah";
article={
"headline": "The headline.",
"sub_headline": "This subtitle.",
"elements": [
{
"type": "text",
"text": "Frobnicate the fizzbuzz!"
},
{
"type": "header",
"text": "Paragraph title"
},
{
"type": "text",
"text": "Paragraph content..."
}
]
};
const MORE_STUFF_I_DO_NOT_NEED = 42;
</script>
I'd like to have feedback on the implementation of the library.