GASではてなブログから一覧取得してスプレッドシートに保存

## 経緯
自分が所属しているサークルではてなブログに記事を投稿し、それを参照してホームページに一覧を載せるようにしたいということではてなブログのAPIを使おうということになりました

## できたもの
テスト用で同じ記事が上がってますがちゃんと投稿している分は取得できています！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3453576/1082e9aa-efb0-4ad6-a57c-ce3da3dd7e55.png)

ついでにサークルのホームページとはてなブログを宣伝

https://jyogi.pages.dev/

https://jyogi-blog.hatenablog.com/archive/2025

## はてなブログAPIとは？

はてなブログに関するAPIは主に２種類あるようで
1. はてなブログAtomPub
    > ウェブリソースを公開、編集するためのアプリケーション・プロトコル仕様
    > はてなブログのエントリを参照、投稿、編集、削除する
    
    とのことらしいです
    ブログ本文をいじるのはこのAPIでできるようです。

2. はてなブログoEmbed API
    > Webサイトにさまざまなコンテンツを埋め込むための仕組み
    > はてなブログの各記事をoEmbedを用いて埋め込むことが可能です。
    
    埋め込み用の情報が入っているようですね
    

取得できる情報など詳しくは公式ドキュメントを読んでもらった方が正確で分かりやすいと思います。

https://developer.hatena.ne.jp/ja/documents/blog/

## なぜGAS？

今回はバックエンドを持っていないホームページでこのAPIを使いたかったのですが、フロントのみではCORSの問題が出そうで、サーバーサイドプロキシを使用する必要が出てきそう（未検証&知識不足でふわっとしています）とのことで簡易的に使えるGASかつ、他のデータ管理でもGoogleスプレッドシートを使用していたためGASにしました。

また、GASは定期実行を楽に行えるとのことで採用しました。

## 実装

ホームページのデータを入れているスプレッドシートの拡張機能のタブからApps Script を選択し、GASのエディタ画面を開いて作業開始です。

### 環境変数設定

以下の記事を参考にはてなブログで使用する環境変数をスクリプトプロパティに設定します

https://zenn.dev/u1e2k/articles/2cbdeb9db4b3cc

- 使用する環境変数は自分のブログのURLをから得られます
`https://blog.hatena.ne.jp/{はてなID}/{ブログID}`
- API_KEYははてなブログのアカウント設定/基本設定の一番下にあります (投稿当時)

```
# 環境変数
USERNAME=<はてなID>
BLOG_ID=<ぶろぐID>
API_KEY=<API_KEY>
```

### コード
設定したらはてなブログAPIからデータを取得するコードを書いていきます。
初めははてなブログAtomPubのみで取得をしていたのですが、サムネイル用の画像取得がうまくいかず、はてなブログoEmbed APIの方ではめんどくさいことせずに一発でimageUrlを取得できるようなので組み合わせて両方使うことになりました。

はてなブログoEmbed APIは本文取得までしなくてもいい場合や埋め込みが必要な場合には有用そうです

