1
\$\begingroup\$

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.

\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Browse other questions tagged or ask your own question.