Googleカレンダーから祝日情報を取得してみた

(参考サイト)
https://excel-ubara.com/GenerativeAI/GAI063.html
(掲載元よりコード引用の許可を頂いています)

祝日の情報を取得したいと思いGoogleカレンダーから取得できないものかと探していたら
「エクセルの神髄」様から丁度探していた記事が見つかった。
ただし特定月の情報のみ取得したかったので月まで指定できるように改変してみた。





# 年を指定している箇所の特定
```javascript:parseICS.gs
function parseICS(icsString, targetYearStr) {
(省略)
      if (date && summary && date.startsWith(targetYearStr)) {
        holidays.push({
          date: date,
          name: summary
        });
(省略)
}
```
- dateが指定年から始まっていると変数に格納しているのがわかる



# date変数がどのように生成されているか確認する
```javascript:parseICS.gs
function parseICS(icsString, targetYearStr) {
(省略)
      eventLines.forEach(line => {
        if (line.startsWith('DTSTART;VALUE=DATE:')) {
          date = line.substring('DTSTART;VALUE=DATE:'.length).trim();
        } else if (line.startsWith('SUMMARY:')) {
          summary = line.substring('SUMMARY:'.length).trim();
        }
      });
(省略)
}
```
- date変数へ格納するのはeventLinesで「'DTSTART;VALUE=DATE:'」から始まるものであるのがわかる


```javascript:.gs
function parseICS(icsString, targetYearStr) {
(省略)
  const vevents = icsString.split('BEGIN:VEVENT');
  
  if (vevents.length > 1) {
    vevents.slice(1).forEach(veventData => {
      const eventLines = veventData.split(/\r\n|\r|\n/); 
(省略)
}
```
- eventLinesはveventDataを改行でsplitしているのがわかる
- veventDataはveventsをsliceしているのがわかる
- veventsはicsStringをsplitしているのがわかる


```javascript:doGet.gs
const ICAL_URL = 'https://calendar.google.com/calendar/ical/ja.japanese%23holiday%40group.v.calendar.google.com/public/basic.ics';
(省略)
function doGet(e) {
(省略)
  try {
    const icsString = UrlFetchApp.fetch(ICAL_URL).getContentText();
    const events = parseICS(icsString, targetYearStr);
(省略)
}
```
- icsStringはGoogleカレンダーの情報であることがわかる

# icsStringの変数の中身を確認する
## 変数の値を出力するコードを書く
```javascript:doGet.gs
(省略)
function doGet(e) {
    const icsString = UrlFetchApp.fetch(ICAL_URL).getContentText();

    return ContentService.createTextOutput('<holidays>'+icsString+'</holidays>');
(省略)
```
- icsStringをxlmで返すコードを追記する

## テストデプロイする
![01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106311/3b798b28-4c25-44df-a38c-772b7e0d78c2.png)
## 生成されたリンクを実行する
![rev02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106311/d754ee84-8bd4-40cd-b39e-751ddce0041f.png)


## 内容を確認する
![03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106311/2db253b6-f3c1-4264-a1f5-250b32f76a56.png)

- [BEGIN:VEVENT~END:VEVENT]が単位となっている
- 日付の指定箇所:[DTSTART;VALUE=DATE:20200101]

オリジナルのコードでは年４桁で渡すが、月まで指定したい場合は
年月6桁にすればよさそうなことがわかった


# コードを修正する
## 年(yyyy)から年月(yyyymm)を取得する
```javascript:doGet-修正前.gs
function doGet(e) {
  let targetYearStr = '';
  if (e && e.parameter && e.parameter.year) {
    targetYearStr = e.parameter.year;
  } else {
    targetYearStr = new Date().getFullYear().toString(); // 年が指定されていない場合は当年
  }
(省略)
```
```javascript:doGet-修正後.gs
  let targetYearMonthStr = '';
  if (e && e.parameter && e.parameter.yearmonth) {
    targetYearMonthStr = e.parameter.yearmonth;
  } else {
    targetYearMonthStr = new Date().getFullYear().toString(); // 年が指定されていない場合は当年
    targetYearMonthStr += (new Date().getMonth() + 1).toString().padStart(2, '0');
  }
(省略)
```
- 変数名targetYearStrからtargetYearMonthStr
- イベント名e.parameter.yearからe.parameter.yearmonth
- パラメータが指定されていないときは実行月を２桁で取得コードを追記(1月=0が返るGAS仕様なので+1しています)

