import { normalizeText, ageFromBirthDate } from '@akrababul/shared'; export interface ParsedRow { order: number; gender: 'E' | 'K' | ''; relation: string; firstName: string; lastName: string | null; fatherName: string; motherName: string; birthPlace: string; birthDate: string; placeParts: string[]; registryNo: string; } export interface ParsedSelf { firstName: string; lastName: string; birthYear: number; } /** `selfAge` yalnızca tarayıcıda kullanılır; tam doğum tarihi sunucuya gönderilmez. */ export interface ParsedDocument { self: ParsedSelf; rows: ParsedRow[]; selfAge: number; } export type SoyParseErrorCode = 'NO_TABLE' | 'BAD_HEADER' | 'CLIPPED_TABLE' | 'NO_SELF' | 'BAD_SELF_DATE'; /** Hata raporuna eklenen teşhis alanları: sütun adları ve sayılar; kayıt metni veya kişisel veri içermez. */ export type SoyParseDetails = Record; export class SoyParseError extends Error { constructor(public readonly code: SoyParseErrorCode, message: string, public readonly details: SoyParseDetails = {}) { super(message); this.name = 'SoyParseError'; } } /** Okunan sütunlar ve e-Devlet'teki başlıkları. Sütunlar konuma göre değil, başlık adına göre bulunur. */ const COLUMNS = { order: 'Sıra', gender: 'C', relation: 'Yakınlık Derecesi', firstName: 'Adı', lastName: 'Soyadı', fatherName: 'Baba Adı', motherName: 'Ana Adı', birth: 'Doğum Yeri ve Tarihi', place: 'İl-İlçe-Mahalle/Köy', registry: 'Cilt-Hane-Birey Sıra No', } as const; type ColumnKey = keyof typeof COLUMNS; /** Kayıt anahtarı, ad-soyad ve yaş sınırı bunlardan üretilir; biri eksikse belge okunamaz. * Kalanlar (Sıra, C, Baba/Ana Adı) yoksa satır yine okunur; "Medeni Hali", "Durumu" ve * tanınmayan sütunlar yok sayılır. */ const REQUIRED: readonly ColumnKey[] = ['relation', 'firstName', 'lastName', 'birth', 'place', 'registry']; /** Belgenin doğru e-Devlet çıktısı olup olmadığını hata raporunda göstermek için. */ const EXPECTED_TITLE = 'Alt-Üst Soy Bilgisi Sorgulama'; const TEXT_NODE = 3, ELEMENT_NODE = 1; /** * Başlık eşleştirmeye özgü normalizasyon: görünmez karakterleri atar, tire çevresindeki boşluğu * siler, harf büyüklüğünü yok sayar. Hücre içerikleri buradan GEÇMEZ — kayıt anahtarı * `normalizeText` ile üretilir ve o kural (hash_version=1) değişmemelidir. */ function headerKey(s: string): string { return normalizeText(s.replace(/[\u00AD\u200B-\u200D\uFEFF]/g, '')).replace(/\s*-\s*/g, '-').toLocaleLowerCase('tr'); } const COLUMN_KEYS = Object.entries(COLUMNS).map(([k, title]) => [k as ColumnKey, headerKey(title)] as const); /** Hücreyi
sınırlarından parçalara böler; her parça normalize edilir, boşlar atılır. */ function cellParts(td: Element): string[] { const parts: string[] = []; let cur = ''; const walk = (n: Node) => { if (n.nodeType === TEXT_NODE) { cur += n.textContent ?? ''; return; } if (n.nodeType === ELEMENT_NODE) { if ((n as Element).tagName === 'BR') { parts.push(cur); cur = ''; return; } n.childNodes.forEach(walk); } }; td.childNodes.forEach(walk); parts.push(cur); return parts.map(normalizeText).filter((p) => p.length > 0); } const dash = (s: string | undefined): string | null => (!s || s === '-' ? null : s); interface HeaderMatch { row: Element; /** Başlık hücresi sayısı; veri satırları da bu kadar hücre taşımalı. */ cells: number; titles: string[]; index: Partial>; missing: ColumnKey[]; } /** Başlık satırı adayları: `th` taşıyan satırlar; hiç yoksa ilk satır (bazı çıktılarda `thead` bulunmaz). */ function headerRows(table: Element): Element[] { const rows = Array.from(table.querySelectorAll('tr')); const withTh = rows.filter((tr) => tr.querySelector('th') !== null); return withTh.length > 0 ? withTh : rows.slice(0, 1); } /** * Kayıt satırlarında en sık görülen `td` sayısı. Başlık satırıyla karşılaştırılır: kısmi * seçimle kopyalanan belgelerde başlık satırı hücre kaybeder, kayıt satırları tam kalır ve * hiçbir satır okunamaz. `colspan` taşıyan AÇIKLAMALAR satırları azınlıkta kaldığı için * sonucu etkilemez. Eşitlikte büyük olan seçilir; asıl kayıt satırları en geniş olanlardır. */ function bodyCellCount(table: Element, headerRow: Element): number { const seen = new Map(); for (const tr of Array.from(table.querySelectorAll('tr'))) { if (tr === headerRow) continue; const n = tr.querySelectorAll('td').length; if (n > 0) seen.set(n, (seen.get(n) ?? 0) + 1); } let cells = 0, most = 0; for (const [n, count] of seen) if (count > most || (count === most && n > cells)) { cells = n; most = count; } return cells; } function matchHeader(tr: Element): HeaderMatch { const titles = Array.from(tr.querySelectorAll('th, td')).map((c) => normalizeText(c.textContent ?? '')); const keys = titles.map(headerKey); const index: Partial> = {}; for (const [key, want] of COLUMN_KEYS) { const i = keys.indexOf(want); if (i >= 0) index[key] = i; } return { row: tr, cells: titles.length, titles, index, missing: REQUIRED.filter((k) => index[k] === undefined) }; } export function parseSoyDocument(html: string): ParsedDocument { const doc = new DOMParser().parseFromString(html, 'text/html'); const titleOk = normalizeText(doc.title).includes(EXPECTED_TITLE); const resultTables = Array.from(doc.querySelectorAll('table.resultTable')); const allTables = Array.from(doc.querySelectorAll('table')); // Beklenen sınıf yoksa sayfadaki tüm tablolar denenir; belgeyi seçen şey başlıklarıdır. const tables = resultTables.length > 0 ? resultTables : allTables; if (tables.length === 0) { throw new SoyParseError('NO_TABLE', 'Belgede soy tablosu bulunamadı', { tables: 0, resultTables: 0, titleOk }); } let best: { table: Element; head: HeaderMatch } | null = null; let closest: HeaderMatch | null = null; // yalnızca hata raporu için: eksiği en az olan aday for (const table of tables) { for (const tr of headerRows(table)) { const head = matchHeader(tr); if (head.missing.length === 0) { best = { table, head }; break; } if (!closest || head.missing.length < closest.missing.length) closest = head; } if (best) break; } if (!best) { throw new SoyParseError('BAD_HEADER', 'Tablo başlıkları beklenen biçimde değil', { tables: allTables.length, resultTables: resultTables.length, titleOk, headerCells: closest?.cells ?? 0, missing: (closest?.missing ?? REQUIRED).map((k) => COLUMNS[k]).join(', ').slice(0, 200), // Sütun adları kişisel veri değildir; yine de yalnızca e-Devlet tablosu bulunduğunda gönderilir. headers: resultTables.length > 0 ? (closest?.titles.join(' | ') ?? '').slice(0, 300) : '', }); } const { table, head } = best; const bodyCells = bodyCellCount(table, head.row); const at = (c: string[][], key: ColumnKey): string[] => { const i = head.index[key]; return i === undefined ? [] : c[i] ?? []; }; const rows: ParsedRow[] = []; let self: ParsedSelf | null = null; let selfAge = 0; for (const tr of Array.from(table.querySelectorAll('tr'))) { if (tr === head.row) continue; const tds = Array.from(tr.querySelectorAll('td')); if (tds.length !== head.cells) continue; // AÇIKLAMALAR ve benzeri satırlar const c = tds.map(cellParts); const relation = at(c, 'relation')[0] ?? ''; const firstName = at(c, 'firstName').join(' '); const lastName = dash(at(c, 'lastName').join(' ')); const birth = at(c, 'birth'); if (relation === 'Kendisi') { const m = /(\d{2})\/(\d{2})\/(\d{4})/.exec(birth[birth.length - 1] ?? ''); if (!m) throw new SoyParseError('BAD_SELF_DATE', 'Kendi kaydınızda doğum tarihi okunamadı', { headerCells: head.cells, bodyCells }); self = { firstName, lastName: lastName ?? '', birthYear: Number(m[3]) }; selfAge = ageFromBirthDate(Number(m[1]), Number(m[2]), Number(m[3])); continue; } const g = at(c, 'gender')[0]; rows.push({ order: Number(at(c, 'order')[0] ?? rows.length + 1), gender: g === 'E' || g === 'K' ? g : '', relation, firstName, lastName, fatherName: at(c, 'fatherName').join(' '), motherName: at(c, 'motherName').join(' '), birthPlace: birth.length > 1 ? birth.slice(0, -1).join(' ') : '', birthDate: birth[birth.length - 1] ?? '', placeParts: at(c, 'place'), registryNo: at(c, 'registry').join(''), }); } if (!self) { // Hiçbir satır okunamadıysa ve satır genişliği başlıkla tutmuyorsa sorun eksik kaydın değil, // eksik kopyalanmış belgenin kendisidir: sütunlar başlığa göre bulunduğu için kaydırılmış // bir tabloyu okumak yanlış isim ve kayıt anahtarı üretirdi. if (rows.length === 0 && bodyCells !== head.cells) { throw new SoyParseError('CLIPPED_TABLE', 'Belge eksik kopyalanmış görünüyor - tablonun başlık satırıyla kayıt satırları uyuşmuyor.', { headerCells: head.cells, bodyCells, rows: 0, titleOk }); } throw new SoyParseError('NO_SELF', 'Belgede kendi kaydınız bulunamadı', { headerCells: head.cells, bodyCells, rows: rows.length, titleOk }); } return { self, rows, selfAge }; }