/** * DOCX / XLSX / ZIP metin çıkarımı — harici bağımlılık YOK. * ihaleab projesindeki backend/src/lib/officeText.ts dosyasından taşındı. * * NEDEN SUNUCUDA: ihaleist'te PDF metni tarayıcıda (pdfjs) çıkarılıyor, ama * docx/xlsx/zip için tarayıcıda çözücü yok. Belgelerin baytları zaten * sunucuda duruyor (BELGE_DIR), Node'un yerleşik zlib'i de burada. */ // ZIP / DOCX / XLSX metin çıkarımı — harici bağımlılık YOK. // // NEDEN: ihale şartnameleri pratikte .docx ve .xlsx olarak yayımlanıyor ve // çoğu zaman tek bir .zip arşivinin içinde geliyor (ör. Letonya EIS'te // "Nolikums ar pielikumiem.zip" — 17 dosya, içinde 12 kısmın teknik // şartnamesi ve mali teklif formu). pdf-parse yalnızca PDF okuyabildiği için // bu dosyalardan hiç metin çıkarılamıyordu; aranan birim miktarlar // (kaç adet polo gömlek, kaç çift bot) tam olarak bu dosyaların içinde. // // TASARIM: OOXML dosyalarının tamamı ZIP arşividir ve içerikleri düz XML'dir. // Node'un yerleşik zlib'i ile küçük bir ZIP okuyucu yazmak, yeni bir paket // eklemekten daha az risk taşıyor (projede zaten HTML/XML için regex tabanlı // yaklaşım benimsenmiş, bkz. tenderDocuments.ts stripHtml). // // SINIRLAR: ZIP64 arşivleri ve şifreli arşivler desteklenmez — bu durumlarda // anlamlı bir hata döner, sessizce bozuk veri üretilmez. import { inflateRawSync } from 'node:zlib'; const EOCD_SIG = 0x06054b50; const CENTRAL_SIG = 0x02014b50; const LOCAL_SIG = 0x04034b50; const MAX_ENTRIES = 400; const MAX_ENTRY_BYTES = 80 * 1024 * 1024; /** * ZIP arşivinin merkezî dizinini okuyup girdileri döndürür. İçerik tembel * açılır (read()) — 17 dosyalık bir arşivde yalnızca ihtiyaç duyulanlar * açılsın diye. */ export function readZip(buf) { const eocd = findEocd(buf); if (eocd < 0) throw new Error("ZIP dizini bulunamadı (geçersiz veya ZIP64 arşiv)."); const entryCount = buf.readUInt16LE(eocd + 10); const centralOffset = buf.readUInt32LE(eocd + 16); if (centralOffset >= buf.length) throw new Error("ZIP merkezî dizin konumu geçersiz."); const entries = []; let p = centralOffset; for (let i = 0; i < Math.min(entryCount, MAX_ENTRIES); i++) { if (p + 46 > buf.length || buf.readUInt32LE(p) !== CENTRAL_SIG) break; const method = buf.readUInt16LE(p + 10); const flags = buf.readUInt16LE(p + 8); const compSize = buf.readUInt32LE(p + 20); const uncompSize = buf.readUInt32LE(p + 24); const nameLen = buf.readUInt16LE(p + 28); const extraLen = buf.readUInt16LE(p + 30); const commentLen = buf.readUInt16LE(p + 32); const localOffset = buf.readUInt32LE(p + 42); const name = decodeName(buf.subarray(p + 46, p + 46 + nameLen), flags); const isDirectory = name.endsWith("/") || uncompSize === 0; entries.push({ name, size: uncompSize, isDirectory, read: () => { if (flags & 0x1) throw new Error(`Şifreli arşiv girdisi okunamaz: ${name}`); if (compSize > MAX_ENTRY_BYTES) throw new Error(`Arşiv girdisi çok büyük: ${name}`); if (localOffset + 30 > buf.length || buf.readUInt32LE(localOffset) !== LOCAL_SIG) { throw new Error(`Arşiv girdisi konumu geçersiz: ${name}`); } const lNameLen = buf.readUInt16LE(localOffset + 26); const lExtraLen = buf.readUInt16LE(localOffset + 28); const start = localOffset + 30 + lNameLen + lExtraLen; const raw = buf.subarray(start, start + compSize); if (method === 0) return Buffer.from(raw); if (method === 8) return inflateRawSync(raw); throw new Error(`Desteklenmeyen sıkıştırma yöntemi (${method}): ${name}`); }, }); p += 46 + nameLen + extraLen + commentLen; } return entries; } /** EOCD imzasını sondan geriye doğru arar (yorum alanı en fazla 64KB olabilir). */ function findEocd(buf) { const min = Math.max(0, buf.length - 65_557); for (let i = buf.length - 22; i >= min; i--) { if (buf.readUInt32LE(i) === EOCD_SIG) return i; } return -1; } /** ZIP adları UTF-8 bayrağı yoksa CP437'dir; pratikte UTF-8 denemesi yeterli. */ function decodeName(raw, flags) { if (flags & 0x800) return raw.toString("utf8"); const asUtf8 = raw.toString("utf8"); return asUtf8.includes("�") ? raw.toString("latin1") : asUtf8; } /* ---------------- XML yardımcıları ---------------- */ function decodeXmlEntities(s) { return s .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))) .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16))) .replace(/&/g, "&"); } function stripTags(xml) { return decodeXmlEntities(xml.replace(/<[^>]+>/g, "")).replace(/[ \t]+/g, " ").trim(); } function findEntry(entries, path) { return entries.find((e) => e.name === path || e.name === path.replace(/^\//, "")) ?? null; } /* ---------------- DOCX ---------------- */ /** * DOCX'ten metin çıkarır. Tablolar korunur: hücreler " | ", satırlar yeni * satır. Miktar tabloları düz metne ezilirse hangi sayının hangi kaleme ait * olduğu kaybolduğu için bu şart. */ export function docxToText(buf) { const entries = readZip(buf); const doc = findEntry(entries, "word/document.xml"); if (!doc) throw new Error("DOCX içinde word/document.xml bulunamadı."); let xml = doc.read().toString("utf8"); // Hücre/satır sınırları ÖNCE işaretleyiciye çevrilir. Doğrudan " | " ve "\n" // yazılamaz: bir tablo hücresi kendi içinde barındırdığı için paragraf // sonu ayracı kendi satırına düşürür ve satır temizliğinde silinirdi. const CELL = ""; const ROW = ""; xml = xml .replace(/]*\/?>/g, "\n") .replace(/]*\/?>/g, "\t") .replace(/<\/w:tc>/g, CELL) .replace(/<\/w:tr>/g, ROW) .replace(/<\/w:p>/g, "\n"); const duz = stripTagsPreservingBreaks(xml) // İşaretleyicinin etrafındaki boşluk/satır sonlarını yut. .replace(new RegExp(`\\s*${CELL}\\s*`, "g"), " | ") .replace(new RegExp(`\\s*${ROW}\\s*`, "g"), "\n"); return tidyLines(duz); } /** stripTags'in satır sonlarını koruyan hali. */ function stripTagsPreservingBreaks(xml) { return decodeXmlEntities(xml.replace(/<[^>]+>/g, "")); } function tidyLines(text) { return text .split("\n") .map((l) => l.replace(/[ \t ]+/g, " ").replace(/\s*\|\s*/g, " | ").trim()) .map((l) => l.replace(/^\|\s*/, "").replace(/\s*\|$/, "").trim()) .filter((l) => l.length > 0) .join("\n") .replace(/\n{3,}/g, "\n\n") .trim(); } /* ---------------- XLSX ---------------- */ /** A1 -> 0, B1 -> 1 ... (sütun harflerini indekse çevirir) */ function colIndex(ref) { const m = /^([A-Z]+)/.exec(ref); if (!m) return 0; let n = 0; for (const ch of m[1]) n = n * 26 + (ch.charCodeAt(0) - 64); return n - 1; } function parseSharedStrings(entries) { const e = findEntry(entries, "xl/sharedStrings.xml"); if (!e) return []; const xml = e.read().toString("utf8"); const out = []; // Her bir dize; içinde birden çok parçası olabilir (biçimli metin). for (const si of xml.split(/]*>/).slice(1)) { const govde = si.split("")[0] ?? ""; const parcalar = [...govde.matchAll(/]*>([\s\S]*?)<\/t>/g)].map((m) => m[1]); out.push(decodeXmlEntities(parcalar.join("")).trim()); } return out; } function sheetNames(entries) { const wb = findEntry(entries, "xl/workbook.xml"); if (!wb) return []; const xml = wb.read().toString("utf8"); return [...xml.matchAll(/]*\bname="([^"]*)"/g)].map((m) => decodeXmlEntities(m[1])); } /** * XLSX'ten metin çıkarır. Her sayfa "### " başlığıyla, satırlar * " | " ayraçlı olarak döner — mali teklif formundaki kalem/miktar tabloları * bu biçimde okunabilir kalıyor. */ export function xlsxToText(buf) { const entries = readZip(buf); const shared = parseSharedStrings(entries); const adlar = sheetNames(entries); const sayfalar = entries .filter((e) => /^xl\/worksheets\/sheet\d+\.xml$/.test(e.name)) .sort((a, b) => { const na = Number(/sheet(\d+)\.xml/.exec(a.name)?.[1] ?? 0); const nb = Number(/sheet(\d+)\.xml/.exec(b.name)?.[1] ?? 0); return na - nb; }); if (sayfalar.length === 0) throw new Error("XLSX içinde çalışma sayfası bulunamadı."); const bloklar = []; sayfalar.forEach((sheet, idx) => { const xml = sheet.read().toString("utf8"); const satirlar = []; for (const rowMatch of xml.matchAll(/]*>([\s\S]*?)<\/row>/g)) { const hucreler = []; for (const c of rowMatch[1].matchAll(/]*)>([\s\S]*?)<\/c>/g)) { const attrs = c[1]; const govde = c[2]; const ref = /\br="([A-Z]+\d+)"/.exec(attrs)?.[1] ?? ""; const tip = /\bt="([^"]*)"/.exec(attrs)?.[1] ?? ""; const vRaw = /]*>([\s\S]*?)<\/v>/.exec(govde)?.[1]; let deger = ""; if (tip === "s") { const i = Number(vRaw); deger = Number.isInteger(i) ? shared[i] ?? "" : ""; } else if (tip === "inlineStr") { deger = stripTags(govde); } else if (vRaw != null) { deger = decodeXmlEntities(vRaw).trim(); } if (!deger) continue; const i = ref ? colIndex(ref) : hucreler.length; while (hucreler.length < i) hucreler.push(""); hucreler[i] = deger; } // Kendi kendine boş satırları atla. const satir = hucreler.join(" | ").replace(/(\s*\|\s*)+$/, "").trim(); if (satir.replace(/[|\s]/g, "")) satirlar.push(satir); } if (satirlar.length > 0) { bloklar.push(`### ${adlar[idx] ?? `Sayfa ${idx + 1}`}\n${satirlar.join("\n")}`); } }); return bloklar.join("\n\n").trim(); } /* ---------------- Dağıtıcı ---------------- */ export function isDocx(name, buf) { return /\.docx$/i.test(name) || (/\.doc$/i.test(name) === false && hasEntry(buf, "word/document.xml")); } export function isXlsx(name) { return /\.xlsx$/i.test(name); } export function isZip(name, buf) { return /\.zip$/i.test(name) || (buf.length > 4 && buf.readUInt32LE(0) === LOCAL_SIG); } function hasEntry(buf, path) { try { return readZip(buf).some((e) => e.name === path); } catch { return false; } } /** * Dosya adına ve içeriğine bakarak uygun çıkarıcıyı seçer. Tanınmayan tür * için null döner (çağıran taraf PDF/HTML yollarına düşebilsin diye). */ export function officeToText(name, buf) { try { if (isXlsx(name)) return xlsxToText(buf); if (/\.docx$/i.test(name)) return docxToText(buf); } catch { return null; } return null; } /* ---------------- Arşiv (zip) ---------------- */ // Derin analiz zaten 90.000 karakter okuyor; tek bir arşivin bunun tamamını // yemesini istemiyoruz — birden çok belge birleştiriliyor. const MAX_ARSIV_KARAKTER = 60000; /** * ZIP/DOCX/XLSX tamponundan metin çıkarır. Arşivse içindeki Office dosyaları * tek tek çözülüp "## " başlıklarıyla birleştirilir. İhale * paketleri iç içe zip olabildiği için bir seviye iç arşiv de açılır. */ export function arsivMetni(name, buf, depth = 0) { const direct = officeToText(name, buf); if (direct) return direct; let entries; try { entries = readZip(buf); } catch (e) { throw new Error(`Arşiv okunamadı: ${String(e.message || e).slice(0, 160)}`); } const bloklar = []; let toplam = 0; for (const entry of entries) { if (toplam >= MAX_ARSIV_KARAKTER) break; if (entry.isDirectory) continue; // Word'ün geçici kilit dosyaları (~$...) içerik taşımaz. if (/(^|\/)~\$/.test(entry.name)) continue; const kisaAd = entry.name.split('/').pop() ?? entry.name; let metin = null; try { const icerik = entry.read(); if (depth < 1 && /\.zip$/i.test(entry.name)) { metin = arsivMetni(kisaAd, icerik, depth + 1); } else { metin = officeToText(entry.name, icerik); } } catch { continue; // tek bozuk dosya tüm paketi düşürmesin } if (!metin) continue; bloklar.push(`## ${kisaAd}\n${metin}`); toplam += metin.length; } if (bloklar.length === 0) throw new Error('Arşiv içinde okunabilir doküman bulunamadı.'); return bloklar.join('\n\n').slice(0, MAX_ARSIV_KARAKTER); } /** Sunucunun metin çıkarabildiği uzantılar (PDF tarayıcıda okunuyor). */ export function sunucuOkuyabilir(ad) { return /\.(docx|xlsx|zip)$/i.test(String(ad || '')); }