/** * 妻より巨人 * 毎朝、今日の巨人戦の中継予定がメールで届きます。 * https://ichikawaya.co.jp/tools/giants/ * * 【使い方】 * 1. このスクリプトを全部コピーして、GASエディタに貼り付け * 2. 下の「設定」を必要なら変更(そのままでも動きます) * 3. 関数 setup を一度だけ実行 → 許可を押す * 以上です。あとは毎朝勝手に届きます。 * * 【やめたいとき】 * 関数 stop を実行してください。通知が止まります。 * * 読売ジャイアンツ・読売新聞社とは一切関係のない非公式ツールです。 * giants.jp の公開ページを読んで動いているだけなので、 * 先方の都合で予告なく壊れます。そのときはそのときです。 */ // =================================================================== // 設定 — ここだけ見ればOK // =================================================================== // 通知したい時間。7 なら 7:00〜8:00 の間に届きます。 // (GASの仕様で「7時ちょうど」にはなりません。時刻は毎日前後します) const NOTIFY_HOUR = 7; // メールの送信者名。受信トレイでこの名前が表示されます。 const SENDER_NAME = '妻より巨人'; // 天気を件名に入れたい人だけ。空欄のままでも普通に動きます。 // 空欄 → [G]18時-BSフジ@マツダ // キーあり → [G]18時-BSフジ@マツダ(曇/30%) // キーは https://www.weatherapi.com/ で無料登録すると取れます(1分くらい) const WEATHER_API_KEY = ''; // 本文にラジオも載せたい人は false に(件名には元々出ません) const TV_ONLY = true; // =================================================================== // ここから下は触らなくて大丈夫です // =================================================================== const GIANTS_URL = 'https://www.giants.jp/oa-schedule/first-team/'; // 件名で「TV中継」とみなさない放送種別 // C = CS(契約者しか見られない) // ネ = ネット配信 // ラ = ラジオ // ここに無いものは全部TVとして扱います。 // (地上波のアイコン表記が将来変わっても、TVとして拾われる安全側の設計) const NOT_TV = ['C', 'ネ', 'ラ']; // ===== 宛先(自分のGmailアドレスを自動で取得します) ===== function getNotifyTo() { // トリガー実行時は getEffectiveUser のほうが確実に取れます let to = ''; try { to = Session.getEffectiveUser().getEmail(); } catch (e) {} if (!to) { try { to = Session.getActiveUser().getEmail(); } catch (e) {} } return to; } // ===== セットアップ(一度だけ実行してください) ===== function setup() { // 既存のトリガーを消してから作り直す(何度実行しても重複しません) ScriptApp.getProjectTriggers().forEach(function(t) { if (t.getHandlerFunction() === 'checkAndNotify') { ScriptApp.deleteTrigger(t); } }); ScriptApp.newTrigger('checkAndNotify') .timeBased() .atHour(NOTIFY_HOUR) .everyDays(1) .inTimezone('Asia/Tokyo') .create(); const to = getNotifyTo(); Logger.log('セットアップ完了'); Logger.log('宛先: ' + (to || '⚠ 取得できませんでした')); Logger.log('毎朝 ' + NOTIFY_HOUR + ':00〜' + (NOTIFY_HOUR + 1) + ':00 の間に届きます'); Logger.log('天気: ' + (WEATHER_API_KEY ? 'あり' : 'なし(WEATHER_API_KEYが空欄です)')); Logger.log('試しに今すぐ届けたいときは testToday を実行してください'); } // ===== 通知を止める ===== function stop() { let count = 0; ScriptApp.getProjectTriggers().forEach(function(t) { if (t.getHandlerFunction() === 'checkAndNotify') { ScriptApp.deleteTrigger(t); count++; } }); Logger.log(count > 0 ? '通知を止めました' : 'もともと動いていませんでした'); } // ===== 今すぐ1通送ってテスト ===== function testToday() { checkAndNotify(); } // ===== メイン処理(トリガーから毎朝呼ばれます) ===== function checkAndNotify() { const today = getTodayString(); Logger.log('本日: ' + today); const notifyTo = getNotifyTo(); if (!notifyTo) { Logger.log('メールアドレスが取得できませんでした'); return; } let html; try { const response = UrlFetchApp.fetch(GIANTS_URL, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; GAS/1.0)' }, muteHttpExceptions: true }); if (response.getResponseCode() !== 200) { Logger.log('取得失敗: HTTP ' + response.getResponseCode()); return; } html = response.getContentText('UTF-8'); } catch (e) { Logger.log('ネットワークエラー: ' + e.message); return; } const gameData = parseTodayGame(html, today); if (!gameData) { sendMail(notifyTo, '[G]今日は試合がありません', ''); Logger.log('試合なし通知を送信しました'); return; } gameData.weather = getWeather(gameData.venue, gameData.startTime); sendNotification(gameData, notifyTo); } // ===== 今日の日付文字列を返す(例: "7.17") ===== function getTodayString() { const now = new Date(); return (now.getMonth() + 1) + '.' + now.getDate(); } // ===== HTMLから当日の試合ブロックを探してパース ===== function parseTodayGame(html, today) { const cardPattern = /
([\s\S]*?)(?=
(\d+)\.(\d+)/); if (!dateMatch) continue; const blockDate = dateMatch[1] + '.' + dateMatch[2]; if (blockDate !== today) continue; const scheduleMatch = block.match(/broadcast_schedule__[^"]*">[\d.]+\s+(\d+:\d+)/); const startTime = scheduleMatch ? scheduleMatch[1] : '18:00'; const venueMatch = block.match(/broadcast_gametype__[^"]*">[\s\S]*?

[^<]+<\/p>\s*

([^<]+)<\/p>/); const venue = venueMatch ? venueMatch[1].trim() : '不明'; const opponentMatch = block.match(/broadcast_teamname__[^"]*">[\s\S]*?<\/span>([^<]+)<\/p>/); const opponent = opponentMatch ? opponentMatch[1].trim() : '不明'; results.push({ date: blockDate, startTime: startTime, venue: venue, opponent: opponent, channels: parseChannels(block) }); } return results.length > 0 ? results[0] : null; } // ===== 放送局情報をパース(ラジオ・CS含めて全部拾う) ===== function parseChannels(block) { const channels = []; const itemPattern = /

  • ([\s\S]*?)<\/li>/g; let m; while ((m = itemPattern.exec(block)) !== null) { const item = m[1]; const nameMatch = item.match(/channel_name__[^"]*">([^<]+)<\/p>/); if (!nameMatch) continue; const startMatch = item.match(/channel_start__[^"]*">(\d+:\d+)/); const iconMatch = item.match(/channel_icon__[^"]*">([^<]+)<\/span>/); channels.push({ icon: iconMatch ? iconMatch[1].trim() : '', name: nameMatch[1].trim(), start: startMatch ? startMatch[1] : '' }); } return channels; } // ===== 放送種別の判定 ===== function isTv(ch) { return NOT_TV.indexOf(ch.icon) === -1; } // 地上波・BS function isCs(ch) { return ch.icon === 'C'; } function isRadio(ch) { return ch.icon === 'ラ'; } // ===== 球場名の正規化 ===== // giants.jp は2文字の球場名を「横 浜」のように全角スペースで字送りしています。 // スペースを消せば「横浜」「長野」「松山」など、未知の地方球場も自動で正しくなります。 function normalizeVenue(venue) { return venue.replace(/[\s ]/g, ''); } // ===== 球場名→短縮名 ===== // 長い名前を縮めるためだけの表です。 // ここに無い球場(甲子園・横浜・神宮・地方球場など)はそのまま表示されます。 const VENUE_SHORT = { '東京ドーム': '東京D', 'マツダスタジアム': 'マツダ', 'バンテリンドームナゴヤ': '名古屋D', 'ESコンフィールド北海道': 'エスコン', '札幌ドーム': '札幌D', 'みずほPayPayドーム福岡': '福岡D', 'PayPayドーム': '福岡D', '京セラドーム大阪': '大阪D', 'ほっともっとフィールド神戸': 'ほっともっと', '楽天モバイルパーク宮城': '楽天パーク', 'ベルーナドーム': '西武D', 'ZOZOマリンスタジアム': 'ZOZO', }; function getVenueShort(venue) { const v = normalizeVenue(venue); return VENUE_SHORT[v] || v; } // ===== 球場→座標(天気取得用) ===== // 地方球場は登録していません。天気が出ないだけで、中継情報は普通に届きます。 const VENUE_COORDS = { '東京ドーム': { lat: 35.7056, lon: 139.7519 }, 'マツダスタジアム': { lat: 34.3916, lon: 132.4853 }, '甲子園': { lat: 34.7197, lon: 135.3614 }, '横浜': { lat: 35.4439, lon: 139.6381 }, '神宮': { lat: 35.6709, lon: 139.7183 }, 'バンテリンドームナゴヤ': { lat: 35.1851, lon: 136.9472 }, 'ESコンフィールド北海道': { lat: 43.0978, lon: 141.6742 }, '札幌ドーム': { lat: 43.0151, lon: 141.4097 }, 'みずほPayPayドーム福岡': { lat: 33.5957, lon: 130.3623 }, 'PayPayドーム': { lat: 33.5957, lon: 130.3623 }, '京セラドーム大阪': { lat: 34.6673, lon: 135.4728 }, 'ほっともっとフィールド神戸': { lat: 34.6580, lon: 135.1928 }, '楽天モバイルパーク宮城': { lat: 38.2587, lon: 140.9003 }, 'ベルーナドーム': { lat: 35.7638, lon: 139.4126 }, 'ZOZOマリンスタジアム': { lat: 35.6454, lon: 139.9957 }, }; // ===== 天気を取得(WeatherAPI.com) ===== // APIキーが空欄、または球場が未登録なら null を返します(天気なしで通知) function getWeather(venue, startTime) { if (!WEATHER_API_KEY) return null; const coords = VENUE_COORDS[normalizeVenue(venue)]; if (!coords) { Logger.log('球場座標なし: ' + venue); return null; } const url = 'https://api.weatherapi.com/v1/forecast.json' + '?key=' + WEATHER_API_KEY + '&q=' + coords.lat + ',' + coords.lon + '&days=1&lang=ja'; let response; try { response = UrlFetchApp.fetch(url, { muteHttpExceptions: true }); } catch (e) { Logger.log('天気取得エラー: ' + e.message); return null; } if (response.getResponseCode() !== 200) { Logger.log('天気API失敗: ' + response.getResponseCode() + ' ' + response.getContentText()); return null; } try { const data = JSON.parse(response.getContentText()); const hour = parseInt(startTime.split(':')[0], 10); const hourData = data.forecast.forecastday[0].hour[hour]; return { condition: hourData.condition.text, precip: hourData.chance_of_rain }; } catch (e) { Logger.log('天気パースエラー: ' + e.message); return null; } } // ===== メール送信(送信者名つき) ===== function sendMail(to, subject, body) { MailApp.sendEmail({ to: to, subject: subject, body: body, name: SENDER_NAME }); } // ===== 件名用の時刻表示 ===== // 18:00 → 18時 (キリがいいので短く) // 17:50 → 17:50 (半端なので正確に) function formatTime(t) { if (!t) return ''; const m = t.match(/^(\d{1,2}):(\d{2})$/); if (!m) return t; return m[2] === '00' ? parseInt(m[1], 10) + '時' : t; } // ===== "14:00" → 840(分に直す。並べ替え用) ===== function toMinutes(t) { const m = (t || '').match(/^(\d{1,2}):(\d{2})$/); return m ? parseInt(m[1], 10) * 60 + parseInt(m[2], 10) : 9999; } // ===== いちばん早く始まる局を返す ===== // giants.jp は地上波を先に並べますが、地上波は試合開始より遅れて // 始まることがあります。件名の役目は「いつから見られるか」なので、 // 並び順ではなく開始時刻の早いものを選びます。 // 同時刻なら元の並び順(=地上波が先)が残ります。 function earliestChannel(list) { return list.slice().sort(function (a, b) { return toMinutes(a.start) - toMinutes(b.start); })[0]; } // ===== 通知メールの組み立てと送信 ===== function sendNotification(gameData, notifyTo) { const venueShort = getVenueShort(gameData.venue); const weather = gameData.weather; const channels = gameData.channels; const weatherStr = weather ? '(' + weather.condition + '/' + weather.precip + '%)' : ''; const tvChannels = channels.filter(isTv); const csChannels = channels.filter(isCs); // 件名のラベルと時刻を決める // 地上波・BSがある → いちばん早く始まる局。[G]14時-BS日テレ@東京D // CSしかない → [G]18時-CSのみ@東京D // どちらも無い → [G]18時-TV中継なし@東京D(時刻は試合開始) let label, hourSrc; if (tvChannels.length > 0) { const ch = earliestChannel(tvChannels); label = ch.name; hourSrc = ch.start; } else if (csChannels.length > 0) { label = 'CSのみ'; hourSrc = earliestChannel(csChannels).start; } else { label = 'TV中継なし'; hourSrc = gameData.startTime; } const subject = '[G]' + formatTime(hourSrc) + '-' + label + '@' + venueShort + weatherStr; // 本文 let body = '■ 本日の巨人戦\n'; body += '日付: ' + gameData.date + '\n'; body += '相手: ' + gameData.opponent + '\n'; body += '球場: ' + venueShort + '\n'; body += '試合開始: ' + gameData.startTime + '\n'; if (weather) { body += '天気: ' + weather.condition + ' 降水確率: ' + weather.precip + '%\n'; } body += '\n'; const listed = channels.filter(function(ch) { return TV_ONLY ? !isRadio(ch) : true; }); if (listed.length > 0) { body += '■ 放送予定\n'; listed.forEach(function(ch) { body += '[' + ch.icon + '] ' + ch.name + ' ' + ch.start + '〜\n'; }); } else { body += '本日は放送の予定がありません。\n'; } body += '\n詳細: ' + GIANTS_URL + '\n'; body += '\n--\n'; body += '妻より巨人 - 毎朝、今日の巨人戦の中継予定がメールで届きます\n'; body += 'https://ichikawaya.co.jp/tools/giants/\n'; sendMail(notifyTo, subject, body); Logger.log('通知送信: ' + subject); }