DartDisclosureParser.cs 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. using System.Globalization;
  2. using System.Text.Json;
  3. namespace Infrastructure.StockData;
  4. /// <summary>
  5. /// OpenDART 공시검색(list.json) 응답 파서 — 순수 C# (System.Text.Json).
  6. /// 응답 골격: { "status", "message", "page_no", "page_count", "total_count", "total_page", "list":[ {...} ] }.
  7. /// status: "000" 정상, "013" 조회된 데이터 없음(정상 빈결과), "010"/"011" 키 오류, "020" 요청 제한(쿼터) 초과,
  8. /// "100" 파라미터 오류 등 — 전체 라벨은 DescribeStatus 참고. list 항목의 모든 값은 문자열.
  9. /// - stock_code 는 6자리 단축코드(상장) 또는 "" (비상장) → 빈 값은 null.
  10. /// - rcept_dt 는 YYYYMMDD → DateOnly. rm(비고)은 없거나 "" → null.
  11. /// list.json 이 stock_code 를 이미 포함하므로 corpCode.xml 매핑 없이 종목별 조회가 가능하다.
  12. /// </summary>
  13. public static class DartDisclosureParser
  14. {
  15. /// <summary>공시 1행 (list.json 항목). stock_code 빈 값은 null, rcept_dt 는 DateOnly.</summary>
  16. public sealed record DisclosureRow(
  17. string RceptNo,
  18. string CorpCode,
  19. string CorpName,
  20. string? StockCode,
  21. string CorpCls,
  22. string ReportNm,
  23. string FlrNm,
  24. DateOnly RceptDt,
  25. string? Rm
  26. );
  27. /// <summary>파싱 결과 — status/총페이지 등 메타와 파싱된 행. status != "000" 이면 Rows 는 빈 목록.</summary>
  28. public sealed record ParseResult(
  29. string Status,
  30. string Message,
  31. int PageNo,
  32. int TotalPage,
  33. int TotalCount,
  34. IReadOnlyList<DisclosureRow> Rows
  35. );
  36. public static ParseResult Parse(string json)
  37. {
  38. using var doc = JsonDocument.Parse(json);
  39. var root = doc.RootElement;
  40. var status = GetString(root, "status");
  41. var message = GetString(root, "message");
  42. var pageNo = GetInt(root, "page_no");
  43. var totalPage = GetInt(root, "total_page");
  44. var totalCount = GetInt(root, "total_count");
  45. var rows = new List<DisclosureRow>();
  46. if (status != "000" || !root.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array)
  47. {
  48. return new ParseResult(status, message, pageNo, totalPage, totalCount, rows);
  49. }
  50. foreach (var item in list.EnumerateArray())
  51. {
  52. var rceptNo = GetString(item, "rcept_no");
  53. var corpCode = GetString(item, "corp_code");
  54. var reportNm = GetString(item, "report_nm");
  55. var rceptDt = ParseDate(GetString(item, "rcept_dt"));
  56. if (rceptNo.Length == 0 || corpCode.Length == 0 || reportNm.Length == 0 || rceptDt is null)
  57. {
  58. continue;
  59. }
  60. rows.Add(new DisclosureRow(
  61. rceptNo,
  62. corpCode,
  63. GetString(item, "corp_name"),
  64. ToNull(GetString(item, "stock_code")),
  65. GetString(item, "corp_cls"),
  66. reportNm,
  67. GetString(item, "flr_nm"),
  68. rceptDt.Value,
  69. ToNull(GetString(item, "rm"))
  70. ));
  71. }
  72. return new ParseResult(status, message, pageNo, totalPage, totalCount, rows);
  73. }
  74. /// <summary>
  75. /// OpenDART 공통 status 코드 라벨 (개발가이드 메시지 설명 기준).
  76. /// 주의: "020" 은 '요청 제한(쿼터) 초과'다 — 키 오류("010"/"011")와 혼동 금지 (일 20,000건 한도).
  77. /// </summary>
  78. public static string DescribeStatus(string status)
  79. {
  80. return status switch
  81. {
  82. "000" => "정상",
  83. "010" => "등록되지 않은 키",
  84. "011" => "사용할 수 없는 키 (사용중지/삭제)",
  85. "012" => "접근할 수 없는 IP",
  86. "013" => "조회된 데이터 없음",
  87. "014" => "파일이 존재하지 않음",
  88. "020" => "요청 제한(쿼터) 초과",
  89. "021" => "조회 가능한 회사 개수 초과 (최대 100건)",
  90. "100" => "필드의 부적절한 값 (파라미터 오류)",
  91. "101" => "부적절한 접근",
  92. "800" => "시스템 점검으로 서비스 중지",
  93. "900" => "정의되지 않은 오류",
  94. "901" => "계정 개인정보 보유기간 만료로 사용할 수 없는 키",
  95. _ => "알 수 없는 상태"
  96. };
  97. }
  98. private static string GetString(JsonElement element, string name)
  99. {
  100. if (!element.TryGetProperty(name, out var value))
  101. {
  102. return string.Empty;
  103. }
  104. return value.ValueKind switch
  105. {
  106. JsonValueKind.String => value.GetString()?.Trim() ?? string.Empty,
  107. JsonValueKind.Number => value.GetRawText(),
  108. _ => string.Empty
  109. };
  110. }
  111. private static int GetInt(JsonElement element, string name)
  112. {
  113. if (!element.TryGetProperty(name, out var value))
  114. {
  115. return 0;
  116. }
  117. return value.ValueKind switch
  118. {
  119. JsonValueKind.Number => value.TryGetInt32(out var n) ? n : 0,
  120. JsonValueKind.String => int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var s) ? s : 0,
  121. _ => 0
  122. };
  123. }
  124. /// <summary>빈 문자열/"-" 은 null, 나머지는 trim 값</summary>
  125. private static string? ToNull(string raw)
  126. {
  127. return raw.Length == 0 || raw == "-" ? null : raw;
  128. }
  129. private static DateOnly? ParseDate(string raw)
  130. {
  131. return DateOnly.TryParseExact(raw, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : null;
  132. }
  133. }