job-scraper/notify.py

349 lines
12 KiB
Python
Raw Permalink Normal View History

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.")
2026-01-29 16:24:44 +00:00
else:
# Console output for changes
self._notify_console(reports_with_changes)
2026-01-29 16:24:44 +00:00
# Email (if configured) - only sends when there are changes
email_config = self.config.get("email")
2026-01-29 16:24:44 +00:00
if email_config and reports_with_changes:
self._notify_email(reports_with_changes, email_config)
2026-01-29 16:24:44 +00:00
# msmtp (if configured - sends daily summary always)
2026-01-20 18:27:17 +00:00
msmtp_config = self.config.get("msmtp")
if msmtp_config:
2026-01-29 16:24:44 +00:00
self._notify_msmtp_daily_summary(reports, msmtp_config)
2026-01-20 18:27:17 +00:00
2026-01-29 16:24:44 +00:00
# Slack (if configured) - only sends when there are changes
slack_config = self.config.get("slack")
2026-01-29 16:24:44 +00:00
if slack_config and reports_with_changes:
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}")
2026-01-20 18:27:17 +00:00
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}")
2026-01-29 16:24:44 +00:00
def _notify_msmtp_daily_summary(self, reports: list[ChangeReport], config: dict):
"""Send daily summary email via system msmtp (always sends)."""
import subprocess
from datetime import datetime
to_addr = config.get("to_addr", "me@bastiangruber.ca")
from_addr = config.get("from_addr", "admin@novanexus.ca")
# Calculate totals
total_companies = len([r for r in reports if r.total_active > 0])
total_jobs = sum(r.total_active for r in reports)
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
if total_new or total_removed:
changes = []
if total_new:
changes.append(f"+{total_new}")
if total_removed:
changes.append(f"-{total_removed}")
subject = f"Job Board: {', '.join(changes)} | {total_jobs} jobs"
else:
subject = f"Job Board: No changes | {total_jobs} jobs"
# Build plain text body
body_lines = [
"JOB BOARD DAILY SUMMARY",
f"{datetime.now().strftime('%Y-%m-%d %H:%M')}",
"",
"OVERVIEW",
f" Companies with jobs: {total_companies}",
f" Total jobs tracked: {total_jobs}",
"",
]
# Changes section
reports_with_changes = [r for r in reports if r.new_jobs or r.removed_jobs]
if reports_with_changes:
body_lines.append(f"CHANGES: +{total_new} new, -{total_removed} removed")
body_lines.append("-" * 40)
for report in reports_with_changes:
if 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" + {report.company_name}: {job.title}{location_str}{remote_str}")
if report.removed_jobs:
for job in report.removed_jobs:
body_lines.append(f" - {report.company_name}: {job.title}")
body_lines.append("")
else:
body_lines.append("CHANGES: No changes detected")
body_lines.append("")
body_lines.append("---")
body_lines.append("https://jobs.novanexus.ca")
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("✓ Daily summary email 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 daily summary: {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"""
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #333;">🔔 {total_new} New Job Openings</h1>
"""
for report in reports:
if report.new_jobs:
html += f"""
<h2 style="color: #666; border-bottom: 1px solid #ddd; padding-bottom: 5px;">
{report.company_name}
</h2>
<ul>
"""
for job in report.new_jobs:
location = f" <span style='color: #888;'>({job.location})</span>" if job.location else ""
html += f"""
<li style="margin: 10px 0;">
<a href="{job.url}" style="color: #0066cc; text-decoration: none;">
{job.title}
</a>
{location}
</li>
"""
html += "</ul>"
html += """
</body>
</html>
"""
return html