Collecting your own data: scraping and APIs
The situation: data nobody has
Until now, uy_ijara.csv was sitting there ready-made. In a real project the first question is different: where does the data come from?
The idea that Kaggle has everything is wrong. The Tashkent rental market, customer messages in Uzbek, sales at a local shop: none of it is on Kaggle. If you can collect that data, you have something your competitor doesn't.
This chapter shows how. But the rules come first.
The rules, BEFORE the code
You can't skip this section
Pulling data from the web isn't a technical question. It's a legal and ethical one. What follows are not suggestions, they are requirements:
- Read
robots.txt.site.uz/robots.txtis where the site owner writes what they allow. If it saysDisallow, you don't go there. - Read the terms of service (ToS). Many sites ban automated collection outright. If there's a ban, you stop, or you get written permission.
- Ask slowly. One request per second is the limit of politeness. 50 parallel requests is an attack, and that's how it looks to the site owner.
- Identify yourself. Put the project name and a way to contact you in the
User-Agent. Hiding signals bad intent. - Do NOT collect personal data. Phone numbers, names, addresses, emails are protected by law (in Uzbekistan too: the law "On personal data"). Your model doesn't need a phone number. If you don't collect it, you can't lose it, nobody can steal it from you, and nobody can take you to court over it.
- Don't touch data behind a login. If you need to log in, it isn't public data.
- If there's an API, you use the API. Scraping is the last resort, not the first.
A short test: "If the site owner saw my code, would they be upset?" If yes, don't do it.
import pandas as pd show(pd.DataFrame([ {"Route": "1. Official API", "Quality": "Best", "Note": "Clean structure, clear terms, doesn't break"}, {"Route": "2. Open data / export", "Quality": "Good", "Note": "stat.uz, data.gov.uz, the site's own CSV export"}, {"Route": "3. Ask for a partnership", "Quality": "Good", "Note": "Often works, just ask. Free and legal"}, {"Route": "4. Scraping", "Quality": "Last resort", "Note": "Breaks when the site changes; the legal liability is yours"}, ]))
| Route | Quality | Note |
|---|---|---|
| 1. Official API | Best | Clean structure, clear terms, doesn't break |
| 2. Open data / export | Good | stat.uz, data.gov.uz, the site's own CSV export |
| 3. Ask for a partnership | Good | Often works, just ask. Free and legal |
| 4. Scraping | Last resort | Breaks when the site changes; the legal liability is yours |
There is NO live site in this chapter
Every number in this book has to reproduce forever. If we scraped a live site, tomorrow it would change its design and the numbers in the book would turn into lies.
So the datasets/fixture/ folder holds saved pages. They copy the structure of a real listings site, dirt included.
Column names
The saved pages are an Uzbek listings site, so the HTML classes and the values in them are Uzbek: elon listing, sarlavha title, narx price, xona rooms, maydon area, tuman district, qavat floor, aloqa contact. Prices come in three forms: у.е. (Russian shorthand for "conventional units", meaning US dollars), so'm (Uzbek sum), and Kelishilgan ("negotiable", no number at all).
from pathlib import Path FIX = DATA / "fixture" for p in sorted(FIX.iterdir()): print(f" {p.name:<18} {p.stat().st_size:>6} bytes") print() raw_html = (FIX / "elonlar_1.html").read_text(encoding="utf-8") print("What the first listing block looks like:") start = raw_html.index('<div class="elon"') print(raw_html[start:start + 560])
api.json 36707 bytes
elonlar_1.html 13135 bytes
elonlar_10.html 12978 bytes
elonlar_2.html 13036 bytes
elonlar_3.html 12953 bytes
elonlar_4.html 13018 bytes
elonlar_5.html 12979 bytes
elonlar_6.html 13010 bytes
elonlar_7.html 12842 bytes
elonlar_8.html 13004 bytes
elonlar_9.html 12835 bytes
What the first listing block looks like:
<div class="elon" data-id="101">
<h3 class="sarlavha">Olmazor — 1 xonali kvartira & balkon</h3>
<div class="narx">10 625 000 so'm</div>
<div class="tafsilot">
<span class="xona">1 xona</span>
<span class="maydon">33.1 m²</span>
<span class="tuman">Olmazor</span>
<span class="qavat">11-qavat</span>
</div>
<div class="aloqa">Tel: +998 91 328-86-89</div>
</div>
<div class="elon" data-id="102">
<h3 class="sarlavha">4 xonali kvartira, Bektemir</h3>
<div class="narx">12
Reading HTML: BeautifulSoup
from bs4 import BeautifulSoup soup = BeautifulSoup(raw_html, "html.parser") listings = soup.select("div.elon") print(f"Listings found on page 1: {len(listings)}") print() first = listings[0] print(f" data-id : {first['data-id']}") print(f" title : {first.select_one('h3.sarlavha').get_text(strip=True)}") print(f" price : {first.select_one('div.narx').get_text(strip=True)}") print(f" district : {first.select_one('span.tuman').get_text(strip=True)}") rooms = first.select_one("span.xona") print(f" rooms : {rooms.get_text(strip=True) if rooms else 'MISSING'}")
Listings found on page 1: 30 data-id : 101 title : Olmazor — 1 xonali kvartira & balkon price : 10 625 000 so'm district : Olmazor rooms : 1 xona
select_one can always return None
The if rooms else part above isn't there by accident. On real pages a field being missing is normal.
If you write first.select_one("span.xona").get_text(), the first incomplete listing gives you AttributeError: 'NoneType' object has no attribute 'get_text', and a 3-hour collection run stops.
The rule: every select_one result gets checked. That isn't paranoia. It's the form that Chapter 5's "don't trust external data" rule takes here.
Writing the collector
Now a real file. It stays in the ish_en/collect/ folder and you'll use it later too.
create this filecollect/rules.py
"""Rules checked BEFORE collecting.""" import urllib.parse import urllib.request import urllib.robotparser AGENT = "MLBookStudent/1.0 (+study project; contact: student@example.uz)" def is_allowed(url, agent=AGENT): """Whether robots.txt allows fetching this URL. Note: robots.txt allowing it does NOT mean the ToS allows it. A person has to read the terms, not code. """ parts = urllib.parse.urlparse(url) rp = urllib.robotparser.RobotFileParser() rp.set_url(f"{parts.scheme}://{parts.netloc}/robots.txt") try: rp.read() except Exception: return False # if robots.txt can't be read, we treat it as NOT ALLOWED return rp.can_fetch(agent, url) PERSONAL = {"tel", "telefon", "phone", "ism", "name", "email", "manzil", "address"} def drop_personal(record): """Removes personal fields from a record. Called at the START of collection, not the END: data that was never written to disk can't be stolen. """ return {k: v for k, v in record.items() if k.lower() not in PERSONAL}
create this filecollect/fetch.py
"""A slow, forgiving, caching downloader.""" import hashlib import time import urllib.request from pathlib import Path CACHE = Path("cache") DELAY = 1.0 # one request per second ATTEMPTS = 3 def _cache_path(url): return CACHE / (hashlib.sha256(url.encode()).hexdigest()[:16] + ".html") def fetch(url, agent="MLBookStudent/1.0", force=False): """Returns the text at a URL. Downloaded once, then served from the cache. The cache is the core of politeness: even if you run your code 50 times, the site gets one request. """ CACHE.mkdir(exist_ok=True) path = _cache_path(url) if path.exists() and not force: return path.read_text(encoding="utf-8"), "cache" last_error = None for attempt in range(1, ATTEMPTS + 1): try: request = urllib.request.Request(url, headers={"User-Agent": agent}) with urllib.request.urlopen(request, timeout=20) as response: text = response.read().decode("utf-8", errors="replace") path.write_text(text, encoding="utf-8") time.sleep(DELAY) return text, "network" except Exception as x: last_error = x time.sleep(DELAY * 2 ** attempt) # exponential backoff raise RuntimeError(f"not fetched after {ATTEMPTS} attempts: {url}") from last_error
create this filecollect/parse.py
"""HTML -> records. NO cleaning at all: stored raw.""" from bs4 import BeautifulSoup from .rules import drop_personal def text(block, selector): """Returns None if missing, not an AttributeError.""" found = block.select_one(selector) return found.get_text(strip=True) if found else None def parse_page(html_text): soup = BeautifulSoup(html_text, "html.parser") result = [] for block in soup.select("div.elon"): record = { "source_id": block.get("data-id"), "title": text(block, "h3.sarlavha"), "price_raw": text(block, "div.narx"), "rooms_raw": text(block, "span.xona"), "area_raw": text(block, "span.maydon"), "district": text(block, "span.tuman"), "floor_raw": text(block, "span.qavat"), "phone": text(block, "div.aloqa"), # collected, but NOT stored } result.append(drop_personal(record)) return result
import sys sys.path.insert(0, str(ISH)) from collect.parse import parse_page pages = sorted(FIX.glob("elonlar_*.html")) records = [] for page_path in pages: this = parse_page(page_path.read_text(encoding="utf-8")) records += this if page_path is pages[0] or page_path is pages[-1]: print(f" {page_path.name} -> {len(this)} records") print(f" ... {len(pages)} pages read in total") raw_df = pd.DataFrame(records) print(f"\nTotal: {len(raw_df)} records, {len(raw_df.columns)} columns") print(f"Columns: {list(raw_df.columns)}") print() print(f"Is there a phone column? {'phone' in raw_df.columns} <- drop_personal() worked") show(raw_df.head(6))
elonlar_1.html -> 30 records elonlar_9.html -> 30 records ... 10 pages read in total Total: 300 records, 7 columns Columns: ['source_id', 'title', 'price_raw', 'rooms_raw', 'area_raw', 'district', 'floor_raw'] Is there a phone column? False <- drop_personal() worked
| source_id | title | price_raw | rooms_raw | area_raw | district | floor_raw |
|---|---|---|---|---|---|---|
| 101 | Olmazor — 1 xonali kvartira & balkon | 10 625 000 so'm | 1 xona | 33.1 m² | Olmazor | 11-qavat |
| 102 | 4 xonali kvartira, Bektemir | 12 500 000 so'm | 4 xona | 81.3 m² | Bektemir | 2-qavat |
| 103 | 2 xonali kvartira, Sergeli | 500 у.е. | 2 xona | 45.5 m² | Sergeli | 7-qavat |
| 104 | Yakkasaroy — 5 xonali kvartira & balkon | 500 у.е. | 5 xona | 94.5 m² | Yakkasaroy | 6-qavat |
| 105 | Bektemir — 5 xonali kvartira & balkon | 10 625 000 so'm | 5 xona | 102.8 m² | Bektemir | 5-qavat |
| 106 | 1 xonali, remont qilingan | Kelishilgan | 1 xona | 37.8 m² | Chilonzor | 8-qavat |
Page 3 has broken HTML: did the parser stop?
broken = (FIX / "elonlar_3.html").read_text(encoding="utf-8") print(f"Page 3 has an unclosed <div>: {broken.count('<div')} opened, {broken.count('</div>')} closed") print(f"Listings found anyway: {len(parse_page(broken))}") print() print("`html.parser` forgives broken HTML, like a browser does.") print("That's why collection doesn't use an XML parser (`xml`, `lxml-xml`):") print("it's strict and stops at the first error.")
Page 3 has an unclosed <div>: 122 opened, 121 closed Listings found anyway: 30 `html.parser` forgives broken HTML, like a browser does. That's why collection doesn't use an XML parser (`xml`, `lxml-xml`): it's strict and stops at the first error.
How dirty is the collected data
table = [] for column in ["title", "price_raw", "rooms_raw", "area_raw", "district", "floor_raw"]: missing = int(raw_df[column].isna().sum()) table.append({"column": column, "missing": missing, "missing %": f"{missing / len(raw_df):.1%}", "unique values": int(raw_df[column].nunique())}) show(pd.DataFrame(table))
| column | missing | missing % | unique values |
|---|---|---|---|
| title | 0 | 0.0% | 98 |
| price_raw | 0 | 0.0% | 21 |
| rooms_raw | 26 | 8.7% | 5 |
| area_raw | 35 | 11.7% | 194 |
| district | 0 | 0.0% | 8 |
| floor_raw | 0 | 0.0% | 12 |
print("The price column: how many different formats?") for n in raw_df["price_raw"].dropna().unique()[:8]: print(f" {n!r}") print() usd = raw_df["price_raw"].str.contains("у.е.", na=False).sum() som = raw_df["price_raw"].str.contains("so'm", na=False).sum() neg = raw_df["price_raw"].str.contains("Kelishilgan", na=False).sum() print(f" as у.е. : {usd}") print(f" as so'm : {som}") print(f" 'Kelishilgan' : {neg} <- NO number, but the row is there") print() print("Three formats need three kinds of processing. That's Chapter 38's job.")
The price column: how many different formats? "10 625 000 so'm" "12 500 000 so'm" '500\xa0у.е.' 'Kelishilgan' '450\xa0у.е.' "8 750 000 so'm" '700\xa0у.е.' '400\xa0у.е.' as у.е. : 173 as so'm : 81 'Kelishilgan' : 46 <- NO number, but the row is there Three formats need three kinds of processing. That's Chapter 38's job.
variants = [ ("source_id (id the site gave)", ["source_id"]), ("title + district + area + price", ["title", "district", "area_raw", "price_raw"]), ("district + area + rooms", ["district", "area_raw", "rooms_raw"]), ] results = [] for name, key in variants: kept = len(raw_df.drop_duplicates(subset=key)) results.append({"duplicate key": name, "unique kept": kept, "removed": len(raw_df) - kept}) show(pd.DataFrame(results)) print(f"Total records: {len(raw_df)}")
Total records: 300
| duplicate key | unique kept | removed |
|---|---|---|
| source_id (id the site gave) | 300 | 0 |
| title + district + area + price | 291 | 9 |
| district + area + rooms | 245 | 55 |
Three keys, three completely different answers. Which one is right?
repeated = raw_df["area_raw"].value_counts() extra = int((repeated[repeated > 1] - 1).sum()) print(f"Area values seen more than once: {int((repeated > 1).sum())}") print(f"Actual number of extra records : {extra}") print() print("Area is a PHYSICAL property of the flat. If two listings have exactly") print("the same area, it almost always means one flat was listed twice.")
Area values seen more than once: 48 Actual number of extra records : 71 Area is a PHYSICAL property of the flat. If two listings have exactly the same area, it almost always means one flat was listed twice.
Putting the title or price into the duplicate key is a mistake
The table above rejected my first recommendation. I had written in this section: "detect duplicates by content: district + area + price + title". The measurement showed that key misses most of the real duplicates.
The reason: when a flat is re-listed, the owner writes the title differently ("Urgent!", "renovated"), and shows the price in another format (у.е. or so'm). So the title and the price are unstable fields.
district + area + rooms, on the other hand, are physical properties of the flat. They don't change when it's re-listed.
Three lessons:
- Deduplicating on
source_idis meaningless. Every id is unique, nothing gets removed, and you believe your data is clean. Then Chapter 6's problem arrives: the same flat lands in both train and test, which is leakage. - Only STABLE fields go into a duplicate key. Anything whose formatting can vary is kept out of the key.
- That's why deduplication happens AFTER cleaning. Once the price has been turned into a number, it can go into the key. The order: parse -> normalize -> deduplicate -> split. That's Chapter 38's job.
If there's an API, it's all easy
Column names
The saved API response is Uzbek too: jami total, sahifa_hajmi page size, natijalar results, and in each result narx_usd price in USD and maydon_m2 area in m².
import json api = json.loads((FIX / "api.json").read_text(encoding="utf-8")) print(f"API response keys: {list(api.keys())}") print(f"Total records: {api['jami']}, page size: {api['sahifa_hajmi']}") print() print("First record:") for k, v in api["natijalar"][0].items(): print(f" {k:<12} {v!r} ({type(v).__name__})") print() api_df = pd.DataFrame(api["natijalar"]) print(f"DataFrame: {api_df.shape}") print(f"Missing values: {int(api_df.isna().sum().sum())}") print(f"Types are right: {dict(api_df.dtypes.astype(str))}")
API response keys: ['jami', 'sahifa_hajmi', 'natijalar']
Total records: 300, page size: 10
First record:
id 101 (int)
xonalar 1 (int)
tuman 'Olmazor' (str)
maydon_m2 33.1 (float)
narx_usd 850 (int)
qavat 11 (int)
DataFrame: (300, 6)
Missing values: 0
Types are right: {'id': 'int64', 'xonalar': 'int64', 'tuman': 'object', 'maydon_m2': 'float64', 'narx_usd': 'int64', 'qavat': 'int64'}
See the difference: in the API, narx_usd is a number and maydon_m2 is a number. In the HTML both were text, in three formats.
show(pd.DataFrame([ {"Aspect": "Price", "HTML scraping": "3 text formats", "API": "number, one unit"}, {"Aspect": "Missing fields", "HTML scraping": "yes", "API": "no"}, {"Aspect": "Duplicates", "HTML scraping": "yes", "API": "clean by id"}, {"Aspect": "Site redesign", "HTML scraping": "code breaks", "API": "keeps working (versioned)"}, {"Aspect": "Terms", "HTML scraping": "unclear", "API": "written down"}, {"Aspect": "Speed", "HTML scraping": "slow (HTML is big)", "API": "fast (data only)"}, ])) print() print("So the rule: if there's an API, you don't spend time writing a scraper.")
So the rule: if there's an API, you don't spend time writing a scraper.
| Aspect | HTML scraping | API |
|---|---|---|
| Price | 3 text formats | number, one unit |
| Missing fields | yes | no |
| Duplicates | yes | clean by id |
| Site redesign | code breaks | keeps working (versioned) |
| Terms | unclear | written down |
| Speed | slow (HTML is big) | fast (data only) |
Pagination and request limits
create this filecollect/api_client.py
"""Fetching every record from a paginated API, respecting the limits.""" import json import time from pathlib import Path def all_pages(fetch_page, page_size=10, max_pages=100, delay=1.0): """`fetch_page(offset, limit)` -> dict. Collects every record. max_pages is a safeguard: if the API keeps saying 'there's a next page' forever, the code must not loop forever. """ everything, offset = [], 0 for _ in range(max_pages): response = fetch_page(offset, page_size) chunk = response.get("natijalar", []) if not chunk: break everything += chunk offset += len(chunk) if offset >= response.get("jami", 0): break time.sleep(delay) return everything
from collect.api_client import all_pages requests = {"count": 0} def local_api(offset, limit): """Imitates a real API: no network, but with pagination.""" requests["count"] += 1 return {"jami": api["jami"], "natijalar": api["natijalar"][offset:offset + limit]} fetched = all_pages(local_api, page_size=10, delay=0) print(f"Requests : {requests['count']}") print(f"Records fetched: {len(fetched)}") print(f"Total in API : {api['jami']}") print(f"Got everything : {len(fetched) == api['jami']}")
Requests : 30 Records fetched: 300 Total in API : 300 Got everything : True
Save the raw data, then parse it
The most common collection mistake: download the HTML, parse it immediately, and keep only the result.
Two weeks later you realize "I needed the floor too", and you have to download everything again. By then the site has already changed.
The right order:
1. Write the raw HTML to disk (the cache/ folder; fetch.py above does this)
2. Run the parsing separately
3. If you need a new field, re-parse from the cache, without going to the network
Raw data is cheap. Collecting it again is expensive, and sometimes impossible.
Saving the result
RAW = ISH / "raw" RAW.mkdir(exist_ok=True) path = RAW / "listings_raw.csv" raw_df.to_csv(path, index=False) print(f"Saved: ish_en/raw/{path.name}") print(f" {len(raw_df)} rows, {len(raw_df.columns)} columns") print(f" size: {path.stat().st_size} bytes") print() print("Note: this file is DELIBERATELY dirty. Cleaning is Chapter 38's job.") print("Collecting and cleaning are separate steps: collecting takes the raw data, cleaning makes the decisions.")
Saved: ish_en/raw/listings_raw.csv 300 rows, 7 columns size: 24470 bytes Note: this file is DELIBERATELY dirty. Cleaning is Chapter 38's job. Collecting and cleaning are separate steps: collecting takes the raw data, cleaning makes the decisions.
Connecting to the protocol
The 12-step protocol in Chapter 1 starts with "understand the data". This chapter is the step before that: creating the data.
show(pd.DataFrame([ {"Step": "0. Check the rules", "Result": "robots.txt, ToS, decision on personal data"}, {"Step": "1. Pick the source", "Result": "API > export > partnership > scraping"}, {"Step": "2. Collect and keep the raw data", "Result": "cache/ folder, downloaded once"}, {"Step": "3. Parse", "Result": "raw/listings_raw.csv, not cleaned"}, {"Step": "4. Build the dataset", "Result": "Chapter 38: cleaning, dedup, split"}, ]))
| Step | Result |
|---|---|
| 0. Check the rules | robots.txt, ToS, decision on personal data |
| 1. Pick the source | API > export > partnership > scraping |
| 2. Collect and keep the raw data | cache/ folder, downloaded once |
| 3. Parse | raw/listings_raw.csv, not cleaned |
| 4. Build the dataset | Chapter 38: cleaning, dedup, split |
What we learned today
show(pd.DataFrame([ {"Concept": "Rules before code", "In one sentence": "robots.txt, ToS, ask slowly, don't collect personal data"}, {"Concept": "Source order", "In one sentence": "API > export > ask for a partnership > scraping"}, {"Concept": "select_one returns None", "In one sentence": "Check every result, or collection stops"}, {"Concept": "html.parser", "In one sentence": "Forgives broken HTML; no XML parser"}, {"Concept": "Cache", "In one sentence": "Downloaded once: politeness and speed"}, {"Concept": "Unique ids deceive", "In one sentence": "Measured: different id, same content"}, {"Concept": "Personal data", "In one sentence": "Data never written can't be stolen"}, {"Concept": "Keep the raw data", "In one sentence": "Re-collecting is expensive, disk is cheap"}, ]))
| Concept | In one sentence |
|---|---|
| Rules before code | robots.txt, ToS, ask slowly, don't collect personal data |
| Source order | API > export > ask for a partnership > scraping |
| select_one returns None | Check every result, or collection stops |
| html.parser | Forgives broken HTML; no XML parser |
| Cache | Downloaded once: politeness and speed |
| Unique ids deceive | Measured: different id, same content |
| Personal data | Data never written can't be stolen |
| Keep the raw data | Re-collecting is expensive, disk is cheap |
In the next chapter we turn this dirty file into a dataset: we unify the price formats, remove the duplicates, and do the split properly.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.