Fixed scanning issue. Updated worker.
Some checks failed
ci / build-test (push) Has been cancelled
ci / publish-image (push) Has been cancelled

This commit is contained in:
2026-02-14 22:47:19 -05:00
parent 340c62d18b
commit a85989a337
14 changed files with 286 additions and 36 deletions

View File

@@ -48,6 +48,7 @@ public static class InfrastructureServiceCollectionExtensions
services.AddKeyedScoped<IVoiceWorkUpdater, VoiceWorkUpdater>(Locale.Japanese); services.AddKeyedScoped<IVoiceWorkUpdater, VoiceWorkUpdater>(Locale.Japanese);
services.AddKeyedScoped<IVoiceWorkUpdater, EnglishVoiceWorkUpdater>(Locale.English); services.AddKeyedScoped<IVoiceWorkUpdater, EnglishVoiceWorkUpdater>(Locale.English);
services.AddScoped<IVoiceWorkSearchUpdater, VoiceWorkSearchUpdater>();
//services.AddKeyedScoped<ISupportedLanguage, JapaneseLanguage>(Locale.Japanese); //services.AddKeyedScoped<ISupportedLanguage, JapaneseLanguage>(Locale.Japanese);
//services.AddKeyedScoped<ISupportedLanguage, EnglishLanguage>(Locale.English); //services.AddKeyedScoped<ISupportedLanguage, EnglishLanguage>(Locale.English);
@@ -64,27 +65,34 @@ public static class InfrastructureServiceCollectionExtensions
services.AddSingleton<ICache, MemoryCacheAdapter>(); services.AddSingleton<ICache, MemoryCacheAdapter>();
services.AddSingleton<ISpamCircleCache, SpamCircleCache>(); services.AddSingleton<ISpamCircleCache, SpamCircleCache>();
services.AddHttpClient<IHttpService, HttpService>(client =>
{
client.DefaultRequestHeaders.UserAgent.ParseAdd("JSMR/1.0");
});
services.AddScoped<IHttpService, HttpService>();
services.AddScoped<IHtmlLoader, HtmlLoader>();
services.AddSingleton<ILanguageIdentifier, LanguageIdentifier>(); services.AddSingleton<ILanguageIdentifier, LanguageIdentifier>();
services.AddSingleton<IClock, Clock>(); services.AddSingleton<IClock, Clock>();
services.AddSingleton<ITimeProvider, TokyoTimeProvider>(); services.AddSingleton<ITimeProvider, TokyoTimeProvider>();
services.AddHttpClient<IDLSiteClient, DLSiteClient>(httpClient => services.AddHttpServices();
return services;
}
private static IServiceCollection AddHttpServices(this IServiceCollection services)
{ {
httpClient.BaseAddress = new Uri("https://www.dlsite.com/"); //services.AddHttpClient<IHttpService, HttpService>(client =>
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("JSMR/1.0 (+contact@example.com)"); //{
httpClient.Timeout = TimeSpan.FromSeconds(15); // client.DefaultRequestHeaders.UserAgent.ParseAdd("JSMR/1.0");
//});
//services.AddScoped<IHttpService, HttpService>();
services.AddScoped<IHtmlLoader, HtmlLoader>();
// ONE registration for IHttpService as a typed client:
services.AddHttpClient<IHttpService, HttpService>((sp, http) =>
{
http.BaseAddress = new Uri("https://www.dlsite.com/");
http.DefaultRequestHeaders.UserAgent.ParseAdd("JSMR/1.0");
http.Timeout = TimeSpan.FromSeconds(15);
}) })
.AddResilienceHandler("dlsite", builder => .AddResilienceHandler("dlsite", builder => {
{
builder.AddRetry(new HttpRetryStrategyOptions builder.AddRetry(new HttpRetryStrategyOptions
{ {
MaxRetryAttempts = 3, MaxRetryAttempts = 3,
@@ -93,20 +101,12 @@ public static class InfrastructureServiceCollectionExtensions
BackoffType = DelayBackoffType.Exponential, BackoffType = DelayBackoffType.Exponential,
ShouldHandle = new PredicateBuilder<HttpResponseMessage>() ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>() .Handle<HttpRequestException>()
.HandleResult(msg => .HandleResult(r => (int)r.StatusCode >= 500 || (int)r.StatusCode == 429)
msg.StatusCode == (HttpStatusCode)429 || });
(int)msg.StatusCode >= 500)
}); });
// (Optional) add a circuit breaker: // Register DLSiteClient as a normal scoped service
// builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions services.AddScoped<IDLSiteClient, DLSiteClient>();
// {
// FailureRatio = 0.2,
// SamplingDuration = TimeSpan.FromSeconds(30),
// MinimumThroughput = 20,
// BreakDuration = TimeSpan.FromSeconds(15)
// });
});
return services; return services;
} }