```javascript
function getHatenaBlogEntries() {
  // 設定情報 - スクリプトプロパティから取得
  var USERNAME = PropertiesService.getScriptProperties().getProperty("USERNAME");
  var BLOG_ID = PropertiesService.getScriptProperties().getProperty("BLOG_ID");
  var API_KEY = PropertiesService.getScriptProperties().getProperty("API_KEY");

  // APIエンドポイント
  var API_URL = `https://blog.hatena.ne.jp/${USERNAME}/${BLOG_ID}/atom/entry`;

  // 認証情報の準備
  var headers = {
    'Authorization': 'Basic ' + Utilities.base64Encode(USERNAME + ':' + API_KEY)
  };

  // オプションの設定
  var options = {
    'method': 'get',
    'headers': headers
  };

  try {
    // APIからデータを取得
    var response = UrlFetchApp.fetch(API_URL, options);
    var xmlContent = response.getContentText();

    // XMLをパース
    var document = XmlService.parse(xmlContent);
    var root = document.getRootElement();

    // Atom名前空間の定義
    var atomNS = XmlService.getNamespace('http://www.w3.org/2005/Atom');
    var appNS = XmlService.getNamespace('app', 'http://www.w3.org/2007/app');

    // エントリーを取得
    var entries = root.getChildren('entry', atomNS);
    var processedEntries = [];

    // 各エントリーを処理
    entries.forEach(function(entry) {
      // 下書き記事を除外
      var control = entry.getChild('control', appNS);
      if (control) {
        var draft = control.getChildText('draft', appNS);
        if (draft === 'yes') {
          console.log('下書き');
          return; // 下書きなら処理をスキップ
        }
      }

      var content = entry.getChildText('content', atomNS) || '';
      var entryLink = '';  // 記事のリンクを取得する変数
      var thumbnail = '';  // サムネイル画像のURL

      // リンクを取得
      var links = entry.getChildren('link', atomNS);
      links.forEach(function(link) {
        if (link.getAttribute('rel') && link.getAttribute('rel').getValue() === 'alternate') {
          entryLink = link.getAttribute('href').getValue();
        }
      });

      console.log(entryLink)

      // oEmbed APIを使って画像URLを取得
      if (entryLink) {
        thumbnail = getOEmbedData(entryLink);
      }

      var entryData = {
        title: entry.getChildText('title', atomNS),
        link: entryLink,
        published: formatDate(entry.getChildText('published', atomNS)),
        updated: formatDate(entry.getChildText('updated', atomNS)),
        content: content,
        summary: entry.getChildText('summary', atomNS),
        thumbnail: thumbnail
      };
      processedEntries.push(entryData);
    });
    return processedEntries;
  } catch (error) {
    // エラーハンドリング
    Logger.log('エラーが発生しました: ' + error.toString());
    return [];
  }
}
```

サムネイルの画像を撮るためだけにoEmbedAPIを使用しています

```javascript
// oEmbed APIを使用して画像を取得
function getOEmbedData(entryLink) {
  var oembedUrl = `https://hatena.blog/oembed?url=${encodeURIComponent(entryLink)}&format=json`;
  try {
    var response = UrlFetchApp.fetch(oembedUrl);
    var jsonData = JSON.parse(response.getContentText());
    
    // 画像URL（thumbnail）を返す
    return jsonData.image_url || ''; // サムネイルURLがあれば返す
  } catch (error) {
    Logger.log('oEmbed APIエラー: ' + error.toString());
    return ''; // エラーの場合は空文字を返す
  }
}
```

取得される日付の形式が ISO 8601 であるためホームページで直接使える`yyyy/mm/dd`形式に変換しています

```javascript
// yyyy-mm-ddThh:mm:ss+09:00 を yyyy/mm/dd に変換
function formatDate(isoString) {
  if (!isoString) return '';
  var date = new Date(isoString);
  return Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy/MM/dd');
}
```

スプレッドシートに書き出す関数で、NewsListというシートを指定して書き込みをするようになっています

```javascript
// スプレッドシートに書き出す関数
function writeEntriesToSheet() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('NewsList');
  
  // ヘッダーを設定
  sheet.getRange(1, 1, 1, 7).setValues([
    ['title', 'link', 'published', 'updated', 'summary', 'content', 'thumbnail']
  ]);

  // エントリーを取得
  var entries = getHatenaBlogEntries();
  // データを2行目から書き出し
  entries.forEach(function(entry, index) {
    sheet.getRange(index + 2, 1, 1, 7).setValues([
      [
        entry.title, 
        entry.link, 
        entry.published, 
        entry.updated, 
        entry.summary, 
        entry.content,
        entry.thumbnail
      ]
    ]);
  });
}
```

これは手動で設定する場合はいらないですがこれで設定できるようです

```javascript
// 毎日00:00に実行するトリガーを設定する関数
function createDailyTrigger() {
  // 既存のトリガーを削除
  var existingTriggers = ScriptApp.getProjectTriggers();
  for (var i = 0; i < existingTriggers.length; i++) {
    ScriptApp.deleteTrigger(existingTriggers[i]);
  }
  
  // 新しいトリガーを作成
  ScriptApp.newTrigger('writeEntriesToSheet')
    .timeBased()
    .atHour(0)
    .everyDays(1)
    .create();
}
```

上の関数と同様に必須ではないですがあった方が良いものだと思います

```javascript
// スクリプトの初回実行時またはプロジェクトの再デプロイ時にトリガーを設定
function onOpen() {
  createDailyTrigger();
}
```

### 定期実行設定

最後に定期実行を設定します
1. 左のタブのトリガーをクリック
2. トリガーを追加を押す
3. 項目を設定する
    ```
    実行する関数を選択：writeEntriesToSheet
    実行するデプロイを選択：Head
    イベントのソースを選択：時間主導型
    時間ベースのトリガーのタイプを選択：日付ベースのタイマー
    時刻を選択：任意のタイミング（自分は午前0時〜1時）
    ```
4. 保存する

## 余談

GASを開こうとしたらサークルアカウントと個人アカウントがうまく判別されず？エラー吐いて開けず、キャッシュ消さないといけなかったが、めんどくさかったためシークレットモードでログインし直して作業した...

余力があったらフロントの実装（スプレッドシートから取得）の記事も書きたいなぁ〜

## 最後に
初心者&AIとのコーディングであるため、間違っている部分があったらご指摘いただけるとありがたいです。
 
## 参考資料・記事

https://developer.hatena.ne.jp/ja/documents/blog/apis/atom

https://developer.hatena.ne.jp/ja/documents/blog/apis/oembed

https://korosuke613.hatenablog.com/entry/2019/06/27/223528?utm_source=feed

https://kita127.hatenablog.com/entry/2023/05/17/004937