Free Kindle Books Email Notifier

Scrape Amazon's free Kindle books and email yourself a daily digest.

Introduction

30 Second Summary

Amazon gives away hundreds of Kindle books for free every day, but the list rotates constantly. Unless you check the page manually, you miss out on great reads that disappear within hours.

In this project, you will build a Python automation that scrapes Amazon's free Kindle books, formats them into a clean email digest, and delivers it to your inbox every morning on autopilot.

What You'll Build

A Python script running on an Ubuntu server that launches a headless Chromium browser, extracts free Kindle book data, and emails you a formatted daily digest.

By the end of this project, you'll have:

  • A working web scraper using Playwright's sync API that navigates Amazon's JavaScript-rendered page and extracts book titles, authors, and links.
  • An HTML email sent programmatically through Gmail's SMTP server with a formatted table of today's free books.
  • A cron job on your Ubuntu server that triggers the entire pipeline daily without any manual intervention.
  • Secret Mission: Track previously notified books so your daily email only highlights new additions to the free list.

Are there any prerequisites?

No prior experience with web scraping or email automation is needed. This guide walks you through everything from scratch, including setting up Python, Playwright, and Gmail authentication on your Ubuntu server.

Set Up Your Project Environment

Before you can scrape any data or send emails, you need a workspace on your server with the right tools installed.

In this step, you will create a project folder, set up a Python virtual environment, install Playwright for browser automation, and configure a Gmail app password so your script can send emails on your behalf.

In this step, get ready to:
  • Create a project directory with a Python virtual environment and install Playwright.
  • Install Chromium browser binaries and Ubuntu system dependencies.
  • Set up a Gmail app password for sending automated emails.
Create a project directory and install Playwright

A virtual environment keeps your project's packages isolated from the rest of your system. This means the libraries you install here won't interfere with other Python projects on your server.

  • Create the project directory and move into it by running these commands:
mkdir ~/kindle-notifier
cd ~/kindle-notifier
  • Create a Python virtual environment and activate it by running:
python3 -m venv venv
source venv/bin/activate

Your terminal prompt should now show (venv) at the beginning, confirming the virtual environment is active.

  • Install the Playwright Python package by running:
pip install playwright

What does this command do?

This installs the Playwright Python library into your virtual environment. Playwright is a browser automation framework that can control Chromium, Firefox, and WebKit browsers programmatically.

You will use it to load Amazon's Kindle page (which requires JavaScript to render) and extract book data.

Seeing a permission error?

Make sure you activated the virtual environment first. Your prompt should show (venv) at the start. If not, run source venv/bin/activate again.

help me fix a permission error when running pip install in my virtual environment

Install Chromium browser binaries and system dependencies

Playwright needs an actual browser binary to automate. On Ubuntu, it also needs system-level libraries (like graphics and font rendering) that Chromium depends on.

  • Install the Chromium browser and its Ubuntu system dependencies by running:
playwright install --with-deps chromium

What does this command do?

The playwright install portion downloads the Chromium browser binary that Playwright will control.

The --with-deps flag tells Playwright to also install the required Ubuntu system libraries (like libglib, font packages, etc.) using apt.

Adding chromium at the end limits the install to just Chromium, skipping Firefox and WebKit which you do not need for this project.

This command may ask for your sudo password to install system packages. Enter it when prompted.

You should see output showing Chromium being downloaded and dependencies being installed. Once it finishes without errors, your browser automation environment is ready.

Command not found?

If you see a "command not found" error for playwright, make sure your virtual environment is still active (check for (venv) in your prompt).

If you closed your terminal since the last substep, navigate back and reactivate:

cd ~/kindle-notifier
source venv/bin/activate

help me fix a playwright command not found error on Ubuntu

Set up a Gmail app password

Your script will use Gmail's SMTP server to send emails. Gmail requires an app password (a special 16-digit code) for scripts to authenticate, instead of your regular account password.

To generate one, you first need 2-Step Verification enabled on your Google Account.

  • Open your browser and go to https://myaccount.google.com/security.
  • Sign in with your Gmail account if prompted.
  • Under the **How you sign in to Google** section, click 2-Step Verification.
  • Follow the prompts to enable 2-Step Verification if it is not already on.

