// ═══════════════════════════════════════════════════════════════════════════════ // מערכת תמלול אודיו אוטומטית (מבוססת Gemini API) - גרסה נקייה ללא גליונות // ═══════════════════════════════════════════════════════════════════════════════ // ┌─────────────────────────────────────────────────────────────────────────────┐ // │ פונקציות גלויות בלבד: │ // │ 1. הרצהראשית — מפעילה את סריקת המיילים ותמלולם │ // │ 2. הגדרטריגרים — מגדירה טריגר אוטומטי הרץ כל 5 דקות │ // │ 3. מחקטריגרים — מוחקת את כל הטריגרים של הפרויקט │ // └─────────────────────────────────────────────────────────────────────────────┘ // ═══════════════════════════════════════════════════════════════════════════════ // הגדרות גלובליות ופלייסהולדרים להגדרה ידנית // ═══════════════════════════════════════════════════════════════════════════════ const API_KEYS = { PRIMARY: "כאן לכתוב API ראשי", FALLBACK: "כאן לכתוב API גיבוי 1", EXTRA: "כאן לכתוב API גיבוי 2", EXTRA2: "כאן לכתוב API גיבוי 3", EXTRA3: "כאן לכתוב API גיבוי 4", EXTRA4: "כאן לכתוב API גיבוי 5" }; const MODELS = { TRANSCRIBE: [ "gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts", "gemini-2.5-pro-tts-preview" ] }; const USERS = { MAIN: { email: "כאן לכתוב את המייל שלך", allowAll: true } }; const SETTINGS = { MAX_AUDIO_BYTES: 19 * 1024 * 1024, // 19MB גבול עליון לתמלול ישיר inline EMAIL_DAILY_LIMIT: 80 }; const TRANSCRIBE_PROMPT = `אתה מתמלל שיעור תורני (גמרא, הלכה, אגדה, חסידות, מחשבה, תנ"ך). עליך להבין את ההקשר התורני ולתמלל נכון מונחים, שמות וחיבורים תורניים. הנחיות: 1. תמלל במדויק את כל שנאמר, וערוך לשונית תוך הבנת ההקשר התורני. 2. חלק לפסקאות לפי נושאים או הפסקות טבעיות. 3. הוסף סימני פיסוק מתאימים. 4. הוסף כותרת ראשית אחת בתחילת התמלול. 5. כותרות ביניים — רק למעבר ממשי לנושא חדש (2-4 לשיעור). 6. אל תוסיף סיכום בסוף. 7. הכל בעברית מימין לשמאל. 8. אם קטע לא ברור — כתוב [לא ברור] ואל תמציא. 9. אל תמציא מילים שלא נאמרו. 10. אסור לקצר את התוכן! 11. אסור לחזור על אותו תוכן פעמיים. 12. הגיה ישיבתית: ייתכן שהדובר הוגה קמץ כ"או" (קומץ) וצרי כ"אי" (ביית). כתוב תמיד בעברית תקנית בלבד. 13. זיהוי תורני: זהה נכון מונחים כמו גמרא, משנה, ברייתא, סוגיא, תוספות, ראשונים, אחרונים, שולחן ערוך, רמב"ם, רש"י וכו'. כתוב שמות מחברים וחיבורים בצורתם התקנית (רש"י, תוספות, רמב"ן, מהרש"א וכו'). 14. ארמית: קטעים בשפה הארמית (בגמרא, בזוהר) צריכים להופיע בארמית המקורית, לא מתורגמים לעברית. 15. מבנה שיעור: זהה מבנה של שיעור — שאלה פותחת, הבאת מקורות, קושיות ותירוצים, ביאור שיטות, סיכום ביניים, מסקנה — ותמלל תוך שמירה על המבנה הלוגי. 16. ידע תורני: השתמש בידע התורני שלך כדי להבין מילים שנאמרו — במיוחד מונחים, ציטוטי מקורות ושמות. אם הדובר ציטט מקור מוכר והציטוט נשמע חלקית — השתמש בידיעתך לתמלל נכון. אם קטע שלם לא ברור — כתוב [לא ברור] ואל תשלים. פקודות קוליות מיוחדות — כשהדובר אומר את הביטויים הבאים, בצע את הפעולה ואל תכתוב את המילים עצמן: • "פתח כותרת" — התחל כותרת (## ) מכאן. השמט את הביטוי. • "סגור כותרת" — סיים את הכותרת. השמט את הביטוי. • "פתח סוגריים" — הכנס ( . השמט את הביטוי. • "סגור סוגריים" — הכנס ) . השמט את הביטוי. • "פתח אותיות" — מעכשיו כל אות שנאמרת נכתבת כתו בודד ברצף (בלי רווחים). "רווח" = רווח. השמט "פתח אותיות". • "סגור אותיות" — חזור לתמלול רגיל (מילים שלמות). השמט "סגור אותיות". • "מחק משפט אחרון" — מחק את המשפט האחרון שתמללת. השמט את הפקודה. • "מחק X משפטים" (X = מספר) — מחק את X המשפטים האחרונים שתמללת. השמט את הפקודה. • "שורה חדשה" — ירידת שורה (לא פסקה חדשה). השמט את הפקודה. • "פסקה חדשה" — התחל פסקה חדשה. השמט את הפקודה. • "פתח הדגשה" — התחל טקסט מודגש (**). השמט את הפקודה. • "סגור הדגשה" — סיים טקסט מודגש (**). השמט את הפקודה. • "פתח ציטוט" — התחל ציטוט (> ). השמט את הפקודה. • "סגור ציטוט" — סיים ציטוט. השמט את הפקודה. • "נקודה" — הכנס נקודה (.) . השמט את הפקודה. • "פסיק" — הכנס פסיק (,) . השמט את הפקודה. • "סימן שאלה" — הכנס סימן שאלה (?) . השמט את הפקודה. • "מקף" — הכנס מקף (—) . השמט את הפקודה. פורמט הפלט: **[כותרת ראשית]** [תוכן...]`; // ═══════════════════════════════════════════════════════════════════════════════ // IIFE — פונקציות פנימיות מוגנות // ═══════════════════════════════════════════════════════════════════════════════ var _ = (function() { function canSendEmail() { var props = PropertiesService.getScriptProperties(); var today = new Date().toISOString().substring(0, 10); var key = "emailCount_" + today; var count = parseInt(props.getProperty(key) || "0"); if (count >= SETTINGS.EMAIL_DAILY_LIMIT) { Logger.log("[מונה מיילים] " + count + "/" + SETTINGS.EMAIL_DAILY_LIMIT + " — עוצר"); return false; } props.setProperty(key, String(count + 1)); return true; } function safeSendEmail(to, subject, body, options) { if (!canSendEmail()) throw new Error("Service invoked too many times for one day: email"); try { GmailApp.sendEmail(to, subject, body, options); } catch (e) { if (e.message && e.message.includes("too many times")) { var props = PropertiesService.getScriptProperties(); props.setProperty("emailCount_" + new Date().toISOString().substring(0, 10), String(SETTINGS.EMAIL_DAILY_LIMIT)); } throw e; } } function getApiKeys() { return [API_KEYS.PRIMARY, API_KEYS.FALLBACK, API_KEYS.EXTRA, API_KEYS.EXTRA2, API_KEYS.EXTRA3, API_KEYS.EXTRA4]; } function hasAlreadyReplied(thread) { if (!thread) return false; try { var messages = thread.getMessages(); if (!messages || messages.length <= 1) return false; var myEmail = Session.getActiveUser().getEmail(); for (var i = messages.length - 1; i >= 1; i--) { var from = messages[i].getFrom(); if (from && (from.includes(myEmail) || from.includes("תמלול"))) return true; } } catch (e) {} return false; } function escHtml(str) { return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function buildEmailHtml(content, sources, serviceName, isError, modelName) { var color = isError ? "#d93025" : "#1a73e8"; var icon = isError ? "⚠️" : "✅"; var displayModel = modelName ? modelName.replace(/^gemini-/i,"") : ""; var modelInfo = displayModel ? " | מודל: " + displayModel : ""; return '
'+content+'
נוצר בסייעתא דשמיא'+modelInfo+'
'; } function formatTranscript(text) { if (!text) return ""; text = text.replace(/\*\*(.+?)\*\*/g, '$1'); text = text.replace(/###\s+(.+)$/gm, '
$1
'); text = text.replace(/##\s+(.+)$/gm, '
$1
'); var blocks = text.split(/\n{2,}/); return blocks.map(function(p){p=p.trim();if(!p)return"";if(/^
'+p.replace(/\n/g,'
')+'