View File

@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
namespace JSMR.Infrastructure.Data;
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
// adjust base path if needed (points to the worker for secrets/env)
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Development.json", optional: true)
.AddUserSecrets(typeof(AppDbContextFactory).Assembly, optional: true)
.AddEnvironmentVariables()
.Build();
var conn = config.GetConnectionString("AppDb")
?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb");
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseMySql(conn, ServerVersion.AutoDetect(conn))
.EnableSensitiveDataLogging(false)
.Options;
return new AppDbContext(options);
}
}

View File

@@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging;
namespace JSMR.Infrastructure.Integrations.DLSite; namespace JSMR.Infrastructure.Integrations.DLSite;
public class DLSiteClient(IHttpService http, ILogger logger) : ApiClient(http, logger), IDLSiteClient public class DLSiteClient(IHttpService http, ILogger<DLSiteClient> logger) : ApiClient(http, logger), IDLSiteClient
{ {
public async Task<VoiceWorkDetailCollection> GetVoiceWorkDetailsAsync(string[] productIds, CancellationToken cancellationToken = default) public async Task<VoiceWorkDetailCollection> GetVoiceWorkDetailsAsync(string[] productIds, CancellationToken cancellationToken = default)
{ {

View File

@@ -6,8 +6,31 @@ namespace JSMR.Infrastructure.Integrations.DLSite.Serialization;
public sealed class DictionaryConverter<TKey, TValue> : JsonConverter<Dictionary<TKey, TValue>> where TKey : notnull public sealed class DictionaryConverter<TKey, TValue> : JsonConverter<Dictionary<TKey, TValue>> where TKey : notnull
{ {
public override Dictionary<TKey, TValue>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) public override Dictionary<TKey, TValue>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> JsonSerializer.Deserialize<Dictionary<TKey, TValue>>(ref reader, options); {
if (reader.TokenType == JsonTokenType.Null)
return null;
if (reader.TokenType == JsonTokenType.StartArray)
{
if (!reader.Read())
throw new JsonException("Unexpected end while reading array.");
if (reader.TokenType != JsonTokenType.EndArray)
throw new JsonException("Non-empty JSON array cannot be converted to Dictionary.");
return [];
}
if (reader.TokenType == JsonTokenType.StartObject)
{
return JsonSerializer.Deserialize<Dictionary<TKey, TValue>>(ref reader, options) ?? [];
}
throw new JsonException($"Unexpected token {reader.TokenType} when reading Dictionary.");
}
public override void Write(Utf8JsonWriter writer, Dictionary<TKey, TValue> value, JsonSerializerOptions options) public override void Write(Utf8JsonWriter writer, Dictionary<TKey, TValue> value, JsonSerializerOptions options)
=> JsonSerializer.Serialize(writer, value, options); {
JsonSerializer.Serialize(writer, (IDictionary<TKey, TValue>)value, options);
}
} }

View File

@@ -19,6 +19,9 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.10.0" /> <PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.10.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />

View File

@@ -32,9 +32,9 @@ public class DLSiteClientTests
var logger = Substitute.For<ILogger<DLSiteClient>>(); var logger = Substitute.For<ILogger<DLSiteClient>>();
var client = new DLSiteClient(httpService, logger); var client = new DLSiteClient(httpService, logger);
var result = await client.GetVoiceWorkDetailsAsync(["RJ01230163"], CancellationToken.None); var result = await client.GetVoiceWorkDetailsAsync(["RJ01230163", "RJ01536422"], CancellationToken.None);
result.Count.ShouldBe(1); result.Count.ShouldBe(2);
result.ShouldContainKey("RJ01230163"); result.ShouldContainKey("RJ01230163");
result["RJ01230163"].HasTrial.ShouldBeTrue(); result["RJ01230163"].HasTrial.ShouldBeTrue();
@@ -44,6 +44,9 @@ public class DLSiteClientTests
result["RJ01230163"].SupportedLanguages.Select(x => x.Language).ShouldContain(Language.English); result["RJ01230163"].SupportedLanguages.Select(x => x.Language).ShouldContain(Language.English);
result["RJ01230163"].DownloadCount.ShouldBe(659); result["RJ01230163"].DownloadCount.ShouldBe(659);
result["RJ01230163"].WishlistCount.ShouldBe(380); result["RJ01230163"].WishlistCount.ShouldBe(380);
// Testing this one for empty array of prices
result.ShouldContainKey("RJ01536422");
} }
[Fact] [Fact]

View File

@@ -219,5 +219,94 @@
} }
], ],
"default_point_str": "150" "default_point_str": "150"
},
"RJ01536422": {
"site_id": "home",
"site_id_touch": "hometouch",
"maker_id": "RG36156",
"age_category": 1,
"affiliate_deny": 0,
"dl_count": null,
"wishlist_count": 94,
"dl_format": 0,
"rank": [],
"rate_average": null,
"rate_average_2dp": null,
"rate_average_star": null,
"rate_count": null,
"rate_count_detail": [],
"review_count": 0,
"price": null,
"price_without_tax": null,
"price_str": "0",
"default_point_rate": 5,
"default_point": 0,
"product_point_rate": null,
"dlsiteplay_work": false,
"is_ana": true,
"is_sale": true,
"on_sale": 0,
"is_discount": false,
"is_pointup": false,
"gift": [],
"is_rental": false,
"work_rentals": [],
"upgrade_min_price": 110,
"down_url": "https:\/\/www.dlsite.com\/maniax\/download\/=\/product_id\/RJ01536422.html",
"is_tartget": null,
"title_id": null,
"title_name": null,
"title_name_masked": null,
"title_volumn": null,
"title_work_count": null,
"is_title_completed": false,
"bulkbuy_key": null,
"bonuses": [],
"is_limit_work": false,
"is_sold_out": false,
"limit_stock": 0,
"is_reserve_work": false,
"is_reservable": false,
"is_timesale": false,
"timesale_stock": 0,
"is_free": false,
"is_oly": false,
"is_led": false,
"is_noreduction": false,
"is_wcc": false,
"translation_info": {
"is_translation_agree": false,
"is_volunteer": false,
"is_original": true,
"is_parent": false,
"is_child": false,
"is_translation_bonus_child": false,
"original_workno": null,
"parent_workno": null,
"child_worknos": [],
"lang": null,
"production_trade_price_rate": 0,
"translation_bonus_langs": [],
"translation_status_for_translator": []
},
"work_name": "\u73c8\u7432\u5c4b \u7db4 \/ \u3044\u3064\u3082\u3044\u3064\u3067\u3082\u301cAlone with you\u301c",
"work_name_masked": "\u73c8\u7432\u5c4b \u7db4 \/ \u3044\u3064\u3082\u3044\u3064\u3067\u3082\u301cAlone with you\u301c",
"work_image": "\/\/img.dlsite.jp\/modpub\/images2\/ana\/doujin\/RJ01537000\/RJ01536422_ana_img_main.jpg",
"sales_end_info": null,
"voice_pack": null,
"regist_date": "2026-01-01 00:00:00",
"locale_price": [],
"locale_price_str": [],
"currency_price": null,
"work_type": "SOU",
"book_type": null,
"discount_calc_type": null,
"is_garumani_general": false,
"is_pack_work": false,
"limited_free_terms": [],
"official_price": null,
"options": "JPN#SND",
"custom_genres": [],
"default_point_str": "0"
} }
} }

