Index Ai Academy
Phase 0 · Tools · Talking to APIs

Web pages: HTML and CSS

Read and write HTML, what forms send, CSS selectors and escaping.

About 40 minutes · Free lesson

Start this lesson

Why it matters

Python developers write HTML more often than they expect: email bodies, reports, the pages a web app sends, the templates in Flask. They read it even more: scraping, testing a web app, debugging what a server sent back. You don't need to be a designer. You need to know how a page is put together, what a form really sends, how selectors pick things out, and how to keep user text from breaking a page.

What you'll be able to do

  • Read and write HTML: elements, attributes, links, lists and tables, as a tree
  • Explain what a form sends when it's submitted, with GET and with POST
  • Pick elements out with CSS selectors, the same selectors scraping and styling use
  • Escape text before putting it into a page, so names with < & and quotes can't break it

What it covers

  1. A page is a tree of elements
  2. Tables, and the attributes that matter
  3. Selectors: picking elements out
  4. Escaping: text that looks like HTML
  5. A review that can't break the page
  6. Pages people can use
  7. Worked example: a page of deals, built and checked

A page is a tree of elements

HTML is text with tags in angle brackets. An element is an opening tag, its content and a closing tag: <h1>Deals</h1>. Tags can carry attributes, like the address in a link: <a href="/shop/deals">Deals</a>. Elements sit inside other elements, so a page is a tree:

from preview import preview

page = """<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Trail Club news</title>
</head>
<body>
  <h1>Trail Club news</h1>
  <p>Saturday's hike starts at <strong>8:00</strong> at Fern Hollow.</p>
  <ul>
    <li><a href="/shop/deals?page=1">This week's deals</a></li>
    <li>New: the <em>Lookout</em> down vest</li>
  </ul>
  <img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=" alt="A trail map">
</body>
</html>"""
preview(page)
print(len(page), "characters of HTML")
It runs right here in your browser, with nothing to install.

preview shows the page under the output, in a frame of its own. The <head> holds facts about the page, like its <title> for the browser tab. The <body> holds what you see. <ul> is a list and each <li> an item in it. <strong> and <em> mark important and stressed words. <img> has no closing tag, and its alt text describes the image for anyone who can't see it.

Tables, and the attributes that matter

Tables hold rows (<tr>) of cells: <th> for headings, <td> for data. Two attributes do a lot of work on any element:

  • class names a group of elements, like class="price" on every price. An element can have several classes, separated by spaces.
  • id names one element, unique on the page.
from preview import preview

rows = [("Ridgeline 1-person tent", 200), ("Ember spork", 5), ("Scree hiking boot", 190)]
cells = "\n".join(f'  <tr><td>{name}</td><td class="price">${price:.2f}</td></tr>' for name, price in rows)
table = f"""<table id="catalog">
  <tr><th>Product</th><th>Price</th></tr>
{cells}
</table>
<style>
  table {{ border-collapse: collapse; font-family: sans-serif; }}
  td, th {{ border: 1px solid #ccc; padding: 4px 10px; }}
  .price {{ text-align: right; color: #1a6b3c; }}
</style>"""
preview(table)
print(table.count("<tr>"), "rows")

The <style> element holds CSS, the language for how things look. Each rule is a selector, which says which elements it applies to, and then properties in braces. td, th means every data cell and heading cell. .price means every element with the class "price". (Inside the f-string, CSS's braces are doubled, {{ }}, so Python doesn't read them as gaps to fill.)

Selectors: picking elements out

The same selectors that style a page also find things in it, which is how scrapers and web tests work. Beautiful Soup's select takes a CSS selector and returns the matching elements:

from bs4 import BeautifulSoup

page = """<section class="deals">
  <article class="deal" data-sku="TE-101"><h2><a href="/shop/products/TE-101">Ridgeline 1-person tent</a></h2>
    <p class="price"><span class="now">$170.00</span> <s class="was">$200.00</s></p></article>
  <article class="deal sponsored" data-sku="CA-133"><h2><a href="/shop/products/CA-133">Ember cool box</a></h2>
    <p class="price"><span class="now">$145.00</span></p></article>
</section>"""
soup = BeautifulSoup(page, "html.parser")

print([a.get_text() for a in soup.select("article h2 a")])
print([s.get_text() for s in soup.select(".deal .now")])
print([a["data-sku"] for a in soup.select("article.sponsored")])
print([a["data-sku"] for a in soup.select("article:not(.sponsored)")])
print(soup.select_one("a[href$='TE-101']")["href"])
SelectorMatches
h2every h2 element
.priceevery element with class "price"
#catalogthe element with id "catalog"
article h2h2 elements anywhere inside an article
article.sponsoredarticles that also have class "sponsored"
a[href$='TE-101']links whose href ends with TE-101
:not(.sponsored)elements without that class

A space means "somewhere inside"; no space means "the same element".

