어린이날 기념 커밋

This commit is contained in:
2026-05-05 00:50:11 +09:00
parent e40a2a568e
commit 960163dad8
29 changed files with 4399 additions and 463 deletions

View File

@@ -259,10 +259,48 @@ function Normalize-CompactText {
return ($Value -replace '\s+', [string]::Empty).Trim()
}
function Strip-BasicDistrictDisambiguation {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value))
{
return [string]::Empty
}
$normalized = $Value.Trim()
$regionLabels = @(
"%EC%84%9C%EC%9A%B8",
"%EB%B6%80%EC%82%B0",
"%EB%8C%80%EA%B5%AC",
"%EC%9D%B8%EC%B2%9C",
"%EA%B4%91%EC%A3%BC",
"%EB%8C%80%EC%A0%84",
"%EC%9A%B8%EC%82%B0",
"%EC%84%B8%EC%A2%85",
"%EA%B2%BD%EA%B8%B0",
"%EA%B0%95%EC%9B%90",
"%EC%B6%A9%EB%B6%81",
"%EC%B6%A9%EB%82%A8",
"%EC%A0%84%EB%B6%81",
"%EC%A0%84%EB%82%A8",
"%EA%B2%BD%EB%B6%81",
"%EA%B2%BD%EB%82%A8",
"%EC%A0%9C%EC%A3%BC"
)
foreach ($encodedLabel in $regionLabels)
{
$label = Decode-Text $encodedLabel
$normalized = $normalized.Replace("($label)", [string]::Empty)
}
return $normalized.Replace("()", [string]::Empty).Trim()
}
function Normalize-BasicDistrictToken {
param([string]$Value)
$normalized = Normalize-CompactText -Value $Value
$normalized = Normalize-CompactText -Value (Strip-BasicDistrictDisambiguation -Value $Value)
if ([string]::IsNullOrWhiteSpace($normalized))
{
return [string]::Empty
@@ -272,9 +310,32 @@ function Normalize-BasicDistrictToken {
$normalized = $normalized.Replace($(Decode-Text "%EA%B5%B0%EC%88%98"), $(Decode-Text "%EA%B5%B0"))
$normalized = $normalized.Replace($(Decode-Text "%EC%8B%9C%EC%9E%A5"), $(Decode-Text "%EC%8B%9C"))
$normalized = $normalized.Replace($(Decode-Text "%EA%B5%90%EC%9C%A1%EA%B0%90"), [string]::Empty)
$normalized = $normalized.Replace("()", [string]::Empty)
return $normalized.Trim()
}
function Normalize-BasicDistrictDisplayName {
param(
[string]$DistrictName,
[string]$DisplayName,
[string]$RegionName
)
$normalized = if ([string]::IsNullOrWhiteSpace($DistrictName)) { $DisplayName } else { $DistrictName }
if ([string]::IsNullOrWhiteSpace($normalized))
{
return [string]::Empty
}
$normalized = $normalized.Trim()
if (-not [string]::IsNullOrWhiteSpace($RegionName) -and $normalized.StartsWith($RegionName.Trim(), [System.StringComparison]::Ordinal))
{
$normalized = $normalized.Substring($RegionName.Trim().Length).Trim()
}
return Strip-BasicDistrictDisambiguation -Value $normalized
}
function New-OfficialWinnerEntry {
param(
[pscustomobject]$Cycle,
@@ -977,6 +1038,8 @@ foreach ($region in $regions)
continue
}
$rawDistrictName = $districtName
$districtName = Normalize-BasicDistrictDisplayName -DistrictName $districtName -DisplayName ([string]$winnerItem.wiwName) -RegionName $regionDisplayName
$districtKey = Normalize-BasicDistrictToken -Value $districtName
if ([string]::IsNullOrWhiteSpace($districtKey))
{
@@ -998,7 +1061,7 @@ foreach ($region in $regions)
$record = $basicRecordsByKey[$recordKey]
Add-HistoryEntry -Target $record.WinnerHistory -Entry (New-OfficialWinnerEntry -Cycle $cycle -Item $winnerItem -SourceUrl $winnerSourceUrl)
$turnoutSnapshot = Resolve-BasicTurnoutSnapshot -DistrictName $districtName -TurnoutItems $turnoutDetails
$turnoutSnapshot = Resolve-BasicTurnoutSnapshot -DistrictName $rawDistrictName -TurnoutItems $turnoutDetails
if ($null -ne $turnoutSnapshot)
{
Add-HistoryEntry -Target $record.TurnoutHistory -Entry (New-OfficialTurnoutEntry -Cycle $cycle -Electors $turnoutSnapshot.Electors -Votes $turnoutSnapshot.Votes -SourceUrl $turnoutSourceUrl)

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Security.Cryptography;
using Tornado3_2026Election.Domain;
using Tornado3_2026Election.Services;
@@ -59,6 +60,7 @@ internal static class CurrentApiCutDiagnostics
Console.WriteLine($"- Region Scope: {options.RegionScope}");
Console.WriteLine($"- Max Regions: {(options.MaxRegions <= 0 ? "all" : options.MaxRegions)}");
Console.WriteLine($"- Send Mode: {ResolveSendModeLabel(options)}");
Console.WriteLine($"- Scene Capture: {(options.CaptureSceneImages ? "on" : "off")}");
Console.WriteLine($"- Output: {options.OutputPath}");
var stationCatalog = new StationCatalogService().GetAll();
@@ -142,6 +144,66 @@ internal static class CurrentApiCutDiagnostics
continue;
}
if (ShouldUseAggregateTurnoutSnapshot(template, phase, electionType))
{
var result = new CurrentApiCutDiagnosticResult
{
Station = station.Id,
Channel = template.RecommendedChannel.ToString(),
TemplateId = template.Id,
TemplateName = template.Name,
Phase = phase.ToString(),
ElectionType = electionType,
Region = string.Join(", ", targets.Select(target => target.DisplayName)),
DistrictCode = string.Join(",", targets.Select(target => target.DistrictCode)),
Status = "unknown"
};
try
{
var snapshot = await CreateAggregateTurnoutSnapshotAsync(
apiClient,
electionType,
districts,
targets,
template,
CancellationToken.None)
.ConfigureAwait(false);
PopulateDataFields(result, snapshot, "GET /tupyo aggregate overview");
if (!ValidateSnapshotForFormat(template, snapshot, out var validationError, out var warning))
{
result.Status = "validation-failed";
result.Detail = validationError;
result.Warning = warning;
}
else if (adapter is not null && simulatedSendCount < options.SendLimit)
{
await SimulateSendAsync(adapter, station, template, snapshot, options, result).ConfigureAwait(false);
simulatedSendCount++;
result.Status = options.LiveSend ? "sent-live" : "sent-mock";
result.Detail = options.LiveSend
? "validated and live send completed"
: "validated and mock send completed";
result.Warning = warning;
}
else
{
result.Status = "valid";
result.Detail = adapter is null ? "validated" : "validated; send limit reached";
result.Warning = warning;
}
}
catch (Exception ex)
{
result.Status = "api-or-send-failed";
result.Detail = ex.Message;
}
results.Add(result);
continue;
}
foreach (var target in targets)
{
var result = new CurrentApiCutDiagnosticResult
@@ -186,7 +248,7 @@ internal static class CurrentApiCutDiagnostics
}
else if (adapter is not null && simulatedSendCount < options.SendLimit)
{
await SimulateSendAsync(adapter, station, template, snapshot, options.ImageRootPath).ConfigureAwait(false);
await SimulateSendAsync(adapter, station, template, snapshot, options, result).ConfigureAwait(false);
simulatedSendCount++;
result.Status = options.LiveSend ? "sent-live" : "sent-mock";
result.Detail = options.LiveSend
@@ -259,6 +321,194 @@ internal static class CurrentApiCutDiagnostics
: $"mock ({options.SendLimit})";
}
private static async Task<ElectionDataSnapshot> CreateAggregateTurnoutSnapshotAsync(
SbsElectionApiClient apiClient,
string electionType,
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> allDistricts,
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> selectedDistricts,
FormatTemplateDefinition template,
CancellationToken cancellationToken)
{
var overview = await apiClient
.GetTurnoutOverviewAsync(electionType, allDistricts, cancellationToken)
.ConfigureAwait(false);
var primaryDistrict = selectedDistricts.FirstOrDefault();
var primaryItem = FindTurnoutOverviewItem(overview.Items, primaryDistrict);
var includeNationalSlot = IsBottomTurnoutBoardTemplate(template);
var maxRegionalSlots = includeNationalSlot ? 4 : 7;
var turnoutBoardSlots = new List<TurnoutBoardSlotEntry>();
if (includeNationalSlot)
{
turnoutBoardSlots.Add(new TurnoutBoardSlotEntry(1, "전국", overview.NationalTurnoutRate, true));
}
var nextSlot = turnoutBoardSlots.Count + 1;
foreach (var district in selectedDistricts.Take(maxRegionalSlots))
{
var item = FindTurnoutOverviewItem(overview.Items, district);
if (item is null || item.TurnoutVotes <= 0 || item.TurnoutRate <= 0)
{
continue;
}
turnoutBoardSlots.Add(new TurnoutBoardSlotEntry(
nextSlot++,
ResolveTurnoutBoardDistrictLabel(electionType, item, district),
item.TurnoutRate,
RegionLabel: ResolveTurnoutBoardRegionLabel(item, district)));
}
if (turnoutBoardSlots.Count == (includeNationalSlot ? 1 : 0))
{
throw new InvalidOperationException("No positive turnout board slots were available.");
}
var regionName = primaryItem?.RegionName ?? primaryDistrict?.RegionName ?? string.Empty;
var districtName = primaryItem?.DisplayName ?? primaryDistrict?.DisplayName ?? regionName;
var electionDistrictName = ResolveTurnoutElectionDistrictName(
electionType,
primaryItem,
primaryDistrict,
regionName,
districtName);
var totalExpectedVotes = includeNationalSlot
? overview.TotalExpectedVotes
: primaryItem?.TotalExpectedVotes ?? 0;
var turnoutVotes = includeNationalSlot
? overview.TurnoutVotes
: primaryItem?.TurnoutVotes ?? 0;
return new ElectionDataSnapshot
{
BroadcastPhase = BroadcastPhase.PreElection,
ElectionType = electionType,
DistrictName = string.IsNullOrWhiteSpace(districtName) ? regionName : districtName,
DistrictCode = primaryItem?.DistrictCode ?? primaryDistrict?.DistrictCode ?? string.Empty,
RegionName = regionName,
ElectionDistrictName = electionDistrictName,
Candidates = Array.Empty<CandidateEntry>(),
TotalExpectedVotes = Math.Max(0, totalExpectedVotes),
TurnoutVotes = Math.Max(0, turnoutVotes),
CountedVotesFromApi = null,
RemainingVotesFromApi = null,
CountedRateFromApi = null,
ReceivedAt = DateTimeOffset.Now,
TurnoutBoardSlots = turnoutBoardSlots,
NationalTurnoutRateOverride = overview.NationalTurnoutRate
};
}
private static bool ShouldUseAggregateTurnoutSnapshot(
FormatTemplateDefinition template,
BroadcastPhase phase,
string electionType)
{
return phase == BroadcastPhase.PreElection &&
SupportsPreElectionTurnout(electionType) &&
(IsBottomTurnoutBoardTemplate(template) || IsRegionalTurnoutBoardTemplate(template));
}
private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.Bottom &&
(string.Equals(template.Name, "사전투표율", StringComparison.Ordinal) ||
string.Equals(template.Name, "투표율", StringComparison.Ordinal));
}
private static bool IsRegionalTurnoutBoardTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.Normal &&
string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal);
}
private static SbsElectionApiClient.TurnoutOverviewItem? FindTurnoutOverviewItem(
IReadOnlyList<SbsElectionApiClient.TurnoutOverviewItem> items,
SbsElectionApiClient.DistrictSelectionOption? district)
{
if (district is null || items.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(district.DistrictCode))
{
var matchedByCode = items.FirstOrDefault(item =>
string.Equals(item.DistrictCode, district.DistrictCode, StringComparison.OrdinalIgnoreCase));
if (matchedByCode is not null)
{
return matchedByCode;
}
}
return items.FirstOrDefault(item =>
string.Equals(item.RegionName, district.RegionName, StringComparison.Ordinal) ||
string.Equals(item.DisplayName, district.DisplayName, StringComparison.Ordinal) ||
string.Equals(item.DistrictName, district.DistrictName, StringComparison.Ordinal));
}
private static string ResolveTurnoutElectionDistrictName(
string electionType,
SbsElectionApiClient.TurnoutOverviewItem? item,
SbsElectionApiClient.DistrictSelectionOption? district,
string regionName,
string districtName)
{
if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal))
{
return FirstNonWhiteSpace(
item?.DistrictName,
district?.DistrictName,
districtName,
regionName);
}
return string.IsNullOrWhiteSpace(regionName) ? districtName : regionName;
}
private static string ResolveTurnoutBoardDistrictLabel(
string electionType,
SbsElectionApiClient.TurnoutOverviewItem item,
SbsElectionApiClient.DistrictSelectionOption district)
{
if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal))
{
return FirstNonWhiteSpace(
item.DistrictName,
district.DistrictName,
item.DisplayName,
district.DisplayName,
item.RegionName,
district.RegionName);
}
return ResolveTurnoutBoardRegionLabel(item, district);
}
private static string ResolveTurnoutBoardRegionLabel(
SbsElectionApiClient.TurnoutOverviewItem item,
SbsElectionApiClient.DistrictSelectionOption district)
{
return FirstNonWhiteSpace(
item.RegionName,
district.RegionName,
item.DisplayName,
district.DisplayName);
}
private static string FirstNonWhiteSpace(params string?[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return string.Empty;
}
private static async Task<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetDistrictsAsync(
SbsElectionApiClient apiClient,
IDictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> districtCache,
@@ -367,7 +617,7 @@ internal static class CurrentApiCutDiagnostics
DistrictName = districtName,
DistrictCode = target.DistrictCode,
RegionName = regionName,
ElectionDistrictName = string.IsNullOrWhiteSpace(regionName) ? districtName : regionName,
ElectionDistrictName = ResolveHistoricalElectionDistrictName(electionType, regionName, districtName),
Candidates = Array.Empty<CandidateEntry>(),
TotalExpectedVotes = 0,
TurnoutVotes = 0,
@@ -382,12 +632,26 @@ internal static class CurrentApiCutDiagnostics
};
}
private static string ResolveHistoricalElectionDistrictName(
string electionType,
string regionName,
string districtName)
{
if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal))
{
return string.IsNullOrWhiteSpace(districtName) ? regionName : districtName;
}
return string.IsNullOrWhiteSpace(regionName) ? districtName : regionName;
}
private static async Task SimulateSendAsync(
ITornado3Adapter adapter,
BroadcastStationProfile station,
FormatTemplateDefinition template,
ElectionDataSnapshot snapshot,
string imageRootPath)
CurrentApiCutDiagnosticsOptions options,
CurrentApiCutDiagnosticResult result)
{
foreach (var cut in template.Cuts)
{
@@ -396,7 +660,7 @@ internal static class CurrentApiCutDiagnostics
{
try
{
await SendSingleCutAsync(adapter, station, template, cut, snapshot, imageRootPath).ConfigureAwait(false);
await SendSingleCutAsync(adapter, station, template, cut, snapshot, options, result).ConfigureAwait(false);
lastException = null;
break;
}
@@ -425,18 +689,20 @@ internal static class CurrentApiCutDiagnostics
FormatTemplateDefinition template,
FormatCutDefinition cut,
ElectionDataSnapshot snapshot,
string imageRootPath)
CurrentApiCutDiagnosticsOptions options,
CurrentApiCutDiagnosticResult result)
{
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "connect");
try
{
await adapter.ApplyCutAsync(template.RecommendedChannel, template, cut, snapshot, station, imageRootPath, CancellationToken.None).ConfigureAwait(false);
await adapter.ApplyCutAsync(template.RecommendedChannel, template, cut, snapshot, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "apply");
await adapter.PrepareAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "prepare");
await adapter.TakeAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "take");
await CaptureSceneImageIfRequestedAsync(adapter, template, cut, options, result).ConfigureAwait(false);
}
finally
{
@@ -464,6 +730,83 @@ internal static class CurrentApiCutDiagnostics
}
}
private static async Task CaptureSceneImageIfRequestedAsync(
ITornado3Adapter adapter,
FormatTemplateDefinition template,
FormatCutDefinition cut,
CurrentApiCutDiagnosticsOptions options,
CurrentApiCutDiagnosticResult result)
{
if (!options.CaptureSceneImages || adapter is not KarismaTornado3Adapter karismaAdapter)
{
return;
}
var captureDirectory = Path.Combine(options.OutputPath, "captures");
Directory.CreateDirectory(captureDirectory);
var districtToken = result.DistrictCode.Replace(",", "-", StringComparison.Ordinal);
if (districtToken.Length > 40)
{
districtToken = districtToken[..40];
}
var fileStem = SanitizeFileName(
$"{result.Station}_{result.ElectionType}_{template.Name}_{districtToken}_{cut.Name}");
var outputPath = Path.GetFullPath(Path.Combine(captureDirectory, $"{fileStem}.png"));
var (width, height) = ResolveSceneCaptureSize(template);
await karismaAdapter.SavePendingSceneImageAsync(
template.RecommendedChannel,
outputPath,
width,
height,
frame: -1,
CancellationToken.None)
.ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "capture");
result.CapturePath = outputPath;
result.CaptureHash = ComputeSha256(outputPath);
result.CaptureBytes = new FileInfo(outputPath).Length;
}
private static (int Width, int Height) ResolveSceneCaptureSize(FormatTemplateDefinition template)
{
var sourceWidth = template.SceneWidth.GetValueOrDefault(1920);
var sourceHeight = template.SceneHeight.GetValueOrDefault(1080);
if (sourceWidth <= 0 || sourceHeight <= 0)
{
return (1280, 720);
}
const int maxWidth = 1280;
if (sourceWidth <= maxWidth)
{
return (sourceWidth, sourceHeight);
}
var scale = maxWidth / (double)sourceWidth;
return (maxWidth, Math.Max(1, (int)Math.Round(sourceHeight * scale, MidpointRounding.AwayFromZero)));
}
private static string ComputeSha256(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream));
}
private static string SanitizeFileName(string value)
{
var invalidChars = Path.GetInvalidFileNameChars();
var sanitized = new string(value.Select(character => invalidChars.Contains(character) ? '_' : character).ToArray()).Trim();
if (sanitized.Length > 80)
{
sanitized = sanitized[..80];
}
return string.IsNullOrWhiteSpace(sanitized) ? "capture.png" : sanitized;
}
private static void ThrowIfAdapterErrored(ITornado3Adapter adapter, string action)
{
if (adapter.State == TornadoConnectionState.Error)
@@ -688,7 +1031,21 @@ internal static class CurrentApiCutDiagnostics
return "광역단체장";
}
return phase == BroadcastPhase.PreElection ? "광역단체장" : defaultElectionType;
if (phase == BroadcastPhase.PreElection)
{
return SupportsPreElectionTurnout(defaultElectionType)
? defaultElectionType
: "광역단체장";
}
return defaultElectionType;
}
private static bool SupportsPreElectionTurnout(string? electionType)
{
return string.Equals(electionType, "광역단체장", StringComparison.Ordinal) ||
string.Equals(electionType, "교육감", StringComparison.Ordinal) ||
string.Equals(electionType, "기초단체장", StringComparison.Ordinal);
}
private static string NormalizeRegion(string? regionName)
@@ -816,6 +1173,8 @@ internal static class CurrentApiCutDiagnostics
public bool LiveSend { get; init; }
public bool CaptureSceneImages { get; init; }
public int SendLimit { get; init; } = 24;
public string ImageRootPath { get; init; } = TornadoPathResolver.GetDefaultT3CutPath();
@@ -840,6 +1199,7 @@ internal static class CurrentApiCutDiagnostics
var excludeFilter = string.Empty;
var simulateSend = true;
var liveSend = false;
var captureSceneImages = false;
var sendLimit = 24;
var outputPath = Path.Combine(
"artifacts",
@@ -892,6 +1252,9 @@ internal static class CurrentApiCutDiagnostics
simulateSend = true;
liveSend = true;
break;
case "--capture-scene-images":
captureSceneImages = true;
break;
case "--send-limit":
if (int.TryParse(NextValue(), out var parsedSendLimit))
{
@@ -923,9 +1286,10 @@ internal static class CurrentApiCutDiagnostics
ExcludeFilter = excludeFilter,
SimulateSend = simulateSend,
LiveSend = liveSend,
CaptureSceneImages = captureSceneImages,
SendLimit = sendLimit,
ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
OutputPath = outputPath,
OutputPath = Path.GetFullPath(outputPath),
DefaultElectionType = defaultElectionType
};
}
@@ -966,6 +1330,12 @@ internal static class CurrentApiCutDiagnostics
public string SourcePath { get; set; } = string.Empty;
public string CapturePath { get; set; } = string.Empty;
public string CaptureHash { get; set; } = string.Empty;
public long CaptureBytes { get; set; }
public int CandidateCount { get; set; }
public int PositiveCandidateVoteCount { get; set; }