library(tidyverse)
library(rvest)
library(jsonlite)
library(knitr)
base_url <- "https://raw.githubusercontent.com/AnissSahraoui/DATA607/main/Week7/"Reading the Same Books from HTML and JSON
Loading two hand-written files into R and asking whether they match
1 Overview
The task is to record three books on one subject, write the same information by hand in two formats, load each into R, and decide whether the resulting data frames are identical.
The interesting part is the last step. The two files hold the same facts, but HTML and JSON represent them differently, so the first comparison fails for reasons that are worth understanding. This report shows those differences, fixes each one, and ends with the two frames genuinely identical.
2 The Three Books
I chose three books on data science and statistics. Two of them have multiple authors, which satisfies the requirement, and one of those has four. For each book I recorded the title and authors plus three further attributes: publication year, publisher, and ISBN.
| Title | Authors | Year |
|---|---|---|
| R for Data Science (2nd ed.) | Hadley Wickham, Mine Çetinkaya-Rundel, Garrett Grolemund | 2023 |
| An Introduction to Statistical Learning (2nd ed.) | Gareth James, Daniela Witten, Trevor Hastie, Robert Tibshirani | 2021 |
| The Art of Statistics | David Spiegelhalter | 2019 |
The ISBNs, publishers and years were checked against the Open Library API, and the author list for R for Data Science against the book’s own site, https://r4ds.hadley.nz, because Open Library’s record for that edition lists only two of its three authors.
Both files were written by hand and are stored in my GitHub repository, so they are publicly reachable and this document runs anywhere: books.html and books.json.
2.1 The HTML file
read_lines(paste0(base_url, "books.html")) |> cat(sep = "\n")<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Three Books on Data Science</title>
</head>
<body>
<h1>Three Books on Data Science</h1>
<table>
<thead>
<tr>
<th>Title</th>
<th>Authors</th>
<th>Year</th>
<th>Publisher</th>
<th>ISBN</th>
</tr>
</thead>
<tbody>
<tr>
<td>R for Data Science</td>
<td>Hadley Wickham, Mine Çetinkaya-Rundel, Garrett Grolemund</td>
<td>2023</td>
<td>O'Reilly Media</td>
<td>978-1-492-09740-2</td>
</tr>
<tr>
<td>An Introduction to Statistical Learning</td>
<td>Gareth James, Daniela Witten, Trevor Hastie, Robert Tibshirani</td>
<td>2021</td>
<td>Springer</td>
<td>978-1-0716-1417-4</td>
</tr>
<tr>
<td>The Art of Statistics</td>
<td>David Spiegelhalter</td>
<td>2019</td>
<td>Pelican Books</td>
<td>978-0-241-39863-0</td>
</tr>
</tbody>
</table>
</body>
</html>2.2 The JSON file
read_lines(paste0(base_url, "books.json")) |> cat(sep = "\n"){
"books": [
{
"title": "R for Data Science",
"authors": ["Hadley Wickham", "Mine Çetinkaya-Rundel", "Garrett Grolemund"],
"year": 2023,
"publisher": "O'Reilly Media",
"isbn": "978-1-492-09740-2"
},
{
"title": "An Introduction to Statistical Learning",
"authors": ["Gareth James", "Daniela Witten", "Trevor Hastie", "Robert Tibshirani"],
"year": 2021,
"publisher": "Springer",
"isbn": "978-1-0716-1417-4"
},
{
"title": "The Art of Statistics",
"authors": ["David Spiegelhalter"],
"year": 2019,
"publisher": "Pelican Books",
"isbn": "978-0-241-39863-0"
}
]
}The two files are deliberately not written the same way, because the formats do not work the same way. HTML has no way to express a list inside a cell, so the authors are one comma-separated string. JSON does, so the authors are a proper array. That single choice is the main reason the two data frames will not match at first.
3 Loading the HTML
rvest reads the page, selects the table element, and converts it to a data frame.
books_html <- read_html(paste0(base_url, "books.html")) |>
html_element("table") |>
html_table()
books_html| Title | Authors | Year | Publisher | ISBN |
|---|---|---|---|---|
| R for Data Science | Hadley Wickham, Mine Çetinkaya-Rundel, Garrett Grolemund | 2023 | O’Reilly Media | 978-1-492-09740-2 |
| An Introduction to Statistical Learning | Gareth James, Daniela Witten, Trevor Hastie, Robert Tibshirani | 2021 | Springer | 978-1-0716-1417-4 |
| The Art of Statistics | David Spiegelhalter | 2019 | Pelican Books | 978-0-241-39863-0 |
tibble(column = names(books_html), type = map_chr(books_html, ~ class(.x)[1])) |> kable()| column | type |
|---|---|
| Title | character |
| Authors | character |
| Year | integer |
| Publisher | character |
| ISBN | character |
Worth noting: html_table() is cleverer than expected. Every value in an HTML file is text, but the function inspects each column and converts Year to an integer on its own. Only the ISBN stays character, correctly, because the hyphens make it non-numeric.
4 Loading the JSON
jsonlite reads the file and converts the books array directly into a data frame.
books_json <- fromJSON(paste0(base_url, "books.json"))$books
books_json| title | authors | year | publisher | isbn |
|---|---|---|---|---|
| R for Data Science | Hadley Wickham , Mine Çetinkaya-Rundel, Garrett Grolemund | 2023 | O’Reilly Media | 978-1-492-09740-2 |
| An Introduction to Statistical Learning | Gareth James , Daniela Witten , Trevor Hastie , Robert Tibshirani | 2021 | Springer | 978-1-0716-1417-4 |
| The Art of Statistics | David Spiegelhalter | 2019 | Pelican Books | 978-0-241-39863-0 |
tibble(column = names(books_json), type = map_chr(books_json, ~ class(.x)[1])) |> kable()| column | type |
|---|---|
| title | character |
| authors | list |
| year | integer |
| publisher | character |
| isbn | character |
Here the authors column is a list, not a character vector: each element is a vector of one, three or four names.
books_json$authors[[1]]
[1] "Hadley Wickham" "Mine Çetinkaya-Rundel" "Garrett Grolemund"
[[2]]
[1] "Gareth James" "Daniela Witten" "Trevor Hastie"
[4] "Robert Tibshirani"
[[3]]
[1] "David Spiegelhalter"
5 Comparing the Two Data Frames
identical(books_html, books_json)[1] FALSE
all.equal(books_html, books_json)[1] "Names: 5 string mismatches"
[2] "Attributes: < Component \"class\": Lengths (3, 1) differ (string compare on first 1) >"
[3] "Attributes: < Component \"class\": 1 string mismatch >"
[4] "Component 2: Modes: character, list"
[5] "Component 2: target is character, current is list"
They are not identical, and all.equal() names exactly why. It returns five messages, but they come down to three underlying differences, and none is a disagreement about the books themselves:
- Column names. The HTML table uses the header text I wrote,
Title,Authors,Year, while JSON uses the lowercase keystitle,authors,year. Same five columns, different spelling. - The authors column. Character in one, a list in the other, which is the structural difference between the formats.
- Object class.
html_table()returns a tibble;fromJSON()returns a base data frame. This accounts for the twoAttributes: Component "class"messages, since a tibble carries the class vectorc("tbl_df", "tbl", "data.frame")against a plain"data.frame".identical()compares attributes, so this alone would make itFALSEeven if the contents matched.
tibble(
difference = c("Column names", "Authors column type", "Object class"),
from_html = c(paste(names(books_html), collapse = ", "),
class(books_html$Authors)[1],
class(books_html)[1]),
from_json = c(paste(names(books_json), collapse = ", "),
class(books_json$authors)[1],
class(books_json)[1])
) |>
kable()| difference | from_html | from_json |
|---|---|---|
| Column names | Title, Authors, Year, Publisher, ISBN | title, authors, year, publisher, isbn |
| Authors column type | character | list |
| Object class | tbl_df | data.frame |
It is also worth checking what did not differ. The values themselves agree, including the non-ASCII character in “Çetinkaya-Rundel”, which survived both the HTML and the JSON round trip:
tibble(
check = c("Rows", "Titles match", "Years match", "ISBNs match", "Non-ASCII name preserved"),
result = c(
as.character(nrow(books_html) == nrow(books_json)),
as.character(identical(books_html$Title, books_json$title)),
as.character(identical(books_html$Year, books_json$year)),
as.character(identical(books_html$ISBN, books_json$isbn)),
as.character(grepl("Çetinkaya-Rundel", books_html$Authors[1]) &
any(grepl("Çetinkaya-Rundel", books_json$authors[[1]])))
)
) |>
kable()| check | result |
|---|---|
| Rows | TRUE |
| Titles match | TRUE |
| Years match | TRUE |
| ISBNs match | TRUE |
| Non-ASCII name preserved | TRUE |
6 Reconciling the Differences
Each difference has a decision attached to it.
# 1. Column names: lowercase the HTML names to match the JSON keys. JSON keys are
# the better target because they are already consistent snake_case.
html_tidy <- books_html |>
rename_with(tolower)
# 2. Authors: collapse the JSON list into one comma-separated string, matching the
# HTML. I flatten JSON rather than splitting HTML because the goal here is to
# compare like with like; see the note below on which form is actually better.
json_tidy <- books_json |>
mutate(authors = map_chr(authors, ~ paste(.x, collapse = ", ")))
# 3. Object class: compare both as plain data frames, so the tibble class does not
# by itself cause a mismatch.
html_tidy <- as.data.frame(html_tidy)
json_tidy <- as.data.frame(json_tidy)
# Column order is not guaranteed to match either, so align it explicitly.
json_tidy <- json_tidy[, names(html_tidy)]
str(html_tidy)'data.frame': 3 obs. of 5 variables:
$ title : chr "R for Data Science" "An Introduction to Statistical Learning" "The Art of Statistics"
$ authors : chr "Hadley Wickham, Mine Çetinkaya-Rundel, Garrett Grolemund" "Gareth James, Daniela Witten, Trevor Hastie, Robert Tibshirani" "David Spiegelhalter"
$ year : int 2023 2021 2019
$ publisher: chr "O'Reilly Media" "Springer" "Pelican Books"
$ isbn : chr "978-1-492-09740-2" "978-1-0716-1417-4" "978-0-241-39863-0"
str(json_tidy)'data.frame': 3 obs. of 5 variables:
$ title : chr "R for Data Science" "An Introduction to Statistical Learning" "The Art of Statistics"
$ authors : chr "Hadley Wickham, Mine Çetinkaya-Rundel, Garrett Grolemund" "Gareth James, Daniela Witten, Trevor Hastie, Robert Tibshirani" "David Spiegelhalter"
$ year : int 2023 2021 2019
$ publisher: chr "O'Reilly Media" "Springer" "Pelican Books"
$ isbn : chr "978-1-492-09740-2" "978-1-0716-1417-4" "978-0-241-39863-0"
all.equal(html_tidy, json_tidy)[1] TRUE
identical(html_tidy, json_tidy)[1] TRUE
After the three fixes the two data frames are identical, which confirms that the files always held the same information. The disagreement was entirely about representation.
html_tidy |> kable()| title | authors | year | publisher | isbn |
|---|---|---|---|---|
| R for Data Science | Hadley Wickham, Mine Çetinkaya-Rundel, Garrett Grolemund | 2023 | O’Reilly Media | 978-1-492-09740-2 |
| An Introduction to Statistical Learning | Gareth James, Daniela Witten, Trevor Hastie, Robert Tibshirani | 2021 | Springer | 978-1-0716-1417-4 |
| The Art of Statistics | David Spiegelhalter | 2019 | Pelican Books | 978-0-241-39863-0 |
7 Conclusions
- The two files are not identical when first loaded, but the data is the same.
all.equal()reported three differences: column names, the type of theauthorscolumn, and the object class. After correcting those, bothall.equal()andidentical()returnTRUE. - The real difference is structural, and JSON handles it better. A book can have any number of authors. JSON stores that as an array, so An Introduction to Statistical Learning keeps four distinct author values. HTML has no cell-level list, so the same information becomes the string
"Gareth James, Daniela Witten, Trevor Hastie, Robert Tibshirani". The JSON version is closer to tidy data, and flattening it to match the HTML actually throws information away. - If I needed to analyse these authors, for example to count books per author, I would go the other direction: keep the JSON list and
unnest()it into one row per book-author pair, rather than collapsing it into a string I would then have to split again. - Type conversion is not symmetric between the readers.
html_table()guessed thatYearis an integer even though HTML stores everything as text, whilefromJSON()knew it was a number because JSON distinguishes numbers from strings. They agreed here, but only by luck of the guesser being right. The ISBN is the counter-example worth noticing: both kept it as text, which is correct, since978-1-492-09740-2would be mangled by any attempt to treat it as a number. - The comparison had to be made deliberately.
identical()on the two freshly loaded objects returnsFALSEfor a reason that has nothing to do with the books: one is a tibble and the other is a data frame. A comparison that does not account for class and column order answers a different question than the one being asked.