Skip to content

Instantly share code, notes, and snippets.

@deholic
Last active June 4, 2026 09:26
Show Gist options
  • Select an option

  • Save deholic/e5f56dd3192ff8a49791b11df5543073 to your computer and use it in GitHub Desktop.

Select an option

Save deholic/e5f56dd3192ff8a49791b11df5543073 to your computer and use it in GitHub Desktop.
CosmicTracker - FFXIV Auxesia overlay

CosmicTracker

윈도우에서 실행되는 아욱세시아 우주 개척 현황 오버레이입니다.

이 앱은 공식 페이지 HTML을 읽어 표시하는 비공식 도구이며, Square Enix 또는 파이널판타지14 공식 도구가 아닙니다.

실행

CosmicTracker.exe를 더블클릭하면 실행됩니다.

공개 Gist에는 실행 파일 대신 소스가 포함됩니다. Gist에서 받은 경우 Build-CosmicTracker.bat을 실행하면 CosmicTracker.exe가 생성됩니다.

동작

  • 항상 최상위 창으로 표시됩니다.
  • 창 배경은 반투명입니다.
  • 한국 공식 가이드, NA Lodestone, 중국 공식 API의 아욱세시아 데이터만 읽습니다.
  • 단계가 높은 서버부터, 같은 단계에서는 진행도가 높은 서버부터 순위표로 표시합니다.
  • 진행도는 소수점 아래를 버린 정수 퍼센트로 비교하고 표시합니다.
  • 5분마다 자동 갱신합니다.
  • 위쪽 제목 영역을 드래그하면 창을 옮길 수 있습니다.
  • 오른쪽 아래 모서리로 창 크기를 조절할 수 있습니다.
