Initial Alma assignments reporter
This commit is contained in:
@@ -0,0 +1 @@
|
||||
alma-assignments-reporter
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
FROM golang:1.26 AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /out/alma-assignments-reporter .
|
||||
|
||||
FROM scratch
|
||||
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=build /out/alma-assignments-reporter /alma-assignments-reporter
|
||||
|
||||
ENTRYPOINT ["/alma-assignments-reporter"]
|
||||
@@ -0,0 +1,55 @@
|
||||
# Alma Assignments Reporter
|
||||
|
||||
This service logs into Alma, reads the `Missing / Revise` assignments table, walks the schedule page to find each class, collects upcoming assignments from each class page, and emails a daily report.
|
||||
|
||||
## Configuration
|
||||
|
||||
Required when `SEND_EMAIL=true`:
|
||||
|
||||
- `SMTP_HOST`
|
||||
- `EMAIL_FROM`
|
||||
- `EMAIL_TO`
|
||||
|
||||
Recommended settings:
|
||||
|
||||
- `ALMA_CREDS_FILE=/config/alma.creds`
|
||||
- `ALMA_ASSIGNMENTS_URL=https://example.invalid/children/student-id/assignments`
|
||||
- `ALMA_SCHEDULE_URL=https://example.invalid/children/student-id/schedule`
|
||||
- `ALMA_START_DATE=2026-01-20`
|
||||
- `ALMA_UPCOMING_DAYS=14`
|
||||
- `SMTP_PORT=587`
|
||||
- `SMTP_STARTTLS=true`
|
||||
- `SMTP_USERNAME`
|
||||
- `SMTP_PASSWORD`
|
||||
- `PRINT_REPORT=true`
|
||||
- `SEND_EMAIL=false`
|
||||
|
||||
Any setting can also be supplied via a `*_FILE` variant such as `SMTP_PASSWORD_FILE`.
|
||||
|
||||
The Alma credentials file format is:
|
||||
|
||||
```yaml
|
||||
username: your-alma-username
|
||||
password: your-alma-password
|
||||
```
|
||||
|
||||
## Local run
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
PRINT_REPORT=true SEND_EMAIL=false ALMA_CREDS_FILE=/path/to/alma.creds go run .
|
||||
```
|
||||
|
||||
## Container
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
docker build -t example.invalid/alma-assignments-reporter:latest .
|
||||
```
|
||||
|
||||
The container image uses a static Go binary in `scratch`, with only the CA bundle copied in for HTTPS and SMTP TLS.
|
||||
|
||||
## Kubernetes
|
||||
|
||||
Use a Secret for Alma and SMTP credentials. The example manifest in `cronjob.example.yaml` mounts Alma credentials at `/config/alma.creds` and reads SMTP credentials from secret-backed environment variables.
|
||||
@@ -0,0 +1,54 @@
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: alma-assignments-reporter
|
||||
namespace: default
|
||||
spec:
|
||||
schedule: "0 14 * * *"
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: reporter
|
||||
image: example.invalid/alma-assignments-reporter:latest
|
||||
env:
|
||||
- name: ALMA_ASSIGNMENTS_URL
|
||||
value: https://example.invalid/children/student-id/assignments
|
||||
- name: ALMA_SCHEDULE_URL
|
||||
value: https://example.invalid/children/student-id/schedule
|
||||
- name: ALMA_START_DATE
|
||||
value: "2026-01-20"
|
||||
- name: ALMA_UPCOMING_DAYS
|
||||
value: "14"
|
||||
- name: ALMA_CREDS_FILE
|
||||
value: /config/alma.creds
|
||||
- name: SMTP_HOST
|
||||
value: smtp.email.svc.cluster.local
|
||||
- name: SMTP_PORT
|
||||
value: "587"
|
||||
- name: SMTP_STARTTLS
|
||||
value: "false"
|
||||
- name: EMAIL_FROM
|
||||
value: alma-reporter@example.invalid
|
||||
- name: EMAIL_TO
|
||||
value: parent@example.invalid
|
||||
- name: SMTP_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: alma-assignments-reporter-smtp
|
||||
key: username
|
||||
- name: SMTP_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: alma-assignments-reporter-smtp
|
||||
key: password
|
||||
volumeMounts:
|
||||
- name: alma-creds
|
||||
mountPath: /config
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: alma-creds
|
||||
secret:
|
||||
secretName: alma-assignments-reporter-alma
|
||||
@@ -0,0 +1,5 @@
|
||||
module git.julianfamily.org/joejulian/alma-assignments-reporter
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require golang.org/x/net v0.43.0
|
||||
@@ -0,0 +1,2 @@
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
@@ -0,0 +1,799 @@
|
||||
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 ""
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func TestInferAssignmentDate(t *testing.T) {
|
||||
now := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC)
|
||||
got := inferAssignmentDate("Jan 20", now)
|
||||
want := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferAssignmentDateRollsBackYear(t *testing.T) {
|
||||
now := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC)
|
||||
got := inferAssignmentDate("Dec 15", now)
|
||||
want := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferUpcomingDateRollsForwardYear(t *testing.T) {
|
||||
now := time.Date(2026, 12, 28, 10, 0, 0, 0, time.UTC)
|
||||
got := inferUpcomingDate("Jan 03", now)
|
||||
want := time.Date(2027, 1, 3, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterUpcomingAssignments(t *testing.T) {
|
||||
now := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC)
|
||||
assignments := []Assignment{
|
||||
{DueDate: time.Date(2026, 3, 29, 0, 0, 0, 0, time.UTC), Title: "Soon"},
|
||||
{DueDate: time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC), Title: "Later"},
|
||||
}
|
||||
got := filterUpcomingAssignments(assignments, now, 14)
|
||||
if len(got) != 1 || got[0].Title != "Soon" {
|
||||
t.Fatalf("unexpected filtered assignments: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeAssignments(t *testing.T) {
|
||||
assignment := Assignment{
|
||||
DueDate: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC),
|
||||
Title: "Preparedness",
|
||||
Link: "https://example.invalid/a",
|
||||
}
|
||||
got := dedupeAssignments([]Assignment{assignment, assignment})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d items", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLikeLiveMissingTable(t *testing.T) {
|
||||
doc := mustParseHTML(`
|
||||
<table class="pure-table pure-table-horizontal missing-assignments">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Mar<br>25</td>
|
||||
<td>
|
||||
<small><a href="/class/1">US History</a></small>
|
||||
<a data-alma-modal="StudentAssignmentDetailsModal" href="/assignment/1">HW: Timeline Events</a>
|
||||
</td>
|
||||
<td><i title="Missing"></i></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>`)
|
||||
|
||||
table := findNode(doc, func(n *html.Node) bool {
|
||||
return n.Type == html.ElementNode && n.Data == "table" && hasClass(n, "missing-assignments")
|
||||
})
|
||||
if table == nil {
|
||||
t.Fatal("missing table not found")
|
||||
}
|
||||
row := findNode(table, func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "tr" })
|
||||
cells := directChildrenByTag(row, "td")
|
||||
if got := nodeText(cells[0]); got != "Mar 25" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
link := findNode(cells[1], func(n *html.Node) bool {
|
||||
return n.Type == html.ElementNode && n.Data == "a" && attr(n, "data-alma-modal") != ""
|
||||
})
|
||||
if got := nodeText(link); got != "HW: Timeline Events" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseHTML(text string) *html.Node {
|
||||
doc, err := html.Parse(strings.NewReader(text))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
Reference in New Issue
Block a user