/********************************************************************** * 每日新聞自動化:Google 快訊 RSS → 試算表 → AI 挑選寫文案 → 生圖提示詞 * 版本:公開教學版 v1.0(2026-07-18) * 用法:完整教學見 https://hy-ai-newsbot.pages.dev/ * 授權:可自由複製修改使用(記得把「基本設定」換成你自己的) **********************************************************************/ /* ===== 基本設定(只要改這一區)===== */ const CONFIG = { AI: 'gemini', // 'gemini'(有免費額度)或 'claude' GEMINI_MODEL: 'gemini-2.5-flash', // Gemini 模型 CLAUDE_MODEL: 'claude-haiku-4-5-20251001', // Claude 便宜模型 TZ: 'Asia/Taipei', NEWS_KEEP_ROWS: 2000, // 新聞資料庫只保留最近 N 筆,舊的自動刪 // 圖片提示詞的固定結尾(換成你的品牌色與風格,每張圖視覺才會一致) BRAND_IMG_SUFFIX: 'clean professional commercial photography, brand palette deep green and warm gold accents, soft natural daylight, photorealistic, 16:9, no text, no logo, no watermark.' }; /* ===== 給 AI 的「總監人設」提示詞(把__換成你公司的資料)===== */ const SYSTEM_PROMPT = `你是「__公司」的社群行銷總監。全程使用台灣繁體中文,輸出前自我檢查,絕對不可出現簡體字。 【公司背景(換成你的)】 - 產業與年資:____ - 產品或服務:____ - 主要受眾:____ - 語氣:專業、務實、不浮誇 【最重要:角度要輪流,不可每篇都一樣】 我會給你「近 7 天用過的角度」,你這次產出的角度必須和近期不同。 從以下 8 大角度庫挑: 1.痛點解決 2.數據佐證 3.幕後故事 4.產業趨勢連結 5.客戶情境 6.永續環保 7.專業知識 8.通路視角 【本次產出 2 則】 第 1 則=產品介紹(主打我指定的那個「最久沒推」的產品) 第 2 則=新聞延伸(從我給的新聞標題挑 1 則最能連結到你產業的,延伸成觀點;真的全部無關才寫趨勢觀點) 【每則都要寫 3 個平台版本】 - fb:200-300 字,情境或故事開頭,段落分明,結尾一句行動呼籲 - ig:100 字內,短句斷行,3-5 個 emoji,hashtag 不放這欄 - li:LinkedIn 專業 B2B 語氣,用數據或觀點切入,不用 emoji 【每則附 1 個英文圖片提示詞】 img 欄=一句英文畫面描述(依內容想像畫面),結尾固定接這段品牌風格文字: ${CONFIG.BRAND_IMG_SUFFIX} 【輸出格式:只輸出一個 JSON 陣列,剛好 2 個物件,前後不要任何說明文字,不要加程式碼框】 [ {"type":"產品介紹","angle":"角度名稱","ref":"主打產品名","fb":"...","ig":"...","li":"...","hashtags":"#你的品牌 #...","img":"英文畫面...(結尾接品牌風格句)"}, {"type":"新聞延伸","angle":"角度名稱","ref":"參考的新聞標題","fb":"...","ig":"...","li":"...","hashtags":"#...","img":"英文畫面...(結尾接品牌風格句)"} ]`; /* ===== 第 0 步:自動建好 4 個分頁(貼上程式碼後先跑這個一次)===== */ function setupSheets() { const defs = { '快訊設定': ['分類', 'RSS網址', '啟用(Y/N)'], '新聞資料庫': ['日期', '分類', '標題', '連結'], '產品輪播': ['產品名', '賣點', '適用場景', '上次使用日期'], '貼文草稿': ['日期', '類型', '角度', '參考', 'FB文案', 'IG文案', 'LinkedIn文案', 'Hashtags', '圖片提示詞', '狀態'] }; const ss = SpreadsheetApp.getActiveSpreadsheet(); Object.keys(defs).forEach(function (name) { let sh = ss.getSheetByName(name); if (!sh) sh = ss.insertSheet(name); if (sh.getLastRow() === 0) sh.appendRow(defs[name]); }); Logger.log('4 個分頁已建好,請到「快訊設定」貼上你的 Google 快訊 RSS 網址'); } /* ===== 每日入口(時間觸發器指到這個函式)===== */ function dailyRoutine() { try { fetchAlerts(); } catch (e) { Logger.log('抓新聞失敗:' + e); } try { pruneNewsDB_(); } catch (e) { Logger.log('清資料庫失敗:' + e); } try { generateDailyPosts(); } catch (e) { Logger.log('產文案失敗:' + e); } } /* ===== 抓 Google 快訊 RSS → 寫進「新聞資料庫」(自動去重)===== */ function fetchAlerts() { const ss = SpreadsheetApp.getActiveSpreadsheet(); const setSh = ss.getSheetByName('快訊設定'); const dbSh = ss.getSheetByName('新聞資料庫'); const rows = setSh.getDataRange().getValues().slice(1); // 略過表頭 const today = Utilities.formatDate(new Date(), CONFIG.TZ, 'yyyy-MM-dd'); const atom = XmlService.getNamespace('http://www.w3.org/2005/Atom'); // 用最近 500 筆的連結當「已存過」名單,避免重複灌 const seen = {}; const last = dbSh.getLastRow(); if (last > 1) { dbSh.getRange(Math.max(2, last - 499), 4, Math.min(500, last - 1), 1) .getValues().forEach(function (r) { if (r[0]) seen[r[0]] = true; }); } let added = 0; rows.forEach(function (row) { const cat = row[0], url = row[1], on = String(row[2] || '').toUpperCase(); if (!url || on === 'N') return; // 留空視為啟用,填 N 才停用(防呆) try { const xml = UrlFetchApp.fetch(url, { muteHttpExceptions: true }).getContentText(); const entries = XmlService.parse(xml).getRootElement().getChildren('entry', atom); entries.forEach(function (en) { const title = String(en.getChildText('title', atom) || '').replace(/<[^>]+>/g, ''); const linkEl = en.getChild('link', atom); if (!title || !linkEl) return; let link = linkEl.getAttribute('href').getValue(); const m = link.match(/[?&]url=([^&]+)/); // 快訊給的是轉址,取出真正的新聞網址 if (m) link = decodeURIComponent(m[1]); if (seen[link]) return; seen[link] = true; dbSh.appendRow([today, cat, title, link]); added++; }); } catch (e) { Logger.log(cat + ' 這條 RSS 讀取失敗:' + e); } }); Logger.log('今天新增 ' + added + ' 則新聞'); } /* ===== 資料庫瘦身:只留最近 N 筆,永遠不會爆掉 ===== */ function pruneNewsDB_() { const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('新聞資料庫'); if (!sh) return; const excess = sh.getLastRow() - 1 - CONFIG.NEWS_KEEP_ROWS; if (excess > 0) sh.deleteRows(2, excess); // 新資料在最下面,從第 2 列刪最舊的 } /* ===== 主流程:讀新聞+讀近期角度+挑最久沒推的產品 → 叫 AI 寫 → 存草稿 ===== */ function generateDailyPosts() { const ss = SpreadsheetApp.getActiveSpreadsheet(); const draft = ss.getSheetByName('貼文草稿'); const today = Utilities.formatDate(new Date(), CONFIG.TZ, 'yyyy-MM-dd'); const news = getTodayNews_(20); // 今天的新聞標題,最多 20 則 const recent = getRecentAngles_(); // 近 7 天用過的角度(防重複的關鍵) const product = pickOldestProduct_(); // 最久沒推的產品(輪播) const user = '今天日期:' + today + '\n\n' + '【近 7 天用過的角度,請避開】\n' + (recent.join('、') || '(無)') + '\n\n' + '【產品介紹請主打這個(最久沒推)】\n' + (product ? (product.name + '|賣點:' + product.selling + '|場景:' + product.scenario) : '(輪播表為空,產品介紹自由發揮)') + '\n\n' + '【今日新聞標題】\n' + (news.length ? news.map(function (n, i) { return (i + 1) + '. ' + n; }).join('\n') : '(今日無新聞,兩則都用產品/趨勢角度)'); const posts = parseJsonLoose_(callAI_(SYSTEM_PROMPT, user)); posts.slice(0, 2).forEach(function (p) { draft.appendRow([today, p.type || '', p.angle || '', p.ref || '', p.fb || '', p.ig || '', p.li || '', p.hashtags || '', p.img || '', '待審核']); }); if (product) ss.getSheetByName('產品輪播').getRange(product.row, 4).setValue(today); // 回填日期 Logger.log('已產出 ' + Math.min(posts.length, 2) + ' 則草稿'); } /* ===== 小工具們 ===== */ function getTodayNews_(n) { const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('新聞資料庫'); const last = sh.getLastRow(); if (last < 2) return []; const today = Utilities.formatDate(new Date(), CONFIG.TZ, 'yyyy-MM-dd'); const rows = sh.getRange(Math.max(2, last - 199), 1, Math.min(200, last - 1), 3).getValues(); return rows.filter(function (r) { const d = (r[0] instanceof Date) ? Utilities.formatDate(r[0], CONFIG.TZ, 'yyyy-MM-dd') : String(r[0]); return d === today; }).map(function (r) { return r[2]; }).slice(-n); } function getRecentAngles_() { const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('貼文草稿'); const last = sh.getLastRow(); if (last < 2) return []; // 近 14 筆 ≒ 7 天 × 每天 2 則 const rows = sh.getRange(Math.max(2, last - 13), 3, Math.min(14, last - 1), 1).getValues(); return rows.map(function (r) { return r[0]; }).filter(String); } function pickOldestProduct_() { const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('產品輪播'); if (!sh || sh.getLastRow() < 2) return null; const rows = sh.getRange(2, 1, sh.getLastRow() - 1, 4).getValues(); let best = null; rows.forEach(function (r, i) { if (!r[0]) return; const t = r[3] ? new Date(r[3]).getTime() : 0; // 從沒推過=最優先 if (!best || t < best.t) best = { row: i + 2, name: r[0], selling: r[1], scenario: r[2], t: t }; }); return best; } /* ===== 呼叫 AI(金鑰放「專案設定 → 指令碼屬性」,絕對不要寫死在程式碼裡)===== */ function callAI_(system, user) { const props = PropertiesService.getScriptProperties(); if (CONFIG.AI === 'gemini') { const key = props.getProperty('GEMINI_API_KEY'); if (!key) throw new Error('請到「專案設定 → 指令碼屬性」新增 GEMINI_API_KEY'); const res = UrlFetchApp.fetch( 'https://generativelanguage.googleapis.com/v1beta/models/' + CONFIG.GEMINI_MODEL + ':generateContent?key=' + key, { method: 'post', contentType: 'application/json', muteHttpExceptions: true, payload: JSON.stringify({ systemInstruction: { parts: [{ text: system }] }, contents: [{ role: 'user', parts: [{ text: user }] }] }) }); const data = JSON.parse(res.getContentText()); if (!data.candidates) throw new Error('Gemini 回應異常:' + res.getContentText().slice(0, 300)); return data.candidates[0].content.parts[0].text; } const key = props.getProperty('CLAUDE_API_KEY'); if (!key) throw new Error('請到「專案設定 → 指令碼屬性」新增 CLAUDE_API_KEY'); const res = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', { method: 'post', contentType: 'application/json', muteHttpExceptions: true, headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' }, payload: JSON.stringify({ model: CONFIG.CLAUDE_MODEL, max_tokens: 4000, system: system, messages: [{ role: 'user', content: user }] }) }); const data = JSON.parse(res.getContentText()); if (!data.content) throw new Error('Claude 回應異常:' + res.getContentText().slice(0, 300)); return data.content[0].text; } /* AI 有時會多講話或包程式碼框——只取第一個 [ 到最後一個 ] 之間的 JSON */ function parseJsonLoose_(text) { const s = text.indexOf('['), e = text.lastIndexOf(']'); if (s === -1 || e === -1) throw new Error('AI 沒有回 JSON,開頭是:' + text.slice(0, 200)); return JSON.parse(text.slice(s, e + 1)); } /* ===== 加分題:把當日草稿寄到自己信箱(要用就把觸發器多指一條到這個)===== */ function emailTodayDigest() { const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('貼文草稿'); const last = sh.getLastRow(); if (last < 2) return; const today = Utilities.formatDate(new Date(), CONFIG.TZ, 'yyyy-MM-dd'); const rows = sh.getRange(Math.max(2, last - 5), 1, Math.min(6, last - 1), 9).getValues() .filter(function (r) { const d = (r[0] instanceof Date) ? Utilities.formatDate(r[0], CONFIG.TZ, 'yyyy-MM-dd') : String(r[0]); return d === today; }); if (!rows.length) return; const body = rows.map(function (r) { return '【' + r[1] + '|' + r[2] + '】\n' + r[4] + '\n\n圖片提示詞:\n' + r[8]; }).join('\n\n----------------\n\n'); MailApp.sendEmail(Session.getActiveUser().getEmail(), '今日 AI 貼文草稿 ' + today, body); }