fix: guide EKAP human verification

This commit is contained in:
2026-09-15 17:41:51 +03:00
parent 242e7d8c2e
commit e5e1b12185
10 changed files with 372 additions and 56 deletions
+10
View File
@@ -1,5 +1,15 @@
# Düzeltme planı — uygulama durumu
## 15 Eylül 2026 üretim ve D10 güncellemesi
- Offline işlem kuyruğu ve veri kaybını önleyen tekrar oynatma düzeltmesi GitHub `main` dalındaki `242e7d8` sürümüyle Coolify üzerinden üretime alındı. Canlı sürüm, hazır olma uçları, güvenlik başlıkları ve giriş asset hash'i eşleşti; offline kuyruk kodu üretim paketinde bulundu.
- Tarihi açık beş laboratuvar ihalesi seçildi: `2026/1634449` (Rize), `2026/1676347` (Sivas), `2026/1667534` (Konya), `2026/1620320` (Zonguldak) ve `2026/1636687` (Ankara).
- Beşinin de canlı liste kaydı, ilan metni, tarih/saat, kapsam, teslimat, teklif geçerlilik süresi, kısmi/tam teklif hükmü ve ilan içindeki gerçek belge şartları doğrulandı. Kanıt: [`qa-live-five-tenders.json`](evidence/qa-live-five-tenders.json).
- EKAP resmi doküman paketi beş kayıtta da HTTP 428 insan doğrulaması istedi. Bu nedenle ürün eşleştirme, firma belge kanıtı, fiyat cetveli ve son çıktılar D10 pozitif kabulü olarak işaretlenmedi: ilan aşaması `5/5`, resmi paket `0/5`, tam pozitif zincir `0/5`.
- Uygulamanın bu 428 yanıtını tekrar denenebilir genel 502 hatası gibi göstermesi düzeltildi. Yeni yanıt `HUMAN_VERIFICATION_REQUIRED`, `retryable: false` ve elle yapılacak adımı içeriyor; belge ekranı kullanıcıyı EKAP doğrulamasına ve indirdiği dosyayı yüklemeye yönlendiriyor.
Bu aşamada D10 tamamlanmış değildir. Kapanış için yetkili kullanıcının EKAP güvenlik doğrulamasından sonra beş resmi doküman paketini indirip uygulamaya yüklemesi ve test firmasının gerçek ürün/belge kayıtlarıyla kalan dört kapıyı geçmesi gerekir. Resmi teklif gönderilmedi.
6 Eylül 2026. Temel sürüm: 909ebae. Çalışma dalı: codex/ihaleist-duzeltme-plani.
Planın tamamı henüz bitmedi. Canlı yayın yapılmadı.
+7 -10
View File
@@ -66,6 +66,7 @@ import { bildirimleriIsle } from './notifier.mjs';
import { scanSite } from './site-scan.mjs';
import { createDetailCache } from './detail-cache.mjs';
import { releaseInfo } from './release-info.mjs';
import { sourceFailure } from './source-error.mjs';
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
@@ -663,13 +664,6 @@ function json(res, code, body) {
res.end(JSON.stringify(body));
}
const sourceUnavailable = error => ({
success: false,
error: error?.message || 'Resmi belge kaynağına ulaşılamadı.',
code: 'SOURCE_UNAVAILABLE',
retryable: true,
});
function readRaw(req, limit = 1024 * 1024) {
return new Promise((resolve, reject) => {
let buf = '';
@@ -842,7 +836,8 @@ const server = http.createServer(async (req, res) => {
const out = await documentUrl(ikn);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, sourceUnavailable(e));
const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
}
}
// İlan metni + kalemler — analiz için (tarayıcı gerektirmez)
@@ -854,7 +849,8 @@ const server = http.createServer(async (req, res) => {
const parsed = mergeAnnouncementFields(out.metin, out);
return json(res, 200, { success: true, ...out, ...parsed, tarih: out.tarih || parsed.tarih || '', saat: out.saat || parsed.saat || '' });
} catch (e) {
return json(res, 502, sourceUnavailable(e));
const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
}
}
// Doküman metni — sunucu tarafı tarayıcı gerektirir
@@ -867,7 +863,8 @@ const server = http.createServer(async (req, res) => {
const out = await documentText(ikn);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, sourceUnavailable(e));
const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
}
}
if (url.pathname === '/api/documents/config') {
+31
View File
@@ -0,0 +1,31 @@
const MANUAL_EKAP_URL = 'https://ekap.kik.gov.tr/EKAP/';
export function sourceFailure(error) {
const rawMessage = error?.message || 'Resmi belge kaynağına ulaşılamadı.';
const humanVerification = error?.code === 'HUMAN_VERIFICATION_REQUIRED'
|| /HUMAN_VERIFICATION_REQUIRED|EKAP\s+428/i.test(rawMessage);
if (humanVerification) {
return {
status: 428,
body: {
success: false,
error: 'EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin.',
code: 'HUMAN_VERIFICATION_REQUIRED',
retryable: false,
manualAction: true,
manualUrl: MANUAL_EKAP_URL,
},
};
}
return {
status: 502,
body: {
success: false,
error: rawMessage,
code: 'SOURCE_UNAVAILABLE',
retryable: true,
},
};
}
+13 -3
View File
@@ -177,6 +177,12 @@ const TenderDocumentSession = ({ tender }) => {
try {
const r = await fetch(`/api/documents/url?ikn=${encodeURIComponent(ikn)}`);
const d = await r.json();
if (!d.success && d.code === 'HUMAN_VERIFICATION_REQUIRED' && d.manualAction && d.manualUrl) {
setEkapUrl(d.manualUrl);
setBilgi({ manualAction: true, ekapSekme: sekme ? 'acildi' : 'engellendi' });
if (sekme) sekme.location.href = d.manualUrl;
return;
}
if (!d.success) throw new Error(d.error);
setEkapUrl(d.url);
setBilgi({ dokumanSayisi: d.dokumanSayisi, ekapSekme: sekme ? 'acildi' : 'engellendi' });
@@ -326,16 +332,20 @@ const TenderDocumentSession = ({ tender }) => {
</div>
{ekapUrl && (
<div className={`rounded-lg p-2.5 flex gap-2 border ${bilgi?.ekapSekme === 'engellendi' ? 'bg-amber-50 border-amber-200' : 'bg-slate-50 border-slate-200'}`}>
<div className={`rounded-lg p-2.5 flex gap-2 border ${bilgi?.ekapSekme === 'engellendi' || bilgi?.manualAction ? 'bg-amber-50 border-amber-200' : 'bg-slate-50 border-slate-200'}`}>
<ExternalLink className="w-3.5 h-3.5 text-blue-600 shrink-0 mt-0.5" />
<p className="text-[11px] text-slate-700 leading-relaxed">
{bilgi?.ekapSekme === 'engellendi'
{bilgi?.manualAction
? <>EKAP güvenlik doğrulaması istedi. Kendi oturumunuzda EKAP’ı açıp İKN ile arayın: </>
: bilgi?.ekapSekme === 'engellendi'
? <>Tarayıcınız yeni sekmeyi engelledi. </>
: <>EKAP doküman sayfası yeni sekmede açıldı. Açılmadıysa: </>}
<a href={ekapUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 font-bold hover:underline">
EKAP doküman sayfasını aç
</a>
{' '}· Bağlantı bir süre sonra geçerliliğini yitirebilir; açılmazsa düğmeye yeniden basın.
{bilgi?.manualAction
? <> · Doğrulamadan sonra dosyaları indirin ve aşağıdaki yükleme alanına ekleyin.</>
: <> · Bağlantı bir süre sonra geçerliliğini yitirebilir; açılmazsa düğmeye yeniden basın.</>}
</p>
</div>
)}
+14 -3
View File
@@ -4,11 +4,20 @@
* Dev'de Vite proxy'si /api isteklerini localhost:3001'e yönlendirir.
*/
function sourceError(data, temporaryMessage) {
if (data.code === 'HUMAN_VERIFICATION_REQUIRED' && data.manualAction) {
return new Error(data.error || 'EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açın.');
}
if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) return new Error(temporaryMessage);
return null;
}
export async function fetchLiveTenders({ refresh = false } = {}) {
const res = await fetch(`/api/tenders${refresh ? '?refresh=1' : ''}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) throw new Error('İhale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.');
const failure = sourceError(data, 'İhale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.');
if (failure) throw failure;
throw new Error(`API hatası: HTTP ${res.status}`);
}
if (!data.success) throw new Error(data.error || 'API başarısız yanıt döndü');
@@ -28,7 +37,8 @@ export async function fetchTenderDetail(tenderId, {signal, timeoutMs = 30000} =
const res = await fetch(url, {signal:controller.signal});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) throw new Error('Resmi ihale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.');
const failure = sourceError(data, 'Resmi ihale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.');
if (failure) throw failure;
throw new Error(`Detay API hatası: HTTP ${res.status}`);
}
if (!data.success) throw new Error(data.error || 'Detay alınamadı');
@@ -69,7 +79,8 @@ export async function fetchOfficialTenderDetail(ikn, {signal, timeoutMs = 30000}
const res = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(cleanIkn)}`, { signal: controller.signal });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) throw new Error('Resmî ihale kaynağı geçici olarak erişilemiyor.');
const failure = sourceError(data, 'Resmî ihale kaynağı geçici olarak erişilemiyor.');
if (failure) throw failure;
throw new Error(`Resmî detay API hatası: HTTP ${res.status}`);
}
if (!data.success || data.ikn !== cleanIkn) throw new Error('Resmî detay yanıtı aranan İKN ile uyuşmuyor.');
+27
View File
@@ -0,0 +1,27 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { currentLabAcceptanceCases as cases } from './fixtures/d10-current-lab.mjs';
test('D10 current laboratory acceptance fixture is complete and explicit', () => {
assert.equal(cases.length, 5);
assert.equal(new Set(cases.map(({ ikn }) => ikn)).size, 5);
assert.equal(new Set(cases.map(({ id }) => id)).size, 5);
for (const item of cases) {
assert.match(item.ikn, /^2026\/\d{7}$/);
assert.match(item.id, /^ilan-\d+$/);
assert.match(item.date, /^\d{2}\.\d{2}\.2026$/);
assert.match(item.time, /^\d{2}:\d{2}$/);
assert.ok(item.titleTerm);
assert.ok(item.natureTerm);
assert.ok(item.deliveryTerm);
assert.ok([60, 120].includes(item.validityDays));
assert.ok(item.partialOfferTerm);
assert.ok(item.requirements.length >= 5);
assert.equal(new Set(item.requirements.map(({ key }) => key)).size, item.requirements.length);
for (const requirement of item.requirements) {
assert.ok(requirement.label);
assert.ok(requirement.term);
}
}
});
+117
View File
@@ -0,0 +1,117 @@
const commonRequirements = [
{ key: 'offer-letter', label: 'Teklif mektubu', term: 'Teklif mektubu' },
{ key: 'authorization', label: 'Teklif vermeye yetki bilgileri', term: 'Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler' },
{ key: 'temporary-guarantee', label: 'Geçici teminat', term: 'Geçici teminat' },
];
function tender(definition) {
return {
...definition,
requirements: [...commonRequirements, ...definition.requirements],
};
}
// Current public-announcement acceptance set selected on 15 September 2026.
// These records prove announcement requirements only. EKAP's official document
// package, company/product evidence and generated outputs are separate D10 gates.
export const currentLabAcceptanceCases = [
tender({
ikn: '2026/1634449',
id: 'ilan-2219605',
city: 'Rize',
date: '05.10.2026',
time: '10:30',
scenario: '20-kalem-laboratuvar-cihazi',
titleTerm: 'Laboratuvar cihazı',
natureTerm: '20 KALEM',
deliveryTerm: '50 (Elli) takvim günü',
validityDays: 60,
partialOfferTerm: 'kısmı teklif verilebilir',
requirements: [
{ key: 'manufacturer-or-authorized-seller', label: 'İmalatçı/yetkili satıcı/yetkili temsilci belgesi', term: 'Yetkili Satıcı Belgesi' },
{ key: 'service-qualification', label: 'TSE Hizmet Yeterlilik Belgesi', term: 'TSE Hizmet Yeterlilik Belgesi' },
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
{ key: 'brand-model', label: 'Marka-model beyanı', term: 'MARKA - MODEL BEYANI' },
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
],
}),
tender({
ikn: '2026/1676347',
id: 'ilan-2218485',
city: 'Sivas',
date: '01.10.2026',
time: '10:30',
scenario: 'cihaz-malzeme-yazilim',
titleTerm: 'Laboratuvar cihaz, malzeme ve yazılım',
natureTerm: '2 Kısım Cihaz, 1 Kısım Yazılım ve 1 Kısım Sarf Malzeme',
deliveryTerm: '3. Kısım yazılımın kurulumu 15 takvim günü',
validityDays: 60,
partialOfferTerm: 'kısmı teklif verilebilir',
requirements: [
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
],
}),
tender({
ikn: '2026/1667534',
id: 'ilan-2217472',
city: 'Konya',
date: '28.09.2026',
time: '10:30',
scenario: 'yedek-parca-ve-sarf',
titleTerm: 'Laboratuvar cihaz yedek parça ve sarf malzeme',
natureTerm: '28 Kalem Laboratuvar Cihaz Yedek Parça ve Tıbbi Sarf Malzemesi',
deliveryTerm: '27. ve 28. kalemler için 10 takvim günü',
validityDays: 120,
partialOfferTerm: 'kısmı teklif verilebilir',
requirements: [
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
{ key: 'sample', label: 'Numune', term: 'Numune sunulması istenmektedir' },
],
}),
tender({
ikn: '2026/1620320',
id: 'ilan-2214836',
city: 'Zonguldak',
date: '01.10.2026',
time: '10:00',
scenario: '220-kalem-laboratuvar-sarfi',
titleTerm: 'Laboratuvar sarf malzemeleri',
natureTerm: '220 Kalem Laboratuvar Sarf Malzeme',
deliveryTerm: '15(on beş) gün içerisinde',
validityDays: 120,
partialOfferTerm: 'kısmı teklif verilebilir',
requirements: [
{ key: 'scope-exclusion', label: 'Kapsam Dışı Belgesi', term: 'Kapsam Dışı Belgesi' },
{ key: 'sales-center', label: 'Satış Merkezi Yetki Belgesi', term: 'Satış Merkezi Yetki Belgesi' },
{ key: 'uts', label: 'TİTUBB veya ÜTS belgesi', term: 'TİTUBB veya ÜTS belgesi' },
{ key: 'sample', label: 'Numune', term: 'Numune sunulması istenmektedir' },
],
}),
tender({
ikn: '2026/1636687',
id: 'ilan-2211682',
city: 'Ankara',
date: '28.09.2026',
time: '14:00',
scenario: 'kit-karsiligi-otoanalizor',
titleTerm: 'Kit karşılığı otoanalizör',
natureTerm: '24 Aylık Kit ve Kit Karşılığı Alınacak Otoanalizör',
deliveryTerm: '15 (Onbeş) takvim günü',
validityDays: 120,
partialOfferTerm: 'işin tamamı için teklif verilecektir',
requirements: [
{ key: 'service-training', label: 'Teknik Servis Eğitim Sertifikası', term: 'Teknik Servis Eğitim Sertifikası' },
{ key: 'brochure', label: 'Broşür', term: 'Broşür' },
{ key: 'user-guide', label: 'Cihaz ve ODS kullanım kılavuzu', term: 'ODS yazılım sistemine ait kullanım klavuzu' },
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
{ key: 'sales-center', label: 'Satış Merkezi Yetki Belgesi', term: 'Satış Merkezi Yetki Belgesi' },
{ key: 'device-id-cards', label: 'Cihaz ve sistem tanıtım kimlik kartı', term: 'tanıtım kimlik kartı' },
{ key: 'production-and-age', label: 'Üretimde olma ve 13 yaş altı kanıtı', term: 'on üç yaşın altında olduğunu belirten belgeler' },
{ key: 'offer-authorization-proof', label: 'Teklif yetkisi kanıtı', term: 'Teklif vermeye yetkili olduğuna dair kanıtlayıcı belge' },
{ key: 'uts-evidence', label: 'ÜTS beyanı ve kanıtlayıcı belgeler', term: 'ÜRÜN TAKİP SİSTEMİ (ÜTS) BEYANI VE KANITLAYICI BELGELER' },
],
}),
];
+124 -37
View File
@@ -1,67 +1,154 @@
// Read-only production acceptance probe for the five health/laboratory tenders.
// No authentication, writes, payments, messages, or tender submissions.
import { writeFile } from 'node:fs/promises';
import { healthAcceptanceCases } from './fixtures/health-acceptance.mjs';
// Read-only production acceptance probe for five current laboratory tenders.
// It records every D10 gate separately and never turns an upstream blocker into
// a positive result. No authentication, writes, payments, messages or bids.
import { createHash } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { currentLabAcceptanceCases } from './fixtures/d10-current-lab.mjs';
const base = String(process.argv[2] || 'https://ihaleist.com').replace(/\/$/, '');
const evidencePath = process.env.IHALEIST_LIVE_FIVE_EVIDENCE_PATH || 'docs/quality/evidence/qa-live-five-tenders.json';
const timeoutMs = 30_000;
async function getJson(path) {
function normalize(value) {
return String(value || '').normalize('NFKC').replace(/\s+/g, ' ').trim().toLocaleLowerCase('tr-TR');
}
function includesTerm(text, term) {
return normalize(text).includes(normalize(term));
}
async function probeJson(path) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${base}${path}`, { signal: controller.signal, headers: { 'User-Agent': 'ihaleist-live-five-probe/1.0' } });
const data = await response.json().catch(() => null);
if (!response.ok || !data || data.success !== true) throw new Error(`${path}: HTTP ${response.status}`);
return data;
} finally { clearTimeout(timer); }
return {
path,
status: response.status,
ok: response.ok && data?.success === true,
data,
error: response.ok && data?.success === true ? null : data?.error || `HTTP ${response.status}`,
};
} catch (error) {
return { path, status: 0, ok: false, data: null, error: error?.message || String(error) };
} finally {
clearTimeout(timer);
}
}
const list = await getJson('/api/tenders?limit=100');
const listProbe = await probeJson('/api/tenders');
const list = listProbe.data || {};
const byIkn = new Map((Array.isArray(list.tenders) ? list.tenders : []).map((tender) => [String(tender.ikn || '').replace(/\s/g, ''), tender]));
const tenders = [];
for (const fixture of healthAcceptanceCases) {
const ikn = `2026/${fixture.ikn}`;
const listed = byIkn.get(ikn);
if (!listed) throw new Error(`${ikn}: live listede bulunamadı`);
const detail = await getJson(`/api/tenders/${encodeURIComponent(listed.id)}/detail`);
const document = await getJson(`/api/documents/ilan?ikn=${encodeURIComponent(ikn)}`);
const officialDate = document.sourceDateTime || `${document.tarih || detail.tarih || ''} ${document.saat || detail.saat || ''}`.trim();
if (!officialDate || !document.ikn || document.ikn !== ikn) throw new Error(`${ikn}: resmi tarih/İKN doğrulanamadı`);
for (const fixture of currentLabAcceptanceCases) {
const listed = byIkn.get(fixture.ikn) || null;
const detailProbe = await probeJson(`/api/tenders/${encodeURIComponent(fixture.id)}/detail`);
const detail = detailProbe.data || {};
const announcementText = String(detail.aciklama || '');
const requirementChecks = fixture.requirements.map(({ key, label, term }) => ({
key,
label,
sourceTerm: term,
present: includesTerm(announcementText, term),
}));
const announcementChecks = {
listed: Boolean(listed),
detailEndpoint: detailProbe.ok,
ikn: includesTerm(announcementText, fixture.ikn),
title: includesTerm(`${listed?.baslik || ''} ${announcementText}`, fixture.titleTerm),
date: detail.tarih === fixture.date && includesTerm(announcementText, fixture.date),
time: detail.saat === fixture.time && includesTerm(announcementText, fixture.time),
nature: includesTerm(announcementText, fixture.natureTerm),
delivery: includesTerm(announcementText, fixture.deliveryTerm),
validity: includesTerm(announcementText, `geçerlilik süresi, ihale tarihinden itibaren ${fixture.validityDays}`),
partialOffer: includesTerm(announcementText, fixture.partialOfferTerm),
requirements: requirementChecks.every(({ present }) => present),
};
const announcementPassed = Object.values(announcementChecks).every(Boolean);
const documentProbe = await probeJson(`/api/documents/ilan?ikn=${encodeURIComponent(fixture.ikn)}`);
const document = documentProbe.data || {};
const officialDocumentPackagePassed = documentProbe.ok
&& document.ikn === fixture.ikn
&& Number(document.dokumanSayisi) > 0;
const documentBlocker = officialDocumentPackagePassed ? null : {
stage: 'officialDocumentPackage',
status: documentProbe.status,
code: document.code || null,
retryable: document.retryable ?? null,
message: document.error || documentProbe.error,
};
const dependentStatus = officialDocumentPackagePassed ? 'not-run-read-only-scope' : 'blocked-by-official-document-package';
const stages = {
selectionAndAnnouncement: { passed: announcementPassed, status: announcementPassed ? 'passed' : 'failed' },
officialDocumentPackage: { passed: officialDocumentPackagePassed, status: officialDocumentPackagePassed ? 'passed' : 'blocked', blocker: documentBlocker },
productMatch: { passed: false, status: dependentStatus },
companyDocumentEvidence: { passed: false, status: dependentStatus },
priceAndOutputs: { passed: false, status: dependentStatus },
};
tenders.push({
ikn,
ikn: fixture.ikn,
scenario: fixture.scenario,
id: listed.id,
title: listed.baslik,
institution: listed.kurum,
city: listed.sehir,
sector: listed.sektor,
procedure: listed.usul,
announcementDate: listed.ilanTarihi,
tenderDate: document.tarih || detail.tarih || '',
tenderTime: document.saat || detail.saat || '',
sourceDateTime: officialDate,
officialDateSource: 'documents/ilan sourceDateTime',
announcementDateConflict: ikn === '2026/1573235' && listed.tarih !== document.tarih
? `ilan listesi ${listed.tarih}; resmi detay ${document.tarih}` : null,
detailStatus: detail.success === true,
documentProbeStatus: 200,
id: fixture.id,
title: listed?.baslik || detail.title || null,
institution: listed?.kurum || detail.institution || null,
city: listed?.sehir || detail.city || fixture.city,
tenderDate: detail.tarih || null,
tenderTime: detail.saat || null,
sourceUrl: listed?.kaynakUrl || null,
publicAnnouncement: {
endpointStatus: detailProbe.status,
textChars: announcementText.length,
normalizedTextSha256: announcementText
? createHash('sha256').update(normalize(announcementText)).digest('hex')
: null,
checks: announcementChecks,
requirementChecks,
},
officialDocumentProbe: {
endpointStatus: documentProbe.status,
code: document.code || null,
retryable: document.retryable ?? null,
documentCount: Number(document.dokumanSayisi) || 0,
announcementCount: Number(document.ilanSayisi) || 0,
okasCount: Number(document.okasSayisi) || 0,
documentEndpointSuccess: document.success === true,
sourceUrl: listed.kaynakUrl,
error: document.error || documentProbe.error,
},
stages,
positive: Object.values(stages).every(({ passed }) => passed),
});
}
const blockers = tenders.flatMap((tender) => tender.stages.officialDocumentPackage.blocker
? [{ ikn: tender.ikn, ...tender.stages.officialDocumentPackage.blocker }]
: []);
const result = {
checkedAt: new Date().toISOString(),
base,
readOnly: true,
listProbe: { status: listProbe.status, success: listProbe.ok },
listCount: Array.isArray(list.tenders) ? list.tenders.length : 0,
selectedCount: tenders.length,
announcementPassCount: tenders.filter(({ stages }) => stages.selectionAndAnnouncement.passed).length,
officialDocumentPackagePassCount: tenders.filter(({ stages }) => stages.officialDocumentPackage.passed).length,
positiveCount: tenders.filter(({ positive }) => positive).length,
pass: tenders.length === 5 && tenders.every(({ positive }) => positive),
blockers,
tenders,
scope: 'Live metadata/detail/document endpoint probes only; no authentication, writes, payments, messages, or submissions.',
scope: 'Canlı liste, ilan metni ve resmi doküman uçları salt okunur denetlendi; giriş, veri değişikliği, ödeme, mesaj veya teklif gönderimi yapılmadı.',
};
await mkdir(dirname(evidencePath), { recursive: true });
await writeFile(evidencePath, `${JSON.stringify(result, null, 2)}\n`);
console.log(JSON.stringify({ base, checkedAt: result.checkedAt, tenders: tenders.map(({ ikn, tenderDate, tenderTime, documentCount }) => ({ ikn, tenderDate, tenderTime, documentCount })) }));
console.log(JSON.stringify({
base,
checkedAt: result.checkedAt,
selectedCount: result.selectedCount,
announcementPassCount: result.announcementPassCount,
officialDocumentPackagePassCount: result.officialDocumentPackagePassCount,
positiveCount: result.positiveCount,
pass: result.pass,
blockerCodes: [...new Set(blockers.map(({ code }) => code).filter(Boolean))],
}));
if (!result.pass) process.exitCode = 2;
+24
View File
@@ -0,0 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sourceFailure } from '../server/source-error.mjs';
test('EKAP human verification is a manual non-retryable gate', () => {
const result = sourceFailure(new Error('EKAP 428: {"code":"HUMAN_VERIFICATION_REQUIRED"}'));
assert.equal(result.status, 428);
assert.equal(result.body.code, 'HUMAN_VERIFICATION_REQUIRED');
assert.equal(result.body.retryable, false);
assert.equal(result.body.manualAction, true);
assert.equal(result.body.manualUrl, 'https://ekap.kik.gov.tr/EKAP/');
assert.match(result.body.error, /kendi tarayıcınızda açıp doğrulamayı tamamlayın/);
});
test('ordinary upstream failures stay retryable', () => {
const result = sourceFailure(new Error('upstream timeout'));
assert.equal(result.status, 502);
assert.deepEqual(result.body, {
success: false,
error: 'upstream timeout',
code: 'SOURCE_UNAVAILABLE',
retryable: true,
});
});
+2
View File
@@ -12,6 +12,8 @@ test('detail loading rejects empty source, supports retry and stops on timeout/c
await assert.rejects(()=>fetchTenderDetail('ilan-1'),/HTTP 503/);
globalThis.fetch=async()=>Response.json({success:false,code:'SOURCE_UNAVAILABLE',retryable:true,error:'kaynak yok'},{status:502});
await assert.rejects(()=>fetchTenderDetail('ilan-1'),/geçici olarak erişilemiyor.*yeniden deneyin/);
globalThis.fetch=async()=>Response.json({success:false,code:'HUMAN_VERIFICATION_REQUIRED',retryable:false,manualAction:true,error:'EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açın.'},{status:428});
await assert.rejects(()=>fetchTenderDetail('ekap-2026/123'),/güvenlik doğrulaması.*kendi tarayıcınızda açın/);
globalThis.fetch=async(url,{signal})=>new Promise((resolve,reject)=>{
if(signal.aborted) reject(new DOMException('Cancelled','AbortError'));
else signal.addEventListener('abort',()=>reject(new DOMException('Cancelled','AbortError')),{once:true});