Web Page Scraper with Notifications
Abstract
Section titled “Abstract”Some sites change rarely but matter when they do — a course catalog opens new spots, a product comes back in stock, a status page goes red, a government form gets a new revision. In this project you build an automated watcher that fetches a URL on a schedule, compares the content to the last fetch using an MD5 hash, and pings you when something changes through one or more channels: desktop notification, email, Slack webhook, or Discord webhook.
You will learn:
- How to detect change cheaply with content hashing.
- How to scope monitoring to a single CSS selector instead of the whole page.
- How to send notifications via four different channels.
- How to schedule recurring jobs with the
schedulelibrary. - How to design a polite watcher that does not get banned.
Prerequisites
Section titled “Prerequisites”- Python 3.6 or above.
- A code editor or IDE.
- An internet connection.
- (Optional) An SMTP account (Gmail app password works) and/or a Slack/Discord webhook URL.
Install Dependencies
Section titled “Install Dependencies”pip install requests beautifulsoup4 schedule plyerrequests— fetch the page.beautifulsoup4— extract content from HTML.schedule— run a job every N minutes.plyer— cross-platform desktop notifications.
flowchart TD
n0(["script start"])
n2["main()"]
subgraph WebPageMonitor
n115["check_for_changes()"]
n116["get_content_hash()"]
n117["get_page_content()"]
n118["send_desktop_notification()"]
n119["send_notification_email()"]
n120["start_monitoring()"]
end
n115 --> n116
n115 --> n117
n115 --> n118
n115 --> n119
n120 --> n115
n0 --> n2
n2 --> n120
Getting Started
Section titled “Getting Started”Create the project
Section titled “Create the project”- Create folder
webpage-monitor. - Inside, create
webpagescrapernotifications.py.
Write the code
Section titled “Write the code”Web Page Monitor
pch.viewSource# Web Page Scraper with Notifications
import requests
from bs4 import BeautifulSoup
import time
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import schedule
import hashlib
class WebPageMonitor:
def __init__(self, url, email_config=None):
self.url = url
self.email_config = email_config
self.previous_content = None
def get_page_content(self):
"""Fetch and parse web page content"""
try:
response = requests.get(self.url)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
# Extract text content (you can modify this to target specific elements)
content = soup.get_text().strip()
return content
except requests.RequestException as e:
print(f"Error fetching page: {e}")
return None
def get_content_hash(self, content):
"""Generate hash of content for comparison"""
return hashlib.md5(content.encode()).hexdigest()
def send_notification_email(self, subject, message):
"""Send email notification when changes are detected"""
if not self.email_config:
print("Email configuration not provided")
return
try:
msg = MIMEMultipart()
msg['From'] = self.email_config['from_email']
msg['To'] = self.email_config['to_email']
msg['Subject'] = subject
msg.attach(MIMEText(message, 'plain'))
server = smtplib.SMTP(self.email_config['smtp_server'], self.email_config['smtp_port'])
server.starttls()
server.login(self.email_config['from_email'], self.email_config['password'])
text = msg.as_string()
server.sendmail(self.email_config['from_email'], self.email_config['to_email'], text)
server.quit()
print("Notification email sent successfully!")
except Exception as e:
print(f"Error sending email: {e}")
def send_desktop_notification(self, title, message):
"""Send desktop notification (cross-platform)"""
try:
import plyer
plyer.notification.notify(
title=title,
message=message,
timeout=10
)
except ImportError:
print("Desktop notifications require 'plyer' package")
print(f"Notification: {title} - {message}")
def check_for_changes(self):
"""Check if page content has changed"""
print(f"Checking {self.url} for changes...")
current_content = self.get_page_content()
if current_content is None:
return
current_hash = self.get_content_hash(current_content)
if self.previous_content is None:
self.previous_content = current_hash
print("Initial content captured. Monitoring for changes...")
return
if current_hash != self.previous_content:
print("Content change detected!")
# Send notifications
subject = f"Web Page Change Detected: {self.url}"
message = f"The webpage {self.url} has been updated!\n\nCheck it out: {self.url}"
self.send_desktop_notification("Page Updated!", f"Changes detected on {self.url}")
if self.email_config:
self.send_notification_email(subject, message)
self.previous_content = current_hash
else:
print("No changes detected.")
def start_monitoring(self, interval_minutes=30):
"""Start monitoring the webpage at specified intervals"""
print(f"Starting to monitor {self.url} every {interval_minutes} minutes...")
# Schedule the check
schedule.every(interval_minutes).minutes.do(self.check_for_changes)
# Initial check
self.check_for_changes()
# Keep the script running
while True:
schedule.run_pending()
time.sleep(1)
def main():
# Example usage
url_to_monitor = "https://example.com"
# Optional email configuration
email_config = {
'from_email': 'your_email@gmail.com',
'to_email': 'recipient@gmail.com',
'password': 'your_app_password',
'smtp_server': 'smtp.gmail.com',
'smtp_port': 587
}
# Create monitor instance (set email_config to None to disable email notifications)
monitor = WebPageMonitor(url_to_monitor, email_config=None)
# Start monitoring (checks every 30 minutes)
try:
monitor.start_monitoring(interval_minutes=1) # Check every minute for demo
except KeyboardInterrupt:
print("\nMonitoring stopped by user.")
if __name__ == "__main__":
main() Run it
Section titled “Run it”python webpagescrapernotifications.pyThe script polls the target URL every 30 minutes (by default), hashes the content, and notifies you when the hash changes from the previous run.
Step-by-Step Explanation
Section titled “Step-by-Step Explanation”1. Class skeleton
Section titled “1. Class skeleton”import hashlib, time, smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import requests
from bs4 import BeautifulSoup
import schedule
class WebPageMonitor:
def __init__(self, url, selector=None, email_config=None):
self.url = url
self.selector = selector # optional CSS selector to scope changes
self.email_config = email_config
self.previous_hash = None2. Fetch and extract content
Section titled “2. Fetch and extract content”def get_page_content(self):
response = requests.get(self.url, timeout=15, headers={
"User-Agent": "WebPageMonitor/1.0 (+you@example.com)"
})
response.raise_for_status()
soup = BeautifulSoup(response.content, "html.parser")
if self.selector:
node = soup.select_one(self.selector)
if node is None:
raise ValueError(f"Selector '{self.selector}' not found")
return node.get_text(separator=" ", strip=True)
return soup.get_text(separator=" ", strip=True)- A custom
User-Agentidentifies the bot — many sites block default Python requests. selectorlets you watch only the relevant part of the page. Without it, every footer copyright year change triggers an alert.
3. Hash to detect change
Section titled “3. Hash to detect change”def get_hash(self, content):
return hashlib.md5(content.encode("utf-8")).hexdigest()MD5 is not secure for cryptography but is perfect for change detection — it is fast and the input is not adversarial.
4. The check
Section titled “4. The check”def check_for_changes(self):
try:
content = self.get_page_content()
except Exception as e:
print(f"[error] {e}")
return
current_hash = self.get_hash(content)
if self.previous_hash is None:
self.previous_hash = current_hash
print("First check — baseline recorded.")
return
if current_hash != self.previous_hash:
print("Change detected!")
self.notify("Page changed", f"Content hash changed for {self.url}")
self.previous_hash = current_hash
else:
print("No change.")Notice we do not alert on the first check — that would always fire on startup. We only alert on transitions.
5. Email notification
Section titled “5. Email notification”def send_email(self, subject, body):
cfg = self.email_config
msg = MIMEMultipart()
msg["From"] = cfg["from_email"]
msg["To"] = cfg["to_email"]
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))
with smtplib.SMTP(cfg["smtp_server"], cfg["smtp_port"]) as server:
server.starttls()
server.login(cfg["from_email"], cfg["password"])
server.send_message(msg)For Gmail:
smtp_server = "smtp.gmail.com",smtp_port = 587.- Use an App Password (Google Account → Security → 2-Step Verification → App passwords), not your real Gmail password.
6. Desktop notification
Section titled “6. Desktop notification”def send_desktop(self, title, message):
from plyer import notification
notification.notify(title=title, message=message, timeout=10)Works on Windows, macOS, and many Linux desktops via notify-send.
7. Schedule the job
Section titled “7. Schedule the job”def start(self, interval_minutes=30):
schedule.every(interval_minutes).minutes.do(self.check_for_changes)
print(f"Monitoring {self.url} every {interval_minutes} minutes. Ctrl+C to stop.")
while True:
schedule.run_pending()
time.sleep(1)schedule.every().X.minutes.do(fn) registers a recurring job. The while True loop is the “tick” — it wakes up every second to see if any jobs are due.
Webhook Notifications (Slack / Discord)
Section titled “Webhook Notifications (Slack / Discord)”Webhooks are the easiest cross-team alert channel. Create one in your workspace settings, then:
def send_slack(self, text):
requests.post(self.slack_webhook_url, json={"text": text}, timeout=10)
def send_discord(self, text):
requests.post(self.discord_webhook_url, json={"content": text}, timeout=10)A single line carries the alert — no SMTP setup, no app passwords.
Smarter Change Detection
Section titled “Smarter Change Detection”Plain hashes flag any change — even a refreshed CSRF token. Strategies to reduce noise:
- Scope with a CSS selector. Watch only
#main-content, not the whole page. - Strip noisy elements before hashing:
strip.py for tag in soup.select("script, style, .ads, .timestamp"): tag.decompose() - Diff instead of hash. When the hash changes, compute a unified diff to show what changed:
diff.py import difflib diff = "\n".join(difflib.unified_diff( old.splitlines(), new.splitlines(), lineterm=""))[:1500] self.notify("Page changed", diff) - Threshold-based. Ignore changes under N characters using
difflib.SequenceMatcher.ratio().
Polite Scraping Checklist
Section titled “Polite Scraping Checklist”Before pointing this at a production site:
- Respect
robots.txt. Useurllib.robotparserto check before fetching. - Set a real
User-Agentincluding contact info. - Use a reasonable interval. 30 minutes is friendly; 30 seconds is hostile.
- Honor rate limits. Back off on 429 responses.
- Read the Terms of Service. Some sites explicitly forbid automated monitoring.
- Stop immediately if asked.
A monitor that hammers a small site at 1 Hz can take it offline. Be a good citizen.
Common Mistakes
Section titled “Common Mistakes”| Problem | Cause | Fix |
|---|---|---|
| Always fires on startup | Compared against None | Skip the first iteration; record baseline only |
| Email login fails | Used real password on Gmail | Generate an App Password |
| 403 Forbidden | Default Python User-Agent | Set a real header |
| Notifications fire constantly | Hashing the whole page including timestamps | Scope with a selector or strip dynamic elements |
| Crash freezes loop | Network error inside the job | Wrap the job body in try/except |
| Process killed when terminal closes | Run as a foreground process | Use nohup, tmux, systemd, or run as a Windows service |
Variations to Try
Section titled “Variations to Try”1. Multiple URLs at once
Section titled “1. Multiple URLs at once”sites = [WebPageMonitor(u) for u in URL_LIST]
for s in sites:
schedule.every(30).minutes.do(s.check_for_changes)2. Track specific values
Section titled “2. Track specific values”Use a regex to extract a number (price, stock count) and only alert if it crosses a threshold:
import re
price = float(re.search(r"\$([0-9.]+)", content).group(1))
if price < TARGET_PRICE:
self.notify("Price drop!", f"Now ${price}")3. Screenshot diff
Section titled “3. Screenshot diff”Use Playwright to render the page and save a screenshot. Compute pixel difference with Pillow.
4. Web dashboard
Section titled “4. Web dashboard”Pair with Flask (see Basic Web Server) — a page that lists all monitored URLs, last-check time, and recent history.
5. SQLite history
Section titled “5. SQLite history”Persist every check (timestamp, hash, change?) for later analysis.
6. Pushover / Pushbullet / Telegram
Section titled “6. Pushover / Pushbullet / Telegram”Three more notification channels with simple APIs.
7. Headless browser
Section titled “7. Headless browser”Some sites are JavaScript-rendered. Replace requests with Playwright so you scrape the rendered DOM.
8. RSS-aware mode
Section titled “8. RSS-aware mode”If the site has an RSS feed (see RSS Feed Reader), use that instead of scraping — far more reliable.
9. Selector tester
Section titled “9. Selector tester”A small CLI that lets you try CSS selectors against a URL interactively before wiring them into the monitor.
10. Docker container
Section titled “10. Docker container”Pack the script in a Dockerfile and deploy to a tiny VPS so it runs 24/7 without keeping your laptop on.
Privacy & Operational Notes
Section titled “Privacy & Operational Notes”- Do not put secrets in source. Read SMTP passwords and webhook URLs from environment variables:
os.environ["SMTP_PASSWORD"]. - Add
*.json,.env,credentials.*to.gitignore. - Log carefully. Avoid printing full page content to disk when debugging is over.
- Rotate webhook URLs if you suspect leaks.
Real-World Applications
Section titled “Real-World Applications”- In-stock alerts — bicycles, GPUs, concert tickets.
- Status pages — pre-empt outages by watching
/status. - Job boards — be first to apply to new listings.
- Course availability — universities, training platforms.
- Government forms — get notified when a deadline or form changes.
- Pricing intelligence — track competitor prices (within Terms of Service).
Educational Value
Section titled “Educational Value”- HTTP & HTML parsing — fetch and extract relevant text.
- Hashing — fingerprinting for cheap comparison.
- Scheduling — moving from a one-shot script to a long-running service.
- Notifications — multiple channels, each with different trade-offs.
- Defensive coding — failures must not kill the loop.
- Operational thinking — running a real background process, not just a script.
Next Steps
Section titled “Next Steps”- Add CSS selector scoping to reduce noise.
- Wire up Slack/Discord webhooks alongside email.
- Persist history to SQLite for analysis.
- Containerize with Docker and deploy to a cheap VPS.
- Pair with Email Sender for richer email alerts.
- Extend with diff snippets in the notification body.
Conclusion
Section titled “Conclusion”You built a real automation tool: it watches the web for you, sleeps politely between checks, alerts you through whichever channel you prefer, and recovers gracefully from network hiccups. Hash-based change detection, scheduling, and webhook notifications show up in nearly every monitoring system in production. Full source on GitHub. Explore more automation projects on Python Central Hub.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading