├── .vscode ├── launch.json └── tasks.json ├── README.md ├── jobhunt ├── .gitignore ├── .vscode │ ├── launch.json │ └── tasks.json ├── AngleSharp │ ├── AttributeWhitelistFilter.cs │ ├── CssWhitelistBlacklistFilter.cs │ ├── Extensions.cs │ ├── JobHuntComparer.cs │ └── TagBlacklistFilter.cs ├── Configuration │ ├── IndeedOptions.cs │ ├── ScreenshotOptions.cs │ └── SearchOptions.cs ├── Controllers │ ├── AlertsController.cs │ ├── CompaniesController.cs │ ├── JobsController.cs │ ├── OData │ │ ├── CategoryController.cs │ │ ├── CompanyCategoryController.cs │ │ ├── CompanyController.cs │ │ ├── CompanyNameController.cs │ │ ├── JobCategoryController.cs │ │ ├── JobController.cs │ │ ├── ODataBaseController.cs │ │ ├── SearchController.cs │ │ └── WatchedPageController.cs │ ├── RefreshController.cs │ ├── SearchesController.cs │ └── WatchedPagesController.cs ├── Converters │ └── PandocConverter.cs ├── DTO │ └── JobCount.cs ├── Data │ ├── JobHuntContext.cs │ └── OData │ │ ├── CustomQueryBinder.cs │ │ ├── CustomUriFunctionUtils.cs │ │ ├── GeoFunctionBinder.cs │ │ └── ODataModelBuilder.cs ├── Directory.Build.props ├── Dockerfile ├── Dockerfile.dev ├── Extensions │ ├── HostBuilderExtensions.cs │ ├── HttpExtensions.cs │ ├── MvcBuilderExtensions.cs │ ├── NumberExtensions.cs │ ├── ODataConventionModelBuilderExtensions.cs │ ├── ServiceCollectionExtensions.cs │ └── TypeExtensions.cs ├── Filters │ └── ExceptionLogger.cs ├── Geocoding │ ├── Coordinate.cs │ ├── IGeocoder.cs │ ├── INominatimApi.cs │ └── Nominatim.cs ├── GlobalUsing.cs ├── JobHunt.csproj ├── Migrations │ ├── 20210606154030_InitialCreate.Designer.cs │ ├── 20210606154030_InitialCreate.cs │ ├── 20210926181747_CompanyRecruiter.Designer.cs │ ├── 20210926181747_CompanyRecruiter.cs │ ├── 20211109213143_NpgSql6.0_DateTimeUtc.Designer.cs │ ├── 20211109213143_NpgSql6.0_DateTimeUtc.cs │ ├── 20220130142134_WatchedPageChange.Designer.cs │ ├── 20220130142134_WatchedPageChange.cs │ ├── 20220324201057_WatchedPageChange_Screenshot.Designer.cs │ ├── 20220324201057_WatchedPageChange_Screenshot.cs │ ├── 20220404191815_WatchedPage_RequiresJS.Designer.cs │ ├── 20220404191815_WatchedPage_RequiresJS.cs │ ├── 20220626153835_DuplicateJobs.Designer.cs │ ├── 20220626153835_DuplicateJobs.cs │ ├── 20220717140709_Job_ActualCompany_Deleted.Designer.cs │ ├── 20220717140709_Job_ActualCompany_Deleted.cs │ ├── 20220813150214_Company_Posted_Required.Designer.cs │ ├── 20220813150214_Company_Posted_Required.cs │ ├── 20220814175931_Job_CheckedForDuplicate.Designer.cs │ ├── 20220814175931_Job_CheckedForDuplicate.cs │ ├── 20240320201407_Job_Remote.Designer.cs │ ├── 20240320201407_Job_Remote.cs │ └── JobHuntContextModelSnapshot.cs ├── Models │ ├── Alert.cs │ ├── AlertType.cs │ ├── Category.cs │ ├── Company.cs │ ├── CompanyCategory.cs │ ├── CompanyName.cs │ ├── Job.cs │ ├── JobCategory.cs │ ├── JobStatus.cs │ ├── KeyedEntity.cs │ ├── Search.cs │ ├── SearchRun.cs │ ├── WatchedPage.cs │ └── WatchedPageChange.cs ├── PageWatcher │ └── PageWatcher.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── Searching │ ├── ISearchProvider.cs │ ├── Indeed │ │ ├── GraphQL │ │ │ ├── Employer.cs │ │ │ ├── IndeedGraphQLService.cs │ │ │ ├── JobAttribute.cs │ │ │ ├── JobCompensation.cs │ │ │ ├── JobDataResponse.cs │ │ │ ├── JobDescription.cs │ │ │ ├── JobLocation.cs │ │ │ ├── JobSearchResponse.cs │ │ │ ├── JobSearchVariables.cs │ │ │ ├── Queries.cs │ │ │ └── UnixEpochDateTimeOffsetConverter.cs │ │ ├── IIndeedJobFetcher.cs │ │ ├── IndeedApiSearchProvider.cs │ │ ├── JobResult.cs │ │ └── Publisher │ │ │ ├── IIndeedJobDescriptionApi.cs │ │ │ ├── IIndeedPublisherApi.cs │ │ │ ├── IIndeedSalaryApi.cs │ │ │ ├── IndeedPublisherService.cs │ │ │ ├── JobSearchResponse.cs │ │ │ └── SalaryResponse.cs │ └── SearchProviderName.cs ├── Services │ ├── AlertService.cs │ ├── BaseServices │ │ ├── BaseService.cs │ │ ├── KeyedEntityBaseService.cs │ │ └── ODataBaseService.cs │ ├── CategoryService.cs │ ├── CompanyCategoryService.cs │ ├── CompanyNameService.cs │ ├── CompanyService.cs │ ├── JobCategoryService.cs │ ├── JobService.cs │ ├── SearchService.cs │ ├── WatchedPageChangeService.cs │ └── WatchedPageService.cs ├── Utils │ └── StringUtils.cs ├── Workers │ ├── PageScreenshotWorker.cs │ └── SearchRefreshWorker.cs ├── appsettings.Development.json ├── appsettings.json ├── client │ ├── .eslintrc.json │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ │ ├── favicon.ico │ │ ├── logo.svg │ │ ├── manifest.json │ │ ├── robots.txt │ │ └── static.css │ ├── src │ │ ├── App.tsx │ │ ├── components │ │ │ ├── Alerts.tsx │ │ │ ├── Card.tsx │ │ │ ├── CardBody.tsx │ │ │ ├── CardHeader.tsx │ │ │ ├── Categories.tsx │ │ │ ├── Date.tsx │ │ │ ├── ExpandableSnippet.tsx │ │ │ ├── FeedbackBackdrop.tsx │ │ │ ├── Grid.tsx │ │ │ ├── HideOnScroll.tsx │ │ │ ├── Tab.tsx │ │ │ ├── TabPanel.tsx │ │ │ ├── Tabs.tsx │ │ │ ├── forms │ │ │ │ ├── ApiAutocomplete.tsx │ │ │ │ ├── CountrySelector.tsx │ │ │ │ ├── DeleteDialog.tsx │ │ │ │ ├── EditableMarkdown.tsx │ │ │ │ └── NumberField.tsx │ │ │ ├── model-dialogs │ │ │ │ ├── CompanyDialog.tsx │ │ │ │ ├── JobDialog.tsx │ │ │ │ ├── SearchDialog.tsx │ │ │ │ └── WatchedPageDialog.tsx │ │ │ └── odata │ │ │ │ ├── ODataCategoryFilter.tsx │ │ │ │ └── ODataGrid.tsx │ │ ├── index.tsx │ │ ├── layouts │ │ │ └── MainLayout.tsx │ │ ├── makeStyles.ts │ │ ├── odata │ │ │ ├── ColumnDefinitions.tsx │ │ │ ├── CompanyColumns.tsx │ │ │ └── JobColumns.tsx │ │ ├── react-app-env.d.ts │ │ ├── state.ts │ │ ├── types │ │ │ ├── models │ │ │ │ ├── Category.ts │ │ │ │ ├── Company.ts │ │ │ │ ├── CompanyCategory.ts │ │ │ │ ├── CompanyName.ts │ │ │ │ ├── ICategoryLink.ts │ │ │ │ ├── Job.ts │ │ │ │ ├── JobCategory.ts │ │ │ │ ├── Search.ts │ │ │ │ ├── SearchRun.ts │ │ │ │ ├── WatchedPage.ts │ │ │ │ └── WatchedPageChange.ts │ │ │ └── odata │ │ │ │ ├── LocationFilter.ts │ │ │ │ ├── ODataBatchRequest.ts │ │ │ │ ├── ODataBatchResponse.ts │ │ │ │ ├── ODataMultipleResult.ts │ │ │ │ └── ODataSingleResult.ts │ │ ├── utils │ │ │ ├── constants.ts │ │ │ ├── countries.json │ │ │ ├── forms.ts │ │ │ ├── hooks.ts │ │ │ └── requests.ts │ │ └── views │ │ │ ├── Companies.tsx │ │ │ ├── Company.tsx │ │ │ ├── Dashboard.tsx │ │ │ ├── Job.tsx │ │ │ ├── Jobs.tsx │ │ │ ├── PageChanges.tsx │ │ │ └── Searches.tsx │ ├── tsconfig.json │ └── vite.config.ts ├── docker-compose-prod.yml ├── docker-compose.yml ├── init-data │ └── initdb.d │ │ ├── 10-schema.sql │ │ └── 11-functions.sql ├── install-geckodriver.sh └── jobhunt.sln └── res ├── jobhunt.png ├── jobhunt.svg └── jobhunt_plain.svg /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": ".NET Core Attach Docker", 6 | "type": "coreclr", 7 | "request": "attach", 8 | "processName": "JobHunt", 9 | "pipeTransport": { 10 | "pipeCwd": "${workspaceFolder}/jobhunt", 11 | "pipeProgram": "/usr/bin/docker", 12 | "pipeArgs": ["exec", "-i", "jobhunt_web_1"], 13 | "debuggerPath": "/vsdbg/vsdbg", 14 | "quoteArgs": false 15 | }, 16 | "sourceFileMap": { 17 | "/app": "${workspaceFolder}/jobhunt" 18 | } 19 | }, 20 | { 21 | // Use IntelliSense to find out which attributes exist for C# debugging 22 | // Use hover for the description of the existing attributes 23 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 24 | "name": ".NET Core Launch (web)", 25 | "type": "coreclr", 26 | "request": "launch", 27 | "preLaunchTask": "build", 28 | // If you have changed target frameworks, make sure to update the program path. 29 | "program": "${workspaceFolder}/jobhunt/bin/Debug/net5.0/JobHunt.dll", 30 | "args": [], 31 | "cwd": "${workspaceFolder}/jobhunt", 32 | "stopAtEntry": false, 33 | // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser 34 | "serverReadyAction": { 35 | "action": "openExternally", 36 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)" 37 | }, 38 | "env": { 39 | "ASPNETCORE_ENVIRONMENT": "Development" 40 | }, 41 | "sourceFileMap": { 42 | "/Views": "${workspaceFolder}/Views" 43 | } 44 | }, 45 | { 46 | "name": ".NET Core Attach", 47 | "type": "coreclr", 48 | "request": "attach", 49 | "processId": "${command:pickProcess}" 50 | } 51 | ] 52 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/jobhunt/JobHunt.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/jobhunt/JobHunt.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "${workspaceFolder}/jobhunt/JobHunt.csproj", 36 | "/property:GenerateFullPaths=true", 37 | "/consoleloggerparameters:NoSummary" 38 | ], 39 | "problemMatcher": "$msCompile" 40 | } 41 | ] 42 | } -------------------------------------------------------------------------------- /jobhunt/.gitignore: -------------------------------------------------------------------------------- 1 | Firefox/ 2 | 3 | ## Ignore Visual Studio temporary files, build results, and 4 | ## files generated by popular Visual Studio add-ons. 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | build/ 23 | bld/ 24 | bin/ 25 | Bin/ 26 | obj/ 27 | Obj/ 28 | 29 | # Visual Studio 2015 cache/options directory 30 | .vs/ 31 | /wwwroot/dist/ 32 | 33 | # MSTest test Results 34 | [Tt]est[Rr]esult*/ 35 | [Bb]uild[Ll]og.* 36 | 37 | # NUNIT 38 | *.VisualState.xml 39 | TestResult.xml 40 | 41 | # Build Results of an ATL Project 42 | [Dd]ebugPS/ 43 | [Rr]eleasePS/ 44 | dlldata.c 45 | 46 | *_i.c 47 | *_p.c 48 | *_i.h 49 | *.ilk 50 | *.meta 51 | *.obj 52 | *.pch 53 | *.pdb 54 | *.pgc 55 | *.pgd 56 | *.rsp 57 | *.sbr 58 | *.tlb 59 | *.tli 60 | *.tlh 61 | *.tmp 62 | *.tmp_proj 63 | *.log 64 | *.vspscc 65 | *.vssscc 66 | .builds 67 | *.pidb 68 | *.svclog 69 | *.scc 70 | 71 | # Chutzpah Test files 72 | _Chutzpah* 73 | 74 | # Visual C++ cache files 75 | ipch/ 76 | *.aps 77 | *.ncb 78 | *.opendb 79 | *.opensdf 80 | *.sdf 81 | *.cachefile 82 | 83 | # Visual Studio profiler 84 | *.psess 85 | *.vsp 86 | *.vspx 87 | *.sap 88 | 89 | # TFS 2012 Local Workspace 90 | $tf/ 91 | 92 | # Guidance Automation Toolkit 93 | *.gpState 94 | 95 | # ReSharper is a .NET coding add-in 96 | _ReSharper*/ 97 | *.[Rr]e[Ss]harper 98 | *.DotSettings.user 99 | 100 | # JustCode is a .NET coding add-in 101 | .JustCode 102 | 103 | # TeamCity is a build add-in 104 | _TeamCity* 105 | 106 | # DotCover is a Code Coverage Tool 107 | *.dotCover 108 | 109 | # NCrunch 110 | _NCrunch_* 111 | .*crunch*.local.xml 112 | nCrunchTemp_* 113 | 114 | # MightyMoose 115 | *.mm.* 116 | AutoTest.Net/ 117 | 118 | # Web workbench (sass) 119 | .sass-cache/ 120 | 121 | # Installshield output folder 122 | [Ee]xpress/ 123 | 124 | # DocProject is a documentation generator add-in 125 | DocProject/buildhelp/ 126 | DocProject/Help/*.HxT 127 | DocProject/Help/*.HxC 128 | DocProject/Help/*.hhc 129 | DocProject/Help/*.hhk 130 | DocProject/Help/*.hhp 131 | DocProject/Help/Html2 132 | DocProject/Help/html 133 | 134 | # Click-Once directory 135 | publish/ 136 | 137 | # Publish Web Output 138 | *.[Pp]ublish.xml 139 | *.azurePubxml 140 | # TODO: Comment the next line if you want to checkin your web deploy settings 141 | # but database connection strings (with potential passwords) will be unencrypted 142 | *.pubxml 143 | *.publishproj 144 | 145 | # NuGet Packages 146 | *.nupkg 147 | # The packages folder can be ignored because of Package Restore 148 | **/packages/* 149 | # except build/, which is used as an MSBuild target. 150 | !**/packages/build/ 151 | # Uncomment if necessary however generally it will be regenerated when needed 152 | #!**/packages/repositories.config 153 | 154 | # Microsoft Azure Build Output 155 | csx/ 156 | *.build.csdef 157 | 158 | # Microsoft Azure Emulator 159 | ecf/ 160 | rcf/ 161 | 162 | # Microsoft Azure ApplicationInsights config file 163 | ApplicationInsights.config 164 | 165 | # Windows Store app package directory 166 | AppPackages/ 167 | BundleArtifacts/ 168 | 169 | # Visual Studio cache files 170 | # files ending in .cache can be ignored 171 | *.[Cc]ache 172 | # but keep track of directories ending in .cache 173 | !*.[Cc]ache/ 174 | 175 | # Others 176 | ClientBin/ 177 | ~$* 178 | *~ 179 | *.dbmdl 180 | *.dbproj.schemaview 181 | *.pfx 182 | *.publishsettings 183 | orleans.codegen.cs 184 | 185 | /node_modules 186 | 187 | # RIA/Silverlight projects 188 | Generated_Code/ 189 | 190 | # Backup & report files from converting an old project file 191 | # to a newer Visual Studio version. Backup files are not needed, 192 | # because we have git ;-) 193 | _UpgradeReport_Files/ 194 | Backup*/ 195 | UpgradeLog*.XML 196 | UpgradeLog*.htm 197 | 198 | # SQL Server files 199 | *.mdf 200 | *.ldf 201 | 202 | # Business Intelligence projects 203 | *.rdl.data 204 | *.bim.layout 205 | *.bim_*.settings 206 | 207 | # Microsoft Fakes 208 | FakesAssemblies/ 209 | 210 | # GhostDoc plugin setting file 211 | *.GhostDoc.xml 212 | 213 | # Node.js Tools for Visual Studio 214 | .ntvs_analysis.dat 215 | 216 | # Visual Studio 6 build log 217 | *.plg 218 | 219 | # Visual Studio 6 workspace options file 220 | *.opt 221 | 222 | # Visual Studio LightSwitch build output 223 | **/*.HTMLClient/GeneratedArtifacts 224 | **/*.DesktopClient/GeneratedArtifacts 225 | **/*.DesktopClient/ModelManifest.xml 226 | **/*.Server/GeneratedArtifacts 227 | **/*.Server/ModelManifest.xml 228 | _Pvt_Extensions 229 | 230 | # Paket dependency manager 231 | .paket/paket.exe 232 | 233 | # FAKE - F# Make 234 | .fake/ 235 | -------------------------------------------------------------------------------- /jobhunt/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": ".NET Core Attach Docker", 6 | "type": "coreclr", 7 | "request": "attach", 8 | "processName": "jobhunt", 9 | "pipeTransport": { 10 | "pipeCwd": "${workspaceFolder}/jobunt", 11 | "pipeProgram": "/usr/bin/docker", 12 | "pipeArgs": ["exec", "-i", "jobhunt_web_1"], 13 | "debuggerPath": "/vsdbg/vsdbg", 14 | "quoteArgs": false 15 | }, 16 | "sourceFileMap": { 17 | "/app": "${workspaceFolder}/jobhunt" 18 | } 19 | }, 20 | { 21 | // Use IntelliSense to find out which attributes exist for C# debugging 22 | // Use hover for the description of the existing attributes 23 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 24 | "name": ".NET Core Launch (web)", 25 | "type": "coreclr", 26 | "request": "launch", 27 | "preLaunchTask": "build", 28 | // If you have changed target frameworks, make sure to update the program path. 29 | "program": "${workspaceFolder}/bin/Debug/net5.0/JobHunt.dll", 30 | "args": [], 31 | "cwd": "${workspaceFolder}", 32 | "stopAtEntry": false, 33 | // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser 34 | "serverReadyAction": { 35 | "action": "openExternally", 36 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)" 37 | }, 38 | "env": { 39 | "ASPNETCORE_ENVIRONMENT": "Development" 40 | }, 41 | "sourceFileMap": { 42 | "/Views": "${workspaceFolder}/Views" 43 | } 44 | }, 45 | { 46 | "name": ".NET Core Attach", 47 | "type": "coreclr", 48 | "request": "attach", 49 | "processId": "${command:pickProcess}" 50 | } 51 | ] 52 | } -------------------------------------------------------------------------------- /jobhunt/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/JobHunt.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/JobHunt.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "${workspaceFolder}/JobHunt.csproj", 36 | "/property:GenerateFullPaths=true", 37 | "/consoleloggerparameters:NoSummary" 38 | ], 39 | "problemMatcher": "$msCompile" 40 | } 41 | ] 42 | } -------------------------------------------------------------------------------- /jobhunt/AngleSharp/AttributeWhitelistFilter.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | 3 | using AngleSharp.Dom; 4 | using AngleSharp.Diffing.Core; 5 | 6 | namespace JobHunt.AngleSharp; 7 | public class AttributeWhitelistFilter 8 | { 9 | public IEnumerable Whitelist { get; set; } 10 | private readonly StringComparer _comparer = StringComparer.Create(CultureInfo.InvariantCulture, true); 11 | 12 | public AttributeWhitelistFilter(IEnumerable allowedAttributes) 13 | { 14 | Whitelist = allowedAttributes; 15 | } 16 | 17 | public FilterDecision Filter(in AttributeComparisonSource source, FilterDecision currentDecision) 18 | { 19 | if (currentDecision.IsExclude()) return currentDecision; 20 | 21 | if (!Whitelist.Contains(source.Attribute.Name, _comparer)) 22 | { 23 | return FilterDecision.Exclude; 24 | } 25 | 26 | return currentDecision; 27 | } 28 | } -------------------------------------------------------------------------------- /jobhunt/AngleSharp/CssWhitelistBlacklistFilter.cs: -------------------------------------------------------------------------------- 1 | using AngleSharp.Dom; 2 | using AngleSharp.Diffing.Core; 3 | 4 | namespace JobHunt.AngleSharp; 5 | public class CssWhitelistBlacklistFilter 6 | { 7 | private List _matchingNodes = new List(); 8 | 9 | public string? CssWhitelist { get; set; } 10 | public string? CssBlacklist { get; set; } 11 | 12 | public CssWhitelistBlacklistFilter(string? whitelist, string? blacklist) 13 | { 14 | CssWhitelist = whitelist; 15 | CssBlacklist = blacklist; 16 | } 17 | 18 | public FilterDecision Filter(in ComparisonSource source, FilterDecision currentDecision) 19 | { 20 | if (currentDecision.IsExclude()) return currentDecision; 21 | 22 | var node = source.Node; // needed because in parameters can't be used in lambdas? 23 | if (node.NodeType == NodeType.Element && node is IElement elem) 24 | { 25 | FilterDecision decision = FilterDecision.Keep; 26 | 27 | if (!string.IsNullOrEmpty(CssWhitelist)) 28 | { 29 | if (elem.Matches(CssWhitelist)) 30 | { 31 | // whitelist matches element 32 | _matchingNodes.Add(node); 33 | decision = FilterDecision.Keep; 34 | } 35 | else if (elem.GetDescendants().Any(n => n is IElement e && e.Matches(CssWhitelist))) 36 | { 37 | // parent of a matched element 38 | decision = FilterDecision.Keep; 39 | } 40 | else if (_matchingNodes.Any(n => node.IsDescendantOf(n))) 41 | { 42 | // descendent of matched element 43 | decision = FilterDecision.Keep; 44 | } 45 | else 46 | { 47 | decision = FilterDecision.Exclude; 48 | } 49 | } 50 | 51 | if (!string.IsNullOrEmpty(CssBlacklist) && elem.Matches(CssBlacklist)) 52 | { 53 | decision = FilterDecision.Exclude; 54 | } 55 | 56 | return decision; 57 | } 58 | 59 | return currentDecision; 60 | } 61 | } -------------------------------------------------------------------------------- /jobhunt/AngleSharp/Extensions.cs: -------------------------------------------------------------------------------- 1 | using AngleSharp.Dom; 2 | using AngleSharp.Diffing; 3 | using AngleSharp.Diffing.Core; 4 | using AngleSharp.Diffing.Strategies; 5 | using AngleSharp.Diffing.Strategies.TextNodeStrategies; 6 | 7 | namespace JobHunt.AngleSharp; 8 | public static class DiffingStrategyCollectionExtensions 9 | { 10 | public static IDiffingStrategyCollection AddCssWhitelistBlacklistFilter(this IDiffingStrategyCollection builder, string? cssWhitelist, string? cssBlacklist) 11 | { 12 | var filter = new CssWhitelistBlacklistFilter(cssWhitelist, cssBlacklist); 13 | 14 | builder.AddFilter(filter.Filter); 15 | 16 | return builder; 17 | } 18 | 19 | public static IDiffingStrategyCollection AddTagBlacklistFilter(this IDiffingStrategyCollection builder, params string[] blacklist) 20 | { 21 | var filter = new TagBlacklistFilter(blacklist); 22 | 23 | builder.AddFilter(filter.Filter); 24 | 25 | return builder; 26 | } 27 | 28 | public static IDiffingStrategyCollection AddAttributeWhitelistFilter(this IDiffingStrategyCollection builder, params string[] allowedAttributes) 29 | { 30 | var filter = new AttributeWhitelistFilter(allowedAttributes); 31 | 32 | builder.AddFilter(filter.Filter); 33 | 34 | return builder; 35 | } 36 | 37 | public static IDiffingStrategyCollection AddDefaultJobHuntOptions(this IDiffingStrategyCollection builder, string? cssWhitelist, string? cssBlacklist) 38 | { 39 | builder.IgnoreComments(); 40 | builder.AddSearchingNodeMatcher(); 41 | builder.AddAttributeNameMatcher(); 42 | builder.AddElementComparer(); 43 | builder.AddTextComparer(WhitespaceOption.Normalize, ignoreCase: false); 44 | builder.AddAttributeComparer(); 45 | 46 | builder.AddTagBlacklistFilter(TagNames.Script, TagNames.Link); 47 | builder.AddCssWhitelistBlacklistFilter(cssWhitelist, cssBlacklist); 48 | builder.AddAttributeWhitelistFilter(AttributeNames.Href, AttributeNames.Src, AttributeNames.SrcSet); 49 | 50 | return builder; 51 | } 52 | } 53 | 54 | public static class DiffExtensions 55 | { 56 | public static void SetDiffAttributes(this IDiff diff, Func getElement, string? attrKey = null) where T : struct 57 | { 58 | if (diff.Result == DiffResult.Different && diff is DiffBase db) 59 | { 60 | IElement targetControl = getElement(db.Control); 61 | targetControl.SetAttribute(attrKey ?? "data-jh-modified", "true"); 62 | 63 | IElement targetTest = targetTest = getElement(db.Test); 64 | targetTest.SetAttribute(attrKey ?? "data-jh-modified", "true"); 65 | } 66 | else if (diff.Result == DiffResult.Missing && diff is MissingDiffBase mdb) 67 | { 68 | IElement target = getElement(mdb.Control); 69 | target.SetAttribute(attrKey ?? "data-jh-removed", "true"); 70 | } 71 | else if (diff.Result == DiffResult.Unexpected && diff is UnexpectedDiffBase udb) 72 | { 73 | IElement target = getElement(udb.Test); 74 | target.SetAttribute(attrKey ?? "data-jh-added", "true"); 75 | } 76 | } 77 | } 78 | 79 | public static class DocumentExtensions 80 | { 81 | public static void ReplaceRelativeUrlsWithAbsolute(this IDocument doc, string url) 82 | { 83 | foreach (var elem in doc.QuerySelectorAll("img, script")) 84 | { 85 | var src = elem.GetAttribute("src"); 86 | if (src != null && Uri.TryCreate(src, UriKind.RelativeOrAbsolute, out Uri? srcUri)) 87 | { 88 | if (srcUri.IsRelativeHttpUri(src)) 89 | { 90 | elem.SetAttribute("src", new Uri(new Uri(url), srcUri).AbsoluteUri); 91 | } 92 | } 93 | } 94 | 95 | foreach (var elem in doc.QuerySelectorAll("a, link")) 96 | { 97 | var href = elem.GetAttribute("href"); 98 | if (href != null && Uri.TryCreate(href, UriKind.RelativeOrAbsolute, out Uri? hrefUri)) 99 | { 100 | if (hrefUri.IsRelativeHttpUri(href)) 101 | { 102 | elem.SetAttribute("href", new Uri(new Uri(url), hrefUri).AbsoluteUri); 103 | } 104 | } 105 | } 106 | } 107 | } -------------------------------------------------------------------------------- /jobhunt/AngleSharp/JobHuntComparer.cs: -------------------------------------------------------------------------------- 1 | using AngleSharp; 2 | using AngleSharp.Diffing; 3 | using AngleSharp.Diffing.Core; 4 | using AngleSharp.Diffing.Strategies; 5 | using AngleSharp.Dom; 6 | 7 | namespace JobHunt.AngleSharp; 8 | public static class JobHuntComparer 9 | { 10 | 11 | public static async Task> CompareAsync(string html1, string html2, string? cssSelector, string? cssBlacklist) 12 | { 13 | var context = BrowsingContext.New(); 14 | 15 | var doc1 = await context.OpenAsync(r => r.Content(html1)); 16 | var doc2 = await context.OpenAsync(r => r.Content(html2)); 17 | 18 | return Compare(doc1, doc2, cssSelector, cssBlacklist); 19 | } 20 | 21 | public static IEnumerable Compare(IDocument doc1, IDocument doc2, string? cssSelector, string? cssBlacklist) 22 | { 23 | var diffStrategy = new DiffingStrategyPipeline(); 24 | diffStrategy.AddDefaultJobHuntOptions(cssSelector, cssBlacklist); 25 | 26 | var comparer = new HtmlDiffer(diffStrategy); 27 | 28 | return comparer.Compare( 29 | doc1.Body ?? throw new ArgumentNullException("Body of doc1 was null"), 30 | doc2.Body ?? throw new ArgumentNullException("Body of doc2 was null") 31 | ); 32 | } 33 | } -------------------------------------------------------------------------------- /jobhunt/AngleSharp/TagBlacklistFilter.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | 3 | using AngleSharp.Dom; 4 | using AngleSharp.Diffing.Core; 5 | 6 | namespace JobHunt.AngleSharp; 7 | public class TagBlacklistFilter 8 | { 9 | public IEnumerable Blacklist { get; set; } 10 | private readonly StringComparer _comparer = StringComparer.Create(CultureInfo.InvariantCulture, true); 11 | 12 | public TagBlacklistFilter(IEnumerable blacklist) 13 | { 14 | Blacklist = blacklist; 15 | } 16 | 17 | public FilterDecision Filter(in ComparisonSource source, FilterDecision currentDecision) 18 | { 19 | if (currentDecision.IsExclude()) return currentDecision; 20 | 21 | if (source.Node.NodeType == NodeType.Element && source.Node is IElement elem) 22 | { 23 | if (Blacklist.Contains(elem.TagName, _comparer)) 24 | { 25 | return FilterDecision.Exclude; 26 | } 27 | } 28 | 29 | return currentDecision; 30 | } 31 | } -------------------------------------------------------------------------------- /jobhunt/Configuration/IndeedOptions.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Configuration; 2 | 3 | public class IndeedOptions 4 | { 5 | public bool UseGraphQL { get; set; } 6 | public string? GraphQLApiKey { get; set; } 7 | public string SearchRadiusUnit { get; set; } = "MILES"; 8 | public string? HostName { get; set; } 9 | public bool CanUseGraphQL() => !string.IsNullOrEmpty(GraphQLApiKey); 10 | 11 | public string? PublisherId { get; set; } 12 | public bool FetchSalary { get; set; } 13 | public bool UseGraphQLSalaryAndDescriptions { get; set; } 14 | public bool CanUsePublisher() => !string.IsNullOrEmpty(PublisherId); 15 | } -------------------------------------------------------------------------------- /jobhunt/Configuration/ScreenshotOptions.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Configuration; 2 | public class ScreenshotOptions 3 | { 4 | public const string Section = "Screenshots"; 5 | 6 | public string? Schedule { get; set; } 7 | public required string Directory { get; set; } 8 | public int WidthPixels { get; set; } 9 | public int QualityPercent { get; set; } 10 | public int PageLoadTimeoutSeconds { get; set; } 11 | } -------------------------------------------------------------------------------- /jobhunt/Configuration/SearchOptions.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Configuration; 2 | public class SearchOptions 3 | { 4 | public const string Section = "Search"; 5 | 6 | public required IndeedOptions Indeed { get; set; } 7 | 8 | public required string[] Schedules { get; set; } 9 | 10 | public string? NominatimCountryCodes { get; set; } 11 | 12 | public int PageLoadWaitSeconds { get; set; } 13 | 14 | public bool CheckForDuplicateJobs { get; set; } 15 | public int? DuplicateCheckMonths { get; set; } 16 | public double DescriptionSimilarityThreshold { get; set; } 17 | public double TitleSimilarityThreshold { get; set; } 18 | public double IdenticalDescriptionSimilarityThreshold { get; set; } 19 | } -------------------------------------------------------------------------------- /jobhunt/Controllers/AlertsController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace JobHunt.Controllers; 4 | [ApiController] 5 | [Route("api/[controller]/[action]")] 6 | public class AlertsController : ControllerBase 7 | { 8 | private readonly IAlertService _alertService; 9 | public AlertsController(IAlertService alertService) 10 | { 11 | _alertService = alertService; 12 | } 13 | 14 | [HttpGet] 15 | [Route("~/api/alerts")] 16 | public async Task GetRecent() 17 | { 18 | return new JsonResult(await _alertService.GetRecentAsync()); 19 | } 20 | 21 | [HttpGet] 22 | public async Task UnreadCount() 23 | { 24 | return new JsonResult(await _alertService.GetUnreadCountAsync()); 25 | } 26 | 27 | [HttpPatch("{id}")] 28 | public async Task Read([FromRoute] int id) 29 | { 30 | bool result = await _alertService.MarkAsReadAsync(id); 31 | if (result) 32 | { 33 | return Ok(); 34 | } 35 | else 36 | { 37 | return NotFound(); 38 | } 39 | } 40 | 41 | [HttpPatch] 42 | public async Task AllRead() 43 | { 44 | await _alertService.MarkAllAsReadAsync(); 45 | } 46 | } -------------------------------------------------------------------------------- /jobhunt/Controllers/CompaniesController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace JobHunt.Controllers; 4 | [ApiController] 5 | [Route("api/[controller]/[action]")] 6 | public class CompaniesController : ControllerBase 7 | { 8 | private readonly ICompanyService _companyService; 9 | 10 | public CompaniesController(ICompanyService companyService) 11 | { 12 | _companyService = companyService; 13 | } 14 | 15 | [HttpGet] 16 | public IActionResult Categories() 17 | { 18 | return Ok(_companyService.GetCompanyCategories()); 19 | } 20 | 21 | [HttpPatch] 22 | [Route("{id}")] 23 | public async Task Merge([FromRoute] int id, [FromBody] int dest) 24 | { 25 | bool result = await _companyService.MergeAsync(id, dest); 26 | 27 | if (result) 28 | { 29 | return Ok(); 30 | } 31 | else 32 | { 33 | return NotFound(); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /jobhunt/Controllers/JobsController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace JobHunt.Controllers 4 | { 5 | [ApiController] 6 | [Route("api/[controller]/[action]")] 7 | public class JobsController : ControllerBase 8 | { 9 | private readonly IJobService _jobService; 10 | public JobsController(IJobService jobService) 11 | { 12 | _jobService = jobService; 13 | } 14 | 15 | [HttpGet] 16 | public async Task Counts() 17 | { 18 | return new JsonResult(await _jobService.GetJobCountsAsync()); 19 | } 20 | 21 | [HttpGet] 22 | public IActionResult Categories() 23 | { 24 | return Ok(_jobService.GetJobCategories()); 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /jobhunt/Controllers/OData/CategoryController.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Controllers.OData; 2 | 3 | public class CategoryController : ODataBaseController 4 | { 5 | public CategoryController(ICategoryService service) : base(service) { } 6 | } -------------------------------------------------------------------------------- /jobhunt/Controllers/OData/CompanyCategoryController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.OData.Routing.Controllers; 3 | 4 | namespace JobHunt.Controllers.OData; 5 | 6 | public class CompanyCategoryController : ODataController 7 | { 8 | private readonly ICompanyCategoryService _service; 9 | 10 | public CompanyCategoryController(ICompanyCategoryService service) 11 | { 12 | _service = service; 13 | } 14 | 15 | [HttpPost] 16 | public virtual async Task Post([FromBody] CompanyCategory entity) 17 | { 18 | if (!ModelState.IsValid) 19 | { 20 | return UnprocessableEntity(ModelState); 21 | } 22 | 23 | return Created(await _service.CreateAsync(entity)); 24 | } 25 | 26 | [HttpDelete] 27 | public virtual async Task Delete(int keycategoryId, int keycompanyId) 28 | { 29 | bool? result = await _service.DeleteAsync(keycategoryId, keycompanyId); 30 | if (result.HasValue) 31 | { 32 | return Ok(result.Value); 33 | } 34 | else 35 | { 36 | return NotFound($"CompanyCategory with key {{{keycategoryId},{keycompanyId}}} not found"); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /jobhunt/Controllers/OData/CompanyController.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Controllers.OData; 2 | 3 | public class CompanyController : ODataBaseController 4 | { 5 | public CompanyController(ICompanyService service) : base(service) { } 6 | } -------------------------------------------------------------------------------- /jobhunt/Controllers/OData/CompanyNameController.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Controllers.OData; 2 | 3 | public class CompanyNameController : ODataBaseController 4 | { 5 | public CompanyNameController(ICompanyNameService service) : base(service) { } 6 | } -------------------------------------------------------------------------------- /jobhunt/Controllers/OData/JobCategoryController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.OData.Routing.Controllers; 3 | 4 | namespace JobHunt.Controllers.OData; 5 | 6 | public class JobCategoryController : ODataController 7 | { 8 | private readonly IJobCategoryService _service; 9 | 10 | public JobCategoryController(IJobCategoryService service) 11 | { 12 | _service = service; 13 | } 14 | 15 | [HttpPost] 16 | public virtual async Task Post([FromBody] JobCategory entity) 17 | { 18 | if (!ModelState.IsValid) 19 | { 20 | return UnprocessableEntity(ModelState); 21 | } 22 | 23 | return Created(await _service.CreateAsync(entity)); 24 | } 25 | 26 | [HttpDelete] 27 | public virtual async Task Delete(int keycategoryId, int keyjobId) 28 | { 29 | bool? result = await _service.DeleteAsync(keycategoryId, keyjobId); 30 | if (result.HasValue) 31 | { 32 | return Ok(result.Value); 33 | } 34 | else 35 | { 36 | return NotFound($"JobCategory with key {{{keycategoryId},{keyjobId}}} not found"); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /jobhunt/Controllers/OData/JobController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.OData.Deltas; 3 | 4 | namespace JobHunt.Controllers.OData; 5 | 6 | public class JobController : ODataBaseController 7 | { 8 | private IJobService _service; 9 | private ILogger _logger; 10 | 11 | public JobController(IJobService service, ILogger logger) : base(service) 12 | { 13 | _service = service; 14 | _logger = logger; 15 | } 16 | } -------------------------------------------------------------------------------- /jobhunt/Controllers/OData/ODataBaseController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.OData.Deltas; 3 | using Microsoft.AspNetCore.OData.Query; 4 | using Microsoft.AspNetCore.OData.Results; 5 | using Microsoft.AspNetCore.OData.Routing.Controllers; 6 | using Microsoft.EntityFrameworkCore; 7 | 8 | using JobHunt.Services.BaseServices; 9 | 10 | namespace JobHunt.Controllers.OData; 11 | 12 | public abstract class ODataBaseController : ODataController where T : class, KeyedEntity 13 | { 14 | private readonly IODataBaseService _service; 15 | 16 | public ODataBaseController(IODataBaseService service) 17 | { 18 | _service = service; 19 | } 20 | 21 | [HttpGet] 22 | [EnableQuery(MaxAnyAllExpressionDepth = 5, MaxExpansionDepth = 3)] 23 | public virtual IActionResult Get() 24 | { 25 | return Ok(_service.Set.AsNoTracking()); 26 | } 27 | 28 | [HttpGet] 29 | [EnableQuery(MaxAnyAllExpressionDepth = 5, MaxExpansionDepth = 3)] 30 | public virtual SingleResult Get(int key) // parameter must be named "key" otherwise it doesn't work 31 | { 32 | // returning a SingleResult allows features such as $expand to work 33 | // they don't work otherwise because calling FirstOrDefaultAsync triggers the DB call so .Include can't be called 34 | return SingleResult.Create(_service.Set.AsNoTracking().Where(x => x.Id == key)); 35 | } 36 | 37 | [HttpPost] 38 | public virtual async Task Post([FromBody] T entity) 39 | { 40 | if (!ModelState.IsValid) 41 | { 42 | return UnprocessableEntity(ModelState); 43 | } 44 | 45 | return Created(await _service.CreateAsync(entity)); 46 | } 47 | 48 | [HttpPut] 49 | public virtual async Task Put(int key, Delta delta) 50 | { 51 | if (delta == null) 52 | { 53 | return BadRequest(); 54 | } 55 | 56 | var result = await _service.PutAsync(key, delta); 57 | 58 | if (result == default) 59 | { 60 | return NotFound($"{nameof(T)} with Id={key} not found"); 61 | } 62 | 63 | return await ValidateAndSave(result); 64 | } 65 | 66 | [HttpPatch] 67 | public virtual async Task Patch(int key, Delta delta) 68 | { 69 | if (delta == null) 70 | { 71 | return BadRequest(); 72 | } 73 | 74 | var result = await _service.PatchAsync(key, delta); 75 | 76 | if (result == default) 77 | { 78 | return NotFound($"{nameof(T)} with Id={key} not found"); 79 | } 80 | 81 | return await ValidateAndSave(result); 82 | } 83 | 84 | protected virtual async Task ValidateAndSave(T entity) 85 | { 86 | var context = new ValidationContext(entity); 87 | var results = new List(); 88 | 89 | if (Validator.TryValidateObject(entity, context, results)) 90 | { 91 | await _service.SaveChangesAsync(); 92 | 93 | return Updated(entity); 94 | } 95 | else 96 | { 97 | return UnprocessableEntity(results); 98 | } 99 | } 100 | 101 | [HttpDelete] 102 | public virtual async Task Delete(int key) 103 | { 104 | if (await _service.DeleteAsync(key)) 105 | { 106 | return Ok(); 107 | } 108 | else 109 | { 110 | return NotFound($"{nameof(T)} with Id={key} not found"); 111 | } 112 | } 113 | } -------------------------------------------------------------------------------- /jobhunt/Controllers/OData/SearchController.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Controllers.OData; 2 | 3 | public class SearchController : ODataBaseController 4 | { 5 | public SearchController(ISearchService service) : base(service) { } 6 | } -------------------------------------------------------------------------------- /jobhunt/Controllers/OData/WatchedPageController.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Controllers.OData; 2 | 3 | public class WatchedPageController : ODataBaseController 4 | { 5 | public WatchedPageController(IWatchedPageService service) : base(service) { } 6 | } -------------------------------------------------------------------------------- /jobhunt/Controllers/RefreshController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | using JobHunt.PageWatcher; 5 | using JobHunt.Searching.Indeed; 6 | using JobHunt.Workers; 7 | using JobHunt.Searching.Indeed.GraphQL; 8 | using JobHunt.Searching; 9 | 10 | namespace JobHunt.Controllers; 11 | [ApiController] 12 | [Route("api/[controller]/[action]")] 13 | public class RefreshController : ControllerBase 14 | { 15 | private readonly IIndeedApiSearchProvider _indeed; 16 | private readonly IPageWatcher _pageWatcher; 17 | private readonly ISearchRefreshWorker _refreshWorker; 18 | private readonly IPageScreenshotWorker _screenshotWorker; 19 | private readonly IJobService _jobService; 20 | private readonly IWatchedPageService _wpService; 21 | 22 | public RefreshController( 23 | IIndeedApiSearchProvider indeed, 24 | IPageWatcher pageWatcher, 25 | ISearchRefreshWorker refreshWorker, 26 | IPageScreenshotWorker screenshotWorker, 27 | IJobService jobService, 28 | IWatchedPageService wpService) 29 | { 30 | _indeed = indeed; 31 | _pageWatcher = pageWatcher; 32 | _refreshWorker = refreshWorker; 33 | _screenshotWorker = screenshotWorker; 34 | _jobService = jobService; 35 | _wpService = wpService; 36 | } 37 | 38 | [HttpGet] 39 | public async Task Indeed(CancellationToken token) 40 | { 41 | await _indeed.SearchAllAsync(token); 42 | } 43 | 44 | [HttpGet] 45 | public async Task GetMissingSalaries(string country, [FromServices] IIndeedGraphQLService graphQL) 46 | { 47 | int updated = 0; 48 | 49 | DateTimeOffset start = new DateTime(2023, 02, 01, 0, 0, 0, DateTimeKind.Utc); 50 | List missing = await _jobService.Set.Where(j => j.Provider == SearchProviderName.Indeed && j.Posted >= start && !j.AvgYearlySalary.HasValue).ToListAsync(); 51 | List seen = new List(); 52 | 53 | int skip = 0; 54 | var batch = missing.Take(500); 55 | while (batch.Any()) 56 | { 57 | var results = await graphQL.GetJobDataAsync(batch.Select(b => b.ProviderId!), country); 58 | if (results != null) 59 | { 60 | foreach (var result in results.JobData.Results) 61 | { 62 | var job = missing.First(j => j.ProviderId == result.Job.Key); 63 | job.Salary = result.Job.Compensation?.GetFormattedText(); 64 | job.AvgYearlySalary = result.Job.Compensation?.GetAvgYearlySalary(); 65 | 66 | updated++; 67 | seen.Add(result.Job.Key); 68 | } 69 | } 70 | 71 | skip += 500; 72 | batch = missing.Skip(skip).Take(500); 73 | } 74 | 75 | await _jobService.SaveChangesAsync(); 76 | 77 | return new JsonResult(new 78 | { 79 | Updated = updated, 80 | Missing = missing.Select(j => j.ProviderId!).Except(seen) 81 | }); 82 | } 83 | 84 | [HttpGet] 85 | public async Task PageWatcher(CancellationToken token) 86 | { 87 | await _pageWatcher.RefreshAllAsync(token); 88 | } 89 | 90 | [HttpGet] 91 | public async Task All(CancellationToken token) 92 | { 93 | await _refreshWorker.DoRefreshAsync(token); 94 | } 95 | 96 | [HttpGet] 97 | public async Task Screenshot(CancellationToken token) 98 | { 99 | int numScreenshots = await _screenshotWorker.TakeScreenshotsAsync(token); 100 | return new JsonResult(numScreenshots); 101 | } 102 | 103 | [HttpGet] 104 | public async Task FindDuplicates(CancellationToken token, bool force = false) 105 | { 106 | await _jobService.CheckForDuplicatesAsync(force, token); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /jobhunt/Controllers/SearchesController.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | using JobHunt.Searching.Indeed; 6 | 7 | namespace JobHunt.Controllers; 8 | [ApiController] 9 | [Route("api/[controller]/[action]")] 10 | public class SearchesController : ControllerBase 11 | { 12 | private readonly ISearchService _searchService; 13 | private readonly IJobService _jobService; 14 | private readonly IIndeedApiSearchProvider _indeed; 15 | public SearchesController(ISearchService searchService, IJobService jobService, IIndeedApiSearchProvider indeed) 16 | { 17 | _searchService = searchService; 18 | _jobService = jobService; 19 | _indeed = indeed; 20 | } 21 | 22 | [HttpGet("{id}")] 23 | public async Task Refresh([FromRoute] int id, CancellationToken token) 24 | { 25 | Search? search = await _searchService.FindByIdAsync(id); 26 | if (search != default) 27 | { 28 | bool success = await _indeed.SearchAsync(search, token); 29 | if (success) 30 | { 31 | await _jobService.CheckForDuplicatesAsync(false, token); 32 | return Ok(); 33 | } 34 | else 35 | { 36 | return StatusCode(StatusCodes.Status500InternalServerError); 37 | } 38 | } 39 | else 40 | { 41 | return NotFound(); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /jobhunt/Controllers/WatchedPagesController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | using JobHunt.PageWatcher; 4 | 5 | namespace JobHunt.Controllers; 6 | [ApiController] 7 | [Route("api/[controller]/[action]")] 8 | public class WatchedPagesController : ControllerBase 9 | { 10 | private readonly IWatchedPageService _wpService; 11 | private readonly IWatchedPageChangeService _wpcService; 12 | 13 | public WatchedPagesController(IWatchedPageService wpService, IWatchedPageChangeService wpcService) 14 | { 15 | _wpService = wpService; 16 | _wpcService = wpcService; 17 | } 18 | 19 | [HttpGet] 20 | [Route("{id}")] 21 | public async Task Diff([FromRoute] int id) 22 | { 23 | (string? prev, string? current) = await _wpcService.GetDiffHtmlAsync(id); 24 | 25 | return new JsonResult(new 26 | { 27 | Previous = prev, 28 | Current = current 29 | }); 30 | } 31 | 32 | [HttpGet] 33 | [Route("{id}")] 34 | public async Task PreviousHtml([FromRoute] int id) 35 | { 36 | (string? prev, _) = await _wpcService.GetDiffHtmlAsync(id); 37 | 38 | return new ContentResult() 39 | { 40 | Content = prev, 41 | ContentType = "text/html" 42 | }; 43 | } 44 | 45 | [HttpGet] 46 | [Route("{id}")] 47 | public async Task Html([FromRoute] int id) 48 | { 49 | (_, string? current) = await _wpcService.GetDiffHtmlAsync(id); 50 | 51 | return new ContentResult() 52 | { 53 | Content = current, 54 | ContentType = "text/html" 55 | }; 56 | } 57 | 58 | [HttpGet] 59 | [Route("{id}")] 60 | public async Task Screenshot([FromRoute] int id) 61 | { 62 | var stream = await _wpcService.GetScreenshotAsync(id); 63 | if (stream != default) 64 | { 65 | return new FileStreamResult(stream, "image/webp"); 66 | } 67 | else 68 | { 69 | return new NotFoundResult(); 70 | } 71 | } 72 | 73 | [HttpGet("{id}")] 74 | public async Task Refresh([FromRoute] int id, [FromServices] IPageWatcher pageWatcher) 75 | { 76 | WatchedPage? page = await _wpService.FindByIdAsync(id); 77 | if (page != default) 78 | { 79 | await pageWatcher.RefreshAsync(page, new CancellationToken()); 80 | return Ok(); 81 | } 82 | else 83 | { 84 | return NotFound(); 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /jobhunt/DTO/JobCount.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.DTO; 2 | public class JobCount 3 | { 4 | public int Daily { get; set; } 5 | public int Weekly { get; set; } 6 | public int Monthly { get; set; } 7 | } -------------------------------------------------------------------------------- /jobhunt/Data/OData/CustomQueryBinder.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | 3 | using Microsoft.AspNetCore.OData.Query.Expressions; 4 | using Microsoft.OData.UriParser; 5 | 6 | namespace JobHunt.Data.OData; 7 | public class CustomFilterBinder : FilterBinder 8 | { 9 | private readonly IGeoFunctionBinder _geoBinder; 10 | 11 | public CustomFilterBinder(IGeoFunctionBinder geoBinder) 12 | { 13 | _geoBinder = geoBinder; 14 | } 15 | 16 | public override Expression BindSingleValueFunctionCallNode(SingleValueFunctionCallNode node, QueryBinderContext context) 17 | { 18 | if (_geoBinder.IsFunctionBound(node.Name)) 19 | { 20 | return _geoBinder.BindGeoFunction(node, context, BindArguments, false); 21 | } 22 | else 23 | { 24 | return base.BindSingleValueFunctionCallNode(node, context); 25 | } 26 | } 27 | } 28 | 29 | public class CustomOrderByBinder : OrderByBinder 30 | { 31 | private readonly IGeoFunctionBinder _geoBinder; 32 | 33 | public CustomOrderByBinder(IGeoFunctionBinder geoBinder) 34 | { 35 | _geoBinder = geoBinder; 36 | } 37 | 38 | public override Expression BindSingleValueFunctionCallNode(SingleValueFunctionCallNode node, QueryBinderContext context) 39 | { 40 | if (_geoBinder.IsFunctionBound(node.Name)) 41 | { 42 | return _geoBinder.BindGeoFunction(node, context, BindArguments, true); 43 | } 44 | else 45 | { 46 | return base.BindSingleValueFunctionCallNode(node, context); 47 | } 48 | } 49 | } 50 | 51 | public class CustomSelectExpandBinder : SelectExpandBinder 52 | { 53 | private readonly IGeoFunctionBinder _geoBinder; 54 | 55 | public CustomSelectExpandBinder(IFilterBinder filterBinder, IOrderByBinder orderByBinder, IGeoFunctionBinder geoBinder) : base(filterBinder, orderByBinder) 56 | { 57 | _geoBinder = geoBinder; 58 | } 59 | 60 | public override Expression BindSingleValueFunctionCallNode(SingleValueFunctionCallNode node, QueryBinderContext context) 61 | { 62 | if (_geoBinder.IsFunctionBound(node.Name)) 63 | { 64 | return _geoBinder.BindGeoFunction(node, context, BindArguments, false); 65 | } 66 | else 67 | { 68 | return base.BindSingleValueFunctionCallNode(node, context); 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /jobhunt/Data/OData/CustomUriFunctionUtils.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | using Microsoft.OData.Edm; 4 | using Microsoft.OData.UriParser; 5 | 6 | namespace JobHunt.Data.OData; 7 | public static class CustomUriFunctionUtils 8 | { 9 | public static void AddCustomUriFunction(MethodInfo methodInfo) 10 | { 11 | AddCustomUriFunction( 12 | methodInfo.Name.ToLower(), 13 | methodInfo.ReturnType, 14 | methodInfo.GetParameters().Select(p => p.ParameterType) 15 | ); 16 | } 17 | 18 | public static void AddCustomUriFunction(string name, Type returnType, params Type[] parameterTypes) 19 | { 20 | AddCustomUriFunction(name, returnType, parameterTypes.AsEnumerable()); 21 | } 22 | 23 | private static void AddCustomUriFunction(string name, Type returnType, IEnumerable parameterTypes) 24 | { 25 | IEdmPrimitiveTypeReference edmReturnType = EdmCoreModel.Instance.GetPrimitive( 26 | EdmCoreModel.Instance.GetPrimitiveTypeKind(returnType.TryGetUnderlyingType(out bool wasNullable).Name), 27 | wasNullable 28 | ); 29 | 30 | IEdmPrimitiveTypeReference[] edmParamTypes = parameterTypes 31 | .Select(p => EdmCoreModel.Instance.GetPrimitive( 32 | EdmCoreModel.Instance.GetPrimitiveTypeKind(p.TryGetUnderlyingType(out bool wasNullable).Name), 33 | wasNullable 34 | )) 35 | .ToArray(); 36 | 37 | CustomUriFunctions.AddCustomUriFunction( 38 | name, 39 | new FunctionSignatureWithReturnType(edmReturnType, edmParamTypes) 40 | ); 41 | } 42 | } -------------------------------------------------------------------------------- /jobhunt/Data/OData/GeoFunctionBinder.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | using System.Reflection; 3 | 4 | using Microsoft.AspNetCore.OData.Query.Expressions; 5 | using Microsoft.OData.UriParser; 6 | 7 | using JobHunt.Geocoding; 8 | 9 | namespace JobHunt.Data.OData; 10 | public class GeoFunctionBinder : IGeoFunctionBinder 11 | { 12 | private readonly JobHuntContext _context; 13 | private readonly IGeocoder _geocoder; 14 | 15 | public GeoFunctionBinder(JobHuntContext context, IGeocoder geocoder) 16 | { 17 | _context = context; 18 | _geocoder = geocoder; 19 | } 20 | 21 | public Expression BindGeoFunction( 22 | SingleValueFunctionCallNode node, 23 | QueryBinderContext context, 24 | Func, QueryBinderContext, Expression[]> bindArgs, // Why is the BindArguments function internal?? Very annoying 25 | bool isOrderBy 26 | ) 27 | { 28 | switch (node.Name) 29 | { 30 | case GeoFunctionBinder.GeoDistanceMethodName: 31 | Expression[] geoDistanceArgs = bindArgs(node.Parameters, context); 32 | 33 | return BindGeoDistance(geoDistanceArgs, _context); 34 | 35 | case GeoFunctionBinder.GeocodeMethodName: 36 | if (node.Parameters.First() is ConvertNode convert 37 | && convert.Source is ConstantNode constant 38 | && constant.Value is string location 39 | ) 40 | { 41 | Expression[] geocodeArgs = bindArgs(node.Parameters.Skip(1), context); 42 | 43 | return BindGeocode(location, geocodeArgs, _context, _geocoder, isOrderBy); 44 | } 45 | else 46 | { 47 | throw new InvalidOperationException("First parameter to geocode must be a string constant"); 48 | } 49 | 50 | default: 51 | throw new InvalidOperationException($"Unknown geo function: {node.Name}"); 52 | } 53 | } 54 | 55 | private static readonly IEnumerable _boundFunctionNames = new[] { GeocodeMethodName, GeoDistanceMethodName }; 56 | public bool IsFunctionBound(string name) => _boundFunctionNames.Contains(name); 57 | 58 | private const string GeoDistanceMethodName = "geodistance"; 59 | private readonly MethodInfo _geoDistanceMethodInfo = typeof(JobHuntContext).GetMethod(nameof(JobHuntContext.GeoDistance))!; 60 | private Expression BindGeoDistance(IEnumerable arguments, JobHuntContext dbContext) 61 | { 62 | return Expression.Call(Expression.Constant(dbContext), _geoDistanceMethodInfo, arguments.Select(a => ExtractValueFromNullableExpression(a))); 63 | } 64 | 65 | private const string GeocodeMethodName = "geocode"; 66 | private Expression BindGeocode(string location, IEnumerable arguments, JobHuntContext dbContext, IGeocoder geocoder, bool isOrderBy) 67 | { 68 | Coordinate? coord = geocoder.GeocodeAsync(location).Result; 69 | 70 | if (coord.HasValue) 71 | { 72 | List args = new List(4) { 73 | Expression.Constant(coord.Value.Latitude), 74 | Expression.Constant(coord.Value.Longitude) 75 | }; 76 | args.AddRange(arguments); 77 | 78 | return BindGeoDistance(args, dbContext); 79 | } 80 | else if (isOrderBy) 81 | { 82 | return Expression.Constant(0); // can't OrderBy with null so return 0 instead 83 | } 84 | else 85 | { 86 | return Expression.Constant(null); 87 | } 88 | } 89 | 90 | private static Expression ExtractValueFromNullableExpression(Expression source) 91 | { 92 | return source.Type.IsNullable() ? Expression.Property(source, "Value") : source; 93 | } 94 | } 95 | 96 | public interface IGeoFunctionBinder 97 | { 98 | Expression BindGeoFunction( 99 | SingleValueFunctionCallNode node, 100 | QueryBinderContext context, 101 | Func, QueryBinderContext, Expression[]> bindArgs, 102 | bool isOrderBy 103 | ); 104 | bool IsFunctionBound(string name); 105 | } -------------------------------------------------------------------------------- /jobhunt/Data/OData/ODataModelBuilder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.OData.ModelBuilder; 2 | using Microsoft.OData.Edm; 3 | 4 | namespace JobHunt.Data.OData; 5 | public static class ODataModelBuilder 6 | { 7 | public static IEdmModel Build() 8 | { 9 | var builder = new ODataConventionModelBuilder(); 10 | builder.EnableLowerCamelCase(); 11 | 12 | builder.EntitySet(nameof(Category)); 13 | 14 | builder.EntitySet(nameof(CompanyCategory)); 15 | builder.EntityType() 16 | .HasKey(cc => new { cc.CategoryId, cc.CompanyId }); 17 | 18 | builder.EntitySet(nameof(JobCategory)); 19 | builder.EntityType() 20 | .HasKey(jc => new { jc.CategoryId, jc.JobId }); 21 | 22 | builder.EntitySet(nameof(Company)); 23 | builder.EntitySet(nameof(CompanyName)); 24 | 25 | builder.EntitySet(nameof(Job)); 26 | 27 | builder.EntitySet(nameof(Search)); 28 | builder.AddUnmappedProperty(s => s.DisplayName); 29 | 30 | builder.EntitySet(nameof(WatchedPage)); 31 | 32 | return builder.GetEdmModel(); 33 | } 34 | } -------------------------------------------------------------------------------- /jobhunt/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | $(DefaultItemExcludes);$(MSBuildProjectDirectory)/obj/**/* 5 | $(DefaultItemExcludes);$(MSBuildProjectDirectory)/bin/**/* 6 | 7 | 8 | 9 | $(MSBuildProjectDirectory)/obj/container/ 10 | $(MSBuildProjectDirectory)/bin/container/ 11 | 12 | 13 | -------------------------------------------------------------------------------- /jobhunt/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build-env 2 | WORKDIR /app 3 | 4 | # Add repo for node 5 | RUN apt-get update 6 | RUN apt-get install -y ca-certificates curl gnupg 7 | RUN mkdir -p /etc/apt/keyrings 8 | RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg 9 | RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list 10 | 11 | RUN apt-get update 12 | RUN apt-get install -y nodejs jq unzip 13 | RUN npm install -g pnpm 14 | 15 | # Copy csproj and restore as distinct layers 16 | COPY *.csproj ./ 17 | RUN dotnet restore 18 | 19 | # Copy everything else and build 20 | ADD . ./ 21 | 22 | # download and install geckodriver 23 | RUN ./install-geckodriver.sh 24 | 25 | RUN dotnet publish -c Release -o out 26 | 27 | 28 | # Build runtime image 29 | FROM mcr.microsoft.com/dotnet/aspnet:7.0 30 | WORKDIR /app 31 | RUN apt-get update 32 | RUN curl -sL https://deb.nodesource.com/setup_16.x | bash - 33 | RUN apt-get install -y nodejs unzip libfreetype6 libfontconfig1 ca-certificates pandoc firefox-esr 34 | COPY --from=build-env /app/out . 35 | COPY --from=build-env /usr/local/bin/geckodriver /usr/local/bin/ 36 | ENTRYPOINT ["dotnet", "JobHunt.dll"] -------------------------------------------------------------------------------- /jobhunt/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build-env 2 | WORKDIR /app 3 | 4 | # Add repo for node 5 | RUN apt-get update 6 | RUN apt-get install -y ca-certificates curl gnupg 7 | RUN mkdir -p /etc/apt/keyrings 8 | RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg 9 | RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list 10 | 11 | # install node and npm 12 | RUN apt-get update 13 | RUN apt-get install -y --no-install-recommends nodejs unzip libfreetype6 libfontconfig1 ca-certificates procps pandoc firefox-esr jq 14 | 15 | # install pnpm 16 | RUN npm install -g pnpm 17 | 18 | # install Visual Studio debugger 19 | RUN curl -sSL https://aka.ms/getvsdbgsh | /bin/sh /dev/stdin -v latest -l /vsdbg 20 | 21 | # Copy csproj and restore as distinct layers 22 | COPY *.csproj ./ 23 | RUN dotnet restore 24 | 25 | # download and install geckodriver 26 | COPY install-geckodriver.sh ./ 27 | RUN ./install-geckodriver.sh 28 | 29 | ENTRYPOINT dotnet watch run --urls=https://+:5000 -------------------------------------------------------------------------------- /jobhunt/Extensions/HostBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using Serilog; 2 | using Serilog.Exceptions; 3 | using Serilog.Exceptions.Destructurers; 4 | using Serilog.Exceptions.Core; 5 | using Serilog.Exceptions.EntityFrameworkCore.Destructurers; 6 | using Serilog.Exceptions.Refit.Destructurers; 7 | 8 | namespace JobHunt.Extensions; 9 | 10 | public static class HostBuilderExtensions 11 | { 12 | /// 13 | /// Add the default Serilog configuration with destructurers 14 | /// 15 | public static IHostBuilder UseJobHuntSerilog(this IHostBuilder builder) 16 | { 17 | builder.UseSerilog((ctx, lc) => lc 18 | .Enrich.WithExceptionDetails(new DestructuringOptionsBuilder() 19 | .WithDefaultDestructurers() 20 | .WithDestructurers(new IExceptionDestructurer[] 21 | { 22 | new DbUpdateExceptionDestructurer(), 23 | new ApiExceptionDestructurer(destructureHttpContent: true) 24 | })) 25 | .Enrich.FromLogContext() 26 | .ReadFrom.Configuration(ctx.Configuration) 27 | ); 28 | 29 | return builder; 30 | } 31 | } -------------------------------------------------------------------------------- /jobhunt/Extensions/HttpExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | 3 | namespace JobHunt.Extensions; 4 | public static class HttpClientExtensions 5 | { 6 | public static async Task GetStatusCodeAsync(this HttpClient client, string requestUri) 7 | { 8 | HttpRequestMessage headRequest = new HttpRequestMessage(HttpMethod.Head, requestUri); 9 | using (var headResponse = await client.SendAsync(headRequest, HttpCompletionOption.ResponseHeadersRead)) 10 | { 11 | // retry as GET if HEAD not supported 12 | // good servers should return MethodNotAllowed in such a case, but I have seen some return NotFound 13 | if (headResponse.StatusCode == HttpStatusCode.MethodNotAllowed || headResponse.StatusCode == HttpStatusCode.NotFound) 14 | { 15 | using (var getResponse = await client.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead)) 16 | { 17 | return getResponse.StatusCode; 18 | } 19 | } 20 | else 21 | { 22 | return headResponse.StatusCode; 23 | } 24 | } 25 | } 26 | } 27 | 28 | public static class HttpStatusCodeExtensions 29 | { 30 | public static bool IsSuccessStatusCode(this HttpStatusCode code) 31 | { 32 | return (int) code >= 200 && (int) code <= 299; 33 | } 34 | } 35 | 36 | public static class UriExtensions 37 | { 38 | public static bool IsRelativeHttpUri(this Uri uri, string uriString) 39 | { 40 | // check if URI starts with // as this is actually an absolute URI, but C# doesn't recognise them 41 | return !uri.IsAbsoluteUri && !uriString.StartsWith("//"); 42 | } 43 | } -------------------------------------------------------------------------------- /jobhunt/Extensions/MvcBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.OData; 2 | using Microsoft.AspNetCore.OData.Batch; 3 | using Microsoft.AspNetCore.OData.Query.Expressions; 4 | 5 | using JobHunt.Data.OData; 6 | 7 | namespace JobHunt.Extensions; 8 | public static class MvcBuilderExtensions 9 | { 10 | public static IMvcBuilder AddJobHuntOData(this IMvcBuilder builder, IConfiguration configuration) 11 | { 12 | builder.AddOData(options => 13 | { 14 | options.Filter() 15 | .Select() 16 | .Expand() 17 | .Count() 18 | .OrderBy() 19 | .SkipToken() 20 | .SetMaxTop(500); 21 | 22 | options.TimeZone = TimeZoneInfo.Utc; 23 | 24 | var batchHandler = new DefaultODataBatchHandler(); 25 | options.AddRouteComponents( 26 | "api/odata", 27 | ODataModelBuilder.Build(), 28 | // add custom binders for GeoDistance function 29 | s => s.AddJobHuntCoreServices(configuration) // not sure why the core services also need adding here 30 | .AddScoped() 31 | .AddScoped() 32 | .AddScoped() 33 | .AddScoped() 34 | .AddSingleton(batchHandler) 35 | ); 36 | }); 37 | 38 | CustomUriFunctionUtils.AddCustomUriFunction(typeof(JobHuntContext).GetMethod(nameof(JobHuntContext.GeoDistance))!); 39 | CustomUriFunctionUtils.AddCustomUriFunction("geocode", typeof(double?), typeof(string), typeof(double), typeof(double)); 40 | 41 | return builder; 42 | } 43 | } -------------------------------------------------------------------------------- /jobhunt/Extensions/NumberExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Extensions; 2 | public static class NumberExtensions 3 | { 4 | public static string ToOrdinalString(this int number) 5 | { 6 | switch (number) 7 | { 8 | case int p when p % 100 == 11: 9 | case int q when q % 100 == 12: 10 | case int r when r % 100 == 13: 11 | return $"{number}th"; 12 | case int p when p % 10 == 1: 13 | return $"{number}st"; 14 | case int p when p % 10 == 2: 15 | return $"{number}nd"; 16 | case int p when p % 10 == 3: 17 | return $"{number}rd"; 18 | default: 19 | return $"{number}th"; 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /jobhunt/Extensions/ODataConventionModelBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | using System.Reflection; 3 | using Microsoft.OData.ModelBuilder; 4 | 5 | namespace JobHunt.Extensions; 6 | public static class ODataConventionModelBuilderExtensions 7 | { 8 | /// 9 | /// Add an unmapped property to the OData model to allow usage in $select clauses 10 | /// 11 | /// Entity type 12 | /// Lambda to select property 13 | public static void AddUnmappedProperty(this ODataConventionModelBuilder builder, Expression> propertySelector) 14 | { 15 | LambdaExpression lambda = propertySelector; 16 | 17 | MemberExpression member; 18 | if (lambda.Body is UnaryExpression unary) 19 | { 20 | member = (MemberExpression) unary.Operand; 21 | } 22 | else 23 | { 24 | member = (MemberExpression) lambda.Body; 25 | } 26 | 27 | builder.StructuralTypes.First(t => t.ClrType == typeof(T)).AddProperty((PropertyInfo) member.Member); 28 | } 29 | } -------------------------------------------------------------------------------- /jobhunt/Extensions/TypeExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Extensions; 2 | public static class TypeExtensions 3 | { 4 | public static bool IsNullable(this Type type) 5 | { 6 | return Nullable.GetUnderlyingType(type) != null; 7 | } 8 | 9 | public static Type TryGetUnderlyingType(this Type type, out bool wasNullable) 10 | { 11 | Type? underlying = Nullable.GetUnderlyingType(type); 12 | 13 | wasNullable = underlying != null; 14 | 15 | return underlying ?? type; 16 | } 17 | } -------------------------------------------------------------------------------- /jobhunt/Filters/ExceptionLogger.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.AspNetCore.Mvc.Filters; 5 | 6 | namespace JobHunt.Filters; 7 | public class ExceptionLogger : IAsyncExceptionFilter 8 | { 9 | private readonly ILogger _logger; 10 | public ExceptionLogger(ILogger logger) : base() 11 | { 12 | _logger = logger; 13 | } 14 | public Task OnExceptionAsync(ExceptionContext context) 15 | { 16 | _logger.LogError(context.Exception, "Uncaught exception thrown"); 17 | 18 | context.Result = new ContentResult { StatusCode = StatusCodes.Status500InternalServerError, Content = "JobHunt has encountered an error. Please try again or report an issue at https://github.com/jamerst/JobHunt/issues.", ContentType = "text/plain" }; 19 | return Task.CompletedTask; 20 | } 21 | } -------------------------------------------------------------------------------- /jobhunt/Geocoding/Coordinate.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Geocoding; 2 | public struct Coordinate 3 | { 4 | public double Latitude { get; set; } 5 | public double Longitude { get; set; } 6 | } -------------------------------------------------------------------------------- /jobhunt/Geocoding/IGeocoder.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Geocoding; 2 | public interface IGeocoder 3 | { 4 | Task GeocodeAsync(string location); 5 | } 6 | -------------------------------------------------------------------------------- /jobhunt/Geocoding/INominatimApi.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | using Refit; 4 | 5 | namespace JobHunt.Geocoding; 6 | 7 | [Headers("User-Agent: jamerst/JobHunt")] 8 | public interface INominatimApi 9 | { 10 | [Get("/search")] 11 | Task>> SearchAsync(GeocodeParams geocodeParams); 12 | } 13 | 14 | public class GeocodeParams 15 | { 16 | [AliasAs("q")] 17 | public required string Query { get; set; } 18 | 19 | [AliasAs("countrycodes")] 20 | public string? CountryCodes { get; set; } 21 | 22 | [AliasAs("limit")] 23 | public int Limit { get; set; } = 10; 24 | 25 | [AliasAs("format")] 26 | public string Format => "jsonv2"; 27 | } 28 | 29 | public class Location 30 | { 31 | [JsonPropertyName("lat")] 32 | public string? Latitude { get; set; } 33 | [JsonPropertyName("lon")] 34 | public string? Longitude { get; set; } 35 | } -------------------------------------------------------------------------------- /jobhunt/Geocoding/Nominatim.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Caching.Memory; 2 | 3 | using Refit; 4 | 5 | namespace JobHunt.Geocoding; 6 | public class Nominatim : IGeocoder 7 | { 8 | private readonly SearchOptions _options; 9 | private readonly ILogger _logger; 10 | private readonly INominatimApi _api; 11 | private readonly IMemoryCache _cache; 12 | 13 | public Nominatim(IOptions options, ILogger logger, INominatimApi api, IMemoryCache cache) 14 | { 15 | _options = options.Value; 16 | _logger = logger; 17 | _api = api; 18 | _cache = cache; 19 | } 20 | 21 | public async Task GeocodeAsync(string location) 22 | { 23 | if (_cache.TryGetValue($"Nominatim.GeocodeAsync_{location.ToLower()}", out var result)) 24 | { 25 | return result; 26 | } 27 | else 28 | { 29 | ApiResponse> response; 30 | try 31 | { 32 | response = (await _api.SearchAsync( 33 | new GeocodeParams 34 | { 35 | Query = location, 36 | CountryCodes = _options.NominatimCountryCodes, 37 | Limit = 1 38 | }) 39 | ); 40 | } 41 | catch (Exception ex) 42 | { 43 | _logger.LogError(ex, "Nominatim request exception for {location}", location); 44 | return null; 45 | } 46 | 47 | Coordinate? coord = null; 48 | if (response.IsSuccessStatusCode && response.Content != null) 49 | { 50 | var nominatimResult = response.Content.FirstOrDefault(); 51 | 52 | if (double.TryParse(nominatimResult?.Latitude, out double lat) && double.TryParse(nominatimResult?.Longitude, out double lng)) 53 | { 54 | coord = new Coordinate 55 | { 56 | Latitude = lat, 57 | Longitude = lng 58 | }; 59 | } 60 | } 61 | else 62 | { 63 | _logger.LogError("Nominatim request failed {@response}", response); 64 | } 65 | 66 | _cache.Set($"Nominatim.GeocodeAsync_{location.ToLower()}", coord); 67 | return coord; 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /jobhunt/GlobalUsing.cs: -------------------------------------------------------------------------------- 1 | global using System.ComponentModel.DataAnnotations; 2 | global using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | global using Microsoft.Extensions.Options; 5 | 6 | global using JobHunt.Configuration; 7 | global using JobHunt.Data; 8 | global using JobHunt.DTO; 9 | global using JobHunt.Extensions; 10 | global using JobHunt.Models; 11 | global using JobHunt.Services; 12 | global using JobHunt.Utils; -------------------------------------------------------------------------------- /jobhunt/Migrations/20210926181747_CompanyRecruiter.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | namespace JobHunt.Migrations 4 | { 5 | public partial class CompanyRecruiter : Migration 6 | { 7 | protected override void Up(MigrationBuilder migrationBuilder) 8 | { 9 | migrationBuilder.AddColumn( 10 | name: "Recruiter", 11 | table: "Companies", 12 | type: "boolean", 13 | nullable: false, 14 | defaultValue: false); 15 | } 16 | 17 | protected override void Down(MigrationBuilder migrationBuilder) 18 | { 19 | migrationBuilder.DropColumn( 20 | name: "Recruiter", 21 | table: "Companies"); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /jobhunt/Migrations/20220130142134_WatchedPageChange.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 4 | 5 | #nullable disable 6 | 7 | namespace JobHunt.Migrations 8 | { 9 | public partial class WatchedPageChange : Migration 10 | { 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.DropColumn( 14 | name: "Hash", 15 | table: "WatchedPages"); 16 | 17 | migrationBuilder.CreateTable( 18 | name: "WatchedPageChanges", 19 | columns: table => new 20 | { 21 | Id = table.Column(type: "integer", nullable: false) 22 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 23 | WatchedPageId = table.Column(type: "integer", nullable: false), 24 | Created = table.Column(type: "timestamp with time zone", nullable: false), 25 | Html = table.Column(type: "text", nullable: false) 26 | }, 27 | constraints: table => 28 | { 29 | table.PrimaryKey("PK_WatchedPageChanges", x => x.Id); 30 | table.ForeignKey( 31 | name: "FK_WatchedPageChanges_WatchedPages_WatchedPageId", 32 | column: x => x.WatchedPageId, 33 | principalTable: "WatchedPages", 34 | principalColumn: "Id", 35 | onDelete: ReferentialAction.Cascade); 36 | }); 37 | 38 | migrationBuilder.CreateIndex( 39 | name: "IX_WatchedPageChanges_WatchedPageId", 40 | table: "WatchedPageChanges", 41 | column: "WatchedPageId"); 42 | } 43 | 44 | protected override void Down(MigrationBuilder migrationBuilder) 45 | { 46 | migrationBuilder.DropTable( 47 | name: "WatchedPageChanges"); 48 | 49 | migrationBuilder.AddColumn( 50 | name: "Hash", 51 | table: "WatchedPages", 52 | type: "text", 53 | nullable: true); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /jobhunt/Migrations/20220324201057_WatchedPageChange_Screenshot.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace JobHunt.Migrations 6 | { 7 | public partial class WatchedPageChange_Screenshot : Migration 8 | { 9 | protected override void Up(MigrationBuilder migrationBuilder) 10 | { 11 | migrationBuilder.AddColumn( 12 | name: "ScreenshotFileName", 13 | table: "WatchedPageChanges", 14 | type: "text", 15 | nullable: true); 16 | } 17 | 18 | protected override void Down(MigrationBuilder migrationBuilder) 19 | { 20 | migrationBuilder.DropColumn( 21 | name: "ScreenshotFileName", 22 | table: "WatchedPageChanges"); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /jobhunt/Migrations/20220404191815_WatchedPage_RequiresJS.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace JobHunt.Migrations 6 | { 7 | public partial class WatchedPage_RequiresJS : Migration 8 | { 9 | protected override void Up(MigrationBuilder migrationBuilder) 10 | { 11 | migrationBuilder.AddColumn( 12 | name: "RequiresJS", 13 | table: "WatchedPages", 14 | type: "boolean", 15 | nullable: false, 16 | defaultValue: false); 17 | } 18 | 19 | protected override void Down(MigrationBuilder migrationBuilder) 20 | { 21 | migrationBuilder.DropColumn( 22 | name: "RequiresJS", 23 | table: "WatchedPages"); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /jobhunt/Migrations/20220626153835_DuplicateJobs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace JobHunt.Migrations 7 | { 8 | public partial class DuplicateJobs : Migration 9 | { 10 | protected override void Up(MigrationBuilder migrationBuilder) 11 | { 12 | migrationBuilder.AlterDatabase() 13 | .Annotation("Npgsql:PostgresExtension:pg_trgm", ",,"); 14 | 15 | migrationBuilder.AddColumn( 16 | name: "DuplicateJobId", 17 | table: "Jobs", 18 | type: "integer", 19 | nullable: true); 20 | 21 | migrationBuilder.CreateIndex( 22 | name: "IX_Jobs_Description", 23 | table: "Jobs", 24 | column: "Description") 25 | .Annotation("Npgsql:IndexMethod", "gin") 26 | .Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" }); 27 | 28 | migrationBuilder.CreateIndex( 29 | name: "IX_Jobs_DuplicateJobId", 30 | table: "Jobs", 31 | column: "DuplicateJobId"); 32 | 33 | migrationBuilder.CreateIndex( 34 | name: "IX_Jobs_Title", 35 | table: "Jobs", 36 | column: "Title") 37 | .Annotation("Npgsql:IndexMethod", "gin") 38 | .Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" }); 39 | 40 | migrationBuilder.AddForeignKey( 41 | name: "FK_Jobs_Jobs_DuplicateJobId", 42 | table: "Jobs", 43 | column: "DuplicateJobId", 44 | principalTable: "Jobs", 45 | principalColumn: "Id"); 46 | } 47 | 48 | protected override void Down(MigrationBuilder migrationBuilder) 49 | { 50 | migrationBuilder.DropForeignKey( 51 | name: "FK_Jobs_Jobs_DuplicateJobId", 52 | table: "Jobs"); 53 | 54 | migrationBuilder.DropIndex( 55 | name: "IX_Jobs_Description", 56 | table: "Jobs"); 57 | 58 | migrationBuilder.DropIndex( 59 | name: "IX_Jobs_DuplicateJobId", 60 | table: "Jobs"); 61 | 62 | migrationBuilder.DropIndex( 63 | name: "IX_Jobs_Title", 64 | table: "Jobs"); 65 | 66 | migrationBuilder.DropColumn( 67 | name: "DuplicateJobId", 68 | table: "Jobs"); 69 | 70 | migrationBuilder.AlterDatabase() 71 | .OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,"); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /jobhunt/Migrations/20220717140709_Job_ActualCompany_Deleted.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace JobHunt.Migrations 6 | { 7 | public partial class Job_ActualCompany_Deleted : Migration 8 | { 9 | protected override void Up(MigrationBuilder migrationBuilder) 10 | { 11 | migrationBuilder.AddColumn( 12 | name: "ActualCompanyId", 13 | table: "Jobs", 14 | type: "integer", 15 | nullable: true); 16 | 17 | migrationBuilder.AddColumn( 18 | name: "Deleted", 19 | table: "Jobs", 20 | type: "boolean", 21 | nullable: false, 22 | defaultValue: false); 23 | 24 | migrationBuilder.CreateIndex( 25 | name: "IX_Jobs_ActualCompanyId", 26 | table: "Jobs", 27 | column: "ActualCompanyId"); 28 | 29 | migrationBuilder.AddForeignKey( 30 | name: "FK_Jobs_Companies_ActualCompanyId", 31 | table: "Jobs", 32 | column: "ActualCompanyId", 33 | principalTable: "Companies", 34 | principalColumn: "Id", 35 | onDelete: ReferentialAction.SetNull); 36 | } 37 | 38 | protected override void Down(MigrationBuilder migrationBuilder) 39 | { 40 | migrationBuilder.DropForeignKey( 41 | name: "FK_Jobs_Companies_ActualCompanyId", 42 | table: "Jobs"); 43 | 44 | migrationBuilder.DropIndex( 45 | name: "IX_Jobs_ActualCompanyId", 46 | table: "Jobs"); 47 | 48 | migrationBuilder.DropColumn( 49 | name: "ActualCompanyId", 50 | table: "Jobs"); 51 | 52 | migrationBuilder.DropColumn( 53 | name: "Deleted", 54 | table: "Jobs"); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /jobhunt/Migrations/20220813150214_Company_Posted_Required.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace JobHunt.Migrations 7 | { 8 | public partial class Company_Posted_Required : Migration 9 | { 10 | protected override void Up(MigrationBuilder migrationBuilder) 11 | { 12 | migrationBuilder.AlterColumn( 13 | name: "Posted", 14 | table: "Jobs", 15 | type: "timestamp with time zone", 16 | nullable: false, 17 | defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), 18 | oldClrType: typeof(DateTime), 19 | oldType: "timestamp with time zone", 20 | oldNullable: true); 21 | 22 | migrationBuilder.AlterColumn( 23 | name: "CompanyId", 24 | table: "Jobs", 25 | type: "integer", 26 | nullable: false, 27 | defaultValue: 0, 28 | oldClrType: typeof(int), 29 | oldType: "integer", 30 | oldNullable: true); 31 | } 32 | 33 | protected override void Down(MigrationBuilder migrationBuilder) 34 | { 35 | migrationBuilder.AlterColumn( 36 | name: "Posted", 37 | table: "Jobs", 38 | type: "timestamp with time zone", 39 | nullable: true, 40 | oldClrType: typeof(DateTimeOffset), 41 | oldType: "timestamp with time zone"); 42 | 43 | migrationBuilder.AlterColumn( 44 | name: "CompanyId", 45 | table: "Jobs", 46 | type: "integer", 47 | nullable: true, 48 | oldClrType: typeof(int), 49 | oldType: "integer"); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /jobhunt/Migrations/20220814175931_Job_CheckedForDuplicate.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace JobHunt.Migrations 6 | { 7 | public partial class Job_CheckedForDuplicate : Migration 8 | { 9 | protected override void Up(MigrationBuilder migrationBuilder) 10 | { 11 | migrationBuilder.AddColumn( 12 | name: "CheckedForDuplicate", 13 | table: "Jobs", 14 | type: "boolean", 15 | nullable: false, 16 | defaultValue: false); 17 | } 18 | 19 | protected override void Down(MigrationBuilder migrationBuilder) 20 | { 21 | migrationBuilder.DropColumn( 22 | name: "CheckedForDuplicate", 23 | table: "Jobs"); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /jobhunt/Migrations/20240320201407_Job_Remote.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace JobHunt.Migrations 6 | { 7 | /// 8 | public partial class JobRemote : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.AddColumn( 14 | name: "Remote", 15 | table: "Jobs", 16 | type: "boolean", 17 | nullable: false, 18 | defaultValue: false); 19 | 20 | migrationBuilder.AlterColumn( 21 | name: "Location", 22 | table: "Companies", 23 | type: "text", 24 | nullable: true, 25 | oldClrType: typeof(string), 26 | oldType: "text"); 27 | } 28 | 29 | /// 30 | protected override void Down(MigrationBuilder migrationBuilder) 31 | { 32 | migrationBuilder.DropColumn( 33 | name: "Remote", 34 | table: "Jobs"); 35 | 36 | migrationBuilder.AlterColumn( 37 | name: "Location", 38 | table: "Companies", 39 | type: "text", 40 | nullable: false, 41 | defaultValue: "", 42 | oldClrType: typeof(string), 43 | oldType: "text", 44 | oldNullable: true); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /jobhunt/Models/Alert.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Models; 2 | public class Alert 3 | { 4 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 5 | public int Id { get; set; } 6 | public bool Read { get; set; } 7 | public required string Type { get; set; } 8 | public required string Title { get; set; } 9 | public string? Message { get; set; } 10 | public string? Url { get; set; } 11 | public DateTimeOffset Created { get; set; } 12 | } -------------------------------------------------------------------------------- /jobhunt/Models/AlertType.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Models; 2 | public static class AlertType 3 | { 4 | public const string NewJob = "NewJob"; 5 | public const string PageUpdate = "PageUpdate"; 6 | public const string Error = "Error"; 7 | } -------------------------------------------------------------------------------- /jobhunt/Models/Category.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Models; 2 | public class Category : KeyedEntity 3 | { 4 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 5 | public int Id { get; set; } 6 | public required string Name { get; set; } 7 | public IList CompanyCategories { get; set; } = null!; 8 | public IList JobCategories { get; set; } = null!; 9 | } -------------------------------------------------------------------------------- /jobhunt/Models/Company.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Models; 2 | public class Company : KeyedEntity 3 | { 4 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 5 | public int Id { get; set; } 6 | [Required] 7 | public required string Name { get; set; } 8 | public string? Location { get; set; } 9 | public double? Latitude { get; set; } 10 | public double? Longitude { get; set; } 11 | public string? Notes { get; set; } 12 | public bool Watched { get; set; } = false; 13 | public bool Blacklisted { get; set; } = false; 14 | public required List Jobs { get; set; } 15 | public string? Website { get; set; } 16 | public short? Rating { get; set; } 17 | public string? Glassdoor { get; set; } 18 | public string? GlassdoorId { get; set; } 19 | public float? GlassdoorRating { get; set; } 20 | public string? LinkedIn { get; set; } 21 | public string? Endole { get; set; } 22 | public bool Recruiter { get; set; } 23 | public List WatchedPages { get; set; } = null!; 24 | public List CompanyCategories { get; set; } = null!; 25 | public List AlternateNames { get; set; } = null!; 26 | } -------------------------------------------------------------------------------- /jobhunt/Models/CompanyCategory.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Models; 2 | public class CompanyCategory 3 | { 4 | [Required] 5 | public int CompanyId { get; set; } 6 | public Company Company { get; set; } = null!; 7 | [Required] 8 | public int CategoryId { get; set; } 9 | public Category Category { get; set; } = null!; 10 | } -------------------------------------------------------------------------------- /jobhunt/Models/CompanyName.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Models; 2 | public class CompanyName : KeyedEntity 3 | { 4 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 5 | public int Id { get; set; } 6 | public int CompanyId { get; set; } 7 | public Company Company { get; set; } = null!; 8 | public required string Name { get; set; } 9 | } -------------------------------------------------------------------------------- /jobhunt/Models/Job.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Models; 2 | public class Job : KeyedEntity 3 | { 4 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 5 | public int Id { get; set; } 6 | [Required] 7 | public required string Title { get; set; } 8 | [Required] 9 | public required string Description { get; set; } 10 | public string? Salary { get; set; } 11 | public int? AvgYearlySalary { get; set; } 12 | public bool Remote { get; set; } 13 | public required string Location { get; set; } 14 | public double? Latitude { get; set; } 15 | public double? Longitude { get; set; } 16 | public string? Url { get; set; } 17 | [Required] 18 | public int CompanyId { get; set; } 19 | public Company Company { get; set; } = null!; 20 | public DateTimeOffset Posted { get; set; } 21 | public string? Notes { get; set; } 22 | public bool Seen { get; set; } = false; 23 | public bool Archived { get; set; } = false; 24 | public string Status { get; set; } = JobStatus.NotApplied; 25 | public DateTimeOffset? DateApplied { get; set; } 26 | public List JobCategories { get; set; } = null!; 27 | public string? Provider { get; set; } 28 | public string? ProviderId { get; set; } 29 | public int? SourceId { get; set; } 30 | public Search? Source { get; set; } 31 | public int? DuplicateJobId { get; set; } 32 | public Job? DuplicateJob { get; set; } 33 | public int? ActualCompanyId { get; set; } 34 | public Company? ActualCompany { get; set; } 35 | public bool Deleted { get; set; } 36 | public bool CheckedForDuplicate { get; set; } 37 | } -------------------------------------------------------------------------------- /jobhunt/Models/JobCategory.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Models; 2 | public class JobCategory 3 | { 4 | [Required] 5 | public int JobId { get; set; } 6 | public Job Job { get; set; } = null!; 7 | [Required] 8 | public int CategoryId { get; set; } 9 | public Category Category { get; set; } = null!; 10 | } -------------------------------------------------------------------------------- /jobhunt/Models/JobStatus.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Models; 2 | public class JobStatus 3 | { 4 | public const string 5 | NotApplied = "Not Applied", 6 | AwaitingResponse = "Awaiting Response", 7 | InProgress = "In Progress", 8 | Rejected = "Rejected", 9 | DroppedOut = "Dropped Out"; 10 | } -------------------------------------------------------------------------------- /jobhunt/Models/KeyedEntity.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Models; 2 | public interface KeyedEntity 3 | { 4 | int Id { get; set; } 5 | } -------------------------------------------------------------------------------- /jobhunt/Models/Search.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Models; 2 | public class Search : KeyedEntity 3 | { 4 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 5 | public int Id { get; set; } 6 | public required string Provider { get; set; } 7 | public required string Query { get; set; } 8 | public required string Country { get; set; } 9 | public string? Location { get; set; } 10 | public int? Distance { get; set; } 11 | public int? MaxAge { get; set; } 12 | public int? LastResultCount { get; set; } 13 | public bool? LastFetchSuccess { get; set; } 14 | public bool Enabled { get; set; } = true; 15 | public bool EmployerOnly { get; set; } 16 | public string? JobType { get; set; } 17 | public DateTimeOffset? LastRun { get; set; } 18 | public IList FoundJobs { get; set; } = null!; 19 | public IList Runs { get; set; } = null!; 20 | 21 | public override string ToString() 22 | { 23 | return $"{Query} jobs in {Location} on {Provider}"; 24 | } 25 | 26 | // [NotMapped] 27 | public string DisplayName => ToString(); 28 | } -------------------------------------------------------------------------------- /jobhunt/Models/SearchRun.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Models; 2 | public class SearchRun 3 | { 4 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 5 | public int Id { get; set; } 6 | public int SearchId { get; set; } 7 | public Search Search { get; set; } = null!; 8 | public DateTimeOffset Time { get; set; } 9 | public bool Success { get; set; } 10 | public string? Message { get; set; } 11 | public int NewJobs { get; set; } 12 | public int NewCompanies { get; set; } 13 | public int TimeTaken { get; set; } 14 | } -------------------------------------------------------------------------------- /jobhunt/Models/WatchedPage.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Models; 2 | public class WatchedPage : KeyedEntity 3 | { 4 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 5 | public int Id { get; set; } 6 | public int CompanyId { get; set; } 7 | public Company Company { get; set; } = null!; 8 | public required string Url { get; set; } 9 | public string? CssSelector { get; set; } 10 | public string? CssBlacklist { get; set; } 11 | public DateTimeOffset? LastScraped { get; set; } 12 | public DateTimeOffset? LastUpdated { get; set; } 13 | public string? StatusMessage { get; set; } 14 | public bool Enabled { get; set; } = true; 15 | public bool RequiresJS { get; set; } 16 | public List Changes { get; set; } = null!; 17 | } -------------------------------------------------------------------------------- /jobhunt/Models/WatchedPageChange.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Models; 2 | public class WatchedPageChange : KeyedEntity 3 | { 4 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 5 | public int Id { get; set; } 6 | public int WatchedPageId { get; set; } 7 | public WatchedPage WatchedPage { get; set; } = null!; 8 | public DateTimeOffset Created { get; set; } 9 | public required string Html { get; set; } 10 | public string? ScreenshotFileName { get; set; } 11 | } -------------------------------------------------------------------------------- /jobhunt/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Text.Json.Serialization; 3 | 4 | using Microsoft.AspNetCore.OData; 5 | 6 | using AspNetCore.SpaServices.ViteDevelopmentServer; 7 | using Serilog; 8 | 9 | using JobHunt.Filters; 10 | using JobHunt.Workers; 11 | 12 | var builder = WebApplication.CreateBuilder(); 13 | 14 | builder.Host.UseJobHuntSerilog(); 15 | 16 | builder.WebHost.UseKestrel(options => options.ListenAnyIP(5000)); 17 | 18 | string? cultureName = builder.Configuration.GetValue("CultureName"); 19 | if (!string.IsNullOrEmpty(cultureName)) 20 | { 21 | var cultureInfo = new CultureInfo(cultureName); 22 | CultureInfo.DefaultThreadCurrentCulture = cultureInfo; 23 | CultureInfo.DefaultThreadCurrentUICulture = cultureInfo; 24 | } 25 | 26 | builder.Services.AddHostedService(); 27 | builder.Services.AddHostedService(); 28 | 29 | if (builder.Environment.IsDevelopment()) 30 | { 31 | builder.Services.AddCors(builder => 32 | { 33 | builder.AddDefaultPolicy(policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); 34 | }); 35 | } 36 | 37 | builder.Services 38 | .AddControllers(options => 39 | { 40 | options.Filters.Add(typeof(ExceptionLogger)); 41 | options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true; 42 | }) 43 | .AddJobHuntOData(builder.Configuration) 44 | .AddJsonOptions(options => 45 | { 46 | options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; 47 | options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; 48 | }); 49 | 50 | builder.Services.AddSpaStaticFiles(configuration => configuration.RootPath = "client/build"); 51 | 52 | builder.Services.AddJobHuntCoreServices(builder.Configuration); 53 | builder.Services.AddIndeedApiSearchProvider(builder.Configuration); 54 | builder.Services.AddPageWatcher(); 55 | builder.Services.AddJobHuntWorkers(); 56 | 57 | try 58 | { 59 | Log.Information("Starting web host"); 60 | 61 | var app = builder.Build(); 62 | 63 | if (app.Environment.IsDevelopment()) 64 | { 65 | app.UseDeveloperExceptionPage(); 66 | app.UseODataRouteDebug(); 67 | } 68 | else 69 | { 70 | app.UseExceptionHandler("/Error"); 71 | } 72 | 73 | app.UseODataBatching(); 74 | 75 | app.UseStaticFiles(); 76 | app.UseSpaStaticFiles(); 77 | 78 | app.UseRouting(); 79 | 80 | app.UseCors(); 81 | #pragma warning disable ASP0014 // Suggest using top level route registrations 82 | // disable warning - using app.MapControllers() breaks OData 83 | app.UseEndpoints(endpoints => endpoints.MapControllers()); 84 | #pragma warning restore ASP0014 // Suggest using top level route registrations 85 | 86 | app.UseSpa(spa => 87 | { 88 | spa.Options.SourcePath = "client"; 89 | 90 | if (app.Environment.IsDevelopment()) 91 | { 92 | spa.Options.DevServerPort = 5001; 93 | spa.UseViteDevelopmentServer("start"); 94 | } 95 | }); 96 | 97 | app.Run(); 98 | } 99 | catch (Exception ex) 100 | { 101 | Log.Fatal(ex, "Host terminated unexpectedly"); 102 | return 1; 103 | } 104 | finally 105 | { 106 | Log.CloseAndFlush(); 107 | } 108 | 109 | return 0; -------------------------------------------------------------------------------- /jobhunt/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:7115", 7 | "sslPort": 44341 8 | } 9 | }, 10 | "profiles": { 11 | "IIS Express": { 12 | "commandName": "IISExpress", 13 | "launchBrowser": true, 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Development" 16 | } 17 | }, 18 | "src": { 19 | "commandName": "Project", 20 | "launchBrowser": true, 21 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 22 | "environmentVariables": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | } 25 | }, 26 | "profiles": { 27 | "dotnet": { 28 | "commandName": "Project", 29 | "hotReloadProfile": "aspnetcore" 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /jobhunt/Searching/ISearchProvider.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Searching; 2 | public interface ISearchProvider 3 | { 4 | Task SearchAllAsync(CancellationToken token); 5 | Task SearchAsync(Search searchParams, CancellationToken token); 6 | } -------------------------------------------------------------------------------- /jobhunt/Searching/Indeed/GraphQL/Employer.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Searching.Indeed.GraphQL; 2 | 3 | public class Employer 4 | { 5 | public required string Name { get; set; } 6 | } -------------------------------------------------------------------------------- /jobhunt/Searching/Indeed/GraphQL/JobAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Searching.Indeed.GraphQL; 2 | 3 | public class JobAttribute 4 | { 5 | public required string Label { get; set; } 6 | } -------------------------------------------------------------------------------- /jobhunt/Searching/Indeed/GraphQL/JobCompensation.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | 4 | namespace JobHunt.Searching.Indeed.GraphQL; 5 | 6 | public class JobCompensation 7 | { 8 | public Salary? BaseSalary { get; set; } 9 | public JobEstimatedCompensation? Estimated { get; set; } 10 | public string? FormattedText { get; set; } 11 | 12 | public string? GetFormattedText() => FormattedText ?? Estimated?.GetFormattedText(); 13 | public int? GetAvgYearlySalary() => BaseSalary?.GetAvgYearlySalary() ?? Estimated?.BaseSalary?.GetAvgYearlySalary(); 14 | } 15 | 16 | public class JobEstimatedCompensation 17 | { 18 | public required Salary? BaseSalary { get; set; } 19 | public required string? FormattedText { get; set; } 20 | public string? GetFormattedText() => !string.IsNullOrEmpty(FormattedText) 21 | ? $"{FormattedText} (estimated)" 22 | : null; 23 | } 24 | 25 | public class Salary 26 | { 27 | public required ISalaryType Range { get; set; } 28 | public SalaryUnit UnitOfWork { get; set; } 29 | 30 | public int GetAvgYearlySalary() 31 | { 32 | int avgSalary = (int)Range.GetAvgSalary(); 33 | 34 | return UnitOfWork switch 35 | { 36 | SalaryUnit.Year => avgSalary, 37 | SalaryUnit.Quarter => avgSalary * 4, 38 | SalaryUnit.Month => avgSalary * 12, 39 | SalaryUnit.BiWeek => avgSalary * 24, 40 | SalaryUnit.Week => avgSalary * 48, 41 | SalaryUnit.Day => avgSalary * 48 * 5, 42 | SalaryUnit.Hour => avgSalary * 48 * 5 * 8, 43 | _ => throw new InvalidOperationException($"Unknown salary unit {UnitOfWork}") 44 | }; 45 | } 46 | } 47 | 48 | [JsonPolymorphic(TypeDiscriminatorPropertyName = "__typename")] 49 | [JsonDerivedType(typeof(AtLeastSalary), AtLeastSalary.TypeName)] 50 | [JsonDerivedType(typeof(AtMostSalary), AtMostSalary.TypeName)] 51 | [JsonDerivedType(typeof(ExactlySalary), ExactlySalary.TypeName)] 52 | [JsonDerivedType(typeof(RangeSalary), RangeSalary.TypeName)] 53 | public interface ISalaryType 54 | { 55 | double GetAvgSalary(); 56 | } 57 | 58 | public class AtLeastSalary : ISalaryType 59 | { 60 | public const string TypeName = "AtLeast"; 61 | 62 | public double Min { get; set; } 63 | 64 | public double GetAvgSalary() 65 | { 66 | return Min; 67 | } 68 | } 69 | 70 | public class AtMostSalary : ISalaryType 71 | { 72 | public const string TypeName = "AtMost"; 73 | 74 | public double Max { get; set; } 75 | 76 | public double GetAvgSalary() 77 | { 78 | return Max; 79 | } 80 | } 81 | 82 | public class ExactlySalary : ISalaryType 83 | { 84 | public const string TypeName = "Exactly"; 85 | 86 | public double Value { get; set; } 87 | 88 | public double GetAvgSalary() 89 | { 90 | return Value; 91 | } 92 | } 93 | 94 | public class RangeSalary : ISalaryType 95 | { 96 | public const string TypeName = "Range"; 97 | 98 | public double Min { get; set; } 99 | public double Max { get; set; } 100 | 101 | public double GetAvgSalary() 102 | { 103 | if (Min < 1) 104 | { 105 | return Max; 106 | } 107 | else if (Max < 1) 108 | { 109 | return Min; 110 | } 111 | else 112 | { 113 | return (Min + Max) / 2; 114 | } 115 | } 116 | } 117 | 118 | public enum SalaryUnit 119 | { 120 | [Display(Name = "YEAR")] 121 | Year, 122 | 123 | [Display(Name = "QUARTER")] 124 | Quarter, 125 | 126 | [Display(Name = "MONTH")] 127 | Month, 128 | 129 | [Display(Name = "BIWEEK")] 130 | BiWeek, 131 | 132 | [Display(Name = "WEEK")] 133 | Week, 134 | 135 | [Display(Name = "DAY")] 136 | Day, 137 | 138 | [Display(Name = "HOUR")] 139 | Hour 140 | } -------------------------------------------------------------------------------- /jobhunt/Searching/Indeed/GraphQL/JobDataResponse.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Searching.Indeed.GraphQL; 2 | 3 | public class JobDataResponse 4 | { 5 | public required JobDataResults JobData { get; set; } 6 | } 7 | 8 | public class JobDataResults 9 | { 10 | public required List Results { get; set; } 11 | } 12 | 13 | public class JobDataResultWrapper 14 | { 15 | public required JobDataResult Job { get; set; } 16 | } 17 | 18 | public class JobDataResult 19 | { 20 | public JobCompensation? Compensation { get; set; } 21 | public required string Key { get; set; } 22 | public JobDescription? Description { get; set; } 23 | public required List Attributes { get; set; } 24 | } 25 | -------------------------------------------------------------------------------- /jobhunt/Searching/Indeed/GraphQL/JobDescription.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Searching.Indeed.GraphQL; 2 | 3 | public class JobDescription 4 | { 5 | public required string Html { get; set; } 6 | } -------------------------------------------------------------------------------- /jobhunt/Searching/Indeed/GraphQL/JobLocation.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Searching.Indeed.GraphQL; 2 | 3 | public class JobLocation 4 | { 5 | public required FormattedJobLocation Formatted { get; set; } 6 | public double Latitude { get; set; } 7 | public double Longitude { get; set; } 8 | } 9 | 10 | public class FormattedJobLocation 11 | { 12 | public required string Long { get; set; } 13 | } -------------------------------------------------------------------------------- /jobhunt/Searching/Indeed/GraphQL/JobSearchResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace JobHunt.Searching.Indeed.GraphQL; 4 | 5 | public class JobSearchResponse 6 | { 7 | public required JobSearch JobSearch { get; set; } 8 | } 9 | 10 | public class JobSearch 11 | { 12 | public required List Results { get; set; } 13 | public required JobSearchPageInfo PageInfo { get; set; } 14 | } 15 | 16 | public class JobSearchResult 17 | { 18 | public required IndeedJob Job { get; set; } 19 | 20 | public JobResult ToJobResult(SearchOptions options) 21 | { 22 | var result = new JobResult 23 | { 24 | Key = Job.Key, 25 | Title = Job.Title, 26 | Url = $"https://{options.Indeed.HostName}/viewjob?jk={Job.Key}", 27 | HtmlDescription = Job.Description?.Html, 28 | Location = Job.Location.Formatted.Long, 29 | EmployerName = Job.SourceEmployerName, 30 | Posted = Job.DateOnIndeed, 31 | Attributes = Job.Attributes.Select(a => a.Label), 32 | FormattedSalary = Job.Compensation?.GetFormattedText(), 33 | AvgYearlySalary = Job.Compensation?.GetAvgYearlySalary() 34 | }; 35 | 36 | if (result.Location.ToLower() == "remote" 37 | || (Job.Location.Latitude == 25 && Job.Location.Longitude == -40)) // Indeed uses those coords for remote jobs for some reason 38 | { 39 | result.Remote = true; 40 | } 41 | else 42 | { 43 | result.Latitude = Job.Location.Latitude; 44 | result.Longitude = Job.Location.Longitude; 45 | } 46 | 47 | if (!string.IsNullOrEmpty(Job.Employer?.Name) && Job.Employer.Name.ToLower() != result.EmployerName.ToLower()) 48 | { 49 | result.AlternativeEmployerName = Job.Employer.Name; 50 | } 51 | 52 | return result; 53 | } 54 | } 55 | 56 | public class IndeedJob 57 | { 58 | public required string Key { get; set; } 59 | public required string Title { get; set; } 60 | public JobDescription? Description { get; set; } 61 | public required JobLocation Location { get; set; } 62 | public required string SourceEmployerName { get; set; } 63 | public Employer? Employer { get; set; } 64 | [JsonConverter(typeof(UnixEpochDateTimeOffsetConverter))] 65 | public DateTimeOffset DateOnIndeed { get; set; } 66 | public required List Attributes { get; set; } 67 | public JobCompensation? Compensation { get; set; } 68 | } 69 | 70 | public class JobSearchPageInfo 71 | { 72 | public string? NextCursor { get; set; } 73 | } 74 | -------------------------------------------------------------------------------- /jobhunt/Searching/Indeed/GraphQL/JobSearchVariables.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace JobHunt.Searching.Indeed.GraphQL; 4 | 5 | public class JobSearchVariables 6 | { 7 | public string? Cursor { get; set; } 8 | public string? Query { get; set; } 9 | public JobSearchLocationInput? Location { get; set; } 10 | public List Filters { get; set; } = new List(); 11 | public int Limit { get; set; } 12 | } 13 | 14 | public class JobSearchLocationInput 15 | { 16 | public required string Where { get; set; } 17 | public int Radius { get; set; } 18 | public required string RadiusUnit { get; set; } 19 | } 20 | 21 | public class JobSearchFilterInput 22 | { 23 | public JobSearchDateRangeFilterInput? Date { get; set; } 24 | } 25 | 26 | public class JobSearchDateRangeFilterInput 27 | { 28 | public required string Field { get; set; } 29 | public string? Start { get; set; } 30 | public string? End { get; set; } 31 | } -------------------------------------------------------------------------------- /jobhunt/Searching/Indeed/GraphQL/UnixEpochDateTimeOffsetConverter.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace JobHunt.Searching.Indeed.GraphQL; 5 | 6 | sealed class UnixEpochDateTimeOffsetConverter : JsonConverter 7 | { 8 | static readonly DateTimeOffset s_epoch = new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); 9 | 10 | public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 11 | { 12 | long timestamp = reader.GetInt64()!; 13 | 14 | return s_epoch.AddMilliseconds(timestamp); 15 | } 16 | 17 | public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) 18 | { 19 | throw new NotImplementedException(); 20 | } 21 | } -------------------------------------------------------------------------------- /jobhunt/Searching/Indeed/IIndeedJobFetcher.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Searching.Indeed; 2 | 3 | public interface IIndeedJobFetcher 4 | { 5 | Task JobSearchAsync(Search search, Func, Task> processResults, CancellationToken token); 6 | 7 | Task AfterSearchCompleteAsync(Search search, IEnumerable jobs, CancellationToken token); 8 | } -------------------------------------------------------------------------------- /jobhunt/Searching/Indeed/JobResult.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Searching.Indeed; 2 | 3 | public class JobResult 4 | { 5 | public required string Key { get; set; } 6 | public required string Title { get; set; } 7 | public required string Url { get; set; } 8 | public string? HtmlDescription { get; set; } 9 | public bool Remote { get; set; } 10 | public required string Location { get; set;} 11 | public double? Latitude { get; set; } 12 | public double? Longitude { get; set; } 13 | public required string EmployerName { get; set; } 14 | public string? AlternativeEmployerName { get; set; } 15 | public DateTimeOffset Posted { get; set; } 16 | public required IEnumerable Attributes { get; set; } 17 | public string? FormattedSalary { get; set; } 18 | public int? AvgYearlySalary { get; set; } 19 | } -------------------------------------------------------------------------------- /jobhunt/Searching/Indeed/Publisher/IIndeedJobDescriptionApi.cs: -------------------------------------------------------------------------------- 1 | using Refit; 2 | 3 | namespace JobHunt.Searching.Indeed.Publisher; 4 | 5 | public interface IIndeedJobDescriptionApi 6 | { 7 | [Get("/rpc/jobdescs")] 8 | Task>> GetJobDescriptionsAsync([AliasAs("jks")][Query(CollectionFormat.Csv)] IEnumerable jobKeys); 9 | } -------------------------------------------------------------------------------- /jobhunt/Searching/Indeed/Publisher/IIndeedPublisherApi.cs: -------------------------------------------------------------------------------- 1 | using Refit; 2 | 3 | namespace JobHunt.Searching.Indeed.Publisher; 4 | 5 | public interface IIndeedPublisherApi 6 | { 7 | [Get("/ads/apisearch")] 8 | Task> SearchAsync(JobSearchParams searchParams); 9 | } 10 | 11 | public class JobSearchParams 12 | { 13 | public JobSearchParams(string publisherId, Search search) 14 | { 15 | PublisherId = publisherId; 16 | Query = search.Query; 17 | Country = search.Country; 18 | Location = search.Location; 19 | Radius = search.Distance; 20 | FromAge = search.MaxAge; 21 | 22 | if (search.EmployerOnly) 23 | { 24 | // presuming this means "exclude recruiter" 25 | // to exclude direct hire you can use 0bf:exdh(); 26 | EmployerType = "0bf:exrec();"; 27 | } 28 | 29 | JobType = search.JobType; 30 | } 31 | 32 | [AliasAs("publisher")] 33 | public string PublisherId { get; private set; } 34 | 35 | [AliasAs("q")] 36 | public string Query { get; set; } 37 | 38 | [AliasAs("co")] 39 | public string Country { get; set; } 40 | 41 | [AliasAs("l")] 42 | public string? Location { get; set; } 43 | 44 | [AliasAs("radius")] 45 | public int? Radius { get; set; } 46 | 47 | [AliasAs("fromage")] 48 | public int? FromAge { get; set; } 49 | 50 | [AliasAs("sc")] 51 | public string? EmployerType { get; private set; } 52 | 53 | [AliasAs("jt")] 54 | public string? JobType { get; set; } 55 | 56 | [AliasAs("start")] 57 | public int Start { get; set; } 58 | 59 | #region Constant Parameters 60 | [AliasAs("sort")] 61 | public string Sort => "date"; 62 | 63 | [AliasAs("limit")] 64 | public int PageSize => IndeedApiSearchProvider.PageSize; 65 | 66 | [AliasAs("format")] 67 | public string Format => "json"; 68 | 69 | [AliasAs("userip")] 70 | public string UserIP => "1.2.3.4"; 71 | 72 | [AliasAs("useragent")] 73 | public string UserAgent => "Mozilla//4.0(Firefox)"; 74 | 75 | [AliasAs("latlong")] 76 | public int ReturnCoordinates => 1; 77 | 78 | [AliasAs("v")] 79 | public int Version => 2; 80 | 81 | [AliasAs("filter")] 82 | public int FilterDuplicates => 0; 83 | #endregion 84 | } -------------------------------------------------------------------------------- /jobhunt/Searching/Indeed/Publisher/IIndeedSalaryApi.cs: -------------------------------------------------------------------------------- 1 | using Refit; 2 | 3 | namespace JobHunt.Searching.Indeed.Publisher; 4 | 5 | public interface IIndeedSalaryApi 6 | { 7 | [Get("/viewjob?vjs=1&jk={jobKey}")] 8 | [Headers("User-Agent: PostmanRuntime/7.29.2")] 9 | Task> GetSalaryAsync(string jobKey); 10 | } 11 | 12 | // need to use a factory to create these since the domain depends on the job location and the base address for a HTTPClient cannot be changed 13 | // we need to use the same domain as the country that the job is advertised in order to get the salary in the local currency 14 | public class IndeedSalaryApiFactory : IIndeedSalaryApiFactory 15 | { 16 | private readonly IHttpClientFactory _clientFactory; 17 | public IndeedSalaryApiFactory(IHttpClientFactory clientFactory) 18 | { 19 | _clientFactory = clientFactory; 20 | } 21 | 22 | public IIndeedSalaryApi CreateApi(string baseAddress) 23 | { 24 | var client = _clientFactory.CreateClient(baseAddress); 25 | client.BaseAddress = new Uri(baseAddress); 26 | 27 | return RestService.For(client); 28 | } 29 | } 30 | 31 | public interface IIndeedSalaryApiFactory 32 | { 33 | IIndeedSalaryApi CreateApi(string baseAddress); 34 | } -------------------------------------------------------------------------------- /jobhunt/Searching/Indeed/Publisher/JobSearchResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Globalization; 3 | using System.Text.Json; 4 | using System.Text.Json.Serialization; 5 | 6 | namespace JobHunt.Searching.Indeed.Publisher; 7 | 8 | public class JobSearchResponse 9 | { 10 | public int TotalResults { get; set; } 11 | public required IEnumerable Results { get; set; } 12 | } 13 | 14 | public class PublisherJobResult 15 | { 16 | public required string JobTitle { get; set; } 17 | 18 | public required string Company { get; set; } 19 | 20 | public required string FormattedLocation { get; set; } 21 | 22 | [JsonConverter(typeof(RFC1123DateTimeConverter))] 23 | public DateTime Date { get; set; } 24 | 25 | public required string Snippet { get; set; } 26 | 27 | public required string Url { get; set; } 28 | 29 | public double Latitude { get; set; } 30 | 31 | public double Longitude { get; set; } 32 | 33 | public required string JobKey { get; set; } 34 | 35 | public bool Sponsored { get; set; } 36 | 37 | public JobResult ToJobResult() 38 | { 39 | // get the hostname of the job view URL to allow creating link without tracking to correct domain 40 | // returns "https://uk.indeed.com" for a UK job 41 | string jobBaseUri = new Uri(Url).GetLeftPart(UriPartial.Authority); 42 | 43 | var result = new JobResult 44 | { 45 | Key = JobKey, 46 | Title = JobTitle, 47 | Url = $"{jobBaseUri}/viewjob?jk={JobKey}", 48 | HtmlDescription = Snippet, 49 | Location = FormattedLocation, 50 | EmployerName = Company, 51 | Posted = new DateTimeOffset(Date, TimeSpan.Zero), 52 | Attributes = Enumerable.Empty(), 53 | FormattedSalary = null, 54 | AvgYearlySalary = null 55 | }; 56 | 57 | if (result.Location.ToLower() == "remote" 58 | || (Latitude == 25 && Longitude == -40)) // Indeed uses those coords for remote jobs for some reason 59 | { 60 | result.Remote = true; 61 | } 62 | else 63 | { 64 | result.Latitude = Latitude; 65 | result.Longitude = Longitude; 66 | } 67 | 68 | return result; 69 | } 70 | 71 | private class RFC1123DateTimeConverter : JsonConverter 72 | { 73 | public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 74 | { 75 | Debug.Assert(typeToConvert == typeof(DateTime)); 76 | return DateTime.ParseExact(reader.GetString()!, "r", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); 77 | } 78 | 79 | public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) 80 | { 81 | writer.WriteStringValue(value.ToString()); 82 | } 83 | } 84 | } -------------------------------------------------------------------------------- /jobhunt/Searching/SearchProviderName.cs: -------------------------------------------------------------------------------- 1 | namespace JobHunt.Searching; 2 | public class SearchProviderName 3 | { 4 | public static readonly string[] AllProviders = { Indeed }; 5 | public const string Indeed = "Indeed"; 6 | } -------------------------------------------------------------------------------- /jobhunt/Services/AlertService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace JobHunt.Services; 4 | public class AlertService : IAlertService 5 | { 6 | private readonly JobHuntContext _context; 7 | private const int _maxAlerts = 20; 8 | public AlertService(JobHuntContext context) 9 | { 10 | _context = context; 11 | } 12 | public async Task CreateAsync(Alert alert) 13 | { 14 | alert.Created = DateTimeOffset.UtcNow; 15 | _context.Alerts.Add(alert); 16 | await _context.SaveChangesAsync(); 17 | } 18 | 19 | public async Task CreateErrorAsync(string title, string? message = null, string? url = null) 20 | { 21 | await CreateAsync(new Alert 22 | { 23 | Type = AlertType.Error, 24 | Title = title, 25 | Message = message, 26 | Url = url 27 | }); 28 | } 29 | 30 | public async Task> GetRecentAsync() 31 | { 32 | return await _context.Alerts.AsNoTracking().OrderByDescending(a => a.Created).Take(_maxAlerts).ToListAsync(); 33 | } 34 | 35 | public async Task MarkAsReadAsync(int id) 36 | { 37 | Alert? alert = await _context.Alerts.SingleOrDefaultAsync(a => a.Id == id); 38 | 39 | if (alert == default(Alert)) 40 | { 41 | return false; 42 | } 43 | 44 | alert.Read = true; 45 | 46 | await _context.SaveChangesAsync(); 47 | return true; 48 | } 49 | 50 | public async Task MarkAllAsReadAsync() 51 | { 52 | List alerts = await _context.Alerts.Where(a => !a.Read).ToListAsync(); 53 | 54 | foreach (var a in alerts) 55 | { 56 | a.Read = true; 57 | } 58 | 59 | await _context.SaveChangesAsync(); 60 | } 61 | 62 | public async Task GetUnreadCountAsync() 63 | { 64 | return await _context.Alerts.CountAsync(a => !a.Read); 65 | } 66 | } 67 | 68 | public interface IAlertService 69 | { 70 | Task CreateAsync(Alert alert); 71 | Task CreateErrorAsync(string title, string? message = null, string? url = null); 72 | Task> GetRecentAsync(); 73 | Task MarkAsReadAsync(int id); 74 | Task MarkAllAsReadAsync(); 75 | Task GetUnreadCountAsync(); 76 | } -------------------------------------------------------------------------------- /jobhunt/Services/BaseServices/BaseService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace JobHunt.Services.BaseServices; 4 | public class BaseService : IBaseService where T : class, KeyedEntity 5 | { 6 | protected readonly JobHuntContext _context; 7 | 8 | public BaseService(JobHuntContext context) 9 | { 10 | _context = context; 11 | } 12 | 13 | public DbSet Set => _context.Set(); 14 | 15 | public Task SaveChangesAsync() => _context.SaveChangesAsync(); 16 | 17 | public virtual async Task CreateAsync(T entity) 18 | { 19 | Set.Add(entity); 20 | 21 | await BeforeSaveAsync(entity); 22 | 23 | await SaveChangesAsync(); 24 | 25 | return entity; 26 | } 27 | 28 | public virtual Task BeforeSaveAsync(T entity) 29 | { 30 | return Task.FromResult(entity); 31 | } 32 | } 33 | 34 | public interface IBaseService where T : class, KeyedEntity 35 | { 36 | DbSet Set { get; } 37 | Task SaveChangesAsync(); 38 | Task CreateAsync(T entity); 39 | Task BeforeSaveAsync(T entity); 40 | } -------------------------------------------------------------------------------- /jobhunt/Services/BaseServices/KeyedEntityBaseService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace JobHunt.Services.BaseServices; 4 | 5 | public class KeyedEntityBaseService : BaseService, IKeyedEntityBaseService 6 | where T : class, KeyedEntity 7 | { 8 | public KeyedEntityBaseService(JobHuntContext context) : base(context) { } 9 | 10 | public virtual async Task FindByIdAsync(int id) 11 | { 12 | return await _context.Set().FirstOrDefaultAsync(x => x.Id == id); 13 | } 14 | 15 | public virtual async Task DeleteAsync(int id) 16 | { 17 | var entity = await FindByIdAsync(id); 18 | if (entity == default) 19 | { 20 | return false; 21 | } 22 | 23 | Set.Remove(entity); 24 | await SaveChangesAsync(); 25 | 26 | return true; 27 | } 28 | } 29 | 30 | public interface IKeyedEntityBaseService : IBaseService where T : class, KeyedEntity 31 | { 32 | Task FindByIdAsync(int id); 33 | Task DeleteAsync(int id); 34 | } -------------------------------------------------------------------------------- /jobhunt/Services/BaseServices/ODataBaseService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.OData.Deltas; 2 | 3 | namespace JobHunt.Services.BaseServices; 4 | 5 | public class ODataBaseService : KeyedEntityBaseService, IODataBaseService where T : class, KeyedEntity 6 | { 7 | public ODataBaseService(JobHuntContext context) : base(context) { } 8 | 9 | public virtual async Task PutAsync(int id, Delta delta) 10 | { 11 | var entity = await FindByIdAsync(id); 12 | if (entity == default) 13 | { 14 | return default; 15 | } 16 | 17 | delta.Put(entity); 18 | 19 | await BeforeSaveAsync(entity); 20 | 21 | return entity; 22 | } 23 | 24 | public virtual async Task PatchAsync(int id, Delta delta) 25 | { 26 | var entity = await FindByIdAsync(id); 27 | if (entity == default) 28 | { 29 | return default; 30 | } 31 | 32 | delta.Patch(entity); 33 | 34 | await BeforeSaveAsync(entity); 35 | 36 | return entity; 37 | } 38 | } 39 | 40 | public interface IODataBaseService : IKeyedEntityBaseService where T : class, KeyedEntity 41 | { 42 | Task PutAsync(int id, Delta delta); 43 | Task PatchAsync(int id, Delta delta); 44 | } -------------------------------------------------------------------------------- /jobhunt/Services/CategoryService.cs: -------------------------------------------------------------------------------- 1 | using JobHunt.Services.BaseServices; 2 | 3 | public class CategoryService : ODataBaseService, ICategoryService 4 | { 5 | public CategoryService(JobHuntContext context) : base(context) {} 6 | } 7 | 8 | public interface ICategoryService : IODataBaseService { } -------------------------------------------------------------------------------- /jobhunt/Services/CompanyCategoryService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace JobHunt.Services; 4 | 5 | public class CompanyCategoryService : ICompanyCategoryService { 6 | private readonly JobHuntContext _context; 7 | 8 | public CompanyCategoryService(JobHuntContext context) 9 | { 10 | _context = context; 11 | } 12 | 13 | public async Task CreateAsync(CompanyCategory entity) 14 | { 15 | _context.Add(entity); 16 | 17 | await _context.SaveChangesAsync(); 18 | 19 | return entity; 20 | } 21 | 22 | public async Task DeleteAsync(int categoryId, int companyId) 23 | { 24 | CompanyCategory? entity = await _context.CompanyCategories 25 | .FirstOrDefaultAsync(c => c.CategoryId == categoryId && c.CompanyId == companyId); 26 | 27 | if (entity == default) 28 | { 29 | return null; 30 | } 31 | 32 | bool deletedCategory = false; 33 | if (!_context.CompanyCategories.Any(cc => cc.CategoryId == categoryId && cc.CompanyId == companyId) 34 | && !_context.JobCategories.Any(jc => jc.CategoryId == categoryId)) 35 | { 36 | Category? category = await _context.Categories.FirstOrDefaultAsync(c => c.Id == categoryId); 37 | if (category != default) 38 | { 39 | deletedCategory = true; 40 | _context.Categories.Remove(category); 41 | } 42 | } 43 | 44 | _context.CompanyCategories.Remove(entity); 45 | 46 | await _context.SaveChangesAsync(); 47 | 48 | return deletedCategory; 49 | } 50 | } 51 | 52 | public interface ICompanyCategoryService 53 | { 54 | Task CreateAsync(CompanyCategory entity); 55 | Task DeleteAsync(int categoryId, int companyId); 56 | } -------------------------------------------------------------------------------- /jobhunt/Services/CompanyNameService.cs: -------------------------------------------------------------------------------- 1 | using JobHunt.Services.BaseServices; 2 | 3 | namespace JobHunt.Services; 4 | 5 | public class CompanyNameService : ODataBaseService, ICompanyNameService 6 | { 7 | public CompanyNameService(JobHuntContext context) : base(context) { } 8 | } 9 | 10 | public interface ICompanyNameService : IODataBaseService { } -------------------------------------------------------------------------------- /jobhunt/Services/CompanyService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | using JobHunt.Geocoding; 4 | using JobHunt.PageWatcher; 5 | using JobHunt.Services.BaseServices; 6 | 7 | namespace JobHunt.Services; 8 | public class CompanyService : ODataBaseService, ICompanyService 9 | { 10 | private readonly IGeocoder _geocoder; 11 | private readonly IPageWatcher _pageWatcher; 12 | 13 | public CompanyService(JobHuntContext context, IGeocoder geocoder, IPageWatcher pageWatcher) : base(context) 14 | { 15 | _geocoder = geocoder; 16 | _pageWatcher = pageWatcher; 17 | } 18 | 19 | public async Task FindByNameAsync(string name) 20 | { 21 | return await _context.Companies 22 | .Include(c => c.AlternateNames) 23 | .FirstOrDefaultAsync(c => c.Name.ToLower() == name || c.AlternateNames.Any(an => an.Name.ToLower() == name)); 24 | } 25 | 26 | public async Task CreateAllAsync(IEnumerable companies) 27 | { 28 | _context.Companies.AddRange(companies); 29 | 30 | await _context.SaveChangesAsync(); 31 | } 32 | 33 | public IAsyncEnumerable GetCompanyCategories() 34 | { 35 | return _context.Categories 36 | .Where(c => c.CompanyCategories.Any()) 37 | .AsAsyncEnumerable(); 38 | } 39 | 40 | public async Task MergeAsync(int srcId, int destId) 41 | { 42 | Company? src = await _context.Companies 43 | .SingleOrDefaultAsync(c => c.Id == srcId); 44 | Company? dest = await _context.Companies 45 | .Include(c => c.AlternateNames) 46 | .Include(c => c.CompanyCategories) 47 | .SingleOrDefaultAsync(c => c.Id == destId); 48 | 49 | if (src == default || dest == default) 50 | { 51 | return false; 52 | } 53 | 54 | using (var transaction = await _context.Database.BeginTransactionAsync()) 55 | { 56 | await _context.Jobs 57 | .Where(j => j.CompanyId == srcId) 58 | .ExecuteUpdateAsync(s => s.SetProperty(j => j.CompanyId, destId)); 59 | await _context.Jobs 60 | .Where(j => j.ActualCompanyId == srcId) 61 | .ExecuteUpdateAsync(s => s.SetProperty(j => j.ActualCompanyId, destId)); 62 | 63 | // only update CompanyNames where the same name doesn't already exist on the destination 64 | // any remaining CompanyNames will simply be deleted 65 | var destNames = dest.AlternateNames.Select(n => n.Name); 66 | await _context.CompanyNames 67 | .Where(n => n.CompanyId == srcId && !destNames.Contains(n.Name)) 68 | .ExecuteUpdateAsync(s => s.SetProperty(n => n.CompanyId, destId)); 69 | 70 | var destCategories = dest.CompanyCategories.Select(c => c.CategoryId); 71 | await _context.CompanyCategories 72 | .Where(c => c.CompanyId == srcId) 73 | .ExecuteUpdateAsync(s => s.SetProperty(c => c.CompanyId, destId)); 74 | 75 | await _context.WatchedPages 76 | .Where(p => p.CompanyId == srcId) 77 | .ExecuteUpdateAsync(s => s.SetProperty(p => p.CompanyId, destId)); 78 | 79 | await transaction.CommitAsync(); 80 | } 81 | 82 | if (src.Name != dest.Name) 83 | { 84 | dest.AlternateNames.Add(new CompanyName { Name = src.Name }); 85 | } 86 | 87 | if (src.Recruiter && !dest.Recruiter) 88 | { 89 | dest.Recruiter = true; 90 | } 91 | 92 | _context.Companies.Remove(src); 93 | 94 | await _context.SaveChangesAsync(); 95 | 96 | return true; 97 | } 98 | } 99 | 100 | public interface ICompanyService : IODataBaseService 101 | { 102 | Task FindByNameAsync(string name); 103 | Task CreateAllAsync(IEnumerable companies); 104 | IAsyncEnumerable GetCompanyCategories(); 105 | Task MergeAsync(int srcId, int destId); 106 | } -------------------------------------------------------------------------------- /jobhunt/Services/JobCategoryService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace JobHunt.Services; 4 | 5 | public class JobCategoryService : IJobCategoryService { 6 | private readonly JobHuntContext _context; 7 | 8 | public JobCategoryService(JobHuntContext context) 9 | { 10 | _context = context; 11 | } 12 | 13 | public async Task CreateAsync(JobCategory entity) 14 | { 15 | _context.Add(entity); 16 | 17 | await _context.SaveChangesAsync(); 18 | 19 | return entity; 20 | } 21 | 22 | public async Task DeleteAsync(int categoryId, int jobId) 23 | { 24 | JobCategory? entity = await _context.JobCategories 25 | .FirstOrDefaultAsync(c => c.CategoryId == categoryId && c.JobId == jobId); 26 | 27 | if (entity == default) 28 | { 29 | return null; 30 | } 31 | 32 | bool deletedCategory = false; 33 | if (!_context.JobCategories.Any(jc => jc.CategoryId == categoryId && jc.JobId != jobId) 34 | && !_context.CompanyCategories.Any(cc => cc.CategoryId == categoryId)) 35 | { 36 | Category? category = await _context.Categories.FirstOrDefaultAsync(c => c.Id == categoryId); 37 | if (category != default) 38 | { 39 | deletedCategory = true; 40 | _context.Categories.Remove(category); 41 | } 42 | } 43 | 44 | _context.JobCategories.Remove(entity); 45 | 46 | await _context.SaveChangesAsync(); 47 | 48 | return deletedCategory; 49 | } 50 | } 51 | 52 | public interface IJobCategoryService 53 | { 54 | Task CreateAsync(JobCategory entity); 55 | Task DeleteAsync(int categoryId, int jobId); 56 | } -------------------------------------------------------------------------------- /jobhunt/Services/SearchService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | using JobHunt.Services.BaseServices; 4 | 5 | namespace JobHunt.Services; 6 | public class SearchService : ODataBaseService, ISearchService 7 | { 8 | public SearchService(JobHuntContext context) : base(context) { } 9 | 10 | public async Task GetByIdAsync(int id) 11 | { 12 | return await _context.Searches 13 | .AsNoTracking() 14 | .Include(s => s.Runs.OrderByDescending(sr => sr.Time).Take(10)) 15 | .SingleOrDefaultAsync(s => s.Id == id); 16 | } 17 | 18 | public async Task> FindEnabledByProviderAsync(string provider) 19 | { 20 | return await _context.Searches.Where(s => s.Provider == provider && s.Enabled).ToListAsync(); 21 | } 22 | 23 | public async Task CreateSearchRunAsync(int searchId, bool success, string? message, int newJobs, int newCompanies, int timeTaken) 24 | { 25 | Search search = await _context.Searches.SingleAsync(s => s.Id == searchId); 26 | search.LastResultCount = newJobs; 27 | search.LastFetchSuccess = success; 28 | search.LastRun = DateTimeOffset.UtcNow; 29 | 30 | SearchRun run = new SearchRun 31 | { 32 | Search = null!, 33 | SearchId = searchId, 34 | Time = DateTimeOffset.UtcNow, 35 | Success = success, 36 | Message = message, 37 | NewJobs = newJobs, 38 | NewCompanies = newCompanies, 39 | TimeTaken = timeTaken 40 | }; 41 | 42 | _context.SearchRuns.Add(run); 43 | 44 | await _context.SaveChangesAsync(); 45 | 46 | return run; 47 | } 48 | } 49 | 50 | public interface ISearchService : IODataBaseService 51 | { 52 | Task> FindEnabledByProviderAsync(string provider); 53 | Task CreateSearchRunAsync(int searchId, bool success, string? message, int newJobs, int newCompanies, int timeTaken); 54 | } -------------------------------------------------------------------------------- /jobhunt/Services/WatchedPageChangeService.cs: -------------------------------------------------------------------------------- 1 | using AngleSharp; 2 | using AngleSharp.Dom; 3 | using AngleSharp.Html.Parser; 4 | using AngleSharp.Diffing.Core; 5 | using Microsoft.EntityFrameworkCore; 6 | 7 | using JobHunt.AngleSharp; 8 | using JobHunt.Services.BaseServices; 9 | 10 | namespace JobHunt.Services; 11 | public class WatchedPageChangeService : KeyedEntityBaseService, IWatchedPageChangeService 12 | { 13 | private readonly ScreenshotOptions _options; 14 | 15 | public WatchedPageChangeService(JobHuntContext context, IOptions options) : base(context) 16 | { 17 | _options = options.Value; 18 | } 19 | 20 | public async Task GetLatestChangeOrDefaultAsync(int watchedPageId) 21 | { 22 | return await _context.WatchedPageChanges 23 | .Where(c => c.WatchedPageId == watchedPageId) 24 | .OrderByDescending(c => c.Created) 25 | .FirstOrDefaultAsync(); 26 | } 27 | 28 | public async Task GetScreenshotAsync(int changeId) 29 | { 30 | WatchedPageChange? change = await _context.WatchedPageChanges.FirstOrDefaultAsync(c => c.Id == changeId); 31 | 32 | if (change == default || string.IsNullOrEmpty(change.ScreenshotFileName)) 33 | { 34 | return null; 35 | } 36 | 37 | string filePath = Path.Combine(_options.Directory, change.ScreenshotFileName); 38 | if (!File.Exists(filePath)) 39 | { 40 | return null; 41 | } 42 | 43 | return new FileStream(Path.Combine(_options.Directory, change.ScreenshotFileName), FileMode.Open, FileAccess.Read); 44 | } 45 | 46 | public async Task<(string?, string?)> GetDiffHtmlAsync(int changeId) 47 | { 48 | WatchedPageChange? change = await _context.WatchedPageChanges 49 | .Include(c => c.WatchedPage) 50 | .FirstOrDefaultAsync(c => c.Id == changeId); 51 | if (change == default) 52 | { 53 | return (null, null); 54 | } 55 | 56 | WatchedPageChange? previousChange = await _context.WatchedPageChanges 57 | .Where(c => c.WatchedPageId == change.WatchedPageId && c.Id != change.Id && c.Created <= change.Created) 58 | .OrderByDescending(c => c.Created) 59 | .FirstOrDefaultAsync(); 60 | 61 | var context = BrowsingContext.New(); 62 | 63 | var current = await context.OpenAsync(r => r.Content(change.Html)); 64 | current.ReplaceRelativeUrlsWithAbsolute(change.WatchedPage.Url); 65 | 66 | if (previousChange == default) 67 | { 68 | 69 | return (null, current.ToHtml()); 70 | } 71 | 72 | var previous = await context.OpenAsync(r => r.Content(previousChange.Html)); 73 | previous.ReplaceRelativeUrlsWithAbsolute(change.WatchedPage.Url); 74 | 75 | var diffs = JobHuntComparer.Compare(previous, current, change.WatchedPage.CssSelector, change.WatchedPage.CssBlacklist); 76 | 77 | foreach (var diff in diffs) 78 | { 79 | if (diff.Target == DiffTarget.Element) 80 | { 81 | diff.SetDiffAttributes(x => (IElement) x.Node); 82 | } 83 | else if (diff.Target == DiffTarget.Attribute) 84 | { 85 | diff.SetDiffAttributes(x => (IElement) x.ElementSource.Node, "data-jh-modified"); 86 | } 87 | else if (diff.Target == DiffTarget.Text) 88 | { 89 | diff.SetDiffAttributes(x => x.Node.ParentElement ?? throw new InvalidOperationException("Parent element for text node not found")); 90 | } 91 | } 92 | 93 | previous.Head?.Prepend(new HtmlParser().ParseFragment(_changeStyles, previous.Head).First()); 94 | current.Head?.Prepend(new HtmlParser().ParseFragment(_changeStyles, current.Head).First()); 95 | 96 | return (previous.ToHtml(), current.ToHtml()); 97 | } 98 | 99 | private const string _changeStyles = """ 100 | 111 | """; 112 | } 113 | 114 | public interface IWatchedPageChangeService : IKeyedEntityBaseService 115 | { 116 | Task GetLatestChangeOrDefaultAsync(int watchedPageId); 117 | Task GetScreenshotAsync(int changeId); 118 | Task<(string?, string?)> GetDiffHtmlAsync(int changeId); 119 | } -------------------------------------------------------------------------------- /jobhunt/Services/WatchedPageService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using JobHunt.Services.BaseServices; 3 | 4 | namespace JobHunt.Services; 5 | public class WatchedPageService : ODataBaseService, IWatchedPageService 6 | { 7 | public WatchedPageService(JobHuntContext context) : base(context) { } 8 | 9 | public async override Task FindByIdAsync(int id) 10 | { 11 | return await _context.WatchedPages 12 | .Include(p => p.Company) 13 | .FirstOrDefaultAsync(p => p.Id == id); 14 | } 15 | 16 | public async Task UpdateStatusAsync(int id, bool changed = false, string? statusMessage = null) 17 | { 18 | WatchedPage? page = await _context.WatchedPages.FirstOrDefaultAsync(p => p.Id == id); 19 | 20 | if (page == default) 21 | { 22 | return; 23 | } 24 | 25 | page.LastScraped = DateTimeOffset.UtcNow; 26 | 27 | if (!string.IsNullOrEmpty(statusMessage)) 28 | { 29 | page.StatusMessage = statusMessage; 30 | } 31 | else if (changed) 32 | { 33 | page.LastUpdated = DateTimeOffset.UtcNow; 34 | page.StatusMessage = null; 35 | } 36 | 37 | await _context.SaveChangesAsync(); 38 | } 39 | 40 | public async Task> GetAllActiveAsync() 41 | { 42 | return await _context.WatchedPages 43 | .Include(wp => wp.Company) 44 | .Where(wp => wp.Enabled) 45 | .ToListAsync(); 46 | } 47 | } 48 | 49 | public interface IWatchedPageService : IODataBaseService 50 | { 51 | Task UpdateStatusAsync(int id, bool changed = false, string? statusMessage = null); 52 | Task> GetAllActiveAsync(); 53 | } -------------------------------------------------------------------------------- /jobhunt/Utils/StringUtils.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace JobHunt.Utils; 4 | 5 | public static class StringUtils 6 | { 7 | private static Regex _htmlRegex = new Regex(@"<([A-z]+)[^>]*>.*<\/\1>"); 8 | public static bool IsHtml(string str) => _htmlRegex.IsMatch(str); 9 | } -------------------------------------------------------------------------------- /jobhunt/Workers/SearchRefreshWorker.cs: -------------------------------------------------------------------------------- 1 | using Cronos; 2 | 3 | using JobHunt.PageWatcher; 4 | using JobHunt.Searching.Indeed; 5 | 6 | namespace JobHunt.Workers; 7 | public class SearchRefreshWorker : BackgroundService, ISearchRefreshWorker 8 | { 9 | private readonly IServiceProvider _provider; 10 | private readonly SearchOptions _options; 11 | private readonly ILogger _logger; 12 | 13 | public SearchRefreshWorker(IServiceProvider provider, IOptions options, ILogger logger) 14 | { 15 | _provider = provider; 16 | _options = options.Value; 17 | _logger = logger; 18 | } 19 | 20 | protected override async Task ExecuteAsync(CancellationToken token) 21 | { 22 | _logger.LogInformation("SearchRefreshWorker started"); 23 | if (_options.Schedules == null) 24 | { 25 | _logger.LogWarning("No search refresh schedule provided. Stopping."); 26 | return; 27 | } 28 | CronExpression[] expressions = _options.Schedules.Select(s => CronExpression.Parse(s)).ToArray(); 29 | while (!token.IsCancellationRequested) 30 | { 31 | DateTimeOffset? next = expressions.Select(e => e.GetNextOccurrence(DateTimeOffset.Now, TimeZoneInfo.Local, true)).Min(); 32 | 33 | if (next.HasValue) 34 | { 35 | var delay = next.Value - DateTimeOffset.Now; 36 | try 37 | { 38 | await Task.Delay((int) delay.TotalMilliseconds, token); 39 | } 40 | catch (TaskCanceledException) 41 | { 42 | _logger.LogInformation("SearchRefreshWorker stopping: task cancelled"); 43 | return; 44 | } 45 | 46 | _logger.LogInformation("SearchRefresh started"); 47 | try 48 | { 49 | await DoRefreshAsync(token); 50 | } 51 | catch (Exception ex) 52 | { 53 | _logger.LogError(ex, "Uncaught SearchRefreshWorker exception"); 54 | } 55 | _logger.LogInformation("SearchRefresh completed"); 56 | } 57 | else 58 | { 59 | _logger.LogInformation("SearchRefreshWorker stopping: no more occurrences"); 60 | return; 61 | } 62 | } 63 | _logger.LogInformation("SearchRefreshWorker stopping"); 64 | } 65 | 66 | public async Task DoRefreshAsync(CancellationToken token) 67 | { 68 | // needs multiple separate scopes to prevent threading issues with DbContext 69 | using (IServiceScope indeedScope = _provider.CreateScope()) 70 | using (IServiceScope pageScope = _provider.CreateScope()) 71 | { 72 | List tasks = new List(); 73 | 74 | IIndeedApiSearchProvider indeed = indeedScope.ServiceProvider.GetRequiredService(); 75 | tasks.Add(indeed.SearchAllAsync(token)); 76 | 77 | IPageWatcher pageWatcher = pageScope.ServiceProvider.GetRequiredService(); 78 | tasks.Add(pageWatcher.RefreshAllAsync(token)); 79 | 80 | await Task.WhenAll(tasks); 81 | } 82 | 83 | // check for duplicates after all searches complete 84 | // ensures that any duplicates from different providers can be detected (if I ever add more providers...) 85 | // also avoids a very strange issue where checking for duplicates straight after they were created didn't work 86 | if (_options.CheckForDuplicateJobs) 87 | { 88 | using (IServiceScope duplicateScope = _provider.CreateScope()) 89 | { 90 | IJobService jobService = duplicateScope.ServiceProvider.GetRequiredService(); 91 | await jobService.CheckForDuplicatesAsync(false, token); 92 | } 93 | } 94 | } 95 | } 96 | 97 | public interface ISearchRefreshWorker 98 | { 99 | Task DoRefreshAsync(CancellationToken token); 100 | } -------------------------------------------------------------------------------- /jobhunt/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Serilog": { 3 | "MinimumLevel": { 4 | "Default": "Information", 5 | "Override": { 6 | "Microsoft": "Warning", 7 | "Microsoft.Hosting.Lifetime": "Information" 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /jobhunt/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "AllowedHosts": "*", 3 | "ConnectionStrings": { 4 | "DefaultConnection": "Host=db; Database=jobhunt_db; Username=jobhunt; Password=jobhunt" 5 | }, 6 | "Serilog": { 7 | "MinimumLevel": { 8 | "Default": "Information", 9 | "Override": { 10 | "Microsoft": "Warning", 11 | "System": "Warning" 12 | } 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /jobhunt/client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaFeatures": { 14 | "jsx": true 15 | }, 16 | "ecmaVersion": 13, 17 | "sourceType": "module" 18 | }, 19 | "plugins": [ 20 | "react", 21 | "react-hooks", 22 | "@typescript-eslint" 23 | ], 24 | "rules": { 25 | "prefer-const": "warn", 26 | "react-hooks/rules-of-hooks": "error", 27 | "react-hooks/exhaustive-deps": "warn", 28 | "react/display-name": "off", 29 | "react/prop-types": "off", 30 | "@typescript-eslint/no-non-null-assertion": "off", 31 | "@typescript-eslint/no-explicit-any": "off", 32 | "@typescript-eslint/no-inferrable-types": "off", 33 | "@typescript-eslint/no-unused-vars": [ 34 | "warn", 35 | { "ignoreRestSiblings": true } 36 | ] 37 | }, 38 | "settings": { 39 | "react": { 40 | "version": "detect" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /jobhunt/client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /jobhunt/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | JobHunt 14 | 15 | 16 |
17 |
18 | 21 |
22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /jobhunt/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@date-io/dayjs": "^2.16.0", 7 | "@emotion/cache": "11.10.5", 8 | "@emotion/react": "11.10.5", 9 | "@emotion/styled": "11.10.5", 10 | "@mui/icons-material": "5.11.0", 11 | "@mui/lab": "5.0.0-alpha.53", 12 | "@mui/material": "5.11.2", 13 | "@mui/system": "5.11.2", 14 | "@mui/x-data-grid": "5.17.17", 15 | "@mui/x-date-pickers": "5.0.12", 16 | "@types/node": "18.11.18", 17 | "@types/react": "18.0.26", 18 | "@types/react-dom": "18.0.10", 19 | "dayjs": "1.11.7", 20 | "final-form": "^4.20.7", 21 | "mui-rff": "6.1.2", 22 | "nanoid": "^4.0.0", 23 | "o-data-grid": "1.3.1", 24 | "react": "18.2.0", 25 | "react-dom": "18.2.0", 26 | "react-final-form": "^6.5.9", 27 | "react-helmet": "^6.1.0", 28 | "react-markdown": "8.0.4", 29 | "react-router": "6.6.1", 30 | "react-router-dom": "6.6.1", 31 | "react-swipeable-views": "^0.14.0", 32 | "react-swipeable-views-utils": "^0.14.0", 33 | "react-use-error-boundary": "^3.0.0", 34 | "recoil": "0.7.6", 35 | "remark-gfm": "^3.0.1", 36 | "tss-react": "4.5.1", 37 | "typescript": "4.9.4" 38 | }, 39 | "scripts": { 40 | "start": "vite", 41 | "build": "vite build", 42 | "analyze": "source-map-explorer 'build/static/js/*.js'" 43 | }, 44 | "browserslist": { 45 | "production": [ 46 | ">0.2%", 47 | "not dead", 48 | "not op_mini all" 49 | ], 50 | "development": [ 51 | "last 1 chrome version", 52 | "last 1 firefox version", 53 | "last 1 safari version" 54 | ] 55 | }, 56 | "devDependencies": { 57 | "@types/react-helmet": "6.1.6", 58 | "@types/react-router-dom": "5.3.3", 59 | "@types/react-swipeable-views": "^0.13.0", 60 | "@types/react-swipeable-views-utils": "0.13.4", 61 | "@types/uuid": "9.0.0", 62 | "@typescript-eslint/eslint-plugin": "5.47.1", 63 | "@typescript-eslint/parser": "5.47.1", 64 | "@vitejs/plugin-react": "3.0.0", 65 | "eslint": "8.30.0", 66 | "eslint-plugin-react": "7.31.11", 67 | "eslint-plugin-react-hooks": "^4.6.0", 68 | "react-audible-debug": "^1.0.3", 69 | "react-error-overlay": "6.0.11", 70 | "source-map-explorer": "2.5.3", 71 | "vite": "4.0.3", 72 | "vite-plugin-checker": "0.5.3", 73 | "vite-tsconfig-paths": "4.0.3" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /jobhunt/client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamerst/JobHunt/5aba9a184a52a69b6726b663ceae9dca4b87246a/jobhunt/client/public/favicon.ico -------------------------------------------------------------------------------- /jobhunt/client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "JobHunt", 3 | "name": "JobHunt", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo.svg", 12 | "type": "image/svg+xml", 13 | "sizes": "any" 14 | } 15 | ], 16 | "start_url": ".", 17 | "display": "standalone", 18 | "theme_color": "#2196f3", 19 | "background_color": "#0d47a1" 20 | } 21 | -------------------------------------------------------------------------------- /jobhunt/client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /jobhunt/client/public/static.css: -------------------------------------------------------------------------------- 1 | body, #root { 2 | min-height: 100vh; 3 | margin: 0; 4 | } 5 | 6 | .placeholder { 7 | height: 100vh; 8 | background: #303030; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | noscript { 16 | color: #fff; 17 | font-family: sans-serif; 18 | text-align: center; 19 | } -------------------------------------------------------------------------------- /jobhunt/client/src/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, PropsWithChildren } from "react" 2 | import makeStyles from "makeStyles"; 3 | import { Paper } from "@mui/material"; 4 | 5 | type CardProps = { 6 | className?: string 7 | } 8 | 9 | const useStyles = makeStyles()((theme) => ({ 10 | paper: { 11 | display: "flex", 12 | flexDirection: "column", 13 | marginTop: theme.spacing(3), 14 | "& .MuiDataGrid-columnHeaders": { 15 | background: theme.palette.background.default, 16 | } 17 | } 18 | })); 19 | 20 | const Card = ({ className, children }: PropsWithChildren) => { 21 | const { classes, cx } = useStyles(); 22 | 23 | return ( 24 | 25 | 26 | {children} 27 | 28 | 29 | ); 30 | } 31 | 32 | export default Card; -------------------------------------------------------------------------------- /jobhunt/client/src/components/CardBody.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from "react" 2 | import { Box } from "@mui/material" 3 | 4 | const CardBody = ({ children }: PropsWithChildren) => { 5 | return ( 6 | 7 | {children} 8 | 9 | ); 10 | } 11 | 12 | export default CardBody; -------------------------------------------------------------------------------- /jobhunt/client/src/components/CardHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, ReactElement } from "react" 2 | import { Box, } from "@mui/material"; 3 | import Grid from "components/Grid"; 4 | import makeStyles from "makeStyles"; 5 | 6 | type CardHeaderProps = { 7 | variant?: "icon" | "text", 8 | icon?: ReactElement, 9 | colour?: string 10 | } 11 | 12 | const useStyles = makeStyles()((theme, props) => ({ 13 | bar: { 14 | backgroundColor: props.colour ? props.colour : theme.palette.primary.main, 15 | color: theme.palette.getContrastText(props.colour ? props.colour : theme.palette.primary.main), 16 | zIndex: 100, 17 | borderRadius: theme.shape.borderRadius, 18 | boxShadow: theme.shadows[5], 19 | display: props.variant === "text" ? "block": "inline-block", 20 | width: props.variant === "text" ? "auto" : "2em", 21 | height: props.variant === "text" ? "auto" : "2em", 22 | position: props.variant === "text" ? "static" : "absolute", 23 | "& *": { 24 | color: "inherit" 25 | } 26 | }, 27 | icon: { 28 | fontSize: theme.typography.h3.fontSize, 29 | lineHeight: 1 30 | }, 31 | subtitle: { 32 | color: "rgba(255,255,255,.9)" 33 | } 34 | })); 35 | 36 | const CardHeader = (props: PropsWithChildren) => { 37 | const { classes, cx } = useStyles(props); 38 | 39 | if (props.variant === "text") { 40 | return ( 41 | 42 | 43 | {props.icon ? ({props.icon}) : null} 44 | 45 | {props.children} 46 | 47 | 48 | 49 | ) 50 | } else { 51 | return ( 52 | 53 | {props.icon ? props.icon : null} 54 | 55 | ) 56 | } 57 | } 58 | 59 | CardHeader.defaultProps = { 60 | variant: "text" 61 | } 62 | 63 | export default CardHeader; -------------------------------------------------------------------------------- /jobhunt/client/src/components/Date.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useMemo } from "react"; 2 | 3 | import dayjs, { Dayjs } from "dayjs"; 4 | import relativeTime from "dayjs/plugin/relativeTime"; 5 | import utc from "dayjs/plugin/utc"; 6 | import { Tooltip, TooltipProps } from "@mui/material"; 7 | 8 | export type DateProps = { 9 | date?: string | Dayjs, 10 | emptyText?: string, 11 | disableRelative?: boolean, 12 | format?: string, 13 | tooltipProps?: DateTooltipProps 14 | } 15 | 16 | export type DateTooltipProps = Omit; 17 | 18 | const Date = ({ date, emptyText, disableRelative, format = "DD/MM/YYYY HH:mm", tooltipProps }: DateProps) => { 19 | dayjs.extend(relativeTime); 20 | dayjs.extend(utc); 21 | 22 | const result = useMemo(() => { 23 | if (!date) { 24 | const empty = emptyText ?? ""; 25 | return { displayDate: empty, fullDate: empty, isoDate: empty } 26 | } else { 27 | const d = typeof (date) === "string" ? dayjs.utc(date).local() : date; 28 | 29 | const fullDate = d.format(format); 30 | const displayDate = !disableRelative ? d.fromNow() : fullDate; 31 | const isoDate = d.toISOString(); 32 | 33 | return { displayDate, fullDate, isoDate }; 34 | } 35 | 36 | 37 | }, [date, emptyText, disableRelative, format]); 38 | 39 | if (!result.displayDate || result.displayDate === emptyText) { 40 | return {result.displayDate}; 41 | }else if (result.displayDate !== result.fullDate) { 42 | return ; 43 | } else { 44 | return ; 45 | } 46 | } 47 | 48 | export default Date; -------------------------------------------------------------------------------- /jobhunt/client/src/components/ExpandableSnippet.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, useCallback, useState } from "react" 2 | import makeStyles from "makeStyles"; 3 | import { IconButton } from "@mui/material"; 4 | import Grid from "components/Grid"; 5 | import { ExpandLess, ExpandMore } from "@mui/icons-material"; 6 | 7 | type ExpandableSnippetProps = { 8 | maxHeight?: number, 9 | hidden?: boolean 10 | } 11 | 12 | const useStyles = makeStyles()((_, props) => ({ 13 | collapsed: { 14 | maxHeight: props.maxHeight ?? "30em", 15 | overflow: "hidden", 16 | maskImage: "linear-gradient(to bottom, black 90%, transparent 100%)" 17 | } 18 | })); 19 | 20 | const ExpandableSnippet = (props: PropsWithChildren) => { 21 | const [collapsed, setCollapsed] = useState(true); 22 | 23 | const { classes } = useStyles(props); 24 | 25 | const onClick = useCallback(() => { 26 | setCollapsed(c => !c); 27 | }, []); 28 | 29 | return ( 30 | 31 | 32 | {props.children} 33 | 34 | 35 | {!props.hidden ? ( 36 | 37 | {collapsed ? 38 | () 39 | : 40 | () 41 | } 42 | 43 | ) : null } 44 | 45 | 46 | ); 47 | } 48 | 49 | export default ExpandableSnippet; -------------------------------------------------------------------------------- /jobhunt/client/src/components/FeedbackBackdrop.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect } from "react"; 2 | import { useRecoilState } from "recoil"; 3 | import { Backdrop, CircularProgress, Grow, Paper, Typography } from "@mui/material"; 4 | import { Check } from "@mui/icons-material"; 5 | 6 | import Grid from "./Grid"; 7 | import makeStyles from "makeStyles"; 8 | import { feedbackState } from "state"; 9 | 10 | const useStyles = makeStyles()((theme) => ({ 11 | backdrop: { 12 | zIndex: theme.zIndex.modal + 1 13 | }, 14 | progress: { 15 | color: "#fff" 16 | }, 17 | check: { 18 | fontSize: "10em", 19 | borderRadius: "50%", 20 | background: theme.palette.success.main 21 | }, 22 | error: { 23 | fontSize: "10em", 24 | borderRadius: "50%", 25 | background: theme.palette.error.main, 26 | width: "1em", 27 | height: "1em", 28 | display: "flex", 29 | justifyContent: "center", 30 | alignItems: "center", 31 | fontWeight: theme.typography.fontWeightBold, 32 | marginBottom: theme.spacing(3) 33 | }, 34 | errorPaper: { 35 | padding: theme.spacing(2), 36 | maxWidth: "15em", 37 | textAlign: "center" 38 | } 39 | })); 40 | 41 | const FeedbackBackdrop = () => { 42 | const [state, setState] = useRecoilState(feedbackState); 43 | const { classes } = useStyles(); 44 | 45 | const handleClick = useCallback(() => { 46 | if (state.success || state.error) { 47 | setState({ loading: false, success: false, error: false }); 48 | } 49 | }, [state, setState]); 50 | 51 | useEffect(() => { 52 | if (state.success) { 53 | setTimeout(() => { 54 | setState({ loading: false, success: false, error: false }); 55 | }, 1000); 56 | } 57 | }, [state, setState]); 58 | 59 | return ( 60 | 61 | {state.loading && !state.success && !state.error && } 62 | {state.success && } 63 | {state.error && 64 | 65 | 66 | ! 67 | An error has occurred. Please try again or report this problem if it continues. 68 | 69 | } 70 | 71 | ); 72 | } 73 | 74 | export default FeedbackBackdrop; -------------------------------------------------------------------------------- /jobhunt/client/src/components/Grid.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { Grid as MuiGrid, GridProps as MuiGridProps, GridSize } from '@mui/material'; 3 | import makeStyles from 'makeStyles'; 4 | import { CSSObject } from 'tss-react'; 5 | 6 | type GridProps = MuiGridProps & { xxl?: boolean | GridSize | undefined }; 7 | 8 | const useStyles = makeStyles()((theme, props) => { 9 | let root: CSSObject = {}; 10 | if (props.xxl) { 11 | let percentage; 12 | const i = props.xxl as number; 13 | if (i) { 14 | percentage = `${(i / 12) * 100}%`; 15 | root = { 16 | [theme.breakpoints.up("xxl")]: { 17 | flexGrow: 0, 18 | maxWidth: percentage, 19 | flexBasis: percentage 20 | } 21 | } 22 | } else { 23 | root = { 24 | [theme.breakpoints.up("xxl")]: { 25 | flexGrow: 1, 26 | maxWidth: "100%", 27 | flexBasis: 0 28 | } 29 | } 30 | } 31 | } 32 | 33 | return { root: root }; 34 | }); 35 | 36 | const Grid = forwardRef((props:GridProps, ref:React.ForwardedRef) => { 37 | const { classes, cx } = useStyles(props); 38 | let className = props.className; 39 | const muiProps = { ...props, xxl: undefined }; 40 | 41 | if (props.item) { 42 | className = cx(className, classes.root); 43 | } 44 | 45 | return ; 46 | }); 47 | 48 | export default Grid; -------------------------------------------------------------------------------- /jobhunt/client/src/components/HideOnScroll.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react" 2 | import { Slide, useScrollTrigger } from "@mui/material" 3 | 4 | type HideOnScrollProps = { 5 | children: ReactElement 6 | } 7 | 8 | const HideOnScroll = (props: HideOnScrollProps) => { 9 | const trigger = useScrollTrigger({ threshold: 50 }); 10 | 11 | return ( 12 | {props.children} 13 | ) 14 | } 15 | 16 | export default HideOnScroll; -------------------------------------------------------------------------------- /jobhunt/client/src/components/Tab.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react" 2 | import { TabProps as MuiTabProps } from "@mui/material"; 3 | 4 | export type TabProps = PropsWithChildren<{ 5 | keepMounted?: boolean, 6 | tabProps?: MuiTabProps 7 | }> 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 10 | const Tab = (props: TabProps) => null; 11 | 12 | export default Tab; -------------------------------------------------------------------------------- /jobhunt/client/src/components/TabPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from "react" 2 | import { Box } from "@mui/material"; 3 | import makeStyles from "makeStyles"; 4 | 5 | export type TabPanelProps = { 6 | current: number, 7 | index: number, 8 | id: string 9 | } 10 | 11 | const useStyles = makeStyles()((theme) => ({ 12 | box: { 13 | padding: [theme.spacing(3), theme.spacing(1)], 14 | [`${theme.breakpoints.up("md")}`]: { 15 | padding: theme.spacing(3) 16 | } 17 | } 18 | })); 19 | 20 | const TabPanel = ({ current, index, id, children }: PropsWithChildren) => { 21 | const { classes } = useStyles(); 22 | 23 | return ( 24 | 36 | ); 37 | } 38 | 39 | export default TabPanel; -------------------------------------------------------------------------------- /jobhunt/client/src/components/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useCallback, useEffect, useMemo, useState } from "react" 2 | import { Tab, Tabs as MuiTabs, TabsProps as MuiTabsProps } from "@mui/material"; 3 | import TabPanel from "./TabPanel"; 4 | import { TabProps } from "./Tab"; 5 | import { useMountEffect } from "utils/hooks"; 6 | 7 | type TabsProps = MuiTabsProps & { 8 | labels: string[], 9 | children: React.ReactElement[] 10 | } 11 | 12 | const Tabs = ({ labels, children }: TabsProps) => { 13 | const [current, setCurrent] = useState(0); 14 | 15 | useMountEffect(() => { 16 | if (window.location.hash) { 17 | const index = labels.findIndex((l) => ToKebabCase(l) === window.location.hash.slice(1)); 18 | if (index > -1) { 19 | setCurrent(index); 20 | } 21 | } 22 | }); 23 | 24 | useEffect(() => { 25 | if (!window.location.hash && current === 0) { 26 | return; 27 | } 28 | 29 | window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${ToKebabCase(labels[current])}`); 30 | }, [current, labels]); 31 | 32 | const onChange = useCallback((_: React.SyntheticEvent, v: any) => setCurrent(v), []); 33 | 34 | const tabs = useMemo(() => 35 | React.Children.map(children, (tab, i) => ( 36 | ) 37 | ), 38 | [children, labels] 39 | ); 40 | 41 | const tabPanels = useMemo(() => 42 | React.Children.map(children, (tab, i) => ( 43 | 44 | 45 | {tab.props.children} 46 | 47 | ) 48 | ), 49 | [children, labels, current] 50 | ); 51 | 52 | return ( 53 | 54 | 55 | {tabs} 56 | 57 | {tabPanels} 58 | 59 | ) 60 | } 61 | 62 | // eslint-disable-next-line no-useless-escape 63 | const ToKebabCase = (input: string) => input.replace(" ", "-").replace("[\W-]+/g", "").toLowerCase(); 64 | 65 | export default Tabs; -------------------------------------------------------------------------------- /jobhunt/client/src/components/forms/ApiAutocomplete.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef, useState } from "react"; 2 | import { Autocomplete, AutocompleteProps } from "@mui/material"; 3 | import dayjs from "dayjs"; 4 | 5 | type ApiAutocompleteProps< 6 | T, 7 | M extends boolean | undefined, 8 | D extends boolean | undefined, 9 | F extends boolean | undefined 10 | > = Omit, "options"> & { 11 | fetchUrl: string, 12 | getResponseOptions: (response: any) => T[], 13 | cacheExpiry?: number 14 | } 15 | 16 | type CachedOptions = { 17 | options: T[], 18 | fetched: string 19 | } 20 | 21 | const ApiAutocomplete = < 22 | T, 23 | M extends boolean | undefined, 24 | D extends boolean | undefined, 25 | F extends boolean | undefined 26 | > 27 | ({ fetchUrl, getResponseOptions, cacheExpiry = 5, ...rest }: ApiAutocompleteProps) => { 28 | const [options, setOptions] = useState([]); 29 | const optionsFetched = useRef(false); 30 | 31 | const fetchOptions = useCallback(async () => { 32 | if (optionsFetched.current) { 33 | return; 34 | } 35 | 36 | optionsFetched.current = true; 37 | 38 | const cacheKey = `ApiAutocomplete_${fetchUrl}`; 39 | const cachedString = window.localStorage.getItem(cacheKey); 40 | const cached = cachedString ? JSON.parse(cachedString) as CachedOptions : null; 41 | 42 | if (cached?.fetched && !dayjs(cached.fetched).isBefore(dayjs().subtract(cacheExpiry, "minute"))) { 43 | setOptions(cached.options); 44 | } else { 45 | const response = await fetch(fetchUrl); 46 | 47 | if (response.ok) { 48 | const data = getResponseOptions(await response.json()); 49 | if (Array.isArray(data)) { 50 | window.localStorage.setItem(cacheKey, JSON.stringify({ fetched: dayjs(), options: data })); 51 | setOptions(data); 52 | } 53 | } else { 54 | console.error(`API request failed: GET ${fetchUrl}, HTTP ${response.status}`); 55 | } 56 | } 57 | 58 | }, [fetchUrl, getResponseOptions, cacheExpiry]); 59 | 60 | useEffect(() => { fetchOptions(); }, [fetchOptions]); 61 | 62 | return ; 63 | } 64 | 65 | export default ApiAutocomplete; -------------------------------------------------------------------------------- /jobhunt/client/src/components/forms/CountrySelector.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo } from "react" 2 | import data from "utils/countries.json" 3 | import { Autocomplete } from "mui-rff" 4 | 5 | type CountrySelectorProps = { 6 | name: string, 7 | label: string, 8 | required?: boolean, 9 | allowedCountries?: string[], 10 | hideForbiddenCountries?: boolean 11 | } 12 | 13 | type Country = { 14 | countryName: string, 15 | countryShortCode: string 16 | } 17 | 18 | const getOptionValue = (option: Country) => option.countryShortCode; 19 | const getOptionLabel = (option: Country | string) => (option as Country)?.countryName ?? option; 20 | 21 | const CountrySelector = ({ name, label, required, allowedCountries, hideForbiddenCountries }: CountrySelectorProps) => { 22 | const options: Country[] = useMemo(() => data.filter(d => 23 | !allowedCountries 24 | || !hideForbiddenCountries 25 | || allowedCountries.includes(d.countryShortCode.toLowerCase()) 26 | ), [allowedCountries, hideForbiddenCountries]); 27 | 28 | const getOptionDisabled = useCallback((option: Country) => { 29 | if (!allowedCountries) { 30 | return false; 31 | } else { 32 | return !allowedCountries.some(c => c.toLowerCase() === option.countryShortCode.toLowerCase()); 33 | } 34 | }, [allowedCountries]); 35 | 36 | return ( 37 | 48 | ) 49 | } 50 | 51 | export default CountrySelector; -------------------------------------------------------------------------------- /jobhunt/client/src/components/forms/DeleteDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react"; 2 | import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material"; 3 | 4 | type DeleteDialogProps = { 5 | open: boolean, 6 | entityName: string, 7 | deleteUrl?: string, 8 | onConfirm?: () => void, 9 | onClose: () => void 10 | } 11 | 12 | const DeleteDialog = ({ open, entityName, deleteUrl, onConfirm, onClose }: DeleteDialogProps) => { 13 | const onDelete = useCallback(async () => { 14 | if (deleteUrl) { 15 | const response = await fetch(deleteUrl, { method: "DELETE" }); 16 | 17 | if (!response.ok) { 18 | console.error(`API request failed: DELETE ${deleteUrl}, HTTP ${response.status}`); 19 | } 20 | } 21 | 22 | if (onConfirm) { 23 | onConfirm(); 24 | } 25 | 26 | onClose(); 27 | }, [deleteUrl, onConfirm, onClose]); 28 | 29 | return ( 30 | 31 | Confirm 32 | 33 | Are you sure you want to delete this {entityName}? This action cannot be undone. 34 | 35 | 36 | 37 | 38 | 39 | 40 | ) 41 | } 42 | 43 | export default DeleteDialog; -------------------------------------------------------------------------------- /jobhunt/client/src/components/forms/EditableMarkdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | import { Button, Link, Table, TableBody, TableCell, TableHead, TableRow, TextField } from "@mui/material"; 3 | 4 | import ReactMarkdown from "react-markdown"; 5 | import remarkGfm from "remark-gfm"; 6 | 7 | import makeStyles from "makeStyles"; 8 | import Grid from "components/Grid"; 9 | 10 | 11 | type EditableMarkdownProps = { 12 | value?: string, 13 | emptyText?: string, 14 | label?: string, 15 | onSave: (val: string) => void, 16 | } 17 | 18 | const remarkPlugins = [remarkGfm]; 19 | 20 | const useStyles = makeStyles()(() => ({ 21 | root: { 22 | position: "relative" 23 | }, 24 | buttons: { 25 | position: "absolute", 26 | pointerEvents: "none", 27 | "& button": { 28 | pointerEvents: "all" 29 | } 30 | }, 31 | textField: { 32 | "& textarea": { 33 | fontFamily: "monospace" 34 | } 35 | } 36 | })) 37 | 38 | const components = { 39 | a: Link, 40 | table: Table, 41 | thead: TableHead, 42 | tbody: TableBody, 43 | tr: ({ isHeader, ...props }: any) => , 44 | th: ({ isHeader, ...props }: any) => , 45 | td: ({ isHeader, ...props }: any) => 46 | } 47 | 48 | const EditableMarkdown = ({ value, emptyText, label, onSave }: EditableMarkdownProps) => { 49 | const [editing, setEditing] = useState(false); 50 | const [editingValue, setEditingValue] = useState(value ?? ""); 51 | 52 | const { classes } = useStyles(); 53 | 54 | const onEditSave = useCallback(() => { 55 | if (!editing) { 56 | setEditing(true); 57 | } else { 58 | onSave(editingValue); 59 | setEditing(false); 60 | } 61 | }, [editing, editingValue, onSave]); 62 | 63 | const onDiscard = useCallback(() => { 64 | setEditing(false); 65 | setEditingValue(value ?? ""); 66 | }, [value]); 67 | 68 | const onChange = useCallback((e: React.ChangeEvent) => { 69 | setEditingValue(e.target.value); 70 | }, []); 71 | 72 | return ( 73 | 74 | 75 | { editing && } 76 | 77 | 78 | 79 | 80 | 81 | { 82 | editing 83 | ? 84 | : {value ? value : emptyText ?? ""} 85 | } 86 | 87 | ); 88 | } 89 | 90 | export default EditableMarkdown; -------------------------------------------------------------------------------- /jobhunt/client/src/components/forms/NumberField.tsx: -------------------------------------------------------------------------------- 1 | import { TextField, TextFieldProps } from "mui-rff"; 2 | import React, { useCallback, useMemo } from "react"; 3 | 4 | type NumberFieldProps = TextFieldProps & { 5 | allowDecimal?: boolean, 6 | allowNegative?: boolean 7 | } 8 | 9 | const decimalSep = Intl.NumberFormat().formatToParts(1.1).find(p => p.type === "decimal")?.value ?? "."; 10 | 11 | const NumberField = (props: NumberFieldProps) => { 12 | const { allowDecimal, allowNegative, onKeyDown: propOnKeyDown, inputProps: propInputProps, ...rest } = props; 13 | 14 | const onKeyDown = useCallback((e: React.KeyboardEvent) => { 15 | if (e.key.length === 1) { 16 | const target = e.target as HTMLInputElement; 17 | if (!target) { 18 | return; 19 | } 20 | 21 | if (e.ctrlKey) { 22 | return; 23 | } 24 | 25 | if (allowDecimal && e.key === decimalSep && !target.value.includes(decimalSep)) { 26 | return; 27 | } else if (allowNegative && e.key === '-' && !target.value.includes('-') && target.selectionStart === 0) { 28 | return; 29 | } else if (!isNaN(parseInt(e.key, 10))) { 30 | return; 31 | } 32 | e.preventDefault(); 33 | } 34 | 35 | if (propOnKeyDown) { 36 | propOnKeyDown(e); 37 | } 38 | }, [allowDecimal, allowNegative, propOnKeyDown]); 39 | 40 | const regexPattern = useMemo(() => `^${allowNegative ? "-?" : ""}\\d+${allowDecimal ? `\\${decimalSep}?\\d*` : ""}$`, [allowDecimal, allowNegative]); 41 | const regex = useMemo(() => 42 | new RegExp(regexPattern), 43 | [regexPattern] 44 | ); 45 | 46 | // prevent inputting invalid values when pasting 47 | // it would be better to modify the pasted value so that it is valid, but I couldn't get this to work with 48 | // react-final-form, it still persisted the invalid value and I can't see a way to set the value programmatically 49 | const onPaste = useCallback((e: React.ClipboardEvent) => { 50 | const input = e.target as HTMLInputElement; 51 | if (input) { 52 | const currentValue = input.value; 53 | 54 | const value = e.clipboardData.getData("text/plain"); 55 | if (!regex.test(value)) { 56 | // prevent pasting if pasted value is not a valid number 57 | e.preventDefault(); 58 | } 59 | 60 | if (currentValue && input.selectionStart !== 0 && input.selectionEnd !== currentValue.length) { 61 | if ((currentValue.includes("-") && value.includes("-")) || (currentValue.includes(decimalSep) && value.includes(decimalSep))) { 62 | // prevent pasting if field already contains decimal or negative symbol and so does pasted value 63 | e.preventDefault(); 64 | } 65 | } 66 | } 67 | }, [regex]); 68 | 69 | const inputProps = useMemo(() => ({ 70 | ...propInputProps, 71 | pattern: regexPattern 72 | }), [propInputProps, regexPattern]); 73 | 74 | return 75 | } 76 | 77 | export default NumberField; -------------------------------------------------------------------------------- /jobhunt/client/src/components/odata/ODataCategoryFilter.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useCallback, useMemo, useState } from "react"; 2 | 3 | import { AutocompleteRenderInputParams, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent, TextField } from "@mui/material"; 4 | import { nanoid } from "nanoid"; 5 | 6 | import ApiAutocomplete from "components/forms/ApiAutocomplete"; 7 | import Grid from "components/Grid"; 8 | 9 | type ODataCategoryFilterProps = { 10 | value: any, 11 | setValue: (v: any) => void, 12 | fetchUrl: string 13 | } 14 | 15 | type CategoryFilter = { 16 | categories: Category[], 17 | connective: "any" | "all" 18 | } 19 | 20 | type Category = { 21 | id: number, 22 | name: string 23 | } 24 | 25 | export const getCategoryFilterString = (value: any, categoryCollection: string) => { 26 | const filter = value as CategoryFilter; 27 | if (filter && filter.categories.length > 0) { 28 | if (filter.connective === "any") { 29 | return `${categoryCollection}/any(x:x/CategoryId in (${filter.categories.map(c => c.id).join(", ")}))`; 30 | } else if (filter.connective === "all") { 31 | return `(${filter.categories.map(c => `${categoryCollection}/any(x:x/CategoryId eq ${c.id})`).join(" and ")})`; 32 | } 33 | } 34 | return false; 35 | } 36 | 37 | const getCategoryResponseOptions = (r: any) => r as Category[] ?? []; 38 | const renderInput = (params: AutocompleteRenderInputParams) => 39 | const getOptionLabel = (o: any) => (o as Category)?.name ?? ""; 40 | const isOptionEqualToValue = (o: any, v: any) => o.id === v.id; 41 | 42 | const ODataCategoryFilter = ({ value, setValue, fetchUrl }: ODataCategoryFilterProps) => { 43 | const [labelId] = useState(`category-filter-label_${nanoid(10)}`) 44 | 45 | const val = useMemo(() => value as CategoryFilter ?? { connective: "any", categories: [] }, [value]); 46 | 47 | const onConnectiveChange = useCallback((e: SelectChangeEvent<"any" | "all">) => { 48 | if (e.target.value === "any" || e.target.value === "all") { 49 | const newVal = { ...val }; 50 | newVal.connective = e.target.value; 51 | setValue(newVal); 52 | } 53 | }, [val, setValue]); 54 | 55 | const onCategoryChange = useCallback((_: React.SyntheticEvent, v: any) => { 56 | const newVal = { ...val }; 57 | newVal.categories = v as Category[]; 58 | setValue(newVal); 59 | }, [val, setValue]); 60 | 61 | return ( 62 | 63 | 64 | 65 | Operation 66 | 75 | 76 | 77 | 78 | 90 | 91 | 92 | ) 93 | } 94 | 95 | export default ODataCategoryFilter; -------------------------------------------------------------------------------- /jobhunt/client/src/components/odata/ODataGrid.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react" 2 | import { ODataGrid as DefaultDataGrid, ODataGridProps } from "o-data-grid" 3 | import { SearchOff } from "@mui/icons-material"; 4 | 5 | import makeStyles from "makeStyles"; 6 | import { GridOverlay } from "@mui/x-data-grid"; 7 | import { LinearProgress, Typography } from "@mui/material"; 8 | 9 | const pageSizes = [10, 15, 20, 50]; 10 | 11 | const ODataGrid = (props: ODataGridProps) => { 12 | const components = useMemo(() => ({ 13 | ...props.components, 14 | LoadingOverlay: LoadingOverlay, 15 | NoResultsOverlay: NoResultsOverlay, 16 | NoRowsOverlay: NoResultsOverlay 17 | }), [props.components]); 18 | 19 | return ( 20 | 25 | ) 26 | } 27 | 28 | const useLoadingStyles = makeStyles()(() => ({ 29 | container: { 30 | position: "absolute", 31 | top: 0, 32 | width: "100%" 33 | } 34 | })); 35 | 36 | const LoadingOverlay = () => { 37 | const { classes } = useLoadingStyles(); 38 | 39 | return ( 40 | 41 |
42 | 43 |
44 |
45 | ) 46 | } 47 | 48 | const useNoResultsStyles = makeStyles()(() => ({ 49 | overlay: { 50 | height: "100%" 51 | }, 52 | container: { 53 | height: "100%", 54 | display: "flex", 55 | fontSize: 75, 56 | flexDirection: "column", 57 | alignItems: "center", 58 | opacity: .5 59 | } 60 | })); 61 | 62 | const NoResultsOverlay = () => { 63 | const { classes } = useNoResultsStyles(); 64 | 65 | return ( 66 | 67 |
68 | 69 | No Results 70 |
71 |
72 | ) 73 | } 74 | 75 | export default ODataGrid; -------------------------------------------------------------------------------- /jobhunt/client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from "react-dom/client"; 3 | import App from 'App'; 4 | import { RecoilRoot } from 'recoil'; 5 | 6 | const container = document.getElementById("root"); 7 | const root = createRoot(container!); 8 | root.render(); -------------------------------------------------------------------------------- /jobhunt/client/src/makeStyles.ts: -------------------------------------------------------------------------------- 1 | import { createMakeStyles } from "tss-react"; 2 | import { useTheme } from "@mui/material"; 3 | 4 | export const { makeStyles } = createMakeStyles({ useTheme }); 5 | 6 | export default makeStyles; -------------------------------------------------------------------------------- /jobhunt/client/src/odata/ColumnDefinitions.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ODataCategoryFilter, { getCategoryFilterString } from "components/odata/ODataCategoryFilter"; 3 | import { ODataGridColDef } from "o-data-grid"; 4 | 5 | export const createCategoryColumn = (field: string, navigationCollection: string, fetchUrl: string, props?: Partial): ODataGridColDef => ({ 6 | field: field, 7 | headerName: "Categories", 8 | label: "Category", 9 | expand: { 10 | navigationField: navigationCollection, 11 | expand: { 12 | navigationField: "category", 13 | select: "name" 14 | } 15 | }, 16 | sortable: false, 17 | flex: 1, 18 | renderCustomFilter: (value, setValue) => , 19 | getCustomFilterString: (_, value) => getCategoryFilterString(value, navigationCollection), 20 | renderCell: (params) => params.row[navigationCollection].map((c: any) => c["category/name"]).join(", "), 21 | ...props 22 | }); 23 | -------------------------------------------------------------------------------- /jobhunt/client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /jobhunt/client/src/state.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil"; 2 | 3 | type FeedbackState = { 4 | loading: boolean, 5 | success: boolean, 6 | error: boolean, 7 | } 8 | 9 | export const feedbackState = atom({ 10 | key: "feedbackState", 11 | default: { loading: false, success: false, error: false } 12 | }); 13 | 14 | type ThemeState = "light" | "dark" 15 | 16 | const getDefaultTheme = () => { 17 | if (localStorage.getItem("theme") === "dark" || (window.matchMedia("(prefers-color-scheme: dark)").matches && localStorage.getItem("theme") === null)) { 18 | return "dark"; 19 | } else { 20 | return "light"; 21 | } 22 | } 23 | 24 | export const themeState = atom({ 25 | key: "themeState", 26 | default: getDefaultTheme() 27 | }); -------------------------------------------------------------------------------- /jobhunt/client/src/types/models/Category.ts: -------------------------------------------------------------------------------- 1 | type Category = { 2 | id: number, 3 | name: string 4 | } 5 | 6 | export default Category; -------------------------------------------------------------------------------- /jobhunt/client/src/types/models/Company.ts: -------------------------------------------------------------------------------- 1 | import CompanyCategory from "./CompanyCategory"; 2 | import CompanyName from "./CompanyName"; 3 | import Job from "./Job"; 4 | import WatchedPage from "./WatchedPage"; 5 | 6 | type Company = { 7 | id: number, 8 | name: string, 9 | location?: string, 10 | latitude?: number, 11 | longitude?: number, 12 | notes?: string, 13 | watched: boolean, 14 | blacklisted: boolean, 15 | website?: string, 16 | rating?: number, 17 | glassdoor?: string, 18 | linkedIn?: string, 19 | endole?: string, 20 | recruiter: boolean, 21 | 22 | jobs: Job[], 23 | companyCategories: CompanyCategory[], 24 | alternateNames: CompanyName[], 25 | watchedPages: WatchedPage[] 26 | } 27 | 28 | export default Company; -------------------------------------------------------------------------------- /jobhunt/client/src/types/models/CompanyCategory.ts: -------------------------------------------------------------------------------- 1 | import Category from "./Category" 2 | 3 | type CompanyCategory = { 4 | companyId: number, 5 | categoryId: number, 6 | category: Category 7 | } 8 | 9 | export default CompanyCategory; -------------------------------------------------------------------------------- /jobhunt/client/src/types/models/CompanyName.ts: -------------------------------------------------------------------------------- 1 | type CompanyName = { 2 | id: number, 3 | companyId: number, 4 | name: string 5 | } 6 | 7 | export default CompanyName; -------------------------------------------------------------------------------- /jobhunt/client/src/types/models/ICategoryLink.ts: -------------------------------------------------------------------------------- 1 | import Category from "./Category"; 2 | 3 | /** 4 | * Interface for a Category link entity (i.e. JobCategory or CompanyCategory) 5 | */ 6 | export default interface ICategoryLink { 7 | categoryId: number, 8 | category: Category 9 | } -------------------------------------------------------------------------------- /jobhunt/client/src/types/models/Job.ts: -------------------------------------------------------------------------------- 1 | import Company from "./Company"; 2 | import JobCategory from "./JobCategory"; 3 | import { Search } from "./Search"; 4 | 5 | type Job = { 6 | id: number, 7 | title: string, 8 | description: string, 9 | salary?: string, 10 | avgYearlySalary?: number, 11 | remote: boolean, 12 | location: string, 13 | latitude?: number, 14 | longitude?: number, 15 | url?: string, 16 | companyId?: number, 17 | posted: string, 18 | notes: string, 19 | seen: boolean, 20 | archived: boolean, 21 | status: string, 22 | dateApplied: string, 23 | provider?: string, 24 | providerId?: string, 25 | sourceId?: number, 26 | duplicateJobId?: number, 27 | actualCompanyId?: number, 28 | 29 | company: Company, 30 | duplicateJob?: Job, 31 | actualCompany?: Company, 32 | jobCategories: JobCategory[], 33 | source?: Search 34 | } 35 | 36 | export default Job; -------------------------------------------------------------------------------- /jobhunt/client/src/types/models/JobCategory.ts: -------------------------------------------------------------------------------- 1 | import Category from "./Category" 2 | 3 | type JobCategory = { 4 | jobId: number, 5 | categoryId: number, 6 | category: Category 7 | } 8 | 9 | export default JobCategory; -------------------------------------------------------------------------------- /jobhunt/client/src/types/models/Search.ts: -------------------------------------------------------------------------------- 1 | import { SearchRun } from "./SearchRun" 2 | 3 | export type Search = { 4 | id: number, 5 | provider: string, 6 | query: string, 7 | country: string, 8 | location?: string, 9 | distance?: number, 10 | maxAge?: number, 11 | lastResultCount?: number, 12 | lastFetchSuccess?: boolean, 13 | enabled: boolean, 14 | employerOnly: boolean, 15 | jobType?: string, 16 | lastRun?: string, 17 | 18 | runs: SearchRun[], 19 | 20 | displayName: string, 21 | } -------------------------------------------------------------------------------- /jobhunt/client/src/types/models/SearchRun.ts: -------------------------------------------------------------------------------- 1 | export type SearchRun = { 2 | id: number, 3 | time: string, 4 | success: boolean, 5 | message?: string, 6 | newJobs: number, 7 | newCompanies: number, 8 | timeTaken: number 9 | } -------------------------------------------------------------------------------- /jobhunt/client/src/types/models/WatchedPage.ts: -------------------------------------------------------------------------------- 1 | import Company from "./Company"; 2 | import { WatchedPageChange } from "./WatchedPageChange"; 3 | 4 | type WatchedPage = { 5 | id: number, 6 | companyId: number, 7 | url: string, 8 | cssSelector?: string, 9 | cssBlacklist?: string, 10 | lastScraped?: string, 11 | lastUpdated?: string, 12 | statusMessage?: string, 13 | enabled: boolean, 14 | requiresJS: boolean, 15 | 16 | company: Company, 17 | changes: WatchedPageChange[] 18 | } 19 | 20 | export default WatchedPage; -------------------------------------------------------------------------------- /jobhunt/client/src/types/models/WatchedPageChange.ts: -------------------------------------------------------------------------------- 1 | import WatchedPage from "./WatchedPage" 2 | 3 | export type WatchedPageChange = { 4 | id: number, 5 | watchedPageId: number, 6 | created: string, 7 | html: string, 8 | screenshotFileName?: string, 9 | 10 | watchedPage: WatchedPage 11 | } -------------------------------------------------------------------------------- /jobhunt/client/src/types/odata/LocationFilter.ts: -------------------------------------------------------------------------------- 1 | export type LocationFilter = { 2 | location?: string, 3 | distance?: number 4 | } 5 | 6 | export default LocationFilter; -------------------------------------------------------------------------------- /jobhunt/client/src/types/odata/ODataBatchRequest.ts: -------------------------------------------------------------------------------- 1 | type ODataBatchRequest = { 2 | requests: ODataRequest[] 3 | } 4 | 5 | type ODataRequest = { 6 | id?: string, 7 | method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", 8 | url: string, 9 | headers?: Record, 10 | body?: string 11 | } 12 | 13 | export default ODataBatchRequest; -------------------------------------------------------------------------------- /jobhunt/client/src/types/odata/ODataBatchResponse.ts: -------------------------------------------------------------------------------- 1 | type ODataBatchResponse = { 2 | responses: ODataResponse[] 3 | } 4 | 5 | type ODataResponse = { 6 | id?: string, 7 | status: number, 8 | headers: Record, 9 | body: ODataResponseBody 10 | } 11 | 12 | type ODataResponseBody = { 13 | value: any 14 | } 15 | 16 | export default ODataBatchResponse; -------------------------------------------------------------------------------- /jobhunt/client/src/types/odata/ODataMultipleResult.ts: -------------------------------------------------------------------------------- 1 | export type ODataMultipleResult = { 2 | value: T[] 3 | } -------------------------------------------------------------------------------- /jobhunt/client/src/types/odata/ODataSingleResult.ts: -------------------------------------------------------------------------------- 1 | export type ODataSingleResult = { 2 | value: T 3 | } -------------------------------------------------------------------------------- /jobhunt/client/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const IndeedSupportedCountries = ["ar", "au", "at", "bh", "be", "br", "ca", "cl", "cn", "co", "cz", "dk", "fi", "fr", "de", "gr", "hk", "hu", "in", "id", "ie", "il", "it", "jp", "kr", "kw", "lu", "my", "mx", "nl", "nz", "no", "om", "pk", "pe", "ph", "pl", "pt", "qt", "ro", "ru", "sa", "sg", "za", "es", "se", "ch", "tw", "th", "tr", "ae", "gb", "us", "ve", "vn"]; 2 | 3 | export {}; -------------------------------------------------------------------------------- /jobhunt/client/src/utils/forms.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Compute the differences between two objects of the same type, returning the properties which have different values 3 | * @param original Original object 4 | * @param modified Modified object 5 | * @returns Partial containing only properties of changed which have a different value to original 6 | */ 7 | export const getChangedProperties = ,>(original: T, modified: T) => { 8 | const keys = new Set(); 9 | Object.keys(original).forEach(k => keys.add(k)); 10 | Object.keys(modified).forEach(k => keys.add(k)); 11 | 12 | return Array.from(keys).reduce( 13 | (a: Partial, k) => { 14 | const key = k as keyof T; 15 | const oldValue = original[key]; 16 | const newValue = modified[key] !== undefined ? modified[key] : null; 17 | 18 | if (key && oldValue !== newValue) { 19 | return { ...a, [key]: newValue } 20 | } else { 21 | return a; 22 | } 23 | }, 24 | {} 25 | ); 26 | } 27 | 28 | /** 29 | * Check if a partial has any properties that are defined (i.e. !== undefined) 30 | * @param partial Partial object 31 | * @returns True if there is at least one property of partial that is defined 32 | */ 33 | export const hasDefined = (partial: Partial) => Object.entries(partial).some(([ , v]) => v !== undefined); 34 | -------------------------------------------------------------------------------- /jobhunt/client/src/utils/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useTheme, Breakpoint, Theme } from "@mui/material/styles" 2 | import React, { useEffect, useMemo, useState } from "react" 3 | import { useSetRecoilState } from "recoil" 4 | import { feedbackState } from "state" 5 | 6 | export type ResponsiveValues

