| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182 |
- using System.Xml.Linq;
- namespace Infrastructure.StockData;
- /// <summary>
- /// 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 을 중단·재시도해야 한다).
- /// </summary>
- public static class SeibroXml
- {
- /// <summary>파싱된 envelope — ResultCount 는 vector@result 원문 값 (샘플 파일은 truncate 되어 Rows.Count 와 다를 수 있음)</summary>
- public sealed record Envelope(int ResultCount, IReadOnlyList<IReadOnlyDictionary<string, string>> Rows);
- public static Envelope Parse(string xml)
- {
- var doc = XDocument.Parse(xml);
- var root = doc.Root;
- // 라이브 응답은 <SeibroAPI><header/><vector result="N">…</vector></SeibroAPI> 로 <vector> 를 감싼다.
- // (샘플 조각은 <vector> 단독) → 루트가 vector 면 그대로, 아니면 자손에서 <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 응답에 <vector> 없음: 루트 <{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 응답 <vector> 에 result 행수 속성 없음/비숫자: '{resultAttr?.Value}'");
- }
- var rows = new List<IReadOnlyDictionary<string, string>>();
- 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<string, string>(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);
- }
- /// <summary>행 딕셔너리에서 필드 조회 — 생략/빈 값 필드는 null (파서 공용 헬퍼)</summary>
- public static string? GetOrNull(IReadOnlyDictionary<string, string> row, string field)
- {
- return row.TryGetValue(field, out var value) ? value : null;
- }
- }
|