Quick check
What does this print?
from bs4 import BeautifulSoup
soup = BeautifulSoup('<ul><li class="a">1</li><li class="a b">2</li><li class="b">3</li></ul>', "html.parser")
print(len(soup.select(".a")), len(soup.select(".a.b")), len(soup.select("ul .b")))
  1. 3 1 1
  2. 2 2 2
  3. 2 1 2
  4. 1 1 2
Show the answer

2 1 2

.a matches both items that have class a. .a.b needs both classes on one element: only the second. ul .b finds the two li elements with class b inside the list.

Try it now

A form is just a way to build a request. Once you can see the name=value pairs, you can send the same request from Python.

Escaping: text that looks like HTML

What if a product is called Tent <XL> & "Pro"? Put that straight into a page and the browser reads <XL> as a tag. It vanishes, and the rest of the page may break. Worse, if the text came from a user, they could type <script> and run their own code on your page. That attack is called cross-site scripting, or XSS.

The fix is to escape text before it goes into HTML: replace the five characters that mean something there with entities, codes the browser shows as the plain character.

import html

name = 'Tent <XL> & "Pro"'
print(html.escape(name))
print(f"<td>{html.escape(name)}</td>")
print(html.unescape("Trail &amp; Tor&#39;s &quot;best&quot; &lt;3"))

html.escape turns < into &lt;, > into &gt;, & into &amp; and quotes into &quot; and &#x27;. The browser shows the original characters, and nothing is read as a tag. html.unescape goes the other way, which you'll need when an API sends you text full of &amp;.

The rule: escape every piece of text when you put it into HTML, unless you built that HTML yourself. Template engines like Flask's Jinja do it for you automatically. That's one big reason to use them once you're writing real web apps.

Drill

A review that can't break the page

Write review_html(author, text, stars) that returns <blockquote class="review"><p>TEXT</p><footer>AUTHOR, STARS/5</footer></blockquote>, with the author and text escaped.

import html

def review_html(author, text, stars):
    pass

print(review_html("Sam <admin>", "Great & light: \"5 stars\"", 5))
In the lesson, your code is checked as you write it.

Pages people can use

You don't need to be a designer to avoid the mistakes that make a page hard to use. A short checklist covers most of them:

  • One <h1>, then headings in order. Screen readers and search engines use headings to outline the page, so don't pick a heading level for its size.
  • Every image gets alt text that says what it shows, or alt="" if it's only decoration.
  • Every form field gets a <label>, linked by for and id, so clicking the label focuses the field and screen readers announce it.
  • Links say where they go: "Download the size chart", not "click here".
  • Text contrasts with its background, and nothing relies on color alone, like red for "sold out".
  • Phones: add <meta name="viewport" content="width=device-width, initial-scale=1"> and let the layout wrap.

For larger sites, CSS frameworks such as Bootstrap or Tailwind give you ready-made layouts and components that already handle small screens. They're worth learning once you build real web apps. For the pages a Python program generates, like reports, emails and simple app pages, plain HTML that follows this checklist goes a long way.

Worked example

Worked example: a page of deals, built and checked

Build a small deals page from a list of dictionaries, preview it, then read it back with Beautiful Soup to check it says what you meant.

import html
from bs4 import BeautifulSoup
from preview import preview

deals = [
    {"sku": "TE-101", "name": "Ridgeline 1-person tent", "now": 170.0, "was": 200.0},
    {"sku": "PA-108", "name": "Switchback daypack 18L", "now": 46.75, "was": 55.0},
    {"sku": "XX-001", "name": 'Mystery box <limited> & "rare"', "now": 30.0, "was": None},
]

def card(d):
    was = f' <s class="was">${d["was"]:.2f}</s>' if d["was"] else ""
    return (f'<article class="deal" data-sku="{html.escape(d["sku"])}">'
            f'<h2>{html.escape(d["name"])}</h2>'
            f'<p class="price"><span class="now">${d["now"]:.2f}</span>{was}</p></article>')

page = f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Deals</title>
<style>body {{ font-family: sans-serif; }} .deal {{ border: 1px solid #ddd; padding: 6px 12px; margin: 6px 0; }}
.was {{ color: #999; }} .now {{ font-weight: bold; color: #1a6b3c; }}</style></head>
<body><h1>This week's deals</h1>{"".join(card(d) for d in deals)}</body></html>"""
preview(page)

soup = BeautifulSoup(page, "html.parser")
print([a["data-sku"] for a in soup.select("article.deal")])
print(soup.select("article.deal h2")[2].get_text())
print(len(soup.select(".was")), "deals show an old price")
In the lesson you can change this and run it.

card builds one article per deal, escaping every piece of text, and adds the crossed-out old price only when there is one. The page joins the cards inside a full document with a little CSS. Reading the page back with Beautiful Soup is a quick self-check: three articles, the third product's name comes back exactly as written (quotes, angle brackets and all), and only two old prices appear. This build-then-parse habit is also how you'll test the pages your web apps send.

Practice what you just read

  • 5 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Start this lesson

Try a quiz question

Which selector matches price elements only inside articles with class "deal"?

  1. article.deal .price
  2. article .deal.price
  3. article, .deal, .price
  4. .price article.deal

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson