Using rvest (or another R package) to detect when the start of an HTML paragraph is a different format (e.g. emboldened)

Viewed 312

I am using an R package, edgarWebR, to parse SEC filings, such as https://www.sec.gov/Archives/edgar/data/1060224/000090480206000008/sa10k306.htm. It returns a dataframe, of which one column - called "raw" - is HTML. It breaks up the HTML page into paragraphs, one row per paragraph:

other columns raw text
First row <p id="PARA339" style="TEXT-ALIGN: left; MARGIN: 0pt; LINE-HEIGHT: 1.25"><font style="FONT-SIZE: 10pt; FONT-FAMILY: Times New Roman, Times, serif"><i>We had a net loss of $1.</i><i><b>55</b></i><i> million for the year ended December 31, 201</i><i>6</i><i> and have an accumulated deficit of $</i><i>61.5</i><i> million as of December 31, 201</i><i>6</i><i>. To achieve sustainable profitability, we must generate increased revenue.</i></font></p> We had a net loss of $1.55 million for the year ended December 31, 2016 and have an accumulated deficit of $61.5 million as of December 31, 2016. To achieve sustainable profitability, we must generate increased revenue.
Second row <div style="line-height:174%;text-align:left;font-size:9pt;"><font style="font-family:inherit;font-size:9pt;font-style:italic;font-weight:bold;">We have a history of losses, and we cannot assure you that we will achieve profitability.</font></div> We have a history of losses, and we cannot assure you that we will achieve profitability.

You can easily replicate an example dataframe by running

library(edgarWebR)
df <- parse_filing("https://www.sec.gov/Archives/edgar/data/1060224/000090480206000008/sa10k306.htm",include.raw=TRUE)

My goal is to parse the HTML to determine which of the paragraphs represent headings, by calculating which formats (e.g. bold italics) are appearing less frequently throughout the document.

One problem is that some paragraphs will be descriptive (i.e. not a heading level), but contain one or a few words emboldened in the middle of the paragraph, for emphasis. To this end, I have a function which, for each paragraph, will take a list of css selectors (e.g. those for bold or italic) and tell me what proportion of the characters are of that style, thanks to @QHarr:

## the function
paragraph_style_proportion <- Vectorize(function(html, css_selector) {
  html_content <- tryCatch(read_html(html), error=function(err) "NOT HTML")
  if (html_content == "NOT HTML") {
    style_proportion <- -1
  }
  else {
    whole_paragraph_length <- nchar(str_squish(html_content %>% html_text()))
    style_text_length <- sum(nchar(str_squish(html_content %>% html_nodes(css_selector) %>% html_text())))
    style_proportion <- round(style_text_length/whole_paragraph_length, 2)
  }
  return(style_proportion)
})

## to apply the function to the html-containing column "raw" of a dataframe "df"
df <- df %>% 
  mutate(bold_proportion = paragraph_style_proportion(raw, 'b, strong, [style*="font-weight:bold"]'))

From there, I can easily apply a rule such as to record a paragraph as, say, bold, if at least 40% of the characters are bold. From there, I proceed to calculate frequencies of each combination of styles (e.g. italic capitalised text) and assign numerical heading levels.

However, the problem I now face is cases such as the below - in which the first few words of a paragraph are of a different style:

enter image description here

In the example above, my algorithm would class these as "normal" paragraphs - i.e. not bold or italic or anything - as it is well under 40% italic. But clearly it represents a heading of some kind - because the different style is at the beginning of the paragraph.

Some examples with this problem can be found at https://www.sec.gov/Archives/edgar/data/1376067/000137606711000002/vll51231201010k.htm and https://www.sec.gov/Archives/edgar/data/1060224/000090480206000008/sa10k306.htm