## 4桁から6桁のチェックをする
```javascript:doGet-修正前.gs
(省略)
  if (!/^\d{4}$/.test(targetYearStr)) {
    return ContentService.createTextOutput('<holidays><error><message>Invalid year format. Please specify a 4-digit year (e.g., year=2025).</message></error></holidays>')
                         .setMimeType(ContentService.MimeType.XML);
  }
(省略)
```
```javascript:doGet-修正後.gs
  if (!/^\d{6}$/.test(targetYearMonthStr)) {
    return ContentService.createTextOutput('<holidays><error><message>Invalid yearmonth format. Please specify a 6-digit year (e.g., yearmonth=202506).</message></error></holidays>')
                         .setMimeType(ContentService.MimeType.XML);
  }
(省略)
```
- 桁数を4から6
- メッセージを今回の仕様変更に合わせた文言に変更

## xmlタグを変更する
```javascript:convertToXML-修正前.gs
function convertToXML(holidays, year) {
  let xml = '<holidays year="' + year + '">\n';
(省略)
```
```javascript:convertToXML-修正後.gs
function convertToXML(holidays, yearmonth) {
  let xml = '<holidays yearmonth="' + yearmonth + '">\n';
  if (holidays.length === 0) {
    xml += '  <message>No holidays found for the specified yearmonth.</message>\n';
(省略)
```
- 変数名・タグ名・メッセージ文言yearからyearmonth