= Partial> 7 | 8 | export const useResponsive = () => { 9 | const theme = useTheme() 10 | 11 | const matches = useBreakpoints(); 12 | 13 | return function

(responsiveValues: ResponsiveValues

) { 14 | let match: Breakpoint | undefined; 15 | theme.breakpoints.keys.forEach((breakpoint) => { 16 | if (matches[breakpoint] && responsiveValues[breakpoint] != null) { 17 | match = breakpoint; 18 | } 19 | }) 20 | 21 | return match && responsiveValues[match] 22 | } 23 | } 24 | 25 | // eslint-disable-next-line react-hooks/exhaustive-deps 26 | export const useMountEffect = (func: React.EffectCallback) => useEffect(func, []); 27 | 28 | export const useBreakpoints = ():Partial> => { 29 | const theme = useTheme(); 30 | const [matches, setMatches] = useState>>(getMatches(theme.breakpoints.keys, theme)); 31 | 32 | useEffect(() => { 33 | const queries = getQueries(theme.breakpoints.keys, theme); 34 | const listeners: Partial void>> = {}; 35 | 36 | const updateMatch = (b: Breakpoint) => { 37 | setMatches((oldMatches) => ({...oldMatches, [b]: queries[b]?.matches ?? false })); 38 | } 39 | 40 | theme.breakpoints.keys.forEach(b => { 41 | listeners[b] = () => updateMatch(b); 42 | queries[b]!.addEventListener("change", listeners[b]!); 43 | }); 44 | 45 | return () => { 46 | theme.breakpoints.keys.forEach(b => { 47 | queries[b]!.removeEventListener("change", listeners[b]!) 48 | }) 49 | } 50 | }, [theme]); 51 | 52 | return matches; 53 | } 54 | 55 | const getQueries = (breakpoints: Breakpoint[], theme: Theme) => breakpoints.reduce((acc: Partial>, b) => 56 | ({ 57 | ...acc, 58 | [b]: window.matchMedia(theme.breakpoints.up(b).replace(/^@media( ?)/m, '')) 59 | }), 60 | {} 61 | ); 62 | 63 | const getMatches = (breakpoints: Breakpoint[], theme: Theme) => breakpoints.reduce((acc: Partial>, b) => 64 | ({ 65 | ...acc, 66 | [b]: window.matchMedia(theme.breakpoints.up(b).replace(/^@media( ?)/m, '')).matches 67 | }), 68 | {} 69 | ); 70 | 71 | export const useFeedback = () => { 72 | const setState = useSetRecoilState(feedbackState); 73 | 74 | const funcs = useMemo(() => ({ 75 | showLoading: () => setState({ loading: true, success: false, error: false }), 76 | showSuccess: () => setState({ loading: false, success: true, error: false }), 77 | showError: () => setState({ loading: false, success: false, error: true }), 78 | clear: () => setState(s => ({ loading: false, success: s.success, error: false })) 79 | }), [setState]); 80 | 81 | return funcs; 82 | } -------------------------------------------------------------------------------- /jobhunt/client/src/utils/requests.ts: -------------------------------------------------------------------------------- 1 | // btoa is only deprecated for Node.js, and the Buffer class alternative is not available in the browser 2 | export const toBase64Json = (obj: any) => window.btoa(JSON.stringify(obj)); -------------------------------------------------------------------------------- /jobhunt/client/src/views/Companies.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { GridSortModel } from "@mui/x-data-grid" 3 | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; 4 | import { Helmet } from "react-helmet"; 5 | import { ODataColumnVisibilityModel } from "o-data-grid"; 6 | import ODataGrid from "components/odata/ODataGrid"; 7 | 8 | import "dayjs/locale/en-gb" 9 | 10 | import { getCompanyColumns } from "odata/CompanyColumns"; 11 | import CompanyDialog from "components/model-dialogs/CompanyDialog"; 12 | 13 | const columnVisibility: ODataColumnVisibilityModel = { 14 | "avgSalary": { xs: false, xl: true }, 15 | "jobs@odata.count": { xs: false, md: true }, 16 | "latestJob": { xs: false, md: true }, 17 | "latestPageUpdate": { xs: false, xl: true }, 18 | "companyCategories": { xs: false, xxl: true }, 19 | } 20 | 21 | const columns = getCompanyColumns(); 22 | 23 | const defaultSort: GridSortModel = [{ field: "name", sort: "asc" }]; 24 | 25 | const alwaysSelect = ["id"]; 26 | 27 | const Companies = () => ( 28 |

