Add internet job search feature (cv-search-job)
Build and Push Docker Images / build (push) Failing after 1m36s
Build and Push Docker Images / build (push) Failing after 1m36s
- New cv-search-models shared library: EF entities + CvSearchDbContext for cvSearch schema (JobSearchTokens, JobSearchSessions, JobSearchResults tables) - New cv-search-job worker service: polls DB for pending sessions, scrapes job boards via configurable HTML scraping, runs LLM scoring via cv-matcher-api, emails ranked results - cv-matcher-api: JobTokenService creates one-time tokens; JobSearchController handles link clicks and creates sessions - api: proxies job-search start endpoint, appends job search link to match result email - CI workflow updated to build and push myai-cv-search-job:staging image - CLAUDE.md documentation added for all affected services Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
using CvMatcher.Models.Responses;
|
||||
using CvSearch.Models.Data.Entities;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MimeKit;
|
||||
|
||||
namespace CvSearchJob.Services;
|
||||
|
||||
public sealed class CvSearchEmailSender
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<CvSearchEmailSender> _logger;
|
||||
|
||||
public CvSearchEmailSender(IConfiguration config, ILogger<CvSearchEmailSender> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SendResultsAsync(
|
||||
string toEmail,
|
||||
string? attachmentPath,
|
||||
IReadOnlyList<JobSearchResultEntity> results,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var smtpHost = _config["Smtp:Host"];
|
||||
var smtpPort = int.TryParse(_config["Smtp:Port"], out var port) ? port : 587;
|
||||
var smtpUser = _config["Smtp:Username"];
|
||||
var smtpPass = _config["Smtp:Password"];
|
||||
var useStartTls = bool.TryParse(_config["Smtp:UseStartTls"], out var tls) && tls;
|
||||
var contactToEmail = _config["Contact:ToEmail"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(smtpHost)) return;
|
||||
|
||||
var recipients = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(toEmail)) recipients.Add(toEmail);
|
||||
if (!string.IsNullOrWhiteSpace(contactToEmail) &&
|
||||
!recipients.Any(r => string.Equals(r, contactToEmail, StringComparison.OrdinalIgnoreCase)))
|
||||
recipients.Add(contactToEmail);
|
||||
|
||||
if (recipients.Count == 0) return;
|
||||
|
||||
var body = BuildBody(results);
|
||||
var subject = $"MyAi.ro: {results.Count} joburi potrivite CV-ului tau";
|
||||
var environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
|
||||
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
var msg = new MimeMessage();
|
||||
msg.From.Add(MailboxAddress.Parse(smtpUser!));
|
||||
msg.To.Add(MailboxAddress.Parse(recipient));
|
||||
msg.Subject = $"[{environmentName}] {subject}";
|
||||
|
||||
var builder = new BodyBuilder { TextBody = body };
|
||||
if (!string.IsNullOrWhiteSpace(attachmentPath) && File.Exists(attachmentPath))
|
||||
builder.Attachments.Add(attachmentPath);
|
||||
|
||||
msg.Body = builder.ToMessageBody();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new SmtpClient();
|
||||
var tls2 = useStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
||||
await client.ConnectAsync(smtpHost, smtpPort, tls2, ct);
|
||||
if (!string.IsNullOrWhiteSpace(smtpUser))
|
||||
await client.AuthenticateAsync(smtpUser, smtpPass ?? string.Empty, ct);
|
||||
await client.SendAsync(msg, ct);
|
||||
await client.DisconnectAsync(true, ct);
|
||||
_logger.LogInformation("Job search results email sent to {Recipient}", recipient);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send job search results email to {Recipient}", recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildBody(IReadOnlyList<JobSearchResultEntity> results)
|
||||
{
|
||||
if (results.Count == 0)
|
||||
return "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.";
|
||||
|
||||
var lines = new System.Text.StringBuilder();
|
||||
lines.AppendLine($"MyAi.ro a gasit {results.Count} joburi potrivite CV-ului tau:");
|
||||
lines.AppendLine();
|
||||
|
||||
for (int i = 0; i < results.Count; i++)
|
||||
{
|
||||
var r = results[i];
|
||||
var matchResp = TryParseResult(r.ResultJson);
|
||||
lines.AppendLine($"{i + 1}. {r.JobTitle} ({r.Score}% match) [{r.ProviderName}]");
|
||||
lines.AppendLine($" {r.JobUrl}");
|
||||
if (matchResp is not null && !string.IsNullOrWhiteSpace(matchResp.Summary))
|
||||
lines.AppendLine($" {matchResp.Summary}");
|
||||
lines.AppendLine();
|
||||
}
|
||||
|
||||
return lines.ToString();
|
||||
}
|
||||
|
||||
private static JobMatchResponse? TryParseResult(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<JobMatchResponse>(json, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); }
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Web;
|
||||
using CvSearch.Models.Settings;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CvSearchJob.Services;
|
||||
|
||||
public sealed class HtmlJobSearcher
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<HtmlJobSearcher> _logger;
|
||||
|
||||
public HtmlJobSearcher(HttpClient http, ILogger<HtmlJobSearcher> logger)
|
||||
{
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
_http.Timeout = TimeSpan.FromSeconds(20);
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (compatible; MyAi.ro CV-Search/1.0)");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SearchJobUrlsAsync(
|
||||
JobProviderConfig provider,
|
||||
IReadOnlyList<string> cvKeywords,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var allKeywords = provider.InitialKeywords
|
||||
.Concat(cvKeywords)
|
||||
.Where(k => !string.IsNullOrWhiteSpace(k))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (allKeywords.Count == 0)
|
||||
return [];
|
||||
|
||||
var keywordsEncoded = HttpUtility.UrlEncode(string.Join(" ", allKeywords));
|
||||
var searchUrl = provider.SearchUrlTemplate.Replace("{keywords}", keywordsEncoded);
|
||||
|
||||
string html;
|
||||
try
|
||||
{
|
||||
html = await _http.GetStringAsync(searchUrl, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch search results from {Provider} at {Url}", provider.Name, searchUrl);
|
||||
return [];
|
||||
}
|
||||
|
||||
var baseUri = new Uri(searchUrl);
|
||||
var results = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Match all anchor tags capturing href and inner text
|
||||
var anchorPattern = new Regex(@"<a[^>]+href=[""']([^""']+)[""'][^>]*>(.*?)</a>",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
foreach (Match match in anchorPattern.Matches(html))
|
||||
{
|
||||
if (results.Count >= provider.MaxResults) break;
|
||||
|
||||
var href = match.Groups[1].Value.Trim();
|
||||
var anchorText = Regex.Replace(match.Groups[2].Value, "<[^>]+>", " ").Trim();
|
||||
|
||||
if (!href.Contains(provider.JobLinkContains, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
// Stage 2: anchor text must contain at least one CV keyword
|
||||
if (!cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
|
||||
// Make absolute URL
|
||||
if (!Uri.TryCreate(href, UriKind.Absolute, out var absoluteUri))
|
||||
{
|
||||
if (!Uri.TryCreate(baseUri, href, out absoluteUri))
|
||||
continue;
|
||||
}
|
||||
|
||||
var url = absoluteUri.GetLeftPart(UriPartial.Path);
|
||||
if (seen.Add(url))
|
||||
results.Add(url);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Provider {Provider}: found {Count} job URLs", provider.Name, results.Count);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user