13 Commits

Author SHA1 Message Date
claude e38f40732f feat(providers): add headless browser scraping via Playwright for SPA job sites
Build and Push Docker Images Staging / build (push) Successful in 5m20s
ejobs.ro migrated to a Nuxt SPA - plain HTTP GET returns only the JS
bundle. This change equips cv-search-job with a headless Chromium
(Playwright 1.60) so it can fully render SPA pages before extracting
job links.

- Add UseHeadlessBrowser flag to JobProviderEntity, JobProviderConfig,
  and CvSearchDbContext; map it in JobTokenService.ToConfig so the flag
  is included in the session provider-config snapshot
- Migration: add UseHeadlessBrowser column; fix ejobs.ro search URL
  (remove /user/ prefix that caused 404) and set UseHeadlessBrowser=true
- HtmlJobSearcher: detect flag and dispatch to FetchWithPlaywrightAsync;
  plain-HTTP path is unchanged; NetworkIdle timeout falls back to partial
  content rather than failing outright
- Dockerfile: download Playwright Chromium in the SDK build stage via
  npx; copy browser binaries to the final image; install Chromium system
  libs (Ubuntu noble t64 variants)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:42:52 +03:00
claude 209325ace5 fix(providers): correct bestjobs.eu job link filter pattern
Individual job listings on bestjobs.eu use /loc-de-munca/{slug} URLs.
The seeded JobLinkContains value /ro/locuri-de-munca/ matched only the
category navigation links (Vanzari, Inginerie, Management...), so
zero job URLs passed the stage-1 href filter and the scraper returned
nothing. Migration updates the stored record (Id=2) to /loc-de-munca/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:16:35 +03:00
gelu 5ae65642c4 Merge pull request 'feat: move job providers to DB and suppress job-search link when none enabled' (#36) from feature/job-providers-db-and-link-guard into main
feat: move job providers to DB, suppress link when none enabled, LLM keyword extraction
2026-05-29 10:07:16 +00:00
claude 9cf3db089d fix(cv-search-job): separate keyword badges with whitespace in results email
string.Join("") produced no whitespace between inline-block spans,
causing keywords to visually merge in email clients that collapse margins.
Switched to string.Join(" ") and zeroed left margin on each badge so
they wrap cleanly without a gap on the first item.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:05:33 +03:00
claude e5b6f19c1a chore: remove orphaned project directories left over from renames
Deleted stale directories and stray .csproj files that were never added
to the solution after project renames:
- Apis/cv-search-models/  (renamed → cv-search-data)
- Apis/myai-models/       (renamed → myai-data)
- Apis/shared-models/     (empty leftover)
- Apis/cv-search-data/cv-search-models.csproj  (stray old csproj)
- Apis/myai-data/myai-models.csproj            (stray old csproj)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:49:01 +03:00
claude 9bedf57f39 fix(migrations): replace hardcoded schema strings with MigrationConstants.SchemaName
Two migration files had literal schema strings that were missed in earlier passes:
- cv-search-data AddJobSearchTables: two CreateIndex calls used "cvSearch"
- rag-data InitialRagSchema: FK principalSchema used "rag"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:46:41 +03:00
claude b78ede23cf feat(job-search): extract keywords from LLM match call instead of heuristics
Piggybacks keyword extraction onto the existing CV-to-job LLM call —
no extra API calls. The system prompt now instructs the model to return
8-12 English job-search terms (job titles, technologies, skills, domains)
in a new `keywords` field alongside the existing score/summary fields.

Keywords flow: LLM JSON → JobMatchResponse.Keywords → CreateJobSearchTokenRequest →
JobSearchTokenEntity.Keywords (stored comma-separated) → JobSearchSessionEntity.Keywords
(copied at session-creation time, no RAG call needed).

Changes:
- Add Keywords to JobMatchResponse, CreateJobSearchTokenRequest, JobSearchTokenEntity
- IJobTokenService.CreateTokenAsync now accepts IReadOnlyList<string> keywords
- JobTokenService: store keywords on token; TriggerStartAsync reads token.Keywords
  instead of fetching CV text from RAG — removes IRagApiClient dependency
- Remove heuristic ExtractKeywords method
- Migration AddKeywordsToJobSearchTokens: adds Keywords column to cvSearch.JobSearchTokens
- Migration UpdateCvMatchSystemPromptKeywords: updates ai.cv-match.system-prompt seed
  to include keywords in the JSON shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:44:13 +03:00
claude a467fac35d fix(cv-matcher-api): fix keyword extraction for single-line PDF text
PDF text extraction often stores all content without newlines. The previous
line-based splitter would produce one line > 200 chars which was filtered out,
yielding empty keywords. Replace with word-level sampling of the first 2000
chars, splitting on whitespace and common delimiters, skipping phone fragments,
emails, and URLs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:29:10 +03:00
claude 25731868ee fix(email-data): replace hardcoded emailApi schema string with MigrationConstants
Down migration was referencing "emailApi" literal instead of MigrationConstants.SchemaName,
which would have dropped the wrong schema on rollback. Also fix stale comment in DbContext.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:19:58 +03:00
claude c675954f8a fix(cv-search-data): use MigrationConstants.SchemaName in AddJobProviders migration
Replace hardcoded "cvSearch" string literals with MigrationConstants.SchemaName
in the Up, InsertData, and Down methods, consistent with all other migrations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:15:44 +03:00
claude c8d1a21736 chore(config): remove Providers array from appsettings — now in DB
Provider config is no longer read from appsettings or env vars.
All three providers (ejobs.ro, bestjobs.eu, linkedin.com) are seeded
into cvSearch.JobProviders by the AddJobProviders migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:53:17 +03:00
claude d0d45bd2d3 feat(job-search): read providers from DB and suppress link when none enabled
JobTokenService.CreateTokenAsync queries cvSearch.JobProviders for any
enabled row; returns null (no token created) when the table is empty or
all providers are disabled. TriggerStartAsync snapshots enabled providers
from DB at session-start time, preserving the existing snapshot contract.

CvMatcherController guards link-building on a non-null TokenId so the
"Start a job search" CTA is omitted from match emails when no providers
are configured.

JobSearchSettings.Providers list removed — provider config now lives
exclusively in the DB. CvSearchJobTask.GetProviders falls back to an
empty list with a warning (snapshot should always be populated from DB).

Closes #35

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:46:44 +03:00
claude 7c09f5a871 feat(cv-search-data): add JobProviders table to cvSearch schema
New JobProviderEntity persists provider config (name, URL template,
link filter, initial keywords, max results, display order) in the DB
instead of appsettings. Migration seeds three disabled defaults:
ejobs.ro, bestjobs.eu, and linkedin.com.

Closes #35

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:46:34 +03:00
46 changed files with 1088 additions and 704 deletions
+6 -3
View File
@@ -181,10 +181,13 @@ public sealed class CvMatcherController : ControllerBase
try try
{ {
var tokenResp = await _jobSearchApi.CreateTokenAsync( var tokenResp = await _jobSearchApi.CreateTokenAsync(
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language }, new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords },
ct); ct);
var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/'); if (!string.IsNullOrWhiteSpace(tokenResp.TokenId))
jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}"; {
var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/');
jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}";
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -5,4 +5,5 @@ public sealed class CreateJobSearchTokenRequest
public string CvDocumentId { get; set; } = string.Empty; public string CvDocumentId { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;
public string Language { get; set; } = "en"; public string Language { get; set; } = "en";
public List<string> Keywords { get; set; } = [];
} }
@@ -2,5 +2,9 @@ namespace CvMatcher.Models.Responses;
public sealed class CreateJobSearchTokenResponse public sealed class CreateJobSearchTokenResponse
{ {
public string TokenId { get; set; } = string.Empty; /// <summary>
/// The generated token ID, or <c>null</c> when no job providers are currently enabled.
/// Callers must check for null before building the job-search link.
/// </summary>
public string? TokenId { get; set; }
} }
@@ -8,6 +8,7 @@
public List<string> Gaps { get; set; } = []; public List<string> Gaps { get; set; } = [];
public List<string> Recommendations { get; set; } = []; public List<string> Recommendations { get; set; } = [];
public List<string> Evidence { get; set; } = []; public List<string> Evidence { get; set; } = [];
public List<string> Keywords { get; set; } = [];
public bool Cached { get; set; } public bool Cached { get; set; }
public string? JobDocumentId { get; set; } public string? JobDocumentId { get; set; }
public string? JobUrl { get; set; } public string? JobUrl { get; set; }
@@ -7,9 +7,12 @@ public sealed class JobSearchSettings
public int TokenExpiryDays { get; set; } = 7; public int TokenExpiryDays { get; set; } = 7;
public int MinMatchScore { get; set; } = 15; public int MinMatchScore { get; set; } = 15;
public int MaxJobsToMatch { get; set; } = 15; public int MaxJobsToMatch { get; set; } = 15;
public List<JobProviderConfig> Providers { get; set; } = [];
} }
/// <summary>
/// Runtime DTO for a job provider. Populated from <c>cvSearch.JobProviders</c> at session-creation
/// time and snapshotted to <c>JobSearchSessionEntity.ProviderConfigJson</c>.
/// </summary>
public sealed class JobProviderConfig public sealed class JobProviderConfig
{ {
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
@@ -18,4 +21,6 @@ public sealed class JobProviderConfig
public string JobLinkContains { get; set; } = string.Empty; public string JobLinkContains { get; set; } = string.Empty;
public List<string> InitialKeywords { get; set; } = []; public List<string> InitialKeywords { get; set; } = [];
public int MaxResults { get; set; } = 20; 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; }
} }
@@ -53,7 +53,7 @@ public sealed class JobSearchController : ControllerBase
if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email)) if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email))
return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" }); return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" });
var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, ct); var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, ct);
return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId }); return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId });
} }
catch (Exception ex) catch (Exception ex)
@@ -12,9 +12,13 @@ public interface IJobTokenService
/// <param name="cvDocumentId">Identifier of the indexed CV document.</param> /// <param name="cvDocumentId">Identifier of the indexed CV document.</param>
/// <param name="email">Email address of the user who will receive the results.</param> /// <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="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="ct">Cancellation token.</param> /// <param name="ct">Cancellation token.</param>
/// <returns>The generated token ID, to be embedded in the one-click job search link.</returns> /// <returns>
Task<string> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct); /// 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);
/// <summary> /// <summary>
/// Validates the token and, if valid, marks it as used and creates a <c>Pending</c> job search session. /// Validates the token and, if valid, marks it as used and creates a <c>Pending</c> job search session.
+47 -33
View File
@@ -1,6 +1,4 @@
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using Api.Clients.Api.Contracts;
using Api.Services.Contracts; using Api.Services.Contracts;
using CvMatcher.Models.Responses; using CvMatcher.Models.Responses;
using CvSearch.Data; using CvSearch.Data;
@@ -13,35 +11,45 @@ namespace Api.Services;
/// <summary> /// <summary>
/// Creates and validates one-time job search tokens, and creates the corresponding search sessions. /// Creates and validates one-time job search tokens, and creates the corresponding search sessions.
/// Provider configuration is read from <c>cvSearch.JobProviders</c> at session-creation time and
/// snapshotted into <c>JobSearchSessionEntity.ProviderConfigJson</c> so subsequent config changes
/// do not affect already-queued sessions.
/// Keywords are extracted by the LLM during the CV-to-job match call and stored on the token,
/// then copied to the session when the user clicks the link — no extra RAG call needed.
/// </summary> /// </summary>
public sealed class JobTokenService : IJobTokenService public sealed class JobTokenService : IJobTokenService
{ {
private readonly CvSearchDbContext _db; private readonly CvSearchDbContext _db;
private readonly IRagApiClient _rag;
private readonly JobSearchSettings _settings; private readonly JobSearchSettings _settings;
private readonly ILogger<JobTokenService> _logger; private readonly ILogger<JobTokenService> _logger;
public JobTokenService( public JobTokenService(
CvSearchDbContext db, CvSearchDbContext db,
IRagApiClient rag,
IOptions<JobSearchSettings> settings, IOptions<JobSearchSettings> settings,
ILogger<JobTokenService> logger) ILogger<JobTokenService> logger)
{ {
_db = db; _db = db;
_rag = rag;
_settings = settings.Value; _settings = settings.Value;
_logger = logger; _logger = logger;
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<string> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct) public async Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, CancellationToken ct)
{ {
var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct);
if (!hasEnabledProviders)
{
_logger.LogDebug("Job search token skipped — no enabled providers in cvSearch.JobProviders");
return null;
}
var token = new JobSearchTokenEntity var token = new JobSearchTokenEntity
{ {
Id = Guid.NewGuid().ToString("N"), Id = Guid.NewGuid().ToString("N"),
CvDocumentId = cvDocumentId, CvDocumentId = cvDocumentId,
Email = email, Email = email,
Language = language, Language = language,
Keywords = string.Join(",", keywords),
ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays), ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays),
Used = false, Used = false,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
@@ -49,7 +57,7 @@ public sealed class JobTokenService : IJobTokenService
_db.JobSearchTokens.Add(token); _db.JobSearchTokens.Add(token);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
_logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}", token.Id, cvDocumentId); _logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}, Keywords={Keywords}", token.Id, cvDocumentId, token.Keywords);
return token.Id; return token.Id;
} }
@@ -64,11 +72,15 @@ public sealed class JobTokenService : IJobTokenService
token.Used = true; token.Used = true;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
var cv = await _rag.GetDocumentAsync(token.CvDocumentId, ct); var keywords = token.Keywords;
var keywords = cv is not null ? ExtractKeywords(cv.Text) : string.Empty;
var enabledProviders = await _db.JobProviders
.Where(p => p.Enabled)
.OrderBy(p => p.DisplayOrder)
.ToListAsync(ct);
var providerConfigJson = JsonSerializer.Serialize( var providerConfigJson = JsonSerializer.Serialize(
_settings.Providers.Where(p => p.Enabled).ToList(), enabledProviders.Select(ToConfig).ToList(),
new JsonSerializerOptions(JsonSerializerDefaults.Web)); new JsonSerializerOptions(JsonSerializerDefaults.Web));
var session = new JobSearchSessionEntity var session = new JobSearchSessionEntity
@@ -86,34 +98,36 @@ public sealed class JobTokenService : IJobTokenService
_db.JobSearchSessions.Add(session); _db.JobSearchSessions.Add(session);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
_logger.LogInformation("Job search session created. SessionId={SessionId}, Keywords={Keywords}", session.Id, keywords); _logger.LogInformation(
"Job search session created. SessionId={SessionId}, Keywords={Keywords}, Providers={Providers}",
session.Id, keywords, string.Join(", ", enabledProviders.Select(p => p.Name)));
return StartJobSearchStatus.Started; return StartJobSearchStatus.Started;
} }
/// <summary> private static JobProviderConfig ToConfig(JobProviderEntity entity)
/// Extracts up to 10 meaningful keywords from the CV text using simple heuristics (no LLM).
/// Takes the first 5 usable lines, splits them into words, strips punctuation, and deduplicates.
/// </summary>
private static string ExtractKeywords(string cvText)
{ {
var lines = cvText List<string> keywords;
.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries) try
.Select(l => l.Trim()) {
.Where(l => l.Length > 5 && l.Length < 200) keywords = JsonSerializer.Deserialize<List<string>>(entity.InitialKeywordsJson,
// Skip lines that are purely digits, spaces, and phone/contact punctuation (phone numbers, emails, etc.) new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? [];
.Where(l => !Regex.IsMatch(l, @"^[\d\s\+\-\(\)\@\.]+$")) }
.Take(5) catch
.ToList(); {
keywords = [];
}
var words = lines return new JobProviderConfig
.SelectMany(l => l.Split(' ', StringSplitOptions.RemoveEmptyEntries)) {
.Select(w => Regex.Replace(w, @"[^\w\-]", "")) Name = entity.Name,
.Where(w => w.Length > 2) Enabled = entity.Enabled,
.Distinct(StringComparer.OrdinalIgnoreCase) SearchUrlTemplate = entity.SearchUrlTemplate,
.Take(10) JobLinkContains = entity.JobLinkContains,
.ToList(); InitialKeywords = keywords,
MaxResults = entity.MaxResults,
return string.Join(",", words); UseHeadlessBrowser = entity.UseHeadlessBrowser
};
} }
} }
+1 -27
View File
@@ -112,32 +112,6 @@
"JobSearchLinkBaseUrl": "https://myai.ro", "JobSearchLinkBaseUrl": "https://myai.ro",
"TokenExpiryDays": 7, "TokenExpiryDays": 7,
"MinMatchScore": 15, "MinMatchScore": 15,
"MaxJobsToMatch": 15, "MaxJobsToMatch": 15
"Providers": [
{
"Name": "ejobs.ro",
"Enabled": false,
"SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/",
"JobLinkContains": "/user/locuri-de-munca/job/",
"InitialKeywords": [],
"MaxResults": 20
},
{
"Name": "bestjobs.eu",
"Enabled": false,
"SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}",
"JobLinkContains": "/ro/locuri-de-munca/",
"InitialKeywords": [],
"MaxResults": 20
},
{
"Name": "linkedin.com",
"Enabled": false,
"SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania",
"JobLinkContains": "/jobs/view/",
"InitialKeywords": [],
"MaxResults": 20
}
]
} }
} }
@@ -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("20260529140000_UpdateCvMatchSystemPromptKeywords")]
partial class UpdateCvMatchSystemPromptKeywords
{
/// <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(max)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CvDocumentId", "JobDocumentId")
.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,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
using CvMatcher.Data;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class UpdateCvMatchSystemPromptKeywords : Migration
{
private const string OldPrompt =
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.\n" +
"Penalize missing required skills. Do not invent experience. Use concise business language.\n" +
"Respond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.\n" +
"JSON shape: {\"score\":number,\"summary\":\"...\",\"strengths\":[\"...\"],\"gaps\":[\"...\"],\"recommendations\":[\"...\"],\"evidence\":[\"...\"]}";
private const string NewPrompt =
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.\n" +
"Penalize missing required skills. Do not invent experience. Use concise business language.\n" +
"Respond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.\n" +
"Also extract 8 to 12 English job search keywords from the CV — job titles, technologies, skills, and domains.\n" +
"The keywords array must always be in English regardless of {{languageName}}. Exclude names, emails, phone numbers, and locations.\n" +
"JSON shape: {\"score\":number,\"summary\":\"...\",\"strengths\":[\"...\"],\"gaps\":[\"...\"],\"recommendations\":[\"...\"],\"evidence\":[\"...\"],\"keywords\":[\"term1\",\"term2\"]}";
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: new object[] { "ai.cv-match.system-prompt", "*" },
column: "Value",
value: NewPrompt);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: new object[] { "ai.cv-match.system-prompt", "*" },
column: "Value",
value: OldPrompt);
}
}
}
@@ -13,6 +13,7 @@ public sealed class CvSearchDbContext : DbContext
public DbSet<JobSearchTokenEntity> JobSearchTokens => Set<JobSearchTokenEntity>(); public DbSet<JobSearchTokenEntity> JobSearchTokens => Set<JobSearchTokenEntity>();
public DbSet<JobSearchSessionEntity> JobSearchSessions => Set<JobSearchSessionEntity>(); public DbSet<JobSearchSessionEntity> JobSearchSessions => Set<JobSearchSessionEntity>();
public DbSet<JobSearchResultEntity> JobSearchResults => Set<JobSearchResultEntity>(); public DbSet<JobSearchResultEntity> JobSearchResults => Set<JobSearchResultEntity>();
public DbSet<JobProviderEntity> JobProviders => Set<JobProviderEntity>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@@ -33,6 +34,7 @@ public sealed class CvSearchDbContext : DbContext
entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired(); entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired();
entity.Property(x => x.Email).HasMaxLength(256).IsRequired(); entity.Property(x => x.Email).HasMaxLength(256).IsRequired();
entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired(); entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired();
entity.Property(x => x.Keywords).HasMaxLength(1000).HasDefaultValue(string.Empty);
entity.Property(x => x.Used).HasDefaultValue(false); entity.Property(x => x.Used).HasDefaultValue(false);
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
}); });
@@ -65,5 +67,19 @@ public sealed class CvSearchDbContext : DbContext
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
entity.HasIndex(x => x.SessionId); entity.HasIndex(x => x.SessionId);
}); });
modelBuilder.Entity<JobProviderEntity>(entity =>
{
entity.ToTable("JobProviders");
entity.HasKey(x => x.Id);
entity.Property(x => x.Id).UseIdentityColumn();
entity.Property(x => x.Name).HasMaxLength(128).IsRequired();
entity.Property(x => x.SearchUrlTemplate).HasMaxLength(1024).IsRequired();
entity.Property(x => x.JobLinkContains).HasMaxLength(256).IsRequired();
entity.Property(x => x.InitialKeywordsJson).HasMaxLength(2000).HasDefaultValue("[]").IsRequired();
entity.Property(x => x.MaxResults).HasDefaultValue(20);
entity.Property(x => x.DisplayOrder).HasDefaultValue(0);
entity.Property(x => x.UseHeadlessBrowser).HasDefaultValue(false);
});
} }
} }
@@ -0,0 +1,36 @@
namespace CvSearch.Data.Entities;
/// <summary>
/// Persisted job-board provider configuration. Stored in <c>cvSearch.JobProviders</c>.
/// Providers are loaded from here at session-creation time and snapshotted into
/// <c>JobSearchSessionEntity.ProviderConfigJson</c> so runtime config changes do not
/// affect already-queued sessions.
/// </summary>
public sealed class JobProviderEntity
{
public int Id { get; set; }
/// <summary>Display name (e.g. "ejobs.ro").</summary>
public string Name { get; set; } = string.Empty;
/// <summary>When false the provider is skipped at session-creation and the job-search link is hidden.</summary>
public bool Enabled { get; set; }
/// <summary>URL template with <c>{keywords}</c> placeholder (URL-encoded keywords are substituted at runtime).</summary>
public string SearchUrlTemplate { get; set; } = string.Empty;
/// <summary>Substring that must appear in an anchor href to pass the stage-1 link filter.</summary>
public string JobLinkContains { get; set; } = string.Empty;
/// <summary>JSON array of baseline keywords merged with CV keywords before building the search URL.</summary>
public string InitialKeywordsJson { get; set; } = "[]";
/// <summary>Maximum number of job URLs to collect from this provider per session.</summary>
public int MaxResults { get; set; } = 20;
/// <summary>Controls display ordering in future admin UIs.</summary>
public int DisplayOrder { get; set; }
/// <summary>When true, the scraper renders the page with headless Chromium instead of a plain HTTP GET.</summary>
public bool UseHeadlessBrowser { get; set; }
}
@@ -9,4 +9,5 @@ public sealed class JobSearchTokenEntity : BaseEntity
public string Language { get; set; } = "en"; public string Language { get; set; } = "en";
public DateTime ExpiresAt { get; set; } public DateTime ExpiresAt { get; set; }
public bool Used { get; set; } public bool Used { get; set; }
public string Keywords { get; set; } = string.Empty;
} }
@@ -73,13 +73,13 @@ namespace CvSearch.Data.Migrations
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_JobSearchResults_SessionId", name: "IX_JobSearchResults_SessionId",
schema: "cvSearch", schema: MigrationConstants.SchemaName,
table: "JobSearchResults", table: "JobSearchResults",
column: "SessionId"); column: "SessionId");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_JobSearchSessions_Status", name: "IX_JobSearchSessions_Status",
schema: "cvSearch", schema: MigrationConstants.SchemaName,
table: "JobSearchSessions", table: "JobSearchSessions",
column: "Status"); column: "Status");
} }
@@ -1,6 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using CvSearch.Models.Data; using CvSearch.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata;
@@ -9,11 +9,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable #nullable disable
namespace CvSearch.Models.Migrations namespace CvSearch.Data.Migrations
{ {
[DbContext(typeof(CvSearchDbContext))] [DbContext(typeof(CvSearchDbContext))]
[Migration("20260524145702_AddLanguageToJobSearchEntities")] [Migration("20260529084440_AddJobProviders")]
partial class AddLanguageToJobSearchEntities partial class AddJobProviders
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -26,7 +26,55 @@ namespace CvSearch.Models.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b => 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<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -75,7 +123,7 @@ namespace CvSearch.Models.Migrations
b.ToTable("JobSearchResults", "cvSearch"); b.ToTable("JobSearchResults", "cvSearch");
}); });
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b => modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -128,7 +176,7 @@ namespace CvSearch.Models.Migrations
b.ToTable("JobSearchSessions", "cvSearch"); b.ToTable("JobSearchSessions", "cvSearch");
}); });
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b => modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddJobProviders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "JobProviders",
schema: MigrationConstants.SchemaName,
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
SearchUrlTemplate = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: false),
JobLinkContains = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
InitialKeywordsJson = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false, defaultValue: "[]"),
MaxResults = table.Column<int>(type: "int", nullable: false, defaultValue: 20),
DisplayOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0)
},
constraints: table =>
{
table.PrimaryKey("PK_JobProviders", x => x.Id);
});
// Seed the three default providers — all disabled so the feature is opt-in per environment.
// Enable a provider by setting its Enabled column to 1 via SQL or a future admin UI.
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
columns: ["Name", "Enabled", "SearchUrlTemplate", "JobLinkContains", "InitialKeywordsJson", "MaxResults", "DisplayOrder"],
values: new object[,]
{
{ "ejobs.ro", false, "https://www.ejobs.ro/user/locuri-de-munca/?utm_source=myai&q={keywords}", "/user/locuri-de-munca/", "[]", 20, 0 },
{ "bestjobs.eu", false, "https://www.bestjobs.eu/ro/locuri-de-munca?keywords={keywords}", "/ro/locuri-de-munca/", "[]", 20, 1 },
{ "linkedin.com", false, "https://www.linkedin.com/jobs/search/?keywords={keywords}", "/jobs/view/", "[]", 20, 2 },
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JobProviders",
schema: MigrationConstants.SchemaName);
}
}
}
@@ -1,6 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using CvSearch.Models.Data; using CvSearch.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata;
@@ -9,11 +9,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable #nullable disable
namespace CvSearch.Models.Migrations namespace CvSearch.Data.Migrations
{ {
[DbContext(typeof(CvSearchDbContext))] [DbContext(typeof(CvSearchDbContext))]
[Migration("20260522093356_AddJobSearchTables")] [Migration("20260529130000_AddKeywordsToJobSearchTokens")]
partial class AddJobSearchTables partial class AddKeywordsToJobSearchTokens
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -26,7 +26,55 @@ namespace CvSearch.Models.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b => 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<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -75,7 +123,7 @@ namespace CvSearch.Models.Migrations
b.ToTable("JobSearchResults", "cvSearch"); b.ToTable("JobSearchResults", "cvSearch");
}); });
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b => modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -101,6 +149,13 @@ namespace CvSearch.Models.Migrations
.HasMaxLength(1000) .HasMaxLength(1000)
.HasColumnType("nvarchar(1000)"); .HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("ProviderConfigJson") b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -121,7 +176,7 @@ namespace CvSearch.Models.Migrations
b.ToTable("JobSearchSessions", "cvSearch"); b.ToTable("JobSearchSessions", "cvSearch");
}); });
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b => modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -145,6 +200,20 @@ namespace CvSearch.Models.Migrations
b.Property<DateTime>("ExpiresAt") b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2"); .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<bool>("Used") b.Property<bool>("Used")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("bit") .HasColumnType("bit")
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
using CvSearch.Data;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddKeywordsToJobSearchTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Keywords",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens",
type: "nvarchar(1000)",
maxLength: 1000,
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Keywords",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens");
}
}
}
@@ -1,19 +1,22 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using CvSearch.Models.Data; using CvSearch.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable #nullable disable
namespace CvSearch.Models.Migrations namespace CvSearch.Data.Migrations
{ {
[DbContext(typeof(CvSearchDbContext))] [DbContext(typeof(CvSearchDbContext))]
partial class CvSearchDbContextModelSnapshot : ModelSnapshot [Migration("20260529160000_FixBestJobsLinkFilter")]
partial class FixBestJobsLinkFilter
{ {
protected override void BuildModel(ModelBuilder modelBuilder) /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
@@ -23,7 +26,55 @@ namespace CvSearch.Models.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b => 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<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -72,7 +123,7 @@ namespace CvSearch.Models.Migrations
b.ToTable("JobSearchResults", "cvSearch"); b.ToTable("JobSearchResults", "cvSearch");
}); });
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b => modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -125,7 +176,7 @@ namespace CvSearch.Models.Migrations
b.ToTable("JobSearchSessions", "cvSearch"); b.ToTable("JobSearchSessions", "cvSearch");
}); });
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b => modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -149,6 +200,13 @@ namespace CvSearch.Models.Migrations
b.Property<DateTime>("ExpiresAt") b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language") b.Property<string>("Language")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class FixBestJobsLinkFilter : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// bestjobs.eu individual job listings use /loc-de-munca/{slug}.
// The original seed value /ro/locuri-de-munca/ matched only category nav links,
// so zero job URLs passed the stage-1 filter.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 2,
column: "JobLinkContains",
value: "/loc-de-munca/");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 2,
column: "JobLinkContains",
value: "/ro/locuri-de-munca/");
}
}
}
@@ -0,0 +1,234 @@
// <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("20260529170000_AddHeadlessBrowserToProviders")]
partial class AddHeadlessBrowserToProviders
{
/// <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<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>("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<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddHeadlessBrowserToProviders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "UseHeadlessBrowser",
schema: MigrationConstants.SchemaName,
table: "JobProviders",
type: "bit",
nullable: false,
defaultValue: false);
// ejobs.ro (Id=1) is a Nuxt SPA — the old /user/ URL 404s and plain HTTP GET
// returns only the JS bundle, not actual job listings.
// Fix: use the correct search URL and headless Chromium to render job results.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 1,
columns: ["SearchUrlTemplate", "JobLinkContains", "UseHeadlessBrowser"],
values: new object[] { "https://www.ejobs.ro/locuri-de-munca?q={keywords}", "/locuri-de-munca/", true });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 1,
columns: ["SearchUrlTemplate", "JobLinkContains", "UseHeadlessBrowser"],
values: new object[] { "https://www.ejobs.ro/user/locuri-de-munca/?utm_source=myai&q={keywords}", "/user/locuri-de-munca/", false });
migrationBuilder.DropColumn(
name: "UseHeadlessBrowser",
schema: MigrationConstants.SchemaName,
table: "JobProviders");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using CvSearch.Data; using CvSearch.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -23,6 +23,59 @@ namespace CvSearch.Data.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); 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<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 => modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -149,6 +202,13 @@ namespace CvSearch.Data.Migrations
b.Property<DateTime>("ExpiresAt") b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language") b.Property<string>("Language")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>CvSearch.Models</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -1,102 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Models.Migrations
{
/// <inheritdoc />
public partial class AddJobSearchTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "cvSearch");
migrationBuilder.CreateTable(
name: "JobSearchResults",
schema: "cvSearch",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
SessionId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ProviderName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
JobUrl = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: false),
JobTitle = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
JobText = table.Column<string>(type: "nvarchar(max)", nullable: false),
Score = table.Column<int>(type: "int", nullable: false),
ResultJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_JobSearchResults", x => x.Id);
});
migrationBuilder.CreateTable(
name: "JobSearchSessions",
schema: "cvSearch",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
TokenId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Status = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Keywords = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
ProviderConfigJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_JobSearchSessions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "JobSearchTokens",
schema: "cvSearch",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
Used = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_JobSearchTokens", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_JobSearchResults_SessionId",
schema: "cvSearch",
table: "JobSearchResults",
column: "SessionId");
migrationBuilder.CreateIndex(
name: "IX_JobSearchSessions_Status",
schema: "cvSearch",
table: "JobSearchSessions",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JobSearchResults",
schema: "cvSearch");
migrationBuilder.DropTable(
name: "JobSearchSessions",
schema: "cvSearch");
migrationBuilder.DropTable(
name: "JobSearchTokens",
schema: "cvSearch");
}
}
}
@@ -1,46 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Models.Migrations
{
/// <inheritdoc />
public partial class AddLanguageToJobSearchEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Language",
schema: "cvSearch",
table: "JobSearchTokens",
type: "nvarchar(8)",
maxLength: 8,
nullable: false,
defaultValue: "en");
migrationBuilder.AddColumn<string>(
name: "Language",
schema: "cvSearch",
table: "JobSearchSessions",
type: "nvarchar(8)",
maxLength: 8,
nullable: false,
defaultValue: "en");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Language",
schema: "cvSearch",
table: "JobSearchTokens");
migrationBuilder.DropColumn(
name: "Language",
schema: "cvSearch",
table: "JobSearchSessions");
}
}
}
@@ -1,21 +0,0 @@
namespace CvSearch.Models.Settings;
public sealed class JobSearchSettings
{
public bool Enabled { get; set; } = true;
public string JobSearchLinkBaseUrl { get; set; } = string.Empty;
public int TokenExpiryDays { get; set; } = 7;
public int MinMatchScore { get; set; } = 15;
public int MaxJobsToMatch { get; set; } = 15;
public List<JobProviderConfig> Providers { get; set; } = [];
}
public sealed class JobProviderConfig
{
public string Name { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
public string SearchUrlTemplate { get; set; } = string.Empty;
public string JobLinkContains { get; set; } = string.Empty;
public List<string> InitialKeywords { get; set; } = [];
public int MaxResults { get; set; } = 20;
}
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>CvSearch.Models</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
+1 -1
View File
@@ -15,7 +15,7 @@ public sealed class EmailApiDbContext : DbContext
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
base.OnConfiguring(optionsBuilder); base.OnConfiguring(optionsBuilder);
// Configure migration history table to use schema-qualified name: [emailApi].[_Migrations] // Configure migration history table to use schema-qualified name: [email].[_Migrations]
optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName)); optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName));
} }
@@ -185,7 +185,7 @@ namespace Email.Data.Migrations
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "EmailTemplates", schema: "emailApi"); migrationBuilder.DropTable(name: "EmailTemplates", schema: MigrationConstants.SchemaName);
} }
} }
} }
-18
View File
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>MyAi.Models</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -1,62 +0,0 @@
// <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 MyAi.Models.Data;
#nullable disable
namespace MyAi.Models.Migrations
{
[DbContext(typeof(MyAiDbContext))]
[Migration("20260524145351_AddTemplates")]
partial class AddTemplates
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("myAi")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MyAi.Models.Data.Entities.TemplateEntity", 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("Templates", "myAi");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,113 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MyAi.Models.Migrations
{
/// <inheritdoc />
public partial class AddTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "myAi");
migrationBuilder.CreateTable(
name: "Templates",
schema: "myAi",
columns: table => new
{
Key = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Language = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_Templates", x => new { x.Key, x.Language });
});
Seed(migrationBuilder);
}
private static void Seed(MigrationBuilder m)
{
void Row(string key, string lang, string value, string description = "")
=> m.InsertData("Templates", ["Key", "Language", "Value", "Description"], [key, lang, value, description], "myAi");
// Match result email — subject
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
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");
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");
// Match result email — job search CTA footer
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");
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");
// 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 — 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");
// 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");
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");
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");
// AI system prompt for CV matching (language is a {{languageName}} variable inside it)
Row("ai.cv-match.system-prompt", "*",
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.\nPenalize missing required skills. Do not invent experience. Use concise business language.\nRespond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.\nJSON shape: {\"score\":number,\"summary\":\"...\",\"strengths\":[\"...\"],\"gaps\":[\"...\"],\"recommendations\":[\"...\"],\"evidence\":[\"...\"]}",
"System prompt template for the CV-to-job LLM matching call. {{languageName}} is substituted at runtime.");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Templates",
schema: "myAi");
}
}
}
@@ -1,59 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MyAi.Models.Data;
#nullable disable
namespace MyAi.Models.Migrations
{
[DbContext(typeof(MyAiDbContext))]
partial class MyAiDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("myAi")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MyAi.Models.Data.Entities.TemplateEntity", 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("Templates", "myAi");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,70 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using MyAi.Models.Data;
using System.Collections.Concurrent;
namespace MyAi.Models.Services;
public sealed class DbTemplateService : ITemplateService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<DbTemplateService> _logger;
private ConcurrentDictionary<string, string> _cache = new(StringComparer.OrdinalIgnoreCase);
private DateTime _loadedAt = DateTime.MinValue;
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10);
public DbTemplateService(IServiceScopeFactory scopeFactory, ILogger<DbTemplateService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
public string Get(string key, string language = "en")
{
EnsureCacheLoaded();
if (_cache.TryGetValue(CacheKey(key, language), out var value))
return value;
if (!string.Equals(language, "en", StringComparison.OrdinalIgnoreCase)
&& _cache.TryGetValue(CacheKey(key, "en"), out var fallback))
return fallback;
_logger.LogWarning("Template not found: key={Key}, language={Language}", key, language);
return key;
}
public string Render(string key, string language, params (string Key, string Value)[] placeholders)
{
var template = Get(key, language);
foreach (var (k, v) in placeholders)
template = template.Replace($"{{{{{k}}}}}", v, StringComparison.OrdinalIgnoreCase);
return template;
}
private void EnsureCacheLoaded()
{
if (DateTime.UtcNow - _loadedAt < CacheTtl) return;
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MyAiDbContext>();
var rows = db.Templates.AsNoTracking().ToList();
var fresh = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
fresh[CacheKey(row.Key, row.Language)] = row.Value;
_cache = fresh;
_loadedAt = DateTime.UtcNow;
_logger.LogDebug("Template cache refreshed. {Count} templates loaded.", rows.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh template cache. Serving stale cache.");
}
}
private static string CacheKey(string key, string language) => $"{key}::{language}";
}
@@ -1,7 +0,0 @@
namespace MyAi.Models.Services;
public interface ITemplateService
{
string Get(string key, string language = "en");
string Render(string key, string language, params (string Key, string Value)[] placeholders);
}
-18
View File
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>MyAi.Models</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -84,7 +84,7 @@ namespace Rag.Data.Migrations
table.ForeignKey( table.ForeignKey(
name: "FK_Chunks_Documents_DocumentId", name: "FK_Chunks_Documents_DocumentId",
column: x => x.DocumentId, column: x => x.DocumentId,
principalSchema: "rag", principalSchema: MigrationConstants.SchemaName,
principalTable: "Documents", principalTable: "Documents",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
+2
View File
@@ -32,6 +32,8 @@
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" /> <PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
<PackageVersion Include="MailKit" Version="4.16.0" /> <PackageVersion Include="MailKit" Version="4.16.0" />
<PackageVersion Include="PdfPig" Version="0.1.14" /> <PackageVersion Include="PdfPig" Version="0.1.14" />
<!-- Browser automation -->
<PackageVersion Include="Microsoft.Playwright" Version="1.60.0" />
<!-- Tooling --> <!-- Tooling -->
<PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" /> <PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
</ItemGroup> </ItemGroup>
+19
View File
@@ -29,9 +29,28 @@ COPY Helpers/startup-helpers/ Helpers/startup-helpers/
RUN dotnet publish Jobs/cv-search-job/cv-search-job.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false RUN dotnet publish Jobs/cv-search-job/cv-search-job.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 FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app 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 . COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "cv-search-job.dll"] ENTRYPOINT ["dotnet", "cv-search-job.dll"]
@@ -123,8 +123,8 @@ public sealed class CvSearchEmailSender
private static string BuildScanSummary(IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames) private static string BuildScanSummary(IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames)
{ {
var keywordsHtml = keywords.Count > 0 var keywordsHtml = keywords.Count > 0
? string.Join("", keywords.Select(k => ? string.Join(" ", keywords.Select(k =>
$"<span style=\"display:inline-block;background:#e9ecef;border-radius:4px;padding:2px 8px;margin:2px;font-size:12px\">{k}</span>")) $"<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=\"color:#6c757d;font-size:12px;font-style:italic\">none detected</span>";
var providersText = providerNames.Count > 0 var providersText = providerNames.Count > 0
+72 -21
View File
@@ -1,6 +1,7 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Web; using System.Web;
using CvMatcher.Models.Settings; using CvMatcher.Models.Settings;
using Microsoft.Playwright;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace CvSearchJob.Services; namespace CvSearchJob.Services;
@@ -9,6 +10,7 @@ namespace CvSearchJob.Services;
/// Config-driven HTML scraper that fetches a provider's job listing page and extracts matching job URLs. /// 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 /// Uses a two-stage anchor filter: href must contain the provider's link pattern, and anchor text must
/// contain at least one CV keyword. /// contain at least one CV keyword.
/// Supports both plain HTTP GET (default) and headless Chromium rendering for JS-heavy SPAs.
/// </summary> /// </summary>
public sealed class HtmlJobSearcher public sealed class HtmlJobSearcher
{ {
@@ -28,10 +30,6 @@ public sealed class HtmlJobSearcher
/// tags, applies the two-stage filter, and returns up to <see cref="JobProviderConfig.MaxResults"/> absolute URLs. /// 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. /// Returns an empty list when the HTTP request fails rather than throwing.
/// </summary> /// </summary>
/// <param name="provider">Provider configuration including search URL template, link filter, and result cap.</param>
/// <param name="cvKeywords">Keywords extracted from the user's CV to inject into the search query.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Deduplicated list of absolute job page URLs (query string stripped).</returns>
public async Task<IReadOnlyList<string>> SearchJobUrlsAsync( public async Task<IReadOnlyList<string>> SearchJobUrlsAsync(
JobProviderConfig provider, JobProviderConfig provider,
IReadOnlyList<string> cvKeywords, IReadOnlyList<string> cvKeywords,
@@ -53,26 +51,25 @@ public sealed class HtmlJobSearcher
var searchUrl = provider.SearchUrlTemplate.Replace("{keywords}", keywordsEncoded); var searchUrl = provider.SearchUrlTemplate.Replace("{keywords}", keywordsEncoded);
_logger.LogInformation( _logger.LogInformation(
"Provider {Provider}: fetching {Url} | CV keywords: [{Keywords}]", "Provider {Provider}: fetching {Url} [{Mode}] | CV keywords: [{Keywords}]",
provider.Name, searchUrl, string.Join(", ", cvKeywords)); provider.Name, searchUrl,
provider.UseHeadlessBrowser ? "headless" : "http",
string.Join(", ", cvKeywords));
string html; string? html;
try if (provider.UseHeadlessBrowser)
{ html = await FetchWithPlaywrightAsync(provider.Name, searchUrl, ct);
html = await _http.GetStringAsync(searchUrl, ct); else
_logger.LogInformation("Provider {Provider}: received {Length} chars of HTML", provider.Name, html.Length); html = await FetchWithHttpAsync(provider.Name, searchUrl, ct);
}
catch (Exception ex) if (html is null) return [];
{
_logger.LogWarning(ex, "Provider {Provider}: HTTP fetch failed for {Url}", provider.Name, searchUrl); _logger.LogInformation("Provider {Provider}: received {Length} chars of HTML", provider.Name, html.Length);
return [];
}
var baseUri = new Uri(searchUrl); var baseUri = new Uri(searchUrl);
var results = new List<string>(); var results = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Match all anchor tags capturing href and inner text
var anchorPattern = new Regex(@"<a[^>]+href=[""']([^""']+)[""'][^>]*>(.*?)</a>", var anchorPattern = new Regex(@"<a[^>]+href=[""']([^""']+)[""'][^>]*>(.*?)</a>",
RegexOptions.IgnoreCase | RegexOptions.Singleline); RegexOptions.IgnoreCase | RegexOptions.Singleline);
@@ -92,7 +89,6 @@ public sealed class HtmlJobSearcher
stage1Pass++; stage1Pass++;
// Stage 2: anchor text must contain at least one CV keyword
if (!cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase))) if (!cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase)))
{ {
_logger.LogDebug( _logger.LogDebug(
@@ -103,14 +99,12 @@ public sealed class HtmlJobSearcher
stage2Pass++; stage2Pass++;
// Make absolute URL
if (!Uri.TryCreate(href, UriKind.Absolute, out var absoluteUri)) if (!Uri.TryCreate(href, UriKind.Absolute, out var absoluteUri))
{ {
if (!Uri.TryCreate(baseUri, href, out absoluteUri)) if (!Uri.TryCreate(baseUri, href, out absoluteUri))
continue; continue;
} }
// Strip query string and fragment so different tracking variants of the same URL collapse to one.
var url = absoluteUri.GetLeftPart(UriPartial.Path); var url = absoluteUri.GetLeftPart(UriPartial.Path);
if (seen.Add(url)) if (seen.Add(url))
results.Add(url); results.Add(url);
@@ -122,4 +116,61 @@ public sealed class HtmlJobSearcher
return results; 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;
}
}
} }
+12 -5
View File
@@ -204,20 +204,27 @@ public sealed class CvSearchJobTask : IJobTask
/// <summary> /// <summary>
/// Deserialises the provider configuration snapshot stored on the session. /// Deserialises the provider configuration snapshot stored on the session.
/// Falls back to the current live config when the snapshot is absent or unparseable. /// Providers are always snapshotted from the DB at session-creation time, so the snapshot
/// should always be present. Returns an empty list (with a warning) when it is missing or corrupt.
/// </summary> /// </summary>
private List<JobProviderConfig> GetProviders(string? providerConfigJson) private List<JobProviderConfig> GetProviders(string? providerConfigJson)
{ {
if (string.IsNullOrWhiteSpace(providerConfigJson)) return _settings.Providers.Where(p => p.Enabled).ToList(); if (string.IsNullOrWhiteSpace(providerConfigJson))
{
_logger.LogWarning("Session has no provider config snapshot — returning empty provider list");
return [];
}
try try
{ {
return JsonSerializer.Deserialize<List<JobProviderConfig>>(providerConfigJson, return JsonSerializer.Deserialize<List<JobProviderConfig>>(providerConfigJson,
new JsonSerializerOptions(JsonSerializerDefaults.Web)) new JsonSerializerOptions(JsonSerializerDefaults.Web))
?? _settings.Providers.Where(p => p.Enabled).ToList(); ?? [];
} }
catch catch (Exception ex)
{ {
return _settings.Providers.Where(p => p.Enabled).ToList(); _logger.LogWarning(ex, "Failed to deserialise provider config snapshot — returning empty provider list");
return [];
} }
} }
+1 -27
View File
@@ -103,33 +103,7 @@
"JobSearchLinkBaseUrl": "https://myai.ro", "JobSearchLinkBaseUrl": "https://myai.ro",
"TokenExpiryDays": 7, "TokenExpiryDays": 7,
"MinMatchScore": 15, "MinMatchScore": 15,
"MaxJobsToMatch": 15, "MaxJobsToMatch": 15
"Providers": [
{
"Name": "ejobs.ro",
"Enabled": false,
"SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/",
"JobLinkContains": "/user/locuri-de-munca/job/",
"InitialKeywords": [],
"MaxResults": 20
},
{
"Name": "bestjobs.eu",
"Enabled": false,
"SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}",
"JobLinkContains": "/ro/locuri-de-munca/",
"InitialKeywords": [],
"MaxResults": 20
},
{
"Name": "linkedin.com",
"Enabled": false,
"SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania",
"JobLinkContains": "/jobs/view/",
"InitialKeywords": [],
"MaxResults": 20
}
]
}, },
"Jobs": { "Jobs": {
"Tasks": [ "Tasks": [
+1
View File
@@ -13,6 +13,7 @@
<PackageReference Include="Microsoft.Extensions.Hosting" /> <PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Refit.HttpClientFactory" /> <PackageReference Include="Refit.HttpClientFactory" />
<PackageReference Include="Microsoft.Playwright" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>