How would I start to solve this? The previous function cannot tackle this; I would need some way of parsing each text-containing HTML part of the paragraph one-by-one, rather than just pulling out all bold text and dividing by the total length of the text of the paragraph. Especially difficult is that the length of the 'first part of the paragraph' will vary (and usually won't even exist), as will the styles of this first part, and the style of the rest of the paragraph.

Ideally, in cases where the first part of the paragraph is a different format, I'd want to split the paragraph into two rows - making the different-format part its own row, which can then have its heading level classified in the way I've described. If not, I'd either want to flag the whole paragraph as the style of the first few words, or maybe create a column that flags paragraphs where the first few words are of a different style.

Thank you

2 Answers

I've read your related questions here on SO. Interesting work! I believe the solution is somewhere along the lines of:

1: Extract the relevant words from the HTML by doing what you're already doing

relevant_words <- rvest::html_text(your_raw_html_here)
# split word by word
relevant_words <- stringr::str_split(relevant_words, pattern = " ")

2: For every word, you'll have to find it in the raw HTML and check if it's inside a tag (say, a bold tag). You'll need to write regex for those. You'll also need to keep track of repeated ocurrences of a word and differentiate between them.

3: If you manage to do steps 1 and 2, you'll have a list that tells you the tags associated with every word found in step1. I believe that would be very much like How to apply rvest to a dataframe column of HTML to make a column of extracted emboldened words.

4: Now you need to check if the first N elements in your list have a major common style tag or not and compare that with the major style tag found in the rest of the paragraph.

I haven't done enough testing with this yet but the principle is to catch the cases you mentioned by looking at a child tag within the $raw for a given row.

Currently it is set-up to see if the the child's tag/type is in a given vector c("b", "strong", "i"); additionally, there is an additional test on the style attribute html_attr("style") %>% str_detect("italic|bold"). Not N/A will be output if attribute match not found.

The result of these two tests is as to whether this row should be considered to have a header (and need later splitting); boolean logic is used and wrapped in if_else to output false where child wasn't found. if_else(is.na(node), F, as.logical(style_flag | tag_flag). The result is written to a new column called header.

To finesse this a little, a comparison is then done on this new column and the bold_proportion_column to determine if ultimately this should be considered to contain a header row to be processed. The determination is currently on whether bold_proportion_column >= .5 OR header == T then T else F. Obviously, one would need to account for the italic_proportion_column at some point, or re-factor to include both in the proportion function (would be my choice). This determination is written to an outcome column.

I think there is a lot of work to be done on refining this approach and quality assuring it e.g. 1) I would want to re-work so we only parse the html once and 2) return the split of the child html from the rest to meet your requirement to split header section from rest across different rows; 3) I would also want to work through a pre-determined list of test cases from different pages to determine the validity of this approach; 4) I would re-factor to make functions such as get_child more generic e.g. pass in the css selector pattern for more versatility; elsewhere, pass in the proportion to be >= in outcome determination.

But it may be useful as a starter for 10....... I will likely re-visit this and refine if I can.


library(rvest)
library(tidyverse)
library(edgarWebR)
library(stringr)

paragraph_style_proportion <- Vectorize(function(html, css_selector) {
  missing_html_flag <- tryCatch(read_html(html), error = function(err) "NOT HTML")
  if (missing_html_flag == "NOT HTML") {
    style_proportion <- -1
  }
  else {
    page <- read_html(html)
    whole_paragraph_length <- nchar(str_squish(page %>% html_text()))
    style_text_length <- sum(nchar(str_squish(page %>% html_nodes(css_selector) %>% html_text())))
    style_proportion <- round(style_text_length / whole_paragraph_length, 2)
  }
  return(style_proportion)
})

get_child <- function(html) {
  child <- html %>%
    read_html() %>%
    html_node("*:nth-child(2)")
  return(child)
}

is_header <- function(node) {
  style_flag <- node %>%
    html_attr("style") %>%
    str_detect("italic|bold") %>% 
    if_else(is.na(.), F, .)
  tag_type <- node %>% html_name() %>% tolower()
  tag_flag <- tag_type %in% c("b", "strong", "i") # test if of certain type then classify
  flag <- if_else(is.na(node), F, as.logical(style_flag | tag_flag))
  return(flag)
}

df <- parse_filing("https://www.sec.gov/Archives/edgar/data/1070154/000114036109028731/form10k.htm", include.raw = TRUE)

df <- df %>%
  mutate(bold_proportion = paragraph_style_proportion(raw, "b, strong, [style*=bold]"))

df$header <- lapply(df$raw, function(x){is_header(get_child(x))})
df$outcome <-  with(df, ifelse(bold_proportion >=.5 | header == T, T, F))
Related