View File

@@ -5,11 +5,30 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<UserSecretsId>f4ef1bd4-0cc5-4663-8108-fbb9c9eef5ae</UserSecretsId>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<None Remove="appsettings.json" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.Development.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.12" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.12" />
<PackageReference Include="Serilog" Version="4.3.0" /> <PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.2" /> <PackageReference Include="System.CommandLine" Version="2.0.2" />
</ItemGroup> </ItemGroup>

View File

@@ -2,7 +2,7 @@
public sealed class ScanOptions public sealed class ScanOptions
{ {
public string? Locale { get; init; } = "Japanese"; // maps to your Locale enum public string Locale { get; init; } = "Japanese"; // maps to your Locale enum
public int? StartPage { get; init; } // if null, resume from checkpoint or 1 public int? StartPage { get; init; } // if null, resume from checkpoint or 1
public int? EndPage { get; init; } // optional cap public int? EndPage { get; init; } // optional cap
public int? PageSize { get; init; } // override default public int? PageSize { get; init; } // override default

View File

@@ -11,16 +11,30 @@ using System.CommandLine;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
// Build a single configuration pipeline
builder.Configuration
.SetBasePath(builder.Environment.ContentRootPath)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
// Add user secrets (works regardless of environment when optional: true)
.AddUserSecrets(typeof(Program).Assembly, optional: true)
.AddEnvironmentVariables();
// Pull the connection string from config (appsettings or secrets or env)
string connectionString = builder.Configuration.GetConnectionString("AppDb")
?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb");
//builder.Services.AddSerilog(o => o //builder.Services.AddSerilog(o => o
// .WriteTo.Console() // .WriteTo.Console()
// .MinimumLevel.Information()); // .MinimumLevel.Information());
builder.Services builder.Services
.AddApplication() .AddApplication()
.AddInfrastructure(); .AddInfrastructure()
.AddMemoryCache();
string connectionString = builder.Configuration.GetConnectionString("AppDb") //string connectionString = builder.Configuration.GetConnectionString("AppDb")
?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb2"); // ?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb");
builder.Services.AddDbContextFactory<AppDbContext>(optionsBuilder => builder.Services.AddDbContextFactory<AppDbContext>(optionsBuilder =>
optionsBuilder optionsBuilder
@@ -106,4 +120,21 @@ rootCommand.Add(scan);
//rootCommand.SetAction(async (parseResult, cancellationToken) => await rootCommand.InvokeAsync("scan")); //rootCommand.SetAction(async (parseResult, cancellationToken) => await rootCommand.InvokeAsync("scan"));
Command schemaDumpCommand = new("schema-dump", "Emit EF model as a full create script (desired.sql)");
schemaDumpCommand.SetAction(async (parseResult, cancellationToken) =>
{
using var host = builder.Build();
await using var scope = host.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var sql = db.Database.GenerateCreateScript();
var outPath = Path.GetFullPath("desired.sql");
await File.WriteAllTextAsync(outPath, sql);
Console.WriteLine($"[OK] Wrote EF model create script to: {outPath}");
});
rootCommand.Add(schemaDumpCommand);
return await rootCommand.Parse(args).InvokeAsync(); return await rootCommand.Parse(args).InvokeAsync();

View File

@@ -0,0 +1,26 @@
{
"profiles": {
"Scan (JP, 1 page)": {
"commandName": "Project",
"commandLineArgs": "scan --locale Japanese --start 1 --end 1 --pageSize 100",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
},
"workingDirectory": ""
},
"Scan (EN, 3 pages)": {
"commandName": "Project",
"commandLineArgs": "scan --locale English --start 1 --end 3 --pageSize 100",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
},
"Watch (JP, every 5m)": {
"commandName": "Project",
"commandLineArgs": "scan --locale Japanese --start 1 --watch --every 00:05:00 --pageSize 100",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,8 +1,10 @@
using JSMR.Application.Enums; using JSMR.Application.Enums;
using JSMR.Application.Scanning; using JSMR.Application.Scanning;
using JSMR.Infrastructure.Common.Time;
using JSMR.Worker.Options; using JSMR.Worker.Options;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Globalization;
namespace JSMR.Worker.Services; namespace JSMR.Worker.Services;
@@ -21,6 +23,10 @@ public sealed class PagedScanRunner(
int startPage = options.StartPage int startPage = options.StartPage
?? (await checkpoints.GetLastPageAsync(options.Locale, cancellationToken)).GetValueOrDefault(0) + 1; ?? (await checkpoints.GetLastPageAsync(options.Locale, cancellationToken)).GetValueOrDefault(0) + 1;
ITimeProvider timeProvider = serviceProvider.GetRequiredService<ITimeProvider>();
log.LogInformation("Scanning on {ScanTime}...", timeProvider.Now().DateTime.ToString(CultureInfo.CurrentCulture));
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
{ {
int currentPage = startPage; int currentPage = startPage;
@@ -29,7 +35,8 @@ public sealed class PagedScanRunner(
// Iterate until empty page or end reached // Iterate until empty page or end reached
for (; !cancellationToken.IsCancellationRequested && (!end.HasValue || currentPage <= end.Value); currentPage++) for (; !cancellationToken.IsCancellationRequested && (!end.HasValue || currentPage <= end.Value); currentPage++)
{ {
ScanVoiceWorksHandler handler = serviceProvider.GetRequiredService<ScanVoiceWorksHandler>(); using var scope = serviceProvider.CreateScope();
ScanVoiceWorksHandler handler = scope.ServiceProvider.GetRequiredService<ScanVoiceWorksHandler>();
log.LogInformation("Scanning page {Page} (size {Size}, locale {Locale})…", currentPage, pageSize, locale); log.LogInformation("Scanning page {Page} (size {Size}, locale {Locale})…", currentPage, pageSize, locale);

View File

@@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"AppDb": "Server=localhost;Port=3306;User=root;Password=password;database=VoiceWorks;SslMode=none"
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}