';}).filter(function(p){return p;}).join("\n"); } function createWordBlob(text, title) { var htmlLines = ['']; text.split('\n').forEach(function(line) { var t = line.trim(); if (!t) { htmlLines.push('

 

'); return; } if (/^# (.+)/.test(t)) { htmlLines.push('

' + escHtml(t.replace(/^# /,'')) + '

'); return; } if (/^## (.+)/.test(t)) { htmlLines.push('

' + escHtml(t.replace(/^## /,'')) + '

'); return; } if (/^### (.+)/.test(t)) { htmlLines.push('

' + escHtml(t.replace(/^### /,'')) + '

'); return; } var escaped = t.replace(/\*\*(.+?)\*\*/g, function(_, bold) { return '' + escHtml(bold) + ''; }); if (escaped === t) escaped = escHtml(t); htmlLines.push('

' + escaped + '

'); }); htmlLines.push(''); var htmlContent = htmlLines.join('\n'); var token = ScriptApp.getOAuthToken(); var metadata = JSON.stringify({name: title || 'תמלול', mimeType: 'application/vnd.google-apps.document'}); var boundary = 'boundary_word_' + Date.now(); var payload = '--' + boundary + '\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n' + metadata + '\r\n--' + boundary + '\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n' + htmlContent + '\r\n--' + boundary + '--'; var uploadResp = UrlFetchApp.fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', { method: 'POST', headers: {Authorization: 'Bearer ' + token, 'Content-Type': 'multipart/related; boundary=' + boundary}, payload: payload, muteHttpExceptions: true }); if (uploadResp.getResponseCode() !== 200) throw new Error('העלאה ל-Drive נכשלה: ' + uploadResp.getResponseCode()); var fileId = JSON.parse(uploadResp.getContentText()).id; var exportResp = UrlFetchApp.fetch('https://www.googleapis.com/drive/v3/files/' + fileId + '/export?mimeType=application/vnd.openxmlformats-officedocument.wordprocessingml.document', { headers: {Authorization: 'Bearer ' + token}, muteHttpExceptions: true }); try { UrlFetchApp.fetch('https://www.googleapis.com/drive/v3/files/' + fileId, {method:'DELETE', headers:{Authorization:'Bearer '+token}, mimeHttpExceptions:true}); } catch(e) {} return exportResp.getBlob().setName((title||'תמלול') + '.docx'); } // ─── תמלול אודיו (מנוע) ────────────────────────────────────────────────── function isAudioFile(att) { var name = att.getName().toLowerCase(), type = (att.getContentType()||"").toLowerCase(); return [".mp3",".wav",".aac",".flac",".ogg",".aiff",".m4a",".amr",".3gp"].some(function(e){return name.endsWith(e);}) || type.startsWith("audio/"); } function guessMimeType(fileName) { var map = {".mp3":"audio/mp3",".wav":"audio/wav",".aac":"audio/aac",".flac":"audio/flac",".ogg":"audio/ogg",".aiff":"audio/aiff",".m4a":"audio/mp4",".amr":"audio/amr",".3gp":"audio/3gpp"}; for (var ext in map) { if (fileName.toLowerCase().endsWith(ext)) return map[ext]; } return "audio/mp3"; } function parseGeminiResponse(response) { var sc = response.getResponseCode(), rt = response.getContentText(); if (sc !== 200) throw new Error("API Error ("+sc+"): "+(JSON.parse(rt).error||{}).message||"Unknown"); var data = JSON.parse(rt); if (!data.candidates || !data.candidates.length) throw new Error("אין תוצאות"); var c = data.candidates[0]; if (c.finishReason === "SAFETY") throw new Error("נחסם עקב סינון בטיחות"); if (!c.content || !c.content.parts) throw new Error("תוצאה ריקה מהשרת"); return c.content.parts.map(function(p){return p.text;}).join("\n"); } function transcribeInline(audioBytes, mimeType, apiKey, model) { var resp = UrlFetchApp.fetch("https://generativelanguage.googleapis.com/v1beta/models/"+model+":generateContent?key="+apiKey, { method:"POST", contentType:"application/json", payload:JSON.stringify({contents:[{parts:[{text:TRANSCRIBE_PROMPT},{inline_data:{mime_type:mimeType, data:Utilities.base64Encode(audioBytes)}}]}], generationConfig:{maxOutputTokens:65536, temperature:0.1}}), muteHttpExceptions:true }); return parseGeminiResponse(resp); } function transcribeViaFilesAPI(audioBytes, mimeType, fileName, apiKey, model) { var startResp = UrlFetchApp.fetch("https://generativelanguage.googleapis.com/upload/v1beta/files?key="+apiKey, { method:"POST", headers:{"X-Goog-Upload-Protocol":"resumable","X-Goog-Upload-Command":"start","X-Goog-Upload-Header-Content-Length":audioBytes.length.toString(),"X-Goog-Upload-Header-Content-Type":mimeType,"Content-Type":"application/json"}, payload:JSON.stringify({file:{display_name:fileName}}), mimeHttpExceptions:true }); var uploadUrl = startResp.getHeaders()["x-goog-upload-url"]; if (!uploadUrl) throw new Error("לא התקבל upload URL"); var uploadResp = UrlFetchApp.fetch(uploadUrl, {method:"POST", headers:{"X-Goog-Upload-Offset":"0","X-Goog-Upload-Command":"upload, finalize"}, payload:audioBytes, mimeHttpExceptions:true}); var fileUri = JSON.parse(uploadResp.getContentText()).file.uri; if (!fileUri) throw new Error("העלאת קובץ לשרתי Google נכשלה"); Utilities.sleep(3000); var genResp = UrlFetchApp.fetch("https://generativelanguage.googleapis.com/v1beta/models/"+model+":generateContent?key="+apiKey, { method:"POST", contentType:"application/json", payload:JSON.stringify({contents:[{parts:[{text:TRANSCRIBE_PROMPT},{file_data:{mime_type:mimeType, file_uri:fileUri}}]}], generationConfig:{maxOutputTokens:65536, temperature:0.1}}), muteHttpExceptions:true }); try { var fid = fileUri.split("/").pop(); UrlFetchApp.fetch("https://generativelanguage.googleapis.com/v1beta/files/"+fid+"?key="+apiKey, {method:"DELETE",muteHttpExceptions:true}); } catch(e){} return parseGeminiResponse(genResp); } function transcribeAudio(attachment) { Logger.log("[תמלול] 🎵 מתחיל תמלול | קובץ=" + attachment.getName() + " | גודל=" + Math.round(attachment.getBytes().length/1024) + "KB"); var audioBytes = attachment.getBytes(), rawMime = attachment.getContentType()||""; var mimeType = (rawMime.startsWith("audio/")||rawMime==="video/mp4") ? rawMime : guessMimeType(attachment.getName()); var fileSize = audioBytes.length, keys = getApiKeys(); for (var ki = 0; ki < keys.length; ki++) { for (var mi = 0; mi < MODELS.TRANSCRIBE.length; mi++) { try { var text = fileSize > SETTINGS.MAX_AUDIO_BYTES ? transcribeViaFilesAPI(audioBytes,mimeType,attachment.getName(),keys[ki],MODELS.TRANSCRIBE[mi]) : transcribeInline(audioBytes,mimeType,keys[ki],MODELS.TRANSCRIBE[mi]); Logger.log("[תמלול] ✅ הצליח! מודל=" + MODELS.TRANSCRIBE[mi] + " | מפתח=" + (ki+1) + " | אורך=" + text.length + " תווים"); return {text:text, model:MODELS.TRANSCRIBE[mi]}; } catch(e) { Logger.log("[תמלול] ❌ נכשל | מודל=" + MODELS.TRANSCRIBE[mi] + " | מפתח=" + (ki+1) + " | שגיאה=" + e.message); } } } throw new Error("כל המודלים והמפתחות נכשלו בתמלול קובץ זה."); } function buildTranscribeEmailBody(results) { var plainBody = "שלום,\n\nהתמלול מוכן:\n\n", htmlParts = []; results.forEach(function(r, i) { var label = r.folderNumber ? "תיקייה "+r.folderNumber+" — "+r.fileName : "קובץ "+(i+1)+": "+r.fileName; if (r.success) { plainBody += label+"\n"+r.transcript+"\n\n"; htmlParts.push('
'+escHtml(label)+'
'+formatTranscript(r.transcript)+'
'); } else { plainBody += label+" — שגיאה: "+r.error+"\n\n"; htmlParts.push('
'+escHtml(label)+' — שגיאה: '+escHtml(r.error)+'
'); } }); return {plainBody:plainBody, htmlContent:buildEmailHtml(htmlParts.join(""),null,"תמלול",results.some(function(r){return !r.success;}),null)}; } function checkEmailsAndTranscribe() { Logger.log("[תמלול] 🔍 מתחיל בדיקת מיילים לתמלול..."); var props = PropertiesService.getScriptProperties(); var lastT = parseFloat(props.getProperty("lastTranscribeCheck")||"0"); if (Date.now() - lastT < 30000) return; props.setProperty("lastTranscribeCheck", String(Date.now())); // אם המשתמש לא שינה את המייל הדיפולטיבי, העוצר פועל כדי למנוע הרצות שווא if (USERS.MAIN.email === "כאן לכתוב את המייל שלך") { Logger.log("[תמלול] ⚠️ שגיאה: יש לעדכן את המייל האישי שלכם במשתנה USERS.MAIN.email"); return; } var query = 'from:'+USERS.MAIN.email+' subject:"תמלול" is:unread -label:תמלול'; var ivrQuery = 'from:ivr@yml.li is:unread -label:תמלול'; var threads = GmailApp.search(query, 0, 10).concat(GmailApp.search(ivrQuery, 0, 50)); Logger.log("[תמלול] נמצאו " + threads.length + " שרשורים בקבוצת הסריקה"); if (threads.length === 0) { Logger.log("[תמלול] אין שרשורים חדשים לעיבוד"); return; } var label = GmailApp.getUserLabelByName("תמלול") || GmailApp.createLabel("תמלול"); var ivrResults = []; var runStart = Date.now(); for (var ti = 0; ti < threads.length; ti++) { if (Date.now() - runStart > 240000) { Logger.log("[תמלול] ⏰ חציגה קרובה ל-5 דקות, עוצר לריצה הבאה"); break; } var thread = threads[ti]; thread.addLabel(label); if (hasAlreadyReplied(thread)) continue; var messages = thread.getMessages(); for (var mi = 0; mi < messages.length; mi++) { var message = messages[mi]; if (!message.isUnread()) continue; if (Date.now() - runStart > 240000) break; var isIvr = message.getFrom().includes("ivr@yml.li"); try { if (isIvr) { var atts = (message.getAttachments()||[]).filter(isAudioFile); if (atts.length === 0) { message.markRead(); continue; } var body2 = message.getPlainBody()||"", folderMatch = body2.match(/תיקי[הי][:\s]+(\d+)/); var folderNum = folderMatch ? folderMatch[1] : null; for (var ai = 0; ai < atts.length; ai++) { if (Date.now() - runStart > 240000) break; try { var r = transcribeAudio(atts[ai]); ivrResults.push({fileName:atts[ai].getName(),transcript:r.text,model:r.model,folderNumber:folderNum,success:true}); } catch(e) { ivrResults.push({fileName:atts[ai].getName(),folderNumber:folderNum,success:false,error:e.message}); } } message.markRead(); } else { var audioAtts = (message.getAttachments()||[]).filter(isAudioFile); if (audioAtts.length === 0) { thread.reply("לא נמצא קובץ אודיו בתצורה נתמכת.",{htmlBody:buildEmailHtml("לא נמצא אודיו",null,"תמלול",true,null),name:"תמלול"}); message.markRead(); continue; } var results = []; for (var ai2 = 0; ai2 < audioAtts.length; ai2++) { if (Date.now() - runStart > 240000) break; try { var r2 = transcribeAudio(audioAtts[ai2]); results.push({fileName:audioAtts[ai2].getName(),transcript:r2.text,model:r2.model,success:true}); } catch(e) { results.push({fileName:audioAtts[ai2].getName(),success:false,error:e.message}); } } var emailBody = buildTranscribeEmailBody(results); var wordAttachments = []; try { results.forEach(function(r) { if (r.success && r.transcript) { var docTitle = "תמלול — " + r.fileName.replace(/\.[^.]+$/, ""); wordAttachments.push(createWordBlob(r.transcript, docTitle)); } }); } catch(wordErr) { Logger.log("[תמלול] ⚠️ שגיאה ביצירת מסמך Word: " + wordErr.message); } var replyOpts = {htmlBody:emailBody.htmlContent, name:"תמלול"}; if (wordAttachments.length > 0) replyOpts.attachments = wordAttachments; thread.reply(emailBody.plainBody, replyOpts); message.markRead(); } } catch(e) { Logger.log("[תמלול] ❌ שגיאה כללית בעיבוד הודעה: " + e.message); } } } if (ivrResults.length > 0) { var emailBody2 = buildTranscribeEmailBody(ivrResults); var folders = ivrResults.map(function(r){return r.folderNumber;}).filter(Boolean); var subj = folders.length===1 ? "תמלול תיקייה "+folders[0] : "תמלולי מערכת IVR"; var ivrWordAttachments = []; ivrResults.forEach(function(r) { if (r.success && r.transcript) { var docTitle = "תמלול — " + r.fileName.replace(/\.[^.]+$/, ""); ivrWordAttachments.push(createWordBlob(r.transcript, docTitle)); } }); safeSendEmail(USERS.MAIN.email, subj, emailBody2.plainBody, {htmlBody:emailBody2.htmlContent, name:"תמלול", attachments:ivrWordAttachments}); threads.filter(function(t) { return t.getMessages().some(function(m) { return m.getFrom().includes("ivr@yml.li"); }); }).forEach(function(t) { t.addLabel(label); }); } Logger.log("[תמלול] ✅ סיום ריצת בדיקת תמלולים"); } return { checkEmailsAndTranscribe: checkEmailsAndTranscribe, getApiKeys: getApiKeys }; })(); // ═══════════════════════════════════════════════════════════════════════════════ // פונקציות גלויות — בשמות עבריים (ממשק גליון וטריגרים) // ═══════════════════════════════════════════════════════════════════════════════ /** 1. הרצה ראשית — הרצת שירות התמלול בלבד */ function הרצהראשית() { var runStart = Date.now(); _.checkEmailsAndTranscribe(); Logger.log("[ראשי] הריצה הושלמה בתוך " + (Date.now() - runStart) + "ms"); } /** 2. הגדרת טריגרים - מגדירה הרצה אוטומטית ברקע בלבד ללא אינטראקציה עם גליונות */ function הגדרטריגרים() { // מחיקת טריגרים קיימים ScriptApp.getProjectTriggers().forEach(function(t) { try { ScriptApp.deleteTrigger(t); } catch(e){} }); // יצירת טריגר זמן הרצה ראשית כל 5 דקות ScriptApp.newTrigger('הרצהראשית').timeBased().everyMinutes(5).create(); Logger.log("✅ הוגדר טריגר אוטומטי בהצלחה: הרצהראשית (רץ כל 5 דקות)"); } /** 3. מחיקת טריגרים */ function מחקטריגרים() { var triggers = ScriptApp.getProjectTriggers(); triggers.forEach(function(t) { ScriptApp.deleteTrigger(t); }); Logger.log("נמחקו " + triggers.length + " טריגרים"); }