using System.Xml.Linq; namespace Infrastructure.StockData; /// /// SEIBro OpenAPI 공용 응답 envelope 파서 — 순수 C# (System.Xml.Linq), 37개 API 전 공통 (docs/SEIBro/readme.md §1 확정 규칙). /// 응답 골격: /// <vector result="N"> ← result 속성 = 행 수 (0 = 빈 결과, data 없음) /// <data vectorkey="i" type="Document"> /// <result> 또는 <RESULT> ← ⚠️ 래퍼 대소문자가 API 개별로 다름 (파생결합 5종만 대문자) /// <FIELD value="..."/> ← 필드 값은 element 텍스트가 아니라 value 속성 /// </result> /// </data> /// </vector> /// 파서 규칙: /// • 래퍼는 data 의 "첫 자식 element" 를 이름·대소문자 무관하게 취한다 (result/RESULT 둘 다 실재). /// • value="" 또는 element 자체 생략 = null → 행 딕셔너리에서 키 자체를 뺀다 (조회는 GetOrNull 사용). /// • 필드명 조회는 대소문자 무시 (OrdinalIgnoreCase). /// • 비XML 응답은 XmlException, envelope 구조 위반(vector/result 속성/래퍼 부재)은 FormatException — /// silent 빈 결과 금지 (수집 배치가 오류를 인지하고 run 을 중단·재시도해야 한다). /// public static class SeibroXml { /// 파싱된 envelope — ResultCount 는 vector@result 원문 값 (샘플 파일은 truncate 되어 Rows.Count 와 다를 수 있음) public sealed record Envelope(int ResultCount, IReadOnlyList> Rows); public static Envelope Parse(string xml) { var doc = XDocument.Parse(xml); var root = doc.Root; // 라이브 응답은
를 감싼다. // (샘플 조각은 단독) → 루트가 vector 면 그대로, 아니면 자손에서 를 찾는다. var vector = root is not null && root.Name.LocalName.Equals("vector", StringComparison.OrdinalIgnoreCase) ? root : root?.Descendants().FirstOrDefault(c => c.Name.LocalName.Equals("vector", StringComparison.OrdinalIgnoreCase)); if (vector is null) { throw new FormatException($"SEIBro 응답에 없음: 루트 <{root?.Name.LocalName}>"); } var resultAttr = vector.Attributes().FirstOrDefault(c => c.Name.LocalName.Equals("result", StringComparison.OrdinalIgnoreCase)); if (resultAttr is null || !int.TryParse(resultAttr.Value, out var resultCount)) { throw new FormatException($"SEIBro 응답 에 result 행수 속성 없음/비숫자: '{resultAttr?.Value}'"); } var rows = new List>(); var dataIndex = 0; foreach (var data in vector.Elements().Where(c => c.Name.LocalName.Equals("data", StringComparison.OrdinalIgnoreCase))) { // 행 래퍼 = data 의 첫 자식 element (이름·대소문자 무관 — result/RESULT 혼재) var wrapper = data.Elements().FirstOrDefault() ?? throw new FormatException($"SEIBro 응답 data[{dataIndex}] 에 행 래퍼 element 없음"); var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var field in wrapper.Elements()) { var value = field.Attributes().FirstOrDefault(c => c.Name.LocalName.Equals("value", StringComparison.OrdinalIgnoreCase))?.Value; // value="" 는 null 취급 → 키 자체를 넣지 않는다 (element 생략과 동일 의미) if (!string.IsNullOrEmpty(value)) { fields[field.Name.LocalName] = value; } } rows.Add(fields); dataIndex++; } return new Envelope(resultCount, rows); } /// 행 딕셔너리에서 필드 조회 — 생략/빈 값 필드는 null (파서 공용 헬퍼) public static string? GetOrNull(IReadOnlyDictionary row, string field) { return row.TryGetValue(field, out var value) ? value : null; } }