What is 2-Step Verification?

2-Step Verification adds a second layer of security to your Google Account by requiring a code (usually sent to your phone) in addition to your password. Google requires it before you can generate app passwords.

Once 2-Step Verification is enabled, you can generate your app password.

  • Go to https://myaccount.google.com/apppasswords.
  • Sign in again if prompted.
  • In the App name field, type Kindle Notifier (or any name to help you remember what this password is for).
  • Click Create.
  • Copy the 16-digit app password that appears. You will need it in the next step.

Save your app password now

Google only shows the app password once. Copy it to a safe place immediately. If you lose it, you will need to generate a new one.

Now create a configuration file to store your email credentials.

  • Create and open a new file called config.py in the kindle-notifier directory by running:
nano config.py
  • Paste the following configuration into the file:
# Gmail credentials for sending notifications
# IMPORTANT: Do not share this file or commit it to version control
GMAIL_ADDRESS = "your-email@gmail.com"
GMAIL_APP_PASSWORD = "your-app-password-here"
RECIPIENT_EMAIL = "your-email@gmail.com"
  • Replace your-email@gmail.com with your actual Gmail address for both GMAIL_ADDRESS and RECIPIENT_EMAIL.
  • Replace your-app-password-here with the 16-digit app password you just generated (remove the spaces).
  • Save the file by pressing Ctrl+O, then Enter, then exit with Ctrl+X.

Why a separate config file?

Keeping credentials in a separate file means your main script never contains sensitive data directly. If you ever share your scraper code, you can exclude config.py to keep your password private.

Can't find the App Passwords option?

If you do not see the App Passwords page, make sure 2-Step Verification is fully enabled (not just started). Google Workspace (work or school) accounts may have app passwords disabled by the organization administrator.

help me access Google app passwords for my account

Now verify that Playwright can launch a browser successfully.

  • Run a quick test script to confirm everything works by running:
python3 -c "
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto('https://example.com')
    print(page.title())
    browser.close()
"

You should see Example Domain printed in your terminal. This confirms Playwright can launch Chromium headlessly, navigate to a page, and read its content.

Seeing an error about missing libraries?

If you see errors mentioning missing shared libraries, the system dependencies may not have installed completely. Re-run the install command:

playwright install --with-deps chromium

Make sure the command completes without errors before testing again.

help me fix missing library errors when launching Chromium with Playwright on Ubuntu

✔️ Awesome, I've got everything!

Great. Double check you have saved config.py with your real Gmail address and app password filled in.

ⓧ I'd like to double check the full code

Here is the complete config.py file for this step:

# Gmail credentials for sending notifications
# IMPORTANT: Do not share this file or commit it to version control
GMAIL_ADDRESS = "your-email@gmail.com"
GMAIL_APP_PASSWORD = "your-app-password-here"
RECIPIENT_EMAIL = "your-email@gmail.com"

Your environment is ready. Next up, you will write the scraper that loads Amazon's free Kindle books page and extracts the data.

Scrape Amazon's Free Kindle Books

Your environment is ready with Playwright installed and a headless Chromium browser available. Now it's time to put them to work.

Amazon's Kindle Best Sellers page loads its content with JavaScript, which means a simple HTTP request won't capture all the books. In this step, you'll write a Python script that launches a real browser behind the scenes, navigates to the free books list, scrolls to load entries, and pulls out each book's details.

In this step, get ready to:
  • Write a scraping function that launches a headless browser and navigates to Amazon's free Kindle page.
  • Extract each book's rank, title, author, and link using CSS selectors.
  • Test the scraper by running the script and printing results to the terminal.
Launch the browser and navigate to the free books page

Amazon's Best Sellers page has a "Top 100 Free" tab that lists the current free Kindle eBooks. Your scraper needs to open this page in a headless browser and click that tab before extracting any data.

  • Create a new file called scraper.py in your kindle-notifier folder and add the following code:
import smtplib
from datetime import datetime
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from playwright.sync_api import sync_playwright
import config

