fix: preserve complete weekly brief content and date bounds
This commit is contained in:
@@ -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.
|
||||
- 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ı.
|
||||
|
||||
### 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ı.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { splitBriefText } from './brief-content.mjs';
|
||||
|
||||
/** Delivery accounting shared by scheduled and administrator-triggered briefs. */
|
||||
export function createBriefSender({ store, buildBrief, text, html, telegramConfigured, sendTelegram, emailConfigured, sendEmail, warn = console.warn }) {
|
||||
return async function sendBrief(tenderGetir) {
|
||||
@@ -14,16 +16,20 @@ export function createBriefSender({ store, buildBrief, text, html, telegramConfi
|
||||
if (brief.bos) { r.bos++; continue; }
|
||||
const channels = [];
|
||||
const telegram = store.getData(user.email, 'telegram');
|
||||
if (telegramConfigured() && telegram?.chatId) channels.push({
|
||||
name:'telegram', send:() => sendTelegram(telegram.chatId, text(brief).slice(0,3900)),
|
||||
if (telegramConfigured() && telegram?.chatId) {
|
||||
for (const part of splitBriefText(text(brief))) channels.push({
|
||||
name:'telegram', send:() => sendTelegram(telegram.chatId, part, { parseMode:null }),
|
||||
valid:receipt => receipt === true,
|
||||
});
|
||||
}
|
||||
if (emailConfigured()) channels.push({
|
||||
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()),
|
||||
});
|
||||
if (!channels.length) r.kanalsiz++;
|
||||
const failedChannels = new Set();
|
||||
for (const channel of channels) {
|
||||
if (failedChannels.has(channel.name)) continue;
|
||||
// An earlier channel can await the network while the user disables briefs.
|
||||
if (store.getData(user.email, 'brief')?.kapali) { r.kapali++; break; }
|
||||
const counts = r.kanallar[channel.name];
|
||||
@@ -35,6 +41,7 @@ export function createBriefSender({ store, buildBrief, text, html, telegramConfi
|
||||
accepted = true;
|
||||
} catch (error) {
|
||||
counts.hata++;
|
||||
failedChannels.add(channel.name);
|
||||
failed = true;
|
||||
warn('[brief]', user.email, channel.name, error.message);
|
||||
}
|
||||
|
||||
+7
-18
@@ -1,8 +1,9 @@
|
||||
// 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;
|
||||
// saatlik kontrol, haftada bir gönderim (node-cron yok, setInterval).
|
||||
import fs from 'node:fs';
|
||||
import { createBriefBuilder } from './brief-content.mjs';
|
||||
import { createBriefSender } from './brief-delivery.mjs';
|
||||
import path from 'node:path';
|
||||
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'}`);
|
||||
|
||||
/** Bir kullanıcı için brief içeriği. */
|
||||
export function briefOlustur(email, tenders) {
|
||||
const takip = store.getTrackedTenderKeys(email);
|
||||
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 };
|
||||
}
|
||||
export const briefOlustur = createBriefBuilder({
|
||||
trackedKeys:store.getTrackedTenderKeys, upcoming:yaklasanIhaleler, results:resultsByIkn, parseDate:trTarih,
|
||||
});
|
||||
|
||||
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)'
|
||||
@@ -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)})`);
|
||||
}
|
||||
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' : ''}`);
|
||||
}
|
||||
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">
|
||||
<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('🏁 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>`;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -26,12 +26,12 @@ export async function botAdi() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function sendTelegram(chatId, text) {
|
||||
export async function sendTelegram(chatId, text, { parseMode = 'HTML' } = {}) {
|
||||
if (!telegramConfigured() || !chatId) return false;
|
||||
const r = await fetch(`${API()}/sendMessage`, {
|
||||
method: 'POST',
|
||||
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),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -75,3 +75,24 @@ test('one recipient failure does not skip subsequent recipients; feed failure is
|
||||
assert.deepEqual(f.calls,['email']);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user