Files
alma-assignments-reporter/main.go
T
2026-03-28 11:06:40 -07:00

800 lines
20 KiB
Go

package main
import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/smtp"
"net/textproto"
"net/url"
"os"
"path"
"slices"
"strconv"
"strings"
"time"
"golang.org/x/net/html"
)
const (
defaultStartDate = "2026-01-20"
defaultUpcomingDays = 14
userAgent = "alma-assignments-reporter/1.0"
)
type Config struct {
AssignmentsURL string
ScheduleURL string
StartDate time.Time
UpcomingDays int
AlmaCredsFile string
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
SMTPStartTLS bool
EmailFrom string
EmailTo []string
PrintReport bool
SendEmail bool
}
type Assignment struct {
DueDate time.Time
Course string
Title string
Status string
Link string
Category string
}
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run() error {
cfg, err := loadConfig()
if err != nil {
return err
}
creds, err := loadAlmaCreds(cfg.AlmaCredsFile)
if err != nil {
return err
}
client, err := newHTTPClient()
if err != nil {
return err
}
if err := login(client, cfg.AssignmentsURL, creds["username"], creds["password"]); err != nil {
return err
}
now := time.Now()
missing, err := fetchMissingAssignments(client, cfg.AssignmentsURL, now)
if err != nil {
return err
}
missing = filterMissingAssignments(missing, cfg.StartDate)
classLinks, err := fetchClassLinks(client, cfg.ScheduleURL)
if err != nil {
return err
}
var upcoming []Assignment
for _, classURL := range classLinks {
items, err := fetchUpcomingAssignmentsForClass(client, classURL, now)
if err != nil {
return err
}
upcoming = append(upcoming, items...)
}
upcoming = dedupeAssignments(upcoming)
upcoming = filterUpcomingAssignments(upcoming, now, cfg.UpcomingDays)
subject := buildSubject(missing, upcoming, cfg.StartDate, cfg.UpcomingDays)
body := buildBody(missing, upcoming, cfg.StartDate, cfg.AssignmentsURL, cfg.ScheduleURL, now, cfg.UpcomingDays)
if cfg.PrintReport {
fmt.Println(subject)
fmt.Println()
fmt.Print(body)
}
if cfg.SendEmail {
if err := sendEmail(cfg, subject, body); err != nil {
return err
}
}
return nil
}
func loadConfig() (Config, error) {
assignmentsURL, err := readValue("ALMA_ASSIGNMENTS_URL", "", true)
if err != nil {
return Config{}, err
}
scheduleURL, err := readValue("ALMA_SCHEDULE_URL", deriveScheduleURL(assignmentsURL), true)
if err != nil {
return Config{}, err
}
startDateText, err := readValue("ALMA_START_DATE", defaultStartDate, true)
if err != nil {
return Config{}, err
}
startDate, err := time.Parse("2006-01-02", startDateText)
if err != nil {
return Config{}, fmt.Errorf("parse ALMA_START_DATE: %w", err)
}
upcomingDaysText, err := readValue("ALMA_UPCOMING_DAYS", strconv.Itoa(defaultUpcomingDays), true)
if err != nil {
return Config{}, err
}
upcomingDays, err := strconv.Atoi(upcomingDaysText)
if err != nil {
return Config{}, fmt.Errorf("parse ALMA_UPCOMING_DAYS: %w", err)
}
almaCredsFile, err := readValue("ALMA_CREDS_FILE", "/config/alma.creds", true)
if err != nil {
return Config{}, err
}
smtpHost, err := readValue("SMTP_HOST", "", false)
if err != nil {
return Config{}, err
}
smtpPortText, err := readValue("SMTP_PORT", "587", false)
if err != nil {
return Config{}, err
}
smtpPort, err := strconv.Atoi(smtpPortText)
if err != nil {
return Config{}, fmt.Errorf("parse SMTP_PORT: %w", err)
}
smtpUsername, err := readValue("SMTP_USERNAME", "", false)
if err != nil {
return Config{}, err
}
smtpPassword, err := readValue("SMTP_PASSWORD", "", false)
if err != nil {
return Config{}, err
}
smtpStartTLS := readBool("SMTP_STARTTLS", true)
emailFrom, err := readValue("EMAIL_FROM", "", false)
if err != nil {
return Config{}, err
}
emailToText, err := readValue("EMAIL_TO", "", false)
if err != nil {
return Config{}, err
}
emailTo := splitCSV(emailToText)
printReport := readBool("PRINT_REPORT", false)
sendEmailFlag := readBool("SEND_EMAIL", true)
if sendEmailFlag {
if smtpHost == "" || emailFrom == "" || len(emailTo) == 0 {
return Config{}, errors.New("SMTP_HOST, EMAIL_FROM, and EMAIL_TO are required when SEND_EMAIL is true")
}
if smtpUsername != "" && smtpPassword == "" {
return Config{}, errors.New("SMTP_PASSWORD is required when SMTP_USERNAME is set")
}
}
return Config{
AssignmentsURL: assignmentsURL,
ScheduleURL: scheduleURL,
StartDate: startDate,
UpcomingDays: upcomingDays,
AlmaCredsFile: almaCredsFile,
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPUsername: smtpUsername,
SMTPPassword: smtpPassword,
SMTPStartTLS: smtpStartTLS,
EmailFrom: emailFrom,
EmailTo: emailTo,
PrintReport: printReport,
SendEmail: sendEmailFlag,
}, nil
}
func deriveScheduleURL(assignmentsURL string) string {
parsed, err := url.Parse(assignmentsURL)
if err != nil {
return assignmentsURL
}
parsed.Path = path.Dir(parsed.Path) + "/schedule"
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String()
}
func readValue(name, fallback string, required bool) (string, error) {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value, nil
}
if filePath := strings.TrimSpace(os.Getenv(name + "_FILE")); filePath != "" {
data, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("read %s_FILE: %w", name, err)
}
return strings.TrimSpace(string(data)), nil
}
if fallback != "" {
return fallback, nil
}
if required {
return "", fmt.Errorf("missing required setting: %s or %s_FILE", name, name)
}
return "", nil
}
func readBool(name string, fallback bool) bool {
value := strings.TrimSpace(strings.ToLower(os.Getenv(name)))
if value == "" {
return fallback
}
switch value {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}
func splitCSV(value string) []string {
var items []string
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(part)
if part != "" {
items = append(items, part)
}
}
return items
}
func loadAlmaCreds(filePath string) (map[string]string, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("read alma creds: %w", err)
}
values := map[string]string{}
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
values[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
if err := scanner.Err(); err != nil {
return nil, err
}
if envUser, _ := readValue("ALMA_USERNAME", "", false); envUser != "" {
values["username"] = envUser
}
if envPass, _ := readValue("ALMA_PASSWORD", "", false); envPass != "" {
values["password"] = envPass
}
if values["username"] == "" || values["password"] == "" {
return nil, errors.New("alma creds file must contain username and password")
}
return values, nil
}
func newHTTPClient() (*http.Client, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
return &http.Client{
Jar: jar,
Timeout: 30 * time.Second,
}, nil
}
func login(client *http.Client, assignmentsURL, username, password string) error {
initialReq, err := http.NewRequest(http.MethodGet, assignmentsURL, nil)
if err != nil {
return err
}
initialReq.Header.Set("User-Agent", userAgent)
initialResp, err := client.Do(initialReq)
if err != nil {
return err
}
defer initialResp.Body.Close()
if initialResp.StatusCode >= 400 {
return fmt.Errorf("alma initial request failed: %s", initialResp.Status)
}
parsedURL, err := url.Parse(assignmentsURL)
if err != nil {
return err
}
form := url.Values{
"username": []string{username},
"password": []string{password},
"_done": []string{parsedURL.Path},
"deviceKey": []string{""},
}
loginURL := parsedURL.ResolveReference(&url.URL{Path: "/login"})
req, err := http.NewRequest(http.MethodPost, loginURL.String(), strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
return fmt.Errorf("alma login failed: %s", resp.Status)
}
if strings.Contains(resp.Request.URL.Path, "/login") || !bytes.Contains(bytes.ToLower(body), []byte("logout")) {
return errors.New("alma login failed")
}
return nil
}
func fetchMissingAssignments(client *http.Client, assignmentsURL string, now time.Time) ([]Assignment, error) {
root, baseURL, err := fetchHTML(client, assignmentsURL)
if err != nil {
return nil, err
}
table := findNode(root, func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "table" && hasClass(n, "missing-assignments")
})
if table == nil {
return nil, errors.New("missing assignments table not found")
}
var assignments []Assignment
for _, row := range findNodes(table, func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "tr"
}) {
cells := directChildrenByTag(row, "td")
if len(cells) < 3 {
continue
}
courseLink := findNode(cells[1], func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "a" && parentIsTag(n, "small")
})
assignmentLink := findNode(cells[1], func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "a" && attr(n, "data-alma-modal") != ""
})
if courseLink == nil || assignmentLink == nil {
continue
}
assignments = append(assignments, Assignment{
DueDate: inferAssignmentDate(nodeText(cells[0]), now),
Course: nodeText(courseLink),
Title: nodeText(assignmentLink),
Status: firstNonEmpty(attr(findNode(cells[2], func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "i" && attr(n, "title") != ""
}), "title"), nodeText(cells[2]), "Unknown"),
Link: resolveURL(baseURL, attr(assignmentLink, "href")),
})
}
sortAssignments(assignments)
return assignments, nil
}
func fetchClassLinks(client *http.Client, scheduleURL string) ([]string, error) {
root, baseURL, err := fetchHTML(client, scheduleURL)
if err != nil {
return nil, err
}
seen := map[string]struct{}{}
var links []string
for _, link := range findNodes(root, func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "a" &&
strings.Contains(attr(n, "href"), "/class/") &&
attr(n, "data-track") == "Class Home Page"
}) {
href := attr(link, "href")
if href == "" {
continue
}
classURL := resolveURL(baseURL, strings.SplitN(href, "?", 2)[0])
if _, ok := seen[classURL]; ok {
continue
}
seen[classURL] = struct{}{}
links = append(links, classURL)
}
slices.Sort(links)
return links, nil
}
func fetchUpcomingAssignmentsForClass(client *http.Client, classURL string, now time.Time) ([]Assignment, error) {
root, baseURL, err := fetchHTML(client, classURL)
if err != nil {
return nil, err
}
table := findNode(root, func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "table" && hasClass(n, "upcoming-assignments")
})
if table == nil {
return nil, nil
}
course := nodeText(findNode(root, func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "h3" && ancestorHasClass(n, "class-header")
}))
var assignments []Assignment
for _, row := range findNodes(table, func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "tr"
}) {
cells := directChildrenByTag(row, "td")
if len(cells) < 2 {
continue
}
link := findNode(cells[1], func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "a" && attr(n, "data-alma-modal") != ""
})
if link == nil {
continue
}
assignments = append(assignments, Assignment{
DueDate: inferUpcomingDate(nodeText(cells[0]), now),
Course: course,
Title: nodeText(link),
Status: "Upcoming",
Link: resolveURL(baseURL, attr(link, "href")),
Category: nodeText(findNode(cells[1], func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "small" })),
})
}
sortAssignments(assignments)
return assignments, nil
}
func fetchHTML(client *http.Client, rawURL string) (*html.Node, *url.URL, error) {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return nil, nil, err
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, nil, fmt.Errorf("fetch %s: %s", rawURL, resp.Status)
}
doc, err := html.Parse(resp.Body)
if err != nil {
return nil, nil, err
}
return doc, resp.Request.URL, nil
}
func filterMissingAssignments(assignments []Assignment, startDate time.Time) []Assignment {
var filtered []Assignment
for _, assignment := range assignments {
if !assignment.DueDate.Before(startOfDay(startDate)) {
filtered = append(filtered, assignment)
}
}
return filtered
}
func filterUpcomingAssignments(assignments []Assignment, now time.Time, days int) []Assignment {
start := startOfDay(now)
end := start.AddDate(0, 0, days)
var filtered []Assignment
for _, assignment := range assignments {
if !assignment.DueDate.Before(start) && !assignment.DueDate.After(end) {
filtered = append(filtered, assignment)
}
}
return filtered
}
func dedupeAssignments(assignments []Assignment) []Assignment {
seen := map[string]struct{}{}
var deduped []Assignment
for _, assignment := range assignments {
key := assignment.Link + "|" + assignment.Title + "|" + assignment.DueDate.Format("2006-01-02")
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
deduped = append(deduped, assignment)
}
sortAssignments(deduped)
return deduped
}
func buildSubject(missing, upcoming []Assignment, startDate time.Time, upcomingDays int) string {
return fmt.Sprintf(
"Alma report: %d missing since %s, %d due in %d days",
len(missing),
startDate.Format("2006-01-02"),
len(upcoming),
upcomingDays,
)
}
func buildBody(missing, upcoming []Assignment, startDate time.Time, assignmentsURL, scheduleURL string, now time.Time, upcomingDays int) string {
var b strings.Builder
fmt.Fprintf(&b, "Daily Alma missing assignments report for %s\n", now.Format("2006-01-02"))
fmt.Fprintf(&b, "Assignments due on or after %s\n\n", startDate.Format("2006-01-02"))
b.WriteString("Missing / Revision Needed\n")
b.WriteString("-------------------------\n")
if len(missing) == 0 {
b.WriteString("No missing or revision-needed assignments matched the filter.\n")
} else {
for _, assignment := range missing {
fmt.Fprintf(&b, "- %s | %s | %s\n", assignment.DueDate.Format("2006-01-02"), assignment.Course, assignment.Title)
fmt.Fprintf(&b, " Status: %s\n", assignment.Status)
fmt.Fprintf(&b, " Link: %s\n", assignment.Link)
}
}
fmt.Fprintf(&b, "\nUpcoming In Next %d Days\n", upcomingDays)
b.WriteString("------------------------\n")
if len(upcoming) == 0 {
b.WriteString("No upcoming assignments were due in the configured window.\n")
} else {
for _, assignment := range upcoming {
fmt.Fprintf(&b, "- %s | %s | %s\n", assignment.DueDate.Format("2006-01-02"), assignment.Course, assignment.Title)
fmt.Fprintf(&b, " Category: %s\n", firstNonEmpty(assignment.Category, "Upcoming"))
fmt.Fprintf(&b, " Link: %s\n", assignment.Link)
}
}
fmt.Fprintf(&b, "\nAssignments page: %s\n", assignmentsURL)
fmt.Fprintf(&b, "Schedule page: %s\n", scheduleURL)
return b.String()
}
func sendEmail(cfg Config, subject, body string) error {
address := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort)
conn, err := smtp.Dial(address)
if err != nil {
return err
}
defer conn.Close()
host := cfg.SMTPHost
if cfg.SMTPStartTLS {
if ok, _ := conn.Extension("STARTTLS"); ok {
if err := conn.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
return err
}
}
}
if cfg.SMTPUsername != "" {
auth := smtp.PlainAuth("", cfg.SMTPUsername, cfg.SMTPPassword, host)
if err := conn.Auth(auth); err != nil {
return err
}
}
if err := conn.Mail(cfg.EmailFrom); err != nil {
return err
}
for _, recipient := range cfg.EmailTo {
if err := conn.Rcpt(recipient); err != nil {
return err
}
}
writer, err := conn.Data()
if err != nil {
return err
}
defer writer.Close()
headers := textproto.MIMEHeader{}
headers.Set("From", cfg.EmailFrom)
headers.Set("To", strings.Join(cfg.EmailTo, ", "))
headers.Set("Subject", subject)
headers.Set("MIME-Version", "1.0")
headers.Set("Content-Type", "text/plain; charset=UTF-8")
for key, values := range headers {
for _, value := range values {
if _, err := fmt.Fprintf(writer, "%s: %s\r\n", key, value); err != nil {
return err
}
}
}
if _, err := io.WriteString(writer, "\r\n"+body); err != nil {
return err
}
return conn.Quit()
}
func inferAssignmentDate(monthDay string, now time.Time) time.Time {
return inferDate(monthDay, now, false)
}
func inferUpcomingDate(monthDay string, now time.Time) time.Time {
return inferDate(monthDay, now, true)
}
func inferDate(monthDay string, now time.Time, future bool) time.Time {
candidate, err := time.Parse("Jan 02 2006", monthDay+" "+strconv.Itoa(now.Year()))
if err != nil {
return time.Time{}
}
candidate = startOfDay(candidate)
today := startOfDay(now)
if future {
if candidate.Before(today.AddDate(0, 0, -30)) {
return candidate.AddDate(1, 0, 0)
}
return candidate
}
if candidate.After(today.AddDate(0, 0, 30)) {
return candidate.AddDate(-1, 0, 0)
}
return candidate
}
func startOfDay(t time.Time) time.Time {
year, month, day := t.Date()
return time.Date(year, month, day, 0, 0, 0, 0, t.Location())
}
func sortAssignments(assignments []Assignment) {
slices.SortFunc(assignments, func(a, b Assignment) int {
if !a.DueDate.Equal(b.DueDate) {
if a.DueDate.Before(b.DueDate) {
return -1
}
return 1
}
if c := strings.Compare(strings.ToLower(a.Course), strings.ToLower(b.Course)); c != 0 {
return c
}
return strings.Compare(strings.ToLower(a.Title), strings.ToLower(b.Title))
})
}
func resolveURL(baseURL *url.URL, href string) string {
if baseURL == nil || href == "" {
return ""
}
ref, err := url.Parse(href)
if err != nil {
return ""
}
return baseURL.ResolveReference(ref).String()
}
func findNode(root *html.Node, match func(*html.Node) bool) *html.Node {
if root == nil {
return nil
}
if match(root) {
return root
}
for child := root.FirstChild; child != nil; child = child.NextSibling {
if found := findNode(child, match); found != nil {
return found
}
}
return nil
}
func findNodes(root *html.Node, match func(*html.Node) bool) []*html.Node {
var nodes []*html.Node
var walk func(*html.Node)
walk = func(n *html.Node) {
if n == nil {
return
}
if match(n) {
nodes = append(nodes, n)
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
walk(child)
}
}
walk(root)
return nodes
}
func directChildrenByTag(node *html.Node, tag string) []*html.Node {
var children []*html.Node
for child := node.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode && child.Data == tag {
children = append(children, child)
}
}
return children
}
func hasClass(node *html.Node, class string) bool {
for _, item := range strings.Fields(attr(node, "class")) {
if item == class {
return true
}
}
return false
}
func ancestorHasClass(node *html.Node, class string) bool {
for current := node.Parent; current != nil; current = current.Parent {
if hasClass(current, class) {
return true
}
}
return false
}
func parentIsTag(node *html.Node, tag string) bool {
return node != nil && node.Parent != nil && node.Parent.Type == html.ElementNode && node.Parent.Data == tag
}
func attr(node *html.Node, key string) string {
if node == nil {
return ""
}
for _, attribute := range node.Attr {
if attribute.Key == key {
return attribute.Val
}
}
return ""
}
func nodeText(node *html.Node) string {
if node == nil {
return ""
}
var parts []string
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
if text := strings.TrimSpace(n.Data); text != "" {
parts = append(parts, text)
}
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
walk(child)
}
}
walk(node)
return strings.Join(parts, " ")
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}