SeibroXml.cs 3.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
  1. using System.Xml.Linq;
  2. namespace Infrastructure.StockData;
  3. /// <summary>
  4. /// SEIBro OpenAPI 공용 응답 envelope 파서 — 순수 C# (System.Xml.Linq), 37개 API 전 공통 (docs/SEIBro/readme.md §1 확정 규칙).
  5. /// 응답 골격:
  6. /// &lt;vector result="N"&gt; ← result 속성 = 행 수 (0 = 빈 결과, data 없음)
  7. /// &lt;data vectorkey="i" type="Document"&gt;
  8. /// &lt;result&gt; 또는 &lt;RESULT&gt; ← ⚠️ 래퍼 대소문자가 API 개별로 다름 (파생결합 5종만 대문자)
  9. /// &lt;FIELD value="..."/&gt; ← 필드 값은 element 텍스트가 아니라 value 속성
  10. /// &lt;/result&gt;
  11. /// &lt;/data&gt;
  12. /// &lt;/vector&gt;
  13. /// 파서 규칙:
  14. /// • 래퍼는 data 의 "첫 자식 element" 를 이름·대소문자 무관하게 취한다 (result/RESULT 둘 다 실재).
  15. /// • value="" 또는 element 자체 생략 = null → 행 딕셔너리에서 키 자체를 뺀다 (조회는 GetOrNull 사용).
  16. /// • 필드명 조회는 대소문자 무시 (OrdinalIgnoreCase).
  17. /// • 비XML 응답은 XmlException, envelope 구조 위반(vector/result 속성/래퍼 부재)은 FormatException —
  18. /// silent 빈 결과 금지 (수집 배치가 오류를 인지하고 run 을 중단·재시도해야 한다).
  19. /// </summary>
  20. public static class SeibroXml
  21. {
  22. /// <summary>파싱된 envelope — ResultCount 는 vector@result 원문 값 (샘플 파일은 truncate 되어 Rows.Count 와 다를 수 있음)</summary>
  23. public sealed record Envelope(int ResultCount, IReadOnlyList<IReadOnlyDictionary<string, string>> Rows);
  24. public static Envelope Parse(string xml)
  25. {
  26. var doc = XDocument.Parse(xml);
  27. var root = doc.Root;
  28. if (root is null || !root.Name.LocalName.Equals("vector", StringComparison.OrdinalIgnoreCase))
  29. {
  30. throw new FormatException($"SEIBro 응답 루트가 <vector> 가 아님: <{root?.Name.LocalName}>");
  31. }
  32. var resultAttr = root.Attributes().FirstOrDefault(c => c.Name.LocalName.Equals("result", StringComparison.OrdinalIgnoreCase));
  33. if (resultAttr is null || !int.TryParse(resultAttr.Value, out var resultCount))
  34. {
  35. throw new FormatException($"SEIBro 응답 <vector> 에 result 행수 속성 없음/비숫자: '{resultAttr?.Value}'");
  36. }
  37. var rows = new List<IReadOnlyDictionary<string, string>>();
  38. var dataIndex = 0;
  39. foreach (var data in root.Elements().Where(c => c.Name.LocalName.Equals("data", StringComparison.OrdinalIgnoreCase)))
  40. {
  41. // 행 래퍼 = data 의 첫 자식 element (이름·대소문자 무관 — result/RESULT 혼재)
  42. var wrapper = data.Elements().FirstOrDefault() ?? throw new FormatException($"SEIBro 응답 data[{dataIndex}] 에 행 래퍼 element 없음");
  43. var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
  44. foreach (var field in wrapper.Elements())
  45. {
  46. var value = field.Attributes().FirstOrDefault(c => c.Name.LocalName.Equals("value", StringComparison.OrdinalIgnoreCase))?.Value;
  47. // value="" 는 null 취급 → 키 자체를 넣지 않는다 (element 생략과 동일 의미)
  48. if (!string.IsNullOrEmpty(value))
  49. {
  50. fields[field.Name.LocalName] = value;
  51. }
  52. }
  53. rows.Add(fields);
  54. dataIndex++;
  55. }
  56. return new Envelope(resultCount, rows);
  57. }
  58. /// <summary>행 딕셔너리에서 필드 조회 — 생략/빈 값 필드는 null (파서 공용 헬퍼)</summary>
  59. public static string? GetOrNull(IReadOnlyDictionary<string, string> row, string field)
  60. {
  61. return row.TryGetValue(field, out var value) ? value : null;
  62. }
  63. }