#!/usr/bin/env pwsh <# .SYNOPSIS Exports members from a RuneScape 3 clan or OSRS group. .DESCRIPTION The script can run interactively or with parameters. RS3 uses the public Jagex Clan Members Lite endpoint. OSRS uses the public Wise Old Man API because OSRS does not expose the same public Jagex clan CSV. Available output formats: - Markdown - CSV .EXAMPLE .\Get-RunescapeClanMembers.ps1 .EXAMPLE .\Get-RunescapeClanMembers.ps1 -Game RS3 -ClanName "Wapitiklan Empire" -OutputFormat Csv .EXAMPLE .\Get-RunescapeClanMembers.ps1 -Game OSRS -ClanName "KnightSlayer" -OutputFormat Markdown .EXAMPLE .\Get-RunescapeClanMembers.ps1 -Game Both -ClanName "KnightSlayer" -OutputFormat Csv .NOTES Compatible with Windows PowerShell 5.1+ and PowerShell 7+. #> [CmdletBinding()] param( [string]$Game, [string]$ClanName, [string]$OutputFormat, [string]$OutputDir = ".\output", [ValidateRange(5, 300)] [int]$TimeoutSec = 90, [ValidateRange(1, 8)] [int]$MaxRetries = 4, [ValidateRange(0, 60)] [int]$RequestDelaySec = 2, [ValidateRange(1, 120)] [int]$RetryBaseDelaySec = 8, [ValidateRange(5, 600)] [int]$MaxRetryDelaySec = 120, [ValidateRange(25, 5000)] [int]$OutputChunkSize = 250, [ValidateRange(1, 500)] [int]$PreviewCount = 50, [ValidateRange(0, 2147483647)] [int]$OsrsGroupId, [string]$RepositoryUrl, [switch]$ShowAllInConsole, [switch]$OpenFolder, [switch]$AllowInsecureFallback, [switch]$KeepRecoveryFile, [switch]$Version, [switch]$SelfTest ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $script:ApplicationVersion = "0.2.0" $script:LastHttpRequestAt = $null $script:ConfiguredRetryBaseDelaySec = $RetryBaseDelaySec $script:ConfiguredMaxRetryDelaySec = $MaxRetryDelaySec $script:ConfiguredRepositoryUrl = $RepositoryUrl $script:ConfiguredPlainUi = ($env:RS_CLAN_PLAIN_UI -match "^(1|true|yes|on)$") function Get-ScriptBaseDirectory { if (-not [string]::IsNullOrWhiteSpace($PSScriptRoot)) { return [System.IO.Path]::GetFullPath($PSScriptRoot) } if (-not [string]::IsNullOrWhiteSpace($PSCommandPath)) { return [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($PSCommandPath)) } return [System.IO.Path]::GetFullPath((Get-Location).Path) } function Initialize-Console { try { $utf8Bom = New-Object System.Text.UTF8Encoding -ArgumentList $true [Console]::OutputEncoding = $utf8Bom [Console]::InputEncoding = $utf8Bom $global:OutputEncoding = $utf8Bom $PSDefaultParameterValues["Out-File:Encoding"] = "utf8" } catch { Write-Verbose "Could not adjust console encoding: $($_.Exception.Message)" } if ($env:OS -eq "Windows_NT" -and -not [Console]::IsOutputRedirected) { try { cmd.exe /c "chcp 65001 >nul" | Out-Null } catch { Write-Verbose "Could not change the console code page: $($_.Exception.Message)" } } try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { Write-Verbose "Could not force TLS 1.2 in this host: $($_.Exception.Message)" } } function Write-Console { param( [AllowEmptyString()] [string]$Message = "", [System.ConsoleColor]$ForegroundColor ) try { if ($PSBoundParameters.ContainsKey("ForegroundColor")) { $Host.UI.WriteLine($ForegroundColor, $Host.UI.RawUI.BackgroundColor, $Message) } else { $Host.UI.WriteLine($Message) } } catch { Write-Verbose "Could not write through the PowerShell host: $($_.Exception.Message)" Write-Information -MessageData $Message -InformationAction Continue } } function Test-DecoratedConsole { if ($script:ConfiguredPlainUi) { return $false } if ($PSVersionTable.PSVersion.Major -lt 6) { return $false } try { return (-not [Console]::IsOutputRedirected) } catch { return $false } } function Get-UiMarker { param( [string]$Kind, [string]$Fallback ) if (-not (Test-DecoratedConsole)) { return $Fallback } switch ($Kind) { "Info" { return [char]::ConvertFromUtf32(0x2139) } "Ok" { return [char]::ConvertFromUtf32(0x2705) } "Warn" { return [char]::ConvertFromUtf32(0x26A0) } "Fail" { return [char]::ConvertFromUtf32(0x274C) } "Search" { return [char]::ConvertFromUtf32(0x1F50E) } "Summary" { return [char]::ConvertFromUtf32(0x1F4CB) } "Export" { return [char]::ConvertFromUtf32(0x1F4E6) } default { return $Fallback } } } function Write-Info { param([string]$Message) Write-Console "$(Get-UiMarker -Kind "Info" -Fallback "[INFO]") $Message" -ForegroundColor Cyan } function Write-Ok { param([string]$Message) Write-Console "$(Get-UiMarker -Kind "Ok" -Fallback "[OK] ") $Message" -ForegroundColor Green } function Write-Warn2 { param([string]$Message) Write-Console "$(Get-UiMarker -Kind "Warn" -Fallback "[WARN]") $Message" -ForegroundColor Yellow } function Write-Fail { param([string]$Message) Write-Console "$(Get-UiMarker -Kind "Fail" -Fallback "[FAIL]") $Message" -ForegroundColor Red } function ConvertTo-FileUri { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $null } try { $fullPath = [System.IO.Path]::GetFullPath($Path) return ([System.Uri]$fullPath).AbsoluteUri } catch { return $null } } function Write-LocalPath { param( [string]$Label, [string]$Path ) if ([string]::IsNullOrWhiteSpace($Path)) { return } $fullPath = [System.IO.Path]::GetFullPath($Path) Write-Ok "${Label}: $fullPath" $fileUri = ConvertTo-FileUri -Path $fullPath if (-not [string]::IsNullOrWhiteSpace($fileUri)) { Write-Info "Local link: $fileUri" } } function Open-OutputDirectory { param( [string]$Path, [switch]$ProbeOnly ) if ([string]::IsNullOrWhiteSpace($Path)) { Write-Warn2 "No output folder to open." return $false } try { $fullPath = [System.IO.Path]::GetFullPath($Path) } catch { Write-Warn2 "Invalid output path: $Path" return $false } if (-not (Test-Path -LiteralPath $fullPath -PathType Container)) { Write-Warn2 "Output folder not found: $fullPath" return $false } if ($ProbeOnly) { return $true } try { Invoke-Item -LiteralPath $fullPath -ErrorAction Stop return $true } catch { Write-Warn2 "Could not open the output folder automatically: $fullPath" Write-Warn2 "Open it manually from the path shown above." return $false } } function Test-CanPrompt { try { return (-not [Console]::IsInputRedirected) } catch { return $true } } function ConvertTo-Game { param([string]$Value) if ([string]::IsNullOrWhiteSpace($Value)) { return $null } $clean = $Value.Trim() switch -Regex ($clean) { "^(1|rs3|runescape\s*3|runescape)$" { return "RS3" } "^(2|osrs|old\s*school|old\s*school\s*runescape)$" { return "OSRS" } "^(3|both|all|tout|tous|les\s*deux|rs3\s*\+\s*osrs|osrs\s*\+\s*rs3)$" { return "Both" } } throw "Invalid game: '$Value'. Accepted values: RS3, OSRS, or Both." } function ConvertTo-OutputFormat { param([string]$Value) if ([string]::IsNullOrWhiteSpace($Value)) { return $null } $clean = $Value.Trim() switch -Regex ($clean) { "^(1|md|markdown)$" { return "Markdown" } "^(2|csv)$" { return "Csv" } } throw "Invalid format: '$Value'. Accepted values: Markdown or CSV." } function ConvertTo-ClanName { param([string]$Value) if ([string]::IsNullOrWhiteSpace($Value)) { return $null } $clean = $Value.Trim() $clean = $clean.Replace([char]0x00A0, " ").Replace([char]0x202F, " ") $clean = $clean -replace "\s+", " " if ($clean.Length -lt 2) { throw "The clan name must contain at least 2 characters." } if ($clean.Length -gt 100) { throw "The clan name is too long. Limit: 100 characters." } if ($clean -match "[\x00-\x1F]") { throw "The clan name contains an invalid control character." } return $clean } function Read-Choice { param( [string]$Question, [string[]]$AllowedValues, [string[]]$Labels ) if ($null -eq $AllowedValues -or $AllowedValues.Count -eq 0) { throw "No choices available for this question: $Question" } if ($null -ne $Labels -and $Labels.Count -ne $AllowedValues.Count) { throw "The label list must contain the same number of items as the choice list." } $range = if ($AllowedValues.Count -eq 1) { "1" } else { "1-$($AllowedValues.Count)" } while ($true) { Write-Console $Question -ForegroundColor White for ($i = 0; $i -lt $AllowedValues.Count; $i++) { $label = $AllowedValues[$i] if ($null -ne $Labels -and -not [string]::IsNullOrWhiteSpace($Labels[$i])) { $label = $Labels[$i] } Write-Console (" {0}) {1}" -f ($i + 1), $label) -ForegroundColor White } $answer = Read-Host "Your choice ($range)" $choice = 0 if ([int]::TryParse($answer, [ref]$choice) -and $choice -ge 1 -and $choice -le $AllowedValues.Count) { return $AllowedValues[$choice - 1] } Write-Warn2 "Expected answer: a number between 1 and $($AllowedValues.Count)." } } function Read-RequiredText { param([string]$Question) while ($true) { $answer = Read-Host $Question try { $clean = ConvertTo-ClanName -Value $answer if (-not [string]::IsNullOrWhiteSpace($clean)) { return $clean } } catch { Write-Warn2 $_.Exception.Message } } } function Resolve-InteractiveOption { param( [string]$Game, [string]$ClanName, [string]$OutputFormat, [int]$OsrsGroupId ) $resolvedGame = ConvertTo-Game -Value $Game $resolvedFormat = ConvertTo-OutputFormat -Value $OutputFormat $resolvedClan = ConvertTo-ClanName -Value $ClanName if ([string]::IsNullOrWhiteSpace($resolvedGame)) { if (-not (Test-CanPrompt)) { throw "The -Game parameter is required in non-interactive mode. Values: RS3, OSRS, or Both." } Write-Console "" $resolvedGame = Read-Choice -Question "Target game" -AllowedValues @("RS3", "OSRS", "Both") -Labels @("RS3: RuneScape 3 clan through Jagex", "OSRS: OSRS group through Wise Old Man", "Both: search RS3 and OSRS") } if ([string]::IsNullOrWhiteSpace($resolvedClan) -and -not ($resolvedGame -eq "OSRS" -and $OsrsGroupId -gt 0)) { if (-not (Test-CanPrompt)) { throw "The -ClanName parameter is required in non-interactive mode." } if ($resolvedGame -eq "OSRS") { $resolvedClan = Read-RequiredText -Question "OSRS group/clan name to search" } elseif ($resolvedGame -eq "Both") { $resolvedClan = Read-RequiredText -Question "Clan/group name to search in RS3 and OSRS" } else { $resolvedClan = Read-RequiredText -Question "RS3 clan name to search" } } if ([string]::IsNullOrWhiteSpace($resolvedFormat)) { if (-not (Test-CanPrompt)) { throw "The -OutputFormat parameter is required in non-interactive mode. Values: Markdown or CSV." } $resolvedFormat = Read-Choice -Question "Output format" -AllowedValues @("Markdown", "Csv") -Labels @("Markdown (.md)", "CSV (.csv)") } [PSCustomObject]@{ Game = $resolvedGame ClanName = $resolvedClan OutputFormat = $resolvedFormat } } function Get-SafeSlug { param([string]$Text) $slug = $Text.ToLowerInvariant() $slug = $slug -replace "[^a-z0-9]+", "-" $slug = $slug.Trim("-") if ([string]::IsNullOrWhiteSpace($slug)) { return "clan" } return $slug } function Get-FileTimestamp { return (Get-Date).ToString("yyyy-MM-dd_HH-mm-ss") } function Get-ResponseSample { param([string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return "" } $clean = $Text.Trim() return $clean.Substring(0, [Math]::Min(250, $clean.Length)) } function Get-HttpHeader { param([string]$Accept) $userAgent = "RunescapeClanMembersExporter/$script:ApplicationVersion PowerShell/$($PSVersionTable.PSVersion)" if (-not [string]::IsNullOrWhiteSpace($script:ConfiguredRepositoryUrl)) { $userAgent = "$userAgent ($script:ConfiguredRepositoryUrl)" } @{ "User-Agent" = $userAgent "Accept" = $Accept } } function Wait-RequestPace { param( [int]$MinimumDelaySec, [string]$Purpose ) if ($MinimumDelaySec -le 0 -or $null -eq $script:LastHttpRequestAt) { return } $elapsed = (Get-Date) - $script:LastHttpRequestAt $remaining = [Math]::Ceiling($MinimumDelaySec - $elapsed.TotalSeconds) if ($remaining -le 0) { return } Write-Info "Gentle network pause of $remaining second(s) before the next call." for ($i = $remaining; $i -gt 0; $i--) { Write-Progress -Activity $Purpose -Status "Respectful network pause ($i s)" -SecondsRemaining $i -PercentComplete 5 Start-Sleep -Seconds 1 } } function Get-RetryAfterSecond { param([object]$ErrorRecord) try { $response = $ErrorRecord.Exception.Response if ($null -eq $response -or $null -eq $response.Headers) { return $null } $retryAfter = $response.Headers["Retry-After"] if ([string]::IsNullOrWhiteSpace($retryAfter)) { return $null } $seconds = 0 if ([int]::TryParse($retryAfter, [ref]$seconds) -and $seconds -gt 0) { return $seconds } $retryDate = [DateTime]::MinValue if ([DateTime]::TryParse($retryAfter, [ref]$retryDate)) { $delta = $retryDate.ToUniversalTime() - (Get-Date).ToUniversalTime() if ($delta.TotalSeconds -gt 0) { return [Math]::Ceiling($delta.TotalSeconds) } } } catch { return $null } return $null } function Get-HttpStatusCode { param([object]$ErrorRecord) try { if ($null -ne $ErrorRecord.Exception.Response -and $null -ne $ErrorRecord.Exception.Response.StatusCode) { return [int]$ErrorRecord.Exception.Response.StatusCode } } catch { return $null } return $null } function Test-IsPermanentHttpStatusCode { param([int]$StatusCode) return ($StatusCode -in @(400, 401, 403, 404)) } function Get-RetryDelaySecond { param( [int]$Attempt, [object]$ErrorRecord ) $retryAfter = Get-RetryAfterSecond -ErrorRecord $ErrorRecord if ($null -ne $retryAfter) { return [Math]::Min($script:ConfiguredMaxRetryDelaySec, [Math]::Max($script:ConfiguredRetryBaseDelaySec, [int]$retryAfter)) } $exponentialDelay = [int]($script:ConfiguredRetryBaseDelaySec * [Math]::Pow(2, [Math]::Max(0, $Attempt - 1))) $jitter = Get-Random -Minimum 0 -Maximum 4 return [Math]::Min($script:ConfiguredMaxRetryDelaySec, ($exponentialDelay + $jitter)) } function Wait-RetryDelay { param( [int]$Seconds, [string]$Purpose ) if ($Seconds -le 0) { return } Write-Info "Pausing $Seconds second(s), then retrying." for ($remaining = $Seconds; $remaining -gt 0; $remaining--) { Write-Progress -Activity $Purpose -Status "Retry in $remaining s" -SecondsRemaining $remaining -PercentComplete 10 if ($remaining -le 3 -or $remaining % 10 -eq 0) { Write-Info "Resuming in $remaining second(s)..." } Start-Sleep -Seconds 1 } } function Invoke-HttpText { param( [string]$Url, [string]$Accept, [int]$TimeoutSec, [int]$MaxRetries, [string]$Purpose ) $headers = Get-HttpHeader -Accept $Accept $lastMessage = $null for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) { try { $percent = [Math]::Min(95, [int](($attempt / [Math]::Max($MaxRetries, 1)) * 60)) Write-Progress -Activity $Purpose -Status "Attempt $attempt/$MaxRetries" -PercentComplete $percent Wait-RequestPace -MinimumDelaySec $RequestDelaySec -Purpose $Purpose Write-Info "Attempt $attempt/${MaxRetries}: $Url" $request = @{ Uri = $Url Headers = $headers TimeoutSec = $TimeoutSec Method = "GET" } if ($PSVersionTable.PSVersion.Major -lt 6) { $request.UseBasicParsing = $true } $response = Invoke-WebRequest @request $script:LastHttpRequestAt = Get-Date if ($null -eq $response -or [string]::IsNullOrWhiteSpace([string]$response.Content)) { throw "Empty response." } Write-Progress -Activity $Purpose -Completed return [string]$response.Content } catch { $script:LastHttpRequestAt = Get-Date $lastMessage = $_.Exception.Message $statusCode = Get-HttpStatusCode -ErrorRecord $_ if ($null -ne $statusCode) { Write-Warn2 "Attempt $attempt failed (HTTP $statusCode): $lastMessage" } else { Write-Warn2 "Attempt $attempt failed: $lastMessage" } if ($null -ne $statusCode -and (Test-IsPermanentHttpStatusCode -StatusCode $statusCode)) { Write-Progress -Activity $Purpose -Completed throw "Permanent HTTP error ($statusCode) during '$Purpose'. The request will not be retried. Last error: $lastMessage" } if ($attempt -lt $MaxRetries) { $sleepSeconds = Get-RetryDelaySecond -Attempt $attempt -ErrorRecord $_ Wait-RetryDelay -Seconds $sleepSeconds -Purpose $Purpose } } } Write-Progress -Activity $Purpose -Completed throw "All attempts failed. Last error: $lastMessage" } function Invoke-HttpJson { param( [string]$Url, [int]$TimeoutSec, [int]$MaxRetries, [string]$Purpose ) $content = Invoke-HttpText -Url $Url -Accept "application/json" -TimeoutSec $TimeoutSec -MaxRetries $MaxRetries -Purpose $Purpose try { return $content | ConvertFrom-Json } catch { throw "The JSON response could not be read. Detail: $($_.Exception.Message). Response received: $(Get-ResponseSample -Text $content)" } } function ConvertTo-ClanValue { param([object]$Value) if ($null -eq $Value) { return "" } return ([string]$Value).Replace([char]0x00A0, " ").Replace([char]0x202F, " ").Trim() } function Get-ObjectPropertyValue { param( [object]$Object, [string]$Name ) if ($null -eq $Object -or [string]::IsNullOrWhiteSpace($Name)) { return $null } $property = $Object.PSObject.Properties[$Name] if ($null -eq $property) { return $null } return $property.Value } function Get-CsvField { param( [psobject]$Row, [string[]]$Names ) foreach ($property in $Row.PSObject.Properties) { $propertyName = ConvertTo-ClanValue -Value $property.Name foreach ($name in $Names) { if ($propertyName -ieq $name) { return $property.Value } } } return $null } function ConvertFrom-Rs3ClanMembersCsv { param( [string]$CsvText, [string]$ClanName ) $trimmed = $CsvText.Trim() if ([string]::IsNullOrWhiteSpace($trimmed)) { throw "The returned CSV is empty." } if ($trimmed -match "^\s*(