## 全体のコード(JSDocコメントも変更済み)
```javascript:全体-修正前.gs
// 日本の祝日カレンダーのiCal形式URL
const ICAL_URL = 'https://calendar.google.com/calendar/ical/ja.japanese%23holiday%40group.v.calendar.google.com/public/basic.ics';

/**
 * Webリクエスト（GET）を処理し、指定された年の日本の祝日データをXML形式で返します。
 * @param {Object} e - イベントオブジェクト。e.parameter.year で年を指定できます。
 * @return {ContentService.TextOutput} XML形式の祝日データ。
 */
function doGet(e) {
  let targetYearStr = '';
  if (e && e.parameter && e.parameter.year) {
    targetYearStr = e.parameter.year;
  } else {
    targetYearStr = new Date().getFullYear().toString(); // 年が指定されていない場合は当年
  }

  // 年が4桁の数字であることを確認
  if (!/^\d{4}$/.test(targetYearStr)) {
    return ContentService.createTextOutput('<holidays><error><message>Invalid year format. Please specify a 4-digit year (e.g., year=2025).</message></error></holidays>')
                         .setMimeType(ContentService.MimeType.XML);
  }

  try {
    const icsString = UrlFetchApp.fetch(ICAL_URL).getContentText();
    const events = parseICS(icsString, targetYearStr);
    const xmlOutput = convertToXML(events, targetYearStr);
    return ContentService.createTextOutput(xmlOutput).setMimeType(ContentService.MimeType.XML);
  } catch (error) {
    Logger.log('Error: ' + error.toString());
    return ContentService.createTextOutput('<holidays><error><message>Failed to retrieve or parse holiday data: ' + escapeXml(error.toString()) + '</message></error></holidays>')
                         .setMimeType(ContentService.MimeType.XML);
  }
}

/**
 * iCalendar形式の文字列を解析し、指定された年のイベントを抽出します。
 * @param {string} icsString - iCalendar形式のデータ文字列。
 * @param {string} targetYearStr - 対象の年（文字列）。
 * @return {Array<Object>} 抽出されたイベントの配列（{date: 'YYYYMMDD', name: '祝日名'}）。
 */
function parseICS(icsString, targetYearStr) {
  const holidays = [];
  const vevents = icsString.split('BEGIN:VEVENT');
  
  if (vevents.length > 1) {
    vevents.slice(1).forEach(veventData => {
      const eventLines = veventData.split(/\r\n|\r|\n/); // 改行コードの差異に対応
      let date = '';
      let summary = '';

      eventLines.forEach(line => {
        if (line.startsWith('DTSTART;VALUE=DATE:')) {
          date = line.substring('DTSTART;VALUE=DATE:'.length).trim();
        } else if (line.startsWith('SUMMARY:')) {
          summary = line.substring('SUMMARY:'.length).trim();
        }
      });

      if (date && summary && date.startsWith(targetYearStr)) {
        holidays.push({
          date: date,
          name: summary
        });
      }
    });
  }
  return holidays;
}

/**
 * 祝日データの配列をXML文字列に変換します。
 * @param {Array<Object>} holidays - 祝日データの配列。
 * @param {string} year - 対象年。
 * @return {string} XML形式の文字列。
 */
function convertToXML(holidays, year) {
  let xml = '<holidays year="' + year + '">\n';
  if (holidays.length === 0) {
    xml += '  <message>No holidays found for the specified year.</message>\n';
  } else {
    holidays.forEach(holiday => {
      xml += '  <holiday>\n';
      xml += '    <date>' + holiday.date + '</date>\n';
      xml += '    <name>' + escapeXml(holiday.name) + '</name>\n';
      xml += '  </holiday>\n';
    });
  }
  xml += '</holidays>';
  return xml;
}

/**
 * XML特殊文字をエスケープします。
 * @param {string} unsafe - エスケープ対象の文字列。
 * @return {string} エスケープされた文字列。
 */
function escapeXml(unsafe) {
  if (typeof unsafe !== 'string') {
    return '';
  }
  return unsafe.replace(/[<>&'"]/g, function (c) {
    switch (c) {
      case '<': return '&lt;';
      case '>': return '&gt;';
      case '&': return '&amp;';
      case '\'': return '&apos;';
      case '"': return '&quot;';
      default: return c;
    }
  });
}
```
```javascript:全体-修正後.gs
// 日本の祝日カレンダーのiCal形式URL
const ICAL_URL = 'https://calendar.google.com/calendar/ical/ja.japanese%23holiday%40group.v.calendar.google.com/public/basic.ics';

/**
 * Webリクエスト（GET）を処理し、指定された年の日本の祝日データをXML形式で返します。
 * @param {Object} e - イベントオブジェクト。e.parameter.yearmonth で年月を指定できます。
 * @return {ContentService.TextOutput} XML形式の祝日データ。
 */
function doGet(e) {
  let targetYearMonthStr = '';
  if (e && e.parameter && e.parameter.yearmonth) {
    targetYearMonthStr = e.parameter.yearmonth;
  } else {
    // 年月が指定されていない場合は実行時の年月
    targetYearMonthStr = new Date().getFullYear().toString(); 
    targetYearMonthStr += (new Date().getMonth() + 1).toString().padStart(2, '0');
  }

  // 年月が6桁の数字であることを確認
  if (!/^\d{6}$/.test(targetYearMonthStr)) {
    return ContentService.createTextOutput('<holidays><error><message>Invalid yearmonth format. Please specify a 6-digit year (e.g., yearmonth=202506).</message></error></holidays>')
                         .setMimeType(ContentService.MimeType.XML);
  }

  try {
    const icsString = UrlFetchApp.fetch(ICAL_URL).getContentText();
    const events = parseICS(icsString, targetYearMonthStr);
    const xmlOutput = convertToXML(events, targetYearMonthStr);
    return ContentService.createTextOutput(xmlOutput).setMimeType(ContentService.MimeType.XML);
  } catch (error) {
    Logger.log('Error: ' + error.toString());
    return ContentService.createTextOutput('<holidays><error><message>Failed to retrieve or parse holiday data: ' + escapeXml(error.toString()) + '</message></error></holidays>')
                         .setMimeType(ContentService.MimeType.XML);
  }
}

/**
 * iCalendar形式の文字列を解析し、指定された年のイベントを抽出します。
 * @param {string} icsString - iCalendar形式のデータ文字列。
 * @param {string} targetYearMonthStr - 対象の年月（文字列）。
 * @return {Array<Object>} 抽出されたイベントの配列（{date: 'YYYYMMDD', name: '祝日名'}）。
 */
function parseICS(icsString, targetYearMonthStr) {
  const holidays = [];
  const vevents = icsString.split('BEGIN:VEVENT');
  
  if (vevents.length > 1) {
    vevents.slice(1).forEach(veventData => {
      const eventLines = veventData.split(/\r\n|\r|\n/); // 改行コードの差異に対応
      let date = '';
      let summary = '';

      eventLines.forEach(line => {
        if (line.startsWith('DTSTART;VALUE=DATE:')) {
          date = line.substring('DTSTART;VALUE=DATE:'.length).trim();
        } else if (line.startsWith('SUMMARY:')) {
          summary = line.substring('SUMMARY:'.length).trim();
        }
      });

      if (date && summary && date.startsWith(targetYearMonthStr)) {
        holidays.push({
          date: date,
          name: summary
        });
      }
    });
  }
  return holidays;
}

/**
 * 祝日データの配列をXML文字列に変換します。
 * @param {Array<Object>} holidays - 祝日データの配列。
 * @param {string} yearmonth - 対象年月。
 * @return {string} XML形式の文字列。
 */
function convertToXML(holidays, yearmonth) {
  let xml = '<holidays yearmonth="' + yearmonth + '">\n';
  if (holidays.length === 0) {
    xml += '  <message>No holidays found for the specified yearmonth.</message>\n';
  } else {
    holidays.forEach(holiday => {
      xml += '  <holiday>\n';
      xml += '    <date>' + holiday.date + '</date>\n';
      xml += '    <name>' + escapeXml(holiday.name) + '</name>\n';
      xml += '  </holiday>\n';
    });
  }
  xml += '</holidays>';
  return xml;
}

/**
 * XML特殊文字をエスケープします。
 * @param {string} unsafe - エスケープ対象の文字列。
 * @return {string} エスケープされた文字列。
 */
function escapeXml(unsafe) {
  if (typeof unsafe !== 'string') {
    return '';
  }
  return unsafe.replace(/[<>&'"]/g, function (c) {
    switch (c) {
      case '<': return '&lt;';
      case '>': return '&gt;';
      case '&': return '&amp;';
      case '\'': return '&apos;';
      case '"': return '&quot;';
      default: return c;
    }
  });
}
```
# デプロイする
- デプロイは同じ手順なので省略する

# 実行してみる
メッセージ用Xmlも追加した
```excel:excel
=IFERROR(FILTERXML(B1,"//holidays/message"),"")
=IFERROR(FILTERXML(B1,"//holidays/error/message"),"")
```


## 未指定(2025年06月実行)
![11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106311/d620f3b0-352a-4cf7-97d5-a1781305199a.png)
![31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106311/9363aa8e-a477-49bd-a9a0-55ebee2498ec.png)


## 202505
![12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106311/b74b6ecb-0ab0-4183-a1a6-fd2394ba61d9.png)
![32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106311/81c0362f-c41e-429d-8a1b-5350182ad8a6.png)


## 不正な年月
![13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106311/615f5a6d-adbe-4e6b-9806-f0eae363ae75.png)
![33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106311/394e6e40-c83e-45dd-9747-0ba7eb217df6.png)