2026-01-20 16:40:08 +00:00
|
|
|
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-20 16:40:08 +00:00
|
|
|
|
2026-01-29 16:24:44 +00:00
|
|
|
# Email (if configured) - only sends when there are changes
|
2026-01-20 16:40:08 +00:00
|
|
|
email_config = self.config.get("email")
|
2026-01-29 16:24:44 +00:00
|
|
|
if email_config and reports_with_changes:
|
2026-01-20 16:40:08 +00:00
|
|
|
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
|
2026-01-20 16:40:08 +00:00
|
|
|
slack_config = self.config.get("slack")
|
2026-01-29 16:24:44 +00:00
|
|
|
if slack_config and reports_with_changes:
|
2026-01-20 16:40:08 +00:00
|
|
|
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}")
|
|
|
|
|
|
2026-01-20 16:40:08 +00:00
|
|
|
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
|