Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcfc50ff32 | |||
| b1ed1cb201 | |||
| e1f171168e | |||
| ae2bc9b902 | |||
| 30a8df431f | |||
| 95b0cfa0a9 | |||
| 20b13647de | |||
| df011f2a03 | |||
| 3414c61cea | |||
| 898dd09d50 | |||
| 1222a86eb7 | |||
| c89df975bd | |||
| 709c0ac4c3 | |||
| 99e5cfb76b | |||
| 91b2baa445 | |||
| 0f64cb8d99 | |||
| b67e926c5f | |||
| f7d856147e | |||
| 8679bd1efd | |||
| 1bcf95d8d4 | |||
| 73f67d1342 | |||
| 650505c08d | |||
| 4066ab5f3f | |||
| 7a316b4a45 | |||
| 808a4901d9 | |||
| b5b654532c | |||
| 2838885e22 | |||
| 8f90a4cfda | |||
| 978dd3a069 | |||
| f9530b168f |
@@ -15,6 +15,7 @@ env:
|
||||
WEB_IMAGE: apps/myai-web
|
||||
CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job
|
||||
CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job
|
||||
PAGE_FETCHER_API_IMAGE: apps/myai-page-fetcher-api
|
||||
IMAGE_TAG: staging
|
||||
|
||||
jobs:
|
||||
@@ -62,6 +63,10 @@ jobs:
|
||||
run: |
|
||||
docker build -f Jobs/cv-search-job/Dockerfile -t "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" .
|
||||
|
||||
- name: Build Page Fetcher API image
|
||||
run: |
|
||||
docker build -f Apis/page-fetcher-api/Dockerfile -t "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}" .
|
||||
|
||||
- name: Push API image
|
||||
run: |
|
||||
docker push "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}"
|
||||
@@ -88,4 +93,8 @@ jobs:
|
||||
|
||||
- name: Push CV search job image
|
||||
run: |
|
||||
docker push "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}"
|
||||
docker push "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}"
|
||||
|
||||
- name: Push Page Fetcher API image
|
||||
run: |
|
||||
docker push "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}"
|
||||
@@ -181,7 +181,7 @@ public sealed class CvMatcherController : ControllerBase
|
||||
try
|
||||
{
|
||||
var tokenResp = await _jobSearchApi.CreateTokenAsync(
|
||||
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords },
|
||||
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords, Location = res.Location },
|
||||
ct);
|
||||
if (!string.IsNullOrWhiteSpace(tokenResp.TokenId))
|
||||
{
|
||||
@@ -246,38 +246,24 @@ public sealed class CvMatcherController : ControllerBase
|
||||
{
|
||||
var result = await _jobSearchApi.StartSearchAsync(t, ct);
|
||||
var lang = "en";
|
||||
var html = result.Status switch
|
||||
var (title, message) = result.Status switch
|
||||
{
|
||||
StartJobSearchStatus.Started =>
|
||||
HtmlPage(_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)),
|
||||
StartJobSearchStatus.AlreadyUsed =>
|
||||
HtmlPage(_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)),
|
||||
StartJobSearchStatus.Expired =>
|
||||
HtmlPage(_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)),
|
||||
_ =>
|
||||
HtmlPage(_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang))
|
||||
StartJobSearchStatus.Started => (_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)),
|
||||
StartJobSearchStatus.AlreadyUsed => (_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)),
|
||||
StartJobSearchStatus.Expired => (_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)),
|
||||
_ => (_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang))
|
||||
};
|
||||
return Content(html, "text/html");
|
||||
return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Job search start failed for token {Token}.", t);
|
||||
return Content(HtmlPage(_templates.Get("html.job-search.error.title", "en"), _templates.Get("html.job-search.error.message", "en")), "text/html");
|
||||
var title = _templates.Get("html.job-search.error.title", "en");
|
||||
var message = _templates.Get("html.job-search.error.message", "en");
|
||||
return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html");
|
||||
}
|
||||
}
|
||||
|
||||
private static string HtmlPage(string title, string message) => $$"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>{{title}} - MyAi.ro</title>
|
||||
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f5f5}
|
||||
.card{background:#fff;padding:2rem 3rem;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.1);text-align:center;max-width:480px}
|
||||
h1{font-size:1.4rem;margin-bottom:.5rem}p{color:#555}</style>
|
||||
</head>
|
||||
<body><div class="card"><h1>{{title}}</h1><p>{{message}}</p></div></body>
|
||||
</html>
|
||||
""";
|
||||
|
||||
private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Options;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
using Common.Responses;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Api.Controllers
|
||||
{
|
||||
@@ -17,38 +18,44 @@ namespace Api.Controllers
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[EnableCors("FrontendOnly")]
|
||||
[EnableRateLimiting("download")]
|
||||
public sealed class FileDownloadController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<FileDownloadController> _logger;
|
||||
private readonly FileStorageSettings _fileStorageSettings;
|
||||
private readonly IContentTypeProvider _contentTypeProvider;
|
||||
private readonly IEmailSender _emailSender;
|
||||
private const int BufferSize = 81920; // 80 KB buffer for optimal streaming performance
|
||||
private readonly ICaptchaVerifier _captcha;
|
||||
private const int BufferSize = 81920;
|
||||
|
||||
public FileDownloadController(
|
||||
ILogger<FileDownloadController> logger,
|
||||
IOptions<FileStorageSettings> fileStorageSettings,
|
||||
IContentTypeProvider contentTypeProvider,
|
||||
IEmailSender emailSender)
|
||||
IEmailSender emailSender,
|
||||
ICaptchaVerifier captcha)
|
||||
{
|
||||
_logger = logger;
|
||||
_fileStorageSettings = fileStorageSettings.Value;
|
||||
_contentTypeProvider = contentTypeProvider;
|
||||
_emailSender = emailSender;
|
||||
_captcha = captcha;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a file with support for resume (range requests) and chunked transfer.
|
||||
/// Supports HTTP 206 Partial Content for efficient downloads and resume capability.
|
||||
/// Requires a valid reCAPTCHA v3 token on the initial (non-range) request.
|
||||
/// Sends email notification when download starts.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The name of the file to download (optional - uses default from settings if not provided)</param>
|
||||
/// <param name="fileName">The name of the file to download (optional - uses default from settings if not provided).</param>
|
||||
/// <param name="captchaToken">reCAPTCHA v3 token — required on the initial download request; omit on subsequent range requests.</param>
|
||||
/// <returns>File stream with appropriate headers for resumable downloads</returns>
|
||||
[HttpGet("{fileName?}")]
|
||||
[SwaggerOperation(Summary = "Download file", Description = "Downloads a file with support for full and ranged (resumable) transfers.")]
|
||||
[SwaggerOperation(Summary = "Download file", Description = "Downloads a file. Requires a reCAPTCHA v3 token on the initial request. Range requests for resume do not require a token.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Full file content returned")]
|
||||
[SwaggerResponse(StatusCodes.Status206PartialContent, "Partial file content returned for a range request")]
|
||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "No file name provided and no default configured")]
|
||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Missing/invalid captcha token, no file name, or no default configured")]
|
||||
[SwaggerResponse(StatusCodes.Status404NotFound, "Requested file was not found")]
|
||||
[SwaggerResponse(StatusCodes.Status416RangeNotSatisfiable, "Requested byte range is invalid")]
|
||||
[SwaggerResponse(StatusCodes.Status500InternalServerError, "Unexpected server error while downloading")]
|
||||
@@ -58,10 +65,30 @@ namespace Api.Controllers
|
||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status416RangeNotSatisfiable)]
|
||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> DownloadFile(string? fileName = null)
|
||||
public async Task<IActionResult> DownloadFile(string? fileName = null, [FromQuery] string? captchaToken = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
// Captcha required on the initial (full) download only — range requests are resume continuations.
|
||||
var isRangeRequest = !string.IsNullOrEmpty(Request.Headers[HeaderNames.Range].ToString());
|
||||
if (!isRangeRequest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(captchaToken))
|
||||
{
|
||||
_logger.LogWarning("Download attempt without captcha token from IP={IP}", HttpContext.Connection.RemoteIpAddress);
|
||||
return BadRequest(new ErrorResponse { Error = "Captcha token is required.", Code = "captcha_token_missing" });
|
||||
}
|
||||
|
||||
var verdict = await _captcha.VerifyAsync(captchaToken, userIp, "file_download", CancellationToken.None);
|
||||
if (!verdict.Success)
|
||||
{
|
||||
_logger.LogWarning("Download blocked by captcha. IP={IP} Score={Score}", userIp, verdict.Score);
|
||||
return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" });
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
fileName = _fileStorageSettings.DefaultFileName;
|
||||
@@ -99,7 +126,6 @@ namespace Api.Controllers
|
||||
if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType))
|
||||
contentType = "application/octet-stream";
|
||||
|
||||
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ using Email.Data;
|
||||
using Email.Data.Repositories;
|
||||
using Email.Data.Repositories.Contracts;
|
||||
using Email.Data.Services;
|
||||
using EmailApi.Models.Clients;
|
||||
using EmailApi.Models.Settings;
|
||||
using Email.Models.Clients;
|
||||
using Email.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Models.Settings;
|
||||
using MyAi.Data;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using Api.Services.Contracts;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Email.Data.Services;
|
||||
using EmailApi.Models.Clients;
|
||||
using EmailApi.Models.Requests;
|
||||
using Email.Models.Clients;
|
||||
using Email.Models.Requests;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Models.Requests;
|
||||
using Models.Settings;
|
||||
using System.Net;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
@@ -194,31 +195,35 @@ public sealed class EmailApiEmailSender : IEmailSender
|
||||
/// <inheritdoc />
|
||||
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
|
||||
{
|
||||
// Build HTML lists for strengths, gaps, and recommendations
|
||||
var strengths = result.Strengths?.Count > 0
|
||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
|
||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
|
||||
string.Join("", result.Strengths.Select(s => $"<li>{s}</li>")) + "</ul>"
|
||||
: "<p style=\"color:#6c757d\">—</p>";
|
||||
: "<p style=\"color:#6c757d;margin:0\">—</p>";
|
||||
|
||||
var gaps = result.Gaps?.Count > 0
|
||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
|
||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
|
||||
string.Join("", result.Gaps.Select(g => $"<li>{g}</li>")) + "</ul>"
|
||||
: "<p style=\"color:#6c757d\">—</p>";
|
||||
: "<p style=\"color:#6c757d;margin:0\">—</p>";
|
||||
|
||||
var recommendations = result.Recommendations?.Count > 0
|
||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
|
||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
|
||||
string.Join("", result.Recommendations.Select(r => $"<li>{r}</li>")) + "</ul>"
|
||||
: "<p style=\"color:#6c757d\">—</p>";
|
||||
: "<p style=\"color:#6c757d;margin:0\">—</p>";
|
||||
|
||||
// Render the HTML template with substituted values
|
||||
// email.match.body is now stored as HTML in the database
|
||||
var body = _emailTemplates.Render("email.match.body", language,
|
||||
("cvDocumentId", cvDocumentId),
|
||||
("jobLabel", jobLabel ?? "N/A"),
|
||||
("jobUrl", result.JobUrl ?? "N/A"),
|
||||
("score", result.Score.ToString()),
|
||||
("summary", result.Summary ?? string.Empty),
|
||||
("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)),
|
||||
("strengths", strengths),
|
||||
("gaps", gaps),
|
||||
("recommendations", recommendations));
|
||||
|
||||
// Append the job search footer if link is provided
|
||||
if (!string.IsNullOrWhiteSpace(jobSearchLink))
|
||||
{
|
||||
body += _emailTemplates.Render("email.match.job-search-footer", language,
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File",
|
||||
"Serilog.Sinks.Email"
|
||||
"Serilog.Sinks.File"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
@@ -30,25 +29,6 @@
|
||||
"retainedFileCountLimit": 30,
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "Email",
|
||||
"Args": {
|
||||
"restrictedToMinimumLevel": "Error",
|
||||
"fromEmail": "",
|
||||
"toEmail": "",
|
||||
"mailServer": "",
|
||||
"networkCredential": {
|
||||
"userName": "",
|
||||
"password": ""
|
||||
},
|
||||
"port": 587,
|
||||
"enableSsl": true,
|
||||
"emailSubject": "[mihes.ro API] Error Alert",
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
||||
"batchPostingLimit": 10,
|
||||
"period": "0.00:05:00"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Common.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Connection settings for the internal page-fetcher-api service.
|
||||
/// Bound from the <c>PageFetcherApi</c> configuration section.
|
||||
/// </summary>
|
||||
public sealed class PageFetcherApiSettings
|
||||
{
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
public string InternalApiKey { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -6,4 +6,5 @@ public sealed class CreateJobSearchTokenRequest
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Language { get; set; } = "en";
|
||||
public List<string> Keywords { get; set; } = [];
|
||||
public string? Location { get; set; }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
public List<string> Recommendations { get; set; } = [];
|
||||
public List<string> Evidence { get; set; } = [];
|
||||
public List<string> Keywords { get; set; } = [];
|
||||
public string? Location { get; set; }
|
||||
public bool Cached { get; set; }
|
||||
public string? JobDocumentId { get; set; }
|
||||
public string? JobUrl { get; set; }
|
||||
|
||||
@@ -23,4 +23,9 @@ public sealed class JobProviderConfig
|
||||
public int MaxResults { get; set; } = 20;
|
||||
/// <summary>When true the scraper uses a headless Chromium browser to render JS-heavy pages.</summary>
|
||||
public bool UseHeadlessBrowser { get; set; }
|
||||
/// <summary>
|
||||
/// When false, the Stage 2 anchor-text keyword filter is skipped.
|
||||
/// Set to false for providers whose search URL already filters by relevance server-side.
|
||||
/// </summary>
|
||||
public bool RequireKeywordInAnchor { get; set; } = true;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ public sealed class JobSearchController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email))
|
||||
return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" });
|
||||
|
||||
var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, ct);
|
||||
var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, request.Location, ct);
|
||||
return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -8,6 +8,7 @@ COPY Apis/cv-search-data/cv-search-data.csproj Apis/cv-search-data/
|
||||
COPY Apis/cv-matcher-data/cv-matcher-data.csproj Apis/cv-matcher-data/
|
||||
COPY Apis/common/common.csproj Apis/common/
|
||||
COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/
|
||||
COPY Apis/page-fetcher-api-models/page-fetcher-api-models.csproj Apis/page-fetcher-api-models/
|
||||
COPY Apis/myai-data/myai-data.csproj Apis/myai-data/
|
||||
COPY Apis/shared-data/shared-data.csproj Apis/shared-data/
|
||||
COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/
|
||||
@@ -20,6 +21,7 @@ COPY Apis/cv-search-data/ Apis/cv-search-data/
|
||||
COPY Apis/cv-matcher-data/ Apis/cv-matcher-data/
|
||||
COPY Apis/common/ Apis/common/
|
||||
COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/
|
||||
COPY Apis/page-fetcher-api-models/ Apis/page-fetcher-api-models/
|
||||
COPY Apis/myai-data/ Apis/myai-data/
|
||||
COPY Apis/shared-data/ Apis/shared-data/
|
||||
COPY Helpers/common-helpers/ Helpers/common-helpers/
|
||||
|
||||
@@ -13,6 +13,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Refit;
|
||||
using Serilog;
|
||||
using Common.Settings;
|
||||
using PageFetcher.Models;
|
||||
using StartupHelpers;
|
||||
using System.Reflection;
|
||||
|
||||
@@ -36,6 +37,16 @@ try
|
||||
builder.Services.Configure<CvMatcher.Models.Settings.AiSettings>(builder.Configuration.GetSection("Ai"));
|
||||
builder.Services.Configure<MatcherSettings>(builder.Configuration.GetSection("Matcher"));
|
||||
builder.Services.Configure<JobSearchSettings>(builder.Configuration.GetSection("JobSearch"));
|
||||
builder.Services.Configure<PageFetcherApiSettings>(builder.Configuration.GetSection("PageFetcherApi"));
|
||||
|
||||
builder.Services.AddRefitClient<IPageFetcherApiClient>()
|
||||
.ConfigureHttpClient((sp, c) =>
|
||||
{
|
||||
var settings = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<PageFetcherApiSettings>>().Value;
|
||||
c.BaseAddress = new Uri(settings.BaseUrl.TrimEnd('/') + "/");
|
||||
if (!string.IsNullOrWhiteSpace(settings.InternalApiKey))
|
||||
c.DefaultRequestHeaders.Add("X-Internal-Api-Key", settings.InternalApiKey);
|
||||
});
|
||||
|
||||
builder.Services.AddRefitClient<IRefitRagApi>()
|
||||
.ConfigureHttpClient((sp, c) =>
|
||||
@@ -50,7 +61,7 @@ try
|
||||
|
||||
builder.Services.AddScoped<IRagApiClient, RagApiClient>();
|
||||
builder.Services.AddHttpClient<IMatcherAiClient, MatcherAiClient>();
|
||||
builder.Services.AddHttpClient<IJobTextExtractor, JobTextExtractor>();
|
||||
builder.Services.AddScoped<IJobTextExtractor, JobTextExtractor>();
|
||||
|
||||
builder.Services.AddDbContext<CvMatcherDbContext>(options =>
|
||||
{
|
||||
|
||||
@@ -13,12 +13,13 @@ public interface IJobTokenService
|
||||
/// <param name="email">Email address of the user who will receive the results.</param>
|
||||
/// <param name="language">Preferred language for result emails (e.g. <c>"en"</c>, <c>"ro"</c>).</param>
|
||||
/// <param name="keywords">Job search keywords extracted by the LLM during the match call.</param>
|
||||
/// <param name="location">Candidate location extracted from the CV (e.g. "Cluj-Napoca, Romania"). Null if not available.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The generated token ID to embed in the one-click job search link,
|
||||
/// or <c>null</c> when no job providers are currently enabled (link should be suppressed).
|
||||
/// </returns>
|
||||
Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, CancellationToken ct);
|
||||
Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, string? location, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Validates the token and, if valid, marks it as used and creates a <c>Pending</c> job search session.
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using CvMatcher.Models.Settings;
|
||||
using Api.Services.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PageFetcher.Models;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts normalised plain text from a job posting, either from a pasted description or by
|
||||
/// fetching and stripping the HTML of the job page URL.
|
||||
/// fetching the job page text via <c>page-fetcher-api</c> (headless Chromium rendering).
|
||||
/// </summary>
|
||||
public sealed class JobTextExtractor : IJobTextExtractor
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly IPageFetcherApiClient _pageFetcher;
|
||||
private readonly MatcherSettings _settings;
|
||||
|
||||
public JobTextExtractor(HttpClient http, IOptions<MatcherSettings> options)
|
||||
public JobTextExtractor(IPageFetcherApiClient pageFetcher, IOptions<MatcherSettings> options)
|
||||
{
|
||||
_http = http;
|
||||
_pageFetcher = pageFetcher;
|
||||
_settings = options.Value;
|
||||
_http.Timeout = TimeSpan.FromSeconds(25);
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd("MyAi.ro CV Matcher/1.0");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -31,15 +28,18 @@ public sealed class JobTextExtractor : IJobTextExtractor
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jobUrl)) return string.Empty;
|
||||
if (!Uri.TryCreate(jobUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid job URL.");
|
||||
}
|
||||
|
||||
var html = await _http.GetStringAsync(uri, ct);
|
||||
html = Regex.Replace(html, "<script[\\s\\S]*?</script>", " ", RegexOptions.IgnoreCase);
|
||||
html = Regex.Replace(html, "<style[\\s\\S]*?</style>", " ", RegexOptions.IgnoreCase);
|
||||
html = Regex.Replace(html, "<[^>]+>", " ");
|
||||
return Limit(Normalize(WebUtility.HtmlDecode(html)));
|
||||
var response = await _pageFetcher.FetchAsync(new FetchPageRequest
|
||||
{
|
||||
Url = jobUrl,
|
||||
CallerService = "cv-matcher-api"
|
||||
}, ct);
|
||||
|
||||
if (!response.Success)
|
||||
throw new InvalidOperationException($"Failed to fetch job page: {response.Error}");
|
||||
|
||||
return Limit(Normalize(response.Text));
|
||||
}
|
||||
|
||||
/// <summary>Truncates text to the configured maximum character count.</summary>
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed class JobTokenService : IJobTokenService
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, CancellationToken ct)
|
||||
public async Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, string? location, CancellationToken ct)
|
||||
{
|
||||
var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct);
|
||||
if (!hasEnabledProviders)
|
||||
@@ -50,6 +50,7 @@ public sealed class JobTokenService : IJobTokenService
|
||||
Email = email,
|
||||
Language = language,
|
||||
Keywords = string.Join(",", keywords),
|
||||
Location = location,
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays),
|
||||
Used = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
@@ -57,7 +58,7 @@ public sealed class JobTokenService : IJobTokenService
|
||||
|
||||
_db.JobSearchTokens.Add(token);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
_logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}, Keywords={Keywords}", token.Id, cvDocumentId, token.Keywords);
|
||||
_logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}, Keywords={Keywords}, Location={Location}", token.Id, cvDocumentId, token.Keywords, token.Location);
|
||||
return token.Id;
|
||||
}
|
||||
|
||||
@@ -92,6 +93,7 @@ public sealed class JobTokenService : IJobTokenService
|
||||
Language = token.Language,
|
||||
Status = JobSearchStatus.Pending,
|
||||
Keywords = keywords,
|
||||
Location = token.Location,
|
||||
ProviderConfigJson = providerConfigJson,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -126,7 +128,8 @@ public sealed class JobTokenService : IJobTokenService
|
||||
JobLinkContains = entity.JobLinkContains,
|
||||
InitialKeywords = keywords,
|
||||
MaxResults = entity.MaxResults,
|
||||
UseHeadlessBrowser = entity.UseHeadlessBrowser
|
||||
UseHeadlessBrowser = entity.UseHeadlessBrowser,
|
||||
RequireKeywordInAnchor = entity.RequireKeywordInAnchor
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File",
|
||||
"Serilog.Sinks.Email"
|
||||
"Serilog.Sinks.File"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
@@ -30,25 +29,6 @@
|
||||
"retainedFileCountLimit": 30,
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "Email",
|
||||
"Args": {
|
||||
"restrictedToMinimumLevel": "Error",
|
||||
"fromEmail": "",
|
||||
"toEmail": "",
|
||||
"mailServer": "",
|
||||
"networkCredential": {
|
||||
"userName": "",
|
||||
"password": ""
|
||||
},
|
||||
"port": 587,
|
||||
"enableSsl": true,
|
||||
"emailSubject": "[mihes.ro API] Error Alert",
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
||||
"batchPostingLimit": 10,
|
||||
"period": "0.00:05:00"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
<ProjectReference Include="..\cv-search-data\cv-search-data.csproj" />
|
||||
<ProjectReference Include="..\cv-matcher-data\cv-matcher-data.csproj" />
|
||||
<ProjectReference Include="..\common\common.csproj" />
|
||||
<ProjectReference Include="..\page-fetcher-api-models\page-fetcher-api-models.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -82,12 +82,12 @@ namespace CvMatcher.Data.Migrations
|
||||
|
||||
// AI system prompt for CV matching — English
|
||||
Row("ai.cv-match.system-prompt", "en",
|
||||
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\",\"strength 2 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"]}",
|
||||
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"keyword1\",\"keyword2\",\"keyword3\"]}",
|
||||
"System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job.");
|
||||
|
||||
// AI system prompt for CV matching — Romanian
|
||||
Row("ai.cv-match.system-prompt", "ro",
|
||||
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\",\"punct forte 2 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"]}",
|
||||
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"cuvant1\",\"cuvant2\",\"cuvant3\"]}",
|
||||
"System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job.");
|
||||
}
|
||||
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using CvMatcher.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvMatcher.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvMatcherDbContext))]
|
||||
[Migration("20260608124331_ImproveKeywordsAndAddLocation")]
|
||||
partial class ImproveKeywordsAndAddLocation
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("cvMatcher")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("AiPrompts", "cvMatcher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("JobDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Results", "cvMatcher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
|
||||
{
|
||||
b.Property<string>("CacheKey")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("nvarchar(120)");
|
||||
|
||||
b.Property<string>("ResponseText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Temperature")
|
||||
.HasColumnType("decimal(4,2)");
|
||||
|
||||
b.HasKey("CacheKey");
|
||||
|
||||
b.ToTable("ChatCache", "cvMatcher");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvMatcher.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ImproveKeywordsAndAddLocation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Update English prompt: tighter keywords instruction (job-board search terms, not abstract
|
||||
// concepts) and add location field so the LLM extracts the candidate's city/country.
|
||||
migrationBuilder.UpdateData(
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "AiPrompts",
|
||||
keyColumns: ["Key", "Language"],
|
||||
keyValues: ["ai.cv-match.system-prompt", "en"],
|
||||
columns: ["Value", "Description"],
|
||||
values: [
|
||||
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"City, Country\"}.\nFor 'keywords': extract 2-4 short, concrete terms a recruiter would search for on a job board — the candidate's primary role title and key technologies (e.g. 'Senior .NET Developer', 'C#', 'Azure'). Avoid abstract concepts like 'leadership', 'cloud', or 'microservices'.\nFor 'location': extract the candidate's city and country from the CV (e.g. 'Cluj-Napoca, Romania'). Use an empty string if not found.",
|
||||
"System prompt for CV-to-job matching in English. Extracts job-board-friendly keywords (role title + key tech) and candidate location."
|
||||
]);
|
||||
|
||||
// Update Romanian prompt: same improvements.
|
||||
migrationBuilder.UpdateData(
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "AiPrompts",
|
||||
keyColumns: ["Key", "Language"],
|
||||
keyValues: ["ai.cv-match.system-prompt", "ro"],
|
||||
columns: ["Value", "Description"],
|
||||
values: [
|
||||
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"Oraș, Țară\"}.\nPentru 'keywords': extrage 2-4 termeni scurți și concreți pe care un recrutor i-ar căuta pe un site de joburi — titlul principal al rolului și tehnologiile cheie (ex. 'Senior .NET Developer', 'C#', 'Azure'). Evită concepte abstracte precum 'leadership', 'cloud' sau 'microservicii'.\nPentru 'location': extrage orașul și țara candidatului din CV (ex. 'Cluj-Napoca, România'). Folosește string gol dacă nu se găsește.",
|
||||
"System prompt pentru potrivire CV-job în limba română. Extrage cuvinte cheie prietenoase pentru site-uri de joburi (titlu rol + tehnologii cheie) și locația candidatului."
|
||||
]);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "AiPrompts",
|
||||
keyColumns: ["Key", "Language"],
|
||||
keyValues: ["ai.cv-match.system-prompt", "en"],
|
||||
columns: ["Value", "Description"],
|
||||
values: [
|
||||
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"keyword1\",\"keyword2\",\"keyword3\"]}",
|
||||
"System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job."
|
||||
]);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "AiPrompts",
|
||||
keyColumns: ["Key", "Language"],
|
||||
keyValues: ["ai.cv-match.system-prompt", "ro"],
|
||||
columns: ["Value", "Description"],
|
||||
values: [
|
||||
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"cuvant1\",\"cuvant2\",\"cuvant3\"]}",
|
||||
"System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job."
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,4 +33,10 @@ public sealed class JobProviderEntity
|
||||
|
||||
/// <summary>When true, the scraper renders the page with headless Chromium instead of a plain HTTP GET.</summary>
|
||||
public bool UseHeadlessBrowser { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When false, the Stage 2 anchor-text keyword filter is skipped.
|
||||
/// Set to false for providers whose search URL already filters by relevance server-side (ejobs.ro, bestjobs.eu).
|
||||
/// </summary>
|
||||
public bool RequireKeywordInAnchor { get; set; } = true;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ public sealed class JobSearchSessionEntity : BaseEntity
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = JobSearchStatus.Pending;
|
||||
public string Keywords { get; set; } = string.Empty;
|
||||
public string? Location { get; set; }
|
||||
public string? ProviderConfigJson { get; set; }
|
||||
public string Language { get; set; } = "en";
|
||||
}
|
||||
|
||||
@@ -10,4 +10,5 @@ public sealed class JobSearchTokenEntity : BaseEntity
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public bool Used { get; set; }
|
||||
public string Keywords { get; set; } = string.Empty;
|
||||
public string? Location { get; set; }
|
||||
}
|
||||
|
||||
Generated
+243
@@ -0,0 +1,243 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using CvSearch.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvSearch.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvSearchDbContext))]
|
||||
[Migration("20260608124304_AddRequireKeywordInAnchorAndLocation")]
|
||||
partial class AddRequireKeywordInAnchorAndLocation
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("cvSearch")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("DisplayOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("InitialKeywordsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)")
|
||||
.HasDefaultValue("[]");
|
||||
|
||||
b.Property<string>("JobLinkContains")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<int>("MaxResults")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(20);
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<bool>("RequireKeywordInAnchor")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("SearchUrlTemplate")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<bool>("UseHeadlessBrowser")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobProviders", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("JobText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("JobTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<string>("JobUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("nvarchar(2048)");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SessionId");
|
||||
|
||||
b.ToTable("JobSearchResults", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("Keywords")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)")
|
||||
.HasDefaultValue("en");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ProviderConfigJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("TokenId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("JobSearchSessions", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Keywords")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)")
|
||||
.HasDefaultValue("en");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobSearchTokens", "cvSearch");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvSearch.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRequireKeywordInAnchorAndLocation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Location",
|
||||
schema: "cvSearch",
|
||||
table: "JobSearchTokens",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Location",
|
||||
schema: "cvSearch",
|
||||
table: "JobSearchSessions",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "RequireKeywordInAnchor",
|
||||
schema: "cvSearch",
|
||||
table: "JobProviders",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
// ejobs.ro (Id=1) and bestjobs.eu (Id=2) do server-side keyword filtering via their
|
||||
// search URL — the Stage 2 anchor-text filter rejects all Romanian job titles because
|
||||
// they rarely contain abstract LLM keywords.
|
||||
migrationBuilder.UpdateData(
|
||||
schema: "cvSearch",
|
||||
table: "JobProviders",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "RequireKeywordInAnchor",
|
||||
value: false);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
schema: "cvSearch",
|
||||
table: "JobProviders",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "RequireKeywordInAnchor",
|
||||
value: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Location",
|
||||
schema: "cvSearch",
|
||||
table: "JobSearchTokens");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Location",
|
||||
schema: "cvSearch",
|
||||
table: "JobSearchSessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RequireKeywordInAnchor",
|
||||
schema: "cvSearch",
|
||||
table: "JobProviders");
|
||||
}
|
||||
}
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using CvSearch.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvSearch.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvSearchDbContext))]
|
||||
[Migration("20260608124452_AddLocationToProviders")]
|
||||
partial class AddLocationToProviders
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("cvSearch")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("DisplayOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("InitialKeywordsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)")
|
||||
.HasDefaultValue("[]");
|
||||
|
||||
b.Property<string>("JobLinkContains")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<int>("MaxResults")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(20);
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<bool>("RequireKeywordInAnchor")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("SearchUrlTemplate")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<bool>("UseHeadlessBrowser")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobProviders", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("JobText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("JobTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<string>("JobUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("nvarchar(2048)");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SessionId");
|
||||
|
||||
b.ToTable("JobSearchResults", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("Keywords")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)")
|
||||
.HasDefaultValue("en");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ProviderConfigJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("TokenId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("JobSearchSessions", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Keywords")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)")
|
||||
.HasDefaultValue("en");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobSearchTokens", "cvSearch");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvSearch.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLocationToProviders : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// ejobs.ro (Id=1): location in URL path as slug, keywords via q= param.
|
||||
// Verified URL structure: /locuri-de-munca/{location-slug}?q={keywords}
|
||||
migrationBuilder.UpdateData(
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "JobProviders",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "SearchUrlTemplate",
|
||||
value: "https://www.ejobs.ro/locuri-de-munca/{location-slug}?q={keywords}");
|
||||
|
||||
// bestjobs.eu (Id=2): location in URL path as slug, keywords via query param.
|
||||
// Verified URL structure: /ro/locuri-de-munca-in-{location-slug}?keywords={keywords}
|
||||
migrationBuilder.UpdateData(
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "JobProviders",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "SearchUrlTemplate",
|
||||
value: "https://bestjobs.eu/ro/locuri-de-munca-in-{location-slug}?keywords={keywords}");
|
||||
|
||||
// linkedin.com (Id=3): location as query parameter.
|
||||
migrationBuilder.UpdateData(
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "JobProviders",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "SearchUrlTemplate",
|
||||
value: "https://www.linkedin.com/jobs/search/?keywords={keywords}&location={location}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "JobProviders",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "SearchUrlTemplate",
|
||||
value: "https://www.ejobs.ro/locuri-de-munca?q={keywords}");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "JobProviders",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "SearchUrlTemplate",
|
||||
value: "https://www.bestjobs.eu/ro/locuri-de-munca?keywords={keywords}");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "JobProviders",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "SearchUrlTemplate",
|
||||
value: "https://www.linkedin.com/jobs/search/?keywords={keywords}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,9 @@ namespace CvSearch.Data.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<bool>("RequireKeywordInAnchor")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("SearchUrlTemplate")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
@@ -158,6 +161,9 @@ namespace CvSearch.Data.Migrations
|
||||
.HasColumnType("nvarchar(8)")
|
||||
.HasDefaultValue("en");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ProviderConfigJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -216,6 +222,9 @@ namespace CvSearch.Data.Migrations
|
||||
.HasColumnType("nvarchar(8)")
|
||||
.HasDefaultValue("en");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using EmailApi.Models.Requests;
|
||||
using Email.Models.Requests;
|
||||
using Refit;
|
||||
|
||||
namespace EmailApi.Models.Clients;
|
||||
namespace Email.Models.Clients;
|
||||
|
||||
public interface IEmailApiClient
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace EmailApi.Models.Requests;
|
||||
namespace Email.Models.Requests;
|
||||
|
||||
public sealed class SendEmailRequest
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace EmailApi.Models.Settings;
|
||||
namespace Email.Models.Settings;
|
||||
|
||||
public sealed class EmailApiSettings
|
||||
{
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace Models.Settings;
|
||||
namespace Email.Models.Settings;
|
||||
|
||||
public sealed class SmtpSettings
|
||||
{
|
||||
@@ -1,9 +1,9 @@
|
||||
using EmailApi.Models.Requests;
|
||||
using EmailApi.Services;
|
||||
using Api.Services;
|
||||
using Email.Models.Requests;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
namespace EmailApi.Controllers;
|
||||
namespace Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Internal email relay. Accepts an HTML body fragment from trusted callers
|
||||
|
||||
@@ -4,6 +4,7 @@ WORKDIR /src
|
||||
|
||||
COPY Apis/email-api/email-api.csproj Apis/email-api/
|
||||
COPY Apis/email-data/email-data.csproj Apis/email-data/
|
||||
COPY Apis/shared-data/shared-data.csproj Apis/shared-data/
|
||||
COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/
|
||||
COPY Apis/api-models/api-models.csproj Apis/api-models/
|
||||
COPY Apis/common/common.csproj Apis/common/
|
||||
@@ -15,6 +16,7 @@ RUN dotnet restore Apis/email-api/email-api.csproj
|
||||
|
||||
COPY Apis/email-api/ Apis/email-api/
|
||||
COPY Apis/email-data/ Apis/email-data/
|
||||
COPY Apis/shared-data/ Apis/shared-data/
|
||||
COPY Apis/email-api-models/ Apis/email-api-models/
|
||||
COPY Apis/api-models/ Apis/api-models/
|
||||
COPY Apis/common/ Apis/common/
|
||||
|
||||
@@ -3,8 +3,9 @@ using Email.Data;
|
||||
using Email.Data.Repositories;
|
||||
using Email.Data.Repositories.Contracts;
|
||||
using Email.Data.Services;
|
||||
using EmailApi.Services;
|
||||
using Api.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Email.Models.Settings;
|
||||
using Models.Settings;
|
||||
using Serilog;
|
||||
using StartupHelpers;
|
||||
@@ -50,9 +51,8 @@ try
|
||||
|
||||
app.UseDefaultSerilogRequestLogging();
|
||||
app.UseJsonExceptionHandler(ServiceName);
|
||||
app.UseSwaggerInDevelopment("Email API", "EmailAPI");
|
||||
|
||||
app.UseInternalApiKeyProtection();
|
||||
app.UseSwaggerInDevelopment("Email API", "EmailAPI");
|
||||
|
||||
app.UseRouting();
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using Email.Data.Services;
|
||||
using EmailApi.Models.Requests;
|
||||
using Email.Models.Requests;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
using Email.Models.Settings;
|
||||
using Models.Settings;
|
||||
|
||||
namespace EmailApi.Services;
|
||||
namespace Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an HTML body fragment in the branded HTML shell and sends the resulting email via SMTP using MailKit.
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File",
|
||||
"Serilog.Sinks.Email"
|
||||
"Serilog.Sinks.File"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
@@ -30,25 +29,6 @@
|
||||
"retainedFileCountLimit": 30,
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "Email",
|
||||
"Args": {
|
||||
"restrictedToMinimumLevel": "Error",
|
||||
"fromEmail": "",
|
||||
"toEmail": "",
|
||||
"mailServer": "",
|
||||
"networkCredential": {
|
||||
"userName": "",
|
||||
"password": ""
|
||||
},
|
||||
"port": 587,
|
||||
"enableSsl": true,
|
||||
"emailSubject": "[myAi] Email API Error Alert",
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
||||
"batchPostingLimit": 10,
|
||||
"period": "0.00:05:00"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
|
||||
@@ -44,59 +44,180 @@ namespace Email.Data.Migrations
|
||||
Row("email.match.subject", "en", "MyAi.ro CV Match: {{score}}% - {{jobLabel}}", "Subject for the CV match result email");
|
||||
Row("email.match.subject", "ro", "MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}", "Subiect email rezultat potrivire CV");
|
||||
|
||||
// Match result email — body
|
||||
// Match result email — body (HTML formatted)
|
||||
Row("email.match.body", "en",
|
||||
"CV Matcher result\n\nCV Document ID: {{cvDocumentId}}\nJob: {{jobLabel}}\nJob URL: {{jobUrl}}\nScore: {{score}}%\n\nSummary:\n{{summary}}\n\nStrengths:\n{{strengths}}\n\nGaps:\n{{gaps}}\n\nRecommendations:\n{{recommendations}}",
|
||||
"Body for the CV match result email");
|
||||
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">CV Match Report</h2>
|
||||
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 30px; border-collapse: collapse;"">
|
||||
<tr style=""background-color: #2c5282; color: white;"">
|
||||
<td width=""130"" style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #2c5282;"">CV ID</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #2c5282;"">{{cvDocumentId}}</td>
|
||||
</tr>
|
||||
<tr style=""background-color: #f8f9fa;"">
|
||||
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Job</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #ddd; color: #333;"">{{jobLabel}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">URL</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #ddd;""><a href=""{{jobUrl}}"" style=""color: #2c5282; text-decoration: none;"">{{jobUrl}}</a></td>
|
||||
</tr>
|
||||
<tr style=""background-color: #f8f9fa;"">
|
||||
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Score</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #ddd; color: #27ae60; font-weight: bold; font-size: 18px;"">{{score}}%</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Summary</h3>
|
||||
<p style=""line-height: 1.6; color: #333;"">{{summary}}</p>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Strengths</h3>
|
||||
<div style=""line-height: 1.8; color: #333;"">{{strengths}}</div>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Gaps</h3>
|
||||
<div style=""line-height: 1.8; color: #333;"">{{gaps}}</div>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Recommendations</h3>
|
||||
<div style=""line-height: 1.8; color: #333;"">{{recommendations}}</div>",
|
||||
"Body for the CV match result email (HTML formatted)");
|
||||
Row("email.match.body", "ro",
|
||||
"Rezultat potrivire CV\n\nID document CV: {{cvDocumentId}}\nJob: {{jobLabel}}\nURL job: {{jobUrl}}\nScor: {{score}}%\n\nRezumat:\n{{summary}}\n\nPuncte forte:\n{{strengths}}\n\nLipsuri:\n{{gaps}}\n\nRecomandări:\n{{recommendations}}",
|
||||
"Corpul emailului pentru rezultatul potrivirii CV");
|
||||
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">Report Potrivire CV</h2>
|
||||
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 30px; border-collapse: collapse;"">
|
||||
<tr style=""background-color: #2c5282; color: white;"">
|
||||
<td width=""130"" style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #2c5282;"">ID Document CV</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #2c5282;"">{{cvDocumentId}}</td>
|
||||
</tr>
|
||||
<tr style=""background-color: #f8f9fa;"">
|
||||
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Job</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #ddd; color: #333;"">{{jobLabel}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">URL</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #ddd;""><a href=""{{jobUrl}}"" style=""color: #2c5282; text-decoration: none;"">{{jobUrl}}</a></td>
|
||||
</tr>
|
||||
<tr style=""background-color: #f8f9fa;"">
|
||||
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Scor</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #ddd; color: #27ae60; font-weight: bold; font-size: 18px;"">{{score}}%</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Rezumat</h3>
|
||||
<p style=""line-height: 1.6; color: #333;"">{{summary}}</p>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Puncte Forte</h3>
|
||||
<div style=""line-height: 1.8; color: #333;"">{{strengths}}</div>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Lipsuri</h3>
|
||||
<div style=""line-height: 1.8; color: #333;"">{{gaps}}</div>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Recomandări</h3>
|
||||
<div style=""line-height: 1.8; color: #333;"">{{recommendations}}</div>",
|
||||
"Corpul emailului pentru rezultatul potrivirii CV (format HTML)");
|
||||
|
||||
// Match result email — job search CTA footer
|
||||
// Match result email — job search CTA footer (HTML formatted)
|
||||
Row("email.match.job-search-footer", "en",
|
||||
"\n\n---\nWant to find more jobs matching your CV?\nClick: {{jobSearchLink}}\n(link valid for {{expiryDays}} days)",
|
||||
"Job search CTA appended to match result email");
|
||||
@"<hr style=""border: none; border-top: 1px solid #ddd; margin: 30px 0;"">
|
||||
<p style=""margin-top: 20px; font-size: 14px; color: #333;"">Want to find more jobs matching your CV?</p>
|
||||
<table cellpadding=""0"" cellspacing=""0"" border=""0"" style=""margin-bottom: 20px;"">
|
||||
<tr>
|
||||
<td align=""center"" bgcolor=""#2c5282"" style=""background-color: #2c5282; padding: 10px 20px;"">
|
||||
<a href=""{{jobSearchLink}}"" style=""color: #ffffff; font-weight: bold; text-decoration: none; font-size: 14px; display: block;"">Search Jobs</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style=""font-size: 12px; color: #666;"">(link valid for {{expiryDays}} days)</p>",
|
||||
"Job search CTA appended to match result email (HTML formatted)");
|
||||
Row("email.match.job-search-footer", "ro",
|
||||
"\n\n---\nVrei sa gasesti mai multe joburi potrivite CV-ului tau?\nClick: {{jobSearchLink}}\n(link valabil {{expiryDays}} zile)",
|
||||
"CTA cautare joburi adaugat la emailul de potrivire CV");
|
||||
@"<hr style=""border: none; border-top: 1px solid #ddd; margin: 30px 0;"">
|
||||
<p style=""margin-top: 20px; font-size: 14px; color: #333;"">Vrei să găsești mai multe joburi potrivite CV-ului tău?</p>
|
||||
<table cellpadding=""0"" cellspacing=""0"" border=""0"" style=""margin-bottom: 20px;"">
|
||||
<tr>
|
||||
<td align=""center"" bgcolor=""#2c5282"" style=""background-color: #2c5282; padding: 10px 20px;"">
|
||||
<a href=""{{jobSearchLink}}"" style=""color: #ffffff; font-weight: bold; text-decoration: none; font-size: 14px; display: block;"">Caută Joburi</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style=""font-size: 12px; color: #666;"">(link valabil {{expiryDays}} zile)</p>",
|
||||
"CTA cautare joburi adaugat la emailul de potrivire CV (format HTML)");
|
||||
|
||||
// Job search results email — subject
|
||||
Row("email.search-results.subject", "en", "MyAi.ro: {{count}} jobs matching your CV", "Subject for job search results email");
|
||||
Row("email.search-results.subject", "ro", "MyAi.ro: {{count}} joburi potrivite CV-ului tau", "Subiect email rezultate cautare joburi");
|
||||
|
||||
// Job search results email — body preamble (items appended in code)
|
||||
Row("email.search-results.body", "en", "MyAi.ro found {{count}} jobs matching your CV:\n\n{{items}}", "Body preamble for job search results email");
|
||||
Row("email.search-results.body", "ro", "MyAi.ro a gasit {{count}} joburi potrivite CV-ului tau:\n\n{{items}}", "Corpul emailului de rezultate cautare joburi");
|
||||
// Job search results email — body preamble (items appended in code) - HTML formatted
|
||||
Row("email.search-results.body", "en",
|
||||
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">Job Search Results</h2>
|
||||
<p style=""margin-bottom: 20px; color: #333;"">MyAi.ro found <strong>{{count}}</strong> jobs matching your CV:</p>
|
||||
{{items}}",
|
||||
"Body preamble for job search results email (HTML formatted)");
|
||||
Row("email.search-results.body", "ro",
|
||||
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">Rezultate Căutare Joburi</h2>
|
||||
<p style=""margin-bottom: 20px; color: #333;"">MyAi.ro a găsit <strong>{{count}}</strong> joburi potrivite CV-ului tău:</p>
|
||||
{{items}}",
|
||||
"Corpul emailului de rezultate cautare joburi (format HTML)");
|
||||
|
||||
// Job search results email — no results found
|
||||
Row("email.search-results.empty", "en", "MyAi.ro found no jobs matching your CV. Try again later or update your CV.", "No results message for job search results email");
|
||||
Row("email.search-results.empty", "ro", "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.", "Mesaj fara rezultate pentru emailul de cautare joburi");
|
||||
// Job search results email — scan summary block (keywords + providers used)
|
||||
Row("email.search-results.scan-summary", "en",
|
||||
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 18px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||
<tr>
|
||||
<td bgcolor=""#f8f9fa"" style=""background-color: #f8f9fa; padding: 14px 16px; font-size: 13px; color: #495057;"">
|
||||
<div style=""margin-bottom: 8px;""><strong>Keywords used:</strong> {{keywordsHtml}}</div>
|
||||
<div><strong>Providers scanned:</strong> {{providers}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>",
|
||||
"Scan summary block prepended to job search results email (HTML formatted)");
|
||||
Row("email.search-results.scan-summary", "ro",
|
||||
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 18px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||
<tr>
|
||||
<td bgcolor=""#f8f9fa"" style=""background-color: #f8f9fa; padding: 14px 16px; font-size: 13px; color: #495057;"">
|
||||
<div style=""margin-bottom: 8px;""><strong>Cuvinte cheie folosite:</strong> {{keywordsHtml}}</div>
|
||||
<div><strong>Furnizori scanați:</strong> {{providers}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>",
|
||||
"Bloc rezumat scanare adaugat la emailul de rezultate cautare joburi (format HTML)");
|
||||
|
||||
// HTML job-search start page messages
|
||||
Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page");
|
||||
Row("html.job-search.started.message", "en", "Your job search has started. Results will be sent to your email shortly.", "Message for job search started page");
|
||||
Row("html.job-search.started.title", "ro", "Căutare joburi pornită", "Titlu pagina cautare joburi pornita");
|
||||
Row("html.job-search.started.message", "ro", "Căutarea joburilor a început. Rezultatele vor fi trimise pe email în scurt timp.", "Mesaj pagina cautare joburi pornita");
|
||||
// Job search results email — single job result item card
|
||||
Row("email.search-results.item", "en",
|
||||
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 12px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||
<tr>
|
||||
<td style=""padding: 12px 16px; background-color: #ffffff;"">
|
||||
<strong style=""color: #212529;"">{{index}}. {{jobTitle}}</strong>
|
||||
<span style=""background: #28a745; color: #fff; padding: 2px 8px; font-size: 12px; margin-left: 8px;"">{{score}}% match</span>
|
||||
<span style=""color: #6c757d; font-size: 12px; margin-left: 4px;"">[{{providerName}}]</span><br>
|
||||
<a href=""{{jobUrl}}"" style=""color: #2c5282; font-size: 13px;"">{{jobUrl}}</a>
|
||||
{{summary}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>",
|
||||
"Single job result card in job search results email (HTML formatted)");
|
||||
Row("email.search-results.item", "ro",
|
||||
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 12px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||
<tr>
|
||||
<td style=""padding: 12px 16px; background-color: #ffffff;"">
|
||||
<strong style=""color: #212529;"">{{index}}. {{jobTitle}}</strong>
|
||||
<span style=""background: #28a745; color: #fff; padding: 2px 8px; font-size: 12px; margin-left: 8px;"">{{score}}% potrivire</span>
|
||||
<span style=""color: #6c757d; font-size: 12px; margin-left: 4px;"">[{{providerName}}]</span><br>
|
||||
<a href=""{{jobUrl}}"" style=""color: #2c5282; font-size: 13px;"">{{jobUrl}}</a>
|
||||
{{summary}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>",
|
||||
"Card job individual in emailul de rezultate cautare joburi (format HTML)");
|
||||
|
||||
Row("html.job-search.already-used.title", "en", "Link already used", "Title for already-used page");
|
||||
Row("html.job-search.already-used.message", "en", "This job search link has already been used.", "Message for already-used page");
|
||||
Row("html.job-search.already-used.title", "ro", "Link deja folosit", "Titlu pagina link deja folosit");
|
||||
Row("html.job-search.already-used.message", "ro", "Acest link de cautare joburi a fost deja folosit.", "Mesaj pagina link deja folosit");
|
||||
// Job search results email — no results found - HTML formatted
|
||||
Row("email.search-results.empty", "en",
|
||||
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin: 20px 0;"">
|
||||
<tr>
|
||||
<td bgcolor=""#fff3cd"" style=""background-color: #fff3cd; border: 1px solid #ffc107; padding: 15px;"">
|
||||
<p style=""margin: 0; color: #856404;""><strong>No jobs found</strong><br>
|
||||
MyAi.ro found no jobs matching your CV at this moment. Please try again later or update your CV to improve your match results.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>",
|
||||
"No results message for job search results email (HTML formatted)");
|
||||
Row("email.search-results.empty", "ro",
|
||||
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin: 20px 0;"">
|
||||
<tr>
|
||||
<td bgcolor=""#fff3cd"" style=""background-color: #fff3cd; border: 1px solid #ffc107; padding: 15px;"">
|
||||
<p style=""margin: 0; color: #856404;""><strong>Niciun job găsit</strong><br>
|
||||
MyAi.ro nu a găsit joburi potrivite CV-ului tău în acest moment. Te rugăm să încerci din nou mai târziu sau să-ți actualizezi CV-ul pentru a obține rezultate mai bune.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>",
|
||||
"Mesaj fara rezultate pentru emailul de cautare joburi (format HTML)");
|
||||
|
||||
Row("html.job-search.expired.title", "en", "Link expired", "Title for expired link page");
|
||||
Row("html.job-search.expired.message", "en", "This job search link has expired. Please request a new CV match to get a fresh link.", "Message for expired link page");
|
||||
Row("html.job-search.expired.title", "ro", "Link expirat", "Titlu pagina link expirat");
|
||||
Row("html.job-search.expired.message", "ro", "Acest link de cautare joburi a expirat. Solicita o noua potrivire CV pentru a primi un link nou.", "Mesaj pagina link expirat");
|
||||
|
||||
Row("html.job-search.invalid.title", "en", "Invalid link", "Title for invalid link page");
|
||||
Row("html.job-search.invalid.message", "en", "This job search link is not valid.", "Message for invalid link page");
|
||||
Row("html.job-search.invalid.title", "ro", "Link invalid", "Titlu pagina link invalid");
|
||||
Row("html.job-search.invalid.message", "ro", "Acest link de cautare joburi nu este valid.", "Mesaj pagina link invalid");
|
||||
|
||||
Row("html.job-search.error.title", "en", "Error", "Title for error page");
|
||||
Row("html.job-search.error.message", "en", "An error occurred. Please try again later.", "Message for error page");
|
||||
Row("html.job-search.error.title", "ro", "Eroare", "Titlu pagina eroare");
|
||||
Row("html.job-search.error.message", "ro", "A apărut o eroare. Te rugăm să încerci din nou mai târziu.", "Mesaj pagina eroare");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
+33
-32
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Email.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -18,7 +18,7 @@ namespace Email.Data.Migrations
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("email")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
@@ -27,43 +27,44 @@ namespace Email.Data.Migrations
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("OperatorCopy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)")
|
||||
.HasDefaultValue("");
|
||||
b.Property<string>("OperatorCopy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("Templates", "email");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
b.ToTable("Templates", "email");
|
||||
});
|
||||
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Email.Data;
|
||||
|
||||
#nullable disable
|
||||
@@ -15,14 +15,14 @@ namespace Email.Data.Migrations
|
||||
migrationBuilder.InsertData(
|
||||
table: "Templates",
|
||||
columns: new[] { "Key", "Language", "Value", "Description" },
|
||||
values: new object[] { "email.html-shell.start", "*", "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <style>\n body { font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif; margin: 0; padding: 0; background-color: #f5f5f5; }\n .email-container { max-width: 600px; margin: 0 auto; background-color: #ffffff; }\n .email-header { background-color: #2c5282; color: white; padding: 24px; text-align: center; }\n .email-header h1 { margin: 0; font-size: 24px; font-weight: 600; }\n .email-body { padding: 24px; }\n .email-footer { background-color: #f8f9fa; padding: 16px; text-align: center; color: #6c757d; font-size: 12px; border-top: 1px solid #dee2e6; }\n </style>\n</head>\n<body>\n <div class=\"email-container\">\n <div class=\"email-header\">\n <h1>MyAi.ro</h1>\n </div>\n <div class=\"email-body\">\n", "Opening HTML wrapper for branded emails (blue header, white content area)" },
|
||||
values: new object[] { "email.html-shell.start", "*", "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"margin: 0; padding: 0; background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background-color: #f5f5f5;\">\n <tr>\n <td align=\"center\" style=\"padding: 20px 0;\">\n <table width=\"600\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"width: 600px; max-width: 600px; background-color: #ffffff;\">\n <tr>\n <td align=\"center\" style=\"background-color: #2c5282; padding: 24px; text-align: center;\">\n <h1 style=\"margin: 0; color: #ffffff; font-size: 24px; font-weight: 600;\">MyAi.ro</h1>\n </td>\n </tr>\n <tr>\n <td style=\"padding: 24px; background-color: #ffffff;\">\n", "Opening HTML wrapper for branded emails (blue header, white content area)" },
|
||||
schema: MigrationConstants.SchemaName);
|
||||
|
||||
// HTML email shell — closing tags (footer)
|
||||
migrationBuilder.InsertData(
|
||||
table: "Templates",
|
||||
columns: new[] { "Key", "Language", "Value", "Description" },
|
||||
values: new object[] { "email.html-shell.end", "*", " </div>\n <div class=\"email-footer\">\n <p>© 2026 MyAi.ro. All rights reserved.</p>\n </div>\n </div>\n</body>\n</html>\n", "Closing HTML wrapper for branded emails (footer and closing tags)" },
|
||||
values: new object[] { "email.html-shell.end", "*", "\n </td>\n </tr>\n <tr>\n <td align=\"center\" style=\"background-color: #f8f9fa; padding: 16px; text-align: center; border-top: 1px solid #dee2e6;\">\n <p style=\"margin: 0; color: #6c757d; font-size: 12px;\">© 2026 MyAi.ro. All rights reserved.</p>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n</body>\n</html>\n", "Closing HTML wrapper for branded emails (footer and closing tags)" },
|
||||
schema: MigrationConstants.SchemaName);
|
||||
}
|
||||
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Email.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Email.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(EmailDbContext))]
|
||||
[Migration("20260608125339_AddLocationToScanSummaryTemplate")]
|
||||
partial class AddLocationToScanSummaryTemplate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("email")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("OperatorCopy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("Templates", "email");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Email.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLocationToScanSummaryTemplate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "Templates",
|
||||
keyColumns: ["Key", "Language"],
|
||||
keyValues: ["email.search-results.scan-summary", "en"],
|
||||
columns: ["Value"],
|
||||
values: [@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 18px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||
<tr>
|
||||
<td bgcolor=""#f8f9fa"" style=""background-color: #f8f9fa; padding: 14px 16px; font-size: 13px; color: #495057;"">
|
||||
<div style=""margin-bottom: 8px;""><strong>Keywords used:</strong> {{keywordsHtml}}</div>
|
||||
<div style=""margin-bottom: 8px;""><strong>Location:</strong> {{location}}</div>
|
||||
<div><strong>Providers scanned:</strong> {{providers}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>"]);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "Templates",
|
||||
keyColumns: ["Key", "Language"],
|
||||
keyValues: ["email.search-results.scan-summary", "ro"],
|
||||
columns: ["Value"],
|
||||
values: [@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 18px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||
<tr>
|
||||
<td bgcolor=""#f8f9fa"" style=""background-color: #f8f9fa; padding: 14px 16px; font-size: 13px; color: #495057;"">
|
||||
<div style=""margin-bottom: 8px;""><strong>Cuvinte cheie folosite:</strong> {{keywordsHtml}}</div>
|
||||
<div style=""margin-bottom: 8px;""><strong>Locație căutată:</strong> {{location}}</div>
|
||||
<div><strong>Furnizori scanați:</strong> {{providers}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>"]);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "Templates",
|
||||
keyColumns: ["Key", "Language"],
|
||||
keyValues: ["email.search-results.scan-summary", "en"],
|
||||
columns: ["Value"],
|
||||
values: [@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 18px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||
<tr>
|
||||
<td bgcolor=""#f8f9fa"" style=""background-color: #f8f9fa; padding: 14px 16px; font-size: 13px; color: #495057;"">
|
||||
<div style=""margin-bottom: 8px;""><strong>Keywords used:</strong> {{keywordsHtml}}</div>
|
||||
<div><strong>Providers scanned:</strong> {{providers}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>"]);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "Templates",
|
||||
keyColumns: ["Key", "Language"],
|
||||
keyValues: ["email.search-results.scan-summary", "ro"],
|
||||
columns: ["Value"],
|
||||
values: [@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 18px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||
<tr>
|
||||
<td bgcolor=""#f8f9fa"" style=""background-color: #f8f9fa; padding: 14px 16px; font-size: 13px; color: #495057;"">
|
||||
<div style=""margin-bottom: 8px;""><strong>Cuvinte cheie folosite:</strong> {{keywordsHtml}}</div>
|
||||
<div><strong>Furnizori scanați:</strong> {{providers}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,11 @@ namespace MyAi.Data.Migrations
|
||||
Row("email.search-results.empty", "en", "MyAi.ro found no jobs matching your CV. Try again later or update your CV.", "No results message for job search results email");
|
||||
Row("email.search-results.empty", "ro", "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.", "Mesaj fara rezultate pentru emailul de cautare joburi");
|
||||
|
||||
// HTML job-search page shell — wraps title + message in a centered card page
|
||||
Row("html.job-search.shell", "*",
|
||||
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>{{title}} - MyAi.ro</title><style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f5f5}.card{background:#fff;padding:2rem 3rem;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.1);text-align:center;max-width:480px}h1{font-size:1.4rem;margin-bottom:.5rem;color:#2c5282}p{color:#555}</style></head><body><div class=\"card\"><h1>{{title}}</h1><p>{{message}}</p></div></body></html>",
|
||||
"Full HTML shell for job-search status pages. Placeholders: {{title}}, {{message}}.");
|
||||
|
||||
// HTML job-search start page messages
|
||||
Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page");
|
||||
Row("html.job-search.started.message", "en", "Your job search has started. Results will be sent to your email shortly.", "Message for job search started page");
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using MyAi.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MyAi.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddHtmlJobSearchShell : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "Templates",
|
||||
columns: ["Key", "Language", "Value", "Description"],
|
||||
values: new object[]
|
||||
{
|
||||
"html.job-search.shell",
|
||||
"*",
|
||||
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>{{title}} - MyAi.ro</title><style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f5f5}.card{background:#fff;padding:2rem 3rem;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.1);text-align:center;max-width:480px}h1{font-size:1.4rem;margin-bottom:.5rem;color:#2c5282}p{color:#555}</style></head><body><div class=\"card\"><h1>{{title}}</h1><p>{{message}}</p></div></body></html>",
|
||||
"Full HTML shell for job-search status pages. Placeholders: {{title}}, {{message}}."
|
||||
},
|
||||
schema: MigrationConstants.SchemaName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Templates",
|
||||
keyColumns: ["Key", "Language"],
|
||||
keyValues: new object[] { "html.job-search.shell", "*" },
|
||||
schema: MigrationConstants.SchemaName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace PageFetcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Request to fetch a web page via the page-fetcher-api.
|
||||
/// </summary>
|
||||
public sealed class FetchPageRequest
|
||||
{
|
||||
/// <summary>Absolute HTTP or HTTPS URL to fetch.</summary>
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Playwright wait condition. Accepted values: <c>networkidle</c> (default), <c>domcontentloaded</c>, <c>load</c>.
|
||||
/// </summary>
|
||||
public string WaitFor { get; set; } = "networkidle";
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the calling service for audit purposes (e.g. <c>cv-matcher-api</c>, <c>cv-search-job</c>).
|
||||
/// </summary>
|
||||
public string CallerService { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace PageFetcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a page fetch operation.
|
||||
/// </summary>
|
||||
public sealed class FetchPageResponse
|
||||
{
|
||||
/// <summary>Final URL after any redirects.</summary>
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>HTTP status code returned by the page. <c>0</c> on network failure.</summary>
|
||||
public int StatusCode { get; set; }
|
||||
|
||||
/// <summary>Full rendered HTML as returned by Playwright.</summary>
|
||||
public string Html { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Plain text extracted from the HTML (script/style stripped, whitespace normalised).</summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether the fetch succeeded. <c>false</c> on timeout or network error.</summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Exception message when <see cref="Success"/> is <c>false</c>.</summary>
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Refit;
|
||||
|
||||
namespace PageFetcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Refit client for the internal page-fetcher-api service.
|
||||
/// All calls require the <c>X-Internal-Api-Key</c> header, configured at registration time.
|
||||
/// </summary>
|
||||
public interface IPageFetcherApiClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches a web page via headless Chromium and returns the rendered HTML and extracted plain text.
|
||||
/// </summary>
|
||||
[Post("/api/page/fetch")]
|
||||
Task<FetchPageResponse> FetchAsync([Body] FetchPageRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace PageFetcher.Models.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime settings for the page-fetcher service.
|
||||
/// Bound from the <c>PageFetcher</c> configuration section.
|
||||
/// </summary>
|
||||
public sealed class PageFetcherSettings
|
||||
{
|
||||
/// <summary>Default Playwright wait condition (<c>networkidle</c>, <c>load</c>, <c>domcontentloaded</c>).</summary>
|
||||
public string DefaultWaitFor { get; set; } = "networkidle";
|
||||
|
||||
/// <summary>Page navigation timeout in seconds.</summary>
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>Maximum characters stored/returned in the extracted text field.</summary>
|
||||
public int MaxTextChars { get; set; } = 60_000;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>page-fetcher-api-models</AssemblyName>
|
||||
<RootNamespace>PageFetcher.Models</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Refit.HttpClientFactory" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,47 @@
|
||||
using Api.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PageFetcher.Models;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
namespace Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Handles page-fetch requests: navigates to the URL via Playwright and returns rendered HTML and extracted text.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/page")]
|
||||
public sealed class PageController : ControllerBase
|
||||
{
|
||||
private readonly PageFetcherService _service;
|
||||
private readonly ILogger<PageController> _logger;
|
||||
|
||||
public PageController(PageFetcherService service, ILogger<PageController> logger)
|
||||
{
|
||||
_service = service;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a web page via headless Chromium.
|
||||
/// Returns rendered HTML and extracted plain text.
|
||||
/// </summary>
|
||||
[HttpPost("fetch")]
|
||||
[SwaggerOperation(Summary = "Fetch a web page", Description = "Navigates to the given URL using Playwright, returns rendered HTML and stripped plain text.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Page fetched successfully", typeof(FetchPageResponse))]
|
||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid or non-HTTP(S) URL")]
|
||||
public async Task<ActionResult<FetchPageResponse>> Fetch([FromBody] FetchPageRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Url))
|
||||
return BadRequest(new { Error = "Url is required." });
|
||||
|
||||
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
return BadRequest(new { Error = "Url must be an absolute HTTP or HTTPS URL." });
|
||||
|
||||
_logger.LogInformation("Fetch request: {Url} | caller={Caller} | waitFor={WaitFor}",
|
||||
request.Url, request.CallerService, request.WaitFor);
|
||||
|
||||
var result = await _service.FetchAsync(request, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY Directory.Packages.props ./
|
||||
|
||||
COPY Apis/page-fetcher-api/page-fetcher-api.csproj Apis/page-fetcher-api/
|
||||
COPY Apis/page-fetcher-data/page-fetcher-data.csproj Apis/page-fetcher-data/
|
||||
COPY Apis/page-fetcher-api-models/page-fetcher-api-models.csproj Apis/page-fetcher-api-models/
|
||||
COPY Apis/common/common.csproj Apis/common/
|
||||
COPY Apis/shared-data/shared-data.csproj Apis/shared-data/
|
||||
COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
|
||||
COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/
|
||||
|
||||
RUN dotnet restore Apis/page-fetcher-api/page-fetcher-api.csproj
|
||||
|
||||
COPY Apis/page-fetcher-api/ Apis/page-fetcher-api/
|
||||
COPY Apis/page-fetcher-data/ Apis/page-fetcher-data/
|
||||
COPY Apis/page-fetcher-api-models/ Apis/page-fetcher-api-models/
|
||||
COPY Apis/common/ Apis/common/
|
||||
COPY Apis/shared-data/ Apis/shared-data/
|
||||
COPY Helpers/startup-helpers/ Helpers/startup-helpers/
|
||||
COPY Helpers/common-helpers/ Helpers/common-helpers/
|
||||
|
||||
RUN dotnet publish Apis/page-fetcher-api/page-fetcher-api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# Download Playwright Chromium browser in the build stage.
|
||||
# Node.js is only needed here to run npx — it is not copied to the final image.
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \
|
||||
&& npx --yes playwright@1.60.0 install chromium \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
|
||||
# System libraries required by Chromium on Debian bookworm
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
||||
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
|
||||
libgbm1 libasound2t64 libpango-1.0-0 libcairo2 libatspi2.0-0 \
|
||||
libwayland-client0 libx11-xcb1 libx11-6 libxcb1 libxext6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy the Playwright Chromium browser from the build stage
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
COPY --from=build /ms-playwright /ms-playwright
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
ENTRYPOINT ["dotnet", "page-fetcher-api.dll"]
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PageFetcher.Data;
|
||||
using Api.Services;
|
||||
using PageFetcher.Models.Settings;
|
||||
using Serilog;
|
||||
using StartupHelpers;
|
||||
|
||||
StartupExtensions.LoadDotEnvFile();
|
||||
|
||||
const string ServiceName = "page-fetcher-api";
|
||||
var appVersion = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly());
|
||||
|
||||
try
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.ConfigureJsonSerilog(ServiceName, appVersion);
|
||||
Log.Information("Starting {Service} version {AppVersion}", ServiceName, appVersion);
|
||||
|
||||
builder.AddAzureKeyVaultIfConfigured();
|
||||
|
||||
builder.Services.Configure<PageFetcherSettings>(builder.Configuration.GetSection("PageFetcher"));
|
||||
|
||||
builder.Services.AddDbContext<PageFetchDbContext>(options =>
|
||||
{
|
||||
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
||||
options.UseSqlServer(connectionString, sql =>
|
||||
{
|
||||
sql.MigrationsHistoryTable(PageFetchDbContext.MigrationTableName, PageFetchDbContext.SchemaName);
|
||||
sql.MigrationsAssembly("page-fetcher-data");
|
||||
});
|
||||
});
|
||||
|
||||
// Playwright browser: singleton hosted service, shared across all requests
|
||||
builder.Services.AddSingleton<PlaywrightBrowserService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<PlaywrightBrowserService>());
|
||||
|
||||
builder.Services.AddScoped<PageFetcherService>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "Page Fetcher API");
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.LogStartupDiagnostics(ServiceName);
|
||||
|
||||
app.UseDefaultSerilogRequestLogging();
|
||||
app.UseJsonExceptionHandler(ServiceName);
|
||||
app.UseInternalApiKeyProtection();
|
||||
app.UseSwaggerInDevelopment("Page Fetcher API", "PageFetcherAPI");
|
||||
|
||||
app.UseRouting();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
Log.Information("Running EF Core migrations if any");
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<PageFetchDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
Log.Information("{Service} startup complete. Listening for requests...", ServiceName);
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "{Service} terminated unexpectedly", ServiceName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.Information("Shutting down {Service}", ServiceName);
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"page-fetcher-api": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:50268;http://localhost:50269"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Playwright;
|
||||
using PageFetcher.Data;
|
||||
using PageFetcher.Data.Entities;
|
||||
using PageFetcher.Models;
|
||||
using PageFetcher.Models.Settings;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a web page via Playwright, extracts plain text, persists the result to the database,
|
||||
/// and returns a <see cref="FetchPageResponse"/>.
|
||||
/// </summary>
|
||||
public sealed class PageFetcherService
|
||||
{
|
||||
private readonly PlaywrightBrowserService _browserService;
|
||||
private readonly PageFetchDbContext _db;
|
||||
private readonly PageFetcherSettings _settings;
|
||||
private readonly ILogger<PageFetcherService> _logger;
|
||||
|
||||
public PageFetcherService(
|
||||
PlaywrightBrowserService browserService,
|
||||
PageFetchDbContext db,
|
||||
IOptions<PageFetcherSettings> settings,
|
||||
ILogger<PageFetcherService> logger)
|
||||
{
|
||||
_browserService = browserService;
|
||||
_db = db;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the page at <paramref name="request.Url"/> using Playwright, saves the fetch record,
|
||||
/// and returns the HTML and extracted text.
|
||||
/// Returns a failed response (with <see cref="FetchPageResponse.Success"/> = false) rather than throwing
|
||||
/// on network or navigation errors.
|
||||
/// </summary>
|
||||
public async Task<FetchPageResponse> FetchAsync(FetchPageRequest request, CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
string html = string.Empty;
|
||||
string text = string.Empty;
|
||||
int? statusCode = null;
|
||||
bool success = false;
|
||||
string? errorMessage = null;
|
||||
string finalUrl = request.Url;
|
||||
|
||||
try
|
||||
{
|
||||
var page = await _browserService.Browser.NewPageAsync();
|
||||
await using var _ = page.ConfigureAwait(false);
|
||||
|
||||
var waitUntil = request.WaitFor?.ToLowerInvariant() switch
|
||||
{
|
||||
"load" => WaitUntilState.Load,
|
||||
"domcontentloaded" => WaitUntilState.DOMContentLoaded,
|
||||
_ => WaitUntilState.NetworkIdle
|
||||
};
|
||||
|
||||
IResponse? response;
|
||||
try
|
||||
{
|
||||
response = await page.GotoAsync(request.Url, new PageGotoOptions
|
||||
{
|
||||
WaitUntil = waitUntil,
|
||||
Timeout = _settings.TimeoutSeconds * 1_000
|
||||
});
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_logger.LogWarning("Playwright NetworkIdle timeout for {Url}, using partial content", request.Url);
|
||||
response = null;
|
||||
}
|
||||
|
||||
statusCode = response?.Status;
|
||||
finalUrl = page.Url;
|
||||
html = await page.ContentAsync();
|
||||
text = ExtractText(html);
|
||||
success = true;
|
||||
|
||||
_logger.LogInformation("Fetched {Url} → HTTP {Status} | HTML {HtmlLen} chars | text {TextLen} chars | {DurationMs} ms",
|
||||
request.Url, statusCode?.ToString() ?? "timeout", html.Length, text.Length, sw.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
_logger.LogError(ex, "Failed to fetch {Url}", request.Url);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
}
|
||||
|
||||
// Persist fetch record
|
||||
var entity = new PageFetchEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Url = request.Url,
|
||||
CallerService = request.CallerService ?? string.Empty,
|
||||
HttpStatusCode = statusCode,
|
||||
Html = html,
|
||||
Text = text,
|
||||
DurationMs = sw.ElapsedMilliseconds,
|
||||
Success = success,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
|
||||
_db.PageFetches.Add(entity);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return new FetchPageResponse
|
||||
{
|
||||
Url = finalUrl,
|
||||
StatusCode = statusCode ?? 0,
|
||||
Html = html,
|
||||
Text = text,
|
||||
Success = success,
|
||||
Error = errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips script/style blocks and all HTML tags from raw HTML, normalises whitespace,
|
||||
/// and truncates to <see cref="PageFetcherSettings.MaxTextChars"/>.
|
||||
/// </summary>
|
||||
private string ExtractText(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html)) return string.Empty;
|
||||
|
||||
var text = html;
|
||||
text = Regex.Replace(text, "<script[\\s\\S]*?</script>", " ", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "<style[\\s\\S]*?</style>", " ", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "<[^>]+>", " ");
|
||||
text = WebUtility.HtmlDecode(text);
|
||||
text = string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).Trim();
|
||||
|
||||
var max = Math.Max(4_000, _settings.MaxTextChars);
|
||||
return text.Length <= max ? text : text[..max];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton hosted service that owns the Playwright Chromium browser process for the lifetime of the application.
|
||||
/// Launches the browser once at startup and exposes it for injection into <see cref="PageFetcherService"/>.
|
||||
/// </summary>
|
||||
public sealed class PlaywrightBrowserService : IHostedService, IAsyncDisposable
|
||||
{
|
||||
private IPlaywright? _playwright;
|
||||
private IBrowser? _browser;
|
||||
private readonly ILogger<PlaywrightBrowserService> _logger;
|
||||
|
||||
public PlaywrightBrowserService(ILogger<PlaywrightBrowserService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>The running Chromium browser instance. Available after <see cref="StartAsync"/> completes.</summary>
|
||||
public IBrowser Browser => _browser ?? throw new InvalidOperationException("Browser has not been started yet.");
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Launching Playwright Chromium browser...");
|
||||
_playwright = await Playwright.CreateAsync();
|
||||
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
Args = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
|
||||
});
|
||||
_logger.LogInformation("Playwright Chromium browser launched successfully.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Closing Playwright Chromium browser...");
|
||||
if (_browser is not null) await _browser.CloseAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_browser is not null) await _browser.DisposeAsync();
|
||||
_playwright?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.AspNetCore.Hosting": "Information",
|
||||
"Microsoft.AspNetCore.Routing": "Warning",
|
||||
"System.Net.Http.HttpClient": "Warning",
|
||||
"PageFetcherApi": "Information"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "File",
|
||||
"Args": {
|
||||
"path": "logs/page-fetcher-api-.log",
|
||||
"rollingInterval": "Day",
|
||||
"retainedFileCountLimit": 30,
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
"FromLogContext",
|
||||
"WithMachineName",
|
||||
"WithEnvironmentName"
|
||||
]
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.AspNetCore.Hosting": "Information",
|
||||
"Microsoft.AspNetCore.Routing": "Warning",
|
||||
"System.Net.Http.HttpClient": "Warning",
|
||||
"PageFetcherApi": "Information"
|
||||
}
|
||||
},
|
||||
"LogEnvironmentOnStartup": true,
|
||||
"AllowedHosts": "*",
|
||||
"KeyVault": {
|
||||
"VaultUri": "",
|
||||
"Enabled": false
|
||||
},
|
||||
"Database": {
|
||||
"Host": "localhost",
|
||||
"Port": 1433,
|
||||
"Name": "MyAiDb",
|
||||
"User": "sa",
|
||||
"Password": "",
|
||||
"TrustServerCertificate": true
|
||||
},
|
||||
"InternalApi": {
|
||||
"ApiKey": "",
|
||||
"RequireApiKey": true
|
||||
},
|
||||
"PageFetcher": {
|
||||
"DefaultWaitFor": "networkidle",
|
||||
"TimeoutSeconds": 30,
|
||||
"MaxTextChars": 60000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<RootNamespace>PageFetcherApi</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Playwright" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
<PackageReference Include="Serilog.Sinks.Email" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\page-fetcher-data\page-fetcher-data.csproj" />
|
||||
<ProjectReference Include="..\page-fetcher-api-models\page-fetcher-api-models.csproj" />
|
||||
<ProjectReference Include="..\common\common.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\common-helpers\common-helpers.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,34 @@
|
||||
using Shared.Data.Entities;
|
||||
|
||||
namespace PageFetcher.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Audit record of a single page-fetch operation performed by the page-fetcher-api.
|
||||
/// Stores the full rendered HTML and extracted plain text for every URL fetched.
|
||||
/// </summary>
|
||||
public sealed class PageFetchEntity : BaseEntity
|
||||
{
|
||||
/// <summary>The URL that was requested.</summary>
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Name of the service that requested the fetch (e.g. <c>cv-matcher-api</c>, <c>cv-search-job</c>).</summary>
|
||||
public string CallerService { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>HTTP status code returned by the remote server. <c>null</c> on network failure.</summary>
|
||||
public int? HttpStatusCode { get; set; }
|
||||
|
||||
/// <summary>Full rendered HTML as returned by Playwright.</summary>
|
||||
public string Html { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Plain text extracted from the HTML (script/style stripped, whitespace normalised).</summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Playwright round-trip time in milliseconds.</summary>
|
||||
public long DurationMs { get; set; }
|
||||
|
||||
/// <summary><c>true</c> when the page was fetched successfully; <c>false</c> on timeout or network error.</summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Exception message when <see cref="Success"/> is <c>false</c>.</summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace PageFetcher.Data;
|
||||
|
||||
/// <summary>Schema and migration-history table name constants for the pageFetcher EF schema.</summary>
|
||||
public static class MigrationConstants
|
||||
{
|
||||
public const string SchemaName = "pageFetcher";
|
||||
public const string MigrationTableName = "_Migrations";
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using PageFetcher.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PageFetcher.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(PageFetchDbContext))]
|
||||
[Migration("20260608143523_InitialSchema")]
|
||||
partial class InitialSchema
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("pageFetcher")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("PageFetcher.Data.Entities.PageFetchEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("CallerService")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<long>("DurationMs")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("Html")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("HttpStatusCode")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("Success")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("Url");
|
||||
|
||||
b.ToTable("PageFetches", "pageFetcher");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PageFetcher.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialSchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: MigrationConstants.SchemaName);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PageFetches",
|
||||
schema: MigrationConstants.SchemaName,
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Url = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false),
|
||||
CallerService = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
HttpStatusCode = table.Column<int>(type: "int", nullable: true),
|
||||
Html = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Text = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
DurationMs = table.Column<long>(type: "bigint", nullable: false),
|
||||
Success = table.Column<bool>(type: "bit", nullable: false),
|
||||
ErrorMessage = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PageFetches", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PageFetches_CreatedAt",
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "PageFetches",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PageFetches_Url",
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "PageFetches",
|
||||
column: "Url");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PageFetches",
|
||||
schema: MigrationConstants.SchemaName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using PageFetcher.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PageFetcher.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(PageFetchDbContext))]
|
||||
partial class PageFetchDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("pageFetcher")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("PageFetcher.Data.Entities.PageFetchEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("CallerService")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<long>("DurationMs")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("Html")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("HttpStatusCode")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("Success")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("Url");
|
||||
|
||||
b.ToTable("PageFetches", "pageFetcher");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PageFetcher.Data.Entities;
|
||||
|
||||
namespace PageFetcher.Data;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for the <c>pageFetcher</c> schema.
|
||||
/// Owns the <c>PageFetches</c> audit table.
|
||||
/// </summary>
|
||||
public sealed class PageFetchDbContext : DbContext
|
||||
{
|
||||
public const string SchemaName = MigrationConstants.SchemaName;
|
||||
public const string MigrationTableName = MigrationConstants.MigrationTableName;
|
||||
|
||||
public PageFetchDbContext(DbContextOptions<PageFetchDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<PageFetchEntity> PageFetches => Set<PageFetchEntity>();
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
base.OnConfiguring(optionsBuilder);
|
||||
optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName));
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
modelBuilder.Entity<PageFetchEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("PageFetches");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Id).HasMaxLength(64);
|
||||
entity.Property(x => x.Url).HasMaxLength(2000).IsRequired();
|
||||
entity.Property(x => x.CallerService).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.Html).IsRequired();
|
||||
entity.Property(x => x.Text).IsRequired();
|
||||
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
entity.HasIndex(x => x.Url);
|
||||
entity.HasIndex(x => x.CreatedAt);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>page-fetcher-data</AssemblyName>
|
||||
<RootNamespace>PageFetcher.Data</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\shared-data\shared-data.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -2,8 +2,7 @@
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File",
|
||||
"Serilog.Sinks.Email"
|
||||
"Serilog.Sinks.File"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
@@ -30,25 +29,6 @@
|
||||
"retainedFileCountLimit": 30,
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "Email",
|
||||
"Args": {
|
||||
"restrictedToMinimumLevel": "Error",
|
||||
"fromEmail": "",
|
||||
"toEmail": "",
|
||||
"mailServer": "",
|
||||
"networkCredential": {
|
||||
"userName": "",
|
||||
"password": ""
|
||||
},
|
||||
"port": 587,
|
||||
"enableSsl": true,
|
||||
"emailSubject": "[mihes.ro API] Error Alert",
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
||||
"batchPostingLimit": 10,
|
||||
"period": "0.00:05:00"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<!-- Config -->
|
||||
<PackageVersion Include="DotNetEnv" Version="3.2.0" />
|
||||
<!-- HTTP / Refit -->
|
||||
<PackageVersion Include="Refit.HttpClientFactory" Version="10.1.6" />
|
||||
<PackageVersion Include="Refit.HttpClientFactory" Version="11.0.1" />
|
||||
<!-- Serilog -->
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using Azure.Identity;
|
||||
using MailKit.Security;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -9,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
@@ -41,6 +44,8 @@ public static class StartupExtensions
|
||||
.Enrich.WithProperty("Service", serviceName)
|
||||
.Enrich.WithProperty("AppVersion", appVersion)
|
||||
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter());
|
||||
|
||||
AddEmailSinkIfConfigured(configuration, context.Configuration, serviceName);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,9 +62,40 @@ public static class StartupExtensions
|
||||
.Enrich.WithProperty("Service", serviceName)
|
||||
.Enrich.WithProperty("AppVersion", appVersion)
|
||||
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter());
|
||||
|
||||
AddEmailSinkIfConfigured(configuration, builder.Configuration, serviceName);
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddEmailSinkIfConfigured(LoggerConfiguration loggerConfig, IConfiguration appConfig, string serviceName)
|
||||
{
|
||||
var from = appConfig["SerilogEmail:From"];
|
||||
var to = appConfig["SerilogEmail:To"];
|
||||
var host = appConfig["SerilogEmail:Host"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to) || string.IsNullOrWhiteSpace(host))
|
||||
return;
|
||||
|
||||
var port = appConfig.GetValue("SerilogEmail:Port", 587);
|
||||
var userName = appConfig["SerilogEmail:UserName"];
|
||||
var password = appConfig["SerilogEmail:Password"];
|
||||
|
||||
NetworkCredential? credentials = null;
|
||||
if (!string.IsNullOrWhiteSpace(userName))
|
||||
credentials = new NetworkCredential(userName, password);
|
||||
|
||||
loggerConfig.WriteTo.Email(
|
||||
from: from,
|
||||
to: to,
|
||||
host: host,
|
||||
port: port,
|
||||
connectionSecurity: SecureSocketOptions.StartTls,
|
||||
credentials: credentials,
|
||||
subject: $"[myAi {serviceName}] Error Alert",
|
||||
body: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
||||
restrictedToMinimumLevel: LogEventLevel.Error);
|
||||
}
|
||||
|
||||
public static void AddAzureKeyVaultIfConfigured(this WebApplicationBuilder builder)
|
||||
{
|
||||
var keyVaultUri = builder.Configuration["KeyVault:VaultUri"];
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File",
|
||||
"Serilog.Sinks.Email"
|
||||
"Serilog.Sinks.File"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
@@ -31,25 +30,6 @@
|
||||
"retainedFileCountLimit": 30,
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "Email",
|
||||
"Args": {
|
||||
"restrictedToMinimumLevel": "Error",
|
||||
"fromEmail": "",
|
||||
"toEmail": "",
|
||||
"mailServer": "",
|
||||
"networkCredential": {
|
||||
"userName": "",
|
||||
"password": ""
|
||||
},
|
||||
"port": 587,
|
||||
"enableSsl": true,
|
||||
"emailSubject": "[mihes.ro CV cleanup job] Error Alert",
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
||||
"batchPostingLimit": 10,
|
||||
"period": "0.00:05:00"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
|
||||
@@ -9,6 +9,7 @@ COPY Apis/cv-search-data/cv-search-data.csproj Apis/cv-search-data/
|
||||
COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/
|
||||
COPY Apis/email-data/email-data.csproj Apis/email-data/
|
||||
COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/
|
||||
COPY Apis/page-fetcher-api-models/page-fetcher-api-models.csproj Apis/page-fetcher-api-models/
|
||||
COPY Apis/common/common.csproj Apis/common/
|
||||
COPY Apis/myai-data/myai-data.csproj Apis/myai-data/
|
||||
COPY Apis/shared-data/shared-data.csproj Apis/shared-data/
|
||||
@@ -22,6 +23,7 @@ COPY Apis/cv-search-data/ Apis/cv-search-data/
|
||||
COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/
|
||||
COPY Apis/email-data/ Apis/email-data/
|
||||
COPY Apis/email-api-models/ Apis/email-api-models/
|
||||
COPY Apis/page-fetcher-api-models/ Apis/page-fetcher-api-models/
|
||||
COPY Apis/common/ Apis/common/
|
||||
COPY Apis/myai-data/ Apis/myai-data/
|
||||
COPY Apis/shared-data/ Apis/shared-data/
|
||||
|
||||
@@ -7,13 +7,14 @@ using Email.Data;
|
||||
using Email.Data.Repositories;
|
||||
using Email.Data.Repositories.Contracts;
|
||||
using Email.Data.Services;
|
||||
using EmailApi.Models.Clients;
|
||||
using Email.Models.Clients;
|
||||
using CvSearchJob.Tasks;
|
||||
using JobScheduler.Scheduling;
|
||||
using JobScheduler.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using PageFetcher.Models;
|
||||
using Refit;
|
||||
using Serilog;
|
||||
using Common.Settings;
|
||||
@@ -81,7 +82,19 @@ try
|
||||
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<HtmlJobSearcher>();
|
||||
builder.Services.AddRefitClient<IPageFetcherApiClient>()
|
||||
.ConfigureHttpClient((sp, client) =>
|
||||
{
|
||||
var config = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
|
||||
var baseUrl = config["PageFetcherApi:BaseUrl"] ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl))
|
||||
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
var key = config["PageFetcherApi:InternalApiKey"];
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<HtmlJobSearcher>();
|
||||
builder.Services.AddSingleton<CvSearchEmailSender>();
|
||||
|
||||
builder.Services.AddSingleton<CvSearchJobTask>();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using CvMatcher.Models.Responses;
|
||||
using CvSearch.Data.Entities;
|
||||
using Email.Data.Services;
|
||||
using EmailApi.Models.Clients;
|
||||
using EmailApi.Models.Requests;
|
||||
using Email.Models.Clients;
|
||||
using Email.Models.Requests;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CvSearchJob.Services;
|
||||
@@ -46,6 +46,7 @@ public sealed class CvSearchEmailSender
|
||||
IReadOnlyList<string> keywords,
|
||||
IReadOnlyList<string> providerNames,
|
||||
string language,
|
||||
string? location,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorCopy = _emailTemplates.GetOperatorCopy("email.search-results.subject", language);
|
||||
@@ -58,7 +59,7 @@ public sealed class CvSearchEmailSender
|
||||
|
||||
if (recipients.Count == 0) return;
|
||||
|
||||
var htmlBody = BuildBody(results, keywords, providerNames, language);
|
||||
var htmlBody = BuildBody(results, keywords, providerNames, language, location);
|
||||
var subject = _emailTemplates.Render("email.search-results.subject", language,
|
||||
("count", results.Count.ToString()));
|
||||
|
||||
@@ -87,9 +88,9 @@ public sealed class CvSearchEmailSender
|
||||
/// Returns the empty-results template when no results are present.
|
||||
/// Prepends a scan summary block showing the keywords and providers used.
|
||||
/// </summary>
|
||||
private string BuildBody(IReadOnlyList<JobSearchResultEntity> results, IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames, string language)
|
||||
private string BuildBody(IReadOnlyList<JobSearchResultEntity> results, IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames, string language, string? location)
|
||||
{
|
||||
var scanSummary = BuildScanSummary(keywords, providerNames);
|
||||
var scanSummary = BuildScanSummary(keywords, providerNames, language, location);
|
||||
|
||||
if (results.Count == 0)
|
||||
return scanSummary + _emailTemplates.Get("email.search-results.empty", language);
|
||||
@@ -98,18 +99,18 @@ public sealed class CvSearchEmailSender
|
||||
for (int i = 0; i < results.Count; i++)
|
||||
{
|
||||
var r = results[i];
|
||||
var matchResp = TryParseResult(r.ResultJson);
|
||||
var summary = matchResp?.Summary;
|
||||
var summary = TryParseResult(r.ResultJson)?.Summary;
|
||||
var summaryHtml = string.IsNullOrWhiteSpace(summary)
|
||||
? ""
|
||||
: $"<p style=\"margin:8px 0 0;color:#495057;font-size:14px;line-height:1.5;\">{summary}</p>";
|
||||
|
||||
items.Append($"""
|
||||
<div style="border:1px solid #dee2e6;border-radius:6px;padding:16px;margin-bottom:12px">
|
||||
<strong style="color:#212529">{i + 1}. {r.JobTitle}</strong>
|
||||
<span style="background:#28a745;color:#fff;padding:2px 8px;border-radius:12px;font-size:12px;margin-left:8px">{r.Score}% match</span>
|
||||
<span style="color:#6c757d;font-size:12px;margin-left:4px">[{r.ProviderName}]</span><br>
|
||||
<a href="{r.JobUrl}" style="color:#2c5282;font-size:13px">{r.JobUrl}</a>
|
||||
{(string.IsNullOrWhiteSpace(summary) ? "" : $"<p style=\"margin:8px 0 0;color:#495057;font-size:14px;line-height:1.5\">{summary}</p>")}
|
||||
</div>
|
||||
""");
|
||||
items.Append(_emailTemplates.Render("email.search-results.item", language,
|
||||
("index", (i + 1).ToString()),
|
||||
("jobTitle", r.JobTitle),
|
||||
("score", r.Score.ToString()),
|
||||
("providerName", r.ProviderName),
|
||||
("jobUrl", r.JobUrl),
|
||||
("summary", summaryHtml)));
|
||||
}
|
||||
|
||||
return _emailTemplates.Render("email.search-results.body", language,
|
||||
@@ -118,25 +119,26 @@ public sealed class CvSearchEmailSender
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the scan summary block showing the CV keywords and providers used for the search.
|
||||
/// Renders the scan summary block via template, passing keyword tags and provider list as data.
|
||||
/// Keyword tags are built here because they are variable-count inline elements, not structural HTML.
|
||||
/// </summary>
|
||||
private static string BuildScanSummary(IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames)
|
||||
private string BuildScanSummary(IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames, string language, string? location)
|
||||
{
|
||||
var keywordsHtml = keywords.Count > 0
|
||||
? string.Join(" ", keywords.Select(k =>
|
||||
$"<span style=\"display:inline-block;background:#e9ecef;border-radius:4px;padding:2px 8px;margin:2px 2px 2px 0;font-size:12px\">{k}</span>"))
|
||||
: "<span style=\"color:#6c757d;font-size:12px;font-style:italic\">none detected</span>";
|
||||
$"<span style=\"display:inline-block;background:#e9ecef;padding:2px 8px;margin:2px 2px 2px 0;font-size:12px;\">{k}</span>"))
|
||||
: "<span style=\"color:#6c757d;font-size:12px;font-style:italic;\">none detected</span>";
|
||||
|
||||
var providersText = providerNames.Count > 0
|
||||
var providers = providerNames.Count > 0
|
||||
? string.Join(", ", providerNames)
|
||||
: "none";
|
||||
|
||||
return $"""
|
||||
<div style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:6px;padding:14px 16px;margin-bottom:18px;font-size:13px;color:#495057">
|
||||
<div style="margin-bottom:8px"><strong>Keywords used:</strong> {keywordsHtml}</div>
|
||||
<div><strong>Providers scanned:</strong> {providersText}</div>
|
||||
</div>
|
||||
""";
|
||||
var locationDisplay = string.IsNullOrWhiteSpace(location) ? "-" : location;
|
||||
|
||||
return _emailTemplates.Render("email.search-results.scan-summary", language,
|
||||
("keywordsHtml", keywordsHtml),
|
||||
("providers", providers),
|
||||
("location", locationDisplay));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,38 +1,42 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Web;
|
||||
using CvMatcher.Models.Settings;
|
||||
using Microsoft.Playwright;
|
||||
using PageFetcher.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CvSearchJob.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Config-driven HTML scraper that fetches a provider's job listing page and extracts matching job URLs.
|
||||
/// Uses a two-stage anchor filter: href must contain the provider's link pattern, and anchor text must
|
||||
/// contain at least one CV keyword.
|
||||
/// Supports both plain HTTP GET (default) and headless Chromium rendering for JS-heavy SPAs.
|
||||
/// A URL and its anchor text as scraped from a job listing search-results page.
|
||||
/// </summary>
|
||||
public sealed record JobCandidate(string Url, string Title);
|
||||
|
||||
/// <summary>
|
||||
/// Config-driven HTML scraper that fetches a provider's job listing page via <c>page-fetcher-api</c>
|
||||
/// and extracts matching job URL candidates.
|
||||
/// Uses a two-stage anchor filter: href must contain the provider's link pattern, and (optionally)
|
||||
/// anchor text must contain at least one CV keyword.
|
||||
/// </summary>
|
||||
public sealed class HtmlJobSearcher
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly IPageFetcherApiClient _pageFetcher;
|
||||
private readonly ILogger<HtmlJobSearcher> _logger;
|
||||
|
||||
public HtmlJobSearcher(HttpClient http, ILogger<HtmlJobSearcher> logger)
|
||||
public HtmlJobSearcher(IPageFetcherApiClient pageFetcher, ILogger<HtmlJobSearcher> logger)
|
||||
{
|
||||
_http = http;
|
||||
_pageFetcher = pageFetcher;
|
||||
_logger = logger;
|
||||
_http.Timeout = TimeSpan.FromSeconds(20);
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (compatible; MyAi.ro CV-Search/1.0)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the provider's search result page for the combined initial + CV keywords, parses all anchor
|
||||
/// tags, applies the two-stage filter, and returns up to <see cref="JobProviderConfig.MaxResults"/> absolute URLs.
|
||||
/// Returns an empty list when the HTTP request fails rather than throwing.
|
||||
/// Fetches the provider's search result page, parses all anchor tags, applies the two-stage filter,
|
||||
/// and returns up to <see cref="JobProviderConfig.MaxResults"/> candidates (URL + title).
|
||||
/// Returns an empty list when the page fetch fails rather than throwing.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> SearchJobUrlsAsync(
|
||||
public async Task<IReadOnlyList<JobCandidate>> SearchJobUrlsAsync(
|
||||
JobProviderConfig provider,
|
||||
IReadOnlyList<string> cvKeywords,
|
||||
string? location,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var allKeywords = provider.InitialKeywords
|
||||
@@ -48,26 +52,41 @@ public sealed class HtmlJobSearcher
|
||||
}
|
||||
|
||||
var keywordsEncoded = HttpUtility.UrlEncode(string.Join(" ", allKeywords));
|
||||
var searchUrl = provider.SearchUrlTemplate.Replace("{keywords}", keywordsEncoded);
|
||||
var locationEncoded = HttpUtility.UrlEncode(location ?? string.Empty);
|
||||
var locationSlug = (location ?? string.Empty)
|
||||
.ToLowerInvariant()
|
||||
.Replace(",", "")
|
||||
.Replace(" ", "-")
|
||||
.Trim('-');
|
||||
var searchUrl = provider.SearchUrlTemplate
|
||||
.Replace("{keywords}", keywordsEncoded)
|
||||
.Replace("{location}", locationEncoded)
|
||||
.Replace("{location-slug}", locationSlug);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Provider {Provider}: fetching {Url} [{Mode}] | CV keywords: [{Keywords}]",
|
||||
"Provider {Provider}: fetching {Url} | CV keywords: [{Keywords}] | Location: {Location}",
|
||||
provider.Name, searchUrl,
|
||||
provider.UseHeadlessBrowser ? "headless" : "http",
|
||||
string.Join(", ", cvKeywords));
|
||||
string.Join(", ", cvKeywords),
|
||||
location ?? "(none)");
|
||||
|
||||
string? html;
|
||||
if (provider.UseHeadlessBrowser)
|
||||
html = await FetchWithPlaywrightAsync(provider.Name, searchUrl, ct);
|
||||
else
|
||||
html = await FetchWithHttpAsync(provider.Name, searchUrl, ct);
|
||||
var fetchResponse = await _pageFetcher.FetchAsync(new FetchPageRequest
|
||||
{
|
||||
Url = searchUrl,
|
||||
WaitFor = provider.UseHeadlessBrowser ? "networkidle" : "domcontentloaded",
|
||||
CallerService = "cv-search-job"
|
||||
}, ct);
|
||||
|
||||
if (html is null) return [];
|
||||
if (!fetchResponse.Success || string.IsNullOrWhiteSpace(fetchResponse.Html))
|
||||
{
|
||||
_logger.LogWarning("Provider {Provider}: page fetch failed — {Error}", provider.Name, fetchResponse.Error);
|
||||
return [];
|
||||
}
|
||||
|
||||
var html = fetchResponse.Html;
|
||||
_logger.LogInformation("Provider {Provider}: received {Length} chars of HTML", provider.Name, html.Length);
|
||||
|
||||
var baseUri = new Uri(searchUrl);
|
||||
var results = new List<string>();
|
||||
var results = new List<JobCandidate>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var anchorPattern = new Regex(@"<a[^>]+href=[""']([^""']+)[""'][^>]*>(.*?)</a>",
|
||||
@@ -89,7 +108,8 @@ public sealed class HtmlJobSearcher
|
||||
|
||||
stage1Pass++;
|
||||
|
||||
if (!cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase)))
|
||||
if (provider.RequireKeywordInAnchor &&
|
||||
!cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Provider {Provider}: stage-2 reject | href={Href} | text={Text}",
|
||||
@@ -105,9 +125,13 @@ public sealed class HtmlJobSearcher
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip non-HTTP(S) URLs (e.g. file:// or javascript: that can appear in scraped HTML)
|
||||
if (absoluteUri.Scheme != Uri.UriSchemeHttp && absoluteUri.Scheme != Uri.UriSchemeHttps)
|
||||
continue;
|
||||
|
||||
var url = absoluteUri.GetLeftPart(UriPartial.Path);
|
||||
if (seen.Add(url))
|
||||
results.Add(url);
|
||||
results.Add(new JobCandidate(url, anchorText));
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
@@ -116,61 +140,4 @@ public sealed class HtmlJobSearcher
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<string?> FetchWithHttpAsync(string providerName, string url, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _http.GetStringAsync(url, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Provider {Provider}: HTTP fetch failed for {Url}", providerName, url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> FetchWithPlaywrightAsync(string providerName, string url, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var playwright = await Playwright.CreateAsync();
|
||||
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
Args = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
|
||||
});
|
||||
|
||||
var page = await browser.NewPageAsync();
|
||||
|
||||
IResponse? response;
|
||||
try
|
||||
{
|
||||
response = await page.GotoAsync(url, new PageGotoOptions
|
||||
{
|
||||
WaitUntil = WaitUntilState.NetworkIdle,
|
||||
Timeout = 30_000
|
||||
});
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// NetworkIdle timed out — use whatever content rendered so far
|
||||
_logger.LogWarning("Provider {Provider}: Playwright NetworkIdle timeout for {Url}, using partial content", providerName, url);
|
||||
return await page.ContentAsync();
|
||||
}
|
||||
|
||||
if (response is null || response.Status >= 400)
|
||||
{
|
||||
_logger.LogWarning("Provider {Provider}: Playwright got HTTP {Status} for {Url}", providerName, response?.Status, url);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await page.ContentAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Provider {Provider}: Playwright fetch failed for {Url}", providerName, url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PageFetcher.Models;
|
||||
|
||||
namespace CvSearchJob.Tasks;
|
||||
|
||||
@@ -24,6 +25,7 @@ public sealed class CvSearchJobTask : IJobTask
|
||||
private readonly JobSearchSettings _settings;
|
||||
private readonly HtmlJobSearcher _searcher;
|
||||
private readonly ICvMatcherInternalApi _matcherApi;
|
||||
private readonly IPageFetcherApiClient _pageFetcher;
|
||||
private readonly CvSearchEmailSender _emailSender;
|
||||
private readonly ILogger<CvSearchJobTask> _logger;
|
||||
|
||||
@@ -34,6 +36,7 @@ public sealed class CvSearchJobTask : IJobTask
|
||||
IOptions<JobSearchSettings> settings,
|
||||
HtmlJobSearcher searcher,
|
||||
ICvMatcherInternalApi matcherApi,
|
||||
IPageFetcherApiClient pageFetcher,
|
||||
CvSearchEmailSender emailSender,
|
||||
ILogger<CvSearchJobTask> logger)
|
||||
{
|
||||
@@ -41,6 +44,7 @@ public sealed class CvSearchJobTask : IJobTask
|
||||
_settings = settings.Value;
|
||||
_searcher = searcher;
|
||||
_matcherApi = matcherApi;
|
||||
_pageFetcher = pageFetcher;
|
||||
_emailSender = emailSender;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -111,6 +115,7 @@ public sealed class CvSearchJobTask : IJobTask
|
||||
cvKeywords,
|
||||
providers.Select(p => p.Name).ToList(),
|
||||
pending.Language,
|
||||
pending.Location,
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogInformation("Session {SessionId} done. {Count} results sent.", pending.Id, results.Count);
|
||||
@@ -125,7 +130,8 @@ public sealed class CvSearchJobTask : IJobTask
|
||||
|
||||
/// <summary>
|
||||
/// Runs the full search pipeline for a session: scrapes all providers, deduplicates URLs,
|
||||
/// scores each candidate via the matcher API, and persists results that meet the minimum score threshold.
|
||||
/// fetches each individual job page via page-fetcher-api, applies a keyword pre-filter,
|
||||
/// scores passing candidates via the matcher API, and persists results that meet the minimum score threshold.
|
||||
/// </summary>
|
||||
private async Task<List<JobSearchResultEntity>> RunSearchAsync(
|
||||
JobSearchSessionEntity session,
|
||||
@@ -137,30 +143,59 @@ public sealed class CvSearchJobTask : IJobTask
|
||||
if (cvKeywords.Count == 0)
|
||||
_logger.LogWarning("Session {SessionId}: keyword list is empty — scraper will rely on provider InitialKeywords only", session.Id);
|
||||
|
||||
var jobUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var jobCandidates = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); // url → title
|
||||
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
var urls = await _searcher.SearchJobUrlsAsync(provider, cvKeywords, ct);
|
||||
_logger.LogInformation("Session {SessionId}: provider {Provider} returned {Count} URLs", session.Id, provider.Name, urls.Count);
|
||||
foreach (var url in urls) jobUrls.Add(url);
|
||||
var candidates = await _searcher.SearchJobUrlsAsync(provider, cvKeywords, session.Location, ct);
|
||||
_logger.LogInformation("Session {SessionId}: provider {Provider} returned {Count} candidates", session.Id, provider.Name, candidates.Count);
|
||||
foreach (var c in candidates)
|
||||
jobCandidates.TryAdd(c.Url, c.Title);
|
||||
}
|
||||
|
||||
var candidates = jobUrls.Take(_settings.MaxJobsToMatch).ToList();
|
||||
var deduped = jobCandidates.Take(_settings.MaxJobsToMatch).ToList();
|
||||
_logger.LogInformation(
|
||||
"Session {SessionId}: {Total} unique URLs across all providers, scoring {Scoring} (cap={Cap})",
|
||||
session.Id, jobUrls.Count, candidates.Count, _settings.MaxJobsToMatch);
|
||||
"Session {SessionId}: {Total} unique URLs across all providers, processing up to {Cap}",
|
||||
session.Id, jobCandidates.Count, deduped.Count);
|
||||
|
||||
var results = new List<JobSearchResultEntity>();
|
||||
|
||||
foreach (var url in candidates)
|
||||
foreach (var (url, title) in deduped)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Fetch individual job page text via page-fetcher-api
|
||||
var fetchResponse = await _pageFetcher.FetchAsync(new FetchPageRequest
|
||||
{
|
||||
Url = url,
|
||||
WaitFor = "domcontentloaded",
|
||||
CallerService = "cv-search-job"
|
||||
}, ct);
|
||||
|
||||
if (!fetchResponse.Success || string.IsNullOrWhiteSpace(fetchResponse.Text))
|
||||
{
|
||||
_logger.LogWarning("Session {SessionId}: fetch failed for {Url} — {Error}", session.Id, url, fetchResponse.Error);
|
||||
continue;
|
||||
}
|
||||
|
||||
var jobText = fetchResponse.Text;
|
||||
|
||||
// Keyword pre-filter: skip LLM call if no CV keyword appears in the job page text
|
||||
if (cvKeywords.Count > 0 &&
|
||||
!cvKeywords.Any(k => jobText.Contains(k, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Session {SessionId}: pre-filter skip | {Url} | no CV keyword found in job text",
|
||||
session.Id, url);
|
||||
continue;
|
||||
}
|
||||
|
||||
var matchRequest = new MatchJobRequest
|
||||
{
|
||||
CvDocumentId = session.CvDocumentId,
|
||||
JobUrl = url,
|
||||
// Pre-fetched text passed directly so cv-matcher-api skips re-fetching the page
|
||||
JobDescription = jobText,
|
||||
// User already gave GDPR consent when they clicked the one-time job search link
|
||||
GdprConsent = true
|
||||
};
|
||||
@@ -181,7 +216,7 @@ public sealed class CvSearchJobTask : IJobTask
|
||||
SessionId = session.Id,
|
||||
ProviderName = GuessProvider(url, providers),
|
||||
JobUrl = url,
|
||||
JobTitle = matchResult.Summary.Split('.').FirstOrDefault()?.Trim() ?? "Job",
|
||||
JobTitle = matchResult.Summary.Split('.').FirstOrDefault()?.Trim() ?? title,
|
||||
JobText = string.Empty,
|
||||
Score = matchResult.Score,
|
||||
ResultJson = JsonSerializer.Serialize(matchResult, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File",
|
||||
"Serilog.Sinks.Email"
|
||||
"Serilog.Sinks.File"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
@@ -42,25 +41,6 @@
|
||||
"retainedFileCountLimit": 30,
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "Email",
|
||||
"Args": {
|
||||
"restrictedToMinimumLevel": "Error",
|
||||
"fromEmail": "",
|
||||
"toEmail": "",
|
||||
"mailServer": "",
|
||||
"networkCredential": {
|
||||
"userName": "",
|
||||
"password": ""
|
||||
},
|
||||
"port": 587,
|
||||
"enableSsl": true,
|
||||
"emailSubject": "[mihes.ro CV search job] Error Alert",
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
||||
"batchPostingLimit": 10,
|
||||
"period": "0.00:05:00"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" />
|
||||
<PackageReference Include="Microsoft.Playwright" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -26,6 +25,7 @@
|
||||
<ProjectReference Include="..\..\Apis\cv-search-data\cv-search-data.csproj" />
|
||||
<ProjectReference Include="..\..\Apis\common\common.csproj" />
|
||||
<ProjectReference Include="..\..\Apis\email-data\email-data.csproj" />
|
||||
<ProjectReference Include="..\..\Apis\page-fetcher-api-models\page-fetcher-api-models.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
<ProjectReference Include="..\job-scheduler\job-scheduler.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -147,3 +147,6 @@ RateLimiting__Policies__contact__QueueLimit=0
|
||||
RateLimiting__Policies__cvMatcher__PermitLimit=10
|
||||
RateLimiting__Policies__cvMatcher__Window=00:10:00
|
||||
RateLimiting__Policies__cvMatcher__QueueLimit=0
|
||||
RateLimiting__Policies__download__PermitLimit=5
|
||||
RateLimiting__Policies__download__Window=00:01:00
|
||||
RateLimiting__Policies__download__QueueLimit=0
|
||||
|
||||
@@ -35,13 +35,12 @@ services:
|
||||
- Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text}
|
||||
- Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180}
|
||||
|
||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-}
|
||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
||||
- SerilogEmail__From=${SerilogEmail__From:-}
|
||||
- SerilogEmail__To=${SerilogEmail__To:-}
|
||||
- SerilogEmail__Host=${SerilogEmail__Host:-}
|
||||
- SerilogEmail__Port=${SerilogEmail__Port:-587}
|
||||
- SerilogEmail__UserName=${SerilogEmail__UserName:-}
|
||||
- SerilogEmail__Password=${SerilogEmail__Password:-}
|
||||
volumes:
|
||||
- ${LOGS_PATH:-/opt/myai/logs}/rag-api:/app/logs
|
||||
networks:
|
||||
@@ -73,6 +72,9 @@ services:
|
||||
- RagApi__BaseUrl=${RagApi__BaseUrl:-http://rag-api:8080}
|
||||
- RagApi__InternalApiKey=${RagApi__InternalApiKey:-}
|
||||
|
||||
- PageFetcherApi__BaseUrl=${PageFetcherApi__BaseUrl:-http://page-fetcher-api:8080}
|
||||
- PageFetcherApi__InternalApiKey=${PageFetcherApi__InternalApiKey:-}
|
||||
|
||||
- Ai__Provider=${Ai__Provider:-OpenAI}
|
||||
- Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-}
|
||||
- Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini}
|
||||
@@ -85,13 +87,12 @@ services:
|
||||
- Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5}
|
||||
- Matcher__MaxJobTextChars=${Matcher__MaxJobTextChars:-60000}
|
||||
|
||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-}
|
||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
||||
- SerilogEmail__From=${SerilogEmail__From:-}
|
||||
- SerilogEmail__To=${SerilogEmail__To:-}
|
||||
- SerilogEmail__Host=${SerilogEmail__Host:-}
|
||||
- SerilogEmail__Port=${SerilogEmail__Port:-587}
|
||||
- SerilogEmail__UserName=${SerilogEmail__UserName:-}
|
||||
- SerilogEmail__Password=${SerilogEmail__Password:-}
|
||||
volumes:
|
||||
- ${LOGS_PATH:-/opt/myai/logs}/cv-matcher-api:/app/logs
|
||||
networks:
|
||||
@@ -126,13 +127,12 @@ services:
|
||||
|
||||
- FileStorage__Path=${FileStorage__Path:-Files}
|
||||
|
||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-}
|
||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
||||
- SerilogEmail__From=${SerilogEmail__From:-}
|
||||
- SerilogEmail__To=${SerilogEmail__To:-}
|
||||
- SerilogEmail__Host=${SerilogEmail__Host:-}
|
||||
- SerilogEmail__Port=${SerilogEmail__Port:-587}
|
||||
- SerilogEmail__UserName=${SerilogEmail__UserName:-}
|
||||
- SerilogEmail__Password=${SerilogEmail__Password:-}
|
||||
volumes:
|
||||
- ${LOGS_PATH:-/opt/myai/logs}/email-api:/app/logs
|
||||
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
||||
@@ -194,17 +194,19 @@ services:
|
||||
- RateLimiting__Policies__cvMatcher__PermitLimit=${RateLimiting__Policies__cvMatcher__PermitLimit:-10}
|
||||
- RateLimiting__Policies__cvMatcher__Window=${RateLimiting__Policies__cvMatcher__Window:-00:10:00}
|
||||
- RateLimiting__Policies__cvMatcher__QueueLimit=${RateLimiting__Policies__cvMatcher__QueueLimit:-0}
|
||||
- RateLimiting__Policies__download__PermitLimit=${RateLimiting__Policies__download__PermitLimit:-5}
|
||||
- RateLimiting__Policies__download__Window=${RateLimiting__Policies__download__Window:-00:01:00}
|
||||
- RateLimiting__Policies__download__QueueLimit=${RateLimiting__Policies__download__QueueLimit:-0}
|
||||
|
||||
- Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-}
|
||||
- Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-}
|
||||
|
||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-}
|
||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
||||
- SerilogEmail__From=${SerilogEmail__From:-}
|
||||
- SerilogEmail__To=${SerilogEmail__To:-}
|
||||
- SerilogEmail__Host=${SerilogEmail__Host:-}
|
||||
- SerilogEmail__Port=${SerilogEmail__Port:-587}
|
||||
- SerilogEmail__UserName=${SerilogEmail__UserName:-}
|
||||
- SerilogEmail__Password=${SerilogEmail__Password:-}
|
||||
volumes:
|
||||
- ${LOGS_PATH:-/opt/myai/logs}/api:/app/logs
|
||||
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
||||
@@ -229,13 +231,12 @@ services:
|
||||
- Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00}
|
||||
- Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40}
|
||||
|
||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-}
|
||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
||||
- SerilogEmail__From=${SerilogEmail__From:-}
|
||||
- SerilogEmail__To=${SerilogEmail__To:-}
|
||||
- SerilogEmail__Host=${SerilogEmail__Host:-}
|
||||
- SerilogEmail__Port=${SerilogEmail__Port:-587}
|
||||
- SerilogEmail__UserName=${SerilogEmail__UserName:-}
|
||||
- SerilogEmail__Password=${SerilogEmail__Password:-}
|
||||
volumes:
|
||||
- ${LOGS_PATH:-/opt/myai/logs}/cv-cleanup-job:/app/logs
|
||||
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
||||
@@ -268,6 +269,9 @@ services:
|
||||
- EmailApi__BaseUrl=${EmailApi__BaseUrl:-http://email-api:8080}
|
||||
- EmailApi__InternalApiKey=${EmailApi__InternalApiKey:-}
|
||||
|
||||
- PageFetcherApi__BaseUrl=${PageFetcherApi__BaseUrl:-http://page-fetcher-api:8080}
|
||||
- PageFetcherApi__InternalApiKey=${PageFetcherApi__InternalApiKey:-}
|
||||
|
||||
- FileStorage__Path=${FileStorage__Path:-Files}
|
||||
|
||||
- JobSearch__Enabled=${JobSearch__Enabled:-true}
|
||||
@@ -280,13 +284,12 @@ services:
|
||||
- Jobs__Tasks__0__Enabled=${Jobs__CvSearchEnabled:-true}
|
||||
- Jobs__Tasks__0__Interval=${Jobs__CvSearchInterval:-00:00:30}
|
||||
|
||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-}
|
||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
||||
- SerilogEmail__From=${SerilogEmail__From:-}
|
||||
- SerilogEmail__To=${SerilogEmail__To:-}
|
||||
- SerilogEmail__Host=${SerilogEmail__Host:-}
|
||||
- SerilogEmail__Port=${SerilogEmail__Port:-587}
|
||||
- SerilogEmail__UserName=${SerilogEmail__UserName:-}
|
||||
- SerilogEmail__Password=${SerilogEmail__Password:-}
|
||||
volumes:
|
||||
- ${LOGS_PATH:-/opt/myai/logs}/cv-search-job:/app/logs
|
||||
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
||||
@@ -296,6 +299,38 @@ services:
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
page-fetcher-api:
|
||||
image: registry.easysoft.ro/apps/myai-page-fetcher-api:${IMAGE_TAG:-staging}
|
||||
container_name: myai-page-fetcher-api
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
|
||||
- ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080}
|
||||
- APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging}
|
||||
|
||||
- Database__Host=${Database__Host:-sqlserver}
|
||||
- Database__Port=${Database__Port:-1433}
|
||||
- Database__Name=${Database__Name:-MyAiDb}
|
||||
- Database__User=${Database__User:-sa}
|
||||
- Database__Password=${Database__Password:-}
|
||||
- Database__TrustServerCertificate=${Database__TrustServerCertificate:-true}
|
||||
|
||||
- InternalApi__ApiKey=${PageFetcherApi__InternalApiKey:-}
|
||||
- InternalApi__RequireApiKey=true
|
||||
|
||||
- SerilogEmail__From=${SerilogEmail__From:-}
|
||||
- SerilogEmail__To=${SerilogEmail__To:-}
|
||||
- SerilogEmail__Host=${SerilogEmail__Host:-}
|
||||
- SerilogEmail__Port=${SerilogEmail__Port:-587}
|
||||
- SerilogEmail__UserName=${SerilogEmail__UserName:-}
|
||||
- SerilogEmail__Password=${SerilogEmail__Password:-}
|
||||
volumes:
|
||||
- ${LOGS_PATH:-/opt/myai/logs}/page-fetcher-api:/app/logs
|
||||
networks:
|
||||
- myai-network
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
web:
|
||||
image: registry.easysoft.ro/apps/myai-web:${IMAGE_TAG:-staging}
|
||||
container_name: myai-web
|
||||
|
||||
@@ -63,6 +63,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api", "Apis\email-api
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-data", "Apis\email-data\email-data.csproj", "{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "page-fetcher-api-models", "Apis\page-fetcher-api-models\page-fetcher-api-models.csproj", "{4F1A669E-C8AF-428F-87E7-3E0A213DD20B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "page-fetcher-data", "Apis\page-fetcher-data\page-fetcher-data.csproj", "{06F803CD-329D-40C2-B62D-0F14E137D3C7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "page-fetcher-api", "Apis\page-fetcher-api\page-fetcher-api.csproj", "{FC5A722A-7B12-459E-AB9F-0A724797783E}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -357,6 +363,42 @@ Global
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|x64.Build.0 = Release|Any CPU
|
||||
{06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|x86.Build.0 = Release|Any CPU
|
||||
{FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -385,6 +427,9 @@ Global
|
||||
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654}
|
||||
{434119EA-2FFC-4433-9B8E-1E6D94006413} = {0FE6558F-2157-47F2-A835-558416CE0E2B}
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012}
|
||||
{4F1A669E-C8AF-428F-87E7-3E0A213DD20B} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654}
|
||||
{06F803CD-329D-40C2-B62D-0F14E137D3C7} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012}
|
||||
{FC5A722A-7B12-459E-AB9F-0A724797783E} = {0FE6558F-2157-47F2-A835-558416CE0E2B}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67}
|
||||
|
||||
Reference in New Issue
Block a user