@echo off
setlocal
set "CSC=%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe"
if not exist "%CSC%" set "CSC=%WINDIR%\Microsoft.NET\Framework\v4.0.30319\csc.exe"
if not exist "%CSC%" (
echo .NET Framework compiler not found.
exit /b 1
)
"%CSC%" /nologo /target:winexe /platform:anycpu /codepage:65001 /optimize+ /out:CosmicTracker.exe /reference:System.dll /reference:System.Core.dll /reference:System.Drawing.dll /reference:System.Windows.Forms.dll CosmicTracker.cs
if errorlevel 1 exit /b 1
echo Built CosmicTracker.exe
@echo off
start "" powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File "%~dp0CosmicTracker.ps1"
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Windows.Forms;
namespace CosmicTracker
{
internal sealed class ReportItem
{
public int Rank { get; set; }
public string Source { get; set; }
public string Group { get; set; }
public string World { get; set; }
public int Stage { get; set; }
public double Progress { get; set; }
public int ProgressRank { get; set; }
public string ProgressText { get; set; }
public string Status { get; set; }
public string Updated { get; set; }
}
internal sealed class Site
{
public string Name { get; set; }
public string Url { get; set; }
public string Parser { get; set; }
}
internal sealed class GradeInfo
{
public int Stage { get; set; }
public string Status { get; set; }
}
internal sealed class ReportResult
{
public List<ReportItem> Items { get; set; }
public List<string> Errors { get; set; }
public DateTime RetrievedAt { get; set; }
}
internal static class Program
{
private static readonly Site[] Sites =
{
new Site { Name = "한국", Url = "https://guide.ff14.co.kr/cosmic_exploration/report", Parser = "Korean" },
new Site { Name = "글로벌", Url = "https://na.finalfantasyxiv.com/lodestone/cosmic_exploration/report/", Parser = "Lodestone" },
new Site { Name = "중국", Url = "https://ff14act.web.sdo.com/api/cosmicData/getCosmicData", Parser = "China" }
};
private static readonly Dictionary<string, double> GaugeProgress = new Dictionary<string, double>
{
{ "0", 0.0 },
{ "1", 12.5 },
{ "2", 25.0 },
{ "3", 37.5 },
{ "4", 50.0 },
{ "5", 62.5 },
{ "6", 75.0 },
{ "7", 87.5 },
{ "max", 100.0 }
};
private static readonly Dictionary<int, GradeInfo> ChinaAuxesiaDevelopmentGrades = new Dictionary<int, GradeInfo>
{
{ 192, new GradeInfo { Stage = 1, Status = "居住舱安装工程支援行动 物资准备中" } },
{ 193, new GradeInfo { Stage = 1, Status = "居住舱安装工程支援行动 联合工程准备中" } },
{ 194, new GradeInfo { Stage = 1, Status = "居住舱安装工程支援行动 联合工程进行中" } },
{ 195, new GradeInfo { Stage = 2, Status = "居住舱 竣工" } },
{ 196, new GradeInfo { Stage = 3, Status = "第一次宇宙飞线铺设工程 物资准备中" } },
{ 197, new GradeInfo { Stage = 3, Status = "第一次宇宙飞线铺设工程 施工中" } },
{ 198, new GradeInfo { Stage = 4, Status = "宇宙飞线都心段 竣工" } },
{ 199, new GradeInfo { Stage = 5, Status = "能源设施建设工程支援行动 物资准备中" } },
{ 200, new GradeInfo { Stage = 5, Status = "能源设施建设工程支援行动 联合工程(共2期第1期)准备中" } },
{ 201, new GradeInfo { Stage = 5, Status = "能源设施建设工程支援行动 联合工程(共2期第1期)进行中" } },
{ 202, new GradeInfo { Stage = 6, Status = "供水设施建设工程支援行动 联合工程(共2期第2期)准备中" } },
{ 203, new GradeInfo { Stage = 6, Status = "供水设施建设工程支援行动 联合工程(共2期第2期)进行中" } },
{ 204, new GradeInfo { Stage = 7, Status = "发电站和供水站 竣工" } },
{ 205, new GradeInfo { Stage = 8, Status = "子叶塔修复计划 物资准备中" } },
{ 206, new GradeInfo { Stage = 8, Status = "子叶塔修复计划 联合工程准备中" } },
{ 207, new GradeInfo { Stage = 8, Status = "子叶塔修复计划 联合工程进行中" } },
{ 208, new GradeInfo { Stage = 9, Status = "子叶塔 修复完成" } },
{ 209, new GradeInfo { Stage = 10, Status = "原木屋基地扩建工程 物资准备中" } },
{ 210, new GradeInfo { Stage = 10, Status = "原木屋基地扩建工程 联合工程准备中" } },
{ 211, new GradeInfo { Stage = 10, Status = "原木屋基地扩建工程 联合工程进行中" } },
{ 212, new GradeInfo { Stage = 11, Status = "原木屋基地扩建工程 竣工" } },
{ 213, new GradeInfo { Stage = 12, Status = "常叶塔开路支援行动 物资准备中" } },
{ 214, new GradeInfo { Stage = 12, Status = "常叶塔开路支援行动 联合工程准备中" } },
{ 215, new GradeInfo { Stage = 12, Status = "常叶塔开路支援行动 联合工程进行中" } },
{ 216, new GradeInfo { Stage = 13, Status = "常叶塔 开路竣工" } },
{ 217, new GradeInfo { Stage = 14, Status = "常叶塔修复计划 物资准备中" } },
{ 218, new GradeInfo { Stage = 14, Status = "常叶塔修复计划 联合工程准备中" } },
{ 219, new GradeInfo { Stage = 14, Status = "常叶塔修复计划 联合工程进行中" } },
{ 220, new GradeInfo { Stage = 15, Status = "常叶塔 修复完成" } },
{ 221, new GradeInfo { Stage = 15, Status = "第二次宇宙飞线铺设工程 物资收集中" } },
{ 222, new GradeInfo { Stage = 15, Status = "第二次宇宙飞线铺设工程 施工中" } },
{ 223, new GradeInfo { Stage = 16, Status = "树晶堆栈线 竣工" } },
{ 224, new GradeInfo { Stage = 17, Status = "宇宙港建设工程支援行动 物资准备中" } },
{ 225, new GradeInfo { Stage = 17, Status = "宇宙港建设工程支援行动 联合工程准备中" } },
{ 226, new GradeInfo { Stage = 17, Status = "宇宙港建设工程支援行动 联合工程进行中" } },
{ 227, new GradeInfo { Stage = 18, Status = "宇宙港 竣工" } },
{ 228, new GradeInfo { Stage = 19, Status = "末叶塔开路支援行动 物资准备中" } },
{ 229, new GradeInfo { Stage = 19, Status = "末叶塔开路支援行动 联合工程准备中" } },
{ 230, new GradeInfo { Stage = 19, Status = "末叶塔开路支援行动 联合工程进行中" } },
{ 231, new GradeInfo { Stage = 20, Status = "末叶塔 开路竣工" } },
{ 232, new GradeInfo { Stage = 21, Status = "末叶塔修复计划 物资准备中" } },
{ 233, new GradeInfo { Stage = 21, Status = "末叶塔修复计划 联合工程准备中" } },
{ 234, new GradeInfo { Stage = 21, Status = "末叶塔修复计划 联合工程进行中" } },
{ 235, new GradeInfo { Stage = 22, Status = "末叶塔 修复完成" } },
{ 236, new GradeInfo { Stage = 22, Status = "第三次宇宙飞线铺设工程 物资收集中" } },
{ 237, new GradeInfo { Stage = 22, Status = "第三次宇宙飞线铺设工程 施工中" } },
{ 238, new GradeInfo { Stage = 23, Status = "苍石林班线 竣工" } },
{ 239, new GradeInfo { Stage = 24, Status = "机甲库扩建工程 物资准备中" } },
{ 240, new GradeInfo { Stage = 24, Status = "机甲库扩建工程 联合工程准备中" } },
{ 241, new GradeInfo { Stage = 24, Status = "机甲库扩建工程 联合工程进行中" } },
{ 242, new GradeInfo { Stage = 25, Status = "机甲库 扩建完成" } },
{ 243, new GradeInfo { Stage = 26, Status = "核心林区环境整备支援行动 物资收集中" } },
{ 244, new GradeInfo { Stage = 26, Status = "核心林区环境整备支援行动 联合工程准备中" } },
{ 245, new GradeInfo { Stage = 27, Status = "核心林区环境整备支援行动 联合工程进行中" } },
{ 246, new GradeInfo { Stage = 28, Status = "核心林区上层 环境整备完成" } },
{ 247, new GradeInfo { Stage = 29, Status = "太母神桂重启作战 物资收集中" } },
{ 248, new GradeInfo { Stage = 29, Status = "太母神桂重启作战 联合工程准备中" } },
{ 249, new GradeInfo { Stage = 29, Status = "太母神桂重启作战 联合工程进行中" } },
{ 250, new GradeInfo { Stage = 30, Status = "太母神桂 重启完成" } },
{ 251, new GradeInfo { Stage = 30, Status = "奥克塞西亚行星探索计划 圆满完成" } }
};
[STAThread]
private static void Main(string[] args)
{
if (args.Any(arg => string.Equals(arg, "--test", StringComparison.OrdinalIgnoreCase)))
{
ReportResult result = GetAuxesiaReport();
var lines = new List<string>
{
"Items=" + result.Items.Count.ToString(CultureInfo.InvariantCulture),
"Errors=" + result.Errors.Count.ToString(CultureInfo.InvariantCulture)
};
lines.AddRange(result.Items.Take(10).Select(item => string.Format(
CultureInfo.InvariantCulture,
"{0}\t{1}\t{2}\t{3}\t{4}",
item.Rank,
item.Group,
item.World,
item.Stage,
item.ProgressText)));
lines.AddRange(result.Errors.Select(error => "Error=" + error));
System.IO.File.WriteAllLines("CosmicTracker.test.txt", lines.ToArray(), Encoding.UTF8);
return;
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new TrackerForm());
}
internal static ReportResult GetAuxesiaReport()
{
var items = new List<ReportItem>();
var errors = new List<string>();
foreach (var site in Sites)
{
try
{
string content = GetUrlText(site.Url);
IEnumerable<ReportItem> parsed;
if (site.Parser == "Korean")
{
parsed = ParseKoreanReport(content);
}
else if (site.Parser == "China")
{
parsed = ParseChinaReport(content);
}
else
{
parsed = ParseLodestoneReport(content);
}
items.AddRange(parsed);
}
catch (Exception ex)
{
errors.Add(site.Name + ": " + ex.Message);
}
}
return new ReportResult
{
Items = SetReportRanks(items),
Errors = errors,
RetrievedAt = DateTime.Now
};
}
private static string GetUrlText(string url)
{
ServicePointManager.SecurityProtocol = (SecurityProtocolType)3072;
using (var client = new WebClient())
{
client.Encoding = Encoding.UTF8;
client.Headers[HttpRequestHeader.UserAgent] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) CosmicTracker/1.0";
return client.DownloadString(url);
}
}
private static List<ReportItem> ParseKoreanReport(string html)
{
var items = new List<ReportItem>();
string updated = "";
Match updatedMatch = Regex.Match(html, "<span\\s+class=\"txt_update\">\\s*(?<updated>.*?)\\s*</span>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (updatedMatch.Success)
{
updated = CleanHtml(updatedMatch.Groups["updated"].Value);
}
foreach (Match cardMatch in Regex.Matches(html, "<li\\b[^>]*>\\s*<dl>(?<card>.*?)</dl>\\s*</li>", RegexOptions.Singleline | RegexOptions.IgnoreCase))
{
string card = cardMatch.Groups["card"].Value;
Match worldMatch = Regex.Match(card, "<dt\\s+class=\"title\">\\s*(?<world>.*?)\\s*</dt>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
Match stageMatch = Regex.Match(card, "<div\\s+class=\"level\">\\s*<strong>\\s*(?<stage>\\d+)\\s*</strong>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (!worldMatch.Success || !stageMatch.Success)
{
continue;
}
double progress = 0.0;
Match progressMatch = Regex.Match(card, "<div\\s+class=\"gauge\"\\s+style=\"width:\\s*(?<progress>[\\d.]+)%", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (progressMatch.Success)
{
progress = ParseDouble(progressMatch.Groups["progress"].Value);
}
string status = "";
Match statusMatch = Regex.Match(card, "<dd>.*?<p>\\s*(?<status>.*?)\\s*</p>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (statusMatch.Success)
{
status = CleanHtml(statusMatch.Groups["status"].Value);
}
items.Add(NewReportItem("한국", "한국", CleanHtml(worldMatch.Groups["world"].Value), ParseInt(stageMatch.Groups["stage"].Value), progress, status, updated));
}
return items;
}
private static List<ReportItem> ParseLodestoneReport(string html)
{
var items = new List<ReportItem>();
string updated = "";
Match updatedMatch = Regex.Match(html, "ldst_strftime\\((?<seconds>\\d+),\\s*'YMDHM'\\)");
if (updatedMatch.Success)
{
updated = UnixSecondsToLocalText(long.Parse(updatedMatch.Groups["seconds"].Value, CultureInfo.InvariantCulture));
}
MatchCollection markers = Regex.Matches(html, "<h3\\s+class=\"cosmic__report__dc__title\">\\s*(?<dc>.*?)\\s*</h3>|<div\\s+class=\"cosmic__report__card\\b[^\"]*\"", RegexOptions.Singleline | RegexOptions.IgnoreCase);
string currentDc = "글로벌";
for (int i = 0; i < markers.Count; i++)
{
Match marker = markers[i];
if (marker.Groups["dc"].Success)
{
currentDc = CleanHtml(marker.Groups["dc"].Value);
continue;
}
int start = marker.Index;
int end = i + 1 < markers.Count ? markers[i + 1].Index : html.Length;
string segment = html.Substring(start, end - start);
Match worldMatch = Regex.Match(segment, "cosmic__report__card__name.*?<p[^>]*>\\s*(?<world>.*?)\\s*</p>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
Match stageMatch = Regex.Match(segment, "cosmic__report__grade__level.*?<p>\\s*(?<stage>\\d+)\\s*</p>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (!worldMatch.Success || !stageMatch.Success)
{
continue;
}
double progress = 0.0;
Match gaugeMatch = Regex.Match(segment, "gauge-(?<gauge>max|\\d+)", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (gaugeMatch.Success)
{
progress = GaugeToPercent(gaugeMatch.Groups["gauge"].Value);
}
string status = "";
Match statusMatch = Regex.Match(segment, "<p\\s+class=\"cosmic__report__status__text\">\\s*(?<status>.*?)\\s*</p>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (!statusMatch.Success)
{
statusMatch = Regex.Match(segment, "<div\\s+class=\"cosmic__report__status__completed\">\\s*(?<status>.*?)\\s*</div>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
}
if (statusMatch.Success)
{
status = CleanHtml(statusMatch.Groups["status"].Value);
}
items.Add(NewReportItem("글로벌", currentDc, CleanHtml(worldMatch.Groups["world"].Value), ParseInt(stageMatch.Groups["stage"].Value), progress, status, updated));
}
return items;
}
private static List<ReportItem> ParseChinaReport(string json)
{
var items = new List<ReportItem>();
if (!json.Contains("\"code\":10000"))
{
throw new InvalidOperationException("중국 서버 API 응답이 예상 형식과 다릅니다.");
}
foreach (Match objectMatch in Regex.Matches(json, "\\{[^{}]*\"PlanetSerialId\":3[^{}]*\\}", RegexOptions.Singleline))
{
string entry = objectMatch.Value;
int gradeKey = ExtractInt(entry, "DevelopmentGrade");
GradeInfo gradeInfo;
if (!ChinaAuxesiaDevelopmentGrades.TryGetValue(gradeKey, out gradeInfo))
{
continue;
}
double progress = ExtractInt(entry, "ProgressRate") / 10.0;
string areaName = ExtractString(entry, "area_name");
string groupName = ExtractString(entry, "group_name");
string updated = ExtractString(entry, "data_time");
items.Add(NewReportItem("중국", "중국/" + areaName, groupName, gradeInfo.Stage, progress, gradeInfo.Status, updated));
}
return items;
}
private static ReportItem NewReportItem(string source, string group, string world, int stage, double progress, string status, string updated)
{
int progressRank = (int)Math.Floor(progress);
return new ReportItem
{
Rank = 0,
Source = source,
Group = group,
World = world,
Stage = stage,
Progress = progress,
ProgressRank = progressRank,
ProgressText = progressRank.ToString(CultureInfo.InvariantCulture) + "%",
Status = status,
Updated = updated
};
}
private static List<ReportItem> SetReportRanks(IEnumerable<ReportItem> sourceItems)
{
var sorted = sourceItems
.OrderByDescending(item => item.Stage)
.ThenByDescending(item => item.ProgressRank)
.ThenBy(item => item.Group, StringComparer.Ordinal)
.ThenBy(item => item.World, StringComparer.Ordinal)
.ToList();
int rank = 0;
int position = 0;
int? lastStage = null;
int? lastProgressRank = null;
foreach (ReportItem item in sorted)
{
position++;
if (!lastStage.HasValue || item.Stage != lastStage.Value || item.ProgressRank != lastProgressRank.Value)
{
rank = position;
lastStage = item.Stage;
lastProgressRank = item.ProgressRank;
}
item.Rank = rank;
}
return sorted;
}
private static string CleanHtml(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "";
}
string text = Regex.Replace(value, "<[^>]+>", " ");
text = WebUtility.HtmlDecode(text);
text = Regex.Replace(text, "\\s+", " ");
return text.Trim();
}
private static double GaugeToPercent(string gauge)
{
double value;
return GaugeProgress.TryGetValue(gauge, out value) ? value : 0.0;
}
private static int ExtractInt(string jsonObject, string name)
{
Match match = Regex.Match(jsonObject, "\"" + Regex.Escape(name) + "\"\\s*:\\s*(?<value>-?\\d+)");
return match.Success ? ParseInt(match.Groups["value"].Value) : 0;
}
private static string ExtractString(string jsonObject, string name)
{
Match match = Regex.Match(jsonObject, "\"" + Regex.Escape(name) + "\"\\s*:\\s*\"(?<value>(?:\\\\.|[^\"])*)\"");
return match.Success ? Regex.Unescape(match.Groups["value"].Value) : "";
}
private static int ParseInt(string value)
{
int parsed;
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed) ? parsed : 0;
}
private static double ParseDouble(string value)
{
double parsed;
return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out parsed) ? parsed : 0.0;
}
private static string UnixSecondsToLocalText(long seconds)
{
DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
return epoch.AddSeconds(seconds).ToLocalTime().ToString("yyyy/MM/dd HH:mm", CultureInfo.InvariantCulture);
}
}
internal sealed class TrackerForm : Form
{
private readonly Label statusLabel;
private readonly Label footerLabel;
private readonly Button refreshButton;
private readonly DataGridView grid;
private readonly System.Windows.Forms.Timer refreshTimer;
public TrackerForm()
{
Text = "CosmicTracker";
TopMost = true;
Opacity = 0.88;
BackColor = Color.FromArgb(17, 24, 39);
ForeColor = Color.FromArgb(248, 250, 252);
Font = new Font("Malgun Gothic", 9F);
Size = new Size(590, 760);
MinimumSize = new Size(440, 360);
StartPosition = FormStartPosition.CenterScreen;
var root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 4,
Padding = new Padding(12),
BackColor = Color.FromArgb(17, 24, 39)
};
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 42));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 28));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 26));
Controls.Add(root);
var header = new Panel { Dock = DockStyle.Fill };
var title = new Label
{
Text = "아욱세시아 순위표",
AutoSize = true,
ForeColor = Color.FromArgb(248, 250, 252),
Font = new Font("Malgun Gothic", 11F, FontStyle.Bold),
Location = new Point(0, 0)
};
var subtitle = new Label
{
Text = "단계 높은 순, 진행도 높은 순",
AutoSize = true,
ForeColor = Color.FromArgb(156, 207, 232),
Font = new Font("Malgun Gothic", 8F),
Location = new Point(1, 24)
};
refreshButton = new Button
{
Text = "새로고침",
Width = 82,
Height = 28,
Anchor = AnchorStyles.Top | AnchorStyles.Right,
Location = new Point(Width - 205, 3)
};
var closeButton = new Button
{
Text = "닫기",
Width = 54,
Height = 28,
Anchor = AnchorStyles.Top | AnchorStyles.Right,
Location = new Point(Width - 115, 3)
};
header.Resize += delegate
{
refreshButton.Left = header.Width - 148;
closeButton.Left = header.Width - 58;
};
refreshButton.Click += delegate { RefreshData(); };
closeButton.Click += delegate { Close(); };
header.Controls.Add(title);
header.Controls.Add(subtitle);
header.Controls.Add(refreshButton);
header.Controls.Add(closeButton);
root.Controls.Add(header, 0, 0);
statusLabel = new Label
{
Text = "불러오는 중...",
Dock = DockStyle.Fill,
ForeColor = Color.FromArgb(214, 246, 255),
TextAlign = ContentAlignment.MiddleLeft
};
root.Controls.Add(statusLabel, 0, 1);
grid = new DataGridView
{
Dock = DockStyle.Fill,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
AllowUserToResizeRows = false,
AutoGenerateColumns = false,
BackgroundColor = Color.FromArgb(17, 24, 39),
BorderStyle = BorderStyle.None,
CellBorderStyle = DataGridViewCellBorderStyle.None,
ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single,
EnableHeadersVisualStyles = false,
ReadOnly = true,
RowHeadersVisible = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect
};
grid.ColumnHeadersDefaultCellStyle.BackColor = Color.FromArgb(38, 61, 77);
grid.ColumnHeadersDefaultCellStyle.ForeColor = Color.FromArgb(214, 246, 255);
grid.ColumnHeadersDefaultCellStyle.Font = new Font("Malgun Gothic", 9F, FontStyle.Bold);
grid.DefaultCellStyle.BackColor = Color.FromArgb(29, 42, 58);
grid.DefaultCellStyle.ForeColor = Color.FromArgb(248, 250, 252);
grid.DefaultCellStyle.SelectionBackColor = Color.FromArgb(50, 92, 120);
grid.DefaultCellStyle.SelectionForeColor = Color.White;
grid.AlternatingRowsDefaultCellStyle.BackColor = Color.FromArgb(24, 35, 50);
grid.RowTemplate.Height = 28;
grid.Columns.Add(TextColumn("순위", "Rank", 46));
grid.Columns.Add(TextColumn("구분", "Group", 104));
grid.Columns.Add(TextColumn("서버", "World", 150));
grid.Columns.Add(TextColumn("단계", "Stage", 50));
grid.Columns.Add(TextColumn("진행", "ProgressText", 60));
root.Controls.Add(grid, 0, 2);
footerLabel = new Label
{
Text = "",
Dock = DockStyle.Fill,
ForeColor = Color.FromArgb(156, 207, 232),
TextAlign = ContentAlignment.MiddleLeft,
AutoEllipsis = true
};
root.Controls.Add(footerLabel, 0, 3);
refreshTimer = new System.Windows.Forms.Timer { Interval = 5 * 60 * 1000 };
refreshTimer.Tick += delegate { RefreshData(); };
Shown += delegate
{
RefreshData();
refreshTimer.Start();
};
}
private static DataGridViewTextBoxColumn TextColumn(string header, string property, int width)
{
return new DataGridViewTextBoxColumn
{
HeaderText = header,
DataPropertyName = property,
Width = width,
SortMode = DataGridViewColumnSortMode.NotSortable
};
}
private void RefreshData()
{
refreshButton.Enabled = false;
statusLabel.Text = "갱신 중...";
ThreadPool.QueueUserWorkItem(delegate
{
ReportResult result = Program.GetAuxesiaReport();
BeginInvoke(new Action(delegate
{
grid.DataSource = result.Items;
statusLabel.Text = string.Format("마지막 갱신 {0} / {1}개 서버", result.RetrievedAt.ToString("HH:mm:ss"), result.Items.Count);
if (result.Errors.Count > 0)
{
footerLabel.Text = "일부 갱신 실패: " + string.Join(" / ", result.Errors.ToArray());
}
else
{
var updates = result.Items
.Where(item => !string.IsNullOrWhiteSpace(item.Updated))
.GroupBy(item => item.Source)
.Select(group => group.Key + ": " + group.First().Updated);
footerLabel.Text = string.Join(" / ", updates.ToArray());
}
refreshButton.Enabled = true;
}));
});
}
}
}
param(
[switch]$Test
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
if (-not $Test -and [System.Threading.Thread]::CurrentThread.GetApartmentState() -ne [System.Threading.ApartmentState]::STA) {
$scriptPath = $PSCommandPath
Start-Process powershell.exe -WindowStyle Hidden -ArgumentList @(
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-STA',
'-File', "`"$scriptPath`""
)
exit
}
Add-Type -AssemblyName System.Net.Http
$script:RefreshInterval = [TimeSpan]::FromMinutes(5)
$script:Sites = @(
[pscustomobject]@{
Name = '한국'
Url = 'https://guide.ff14.co.kr/cosmic_exploration/report'
Parser = 'Korean'
},
[pscustomobject]@{
Name = '글로벌'
Url = 'https://na.finalfantasyxiv.com/lodestone/cosmic_exploration/report/'
Parser = 'Lodestone'
},
[pscustomobject]@{
Name = '중국'
Url = 'https://ff14act.web.sdo.com/api/cosmicData/getCosmicData'
Parser = 'China'
}
)
$script:GaugeProgress = @{
'0' = 0.0
'1' = 12.5
'2' = 25.0
'3' = 37.5
'4' = 50.0
'5' = 62.5
'6' = 75.0
'7' = 87.5
'max' = 100.0
}
$script:ChinaAuxesiaDevelopmentGrades = @{
192 = [pscustomobject]@{ Stage = 1; Status = '居住舱安装工程支援行动 物资准备中' }
193 = [pscustomobject]@{ Stage = 1; Status = '居住舱安装工程支援行动 联合工程准备中' }
194 = [pscustomobject]@{ Stage = 1; Status = '居住舱安装工程支援行动 联合工程进行中' }
195 = [pscustomobject]@{ Stage = 2; Status = '居住舱 竣工' }
196 = [pscustomobject]@{ Stage = 3; Status = '第一次宇宙飞线铺设工程 物资准备中' }
197 = [pscustomobject]@{ Stage = 3; Status = '第一次宇宙飞线铺设工程 施工中' }
198 = [pscustomobject]@{ Stage = 4; Status = '宇宙飞线都心段 竣工' }
199 = [pscustomobject]@{ Stage = 5; Status = '能源设施建设工程支援行动 物资准备中' }
200 = [pscustomobject]@{ Stage = 5; Status = '能源设施建设工程支援行动 联合工程(共2期第1期)准备中' }
201 = [pscustomobject]@{ Stage = 5; Status = '能源设施建设工程支援行动 联合工程(共2期第1期)进行中' }
202 = [pscustomobject]@{ Stage = 6; Status = '供水设施建设工程支援行动 联合工程(共2期第2期)准备中' }
203 = [pscustomobject]@{ Stage = 6; Status = '供水设施建设工程支援行动 联合工程(共2期第2期)进行中' }
204 = [pscustomobject]@{ Stage = 7; Status = '发电站和供水站 竣工' }
205 = [pscustomobject]@{ Stage = 8; Status = '子叶塔修复计划 物资准备中' }
206 = [pscustomobject]@{ Stage = 8; Status = '子叶塔修复计划 联合工程准备中' }
207 = [pscustomobject]@{ Stage = 8; Status = '子叶塔修复计划 联合工程进行中' }
208 = [pscustomobject]@{ Stage = 9; Status = '子叶塔 修复完成' }
209 = [pscustomobject]@{ Stage = 10; Status = '原木屋基地扩建工程 物资准备中' }
210 = [pscustomobject]@{ Stage = 10; Status = '原木屋基地扩建工程 联合工程准备中' }
211 = [pscustomobject]@{ Stage = 10; Status = '原木屋基地扩建工程 联合工程进行中' }
212 = [pscustomobject]@{ Stage = 11; Status = '原木屋基地扩建工程 竣工' }
213 = [pscustomobject]@{ Stage = 12; Status = '常叶塔开路支援行动 物资准备中' }
214 = [pscustomobject]@{ Stage = 12; Status = '常叶塔开路支援行动 联合工程准备中' }
215 = [pscustomobject]@{ Stage = 12; Status = '常叶塔开路支援行动 联合工程进行中' }
216 = [pscustomobject]@{ Stage = 13; Status = '常叶塔 开路竣工' }
217 = [pscustomobject]@{ Stage = 14; Status = '常叶塔修复计划 物资准备中' }
218 = [pscustomobject]@{ Stage = 14; Status = '常叶塔修复计划 联合工程准备中' }
219 = [pscustomobject]@{ Stage = 14; Status = '常叶塔修复计划 联合工程进行中' }
220 = [pscustomobject]@{ Stage = 15; Status = '常叶塔 修复完成' }
221 = [pscustomobject]@{ Stage = 15; Status = '第二次宇宙飞线铺设工程 物资收集中' }
222 = [pscustomobject]@{ Stage = 15; Status = '第二次宇宙飞线铺设工程 施工中' }
223 = [pscustomobject]@{ Stage = 16; Status = '树晶堆栈线 竣工' }
224 = [pscustomobject]@{ Stage = 17; Status = '宇宙港建设工程支援行动 物资准备中' }
225 = [pscustomobject]@{ Stage = 17; Status = '宇宙港建设工程支援行动 联合工程准备中' }
226 = [pscustomobject]@{ Stage = 17; Status = '宇宙港建设工程支援行动 联合工程进行中' }
227 = [pscustomobject]@{ Stage = 18; Status = '宇宙港 竣工' }
228 = [pscustomobject]@{ Stage = 19; Status = '末叶塔开路支援行动 物资准备中' }
229 = [pscustomobject]@{ Stage = 19; Status = '末叶塔开路支援行动 联合工程准备中' }
230 = [pscustomobject]@{ Stage = 19; Status = '末叶塔开路支援行动 联合工程进行中' }
231 = [pscustomobject]@{ Stage = 20; Status = '末叶塔 开路竣工' }
232 = [pscustomobject]@{ Stage = 21; Status = '末叶塔修复计划 物资准备中' }
233 = [pscustomobject]@{ Stage = 21; Status = '末叶塔修复计划 联合工程准备中' }
234 = [pscustomobject]@{ Stage = 21; Status = '末叶塔修复计划 联合工程进行中' }
235 = [pscustomobject]@{ Stage = 22; Status = '末叶塔 修复完成' }
236 = [pscustomobject]@{ Stage = 22; Status = '第三次宇宙飞线铺设工程 物资收集中' }
237 = [pscustomobject]@{ Stage = 22; Status = '第三次宇宙飞线铺设工程 施工中' }
238 = [pscustomobject]@{ Stage = 23; Status = '苍石林班线 竣工' }
239 = [pscustomobject]@{ Stage = 24; Status = '机甲库扩建工程 物资准备中' }
240 = [pscustomobject]@{ Stage = 24; Status = '机甲库扩建工程 联合工程准备中' }
241 = [pscustomobject]@{ Stage = 24; Status = '机甲库扩建工程 联合工程进行中' }
242 = [pscustomobject]@{ Stage = 25; Status = '机甲库 扩建完成' }
243 = [pscustomobject]@{ Stage = 26; Status = '核心林区环境整备支援行动 物资收集中' }
244 = [pscustomobject]@{ Stage = 26; Status = '核心林区环境整备支援行动 联合工程准备中' }
245 = [pscustomobject]@{ Stage = 27; Status = '核心林区环境整备支援行动 联合工程进行中' }
246 = [pscustomobject]@{ Stage = 28; Status = '核心林区上层 环境整备完成' }
247 = [pscustomobject]@{ Stage = 29; Status = '太母神桂重启作战 物资收集中' }
248 = [pscustomobject]@{ Stage = 29; Status = '太母神桂重启作战 联合工程准备中' }
249 = [pscustomobject]@{ Stage = 29; Status = '太母神桂重启作战 联合工程进行中' }
250 = [pscustomobject]@{ Stage = 30; Status = '太母神桂 重启完成' }
251 = [pscustomobject]@{ Stage = 30; Status = '奥克塞西亚行星探索计划 圆满完成' }
}
function ConvertFrom-HtmlText {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) {
return ''
}
$text = [regex]::Replace($Value, '<[^>]+>', ' ')
$text = [System.Net.WebUtility]::HtmlDecode($text)
$text = [regex]::Replace($text, '\s+', ' ')
return $text.Trim()
}
function Format-ProgressPercent {
param([double]$Value)
return ('{0:0}%' -f [Math]::Floor($Value))
}
function Convert-GaugeToPercent {
param([string]$Gauge)
if ([string]::IsNullOrWhiteSpace($Gauge)) {
return 0.0
}
if ($script:GaugeProgress.ContainsKey($Gauge)) {
return [double]$script:GaugeProgress[$Gauge]
}
return 0.0
}
function Convert-UnixSecondsToLocalText {
param([long]$Seconds)
$epoch = [DateTime]::SpecifyKind([DateTime]'1970-01-01 00:00:00', [DateTimeKind]::Utc)
return $epoch.AddSeconds($Seconds).ToLocalTime().ToString('yyyy/MM/dd HH:mm')
}
function Get-UrlText {
param([string]$Url)
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
$client = New-Object System.Net.Http.HttpClient
$client.Timeout = [TimeSpan]::FromSeconds(30)
[void]$client.DefaultRequestHeaders.UserAgent.ParseAdd('Mozilla/5.0 (Windows NT 10.0; Win64; x64) CosmicTracker/1.0')
try {
$response = $client.GetAsync($Url).GetAwaiter().GetResult()
[void]$response.EnsureSuccessStatusCode()
$bytes = $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
$charset = $null
if ($response.Content.Headers.ContentType -and $response.Content.Headers.ContentType.CharSet) {
$charset = $response.Content.Headers.ContentType.CharSet.Trim('"')
}
if ([string]::IsNullOrWhiteSpace($charset)) {
$encoding = [Text.Encoding]::UTF8
}
else {
try {
$encoding = [Text.Encoding]::GetEncoding($charset)
}
catch {
$encoding = [Text.Encoding]::UTF8
}
}
return $encoding.GetString($bytes)
}
finally {
$client.Dispose()
}
}
function New-ReportItem {
param(
[string]$Source,
[string]$Group,
[string]$World,
[int]$Stage,
[double]$Progress,
[string]$Status,
[string]$Updated
)
[pscustomobject]@{
Rank = 0
Source = $Source
Group = $Group
World = $World
Stage = $Stage
Progress = $Progress
ProgressRank = [int][Math]::Floor($Progress)
ProgressText = (Format-ProgressPercent $Progress)
Status = $Status
Updated = $Updated
}
}
function Set-ReportRanks {
param([object[]]$Items)
$sortedItems = @(
$Items |
Sort-Object -Property `
@{ Expression = { $_.Stage }; Descending = $true },
@{ Expression = { $_.ProgressRank }; Descending = $true },
@{ Expression = { $_.Group }; Descending = $false },
@{ Expression = { $_.World }; Descending = $false }
)
$rank = 0
$position = 0
$lastStage = $null
$lastProgressRank = $null
foreach ($item in $sortedItems) {
$position++
if ($null -eq $lastStage -or $item.Stage -ne $lastStage -or $item.ProgressRank -ne $lastProgressRank) {
$rank = $position
$lastStage = $item.Stage
$lastProgressRank = $item.ProgressRank
}
$item.Rank = $rank
}
return $sortedItems
}
function Parse-KoreanReport {
param([string]$Html)
$items = New-Object 'System.Collections.Generic.List[object]'
$updated = ''
if ($Html -match '(?is)<span\s+class="txt_update">\s*(?<updated>.*?)\s*</span>') {
$updated = ConvertFrom-HtmlText $Matches.updated
}
$cards = [regex]::Matches($Html, '(?is)<li\b[^>]*>\s*<dl>(?<card>.*?)</dl>\s*</li>')
foreach ($cardMatch in $cards) {
$card = $cardMatch.Groups['card'].Value
if ($card -notmatch '(?is)<dt\s+class="title">\s*(?<world>.*?)\s*</dt>') {
continue
}
$world = ConvertFrom-HtmlText $Matches.world
if ($card -notmatch '(?is)<div\s+class="level">\s*<strong>\s*(?<stage>\d+)\s*</strong>') {
continue
}
$stage = [int]$Matches.stage
$progress = 0.0
if ($card -match '(?is)<div\s+class="gauge"\s+style="width:\s*(?<progress>[\d.]+)%') {
$progress = [double]$Matches.progress
}
$status = ''
if ($card -match '(?is)<dd>.*?<p>\s*(?<status>.*?)\s*</p>') {
$status = ConvertFrom-HtmlText $Matches.status
}
[void]$items.Add((New-ReportItem `
-Source '한국' `
-Group '한국' `
-World $world `
-Stage $stage `
-Progress $progress `
-Status $status `
-Updated $updated))
}
return $items.ToArray()
}
function Parse-LodestoneReport {
param([string]$Html)
$items = New-Object 'System.Collections.Generic.List[object]'
$updated = ''
if ($Html -match "ldst_strftime\((?<seconds>\d+),\s*'YMDHM'\)") {
$updated = Convert-UnixSecondsToLocalText ([long]$Matches.seconds)
}
$markerPattern = '(?is)<h3\s+class="cosmic__report__dc__title">\s*(?<dc>.*?)\s*</h3>|<div\s+class="cosmic__report__card\b[^"]*"'
$markers = [regex]::Matches($Html, $markerPattern)
$currentDc = '글로벌'
for ($index = 0; $index -lt $markers.Count; $index++) {
$marker = $markers[$index]
if ($marker.Groups['dc'].Success) {
$currentDc = ConvertFrom-HtmlText $marker.Groups['dc'].Value
continue
}
$start = $marker.Index
$end = $Html.Length
for ($next = $index + 1; $next -lt $markers.Count; $next++) {
$end = $markers[$next].Index
break
}
$segment = $Html.Substring($start, $end - $start)
if ($segment -notmatch '(?is)cosmic__report__card__name.*?<p[^>]*>\s*(?<world>.*?)\s*</p>') {
continue
}
$world = ConvertFrom-HtmlText $Matches.world
if ($segment -notmatch '(?is)cosmic__report__grade__level.*?<p>\s*(?<stage>\d+)\s*</p>') {
continue
}
$stage = [int]$Matches.stage
$progress = 0.0
if ($segment -match '(?is)gauge-(?<gauge>max|\d+)') {
$progress = Convert-GaugeToPercent $Matches.gauge
}
$status = ''
if ($segment -match '(?is)<p\s+class="cosmic__report__status__text">\s*(?<status>.*?)\s*</p>') {
$status = ConvertFrom-HtmlText $Matches.status
}
elseif ($segment -match '(?is)<div\s+class="cosmic__report__status__completed">\s*(?<status>.*?)\s*</div>') {
$status = ConvertFrom-HtmlText $Matches.status
}
[void]$items.Add((New-ReportItem `
-Source '글로벌' `
-Group $currentDc `
-World $world `
-Stage $stage `
-Progress $progress `
-Status $status `
-Updated $updated))
}
return $items.ToArray()
}
function Parse-ChinaReport {
param([string]$Json)
$items = New-Object 'System.Collections.Generic.List[object]'
$response = $Json | ConvertFrom-Json
if ($response.code -ne 10000 -or $null -eq $response.data) {
throw "중국 서버 API 응답이 예상 형식과 다릅니다."
}
foreach ($entry in @($response.data)) {
if ([int]$entry.PlanetSerialId -ne 3) {
continue
}
$gradeKey = [int]$entry.DevelopmentGrade
if (-not $script:ChinaAuxesiaDevelopmentGrades.ContainsKey($gradeKey)) {
continue
}
$gradeInfo = $script:ChinaAuxesiaDevelopmentGrades[$gradeKey]
$progress = [double]$entry.ProgressRate / 10.0
$group = '중국/{0}' -f [string]$entry.area_name
$world = [string]$entry.group_name
$updated = [string]$entry.data_time
[void]$items.Add((New-ReportItem `
-Source '중국' `
-Group $group `
-World $world `
-Stage ([int]$gradeInfo.Stage) `
-Progress $progress `
-Status ([string]$gradeInfo.Status) `
-Updated $updated))
}
return $items.ToArray()
}
function Get-AuxesiaReport {
$items = New-Object 'System.Collections.Generic.List[object]'
$errors = New-Object 'System.Collections.Generic.List[object]'
foreach ($site in $script:Sites) {
try {
$html = Get-UrlText $site.Url
if ($site.Parser -eq 'Korean') {
$parsed = Parse-KoreanReport $html
}
elseif ($site.Parser -eq 'China') {
$parsed = Parse-ChinaReport $html
}
else {
$parsed = Parse-LodestoneReport $html
}
foreach ($item in $parsed) {
[void]$items.Add($item)
}
}
catch {
[void]$errors.Add([pscustomobject]@{
Source = $site.Name
Message = $_.Exception.Message
Detail = $_.InvocationInfo.PositionMessage
})
}
}
$rankedItems = Set-ReportRanks $items.ToArray()
[pscustomobject]@{
Items = $rankedItems
Errors = $errors.ToArray()
RetrievedAt = (Get-Date)
}
}
if ($Test) {
$report = Get-AuxesiaReport
$report.Items |
Select-Object Rank, Group, World, Stage, ProgressText, Status |
Format-Table -AutoSize
if ($report.Errors.Count -gt 0) {
Write-Host ''
Write-Host 'Errors:'
$report.Errors | Format-Table -AutoSize
}
return
}
Add-Type -AssemblyName PresentationFramework
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName WindowsBase
$xaml = @'
<Window
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="CosmicTracker"
Width="560"
Height="760"
MinWidth="420"
MinHeight="360"
WindowStartupLocation="CenterScreen"
WindowStyle="None"
ResizeMode="CanResizeWithGrip"
AllowsTransparency="True"
Background="Transparent"
Topmost="True"
ShowInTaskbar="True"
UseLayoutRounding="True"
SnapsToDevicePixels="True">
<Border Background="#CC111827" BorderBrush="#6688F7FF" BorderThickness="1" CornerRadius="8">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="38" />
<RowDefinition Height="28" />
<RowDefinition Height="*" />
<RowDefinition Height="26" />
</Grid.RowDefinitions>
<Grid x:Name="HeaderBar" Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Orientation="Vertical" VerticalAlignment="Center">
<TextBlock Text="아욱세시아 순위표" Foreground="#F8FAFC" FontSize="16" FontWeight="SemiBold" />
<TextBlock Text="단계 높은 순, 진행도 높은 순" Foreground="#9CCFE8" FontSize="11" />
</StackPanel>
<Button x:Name="RefreshButton" Grid.Column="1" Content="새로고침" Width="72" Height="26" Margin="8,0,0,0" />
<Button x:Name="CloseButton" Grid.Column="2" Content="닫기" Width="48" Height="26" Margin="6,0,0,0" />
</Grid>
<TextBlock x:Name="StatusText" Grid.Row="1" Foreground="#D6F6FF" FontSize="12" VerticalAlignment="Center" Text="불러오는 중..." />
<DataGrid
x:Name="ReportGrid"
Grid.Row="2"
AutoGenerateColumns="False"
Background="Transparent"
BorderThickness="0"
CanUserAddRows="False"
CanUserDeleteRows="False"
CanUserReorderColumns="False"
CanUserResizeRows="False"
CanUserSortColumns="False"
HeadersVisibility="Column"
IsReadOnly="True"
GridLinesVisibility="None"
RowHeaderWidth="0"
RowHeight="28"
ColumnHeaderHeight="26"
FontSize="12"
Foreground="#F8FAFC"
SelectionMode="Single"
SelectionUnit="FullRow"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<DataGrid.Resources>
<Style TargetType="{x:Type DataGridColumnHeader}">
<Setter Property="Background" Value="#263D4D" />
<Setter Property="Foreground" Value="#D6F6FF" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="BorderBrush" Value="#335E7688" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="Padding" Value="6,0" />
</Style>
<Style TargetType="{x:Type DataGridRow}">
<Setter Property="Background" Value="#16FFFFFF" />
<Setter Property="Foreground" Value="#F8FAFC" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="ToolTip" Value="{Binding Status}" />
</Style>
<Style TargetType="{x:Type DataGridCell}">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="#F8FAFC" />
<Setter Property="Padding" Value="6,0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style TargetType="{x:Type Button}">
<Setter Property="Background" Value="#263D4D" />
<Setter Property="Foreground" Value="#F8FAFC" />
<Setter Property="BorderBrush" Value="#6688F7FF" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="8,2" />
</Style>
</DataGrid.Resources>
<DataGrid.Columns>
<DataGridTextColumn Header="순위" Binding="{Binding Rank}" Width="44" />
<DataGridTextColumn Header="구분" Binding="{Binding Group}" Width="84" />
<DataGridTextColumn Header="서버" Binding="{Binding World}" Width="*" />
<DataGridTextColumn Header="단계" Binding="{Binding Stage}" Width="48" />
<DataGridTextColumn Header="진행" Binding="{Binding ProgressText}" Width="64" />
</DataGrid.Columns>
</DataGrid>
<TextBlock x:Name="FooterText" Grid.Row="3" Foreground="#9CCFE8" FontSize="11" VerticalAlignment="Bottom" TextTrimming="CharacterEllipsis" />
</Grid>
</Border>
</Window>
'@
[xml]$xamlXml = $xaml
$reader = New-Object System.Xml.XmlNodeReader $xamlXml
$window = [Windows.Markup.XamlReader]::Load($reader)
$headerBar = $window.FindName('HeaderBar')
$refreshButton = $window.FindName('RefreshButton')
$closeButton = $window.FindName('CloseButton')
$statusText = $window.FindName('StatusText')
$footerText = $window.FindName('FooterText')
$reportGrid = $window.FindName('ReportGrid')
$updateReport = {
try {
$statusText.Text = '갱신 중...'
$refreshButton.IsEnabled = $false
[System.Windows.Input.Mouse]::OverrideCursor = [System.Windows.Input.Cursors]::Wait
$report = Get-AuxesiaReport
$items = @($report.Items)
$reportGrid.ItemsSource = $items
$retrievedText = $report.RetrievedAt.ToString('HH:mm:ss')
$statusText.Text = ('마지막 갱신 {0} / {1}개 서버' -f $retrievedText, $items.Count)
$updates = $items |
Where-Object { -not [string]::IsNullOrWhiteSpace($_.Updated) } |
Group-Object Source |
ForEach-Object {
$first = $_.Group | Select-Object -First 1
'{0}: {1}' -f $_.Name, $first.Updated
}
if ($report.Errors.Count -gt 0) {
$errorSources = ($report.Errors | ForEach-Object { $_.Source }) -join ', '
$footerText.Text = ('일부 갱신 실패: {0}' -f $errorSources)
}
elseif ($updates.Count -gt 0) {
$footerText.Text = ($updates -join ' / ')
}
else {
$footerText.Text = ''
}
}
catch {
$statusText.Text = ('갱신 실패: {0}' -f $_.Exception.Message)
}
finally {
$refreshButton.IsEnabled = $true
[System.Windows.Input.Mouse]::OverrideCursor = $null
}
}
$timer = New-Object Windows.Threading.DispatcherTimer
$timer.Interval = $script:RefreshInterval
$timer.Add_Tick($updateReport)
$headerBar.Add_MouseLeftButtonDown({
if ($_.ChangedButton -eq [System.Windows.Input.MouseButton]::Left) {
try {
$window.DragMove()
}
catch {
}
}
})
$refreshButton.Add_Click($updateReport)
$closeButton.Add_Click({ $window.Close() })
$window.Add_Loaded({
& $updateReport
$timer.Start()
})
$window.Add_Closed({
$timer.Stop()
})
[void]$window.ShowDialog()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment