Files
Tornado3_2026Election/tools/BuildPreElectionHistorySeed.ps1
2026-04-20 20:06:18 +09:00

1045 lines
35 KiB
PowerShell

[CmdletBinding()]
param(
[string]$OutputPath = "..\\Tornado3_2026Election\\Assets\\Data\\pre_election_history.json",
[string]$ServiceKey = $env:NEC_SERVICE_KEY
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
if ([string]::IsNullOrWhiteSpace($ServiceKey))
{
throw "NEC_SERVICE_KEY environment variable or -ServiceKey is required."
}
function Decode-Text {
param([string]$Value)
return [uri]::UnescapeDataString($Value)
}
function Get-FirstValue {
param(
[System.Collections.IDictionary]$Table,
[string[]]$Keys
)
foreach ($key in $Keys)
{
if ($Table.Contains($key) -and $null -ne $Table[$key])
{
$value = [string]$Table[$key]
if (-not [string]::IsNullOrWhiteSpace($value))
{
return $value
}
}
}
return [string]::Empty
}
function Get-XmlItems {
param([xml]$Xml)
if ($null -eq $Xml)
{
return @()
}
$itemNodes = $Xml.SelectNodes("//item")
if ($null -eq $itemNodes)
{
return @()
}
return @($itemNodes)
}
function Invoke-NecXml {
param(
[string]$ServicePath,
[hashtable]$Parameters
)
$pairs = [System.Collections.Generic.List[string]]::new()
$pairs.Add("serviceKey=$([uri]::EscapeDataString($ServiceKey))")
foreach ($key in ($Parameters.Keys | Sort-Object))
{
$rawValue = $Parameters[$key]
if ($null -eq $rawValue)
{
continue
}
$stringValue = [string]$rawValue
if ([string]::IsNullOrWhiteSpace($stringValue))
{
continue
}
$pairs.Add("$([uri]::EscapeDataString($key))=$([uri]::EscapeDataString($stringValue))")
}
$uri = "https://apis.data.go.kr/9760000/${ServicePath}?$($pairs -join '&')"
for ($attempt = 1; $attempt -le 3; $attempt++)
{
try
{
[xml]$xml = (Invoke-WebRequest -UseBasicParsing -Uri $uri).Content
$resultCode = [string]$xml.response.header.resultCode
if (-not [string]::IsNullOrWhiteSpace($resultCode) -and $resultCode -notin @("00", "INFO-00", "INFO-03"))
{
$resultMessage = [string]$xml.response.header.resultMsg
throw "API failure [$resultCode] $resultMessage"
}
return $xml
}
catch
{
if ($attempt -ge 3)
{
throw
}
Start-Sleep -Milliseconds (250 * $attempt)
}
}
throw "Failed to call $ServicePath"
}
function Get-LocalElectionCycles {
param(
[System.Collections.IEnumerable]$CodeItems,
[string]$SgTypeCode
)
$localElectionPattern = '^' +
[regex]::Escape($(Decode-Text "%EC%A0%9C")) +
'(?<order>\d+)' +
[regex]::Escape($(Decode-Text "%ED%9A%8C%20%EC%A0%84%EA%B5%AD%EB%8F%99%EC%8B%9C%EC%A7%80%EB%B0%A9%EC%84%A0%EA%B1%B0")) +
'$'
$cycleMap = @{}
foreach ($item in $CodeItems)
{
$sgName = [string]$item.sgName
if ($sgName -notmatch $localElectionPattern)
{
continue
}
if ([string]$item.sgTypecode -ne $SgTypeCode)
{
continue
}
$sgId = [string]$item.sgId
if ([string]::IsNullOrWhiteSpace($sgId) -or [int]$sgId -gt 20220601)
{
continue
}
$order = [int]$Matches["order"]
$cycleMap[$order] = [pscustomobject]@{
Order = $order
Year = [int]$sgId.Substring(0, 4)
SgId = $sgId
SgTypeCode = $SgTypeCode
}
}
return @($cycleMap.Values | Sort-Object Order)
}
function New-SourceReference {
param(
[string]$Title,
[string]$Url
)
return [pscustomobject][ordered]@{
Title = $Title
Url = $Url
}
}
function Get-RegionApiNames {
param([object]$Region)
$names = [System.Collections.Generic.List[string]]::new()
$primaryName = Decode-Text $Region.DisplayName
if (-not [string]::IsNullOrWhiteSpace($primaryName))
{
$names.Add($primaryName)
}
if ($Region.Contains("ApiNames"))
{
foreach ($encodedName in $Region.ApiNames)
{
$decodedName = Decode-Text $encodedName
if (-not [string]::IsNullOrWhiteSpace($decodedName) -and -not $names.Contains($decodedName))
{
$names.Add($decodedName)
}
}
}
return @($names)
}
function New-HistoryRecord {
param(
[string]$ElectionType,
[string]$Key,
[string]$RegionKey,
[string]$RegionName,
[string]$DistrictName,
[string]$DisplayName
)
return [ordered]@{
ElectionType = $ElectionType
Key = $Key
RegionKey = $RegionKey
RegionName = $RegionName
DistrictName = $DistrictName
DisplayName = $DisplayName
TurnoutHistory = [System.Collections.Generic.List[object]]::new()
WinnerHistory = [System.Collections.Generic.List[object]]::new()
}
}
function Convert-RecordForJson {
param([System.Collections.IDictionary]$Record)
return [ordered]@{
ElectionType = $Record.ElectionType
Key = $Record.Key
RegionKey = $Record.RegionKey
RegionName = $Record.RegionName
DistrictName = $Record.DistrictName
DisplayName = $Record.DisplayName
TurnoutHistory = @($Record.TurnoutHistory | Sort-Object ElectionOrder)
WinnerHistory = @($Record.WinnerHistory | Sort-Object ElectionOrder)
}
}
function Add-HistoryEntry {
param(
[System.Collections.Generic.List[object]]$Target,
[object]$Entry
)
if ($null -eq $Entry)
{
return
}
$existing = $Target | Where-Object { $_.ElectionOrder -eq $Entry.ElectionOrder } | Select-Object -First 1
if ($null -eq $existing)
{
$Target.Add($Entry)
}
}
function Normalize-CompactText {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value))
{
return [string]::Empty
}
return ($Value -replace '\s+', [string]::Empty).Trim()
}
function Normalize-BasicDistrictToken {
param([string]$Value)
$normalized = Normalize-CompactText -Value $Value
if ([string]::IsNullOrWhiteSpace($normalized))
{
return [string]::Empty
}
$normalized = $normalized.Replace($(Decode-Text "%EA%B5%AC%EC%B2%AD%EC%9E%A5"), $(Decode-Text "%EA%B5%AC"))
$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)
return $normalized.Trim()
}
function New-OfficialWinnerEntry {
param(
[pscustomobject]$Cycle,
[object]$Item,
[string]$SourceUrl
)
if ($null -eq $Item)
{
return $null
}
$name = ([string]$Item.name).Trim()
if ([string]::IsNullOrWhiteSpace($name))
{
return $null
}
return [ordered]@{
ElectionOrder = $Cycle.Order
Year = $Cycle.Year
Name = $name
Party = ([string]$Item.jdName).Trim()
Note = [string]::Empty
SourceUrl = $SourceUrl
}
}
function New-OfficialTurnoutEntry {
param(
[pscustomobject]$Cycle,
[int]$Electors,
[int]$Votes,
[string]$SourceUrl
)
if ($Electors -le 0 -or $Votes -le 0)
{
return $null
}
return [ordered]@{
ElectionOrder = $Cycle.Order
Year = $Cycle.Year
Electors = $Electors
Votes = $Votes
TurnoutRate = [Math]::Round(($Votes * 100.0) / $Electors, 1, [MidpointRounding]::AwayFromZero)
SourceUrl = $SourceUrl
}
}
function Resolve-BasicTurnoutSnapshot {
param(
[string]$DistrictName,
[object[]]$TurnoutItems
)
$normalizedTarget = Normalize-CompactText -Value $DistrictName
if ([string]::IsNullOrWhiteSpace($normalizedTarget))
{
return $null
}
$exactMatches = @(
$TurnoutItems |
Where-Object { (Normalize-CompactText -Value ([string]$_.wiwName)) -eq $normalizedTarget }
)
$matchedItems = @(
if ($exactMatches.Count -gt 0)
{
$exactMatches
}
else
{
@(
$TurnoutItems |
Where-Object {
$normalizedWiwName = Normalize-CompactText -Value ([string]$_.wiwName)
-not [string]::IsNullOrWhiteSpace($normalizedWiwName) -and
$normalizedWiwName.StartsWith($normalizedTarget, [System.StringComparison]::Ordinal)
}
)
}
)
if ($matchedItems.Count -eq 0)
{
return $null
}
$electors = 0
$votes = 0
foreach ($matchedItem in $matchedItems)
{
$electors += Parse-PositiveInt -Value ([string]$matchedItem.totSunsu)
$votes += Parse-PositiveInt -Value ([string]$matchedItem.totTusu)
}
if ($electors -le 0 -or $votes -le 0)
{
return $null
}
return [pscustomobject]@{
Electors = $electors
Votes = $votes
}
}
$yearByOrder = @{
1 = 1995
2 = 1998
3 = 2002
4 = 2006
5 = 2010
6 = 2014
7 = 2018
8 = 2022
}
$governorType = Decode-Text "%EA%B4%91%EC%97%AD%EB%8B%A8%EC%B2%B4%EC%9E%A5"
$educationType = Decode-Text "%EA%B5%90%EC%9C%A1%EA%B0%90"
$basicType = Decode-Text "%EA%B8%B0%EC%B4%88%EB%8B%A8%EC%B2%B4%EC%9E%A5"
$redirectKeyword = Decode-Text "%EB%84%98%EA%B2%A8%EC%A3%BC%EA%B8%B0"
$headingPrefix = Decode-Text "%EC%A0%9C"
$headingSuffix = Decode-Text "%ED%9A%8C%20%EC%A0%84%EA%B5%AD%EB%8F%99%EC%8B%9C%EC%A7%80%EB%B0%A9%EC%84%A0%EA%B1%B0"
$winnerTemplatePrefix = Decode-Text "%EB%8B%B9%EC%84%A0%20%EC%84%A0%EA%B1%B0%EA%B2%B0%EA%B3%BC"
$turnoutStartTemplatePrefix = Decode-Text "%EC%84%A0%EA%B1%B0%EA%B2%B0%EA%B3%BC%20%EC%8B%9C%EC%9E%91"
$turnoutTotalTemplatePrefix = Decode-Text "%EC%84%A0%EA%B1%B0%EA%B2%B0%EA%B3%BC%20%ED%95%A9%EA%B3%84"
$candidateKey = Decode-Text "%ED%9B%84%EB%B3%B4"
$candidateNameKey = Decode-Text "%ED%9B%84%EB%B3%B4%EB%AA%85"
$partyKey = Decode-Text "%EC%A0%95%EB%8B%B9"
$partyNameKey = Decode-Text "%EC%A0%95%EB%8B%B9%EB%AA%85"
$noteKey = Decode-Text "%EB%B9%84%EA%B3%A0"
$electorsKey = Decode-Text "%EC%9C%A0%EA%B6%8C%EC%9E%90"
$totalElectorsKey = Decode-Text "%EC%B4%9D%EC%84%A0%EA%B1%B0%EC%9D%B8%EC%88%98"
$totalVotesKey = Decode-Text "%EC%B4%9D%ED%88%AC%ED%91%9C%EC%9E%90%EC%88%98"
$sumKey = Decode-Text "%ED%95%A9%EA%B3%84"
$totalLabel = Decode-Text "%ED%95%A9%EA%B3%84"
$electionHeadingPattern = [regex]::Escape($headingPrefix) + "(?<order>\d+)" + [regex]::Escape($headingSuffix)
$codeInfoSourceUrl = "https://www.data.go.kr/data/15000897/openapi.do"
$winnerSourceUrl = "https://www.data.go.kr/data/15000864/openapi.do"
$turnoutSourceUrl = "https://www.data.go.kr/data/15000900/openapi.do"
$officialSources = @(
(New-SourceReference -Title "NEC Code Info" -Url $codeInfoSourceUrl),
(New-SourceReference -Title "NEC Winner Info" -Url $winnerSourceUrl),
(New-SourceReference -Title "NEC Vote Turnout Info" -Url $turnoutSourceUrl)
)
$regions = @(
[ordered]@{ Key = "%EC%84%9C%EC%9A%B8"; DisplayName = "%EC%84%9C%EC%9A%B8%ED%8A%B9%EB%B3%84%EC%8B%9C"; Titles = @("%EC%84%9C%EC%9A%B8%ED%8A%B9%EB%B3%84%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
[ordered]@{ Key = "%EB%B6%80%EC%82%B0"; DisplayName = "%EB%B6%80%EC%82%B0%EA%B4%91%EC%97%AD%EC%8B%9C"; Titles = @("%EB%B6%80%EC%82%B0%EA%B4%91%EC%97%AD%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
[ordered]@{ Key = "%EB%8C%80%EA%B5%AC"; DisplayName = "%EB%8C%80%EA%B5%AC%EA%B4%91%EC%97%AD%EC%8B%9C"; Titles = @("%EB%8C%80%EA%B5%AC%EA%B4%91%EC%97%AD%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
[ordered]@{ Key = "%EC%9D%B8%EC%B2%9C"; DisplayName = "%EC%9D%B8%EC%B2%9C%EA%B4%91%EC%97%AD%EC%8B%9C"; Titles = @("%EC%9D%B8%EC%B2%9C%EA%B4%91%EC%97%AD%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
[ordered]@{ Key = "%EA%B4%91%EC%A3%BC"; DisplayName = "%EA%B4%91%EC%A3%BC%EA%B4%91%EC%97%AD%EC%8B%9C"; Titles = @("%EA%B4%91%EC%A3%BC%EA%B4%91%EC%97%AD%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
[ordered]@{ Key = "%EB%8C%80%EC%A0%84"; DisplayName = "%EB%8C%80%EC%A0%84%EA%B4%91%EC%97%AD%EC%8B%9C"; Titles = @("%EB%8C%80%EC%A0%84%EA%B4%91%EC%97%AD%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
[ordered]@{ Key = "%EC%9A%B8%EC%82%B0"; DisplayName = "%EC%9A%B8%EC%82%B0%EA%B4%91%EC%97%AD%EC%8B%9C"; Titles = @("%EC%9A%B8%EC%82%B0%EA%B4%91%EC%97%AD%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
[ordered]@{ Key = "%EC%84%B8%EC%A2%85"; DisplayName = "%EC%84%B8%EC%A2%85%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EC%8B%9C"; Titles = @("%EC%84%B8%EC%A2%85%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
[ordered]@{ Key = "%EA%B2%BD%EA%B8%B0"; DisplayName = "%EA%B2%BD%EA%B8%B0%EB%8F%84"; Titles = @("%EA%B2%BD%EA%B8%B0%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0") },
[ordered]@{ Key = "%EA%B0%95%EC%9B%90"; DisplayName = "%EA%B0%95%EC%9B%90%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EB%8F%84"; Titles = @("%EA%B0%95%EC%9B%90%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0", "%EA%B0%95%EC%9B%90%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0"); ApiNames = @("%EA%B0%95%EC%9B%90%EB%8F%84") },
[ordered]@{ Key = "%EC%B6%A9%EB%B6%81"; DisplayName = "%EC%B6%A9%EC%B2%AD%EB%B6%81%EB%8F%84"; Titles = @("%EC%B6%A9%EC%B2%AD%EB%B6%81%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0") },
[ordered]@{ Key = "%EC%B6%A9%EB%82%A8"; DisplayName = "%EC%B6%A9%EC%B2%AD%EB%82%A8%EB%8F%84"; Titles = @("%EC%B6%A9%EC%B2%AD%EB%82%A8%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0") },
[ordered]@{ Key = "%EC%A0%84%EB%B6%81"; DisplayName = "%EC%A0%84%EB%B6%81%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EB%8F%84"; Titles = @("%EC%A0%84%EB%B6%81%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0", "%EC%A0%84%EB%9D%BC%EB%B6%81%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0"); ApiNames = @("%EC%A0%84%EB%9D%BC%EB%B6%81%EB%8F%84") },
[ordered]@{ Key = "%EC%A0%84%EB%82%A8"; DisplayName = "%EC%A0%84%EB%9D%BC%EB%82%A8%EB%8F%84"; Titles = @("%EC%A0%84%EB%9D%BC%EB%82%A8%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0") },
[ordered]@{ Key = "%EA%B2%BD%EB%B6%81"; DisplayName = "%EA%B2%BD%EC%83%81%EB%B6%81%EB%8F%84"; Titles = @("%EA%B2%BD%EC%83%81%EB%B6%81%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0") },
[ordered]@{ Key = "%EA%B2%BD%EB%82%A8"; DisplayName = "%EA%B2%BD%EC%83%81%EB%82%A8%EB%8F%84"; Titles = @("%EA%B2%BD%EC%83%81%EB%82%A8%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0") },
[ordered]@{ Key = "%EC%A0%9C%EC%A3%BC"; DisplayName = "%EC%A0%9C%EC%A3%BC%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EB%8F%84"; Titles = @("%EC%A0%9C%EC%A3%BC%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0", "%EC%A0%9C%EC%A3%BC%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0") }
)
function Get-WikiPageUrl {
param([string]$Title)
return "https://ko.wikipedia.org/wiki/$([uri]::EscapeDataString($Title.Replace(' ', '_')))"
}
function Get-WikiRawUrl {
param([string]$Title)
return "https://ko.wikipedia.org/w/index.php?title=$([uri]::EscapeDataString($Title.Replace(' ', '_')))&action=raw"
}
function Get-WikiPage {
param([object]$Region)
$attempted = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
$queue = [System.Collections.Generic.Queue[string]]::new()
foreach ($encodedTitle in $Region.Titles)
{
$title = Decode-Text $encodedTitle
if ($attempted.Add($title))
{
$queue.Enqueue($title)
}
}
while ($queue.Count -gt 0)
{
$title = $queue.Dequeue()
$rawUrl = Get-WikiRawUrl -Title $title
try
{
$response = Invoke-WebRequest -UseBasicParsing -Uri $rawUrl
}
catch
{
continue
}
$content = [string]$response.Content
if ($null -eq $content)
{
$content = [string]::Empty
}
$content = $content -replace "`r", [string]::Empty
if ([string]::IsNullOrWhiteSpace($content))
{
continue
}
$redirectPattern = '^\s*#(?:' + [regex]::Escape($redirectKeyword) + '|redirect)\s*\[\[(?<target>[^\]]+)\]\]'
if ($content -match $redirectPattern)
{
$redirectTarget = ($Matches["target"] -replace '\|.*$', [string]::Empty).Trim()
if (-not [string]::IsNullOrWhiteSpace($redirectTarget) -and $attempted.Add($redirectTarget))
{
$queue.Enqueue($redirectTarget)
}
continue
}
return [pscustomobject]@{
Title = $title
Content = $content
SourceUrl = Get-WikiPageUrl -Title $title
}
}
throw "Wikipedia page was not found: $($Region.Titles -join ', ')"
}
function Split-WikiTemplateFields {
param([string]$TemplateText)
$text = $TemplateText.Trim()
if ($text.StartsWith("{{", [System.StringComparison]::Ordinal))
{
$text = $text.Substring(2)
}
if ($text.EndsWith("}}", [System.StringComparison]::Ordinal))
{
$text = $text.Substring(0, $text.Length - 2)
}
$fields = [System.Collections.Generic.List[string]]::new()
$builder = [System.Text.StringBuilder]::new()
$braceDepth = 0
$bracketDepth = 0
for ($index = 0; $index -lt $text.Length; $index++)
{
if ($index + 1 -lt $text.Length)
{
$pair = $text.Substring($index, 2)
if ($pair -eq "{{")
{
$braceDepth++
[void]$builder.Append($pair)
$index++
continue
}
if ($pair -eq "}}")
{
if ($braceDepth -gt 0)
{
$braceDepth--
}
[void]$builder.Append($pair)
$index++
continue
}
if ($pair -eq "[[")
{
$bracketDepth++
[void]$builder.Append($pair)
$index++
continue
}
if ($pair -eq "]]")
{
if ($bracketDepth -gt 0)
{
$bracketDepth--
}
[void]$builder.Append($pair)
$index++
continue
}
}
if ($text[$index] -eq "|" -and $braceDepth -eq 0 -and $bracketDepth -eq 0)
{
$fields.Add($builder.ToString())
[void]$builder.Clear()
continue
}
[void]$builder.Append($text[$index])
}
$fields.Add($builder.ToString())
return $fields
}
function Get-WikiTemplateParameters {
param([string]$TemplateText)
$parameters = [ordered]@{}
$fields = Split-WikiTemplateFields -TemplateText $TemplateText
foreach ($field in ($fields | Select-Object -Skip 1))
{
$separatorIndex = $field.IndexOf("=")
if ($separatorIndex -lt 0)
{
continue
}
$name = $field.Substring(0, $separatorIndex).Trim()
$value = $field.Substring($separatorIndex + 1).Trim()
if (-not [string]::IsNullOrWhiteSpace($name))
{
$parameters[$name] = $value
}
}
return $parameters
}
function Normalize-WikiText {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value))
{
return [string]::Empty
}
$text = $Value
$text = [System.Text.RegularExpressions.Regex]::Replace($text, '(?is)<ref[^>]*>.*?</ref>', [string]::Empty)
$text = [System.Text.RegularExpressions.Regex]::Replace($text, '(?is)<ref[^>]*/\s*>', [string]::Empty)
$text = [System.Text.RegularExpressions.Regex]::Replace($text, '(?i)<br\s*/?>', ' ')
$text = $text.Replace("&nbsp;", " ")
while ($text -match '\[\[([^\]|]+)\|([^\]]+)\]\]')
{
$text = [System.Text.RegularExpressions.Regex]::Replace($text, '\[\[([^\]|]+)\|([^\]]+)\]\]', '$2')
}
$text = [System.Text.RegularExpressions.Regex]::Replace($text, '\[\[([^\]]+)\]\]', '$1')
$text = [System.Text.RegularExpressions.Regex]::Replace($text, '\{\{(?:nowrap|small)\|([^{}]+)\}\}', '$1')
$text = [System.Text.RegularExpressions.Regex]::Replace($text, '\{\{lang\|[^|]+\|([^{}]+)\}\}', '$1')
while ($text -match '\{\{[^{}]+\}\}')
{
$text = [System.Text.RegularExpressions.Regex]::Replace($text, '\{\{[^{}]+\}\}', [string]::Empty)
}
$text = $text.Replace("'''", [string]::Empty)
$text = $text.Replace("''", [string]::Empty)
$text = [System.Text.RegularExpressions.Regex]::Replace($text, '\s+', ' ')
return $text.Trim()
}
function Parse-PositiveInt {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value))
{
return 0
}
$digits = $Value -replace '[^0-9]', [string]::Empty
if ([string]::IsNullOrWhiteSpace($digits))
{
return 0
}
return [int]$digits
}
function Get-ElectionSections {
param([string]$Content)
$sectionMatches = [System.Text.RegularExpressions.Regex]::Matches($Content, '(?m)^===\s*(?<heading>.+?)\s*===\s*$')
$sections = @{}
for ($index = 0; $index -lt $sectionMatches.Count; $index++)
{
$heading = $sectionMatches[$index].Groups["heading"].Value
if ($heading -notmatch $electionHeadingPattern)
{
continue
}
$order = [int]$Matches["order"]
$start = $sectionMatches[$index].Index + $sectionMatches[$index].Length
$end = if ($index + 1 -lt $sectionMatches.Count) { $sectionMatches[$index + 1].Index } else { $Content.Length }
$sections[$order] = $Content.Substring($start, $end - $start)
}
return $sections
}
function Convert-ToWinnerEntry {
param(
[int]$ElectionOrder,
[string]$SectionText,
[string]$SourceUrl
)
$winnerPattern = '\{\{' + [regex]::Escape($winnerTemplatePrefix) + '[^\n]*'
$winnerMatch = [System.Text.RegularExpressions.Regex]::Match($SectionText, $winnerPattern)
if (-not $winnerMatch.Success)
{
return $null
}
$parameters = Get-WikiTemplateParameters -TemplateText $winnerMatch.Value
$name = Normalize-WikiText -Value (Get-FirstValue -Table $parameters -Keys @($candidateKey, $candidateNameKey))
if ([string]::IsNullOrWhiteSpace($name))
{
return $null
}
return [ordered]@{
ElectionOrder = $ElectionOrder
Year = $yearByOrder[$ElectionOrder]
Name = $name
Party = Normalize-WikiText -Value (Get-FirstValue -Table $parameters -Keys @($partyKey, $partyNameKey))
Note = Normalize-WikiText -Value (Get-FirstValue -Table $parameters -Keys @($noteKey))
SourceUrl = $SourceUrl
}
}
function Convert-ToTurnoutEntry {
param(
[int]$ElectionOrder,
[string]$SectionText,
[string]$SourceUrl
)
if ($ElectionOrder -lt 3)
{
return $null
}
$startPattern = '\{\{' + [regex]::Escape($turnoutStartTemplatePrefix) + '[^\n]*'
$totalPattern = '\{\{' + [regex]::Escape($turnoutTotalTemplatePrefix) + '[^\n]*'
$startMatch = [System.Text.RegularExpressions.Regex]::Match($SectionText, $startPattern)
$totalMatch = [System.Text.RegularExpressions.Regex]::Match($SectionText, $totalPattern)
if (-not $startMatch.Success -or -not $totalMatch.Success)
{
return $null
}
$startParameters = Get-WikiTemplateParameters -TemplateText $startMatch.Value
$totalParameters = Get-WikiTemplateParameters -TemplateText $totalMatch.Value
$electors = Parse-PositiveInt -Value (Get-FirstValue -Table $startParameters -Keys @($electorsKey, $totalElectorsKey))
$votes = Parse-PositiveInt -Value (Get-FirstValue -Table $totalParameters -Keys @($sumKey, $totalVotesKey))
if ($electors -le 0 -or $votes -le 0)
{
return $null
}
return [ordered]@{
ElectionOrder = $ElectionOrder
Year = $yearByOrder[$ElectionOrder]
Electors = $electors
Votes = $votes
TurnoutRate = [Math]::Round(($votes * 100.0) / $electors, 1, [MidpointRounding]::AwayFromZero)
SourceUrl = $SourceUrl
}
}
if (-not [System.IO.Path]::IsPathRooted($OutputPath))
{
$OutputPath = Join-Path $PSScriptRoot $OutputPath
}
$OutputPath = [System.IO.Path]::GetFullPath($OutputPath)
$outputDirectory = Split-Path -Parent $OutputPath
if (-not [string]::IsNullOrWhiteSpace($outputDirectory) -and -not (Test-Path -LiteralPath $outputDirectory))
{
[void](New-Item -ItemType Directory -Path $outputDirectory)
}
$sources = [System.Collections.Generic.List[object]]::new()
foreach ($source in $officialSources)
{
$sources.Add($source)
}
$codeItems = [System.Collections.Generic.List[object]]::new()
$codePageNo = 1
$codePageSize = 100
while ($true)
{
$codeXml = Invoke-NecXml -ServicePath "CommonCodeService/getCommonSgCodeList" -Parameters @{
pageNo = $codePageNo
numOfRows = $codePageSize
}
$pageItems = Get-XmlItems -Xml $codeXml
foreach ($pageItem in $pageItems)
{
$codeItems.Add($pageItem)
}
$totalCount = Parse-PositiveInt -Value ([string]$codeXml.response.body.totalCount)
if ($pageItems.Count -lt $codePageSize -or ($codePageNo * $codePageSize) -ge $totalCount)
{
break
}
$codePageNo++
}
$localElectionCycles = Get-LocalElectionCycles -CodeItems $codeItems -SgTypeCode "0"
$basicCycles = @($localElectionCycles)
$educationCycles = @($localElectionCycles | Where-Object { $_.Order -ge 5 })
$winnerCache = @{}
$turnoutCache = @{}
function Get-WinnerItems {
param(
[string]$SgId,
[string]$SgTypeCode,
[string]$SdName
)
$cacheKey = "$SgId|$SgTypeCode|$SdName"
if (-not $winnerCache.ContainsKey($cacheKey))
{
$winnerCache[$cacheKey] = Get-XmlItems -Xml (Invoke-NecXml -ServicePath "WinnerInfoInqireService2/getWinnerInfoInqire" -Parameters @{
sgId = $SgId
sgTypecode = $SgTypeCode
sdName = $SdName
pageNo = 1
numOfRows = 400
})
}
return @($winnerCache[$cacheKey])
}
function Get-TurnoutItems {
param(
[string]$SgId,
[string]$SdName
)
$cacheKey = "$SgId|$SdName"
if (-not $turnoutCache.ContainsKey($cacheKey))
{
$turnoutCache[$cacheKey] = Get-XmlItems -Xml (Invoke-NecXml -ServicePath "VoteXmntckInfoInqireService2/getVoteSttusInfoInqire" -Parameters @{
sgId = $SgId
sgTypecode = 3
sdName = $SdName
pageNo = 1
numOfRows = 400
})
}
return @($turnoutCache[$cacheKey])
}
function Get-WinnerItemsForRegion {
param(
[pscustomobject]$Cycle,
[string]$SgTypeCode,
[object]$Region
)
foreach ($apiName in (Get-RegionApiNames -Region $Region))
{
$items = @(Get-WinnerItems -SgId $Cycle.SgId -SgTypeCode $SgTypeCode -SdName $apiName)
if ($items.Count -gt 0)
{
return @($items)
}
}
return @()
}
function Get-TurnoutItemsForRegion {
param(
[pscustomobject]$Cycle,
[object]$Region
)
foreach ($apiName in (Get-RegionApiNames -Region $Region))
{
$items = @(Get-TurnoutItems -SgId $Cycle.SgId -SdName $apiName)
if ($items.Count -gt 0)
{
return @($items)
}
}
return @()
}
$records = [System.Collections.Generic.List[object]]::new()
foreach ($region in $regions)
{
$regionKey = Decode-Text $region.Key
$regionDisplayName = Decode-Text $region.DisplayName
Write-Host ("Building governor history: {0}" -f $regionDisplayName)
$page = Get-WikiPage -Region $region
$sources.Add((New-SourceReference -Title $page.Title -Url $page.SourceUrl))
$sections = Get-ElectionSections -Content $page.Content
$record = New-HistoryRecord -ElectionType $governorType -Key $regionKey -RegionKey $regionKey -RegionName $regionDisplayName -DistrictName $regionDisplayName -DisplayName $regionDisplayName
foreach ($order in 1..8)
{
if (-not $sections.ContainsKey($order))
{
continue
}
Add-HistoryEntry -Target $record.WinnerHistory -Entry (Convert-ToWinnerEntry -ElectionOrder $order -SectionText $sections[$order] -SourceUrl $page.SourceUrl)
Add-HistoryEntry -Target $record.TurnoutHistory -Entry (Convert-ToTurnoutEntry -ElectionOrder $order -SectionText $sections[$order] -SourceUrl $page.SourceUrl)
}
$records.Add((Convert-RecordForJson -Record $record))
}
foreach ($region in $regions)
{
$regionKey = Decode-Text $region.Key
$regionDisplayName = Decode-Text $region.DisplayName
Write-Host ("Building education history: {0}" -f $regionDisplayName)
$record = New-HistoryRecord -ElectionType $educationType -Key $regionKey -RegionKey $regionKey -RegionName $regionDisplayName -DistrictName $regionDisplayName -DisplayName $regionDisplayName
foreach ($cycle in $educationCycles)
{
$winnerItems = Get-WinnerItemsForRegion -Cycle $cycle -SgTypeCode "11" -Region $region
$winnerItem = $winnerItems | Where-Object { -not [string]::IsNullOrWhiteSpace(([string]$_.name)) } | Select-Object -First 1
Add-HistoryEntry -Target $record.WinnerHistory -Entry (New-OfficialWinnerEntry -Cycle $cycle -Item $winnerItem -SourceUrl $winnerSourceUrl)
$turnoutItems = Get-TurnoutItemsForRegion -Cycle $cycle -Region $region
$totalItem = $turnoutItems | Where-Object { [string]$_.wiwName -eq $totalLabel } | Select-Object -First 1
if ($null -ne $totalItem)
{
$electors = Parse-PositiveInt -Value ([string]$totalItem.totSunsu)
$votes = Parse-PositiveInt -Value ([string]$totalItem.totTusu)
Add-HistoryEntry -Target $record.TurnoutHistory -Entry (New-OfficialTurnoutEntry -Cycle $cycle -Electors $electors -Votes $votes -SourceUrl $turnoutSourceUrl)
}
}
$records.Add((Convert-RecordForJson -Record $record))
}
foreach ($region in $regions)
{
$regionKey = Decode-Text $region.Key
$regionDisplayName = Decode-Text $region.DisplayName
Write-Host ("Building basic local history: {0}" -f $regionDisplayName)
$basicRecordsByKey = @{}
foreach ($cycle in $basicCycles)
{
$winnerItems = Get-WinnerItemsForRegion -Cycle $cycle -SgTypeCode "4" -Region $region
$turnoutItems = Get-TurnoutItemsForRegion -Cycle $cycle -Region $region
$turnoutDetails = @($turnoutItems | Where-Object { [string]$_.wiwName -ne $totalLabel })
foreach ($winnerItem in $winnerItems)
{
$districtName = ([string]$winnerItem.sggName).Trim()
if ([string]::IsNullOrWhiteSpace($districtName))
{
$districtName = ([string]$winnerItem.wiwName).Trim()
}
if ([string]::IsNullOrWhiteSpace($districtName))
{
continue
}
$districtKey = Normalize-BasicDistrictToken -Value $districtName
if ([string]::IsNullOrWhiteSpace($districtKey))
{
continue
}
$recordKey = "$regionKey|$districtKey"
if (-not $basicRecordsByKey.ContainsKey($recordKey))
{
$basicRecordsByKey[$recordKey] = New-HistoryRecord `
-ElectionType $basicType `
-Key $recordKey `
-RegionKey $regionKey `
-RegionName $regionDisplayName `
-DistrictName $districtName `
-DisplayName "$regionDisplayName $districtName"
}
$record = $basicRecordsByKey[$recordKey]
Add-HistoryEntry -Target $record.WinnerHistory -Entry (New-OfficialWinnerEntry -Cycle $cycle -Item $winnerItem -SourceUrl $winnerSourceUrl)
$turnoutSnapshot = Resolve-BasicTurnoutSnapshot -DistrictName $districtName -TurnoutItems $turnoutDetails
if ($null -ne $turnoutSnapshot)
{
Add-HistoryEntry -Target $record.TurnoutHistory -Entry (New-OfficialTurnoutEntry -Cycle $cycle -Electors $turnoutSnapshot.Electors -Votes $turnoutSnapshot.Votes -SourceUrl $turnoutSourceUrl)
}
}
}
foreach ($record in ($basicRecordsByKey.Values | Sort-Object DisplayName))
{
$records.Add((Convert-RecordForJson -Record $record))
}
}
$deduplicatedSources = @($sources | Group-Object Url | ForEach-Object { $_.Group[0] } | Sort-Object Url)
$coverageNotes = @(
(Decode-Text "%EC%A0%84%EA%B5%AD%2017%EA%B0%9C%20%EC%8B%9C%EB%8F%84%20%EA%B4%91%EC%97%AD%EB%8B%A8%EC%B2%B4%EC%9E%A5%C2%B7%EA%B5%90%EC%9C%A1%EA%B0%90%20%EA%B8%B0%EC%A4%80%20%EC%A0%80%EC%9E%A5%ED%98%95%20%EC%82%AC%EC%A0%84%20%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%9E%85%EB%8B%88%EB%8B%A4."),
(Decode-Text "%EA%B8%B0%EC%B4%88%EB%8B%A8%EC%B2%B4%EC%9E%A5%EC%9D%80%20%EC%A4%91%EC%95%99%EC%84%A0%EA%B1%B0%EA%B4%80%EB%A6%AC%EC%9C%84%EC%9B%90%ED%9A%8C%20OpenAPI%20%EA%B8%B0%EC%A4%80%20%EC%A0%84%EA%B5%AD%20%EC%84%A0%EA%B1%B0%EA%B5%AC%EB%A5%BC%20%EB%AF%B8%EB%A6%AC%20%EC%A0%80%EC%9E%A5%ED%95%A9%EB%8B%88%EB%8B%A4."),
(Decode-Text "%EA%B4%91%EC%97%AD%EB%8B%A8%EC%B2%B4%EC%9E%A5%20%EB%8B%B9%EC%84%A0%EC%9E%90%EB%8A%94%201995%EB%85%84%EB%B6%80%ED%84%B0%202022%EB%85%84%EA%B9%8C%EC%A7%80,%20%ED%88%AC%ED%91%9C%EC%9C%A8%EC%9D%80%202002%EB%85%84%EB%B6%80%ED%84%B0%202022%EB%85%84%EA%B9%8C%EC%A7%80%20%EC%A0%80%EC%9E%A5%ED%96%88%EC%8A%B5%EB%8B%88%EB%8B%A4."),
(Decode-Text "%EA%B5%90%EC%9C%A1%EA%B0%90%EC%9D%80%20%EC%A0%84%EA%B5%AD%20%EC%A7%81%EC%84%A0%EC%A0%9C%EA%B0%80%20%EC%A0%81%EC%9A%A9%EB%90%9C%202010%EB%85%84%EB%B6%80%ED%84%B0%202022%EB%85%84%EA%B9%8C%EC%A7%80%20%EC%A0%80%EC%9E%A5%ED%96%88%EC%8A%B5%EB%8B%88%EB%8B%A4."),
(Decode-Text "%EA%B8%B0%EC%B4%88%EB%8B%A8%EC%B2%B4%EC%9E%A5%EC%9D%98%20%EB%B6%84%ED%95%A0%20%EC%8B%9C%20%EC%84%A0%EA%B1%B0%EA%B5%AC(%EC%88%98%EC%9B%90%EC%8B%9C%C2%B7%EC%B0%BD%EC%9B%90%EC%8B%9C%20%EB%93%B1)%EB%8A%94%20%EB%8F%99%EC%9D%BC%20%EC%8B%9C%20%EB%8B%A8%EC%9C%84%EB%A1%9C%20%ED%88%AC%ED%91%9C%EC%9C%A8%EC%9D%84%20%ED%95%A9%EC%82%B0%ED%96%88%EC%8A%B5%EB%8B%88%EB%8B%A4.")
)
$catalog = [ordered]@{
Metadata = [ordered]@{
Version = "2026-04-18-national-history-mixed1"
GeneratedAt = (Get-Date -Format "yyyy-MM-dd")
CoverageNotes = $coverageNotes
Sources = $deduplicatedSources
}
Records = @($records)
}
$json = $catalog | ConvertTo-Json -Depth 12
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($OutputPath, $json, $utf8NoBom)
$jsonObject = $json | ConvertFrom-Json
$counts = $jsonObject.Records | Group-Object ElectionType | Sort-Object Name
Write-Host ("Saved pre-election history seed to {0}" -f $OutputPath)
foreach ($count in $counts)
{
Write-Host ("{0}: {1}" -f $count.Name, $count.Count)
}