from dataclasses import dataclass from typing import Optional import json from db import StoredJob from scrapers.base import Job @dataclass class ChangeReport: """Report of changes detected during a scrape.""" company_name: str new_jobs: list[Job] removed_jobs: list[StoredJob] total_active: int class Notifier: """Handles notifications for job changes.""" def __init__(self, config: dict): self.config = config def notify(self, reports: list[ChangeReport]): """Send notifications for all changes.""" # Filter to only reports with changes reports_with_changes = [r for r in reports if r.new_jobs or r.removed_jobs] if not reports_with_changes: print("\nāœ“ No changes detected across all companies.") return # Console output (always) self._notify_console(reports_with_changes) # Email (if configured) email_config = self.config.get("email") if email_config: self._notify_email(reports_with_changes, email_config) # msmtp (if configured - uses system msmtp config) msmtp_config = self.config.get("msmtp") if msmtp_config: self._notify_msmtp(reports_with_changes, msmtp_config) # Slack (if configured) slack_config = self.config.get("slack") if slack_config: self._notify_slack(reports_with_changes, slack_config) def _notify_console(self, reports: list[ChangeReport]): """Print changes to console.""" print("\n" + "=" * 60) print("JOB CHANGES DETECTED") print("=" * 60) total_new = sum(len(r.new_jobs) for r in reports) total_removed = sum(len(r.removed_jobs) for r in reports) print(f"\nSummary: {total_new} new jobs, {total_removed} removed jobs\n") for report in reports: print(f"\nšŸ“Œ {report.company_name} ({report.total_active} active jobs)") print("-" * 40) if report.new_jobs: print(f"\n šŸ†• NEW JOBS ({len(report.new_jobs)}):") for job in report.new_jobs: location_str = f" [{job.location}]" if job.location else "" remote_str = f" šŸ " if job.remote_type == "remote" else "" print(f" • {job.title}{location_str}{remote_str}") print(f" {job.url}") if report.removed_jobs: print(f"\n āŒ REMOVED JOBS ({len(report.removed_jobs)}):") for job in report.removed_jobs: print(f" • {job.title}") print("\n" + "=" * 60) def _notify_email(self, reports: list[ChangeReport], config: dict): """Send email notification.""" import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart # Build email body body = self._build_html_report(reports) msg = MIMEMultipart("alternative") msg["Subject"] = f"Job Alert: {sum(len(r.new_jobs) for r in reports)} new positions" msg["From"] = config["from_addr"] msg["To"] = config["to_addr"] msg.attach(MIMEText(body, "html")) try: with smtplib.SMTP(config["smtp_host"], config["smtp_port"]) as server: server.starttls() server.login(config["username"], config["password"]) server.send_message(msg) print("āœ“ Email notification sent") except Exception as e: print(f"āœ— Failed to send email: {e}") def _notify_msmtp(self, reports: list[ChangeReport], config: dict): """Send email notification via system msmtp.""" import subprocess from datetime import datetime to_addr = config.get("to_addr", "me@bastiangruber.ca") from_addr = config.get("from_addr", "admin@novanexus.ca") total_new = sum(len(r.new_jobs) for r in reports) total_removed = sum(len(r.removed_jobs) for r in reports) # Build subject line parts = [] if total_new: parts.append(f"+{total_new} new") if total_removed: parts.append(f"-{total_removed} removed") subject = f"Job Board Update: {', '.join(parts)}" # Build plain text body body_lines = [ "JOB BOARD CHANGES", f"{datetime.now().strftime('%Y-%m-%d %H:%M')}", "", f"Summary: {total_new} new jobs, {total_removed} removed jobs", "", ] for report in reports: body_lines.append(f"{report.company_name} ({report.total_active} active)") body_lines.append("-" * 40) if report.new_jobs: body_lines.append(f" NEW ({len(report.new_jobs)}):") for job in report.new_jobs: location_str = f" [{job.location}]" if job.location else "" remote_str = " (Remote)" if job.remote_type == "remote" else "" body_lines.append(f" + {job.title}{location_str}{remote_str}") body_lines.append(f" {job.url}") if report.removed_jobs: body_lines.append(f" REMOVED ({len(report.removed_jobs)}):") for job in report.removed_jobs: body_lines.append(f" - {job.title}") body_lines.append("") body_lines.append("---") body_lines.append("Generated by job-scraper") body = "\n".join(body_lines) # Build email message email_msg = f"""Subject: {subject} From: {from_addr} To: {to_addr} Content-Type: text/plain; charset=UTF-8 {body} """ try: result = subprocess.run( ["msmtp", to_addr], input=email_msg, capture_output=True, text=True, ) if result.returncode == 0: print("āœ“ msmtp notification sent") else: print(f"āœ— msmtp failed: {result.stderr}") except FileNotFoundError: print("āœ— msmtp not found - install with: apt install msmtp") except Exception as e: print(f"āœ— Failed to send msmtp notification: {e}") def _notify_slack(self, reports: list[ChangeReport], config: dict): """Send Slack notification.""" import httpx blocks = [] # Header total_new = sum(len(r.new_jobs) for r in reports) blocks.append({ "type": "header", "text": {"type": "plain_text", "text": f"šŸ”” {total_new} New Job Openings"} }) for report in reports: if report.new_jobs: blocks.append({"type": "divider"}) blocks.append({ "type": "section", "text": { "type": "mrkdwn", "text": f"*{report.company_name}* ({len(report.new_jobs)} new)" } }) for job in report.new_jobs[:5]: # Limit to 5 per company location = f" • {job.location}" if job.location else "" blocks.append({ "type": "section", "text": { "type": "mrkdwn", "text": f"<{job.url}|{job.title}>{location}" } }) payload = {"blocks": blocks} try: response = httpx.post(config["webhook_url"], json=payload) response.raise_for_status() print("āœ“ Slack notification sent") except Exception as e: print(f"āœ— Failed to send Slack notification: {e}") def _build_html_report(self, reports: list[ChangeReport]) -> str: """Build HTML email body.""" total_new = sum(len(r.new_jobs) for r in reports) html = f"""

šŸ”” {total_new} New Job Openings

""" for report in reports: if report.new_jobs: html += f"""

{report.company_name}

" html += """ """ return html