#!/usr/bin/env python
# coding: utf-8
#
#
# # Explore and download books from the Gutenberg Books collection
#
# This Jupyter Notebook provides an interactive exploration and downloading interface for the Gutenberg Books Collection.
#
# Explore the vast collection of books, analyze metadata, and download selected texts based on various criteria. Dive into literary exploration and access timeless classics with ease
#
# **Note:** you can execute the whole "Preliminaries" section while it is collapsed by clicking on the "run" icon. Once all the cells in the "Preliminaries" section have been executed, all other cells can be executed independently of one another.
#
# ![preliminaries_collapse.png](data:image/png;base64,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)
# # Preliminaries
# ## Main class `GutenbergBooks`
# In[1]:
import gzip
import urllib.request
import requests
import os
import io
import pandas as pd
GUTENBERG_URL = "https://www.gutenberg.org/cache/epub/feeds/pg_catalog.csv.gz"
GUTENBERG_CACHEDIR = "GutenbergBooks"
pd.options.mode.copy_on_write = True
class GutenbergBooks:
def __init__(self):
self.catalog_url = GUTENBERG_URL
self.catalog_file = self.catalog_url.rsplit('/', 1)[-1][:-3]
self.is_cached = os.path.isfile(self.catalog_file)
self.catalog = self.fetch_catalog()
self.all_subjects = self.get_subjects()
self.cachedir = GUTENBERG_CACHEDIR
if not os.path.exists(self.cachedir):
os.makedirs(self.cachedir)
def is_cached(self):
if os.path.isfile(self.catalog_file):
return True
return False
def cache_catalog(self):
self.catalog = self.fetch_catalog(use_cache=False)
self.catalog.to_csv(self.catalog_file)
self.is_cached = True
def is_book_downloaded(self, bookID):
book_file = f"pg{bookID}.txt"
if os.path.isfile(os.path.join(GUTENBERG_CACHEDIR, book_file)):
return True
return False
def fetch_catalog(self, use_cache=True):
url = self.catalog_url
filename = self.catalog_file
if self.is_cached and use_cache:
print(f"Retrieving {filename} from cache. To refresh cache use cache_catalog()")
dataframe = pd.read_csv(filename, quotechar = '"')
return dataframe
try:
# Retrieve the compressed file from the URL
print(f"Retrieving {filename} from {url}.")
response = urllib.request.urlopen(url)
compressed_data = response.read()
# Decompress the data
decompressed_data = gzip.decompress(compressed_data)
# Load decompressed data into pandas DataFrame
dataframe = pd.read_csv(io.StringIO(decompressed_data.decode('utf-8')), quotechar = '"')
dataframe.to_csv(self.catalog_file)
self.is_cached = True
return dataframe
except Exception as e:
print("An error occurred:", e)
return None
def get_subjects(self):
return self.catalog['Subjects'].str.split('; ').explode().unique().tolist()
def random_subjects(self, n, seed):
"""
This method returns n random subjects.
Parameters:
- n (int): number of subjects.
- seed (int): random seed for reproducibility.
Returns:
- list: Random sample of subjects from the Gutenberg Books catalog
following the subjects distribution.
"""
df = self.catalog['Subjects']
subject_counts = df.str.split('; ').explode() \
.groupby(df.str.split('; ').explode()).count() \
.reset_index(name='Count').sort_values(by='Count', ascending=False) \
.rename(columns={"Subjects": "Subject"}) \
.reset_index(drop=True)
return subject_counts.sample(n=n, replace=False, random_state=seed, weights=subject_counts['Count'])
def topn_subjects(self, n):
df = self.catalog['Subjects']
subject_counts = df.str.split('; ').explode() \
.groupby(df.str.split('; ').explode()).count() \
.reset_index(name='Count').sort_values(by='Count', ascending=False) \
.rename(columns={"Subjects": "Subject"})
return subject_counts.reset_index(drop=True).head(n)
def get_authors(self):
return self.catalog['Authors'].str.split('; ').explode().unique().tolist()
def random_authors(self, n, seed):
df = self.catalog['Authors']
author_counts = df.str.split('; ').explode() \
.groupby(df.str.split('; ').explode()).count() \
.reset_index(name='Count').sort_values(by='Count', ascending=False) \
.rename(columns={"Authors": "Author"}) \
.reset_index(drop=True)
return author_counts.sample(n=n, replace=False, random_state=seed, weights=author_counts['Count'])
def topn_authors(self, n):
df = self.catalog['Authors']
author_counts = df.str.split('; ').explode() \
.groupby(df.str.split('; ').explode()).count() \
.reset_index(name='Count').sort_values(by='Count', ascending=False) \
.rename(columns={"Authors": "Author"})
return author_counts.reset_index(drop=True).head(n)
def get_languages(self):
return self.catalog['Language'].str.split('; ').explode().unique().tolist()
def topn_languages(self, n):
df = self.catalog['Language']
language_counts = df.str.split('; ').explode() \
.groupby(df.str.split('; ').explode()).count() \
.reset_index(name='Count').sort_values(by='Count', ascending=False)
return language_counts.reset_index(drop=True).head(n)
def get_bookshelves(self):
return self.catalog['Bookshelves'].str.split('; ').explode().unique().tolist()
def topn_bookshelves(self, n):
df = self.catalog['Bookshelves']
bookshelf_counts = df.str.split('; ').explode() \
.groupby(df.str.split('; ').explode()).count() \
.reset_index(name='Count').sort_values(by='Count', ascending=False) \
.rename(columns={"Bookshelves": "Bookshelf"})
return bookshelf_counts.reset_index(drop=True).head(n)
def get_types(self):
return self.catalog['Type'].unique().tolist()
def get_books(self, lang, subject, title):
return self.catalog.sample(n=n, replace=False, random_state=seed)
def random_books(self, n, seed):
return self.catalog.sample(n=n, replace=False, random_state=seed)
def books_matching_subject(self, substr):
return self.catalog.query(f'Subjects.str.lower().str.contains("{substr.lower()}", na=False)')
def books_matching_author(self, substr):
return self.catalog.query(f'Author.str.lower().str.contains("{substr.lower()}", na=False)')
def books_matching_year(self, given_year):
"""
Find books from the catalog that match a given year within the birth-death intervals of authors.
Parameters:
- given_year (int): The year to match within the birth-death intervals of authors.
Returns:
- DataFrame: A DataFrame containing books from the catalog where the given year falls within
the birth-death intervals of authors.
This method extracts birth and death years from the 'Authors' column of the catalog and filters
rows where the given year is within any birth-death interval. It returns a DataFrame of matching books.
"""
catalog_copy = self.catalog.copy()
# Create a temporary DataFrame to hold split author-interval pairs
temp_df = catalog_copy['Authors'].str.extractall(r'((?:\w+\s+)?(?:\d{4})\s*-\s*(?:\d{4}))')
temp_df.reset_index(inplace=True)
temp_df.rename(columns={0: 'Author_Interval'}, inplace=True)
# Merge the original catalog with the temporary DataFrame
merged_df = pd.merge(catalog_copy, temp_df, left_index=True, right_on='level_0')
# Extract birth and death years from the author-interval pairs
merged_df['Birth_Year'] = merged_df['Author_Interval'].str.extract(r'(\d{4})')
merged_df['Death_Year'] = merged_df['Author_Interval'].str.extract(r'\d{4}\s*-\s*(\d{4})')
# Convert birth and death years to numeric
merged_df['Birth_Year'] = pd.to_numeric(merged_df['Birth_Year'], errors='coerce')
merged_df['Death_Year'] = pd.to_numeric(merged_df['Death_Year'], errors='coerce')
# Filter rows where the given year is within any birth-death interval
matching_books = merged_df[(merged_df['Birth_Year'] <= given_year) &
(merged_df['Death_Year'] >= given_year)]
# Drop unnecessary columns
matching_books.drop(columns=['Author_Interval', 'level_0'], inplace=True)
# Return matching books
return matching_books
def download_book(self, nr):
"""
Download one book from the Gutenberg collection identified by its id.
If the book already exists in the cache folder, it is not downloaded again.
Parameters:
- nr (int): id of the book in the Gutenberg books collection.
Returns:
- str: the path where the book was downloaded.
"""
b = str(nr)
book = f"pg{b}.txt"
url = f"https://www.gutenberg.org/cache/epub/{b}/{book}"
book_path = os.path.join(GUTENBERG_CACHEDIR, book)
if self.is_book_downloaded(b):
print(f"Book {nr} already exists in cache. Not downloading.")
else:
try:
# Retrieve the book from the URL
print(f"Retrieving {book} from {url}.")
with open(book_path, "w") as f:
f.write(requests.get(url).text)
except Exception as e:
print("An error occurred:", e)
return None
return book_path
def download_books(self, books):
"""
Download a list of books from the Gutenberg collection.
If a book already exists in the cache folder, it is not downloaded again.
Parameters:
- books (list): list of ids of books in the Gutenberg books collection.
Returns:
- str: the path where the book was downloaded.
"""
book_paths = []
for b in books:
path =self.download_book(b)
book_paths += [path]
return book_paths
def download_n_books(self, n, subject):
"""
Download a certain number of books from the Gutenberg collection based on the desired size and subject.
If a book already exists in the cache folder, it is not downloaded again.
Parameters:
- n (int): The number of books to download.
- subject (str): The subject to match when selecting books.
Returns:
- list: A list of paths where the downloaded books are saved.
"""
# Get books matching the subject
matching_books = self.books_matching_subject(subject)
# Limit the number of books to download
books_to_download = matching_books[:n]['Text#']
# Download books
book_paths = [self.download_book(b) for b in books_to_download]
return book_paths
def download_size_books(self, size_mb=128, subject=None):
"""
Download books from the Gutenberg collection based on the desired total size and subject.
If a book already exists in the cache folder, it is not downloaded again.
Parameters:
- size_mb (int): The desired total size of downloaded books in MB. Default is 128MB.
- subject (str, optional): The subject to match when selecting books. Default is None.
Returns:
- list: A list of paths where the downloaded books are saved.
"""
# Get books matching the subject if provided
if subject:
matching_books = self.books_matching_subject(subject)['Text#']
else:
matching_books = self.catalog['Text#']
# Initialize variables
total_size = 0
books_to_download = []
# Iterate through matching books until total size threshold is met
for b in matching_books:
if total_size >= size_mb * 1024 * 1024: # Convert MB to bytes
break
book_path = self.download_book(b)
file_size = os.path.getsize(book_path)
# Add file size to total size
total_size += file_size
# Add book to download list
books_to_download.append(b)
# Download books
book_paths = [self.download_book(b) for b in books_to_download]
print(f"Total size: {int(total_size/1024/1024)}MB")
if total_size <= size_mb * 1024 * 1024:
print(f"Download more books to get {size_mb}MB")
return book_paths
gb = GutenbergBooks()
# ## Use `cache_catalog()` to create a cached copy of the catalog
# In[2]:
# gb.cache_catalog()
# ## Interactive tables
#
# Library `data_table` from Google Colab adds interactivity to Pandas tables.
#
# https://colab.research.google.com/notebooks/data_table.ipynb
# In[3]:
# true if running on Google Colab
import sys
IN_COLAB = 'google.colab' in sys.modules
if IN_COLAB:
from google.colab import data_table
from vega_datasets import data
data_table.enable_dataframe_formatter()
else:
get_ipython().system('pip install itables')
from itables import init_notebook_mode
init_notebook_mode(all_interactive=True)
# ## Code for visualizations
#
# This is needed for plotting.
# In[4]:
import matplotlib
colors = matplotlib.cm.tab20(range(20))
# source: https://matplotlib.org/stable/gallery/misc/packed_bubbles.html
import matplotlib.pyplot as plt
import numpy as np
class BubbleChart:
def __init__(self, area, bubble_spacing=0):
"""
Setup for bubble collapse.
Parameters
----------
area : array-like
Area of the bubbles.
bubble_spacing : float, default: 0
Minimal spacing between bubbles after collapsing.
Notes
-----
If "area" is sorted, the results might look weird.
"""
area = np.asarray(area)
r = np.sqrt(area / np.pi)
self.bubble_spacing = bubble_spacing
self.bubbles = np.ones((len(area), 4))
self.bubbles[:, 2] = r
self.bubbles[:, 3] = area
self.maxstep = 2 * self.bubbles[:, 2].max() + self.bubble_spacing
self.step_dist = self.maxstep / 2
# calculate initial grid layout for bubbles
length = np.ceil(np.sqrt(len(self.bubbles)))
grid = np.arange(length) * self.maxstep
gx, gy = np.meshgrid(grid, grid)
self.bubbles[:, 0] = gx.flatten()[:len(self.bubbles)]
self.bubbles[:, 1] = gy.flatten()[:len(self.bubbles)]
self.com = self.center_of_mass()
def center_of_mass(self):
return np.average(
self.bubbles[:, :2], axis=0, weights=self.bubbles[:, 3]
)
def center_distance(self, bubble, bubbles):
return np.hypot(bubble[0] - bubbles[:, 0],
bubble[1] - bubbles[:, 1])
def outline_distance(self, bubble, bubbles):
center_distance = self.center_distance(bubble, bubbles)
return center_distance - bubble[2] - \
bubbles[:, 2] - self.bubble_spacing
def check_collisions(self, bubble, bubbles):
distance = self.outline_distance(bubble, bubbles)
return len(distance[distance < 0])
def collides_with(self, bubble, bubbles):
distance = self.outline_distance(bubble, bubbles)
return np.argmin(distance, keepdims=True)
def collapse(self, n_iterations=50):
"""
Move bubbles to the center of mass.
Parameters
----------
n_iterations : int, default: 100
Number of moves to perform.
"""
for _i in range(n_iterations):
moves = 0
for i in range(len(self.bubbles)):
rest_bub = np.delete(self.bubbles, i, 0)
# try to move directly towards the center of mass
# direction vector from bubble to the center of mass
dir_vec = self.com - self.bubbles[i, :2]
# shorten direction vector to have length of 1
dir_vec = dir_vec / np.sqrt(dir_vec.dot(dir_vec))
# calculate new bubble position
new_point = self.bubbles[i, :2] + dir_vec * self.step_dist
new_bubble = np.append(new_point, self.bubbles[i, 2:4])
# check whether new bubble collides with other bubbles
if not self.check_collisions(new_bubble, rest_bub):
self.bubbles[i, :] = new_bubble
self.com = self.center_of_mass()
moves += 1
else:
# try to move around a bubble that you collide with
# find colliding bubble
for colliding in self.collides_with(new_bubble, rest_bub):
# calculate direction vector
dir_vec = rest_bub[colliding, :2] - self.bubbles[i, :2]
dir_vec = dir_vec / np.sqrt(dir_vec.dot(dir_vec))
# calculate orthogonal vector
orth = np.array([dir_vec[1], -dir_vec[0]])
# test which direction to go
new_point1 = (self.bubbles[i, :2] + orth *
self.step_dist)
new_point2 = (self.bubbles[i, :2] - orth *
self.step_dist)
dist1 = self.center_distance(
self.com, np.array([new_point1]))
dist2 = self.center_distance(
self.com, np.array([new_point2]))
new_point = new_point1 if dist1 < dist2 else new_point2
new_bubble = np.append(new_point, self.bubbles[i, 2:4])
if not self.check_collisions(new_bubble, rest_bub):
self.bubbles[i, :] = new_bubble
self.com = self.center_of_mass()
if moves / len(self.bubbles) < 0.1:
self.step_dist = self.step_dist / 2
def plot(self, ax, labels, colors):
"""
Draw the bubble plot.
Parameters
----------
ax : matplotlib.axes.Axes
labels : list
Labels of the bubbles.
colors : list
Colors of the bubbles.
"""
for i in range(len(self.bubbles)):
circ = plt.Circle(
self.bubbles[i, :2], self.bubbles[i, 2], color=colors[i])
ax.add_patch(circ)
ax.text(*self.bubbles[i, :2], labels[i],
horizontalalignment='center', verticalalignment='center')
# Attempt to set the font family
desired_font_family = 'DejaVu Serif'
try:
plt.rcParams['font.family'] = desired_font_family
print(f"Using '{desired_font_family}' font family.")
except:
print(f"Warning: Font family '{desired_font_family}' not found. Using fallback font.")
plt.rcParams['font.family'] = 'serif' # Fallback to a generic serif font
# # Explore
# ## Books
# ### All books
#
# The whole Gutenberg collection catalog is saved in the `catalog` of the `GutenbergBooks` object `gb`.
# In[5]:
gb.catalog
# ### Count books in the collection
#
# There are currently $73109$ books in the collection.
# In[6]:
len(gb.catalog)
# ### First five books in the catalog
# In[7]:
gb.catalog.head(5)
# ### Five random books
#
# Looking only at the first lines of a DataFrame might provide an initial glimpse into the data, but it can be insufficient for gaining a comprehensive understanding of its characteristics, that's why sampling from the DataFrame is often more beneficial.
#
# So, let's break away from the norm of quickly scanning the first few lines of a file with the `head` command. Let us instead allocate a bit more computational power and extract a small yet representative sample of the data.
# In[8]:
print("Five random books from catalog")
gb.random_books(n=5, seed=42)
# ## Subjects
# ### Count distinct subjects
#
# There are currently $39619$ distinct subjects.
# In[9]:
len(gb.get_subjects())
# ### Top $n$ subjects
# In[10]:
n = 10
gb.topn_subjects(n)
# ### 20K subjects
# In[11]:
pd.DataFrame(gb.topn_subjects(20000))
# Limiting the number of rows to 20000 because this is the maximum number supported
# by Colab's `data_table`.
# ### Ten random subjects
# In[12]:
gb.random_subjects(10, 42).sort_values(by='Count', ascending=False)
# ### List books matching a given subject
#
# Change the subject by setting the variable `my_subject` (search is case-insensitive).
# In[13]:
substr = "description and travel"
gb.books_matching_subject(substr).head()
# ### Visualize most frequent subjects
# In[14]:
n = 20
gutenberg_books_subjects = {
'subjects': gb.topn_subjects(n)['Subject'].replace({' -- ': '\n'}, regex=True).to_list(),
'market_share': list(map(lambda x: x*n*3, gb.topn_subjects(n)['Count'].to_list())),
'color': colors[:n]
}
bubble_chart = BubbleChart(area=gutenberg_books_subjects['market_share'],
bubble_spacing=2*n)
bubble_chart.collapse()
fig, ax = plt.subplots(subplot_kw=dict(aspect="equal"), figsize=(10, 10))
bubble_chart.plot(
ax, gutenberg_books_subjects['subjects'], gutenberg_books_subjects['color'])
ax.axis("off")
ax.relim()
ax.autoscale_view()
ax.set_title(f'Gutenberg books top {n} subjects')
plt.show()
# ## Authors
# ### Count distinct authors
#
# There are currently $37392$ distinct authors.
# In[15]:
len(gb.get_authors())
# ### All authors
# The `data_table` library can only deal with a maximum of $20000$ rows. If the number of rows exceeds this limit, the usual Pandas display is used (with no interactivity).
#
# In[16]:
pd.DataFrame(gb.get_authors())
# ### Top $n$ authors
# In[17]:
n = 20000
gb.topn_authors(n)
# ### Ten random authors
# In[18]:
gb.random_authors(10, 42).sort_values(by='Count', ascending=False)
# ### Visualize most frequent authors
# In[19]:
n = 20
gutenberg_books_authors = {
'authors': gb.topn_authors(n)['Author'].replace({', ': '\n', ' \[': '\n['}, regex=True).to_list(),
'market_share': list(map(lambda x: x*n*3, gb.topn_authors(n)['Count'].to_list())),
'color': colors[:n]
}
bubble_chart = BubbleChart(area=gutenberg_books_authors['market_share'],
bubble_spacing=2*n)
bubble_chart.collapse()
fig, ax = plt.subplots(subplot_kw=dict(aspect="equal"),figsize=(10, 10))
bubble_chart.plot(
ax, gutenberg_books_authors['authors'], gutenberg_books_authors['color'])
ax.axis("off")
ax.relim()
ax.autoscale_view()
ax.set_title(f'Gutenberg books top {n} authors')
plt.show()
# ## Types
# ### All types
# In[20]:
pd.DataFrame(gb.get_types(), columns=['Type'])
# ### Count books by types
# In[21]:
grouped_counts = gb.catalog.groupby('Type').size().reset_index(name='Count')
grouped_counts
# ### Visualize types
# In[22]:
grouped_data = gb.catalog.groupby('Type').size().reset_index(name='Count')
n = len(grouped_data)
# Extracting values of 'Type' and 'Count' columns as lists
type_list = grouped_data['Type'].tolist()
count_list = grouped_data['Count'].tolist()
gutenberg_books_types = {
'types': type_list,
# adapt the size of smaller items
'market_share': list(map(lambda x: x if x>1000 else x*n*10, count_list)),
'color': colors[:-n]
}
bubble_chart = BubbleChart(area=gutenberg_books_types['market_share'],
bubble_spacing=2*n)
bubble_chart.collapse()
fig, ax = plt.subplots(subplot_kw=dict(aspect="equal"),figsize=(10, 10))
bubble_chart.plot(
ax, gutenberg_books_types['types'], gutenberg_books_types['color'])
ax.axis("off")
ax.relim()
ax.autoscale_view()
ax.set_title(f'Gutenberg books top types')
subtitle = "(the depicted proportions have been altered and do not reflect the true distribution)"
# Set the subtitle below the main title
plt.text(0.5, 0.98, subtitle, fontsize=10, ha='center', transform=plt.gca().transAxes)
plt.show()
# I wasn't aware that the Gutenberg collection contained data other than text. I'll need to explore these additional data types at some point.
# ## Bookshelves
# ### Top $n$ bookshelves
# In[23]:
n = 10
gb.topn_bookshelves(n)
# ### Visualize most frequent bookshelves
# In[24]:
n = 20
gutenberg_books_bookshelves = {
'bookshelves': gb.topn_bookshelves(n)['Bookshelf'].replace({', ': '\n'}, regex=True).to_list(),
'market_share': list(map(lambda x: x*n*3, gb.topn_bookshelves(n)['Count'].to_list())),
'color': colors[:n]
}
bubble_chart = BubbleChart(area=gutenberg_books_bookshelves['market_share'],
bubble_spacing=4*n)
bubble_chart.collapse()
fig, ax = plt.subplots(subplot_kw=dict(aspect="equal"), figsize=(10, 10))
bubble_chart.plot(
ax, gutenberg_books_bookshelves['bookshelves'], gutenberg_books_bookshelves['color'])
ax.axis("off")
ax.relim()
ax.autoscale_view()
ax.set_title(f'Gutenberg books top {n} bookshelves')
plt.show()
# ### Books without bookshelf
#
# Many books do not belong to any bookshelf
# In[25]:
gb.catalog.count()
# ### Number of books without bookshelf
# In[26]:
print(f"Number of books with no bookshelf: {gb.catalog[gb.catalog['Bookshelves'].isna()].shape[0]}")
# ### Five random books without bookshelf
# In[27]:
gb.catalog[gb.catalog['Bookshelves'].isna()].sample(n=5, replace=False, random_state=42)
# ## Languages
# ### Count distinct languages
#
# The Gutenberg collection currently comprises 68 languages.
# In[28]:
len(gb.get_languages())
# ### Top $n$ languages
# In[29]:
gb.topn_languages(10)
# ### Visualize top $n$ languages
# In[30]:
n = 20
gutenberg_books_languages = {
'languages': gb.topn_languages(n)['Language'].to_list(),
'market_share': list(map(lambda x: x*10, gb.topn_languages(n)['Count'].to_list())),
'color': colors
}
bubble_chart = BubbleChart(area=gutenberg_books_languages['market_share'],
bubble_spacing=15)
bubble_chart.collapse()
fig, ax = plt.subplots(subplot_kw=dict(aspect="equal"), figsize=(10, 10))
bubble_chart.plot(
ax, gutenberg_books_languages['languages'], gutenberg_books_languages['color'])
ax.axis("off")
ax.relim()
ax.autoscale_view()
ax.set_title(f'Gutenberg books top {n} languages')
plt.show()
# ## Match books using various criteria
# ### Match books by subject
# In[31]:
substr = "description and travel"
gb.books_matching_subject(substr)
# ### Match books by year
# In[32]:
gb.books_matching_year(1984)
# In[33]:
help(GutenbergBooks.books_matching_year)
# # Downloading files from the Gutenberg collection
#
# ⚠️ Please read carefully this notice about the Gutenberg Project's policies on bulk downloading:
#
#
#
# > “ _The Project Gutenberg website is intended for human users only. Any perceived use of automated tools to access the Project Gutenberg website will result in a temporary or permanent block of your IP address._ ”
#
# See: https://www.gutenberg.org/policy/robot_access.html.
#
# ## The cache directory
#
# By default, `GutenbergBooks` is the directory where all downloaded books are stored. If a book is alredy in the `GutenbergBooks` directory it won't be downloaded again.
#
# ⚠️ The cache directory is empty when you start your Google Colab session! ⚠️
# ## Download one book
# In[34]:
gb.download_book(5687)
# The same book won't be downloaded because it already exists in the cache directory `GutenbergBooks`.
# In[35]:
gb.download_book(5687)
# In[36]:
help(GutenbergBooks.download_book)
# ## Download multiple books
# In[37]:
gb.download_books([5678, 5679, 5680])
# In[38]:
help(GutenbergBooks.download_books)
# ## Download $n$ books by subject
# In[39]:
gb.download_n_books(5, "\(South Africa\) -- Description and travel")
# ## Download a given amount of books by subject
#
# DOwnload books matching a certain subject. Stop when the threshold given by the `size_mb` (size in Megabytes) parameter is reached.
#
# If not specified, `size_mb` is $128$ (the default Hadoop block size).
# In[40]:
gb.download_size_books(subject="\(South Africa\) -- Description and travel")
# In[41]:
get_ipython().system('du -sh GutenbergBooks')
# In[42]:
subject = "United States -- Description and travel"
gb.download_size_books(size_mb=90, subject=subject)
# In[43]:
get_ipython().system('du -sh GutenbergBooks')
# It's not easy to get enough data!
# In[44]:
subject = "California -- Description and travel"
gb.download_size_books(size_mb=50, subject=subject)
# In[45]:
get_ipython().system('du -sh GutenbergBooks')
# # Acknowledgements and some thoughts on Artificial Intelligence
#
# For this tutorial I've made extensive use of the ChatGPT (version $3.5$) AI to:
# - improve my English
# - define code structure
# - write Python code snippets
# - document code
#
# I ideated, organized, adapted, double-checked all content (both text and code) with the aim of creating a useful tool for exploring the Gutenberg books collection and providing a pleasant user experience.
#
# I can imagine that in the future AI will be able to write such tutorials on their own and then the role of a tutorial author will be limited to defining requirements. Maybe there are going to be self-adapting tutorials that create themselves on the spot according to the needs of the readers, thus bypassing the need for tutorial authors. What are tutorial authors going to do then? Something else 😀!
#
#
# In this spirit, I'd like to thank everyone who contributed to the common sense language collection (both natural and programming languages) used to train ChatGPT, the creators of ChatGPT, and the companies making it available as a comfortable Web application.