KINDLE_FREE_URL = "https://www.amazon.com/gp/bestsellers/digital-text/154606011"


def scrape_free_kindle_books():
    """Launch headless browser and scrape free Kindle best sellers."""
    books = []

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page(
            user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
            "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
        )

        page.goto(KINDLE_FREE_URL, wait_until="domcontentloaded")
        page.wait_for_timeout(3000)

        # Click the "Top 100 Free" tab
        free_tab = page.locator("text=Top 100 Free")
        if free_tab.count() > 0:
            free_tab.first.click()
            page.wait_for_timeout(3000)

What does this code do?

The imports at the top include libraries for email (used in a later step) and Playwright's sync API for browser automation.

  • KINDLE_FREE_URL stores the Amazon Best Sellers page for Kindle eBooks.
  • p.chromium.launch(headless=True) starts a Chromium browser with no visible window.
  • The custom user_agent string makes the browser appear as a regular desktop visitor, reducing the chance of being blocked.
  • page.locator("text=Top 100 Free") finds the free books tab on the page and clicks it.
  • Save scraper.py.

You won't see any output yet. The next section adds the data extraction logic that makes this function useful.

Syntax error when saving?

  • Make sure the user_agent string is split across two lines with proper Python string concatenation (two adjacent string literals with no comma between them).
  • Check that the indentation inside the with sync_playwright() as p: block uses consistent spaces (8 spaces for the body).

Help me fix a syntax error in my Playwright scraper setup.

Add scrolling and extract book data

Amazon's page loads items lazily as you scroll down. Without scrolling, you'd only capture a fraction of the available books.

  • In scraper.py, add the following code directly below the last line you wrote (still inside the with sync_playwright() as p: block, at the same indentation as the free_tab line):
        # Scroll down to load more items
        for _ in range(5):
            page.keyboard.press("End")
            page.wait_for_timeout(1500)

        # Extract book data from each grid item
        items = page.query_selector_all("div#gridItemRoot")

        for item in items:
            try:
                rank_el = item.query_selector("span.zg-bdg-text")
                rank = rank_el.inner_text().replace("#", "").strip() if rank_el else "N/A"

                link_el = item.query_selector("a.a-link-normal")
                href = link_el.get_attribute("href") if link_el else ""
                link = f"https://www.amazon.com{href}" if href and not href.startswith("http") else href

                title_el = link_el.query_selector("span div") if link_el else None
                title = title_el.inner_text().strip() if title_el else "Unknown Title"

                author_el = item.query_selector("div.a-row.a-size-small")
                author = author_el.inner_text().strip() if author_el else "Unknown Author"

                books.append({
                    "rank": rank,
                    "title": title,
                    "author": author,
                    "link": link,
                })
            except Exception:
                continue

        browser.close()

    return books

What does this code do?

This section handles the actual data extraction from Amazon's page.

  • The scroll loop presses the End key 5 times with pauses, triggering lazy-loaded content to appear.
  • page.query_selector_all("div#gridItemRoot") grabs every book container element on the page.
  • For each container, the script extracts the rank from span.zg-bdg-text, the link and title from a.a-link-normal, and the author from div.a-row.a-size-small.
  • The try/except block skips any items that fail to parse cleanly, so one broken element won't crash the whole script.

Why does Amazon need scrolling?

Amazon's Best Sellers page uses lazy loading. Items beyond the initial viewport only render when you scroll down to them. Without this scrolling step, you'd capture far fewer than 100 books.

  • Save scraper.py.

You won't see output yet. The next substep adds a way to run the function and print results.

Worried about indentation?

The entire extraction block must sit inside the with sync_playwright() as p: context manager (8 spaces of indentation). If it's outdented too far, Python will close the with block early and the browser variable won't be available.

Help me check my indentation in the Playwright extraction code.

Test the scraper

With the scraping function complete, you need a way to call it and see the results. Python's if __name__ == "__main__" guard lets you run the file directly as a script.

  • At the very bottom of scraper.py, outside the function (no indentation), add the following code:

if __name__ == "__main__":
    books = scrape_free_kindle_books()
    for b in books:
        print(b)

What does this code do?

