Files
hikmettan 3eb0f91823 feat: derin analiz & yol haritasi + ihale dosyasi hazirlama; docx/xlsx/zip okunuyor
Doküman panelindeki "Tum Evraki Derin Analiz Et" dugmesi EKAP ilan metnini ve
ihaleye yuklenmis butun belgeleri TEK metinde birlestirip gonderiyor; model
sartnameyi ilanla ve eklerle capraz okuyabiliyor.

Derin analiz (mode='derin', ayri sistem istemi + 8000 token):
- Teklifi gecersiz kilabilecek noktalar (kirmizi uyari, en ustte)
- Gozden kacan ince detaylar (Kritik/Yuksek/Orta + madde no + neden onemli)
- Alinacak kalemler: aranan evsaflar, miktar, sartnamede geciyorsa fiyat
  (modele "TAHMIN URETME" talimati verildi, yoksa null)
- Belge kontrol listesi, kritik tarihler, maliyet kalemleri
- Teklife kadar yol haritasi (faz / kim / ne zaman / cikti)
- Idareye sorulacak belirsizlikler
Standart hizli ozet modu aynen duruyor.

Ihale Dosyasi Hazirla (rapor sonunda, acilir bolum):
- Eksik firma bilgileri kullaniciya sorulur, profilden gelenler sorulmaz,
  girilenler tarayicida saklanir
- Kalem fiyatlandirma tablosu, canli toplam + yaziyla karsiligi
- Teklif Mektubu, Birim Fiyat Teklif Cetveli, Evrak Kontrol Listesi
- Belgeler yazdirilabilir HTML: jsPDF'in gomulu fontlari WinAnsi, s/g/i/I
  tasimiyor (bkz. services/pdf.js ASCII basliklari) — resmi teklif mektubunda
  bozuk Turkce kabul edilemez

docx/xlsx/zip metin cikarimi (server/office-text.mjs):
- ihaleab projesindeki officeText.ts'ten tasindi, harici bagimlilik yok
- Sunucuda: tarayicida cozucu yok, baytlar zaten BELGE_DIR'de
- Tablolar " | " ile korunuyor, ic ice zip aciliyor, ~$ gecici dosyalari atlanir
- Yeni uc: GET /api/me/documents/text
- Yukleme kabul listesi .doc/.rar cikarildi (karsiligi yok), .xlsx eklendi

fix: yuklenen belgelerdeki "AI analiz" dugmesi bozuktu — extractText nesne
donduruyor ({text,pageCount,usedPages}), kod onu duz metin sanip gonderiyordu;
sunucuya "[object Object]" gidiyor ve analiz reddediliyordu. Uzunluk kontrolu
de nesne uzerinde undefined verdigi icin hatayi yutuyordu.

fix: analiz dokuman panelinden baslatildiginda modal kendi isAnalyzing'ini
bilmiyordu, kullanici 1-2 dakika bos modal goruyordu — ortak aiAnalyzing sinyali
2026-08-31 14:53:46 +03:00

369 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&amp;/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 <w:p> 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(/<w:br\b[^>]*\/?>/g, "\n")
.replace(/<w:tab\b[^>]*\/?>/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 <si> bir dize; içinde birden çok <t> parçası olabilir (biçimli metin).
for (const si of xml.split(/<si\b[^>]*>/).slice(1)) {
const govde = si.split("</si>")[0] ?? "";
const parcalar = [...govde.matchAll(/<t\b[^>]*>([\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(/<sheet\b[^>]*\bname="([^"]*)"/g)].map((m) => decodeXmlEntities(m[1]));
}
/**
* XLSX'ten metin çıkarır. Her sayfa "### <sayfa adı>" 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(/<row\b[^>]*>([\s\S]*?)<\/row>/g)) {
const hucreler = [];
for (const c of rowMatch[1].matchAll(/<c\b([^>]*)>([\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 = /<v\b[^>]*>([\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 "## <dosya adı>" 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 || ''));
}