/**
* 妻より巨人
* 毎朝、今日の巨人戦の中継予定がメールで届きます。
* 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);
}