| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576 |
- 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;
- if (root is null || !root.Name.LocalName.Equals("vector", StringComparison.OrdinalIgnoreCase))
- {
- throw new FormatException($"SEIBro 응답 루트가 <vector> 가 아님: <{root?.Name.LocalName}>");
- }
- var resultAttr = root.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 root.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;
- }
- }
|