diff --git a/NBA-Notifier/NBANotifer.sln b/NBA-Notifier/NBANotifer.sln new file mode 100644 index 0000000..393d52f --- /dev/null +++ b/NBA-Notifier/NBANotifer.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NBANotifier", "NBANotifier\NBANotifier.csproj", "{757A46B6-036C-4C7C-98F5-0073411D07E2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {757A46B6-036C-4C7C-98F5-0073411D07E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {757A46B6-036C-4C7C-98F5-0073411D07E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {757A46B6-036C-4C7C-98F5-0073411D07E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {757A46B6-036C-4C7C-98F5-0073411D07E2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/NBA-Notifier/NBANotifier/AppOrchestrator.cs b/NBA-Notifier/NBANotifier/AppOrchestrator.cs new file mode 100644 index 0000000..50306cb --- /dev/null +++ b/NBA-Notifier/NBANotifier/AppOrchestrator.cs @@ -0,0 +1,39 @@ +using NBANotifier.Interfaces; +using NBANotifier.Models; +using NBANotifier.Services; +using RazorLight; + +namespace NBANotifier; + +public class AppOrchestrator( + IScrapingService scrapingService, + IReportService reportService, + ISubscriberService subscriberService, + IEmailService emailService) +{ + public async Task RunAsync() + { + var engine = new RazorLightEngineBuilder() + .UseFileSystemProject(Utils.GetTemplateDirectory()) + .UseMemoryCachingProvider() + .Build(); + + var nbaData = scrapingService.GetNbaResults(); + var wnbaData = scrapingService.GetWnbaResults(); + var subscribers = await subscriberService.GetSubscribersAsync(); + + foreach (var subscriber in subscribers) + { + var model = new DailyReportModel + { + NbaResults = nbaData, + WnbaResults = wnbaData, + Subscriber = subscriber + }; + + var report = await reportService.BuildHtmlReportAsync(model); + var email = emailService.BuildEmail(report, subscriber); + await emailService.SendEmailAsync(email, subscriber); + } + } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Configuration/appSettings.json b/NBA-Notifier/NBANotifier/Configuration/appSettings.json new file mode 100644 index 0000000..e9ed34a --- /dev/null +++ b/NBA-Notifier/NBANotifier/Configuration/appSettings.json @@ -0,0 +1,18 @@ +{ + "SportsResultSiteUrls": { + "Nba": "https://www.basketball-reference.com/boxscores/", + "Wnba": "https://www.basketball-reference.com/wnba/boxscores/index.fcgi" + }, + "ScrapingSettings": { + "UseSnapshot": true + }, + "EmailSettings": { + "SenderName": "NBA Daily Report", + "SenderEmail": "test@test.com", + "SmtpUser": "", + "SmtpPassword": "", + "SmtpHost": "localhost", + "SmtpPort": 25, + "UseTls": false + } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Data/AppDbContext.cs b/NBA-Notifier/NBANotifier/Data/AppDbContext.cs new file mode 100644 index 0000000..c1de6ee --- /dev/null +++ b/NBA-Notifier/NBANotifier/Data/AppDbContext.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using NBANotifier.Models; +using NBANotifier.Services; + +namespace NBANotifier.Data; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Subscribers { get; set; } +} + +public class AppDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite(Utils.GetDatabaseConnectionString()); + return new AppDbContext(optionsBuilder.Options); + } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Data/DbSeeder.cs b/NBA-Notifier/NBANotifier/Data/DbSeeder.cs new file mode 100644 index 0000000..12f47ac --- /dev/null +++ b/NBA-Notifier/NBANotifier/Data/DbSeeder.cs @@ -0,0 +1,19 @@ +using NBANotifier.Models; + +namespace NBANotifier.Data; + +public class DbSeeder +{ + public static async Task SeedTestSubscribersAsync(AppDbContext db) + { + var subscribers = new List + { + new() { Name = "John Doe", Email = "FakeEmail@gmail.com", SubscribeToWnbaReports = false }, + new() { Name = "Jane Doe", Email = "TestEmail@hotmail.com", SubscribeToWnbaReports = true }, + new() { Name = "Jackson Doe", Email = "DevelopmentEmail@outlook.com", SubscribeToWnbaReports = true } + }; + + await db.Subscribers.AddRangeAsync(subscribers); + await db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Data/Migrations/20260428225141_InitialCreate.Designer.cs b/NBA-Notifier/NBANotifier/Data/Migrations/20260428225141_InitialCreate.Designer.cs new file mode 100644 index 0000000..0a7c8ff --- /dev/null +++ b/NBA-Notifier/NBANotifier/Data/Migrations/20260428225141_InitialCreate.Designer.cs @@ -0,0 +1,46 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NBANotifier.Data; + +#nullable disable + +namespace NBANotifier.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260428225141_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("NBANotifier.Models.Subscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubscribeToWnbaReports") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Subscribers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/NBA-Notifier/NBANotifier/Data/Migrations/20260428225141_InitialCreate.cs b/NBA-Notifier/NBANotifier/Data/Migrations/20260428225141_InitialCreate.cs new file mode 100644 index 0000000..24f7068 --- /dev/null +++ b/NBA-Notifier/NBANotifier/Data/Migrations/20260428225141_InitialCreate.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NBANotifier.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Subscribers", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false), + Email = table.Column(type: "TEXT", nullable: false), + SubscribeToWnbaReports = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Subscribers", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Subscribers"); + } + } +} diff --git a/NBA-Notifier/NBANotifier/Data/Migrations/AppDbContextModelSnapshot.cs b/NBA-Notifier/NBANotifier/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..8787359 --- /dev/null +++ b/NBA-Notifier/NBANotifier/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,43 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NBANotifier.Data; + +#nullable disable + +namespace NBANotifier.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("NBANotifier.Models.Subscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubscribeToWnbaReports") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Subscribers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/NBA-Notifier/NBANotifier/Interfaces/IEmailService.cs b/NBA-Notifier/NBANotifier/Interfaces/IEmailService.cs new file mode 100644 index 0000000..af0f73f --- /dev/null +++ b/NBA-Notifier/NBANotifier/Interfaces/IEmailService.cs @@ -0,0 +1,10 @@ +using MimeKit; +using NBANotifier.Models; + +namespace NBANotifier.Interfaces; + +public interface IEmailService +{ + public MimeMessage BuildEmail(string htmlBody, Subscriber subscriber); + public Task SendEmailAsync(MimeMessage message, Subscriber subscriber); +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Interfaces/IReportService.cs b/NBA-Notifier/NBANotifier/Interfaces/IReportService.cs new file mode 100644 index 0000000..a43e0c9 --- /dev/null +++ b/NBA-Notifier/NBANotifier/Interfaces/IReportService.cs @@ -0,0 +1,8 @@ +using NBANotifier.Models; + +namespace NBANotifier.Interfaces; + +public interface IReportService +{ + public Task BuildHtmlReportAsync(DailyReportModel model); +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Interfaces/IScrapingService.cs b/NBA-Notifier/NBANotifier/Interfaces/IScrapingService.cs new file mode 100644 index 0000000..f3fe2c2 --- /dev/null +++ b/NBA-Notifier/NBANotifier/Interfaces/IScrapingService.cs @@ -0,0 +1,10 @@ +using NBANotifier.Models; + +namespace NBANotifier.Interfaces; + +public interface IScrapingService +{ + public void UpdateSnapshots(); + public NbaResults GetNbaResults(); + public WnbaResults GetWnbaResults(); +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Interfaces/ISubscriberRepo.cs b/NBA-Notifier/NBANotifier/Interfaces/ISubscriberRepo.cs new file mode 100644 index 0000000..7360320 --- /dev/null +++ b/NBA-Notifier/NBANotifier/Interfaces/ISubscriberRepo.cs @@ -0,0 +1,8 @@ +using NBANotifier.Models; + +namespace NBANotifier.Interfaces; + +public interface ISubscriberRepo +{ + public Task> GetSubscribersAsync(); +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Interfaces/ISubscriberService.cs b/NBA-Notifier/NBANotifier/Interfaces/ISubscriberService.cs new file mode 100644 index 0000000..b0e8a3e --- /dev/null +++ b/NBA-Notifier/NBANotifier/Interfaces/ISubscriberService.cs @@ -0,0 +1,8 @@ +using NBANotifier.Models; + +namespace NBANotifier.Interfaces; + +public interface ISubscriberService +{ + public Task> GetSubscribersAsync(); +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Models/DailyReportModel.cs b/NBA-Notifier/NBANotifier/Models/DailyReportModel.cs new file mode 100644 index 0000000..dc0137d --- /dev/null +++ b/NBA-Notifier/NBANotifier/Models/DailyReportModel.cs @@ -0,0 +1,9 @@ +namespace NBANotifier.Models; + +public class DailyReportModel +{ + public string Date { get; set; } = DateTime.Today.ToString("yyyy-MM-dd"); + public required NbaResults NbaResults { get; set; } + public required WnbaResults WnbaResults { get; set; } + public required Subscriber Subscriber { get; set; } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Models/EmailSettings.cs b/NBA-Notifier/NBANotifier/Models/EmailSettings.cs new file mode 100644 index 0000000..a8b0ea2 --- /dev/null +++ b/NBA-Notifier/NBANotifier/Models/EmailSettings.cs @@ -0,0 +1,12 @@ +namespace NBANotifier.Models; + +public class EmailSettings +{ + public required string SenderName { get; set; } + public required string SenderEmail { get; set; } + public required string SmtpUser { get; set; } + public required string SmtpPassword { get; set; } + public required string SmtpHost { get; set; } + public required int SmtpPort { get; set; } + public required bool UseTls { get; set; } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Models/Game.cs b/NBA-Notifier/NBANotifier/Models/Game.cs new file mode 100644 index 0000000..bcad9df --- /dev/null +++ b/NBA-Notifier/NBANotifier/Models/Game.cs @@ -0,0 +1,9 @@ +namespace NBANotifier.Models; + +public class Game +{ + public required string HomeTeam { get; set; } + public required string AwayTeam { get; set; } + public required int HomeScore { get; set; } + public required int AwayScore { get; set; } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Models/NbaResults.cs b/NBA-Notifier/NBANotifier/Models/NbaResults.cs new file mode 100644 index 0000000..cc75c04 --- /dev/null +++ b/NBA-Notifier/NBANotifier/Models/NbaResults.cs @@ -0,0 +1,8 @@ +namespace NBANotifier.Models; + +public class NbaResults +{ + public List Games { get; set; } + public required List WesternConference { get; set; } + public required List EasternConference { get; set; } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Models/Subscriber.cs b/NBA-Notifier/NBANotifier/Models/Subscriber.cs new file mode 100644 index 0000000..9324130 --- /dev/null +++ b/NBA-Notifier/NBANotifier/Models/Subscriber.cs @@ -0,0 +1,9 @@ +namespace NBANotifier.Models; + +public class Subscriber +{ + public int Id { get; set; } + public required string Name { get; set; } + public required string Email { get; set; } + public required bool SubscribeToWnbaReports { get; set; } = false; +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Models/Team.cs b/NBA-Notifier/NBANotifier/Models/Team.cs new file mode 100644 index 0000000..8a3e77e --- /dev/null +++ b/NBA-Notifier/NBANotifier/Models/Team.cs @@ -0,0 +1,12 @@ +namespace NBANotifier.Models; + +public class Team +{ + public required string Name { get; set; } + public required string Wins { get; set; } + public required string Losses { get; set; } + public required string WinLossPercentage { get; set; } + public required string GamesBack { get; set; } + public required string PointsPerGame { get; set; } + public required string PointsAllowedPerGame { get; set; } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Models/WnbaResults.cs b/NBA-Notifier/NBANotifier/Models/WnbaResults.cs new file mode 100644 index 0000000..79811c4 --- /dev/null +++ b/NBA-Notifier/NBANotifier/Models/WnbaResults.cs @@ -0,0 +1,6 @@ +namespace NBANotifier.Models; + +public class WnbaResults +{ + public List Games { get; set; } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/NBANotifier.csproj b/NBA-Notifier/NBANotifier/NBANotifier.csproj new file mode 100644 index 0000000..ce74ce4 --- /dev/null +++ b/NBA-Notifier/NBANotifier/NBANotifier.csproj @@ -0,0 +1,39 @@ + + + + Exe + net10.0 + enable + enable + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + + + + + diff --git a/NBA-Notifier/NBANotifier/Program.cs b/NBA-Notifier/NBANotifier/Program.cs new file mode 100644 index 0000000..2af8829 --- /dev/null +++ b/NBA-Notifier/NBANotifier/Program.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NBANotifier; +using NBANotifier.Data; +using NBANotifier.Interfaces; +using NBANotifier.Models; +using NBANotifier.Services; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Configuration + .SetBasePath(Path.Combine(AppContext.BaseDirectory, "Configuration")) + .AddJsonFile("appSettings.json", false, true); + +var emailSettings = builder.Configuration.GetSection("EmailSettings").Get() ?? + throw new NullReferenceException("Email settings not found"); +builder.Services.AddSingleton(emailSettings); + +builder.Services.AddDbContext(options => + options.UseSqlite(Utils.GetDatabaseConnectionString())); + +builder.Services.AddTransient(); + +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +builder.Services.AddSingleton(); + +var app = builder.Build(); + + +if (args.Contains("--refresh-snapshots")) +{ + var scraper = app.Services.GetRequiredService(); + scraper.UpdateSnapshots(); + return; +} + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + var dbDirectory = Utils.GetDatabaseDirectory(); + Directory.CreateDirectory(dbDirectory); + + var dbPath = Path.Combine(dbDirectory, "app.db"); + var isFirstRun = !File.Exists(dbPath); + + await db.Database.MigrateAsync(); + + if (isFirstRun) await DbSeeder.SeedTestSubscribersAsync(db); +} + +var orchestrator = app.Services.GetRequiredService(); +await orchestrator.RunAsync(); \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Services/EmailService.cs b/NBA-Notifier/NBANotifier/Services/EmailService.cs new file mode 100644 index 0000000..6f7f40d --- /dev/null +++ b/NBA-Notifier/NBANotifier/Services/EmailService.cs @@ -0,0 +1,39 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using MimeKit; +using NBANotifier.Interfaces; +using NBANotifier.Models; + +namespace NBANotifier.Services; + +public class EmailService(EmailSettings emailSettings) : IEmailService +{ + public MimeMessage BuildEmail(string htmlBody, Subscriber subscriber) + { + var message = new MimeMessage(); + message.From.Add(new MailboxAddress(emailSettings.SenderName, emailSettings.SenderEmail)); + message.To.Add(new MailboxAddress(subscriber.Name, subscriber.Email)); + message.Subject = $"Daily NBA Report - {DateTime.Today:yyyy-M-d dddd}"; + + var bodyBuilder = new BodyBuilder + { + HtmlBody = htmlBody, + TextBody = "Please view this email through a client that supports HTML." + }; + + message.Body = bodyBuilder.ToMessageBody(); + return message; + } + + public async Task SendEmailAsync(MimeMessage message, Subscriber subscriber) + { + var socketOptions = emailSettings.UseTls ? SecureSocketOptions.StartTls : SecureSocketOptions.None; + + using var client = new SmtpClient(); + await client.ConnectAsync(emailSettings.SmtpHost, emailSettings.SmtpPort, socketOptions); + if (!string.IsNullOrEmpty(emailSettings.SmtpUser) && !string.IsNullOrEmpty(emailSettings.SmtpPassword)) + await client.AuthenticateAsync(emailSettings.SmtpUser, emailSettings.SmtpPassword); + await client.SendAsync(message); + await client.DisconnectAsync(true); + } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Services/ReportService.cs b/NBA-Notifier/NBANotifier/Services/ReportService.cs new file mode 100644 index 0000000..2944d90 --- /dev/null +++ b/NBA-Notifier/NBANotifier/Services/ReportService.cs @@ -0,0 +1,18 @@ +using NBANotifier.Interfaces; +using NBANotifier.Models; +using RazorLight; + +namespace NBANotifier.Services; + +public class ReportService : IReportService +{ + public async Task BuildHtmlReportAsync(DailyReportModel model) + { + var engine = new RazorLightEngineBuilder() + .UseFileSystemProject(Utils.GetTemplateDirectory()) + .UseMemoryCachingProvider() + .Build(); + + return await engine.CompileRenderAsync("DailyReport.cshtml", model); + } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Services/ScrapingService.cs b/NBA-Notifier/NBANotifier/Services/ScrapingService.cs new file mode 100644 index 0000000..3e41f9a --- /dev/null +++ b/NBA-Notifier/NBANotifier/Services/ScrapingService.cs @@ -0,0 +1,176 @@ +using System.Net; +using HtmlAgilityPack; +using Microsoft.Extensions.Configuration; +using NBANotifier.Interfaces; +using NBANotifier.Models; + +namespace NBANotifier.Services; + +public class ScrapingService(IConfiguration configuration) : IScrapingService +{ + private readonly string _nbaResultsUrl = configuration.GetValue("SportsResultSiteUrls:Nba") ?? + throw new ArgumentNullException(nameof(configuration), + "Could not extract SportsResultSiteUrls:Nba"); + + private readonly string _nbaSnapshotPath = + Path.Combine(Utils.GetTestDataDirectory(), "basketballReference_snapshot.html"); + + private readonly bool _useSnapshot = configuration.GetValue("ScrapingSettings:UseSnapshot"); + + private readonly string _wnbaResultsUrl = configuration.GetValue("SportsResultSiteUrls:Wnba") ?? + throw new ArgumentNullException(nameof(configuration), + "Could not extract SportsResultSiteUrls:Wnba"); + + private readonly string _wnbaSnapshotPath = + Path.Combine(Utils.GetTestDataDirectory(), "BasketballReference_snapshot_wnba.html"); + + public void UpdateSnapshots() + { + HtmlWeb web = new(); + + var doc = web.Load(_nbaResultsUrl); + doc.Save(_nbaSnapshotPath); + + doc = web.Load(_wnbaResultsUrl); + doc.Save(_wnbaSnapshotPath); + } + + public NbaResults GetNbaResults() + { + var doc = new HtmlDocument(); + if (_useSnapshot) + { + doc.Load(_nbaSnapshotPath); + } + else + { + var web = new HtmlWeb(); + doc = web.Load(_nbaResultsUrl); + } + + var results = new NbaResults + { + Games = ScrapeGames(doc), + EasternConference = ScrapeEasternConference(doc), + WesternConference = ScrapeWesternConference(doc) + }; + + return results; + } + + public WnbaResults GetWnbaResults() + { + var doc = new HtmlDocument(); + if (_useSnapshot) + { + doc.Load(_wnbaSnapshotPath); + } + else + { + var web = new HtmlWeb(); + doc = web.Load(_wnbaResultsUrl); + } + + var results = new WnbaResults + { + Games = ScrapeGames(doc) + }; + + return results; + } + + //------- Helper Methods ------- + private string ExtractDecodedText(HtmlNode node, string xPath) + { + var raw = node.SelectSingleNode(xPath).InnerText.Trim(); + return WebUtility.HtmlDecode(raw); + } + + private List ScrapeGames(HtmlDocument doc) + { + List games = []; + + var container = doc.DocumentNode.SelectSingleNode("//div[@class='game_summaries']"); + if (container is null) + throw new InvalidOperationException("No div class with the name game_summaries was found while scraping."); + + var gameSummaries = container.SelectNodes(".//div[contains(@class, 'game_summary')]"); + if (!gameSummaries.Any()) return games; + + foreach (var game in gameSummaries) + { + var teamsTable = game.SelectSingleNode(".//table[@class='teams']"); + var rows = teamsTable.SelectNodes(".//tr"); + + var awayTeamRow = rows[0]; //away team is always the first row, and home team is the second + var homeTeamRow = rows[1]; + + var gameResult = new Game + { + HomeTeam = ExtractDecodedText(homeTeamRow, ".//td[1]/a"), + HomeScore = int.Parse(ExtractDecodedText(homeTeamRow, ".//td[@class='right']")), + + AwayTeam = ExtractDecodedText(awayTeamRow, ".//td[1]/a"), + AwayScore = int.Parse(ExtractDecodedText(awayTeamRow, ".//td[@class='right']")) + }; + + games.Add(gameResult); + } + + return games; + } + + private List ScrapeEasternConference(HtmlDocument doc) + { + List teams = []; + + var container = doc.DocumentNode.SelectSingleNode("//div[@id='all_confs_standings_E']"); + var conferenceTable = container.SelectSingleNode(".//table[@id='confs_standings_E']"); + var teamTables = conferenceTable.SelectNodes(".//tr[@class='full_table']"); + + foreach (var teamTable in teamTables) + { + var team = new Team + { + Name = ExtractDecodedText(teamTable, ".//th[@data-stat='team_name']/a"), + Wins = ExtractDecodedText(teamTable, ".//td[@data-stat='wins']"), + Losses = ExtractDecodedText(teamTable, ".//td[@data-stat='losses']"), + WinLossPercentage = ExtractDecodedText(teamTable, ".//td[@data-stat='win_loss_pct']"), + GamesBack = ExtractDecodedText(teamTable, ".//td[@data-stat='gb']"), + PointsPerGame = ExtractDecodedText(teamTable, ".//td[@data-stat='pts_per_g']"), + PointsAllowedPerGame = ExtractDecodedText(teamTable, ".//td[@data-stat='opp_pts_per_g']") + }; + + teams.Add(team); + } + + return teams.OrderByDescending(t => t.Wins).ToList(); + } + + private List ScrapeWesternConference(HtmlDocument doc) + { + List teams = []; + + var container = doc.DocumentNode.SelectSingleNode("//div[@id='all_confs_standings_W']"); + var conferenceTable = container.SelectSingleNode(".//table[@id='confs_standings_W']"); + var teamTables = conferenceTable.SelectNodes(".//tr[@class='full_table']"); + + foreach (var teamTable in teamTables) + { + var team = new Team + { + Name = ExtractDecodedText(teamTable, ".//th[@data-stat='team_name']/a"), + Wins = ExtractDecodedText(teamTable, ".//td[@data-stat='wins']"), + Losses = ExtractDecodedText(teamTable, ".//td[@data-stat='losses']"), + WinLossPercentage = ExtractDecodedText(teamTable, ".//td[@data-stat='win_loss_pct']"), + GamesBack = ExtractDecodedText(teamTable, ".//td[@data-stat='gb']"), + PointsPerGame = ExtractDecodedText(teamTable, ".//td[@data-stat='pts_per_g']"), + PointsAllowedPerGame = ExtractDecodedText(teamTable, ".//td[@data-stat='opp_pts_per_g']") + }; + + teams.Add(team); + } + + return teams.OrderByDescending(t => t.Wins).ToList(); + } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Services/SubscriberRepo.cs b/NBA-Notifier/NBANotifier/Services/SubscriberRepo.cs new file mode 100644 index 0000000..de10e11 --- /dev/null +++ b/NBA-Notifier/NBANotifier/Services/SubscriberRepo.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using NBANotifier.Data; +using NBANotifier.Interfaces; +using NBANotifier.Models; + +namespace NBANotifier.Services; + +public class SubscriberRepo(AppDbContext db) : ISubscriberRepo +{ + public async Task> GetSubscribersAsync() + { + return await db.Subscribers.ToListAsync(); + } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Services/SubscriberService.cs b/NBA-Notifier/NBANotifier/Services/SubscriberService.cs new file mode 100644 index 0000000..8cb6bb9 --- /dev/null +++ b/NBA-Notifier/NBANotifier/Services/SubscriberService.cs @@ -0,0 +1,12 @@ +using NBANotifier.Interfaces; +using NBANotifier.Models; + +namespace NBANotifier.Services; + +public class SubscriberService(ISubscriberRepo repo) : ISubscriberService +{ + public async Task> GetSubscribersAsync() + { + return await repo.GetSubscribersAsync(); + } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Services/Utils.cs b/NBA-Notifier/NBANotifier/Services/Utils.cs new file mode 100644 index 0000000..a2b04ec --- /dev/null +++ b/NBA-Notifier/NBANotifier/Services/Utils.cs @@ -0,0 +1,38 @@ +namespace NBANotifier.Services; + +internal static class Utils +{ + internal static string GetTestDataDirectory() + { + var projectDir = GetProjectDirectory(); + var dataDir = Path.Combine(projectDir, "TestData"); + Directory.CreateDirectory(dataDir); + + return dataDir; + } + + internal static string GetTemplateDirectory() + { + var projectDir = GetProjectDirectory(); + var templateDir = Path.Combine(projectDir, "Templates"); + Directory.CreateDirectory(templateDir); + + return templateDir; + } + + internal static string GetDatabaseConnectionString() + { + return + $"Data Source={Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "NBANotifier", "app.db")}"; + } + + internal static string GetDatabaseDirectory() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "NBANotifier"); + } + + private static string GetProjectDirectory() + { + return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..\\..\\..\\")); + } +} \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/Templates/DailyReport.cshtml b/NBA-Notifier/NBANotifier/Templates/DailyReport.cshtml new file mode 100644 index 0000000..9e5ca5c --- /dev/null +++ b/NBA-Notifier/NBANotifier/Templates/DailyReport.cshtml @@ -0,0 +1,163 @@ +@model NBANotifier.Models.DailyReportModel + + + + + + + +

Daily NBA Report - @(Model.Date)

+ +

Hi @(Model.Subscriber.Name), There + we're @(Model.NbaResults.Games.Count > 0 ? $"{Model.NbaResults.Games.Count} games today." : "no games today.")

+
+ +
+ @foreach (var game in Model.NbaResults.Games) + { + + + + + + + + + + + + + + + + + + +
@game.AwayTeam @ + @ @game.HomeTeam
@game.AwayTeam -@game.AwayScore
@game.HomeTeam -@game.HomeScore
+ } +
+
+
+ + + + + + + + + + + + + + @foreach (var team in Model.NbaResults.EasternConference) + { + + + + + + + + + + } + +
+ Eastern Conference + + W + + L + + W/L% + + GB + + PPG + + OPPG +
@team.Name@team.Wins@team.Losses@team.WinLossPercentage@team.GamesBack@team.PointsPerGame@team.PointsAllowedPerGame
+
+ + + + + + + + + + + + + + @foreach (var team in Model.NbaResults.WesternConference) + { + + + + + + + + + + } + +
+ Western Conference + + W + + L + + W/L% + + GB + + PPG + + OPPG +
@team.Name@team.Wins@team.Losses@team.WinLossPercentage@team.GamesBack@team.PointsPerGame@team.PointsAllowedPerGame
+
+ +@if (Model.Subscriber.SubscribeToWnbaReports == true) +{ +
+
+

There + we're @(Model.WnbaResults.Games.Count > 0 ? $"{Model.WnbaResults.Games.Count} WNBA games today:" : "no WNBA games today.")

+ +
+ @foreach (var game in Model.WnbaResults.Games) + { + + + + + + + + + + + + + + + + + + +
@game.AwayTeam @ + @ @game.HomeTeam
@game.AwayTeam -@game.AwayScore
@game.HomeTeam -@game.HomeScore
+ } +
+} + + \ No newline at end of file diff --git a/NBA-Notifier/NBANotifier/TestData/BasketballReference_snapshot_wnba.html b/NBA-Notifier/NBANotifier/TestData/BasketballReference_snapshot_wnba.html new file mode 100644 index 0000000..e99323e --- /dev/null +++ b/NBA-Notifier/NBANotifier/TestData/BasketballReference_snapshot_wnba.html @@ -0,0 +1,4430 @@ + + + + + + + + + + + + + WNBA Games Played on October 10, 2025 | Basketball-Reference.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + +
+ +
+ +
+ + + +
+ +
+
+

WNBA Games Played on October 10, 2025

+ + +
+ + + + Oct 10, 2025 + + +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +

1 WNBA Game

+
+ + +
+ + + + + + + + + + + + + +
Las Vegas Aces97
Phoenix Mercury86  +
+ + + +
+ + +
+ + + + + + +
+ + + + +
+ + + + + + +
+
+ +
+ +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/NBA-Notifier/NBANotifier/TestData/basketballReference_snapshot.html b/NBA-Notifier/NBANotifier/TestData/basketballReference_snapshot.html new file mode 100644 index 0000000..afb51a8 --- /dev/null +++ b/NBA-Notifier/NBANotifier/TestData/basketballReference_snapshot.html @@ -0,0 +1,5124 @@ + + + + + + + + + + + + + NBA Games Played on April 28, 2026 | Basketball-Reference.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + +
+ +
+ +
+ + + +
+ +
+
+

NBA Games Played on April 28, 2026

+ + +
+ + + + Apr 28, 2026 + + +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +

3 NBA Games

+
+ + +
+ + + + + + + + + + + + + +
Philadelphia113
Boston97  +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
1234
Philadelphia 21293528
Boston 23342911
+ + + + + + + + + + + + + + +
PTSJ. Embiid-PHI33
TRBJ. Tatum-BOS16
+ +
+ +
+ + + + + + + + + + + + + +
Atlanta97
New York126  +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
1234
Atlanta 22262425
New York 35292636
+ + + + + + + + + + + + + + +
PTSJ. Brunson-NYK39
TRBK. Towns-NYK14
+ +
+ +
+ + + + + + + + + + + + + +
Portland95
San Antonio114  +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
1234
Portland 24212030
San Antonio 36292128
+ + + + + + + + + + + + + + +
PTSD. Avdija-POR22
TRBV. Wembanyama-SAS14
+ +
+ + +
+ + +
+ + +
+ +
+ +
+ +

Conference Standings

+
+
    +
  • * Playoff teams
  • +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Conference Standings Table
Eastern Conference + W + L + W/L% + GB + PS/G + PA/G +
Detroit Pistons* + 6022.732117.8109.6
Boston Celtics* + 5626.6834.0114.9107.2
New York Knicks* + 5329.6467.0116.5110.1
Cleveland Cavaliers* + 5230.6348.0119.5115.4
Atlanta Hawks* + 4636.56114.0118.5116.0
Toronto Raptors* + 4636.56114.0114.6111.8
Philadelphia 76ers* + 4537.54915.0115.9116.1
Orlando Magic* + 4537.54915.0115.7115.1
Charlotte Hornets* + 4438.53716.0116.0111.2
Miami Heat* + 4339.52417.0120.9118.5
Milwaukee Bucks3250.39028.0110.6116.8
Chicago Bulls3151.37829.0116.3121.5
Brooklyn Nets2062.24440.0105.9115.9
Indiana Pacers1963.23241.0112.4120.4
Washington Wizards1765.20743.0112.9124.9
+ + +
+ + +
+
+ + +
+ +
+ +
+ +

 

+
+
    +
  •  
  • +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  Table
Western Conference + W + L + W/L% + GB + PS/G + PA/G +
Oklahoma City Thunder* + 6418.780119.0107.9
San Antonio Spurs* + 6220.7562.0119.8111.5
Denver Nuggets* + 5428.65910.0122.1116.9
Los Angeles Lakers* + 5329.64611.0116.3114.6
Houston Rockets* + 5230.63412.0115.2110.0
Minnesota Timberwolves* + 4933.59815.0118.0114.6
Phoenix Suns* + 4537.54919.0112.6111.1
Portland Trail Blazers* + 4240.51222.0115.5115.8
Los Angeles Clippers* + 4240.51222.0113.8112.6
Golden State Warriors* + 3745.45127.0114.6115.2
New Orleans Pelicans2656.31738.0115.5120.0
Dallas Mavericks2656.31738.0114.1119.6
Memphis Grizzlies2557.30539.0114.7120.7
Sacramento Kings2260.26842.0111.0121.0
Utah Jazz2260.26842.0117.6126.0
+ + +
+ + +
+
+ + +
+
Please note that teams tied in the standings are sorted by team name and no + tiebreakers are being applied to these standings. +
+ + + + + + +
+ + + + +
+ + + + + + +
+
+ +
+ +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/NBA-Notifier/README.md b/NBA-Notifier/README.md new file mode 100644 index 0000000..e991214 --- /dev/null +++ b/NBA-Notifier/README.md @@ -0,0 +1,64 @@ +# NBA Email Notifier +![Static Badge](https://img.shields.io/badge/.NET%20Version-10.0-orange?style=flat-square) + +# Purpose of The App +This is a showcase of a C# app that scrapes current NBA and WNBA information, such as games or conference standings, and sends emails to a list of subscribers containing that information. While the app could theoretically be configured to send out real emails, it's currently configured to send out test emails, which can be seen using a local SMTP email tester +like [Papercut](https://github.com/ChangemakerStudios/Papercut-SMTP), or [Smtp4Dev](https://github.com/rnwood/smtp4dev), which is what was used during development. + +# Basic Features +* The app scraps information from [Basketball-reference.com](https://www.basketball-reference.com/boxscores/) using [HtmlAgilityPack](https://www.nuget.org/packages/HtmlAgilityPack). +* When passing "--refresh-snapshots" to the app as a program argument, the app will download local snapshots of the current NBA and WNBA information, meaning you can easily test emails during development without constantly scraping the website. +* A simple "UseSnapshot" configuration in the AppSettings.json file tells the app to scrape data from either the local snapshots or the live website. +* Emails are built using [RazorLight](https://www.nuget.org/packages/razorlight) and use HTML for the basic layout, with CSS for more visual details. +* All CSS is built in-line with the HTML elements to avoid being stripped by email clients. +* Emails are personalised to the subscriber, meaning each email is personal. This also applies to WNBA preferences, so that only subscribers who subscribe to the WNBA will receive updates. +* Subscribers are kept in an SQLite database using Entity Framework Core, and each record contains the individual subscriber's name, email, and subscription preferences. +* Email settings are kept in an AppSettings.json file, meaning changing the SMTP port, authorisation details, sender details, or any other settings can be configured easily within the file. + +# Sample Email Screenshot +![Sample Email](https://i.imgur.com/8jYNWpn.png) + +# How To Use The App + +## Scraping + +### Refreshing Local Snapshots +After cloning the repo, you can run the app with the program argument "--refresh-snapshots" to scrape and download local screenshots of both the NBA and WNBA pages. This can be done either through the terminal or by editing your launch configurations. +![terminal command](https://i.imgur.com/ifpLEKC.png) +![launch configurations](https://i.imgur.com/XXZVClx.png) +Afterwards, two files should appear in a TestData directory +![TestData directory](https://i.imgur.com/zhskCXq.png) + +### Scraping the live website +To switch between scraping local snapshots and scraping the live website, all you need to do is toggle the "UseSnapshot" setting in the appSettings.json within the configurations directory. +![UseSnapshot field](https://i.imgur.com/Puld8aT.png) + +## Sending Emails + +### For Testing With Smtp4Dev +Make sure that the Smtp4Dev NuGet package is installed and running in the terminal. You should see the interface after opening localhost:5000 in your browser, or whichever localhost port you chose, if you chose to change smtp4dev's default settings. +![smtp4dev web interface](https://i.imgur.com/zOuso9d.png) + +### For Testing Without SMTP4Dev +All of the SMTP details are customizable through the appSettings.json file in the configuration directory. +![email settings](https://i.imgur.com/zL7PcP1.png) + +# Architecture Overview +* When running the app, the scraping service scrapes the HTML from either the local screenshots or from the web URLs specified in the appSettings.json file. After scraping the HTML document, the values are stored in different models and then passed back to the app orchestrator that called the service. +* After scraping, the data gets passed through to the report service, which uses RazorLight to build a string containing HTML according to the template stored in the Templates directory. +* Once the orchestrator has the HTML string, it gets passed to the email service, which builds it into a sendable email with an HTML body. +* The constructed email is then finally sent out according to the email settings specified in the appSettings.json configuration file. +* All services inherit from interfaces and are passed through dependency injection for more testable and maintainable code. + +# Resources Used +[.NET (10.0)](https://learn.microsoft.com/en-us/dotnet/) +[HtmlAgilityPack (1.12.4)](https://www.nuget.org/packages/HtmlAgilityPack/1.12.4) +[MailKit (4.16.0)](https://www.nuget.org/packages/MailKit/4.16.0) +[EntityFrameworkCore (10.0.7)](https://www.nuget.org/packages/Microsoft.EntityFrameworkCore/10.0.7) +[RazorLight (2.3.1)](https://www.nuget.org/packages/RazorLight/2.3.1) +[MimeKit (4.16.0)](https://www.nuget.org/packages/MimeKit/4.16.0) + +# Personal Thoughts +I really enjoyed learning about scraping HTML, how to locate the different HTML elements, and how to scrape the values of those elements. I also liked learning the basics of how to use RazorLight and how to make an HTML page with inline CSS. Both scraping and creating something from HTML were fresh topics that I enjoyed learning a lot about. I had touched on +using MailKit and sending emails before, but I think I learned a little more this time, too, and it was a nice refresher from what I learned before. This was my first time using Smtp4Dev, but I really liked it. It had a nice interface, and it was easy to set up, and it had a lot of useful tools to see and test my emails. To be honest, I really liked this +project, and it was nice to do something different from what I've been doing recently.