[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")) + '(?\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) + "(?\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*\[\[(?[^\]]+)\]\]' 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)]*>.*?', [string]::Empty) $text = [System.Text.RegularExpressions.Regex]::Replace($text, '(?is)]*/\s*>', [string]::Empty) $text = [System.Text.RegularExpressions.Regex]::Replace($text, '(?i)', ' ') $text = $text.Replace(" ", " ") 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*(?.+?)\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) }