fix: preserve complete weekly brief content and date bounds

This commit is contained in:
2026-09-06 14:29:59 +03:00
parent 23512b7a87
commit d3a52caf2b
8 changed files with 143 additions and 24 deletions
+8
View File
@@ -1062,3 +1062,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- Yönetici yanıtında `kanallar.telegram/email` için `denenen/kabul/hata`, kapalı tercih sayısı `kapali`, yapılandırılmış kanalı olmayanların sayısı `kanalsiz` var. `gonderilen` en az bir kanalda kabul edilen kullanıcı sayısı, `hata` en az bir hata yaşayan kullanıcı sayısıdır; kısmi başarıda ikisi de artabilir. `success` hata varsa false, `partial` en az bir kullanıcıya kabul varken hata varsa true. Sağlayıcı kabulü alıcının okuduğunu kanıtlamaz. - Yönetici yanıtında `kanallar.telegram/email` için `denenen/kabul/hata`, kapalı tercih sayısı `kapali`, yapılandırılmış kanalı olmayanların sayısı `kanalsiz` var. `gonderilen` en az bir kanalda kabul edilen kullanıcı sayısı, `hata` en az bir hata yaşayan kullanıcı sayısıdır; kısmi başarıda ikisi de artabilir. `success` hata varsa false, `partial` en az bir kullanıcıya kabul varken hata varsa true. Sağlayıcı kabulü alıcının okuduğunu kanıtlamaz.
- Altı yeni test: zorla opt-out, kısmi kanal hatası, eksik/false kabul, gönderim sırasında tercih değişikliği, boş/kanalsız özet, kullanıcı/ilan kaynağı hatası. 243 test geçti; gerçek e-posta/Telegram gönderilmedi. - Altı yeni test: zorla opt-out, kısmi kanal hatası, eksik/false kabul, gönderim sırasında tercih değişikliği, boş/kanalsız özet, kullanıcı/ilan kaynağı hatası. 243 test geçti; gerçek e-posta/Telegram gönderilmedi.
- Henüz kapanmayanlar: kalıcı kanal bazlı tekrar engeli, zamanlayıcının gönderimden önce hafta kaydı yazması, Telegram uzun içerik kesilmesi. Bu değişiklik otomatik yeniden deneme eklemiyor; kabul edilmiş iletilerin tekrarına yol açacak toplu tekrar çözüm olarak kullanılmamalı. - Henüz kapanmayanlar: kalıcı kanal bazlı tekrar engeli, zamanlayıcının gönderimden önce hafta kaydı yazması, Telegram uzun içerik kesilmesi. Bu değişiklik otomatik yeniden deneme eklemiyor; kabul edilmiş iletilerin tekrarına yol açacak toplu tekrar çözüm olarak kullanılmamalı.
### 2026-09-06 — Haftalık özet içerik bütünlüğü
- Özet oluşturmadaki ilk 200 takip/ilk 15 sonuç sınırları kaldırıldı. Aynı takip anahtarı tekrar işlenmiyor; sonuçlar tarihe göre yeniler önce sıralanıyor. Önceki yedi tam takvim günü Türkiye saatine göre hesaplanıyor; başlangıç günü dahil, bugün ve gelecek tarihler hariç. Başlık bu aralığı açıkça belirtiyor; haftalık tarih etiketi sunucu saat dilimine bağlı değil.
- Telegram için özetin ilk 3900 karakterini kesmek yerine tüm metin en fazla 3900 UTF-16 birimlik parçalara ayrılıyor; emoji çiftleri bölünmüyor. Özet düz metin gönderiliyor; kaynak başlıklarındaki `<`, `>` ve `&` HTML olarak yorumlanmıyor. Diğer mevcut Telegram çağrılarının HTML modu korunuyor.
- Tercih her parça öncesi kontrol ediliyor. Bir parça hata verirse kalan Telegram parçaları gönderilmiyor; e-posta kanalı ayrıca deneniyor. Telegram `denenen/kabul/hata` sayaçları artık mesaj parçası sayısıdır. `gonderilen` en az bir mesaj kabulü bulunan kullanıcı sayısıdır, tüm parçaların tamamlandığı anlamına gelmez; hatalar birlikte değerlendirilmelidir.
- Doğrulama: 248 testlik tüm süit ve ek 1 Telegram taşıma testi geçti; build başarılı (mevcut büyük paket uyarısı sürüyor). 205 takip kaydı, 15 üzeri sonuç, tarih sınırları, uzun Türkçe/emoji içeriğin birebir birleşmesi, parça hatası ve gerçek HTTP çağrısı yerine sahte taşıyıcı ile düz metin gövdesi doğrulandı. Gerçek mesaj gönderilmedi.
- Açık işler: kalıcı parça/kanal teslimat kaydı, zamanlayıcı/yeniden deneme, sağlayıcı oran limitleri ve izinli test alıcısında kabul. Bu aşama çökme sonrası tam-bir-kez teslimat garantisi vermez. Kaynakta sözleşme tarihi bulunmadığında mevcut ihale tarihi yedeği kullanılıyor; geçmiş sonuç tarihi kaynak doğruluğu ayrıca doğrulanmalı.
+35
View File
@@ -0,0 +1,35 @@
/** Assemble every tracked result in the previous seven Istanbul calendar dates. */
export function createBriefBuilder({ trackedKeys, upcoming, results, parseDate }) {
return function buildBrief(email, tenders, now = new Date()) {
const yaklasan = upcoming(email, tenders, 7, now);
const today = new Date(now.getTime() + 3 * 3600000).toISOString().slice(0,10);
const end = Date.parse(today + 'T00:00:00+03:00');
const start = end - 7 * 86400000;
const dated = [];
for (const ikn of new Set(trackedKeys(email))) {
for (const result of results(ikn) || []) {
const date = parseDate(result.sozlesmeTarihi) || parseDate(result.tarih);
if (!date || date.getTime() < start || date.getTime() >= end) continue;
dated.push({ timestamp:date.getTime(), value:{ ikn, baslik:result.baslik, kazanan:result.kazananFirma,
bedel:result.sozlesmeBedeli, birim:result.paraBirimi, teklif:result.teklifSayisi } });
}
}
dated.sort((a,b) => b.timestamp-a.timestamp || String(a.value.ikn).localeCompare(String(b.value.ikn),'tr'));
const sonuclar = dated.map(row=>row.value);
const hafta = now.toLocaleDateString('tr-TR', { timeZone:'Europe/Istanbul', day:'2-digit', month:'long', year:'numeric' });
return { hafta, yaklasan, sonuclar, bos:!yaklasan.length && !sonuclar.length };
};
}
/** Keep UTF-16 surrogate pairs intact while bounding each Telegram message. */
export function splitBriefText(text, limit=3900) {
if (!Number.isInteger(limit) || limit < 2 || limit > 3900) throw new Error('Geçersiz mesaj uzunluğu.');
const parts=[];
let part='';
for (const char of String(text)) {
if (part.length + char.length > limit) { parts.push(part); part=''; }
part += char;
}
if (part) parts.push(part);
return parts;
}
+9 -2
View File
@@ -1,3 +1,5 @@
import { splitBriefText } from './brief-content.mjs';
/** Delivery accounting shared by scheduled and administrator-triggered briefs. */ /** Delivery accounting shared by scheduled and administrator-triggered briefs. */
export function createBriefSender({ store, buildBrief, text, html, telegramConfigured, sendTelegram, emailConfigured, sendEmail, warn = console.warn }) { export function createBriefSender({ store, buildBrief, text, html, telegramConfigured, sendTelegram, emailConfigured, sendEmail, warn = console.warn }) {
return async function sendBrief(tenderGetir) { return async function sendBrief(tenderGetir) {
@@ -14,16 +16,20 @@ export function createBriefSender({ store, buildBrief, text, html, telegramConfi
if (brief.bos) { r.bos++; continue; } if (brief.bos) { r.bos++; continue; }
const channels = []; const channels = [];
const telegram = store.getData(user.email, 'telegram'); const telegram = store.getData(user.email, 'telegram');
if (telegramConfigured() && telegram?.chatId) channels.push({ if (telegramConfigured() && telegram?.chatId) {
name:'telegram', send:() => sendTelegram(telegram.chatId, text(brief).slice(0,3900)), for (const part of splitBriefText(text(brief))) channels.push({
name:'telegram', send:() => sendTelegram(telegram.chatId, part, { parseMode:null }),
valid:receipt => receipt === true, valid:receipt => receipt === true,
}); });
}
if (emailConfigured()) channels.push({ if (emailConfigured()) channels.push({
name:'email', send:() => sendEmail({ to:user.email, subject:`İhaleist — Haftalık Brief (${brief.hafta})`, html:html(brief), text:text(brief) }), name:'email', send:() => sendEmail({ to:user.email, subject:`İhaleist — Haftalık Brief (${brief.hafta})`, html:html(brief), text:text(brief) }),
valid:receipt => typeof receipt?.id === 'string' && Boolean(receipt.id.trim()), valid:receipt => typeof receipt?.id === 'string' && Boolean(receipt.id.trim()),
}); });
if (!channels.length) r.kanalsiz++; if (!channels.length) r.kanalsiz++;
const failedChannels = new Set();
for (const channel of channels) { for (const channel of channels) {
if (failedChannels.has(channel.name)) continue;
// An earlier channel can await the network while the user disables briefs. // An earlier channel can await the network while the user disables briefs.
if (store.getData(user.email, 'brief')?.kapali) { r.kapali++; break; } if (store.getData(user.email, 'brief')?.kapali) { r.kapali++; break; }
const counts = r.kanallar[channel.name]; const counts = r.kanallar[channel.name];
@@ -35,6 +41,7 @@ export function createBriefSender({ store, buildBrief, text, html, telegramConfi
accepted = true; accepted = true;
} catch (error) { } catch (error) {
counts.hata++; counts.hata++;
failedChannels.add(channel.name);
failed = true; failed = true;
warn('[brief]', user.email, channel.name, error.message); warn('[brief]', user.email, channel.name, error.message);
} }
+7 -18
View File
@@ -1,8 +1,9 @@
// Haftalık brief — Pazartesi 08:00: takipteki ihalelerden bu hafta yapılacaklar, // Haftalık brief — Pazartesi 08:00: takipteki ihalelerden bu hafta yapılacaklar,
// geçen hafta sonuçlanan takipliler, takiplilere gelen zeyilname sayısı. // önceki yedi takvim günündeki takipli sonuç kayıtları.
// Telegram (bağlıysa) + e-posta. Son gönderim data/brief-son.json'da tutulur; // Telegram (bağlıysa) + e-posta. Son gönderim data/brief-son.json'da tutulur;
// saatlik kontrol, haftada bir gönderim (node-cron yok, setInterval). // saatlik kontrol, haftada bir gönderim (node-cron yok, setInterval).
import fs from 'node:fs'; import fs from 'node:fs';
import { createBriefBuilder } from './brief-content.mjs';
import { createBriefSender } from './brief-delivery.mjs'; import { createBriefSender } from './brief-delivery.mjs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -25,21 +26,9 @@ function sonGonderimYaz(d) {
const paraTR = (v, birim) => (v == null ? '' : `${Number(v).toLocaleString('tr-TR')} ${birim || 'TL'}`); const paraTR = (v, birim) => (v == null ? '' : `${Number(v).toLocaleString('tr-TR')} ${birim || 'TL'}`);
/** Bir kullanıcı için brief içeriği. */ /** Bir kullanıcı için brief içeriği. */
export function briefOlustur(email, tenders) { export const briefOlustur = createBriefBuilder({
const takip = store.getTrackedTenderKeys(email); trackedKeys:store.getTrackedTenderKeys, upcoming:yaklasanIhaleler, results:resultsByIkn, parseDate:trTarih,
const yaklasan = yaklasanIhaleler(email, tenders, 7); });
const birHafta = Date.now() - 7 * 86400000;
const sonuclar = [];
for (const ikn of takip.slice(0, 200)) {
const r = resultsByIkn(ikn) || [];
for (const s of r) {
const d = trTarih(s.sozlesmeTarihi) || trTarih(s.tarih);
if (d && d.getTime() >= birHafta) sonuclar.push({ ikn, baslik: s.baslik, kazanan: s.kazananFirma, bedel: s.sozlesmeBedeli, birim: s.paraBirimi, teklif: s.teklifSayisi });
}
}
const hafta = new Date().toLocaleDateString('tr-TR', { day: '2-digit', month: 'long', year: 'numeric' });
return { hafta, yaklasan, sonuclar: sonuclar.slice(0, 15), bos: !yaklasan.length && !sonuclar.length };
}
const deadlineText = t => t.son_teklif_zamani const deadlineText = t => t.son_teklif_zamani
? new Date(t.son_teklif_zamani).toLocaleTimeString('tr-TR', { timeZone: 'Europe/Istanbul', hour: '2-digit', minute: '2-digit' }) + ' (Türkiye saati)' ? new Date(t.son_teklif_zamani).toLocaleTimeString('tr-TR', { timeZone: 'Europe/Istanbul', hour: '2-digit', minute: '2-digit' }) + ' (Türkiye saati)'
@@ -52,7 +41,7 @@ export function briefMetni(b) {
for (const t of b.yaklasan) s.push(`• ${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'} — ${t.baslik} (${t.kurum}, ${t.tarih} · ${deadlineText(t)})`); for (const t of b.yaklasan) s.push(`• ${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'} — ${t.baslik} (${t.kurum}, ${t.tarih} · ${deadlineText(t)})`);
} }
if (b.sonuclar.length) { if (b.sonuclar.length) {
s.push('', `🏁 Geçen hafta sonuçlanan takipliler (${b.sonuclar.length}):`); s.push('', `🏁 Önceki 7 takvim günündeki takipli sonuç kayıtları (${b.sonuclar.length}):`);
for (const r of b.sonuclar) s.push(`• ${r.baslik} → ${r.kazanan || 'kazanan açıklanmadı'}${r.bedel != null ? ' · ' + paraTR(r.bedel, r.birim) : ''}${r.teklif != null ? ' · ' + r.teklif + ' teklif' : ''}`); for (const r of b.sonuclar) s.push(`• ${r.baslik} → ${r.kazanan || 'kazanan açıklanmadı'}${r.bedel != null ? ' · ' + paraTR(r.bedel, r.birim) : ''}${r.teklif != null ? ' · ' + r.teklif + ' teklif' : ''}`);
} }
s.push('', 'ihaleist.com'); s.push('', 'ihaleist.com');
@@ -66,7 +55,7 @@ export function briefHtml(b) {
return `<div style="font-family:Inter,Arial,sans-serif;max-width:640px;margin:auto;padding:20px;color:#0f172a"> return `<div style="font-family:Inter,Arial,sans-serif;max-width:640px;margin:auto;padding:20px;color:#0f172a">
<h2 style="margin:0 0 4px;font-size:20px">📋 Haftalık İhale Brief</h2><div style="color:#64748b;font-size:13px">${esc(b.hafta)}</div> <h2 style="margin:0 0 4px;font-size:20px">📋 Haftalık İhale Brief</h2><div style="color:#64748b;font-size:13px">${esc(b.hafta)}</div>
${bolum('⏰ Bu hafta yapılacak takipli ihaleler', b.yaklasan.map((t) => `<b>${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'}</b> — ${esc(t.baslik)} (${esc(t.kurum)}, ${esc(t.tarih)} · ${esc(deadlineText(t))})`))} ${bolum('⏰ Bu hafta yapılacak takipli ihaleler', b.yaklasan.map((t) => `<b>${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'}</b> — ${esc(t.baslik)} (${esc(t.kurum)}, ${esc(t.tarih)} · ${esc(deadlineText(t))})`))}
${bolum('🏁 Geçen hafta sonuçlanan takipliler', b.sonuclar.map((r) => `${esc(r.baslik)} → ${esc(r.kazanan || 'kazanan açıklanmadı')}${r.bedel != null ? ' · ' + esc(paraTR(r.bedel, r.birim)) : ''}`))} ${bolum('🏁 Önceki 7 takvim günündeki takipli sonuç kayıtları', b.sonuclar.map((r) => `${esc(r.baslik)} → ${esc(r.kazanan || 'kazanan açıklanmadı')}${r.bedel != null ? ' · ' + esc(paraTR(r.bedel, r.birim)) : ''}`))}
<p style="margin-top:20px;font-size:12px;color:#94a3b8">Bu brief her Pazartesi gönderilir; Bildirimler sekmesinden kapatabilirsiniz.</p></div>`; <p style="margin-top:20px;font-size:12px;color:#94a3b8">Bu brief her Pazartesi gönderilir; Bildirimler sekmesinden kapatabilirsiniz.</p></div>`;
} }
+2 -2
View File
@@ -26,12 +26,12 @@ export async function botAdi() {
return null; return null;
} }
export async function sendTelegram(chatId, text) { export async function sendTelegram(chatId, text, { parseMode = 'HTML' } = {}) {
if (!telegramConfigured() || !chatId) return false; if (!telegramConfigured() || !chatId) return false;
const r = await fetch(`${API()}/sendMessage`, { const r = await fetch(`${API()}/sendMessage`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, text: String(text).slice(0, 4000), parse_mode: 'HTML', disable_web_page_preview: true }), body: JSON.stringify({ chat_id: chatId, text: String(text).slice(0, 4000), ...(parseMode === null ? {} : { parse_mode:parseMode }), disable_web_page_preview: true }),
signal: AbortSignal.timeout(15000), signal: AbortSignal.timeout(15000),
}); });
const d = await r.json().catch(() => ({})); const d = await r.json().catch(() => ({}));
+37
View File
@@ -0,0 +1,37 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createBriefBuilder, splitBriefText } from '../server/brief-content.mjs';
const parseDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value || '') ? new Date(value+'T00:00:00+03:00') : null;
test('brief includes more than 200 tracked tenders and 15 results without silent caps',()=>{
const keys=Array.from({length:205},(_,i)=>`2026/${i}`), seen=[];
const now=new Date('2026-09-06T21:05:00Z');
const build=createBriefBuilder({trackedKeys:()=>[...keys,keys[0]],
upcoming:(email,tenders,days,clock)=>{assert.equal(clock,now);assert.equal(days,7);return [];},
results:key=>{seen.push(key);return [{baslik:key,sozlesmeTarihi:'2026-09-06'}];},parseDate});
const result=build('test@example.test',[],now);
assert.equal(result.sonuclar.length,205);
assert.equal(new Set(result.sonuclar.map(t=>t.ikn)).size,205);
assert.equal(seen.length,205);
assert.equal(result.bos,false);
assert.equal(result.hafta,'07 Eylül 2026');
});
test('previous seven full Istanbul dates include the first day and exclude current/future dates',()=>{
const build=createBriefBuilder({trackedKeys:()=>['2026/1'],upcoming:()=>[],parseDate,
results:()=>['2026-08-30','2026-08-31','2026-09-06','2026-09-07','2026-09-08','invalid']
.map(date=>({baslik:date,sozlesmeTarihi:date}))});
const result=build('test@example.test',[],new Date('2026-09-06T21:05:00Z'));
assert.deepEqual(result.sonuclar.map(t=>t.baslik),['2026-09-06','2026-08-31']);
});
test('long Turkish/emoji text is preserved exactly without splitting surrogate pairs',()=>{
const text=('Laboratuvar <kit> & test 🧪\n').repeat(600);
const parts=splitBriefText(text);
assert.ok(parts.length>3);
assert.equal(parts.join(''),text);
for(const part of parts){assert.ok(part.length<=3900);assert.ok(part.isWellFormed());}
assert.deepEqual(splitBriefText(''),[]);
assert.throws(()=>splitBriefText('text',1));
});
+21
View File
@@ -75,3 +75,24 @@ test('one recipient failure does not skip subsequent recipients; feed failure is
assert.deepEqual(f.calls,['email']); assert.deepEqual(f.calls,['email']);
await assert.rejects(f.send(async()=>{throw new Error('Feed unavailable');}),/Feed unavailable/); await assert.rejects(f.send(async()=>{throw new Error('Feed unavailable');}),/Feed unavailable/);
}); });
test('long brief is sent completely as plain text with each part accounted for',async()=>{
const content=('Ürün <test> & 🧪\n').repeat(700),sent=[];
const f=fixture({text:()=>content,emailConfigured:()=>false,
sendTelegram:async(chatId,text,options)=>{sent.push(text);assert.deepEqual(options,{parseMode:null});return true;}});
const result=await f.send(async()=>[]);
assert.equal(sent.join(''),content);
assert.ok(sent.length>1);
assert.equal(result.kanallar.telegram.kabul,sent.length);
assert.equal(result.hata,0);
});
test('a failed Telegram part stops subsequent parts but still attempts email',async()=>{
let attempts=0;
const f=fixture({text:()=> 'x'.repeat(12000),sendTelegram:async()=>{attempts++;if(attempts===2)throw new Error('Unavailable');return true;}});
const result=await f.send(async()=>[]);
assert.equal(attempts,2);
assert.deepEqual(result.kanallar.telegram,{denenen:2,kabul:1,hata:1});
assert.equal(result.kanallar.email.kabul,1);
assert.equal(result.hata,1);
});
+22
View File
@@ -0,0 +1,22 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sendTelegram } from '../server/telegram.mjs';
test('brief plain-text mode reaches Telegram request without HTML parsing or content changes',async()=>{
const oldFetch=globalThis.fetch,oldToken=process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN='synthetic-test-token';
const payloads=[];
globalThis.fetch=async(url,options)=>{payloads.push(JSON.parse(options.body));return {ok:true,json:async()=>({ok:true})};};
try {
const text='Laboratuvar <test> & ürün 🧪';
assert.equal(await sendTelegram('test-chat',text,{parseMode:null}),true);
assert.equal(payloads[0].text,text);
assert.equal(Object.hasOwn(payloads[0],'parse_mode'),false);
await sendTelegram('test-chat','<b>Başlık</b>');
assert.equal(payloads[1].parse_mode,'HTML');
} finally {
globalThis.fetch=oldFetch;
if(oldToken===undefined)delete process.env.TELEGRAM_BOT_TOKEN;
else process.env.TELEGRAM_BOT_TOKEN=oldToken;
}
});