29 | 30 | Companies | JobHunt 31 | 32 | 33 | 42 | 43 | 44 |
45 | ); 46 | 47 | export default Companies; -------------------------------------------------------------------------------- /jobhunt/client/src/views/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from "react" 2 | import { Typography } from "@mui/material" 3 | import Grid from "components/Grid"; 4 | import { GridRowParams, GridSortModel } from "@mui/x-data-grid" 5 | import { ODataColumnVisibilityModel } from "o-data-grid"; 6 | import ODataGrid from "components/odata/ODataGrid"; 7 | 8 | import SwipeableView from "react-swipeable-views" 9 | import { autoPlay } from "react-swipeable-views-utils" 10 | import { Helmet } from "react-helmet" 11 | 12 | import makeStyles from "makeStyles"; 13 | 14 | import Card from "components/Card" 15 | import { Work } from "@mui/icons-material" 16 | import CardHeader from "components/CardHeader" 17 | import CardBody from "components/CardBody" 18 | import { getJobColumns } from "odata/JobColumns"; 19 | 20 | type JobCount = { 21 | daily: number, 22 | weekly: number, 23 | monthly: number 24 | } 25 | 26 | const useStyles = makeStyles()((theme) => ({ 27 | unseen: { 28 | fontWeight: theme.typography.fontWeightBold 29 | } 30 | })); 31 | 32 | const AutoPlaySwipeableView = autoPlay(SwipeableView); 33 | 34 | const columns = getJobColumns(); 35 | 36 | const columnVisibility: ODataColumnVisibilityModel = { 37 | "company/name": { xs: false, md: true }, 38 | "duplicateJob/title": false, 39 | "salary": { xs: false, xl: true }, 40 | "status": false, 41 | "jobCategories": false, 42 | "source/displayName": false, 43 | "posted": { xs: false, sm: true }, 44 | "remote": false 45 | }; 46 | 47 | const alwaysSelect = ["id"]; 48 | 49 | const defaultSort:GridSortModel = [{ field: "posted", sort: "desc" }]; 50 | 51 | export const Dashboard = () => { 52 | const [jobCounts, setJobCounts] = useState({ daily: -1, weekly: -1, monthly: -1 }); 53 | const [index, setIndex] = useState(0); 54 | 55 | const { classes } = useStyles(); 56 | 57 | useEffect(() => { 58 | const fetchJobCounts = async () => { 59 | const response = await fetch("/api/jobs/counts", { method: "GET"} ); 60 | if (response.ok) { 61 | const data = await response.json(); 62 | setJobCounts({ ...data }); 63 | } else { 64 | console.error(`API request failed: /api/jobs/counts, HTTP ${response.status}`) 65 | } 66 | }; 67 | 68 | fetchJobCounts() 69 | }, []); 70 | 71 | const getClass = useCallback((params: GridRowParams) => params.row.seen ? "" : classes.unseen, [classes]); 72 | 73 | return ( 74 | 75 | 76 | Dashboard | JobHunt 77 | 78 | 79 | 80 | 81 | )}/> 82 | 83 | New Jobs 84 | setIndex(i)} interval={7500}> 85 |
86 | {jobCounts.daily >= 0 ? jobCounts.daily : null} 87 | Last 24 Hours 88 |
89 |
90 | {jobCounts.weekly >= 0 ? jobCounts.weekly : null} 91 | Last Week 92 |
93 |
94 | {jobCounts.monthly >= 0 ? jobCounts.monthly : null} 95 | Last Month 96 |
97 |
98 |
99 |
100 |
101 |
102 | 103 | 104 | 105 | Recent Jobs 106 | Jobs recently fetched from searches 107 | 108 | 109 | 118 | 119 | 120 | 121 |
122 | ); 123 | } 124 | 125 | 126 | export default Dashboard; -------------------------------------------------------------------------------- /jobhunt/client/src/views/Jobs.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react" 2 | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; 3 | import { GridRowParams, GridSortModel } from "@mui/x-data-grid" 4 | import { ODataColumnVisibilityModel } from "o-data-grid"; 5 | import ODataGrid from "components/odata/ODataGrid"; 6 | 7 | import makeStyles from "makeStyles"; 8 | 9 | import { Helmet } from "react-helmet"; 10 | 11 | // have to do this and specify the locale using a string for some reason 12 | // importing as an object and passing directly doesn't work, no clue why 13 | import "dayjs/locale/en-gb" 14 | 15 | import JobDialog from "components/model-dialogs/JobDialog"; 16 | import { getJobColumns } from "odata/JobColumns"; 17 | 18 | const useStyles = makeStyles()((theme) => ({ 19 | unseen: { 20 | fontWeight: theme.typography.fontWeightBold 21 | }, 22 | archived: { 23 | fontStyle: "italic" 24 | }, 25 | fab: { 26 | position: "fixed", 27 | bottom: theme.spacing(2), 28 | right: theme.spacing(2) 29 | } 30 | })); 31 | 32 | const columns = getJobColumns(); 33 | 34 | const alwaysSelect = ["id", "archived"]; 35 | 36 | const columnVisibility: ODataColumnVisibilityModel = { 37 | "company/name": { xs: false, md: true }, 38 | "duplicateJob/title": false, 39 | "salary": { xs: false, lg: true }, 40 | "status": false, 41 | "jobCategories": { xs: false, xl: true }, 42 | "source/displayName": false, 43 | "posted": { xs: false, sm: true }, 44 | "remote": false, 45 | } 46 | 47 | const defaultSort: GridSortModel = [{ field: "posted", sort: "desc" }]; 48 | 49 | const Jobs = () => { 50 | const { classes } = useStyles(); 51 | 52 | const getClass = useCallback((params: GridRowParams) => params.row.archived ? classes.archived : params.row.seen ? "" : classes.unseen, [classes]); 53 | 54 | return ( 55 |
56 | 57 | Jobs | JobHunt 58 | 59 | 60 | 70 | 71 | 72 |
73 | ); 74 | } 75 | 76 | export default Jobs; -------------------------------------------------------------------------------- /jobhunt/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "target": "es2020", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "jsx": "react-jsx" 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /jobhunt/client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import tsConfigPaths from "vite-tsconfig-paths"; 4 | import checker from "vite-plugin-checker" 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | react(), 9 | tsConfigPaths(), 10 | checker({ 11 | typescript: true, 12 | eslint: { 13 | lintCommand: "eslint ./src" 14 | } 15 | }) 16 | ], 17 | server: { 18 | host: true, 19 | port: 5001, 20 | hmr: { 21 | protocol: "ws" 22 | } 23 | }, 24 | build: { 25 | outDir: "build" 26 | } 27 | }) -------------------------------------------------------------------------------- /jobhunt/docker-compose-prod.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | 3 | services: 4 | web-prod: 5 | image: "jamerst/jobhunt:latest" 6 | ports: 7 | - "80:5000" 8 | depends_on: 9 | - db 10 | volumes: 11 | - /etc/localtime:/etc/localtime:ro 12 | - jobhunt-data:/jobhunt-data 13 | environment: 14 | ASPNETCORE_ENVIRONMENT: Production 15 | Screenshots__Schedule: "*/15 * * * *" 16 | Screenshots__Directory: "/jobhunt-data/page-screenshots" 17 | Screenshots__WidthPixels: 1920 18 | Screenshots__QualityPercent: 80 19 | Screenshots__PageLoadTimeoutSeconds: 10 20 | Search__Indeed__PublisherId: YOUR_PUBLISHER_ID 21 | Search__Indeed__FetchSalary: "true" 22 | Search__Indeed__UseGraphQL: "true" 23 | Search__Indeed__GraphQLApiKey: YOUR_API_KEY 24 | Search__Schedules__0: 0 9 * * * 25 | Search__Schedules__1: 0 12 * * * 26 | Search__Schedules__2: 0 18 * * * 27 | Search__NominatimCountryCodes: "gb" 28 | Search__PageLoadWaitSeconds: 5 29 | Search__CheckForDuplicateJobs: "true" 30 | Search__DescriptionSimilarityThreshold: 0.9 31 | Search__TitleSimilarityThreshold: 0.7 32 | Search__IdenticalDescriptionSimilarityThreshold: 0.98 33 | Serilog__WriteTo__0__Name: "Console" 34 | # Serilog__WriteTo__0__Name: "Elasticsearch" 35 | # Serilog__WriteTo__0__Args__nodeUris: "http://elasticsearch:9200" 36 | # Serilog__WriteTo__0__Args__indexFormat: "jobhunt-serilog-{0:yyyy.MM}" 37 | TZ: "Europe/London" 38 | CultureName: "en-GB" 39 | networks: 40 | - jobhunt 41 | - docker_logging 42 | restart: always 43 | shm_size: '1gb' 44 | db: 45 | image: "postgres:13.2" 46 | environment: 47 | POSTGRES_DB: jobhunt_db 48 | POSTGRES_USER: jobhunt 49 | POSTGRES_PASSWORD: jobhunt 50 | ports: 51 | - "5432:5432" 52 | volumes: 53 | - postgres-data:/var/lib/postgresql/data 54 | - ./init-data/initdb.d:/docker-entrypoint-initdb.d/ 55 | networks: 56 | - jobhunt 57 | restart: always 58 | 59 | volumes: 60 | postgres-data: 61 | jobhunt-data: 62 | 63 | networks: 64 | jobhunt: 65 | driver: bridge 66 | docker_logging: 67 | external: true -------------------------------------------------------------------------------- /jobhunt/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | 3 | services: 4 | web: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile.dev 8 | shm_size: '1gb' 9 | ports: 10 | - "5000:5000" 11 | - "5001:5001" 12 | depends_on: 13 | - db 14 | volumes: 15 | - ./:/app:Z 16 | - ~/.microsoft/usersecrets:/root/.microsoft/usersecrets:ro,Z 17 | - /etc/localtime:/etc/localtime:ro 18 | - jobhunt-data:/jobhunt-data 19 | environment: 20 | # Screenshots__Schedule: "* * * * *" 21 | Screenshots__Directory: "/jobhunt-data/page-screenshots" 22 | Screenshots__QualityPercent: 80 23 | Screenshots__WidthPixels: 1920 24 | Screenshots__PageLoadTimeoutSeconds: 10 25 | Search__Indeed__FetchSalary: "true" 26 | Search__Indeed__UseGraphQL: "true" 27 | # Search__Schedules__0: "* * * * *" 28 | Search__NominatimCountryCodes: "gb" 29 | Search__PageLoadWaitSeconds: 5 30 | Search__CheckForDuplicateJobs: "true" 31 | Search__DescriptionSimilarityThreshold: 0.9 32 | Search__TitleSimilarityThreshold: 0.7 33 | Search__IdenticalDescriptionSimilarityThreshold: 0.98 34 | Serilog__WriteTo__0__Name: "Console" 35 | # Serilog__WriteTo__0__Name: "Elasticsearch" 36 | # Serilog__WriteTo__0__Args__nodeUris: "http://elasticsearch:9200" 37 | # Serilog__WriteTo__0__Args__indexFormat: "jobhunt-serilog-{0:yyyy.MM}" 38 | WDS_SOCKET_PORT: "5000" 39 | TZ: "Europe/London" 40 | CultureName: "en-GB" 41 | networks: 42 | - jobhunt 43 | - docker_logging 44 | db: 45 | image: "postgres:13.1" 46 | environment: 47 | POSTGRES_DB: jobhunt_db 48 | POSTGRES_USER: jobhunt 49 | POSTGRES_PASSWORD: jobhunt 50 | ports: 51 | - "5432:5432" 52 | volumes: 53 | - postgres-data:/var/lib/postgresql/data 54 | # - ./init-data/initdb.d:/docker-entrypoint-initdb.d/:Z 55 | networks: 56 | - jobhunt 57 | 58 | volumes: 59 | postgres-data: 60 | jobhunt-data: 61 | 62 | networks: 63 | jobhunt: 64 | driver: bridge 65 | docker_logging: 66 | external: true -------------------------------------------------------------------------------- /jobhunt/init-data/initdb.d/11-functions.sql: -------------------------------------------------------------------------------- 1 | -- Sourced from https://gist.github.com/carlzulauf/1724506 2 | 3 | -- Haversine Formula based geodistance in miles (constant is diameter of Earth in miles) 4 | -- Based on a similar PostgreSQL function found here: https://gist.github.com/831833 5 | -- Updated to use distance formulas found here: http://www.codecodex.com/wiki/Calculate_distance_between_two_points_on_a_globe 6 | CREATE OR REPLACE FUNCTION public.geodistance(alat double precision, alng double precision, blat double precision, blng double precision) 7 | RETURNS double precision AS 8 | $BODY$ 9 | SELECT asin( 10 | sqrt( 11 | sin(radians($3-$1)/2)^2 + 12 | sin(radians($4-$2)/2)^2 * 13 | cos(radians($1)) * 14 | cos(radians($3)) 15 | ) 16 | ) * 7926.3352 AS distance; 17 | $BODY$ 18 | LANGUAGE sql IMMUTABLE 19 | COST 100; -------------------------------------------------------------------------------- /jobhunt/install-geckodriver.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # download and install latest geckodriver for linux or mac. 3 | # required for selenium to drive a firefox browser. 4 | 5 | # sourced from https://gist.github.com/cgoldberg/4097efbfeb40adf698a7d05e75e0ff51 6 | 7 | install_dir="/usr/local/bin" 8 | json=$(curl -s https://api.github.com/repos/mozilla/geckodriver/releases/latest) 9 | url=$(echo "$json" | jq -r '.assets[].browser_download_url | select(contains("linux64")) | select(contains("asc") | not)') 10 | 11 | echo "Downloading geckodriver from $url" 12 | curl -s -L "$url" | tar -xz -C $install_dir 13 | chmod +x "$install_dir/geckodriver" 14 | echo "Installed geckodriver binary in $install_dir" -------------------------------------------------------------------------------- /jobhunt/jobhunt.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.5.002.0 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JobHunt", "JobHunt.csproj", "{45705574-6500-4677-B3DA-282D8A4B35FE}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {45705574-6500-4677-B3DA-282D8A4B35FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {45705574-6500-4677-B3DA-282D8A4B35FE}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {45705574-6500-4677-B3DA-282D8A4B35FE}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {45705574-6500-4677-B3DA-282D8A4B35FE}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {5E991BEC-969C-4ED1-9AA5-6E1AE2BCCD21} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /res/jobhunt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamerst/JobHunt/5aba9a184a52a69b6726b663ceae9dca4b87246a/res/jobhunt.png --------------------------------------------------------------------------------