This block only runs when you execute scraper.py directly (not when it's imported by another file). It calls your scraping function and prints each book dictionary to the terminal so you can verify everything works.

  • Save scraper.py.
  • Run the script by entering this command in your terminal:
python scraper.py

The script takes about 30 seconds to finish because it needs to load pages and scroll. You should see a series of dictionaries printed to the terminal, each containing rank, title, author, and link keys.

No output or empty results?

  • If you see zero books, Amazon may have changed their page structure. The CSS selectors (like div#gridItemRoot) are based on Amazon's current layout and may need updating if they redesign the page.
  • If you see a timeout or navigation error, your server may not have a stable internet connection. Try running the script again.
  • If you see a "browser was not found" error, make sure you ran playwright install --with-deps in the previous step.

Help me debug why my Playwright Kindle scraper returns no books.

✔️ Awesome, I've got everything!

Great. Double check you've saved scraper.py and move on to the next step.

ⓧ I'd like to double check the full code

Here's the complete scraper.py file as it should look right now:

import smtplib
from datetime import datetime
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from playwright.sync_api import sync_playwright
import config

KINDLE_FREE_URL = "https://www.amazon.com/gp/bestsellers/digital-text/154606011"


def scrape_free_kindle_books():
    """Launch headless browser and scrape free Kindle best sellers."""
    books = []

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page(
            user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
            "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
        )

        page.goto(KINDLE_FREE_URL, wait_until="domcontentloaded")
        page.wait_for_timeout(3000)

        # Click the "Top 100 Free" tab
        free_tab = page.locator("text=Top 100 Free")
        if free_tab.count() > 0:
            free_tab.first.click()
            page.wait_for_timeout(3000)

        # Scroll down to load more items
        for _ in range(5):
            page.keyboard.press("End")
            page.wait_for_timeout(1500)

        # Extract book data from each grid item
        items = page.query_selector_all("div#gridItemRoot")

        for item in items:
            try:
                rank_el = item.query_selector("span.zg-bdg-text")
                rank = rank_el.inner_text().replace("#", "").strip() if rank_el else "N/A"

                link_el = item.query_selector("a.a-link-normal")
                href = link_el.get_attribute("href") if link_el else ""
                link = f"https://www.amazon.com{href}" if href and not href.startswith("http") else href

                title_el = link_el.query_selector("span div") if link_el else None
                title = title_el.inner_text().strip() if title_el else "Unknown Title"

                author_el = item.query_selector("div.a-row.a-size-small")
                author = author_el.inner_text().strip() if author_el else "Unknown Author"

                books.append({
                    "rank": rank,
                    "title": title,
                    "author": author,
                    "link": link,
                })
            except Exception:
                continue

        browser.close()

    return books


if __name__ == "__main__":
    books = scrape_free_kindle_books()
    for b in books:
        print(b)

Your scraper is pulling live data from Amazon. Next up, you'll turn those raw dictionaries into a nicely formatted HTML email and send it to your inbox.

Build and Send the Email Notification

Your scraper can now pull book data straight off Amazon's Kindle best sellers page. Raw data in a terminal is useful for testing, but it is not useful for your morning routine.

In this step, you will turn that raw list of books into a polished HTML email and deliver it to your inbox using Python's built-in smtplib module and Gmail's SMTP server.

In this step, get ready to:
  • Format scraped book data into an HTML email with a table layout.
  • Send the email through Gmail's SMTP server using your app password.
  • Test the full pipeline end-to-end and confirm the email arrives.
Write the email formatting function

Email clients render HTML directly, so you can send a styled table instead of plain text. The format_email function takes the list of book dictionaries from your scraper and returns a complete HTML document with inline styles.

  • In scraper.py, find the line that says if __name__ == "__main__": near the bottom of the file.
  • Add the following function directly above that line:
def format_email(books):
    """Format book data into an HTML email body."""
    date_str = datetime.now().strftime("%B %d, %Y")

    html = f"""<html>
<body style="font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto;">
<h2 style="color: #232f3e;">Free Kindle Books - {date_str}</h2>
<p>Here are today's top free Kindle books on Amazon ({len(books)} found):</p>
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse: collapse; width: 100%;">
    <tr style="background-color: #232f3e; color: white;">
        <th>Rank</th>
        <th>Title</th>
        <th>Author</th>
        <th>Link</th>
    </tr>"""

    for book in books:
        html += f"""
    <tr>
        <td style="text-align: center;">{book['rank']}</td>
        <td>{book['title']}</td>
        <td>{book['author']}</td>
        <td style="text-align: center;"><a href="{book['link']}">View</a></td>
    </tr>"""

    html += """
</table>
<p style="color: #666; font-size: 12px; margin-top: 20px;">
    This email was generated automatically by your Free Kindle Books Notifier.
</p>
</body>
</html>"""

    return html

What does this code do?

The function builds an HTML email body with inline CSS styles (email clients ignore external stylesheets).

  • datetime.now().strftime("%B %d, %Y") produces today's date in a readable format like "July 06, 2026".
  • The table header row uses Amazon's dark brand color #232f3e for a polished look.
  • Each book row includes a clickable "View" link that takes you directly to the book's Amazon page.
  • Save scraper.py.

You will not see a visible change yet. The next function will use this HTML to send the actual email.

Write the email sending function

Python's smtplib module handles the entire SMTP handshake for you. Your send_email function connects to Gmail, upgrades to TLS encryption, authenticates with your app password, and delivers the message.

  • In scraper.py, add the following function directly below format_email (and still above the if __name__ block):
def send_email(html_content):
    """Send the HTML email via Gmail SMTP."""
    msg = MIMEMultipart("alternative")
    msg["Subject"] = f"Free Kindle Books - {datetime.now().strftime('%B %d, %Y')}"
    msg["From"] = config.GMAIL_ADDRESS
    msg["To"] = config.RECIPIENT_EMAIL

    html_part = MIMEText(html_content, "html")
    msg.attach(html_part)

    with smtplib.SMTP("smtp.gmail.com", 587) as server:
        server.starttls()
        server.login(config.GMAIL_ADDRESS, config.GMAIL_APP_PASSWORD)
        server.sendmail(config.GMAIL_ADDRESS, config.RECIPIENT_EMAIL, msg.as_string())

    print(f"[{datetime.now()}] Email sent successfully to {config.RECIPIENT_EMAIL}")

What does this code do?

  • MIMEMultipart("alternative") creates an email container that can hold both plain text and HTML parts.
  • smtplib.SMTP("smtp.gmail.com", 587) opens a connection to Gmail's SMTP server on port 587.
  • server.starttls() upgrades the connection to encrypted TLS before sending credentials.
  • server.login() authenticates using the app password you set up in Step 1.
  • server.sendmail() delivers the complete message to the recipient.
  • Save scraper.py.

You will not see output yet. The next chunk wires everything together so you can run the full pipeline.

Test the full pipeline end-to-end

You need an orchestrator function that calls the scraper, formats the results, and sends the email in sequence. The main() function handles this flow and prints status messages so you can follow along in the terminal.

  • In scraper.py, add the following function directly below send_email (and still above the if __name__ block):
def main():
    print(f"[{datetime.now()}] Starting free Kindle books scrape...")
    books = scrape_free_kindle_books()
    print(f"[{datetime.now()}] Found {len(books)} free books")

    if books:
        html_content = format_email(books)
        send_email(html_content)
    else:
        print(f"[{datetime.now()}] No books found. The page structure may have changed.")

What does this code do?

  • Prints timestamped status messages so you can track progress during long runs.
  • Calls scrape_free_kindle_books() to get the book list, then passes it through format_email() and send_email().
  • If no books are found (Amazon changed their page layout), it prints a warning instead of sending a blank email.

Now replace the old test code at the bottom of the file so it calls main() instead.

  • In scraper.py, find the existing if __name__ block at the bottom of the file:
if __name__ == "__main__":
    books = scrape_free_kindle_books()
    for b in books:
        print(b)
  • Replace that entire block with:
if __name__ == "__main__":
    main()
  • Save scraper.py.
  • Run the full pipeline by running this command:
python scraper.py

The script takes 30-60 seconds to scrape. You should see timestamped output ending with "Email sent successfully".

  • Check your Gmail inbox. You should see an email with the subject "Free Kindle Books" followed by today's date, containing a styled table of books.

Not seeing the email?

  • Check your Spam folder. Automated emails sometimes land there on first send.
  • If you see an SMTPAuthenticationError with "Username and Password not accepted", double check that config.py has your 16-digit app password (not your regular Gmail password) and your full email address.
  • If the script prints "No books found", Amazon may have changed their page structure. Try running the script again in a few minutes.

help me debug my email sending script

✔️ Awesome, I've got everything!

Great. Double check you have saved scraper.py before moving on.

ⓧ I'd like to double check the full code

import smtplib
from datetime import datetime
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from playwright.sync_api import sync_playwright
import config

KINDLE_FREE_URL = "https://www.amazon.com/gp/bestsellers/digital-text/154606011"


def scrape_free_kindle_books():
    """Launch headless browser and scrape free Kindle best sellers."""
    books = []

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page(
            user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
            "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
        )

        page.goto(KINDLE_FREE_URL, wait_until="domcontentloaded")
        page.wait_for_timeout(3000)

        # Click the "Top 100 Free" tab
        free_tab = page.locator("text=Top 100 Free")
        if free_tab.count() > 0:
            free_tab.first.click()
            page.wait_for_timeout(3000)

        # Scroll down to load more items
        for _ in range(5):
            page.keyboard.press("End")
            page.wait_for_timeout(1500)

        # Extract book data from each grid item
        items = page.query_selector_all("div#gridItemRoot")

        for item in items:
            try:
                rank_el = item.query_selector("span.zg-bdg-text")
                rank = rank_el.inner_text().replace("#", "").strip() if rank_el else "N/A"

                link_el = item.query_selector("a.a-link-normal")
                href = link_el.get_attribute("href") if link_el else ""
                link = f"https://www.amazon.com{href}" if href and not href.startswith("http") else href

                title_el = link_el.query_selector("span div") if link_el else None
                title = title_el.inner_text().strip() if title_el else "Unknown Title"

                author_el = item.query_selector("div.a-row.a-size-small")
                author = author_el.inner_text().strip() if author_el else "Unknown Author"

                books.append({
                    "rank": rank,
                    "title": title,
                    "author": author,
                    "link": link,
                })
            except Exception:
                continue

        browser.close()

    return books


def format_email(books):
    """Format book data into an HTML email body."""
    date_str = datetime.now().strftime("%B %d, %Y")

    html = f"""<html>
<body style="font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto;">
<h2 style="color: #232f3e;">Free Kindle Books - {date_str}</h2>
<p>Here are today's top free Kindle books on Amazon ({len(books)} found):</p>
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse: collapse; width: 100%;">
    <tr style="background-color: #232f3e; color: white;">
        <th>Rank</th>
        <th>Title</th>
        <th>Author</th>
        <th>Link</th>
    </tr>"""

    for book in books:
        html += f"""
    <tr>
        <td style="text-align: center;">{book['rank']}</td>
        <td>{book['title']}</td>
        <td>{book['author']}</td>
        <td style="text-align: center;"><a href="{book['link']}">View</a></td>
    </tr>"""

    html += """
</table>
<p style="color: #666; font-size: 12px; margin-top: 20px;">
    This email was generated automatically by your Free Kindle Books Notifier.
</p>
</body>
</html>"""

    return html


def send_email(html_content):
    """Send the HTML email via Gmail SMTP."""
    msg = MIMEMultipart("alternative")
    msg["Subject"] = f"Free Kindle Books - {datetime.now().strftime('%B %d, %Y')}"
    msg["From"] = config.GMAIL_ADDRESS
    msg["To"] = config.RECIPIENT_EMAIL

    html_part = MIMEText(html_content, "html")
    msg.attach(html_part)

    with smtplib.SMTP("smtp.gmail.com", 587) as server:
        server.starttls()
        server.login(config.GMAIL_ADDRESS, config.GMAIL_APP_PASSWORD)
        server.sendmail(config.GMAIL_ADDRESS, config.RECIPIENT_EMAIL, msg.as_string())

    print(f"[{datetime.now()}] Email sent successfully to {config.RECIPIENT_EMAIL}")


def main():
    print(f"[{datetime.now()}] Starting free Kindle books scrape...")
    books = scrape_free_kindle_books()
    print(f"[{datetime.now()}] Found {len(books)} free books")

    if books:
        html_content = format_email(books)
        send_email(html_content)
    else:
        print(f"[{datetime.now()}] No books found. The page structure may have changed.")


if __name__ == "__main__":
    main()

Your notifier works end-to-end. Next up, you will schedule it to run automatically every day using a cron job so you never have to trigger it manually again.

Schedule Daily Execution with Cron

Your scraper works perfectly when you run it manually. The real power of automation comes from removing yourself from the equation entirely.

In this step, you will create a shell wrapper script and schedule it with cron, the built-in Linux task scheduler. Once configured, your server will scrape Amazon and email you every morning without you lifting a finger.

In this step, get ready to:
  • Create a wrapper shell script that activates your virtual environment and runs the scraper.
  • Schedule the script to run daily at 8:00 AM using cron.
  • Verify the cron job fires automatically and delivers an email to your inbox.
Create a wrapper shell script

Cron runs commands in a minimal environment. It does not source your .bashrc or know about your Python virtual environment.

A wrapper shell script solves this by explicitly activating the virtual environment before running your Python script.

  • Create the wrapper script by running this command in your kindle-notifier directory:
cat > run_notifier.sh << 'EOF'
#!/bin/bash
cd /home/$USER/kindle-notifier
source venv/bin/activate
python scraper.py >> cron.log 2>&1
EOF

What does this script do?

  • #!/bin/bash tells the system to run this script with bash.
  • cd /home/$USER/kindle-notifier moves into your project folder so relative paths work correctly.
  • source venv/bin/activate activates your Python virtual environment so Playwright is available.
  • >> cron.log 2>&1 appends both normal output and errors to a log file for debugging.
  • Make the script executable by running:
chmod +x run_notifier.sh
  • Confirm the file exists and is executable by running:
ls -l run_notifier.sh

You should see -rwxr-xr-x (or similar) at the start of the line, confirming execute permissions are set.

Don't see the x permission?

Make sure you typed chmod +x (with a plus sign, not a minus). Run the chmod command again if needed.

Help me fix file permissions on my shell script.

Add a cron job for daily execution

Cron uses a schedule format with five fields: minute, hour, day of month, month, and day of week. The expression 0 8 * * * means "at minute 0 of hour 8, every day."

  • Open your crontab for editing by running:
crontab -e

First time using crontab?

If this is your first time running crontab -e, you may be asked to choose an editor. Select nano (usually option 1) for the simplest editing experience.

  • Scroll to the bottom of the file and add this line:
0 8 * * * /home/$USER/kindle-notifier/run_notifier.sh

What does this cron expression mean?

  • 0 = at minute 0 (the top of the hour).
  • 8 = at 8 AM (server local time).
  • * * * = every day of every month, every day of the week.
  • The path points to your wrapper script, which handles the rest.

Replace $USER with your actual Ubuntu username if your system does not expand it automatically. You can check your username by running whoami in a separate terminal.

  • Save and exit the editor. In nano, press Ctrl+O to save, then Ctrl+X to exit.

You should see a message like crontab: installing new crontab confirming the job was saved.

Seeing a syntax error after saving?

Make sure the cron line has exactly five time fields followed by a space and then the full path. There should be no blank lines or extra spaces before the numbers.

Help me fix my crontab syntax.

Verify and test the cron job

You do not want to wait until 8 AM tomorrow to find out if your cron job works. You can verify the job is registered and then temporarily reschedule it to fire in 2 minutes for a quick test.

  • Confirm your cron job is registered by running:
crontab -l

You should see your 0 8 * * * line listed in the output.

  • Now set a temporary test schedule. Open your crontab again:
crontab -e
  • Change the time fields to 2 minutes from now. For example, if it is currently 3:15 PM, change the line to:
17 15 * * * /home/$USER/kindle-notifier/run_notifier.sh

How do I pick the right minute and hour?

Run date in your terminal to see the current time. Add 2 minutes to the current minute and use 24-hour format for the hour. If the current time is 10:22, use 24 10 as your minute and hour fields.

  • Save and exit the editor.
  • Wait for the scheduled time to pass (about 2 minutes).
  • Check the log file by running:
cat cron.log

You should see output showing the scrape started, how many books were found, and that the email was sent successfully.

  • Check your Gmail inbox. A new free Kindle books email should have arrived without you running anything manually.

No log file or email after 2 minutes?

  • Double check the cron time you set has actually passed. Run date to confirm.
  • Make sure you replaced $USER with your actual username in the crontab entry if your system did not expand it. Run whoami to see your username.
  • Verify the script path is correct by running ls /home/$(whoami)/kindle-notifier/run_notifier.sh to confirm the file exists at that location.

Help me debug why my cron job isn't running.

  • Once your test succeeds, set the schedule back to 8:00 AM. Open your crontab:
crontab -e
  • Change the line back to the daily 8 AM schedule:
0 8 * * * /home/$USER/kindle-notifier/run_notifier.sh
  • Save and exit.

✔️ Awesome, I've got everything!

Great. Make sure you have saved and exited your crontab editor.

ⓧ I'd like to double check the full code

Here is your run_notifier.sh file:

#!/bin/bash
cd /home/$USER/kindle-notifier
source venv/bin/activate
python scraper.py >> cron.log 2>&1

And your crontab entry (view with crontab -l):

0 8 * * * /home/$USER/kindle-notifier/run_notifier.sh

Secret mission

Track New Books Only

Right now your notifier emails you the entire free list every single day. That means the same books show up repeatedly until they leave the top 100. Can you make the script remember what it has already told you about, so your daily digest only contains genuinely new additions?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your Ubuntu server with no paid cloud services. There are no ongoing costs, but you may want to remove the cron job, project files, or browser binaries to free up disk space.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Project directory at ~/kindle-notifier/ (Python scripts, virtual environment, log file, seen books JSON).
  • Cron job running run_notifier.sh daily at 8:00 AM.
  • Playwright Chromium browser binaries installed on your system.
  • Gmail app password configured in your Google Account.

Keep everything running

No action needed. Choose this if you want the daily free Kindle books email to keep arriving in your inbox.

  • Your cron job continues to run the scraper every morning.
  • The seen_books.json file grows over time as new books are tracked.
  • Your Gmail app password remains active and connected to the script.

Pause - I'll come back to this later

Shut down the automated schedule while keeping your project files intact so you can resume later.

  • Remove the cron job by running:
  • Your project files, virtual environment, and seen books history all remain on disk.
  • To resume later, add the cron entry back with crontab -e.
crontab -r

What does this command do?

The crontab -r command removes your entire user crontab. If you have other cron jobs you want to keep, use crontab -e instead and delete only the kindle-notifier line.

Delete - I don't want to use this again

Remove all project resources from your server and revoke the Gmail app password.

  • Remove the cron job by running:
crontab -r
  • Uninstall the Playwright browser binaries by running:
cd ~/kindle-notifier
source venv/bin/activate
playwright uninstall
  • Delete the project directory and all its contents by running:
cd ~
rm -rf ~/kindle-notifier
  • Revoke your Gmail app password to prevent any future access:
  • Go to myaccount.google.com and sign in.
  • Click Security in the left sidebar.
  • Under "Signing in to Google", click App passwords.
  • Find the app password you created for this project and click Remove.

Nice Work!

Nice Work!

Nice work! You just built a fully automated daily email digest that scrapes Amazon's free Kindle best sellers and delivers them straight to your inbox.

You've learned how to:

  • Built a headless browser scraper using Playwright that navigates and extracts data from a JavaScript-rendered page.
  • Composed and sent HTML-formatted emails programmatically using Python's smtplib and Gmail SMTP.
  • Scheduled automated script execution on an Ubuntu server using cron.
  • Secret Mission: Implemented persistent state tracking with JSON to filter duplicate notifications.

Ready to quiz yourself?