ぜんぶ

ぜんせいぶつの文章

自作RSSリーダーの説明

RSSについて

Really Simple Syndicationの略なんだね。
ニュースとかブログとかの更新情報を配信するための文書フォーマット。HTMLみたいにタグで囲う感じの記述。サイト側が基本的に用意してくれてることが多いけど、RSSを勝手に作ってくれるWebサービスもある。利用する側はそのRSSのURLへアクセスすると、RSSが返ってくる。RSSリーダーと呼ばれるものは、登録されているサイトのRSSを勝手に取ってきて見やすいように表示してくれるってことね、RSS、今まで使ってこなかったから、ぼんやりしかわかっていませんでした。
RSSとかについていろいろ調べてみたら、今むしろRSSしかない、となってる人が世界中にそこそこいる、ということがわかった。この波に乗り遅れてはまずいぜ。

RSSリーダーを作った

DeepL使って海外のサイトのタイトルだけ翻訳されるRSSリーダーを作っている人がいて、とても便利そうだったので、それをパクることにした。意外にすんなりできた。Geminiとかにお願いしたらAPIとかいろいろやってくれた。ありちょす。そしてこれ、みんなも簡単にできると思うから、共有する。DeepLは無料の範囲なら月に50万字翻訳してくれるので、ある程度の範囲なら無料でRSSリーダーで使用できる。

今8時間おきくらいにこういうメールが私に届いている。いえーい。

Image in a image block

RSSリーダーの仕込み方

今回作ったものは、Googleスプレットシートに入力したRSSを順番に読んでいってそれをHTMLでまとめてメールを送るというもの。googleアカウントを持っていればできる。注意点としては、そのアカウントのメールアドレスにニュースフィードをまとめたものが届くので、仕事とかでバリバリに使っているアカウントでの運用はおすすめできない。画像が送られてくるので、アカウントのストレージも圧迫することが想定される。なので定期的に削除しないとまずいのだが、その辺は全部自己責任でやってほしい。そして各々カスタマイズするとかをしてほしい。これらは、こんなことができるよ、という紹介みたいなものでもあるので。


まず都合のいいGoogleアカウントにログインだったり切り替えたりして、Googleスプレットシートの画面に行く。空白のスプレットシートをクリックする。

Image in a image block

そしたらそれに好きな名前をつける。RSSlistとかなんでも。
そしたら
↓こんな感じで入力していく。

Image in a image block

順番に説明する。
赤枠1
Aの列にはそのサイトの言語を入れる。「ja」「en」など、完結でわかりやすいものがいい。言語を入力した行にはURLの記入はしない。翻訳で使用するDeepLは言語を自動判別してくれるっぽいから本当は必要ないと思うが一応分けておく仕様にした。というか、ここで分けておくと国ごとのニュースでまとまるので読みやすいと思う。

赤枠2
ここ、B列にはRSSフィードのURLを記入する

赤枠3
C列にはサイトの名前とかを記入する。メールで送られてくるHTML内で、サイトのタイトルが見出しになる。が、ここは別に記入しなくてもいい。記入しなかった場合URLが代わりに見出しとして表示されるようになる。

もしこれを読みながら試しているのだとしたら、jaとenを試しに何個か記入して次に進むのがいいと思う。全部うまくいったらサイトを追加しよう。

そしたらブラウザのウィンドウ下部にある分かりにくいタブを見つけてほしい。そこにある逆三角形をクリックする。

Image in a image block

そしたら名前を変更をクリック

Image in a image block

変更できそうになるので、変更する
ここはひとまず、「RSSlist」と記入してほしい。なんでもはダメ。「RSSlist」と記入する。半角で!

Image in a image block
Image in a image block

そしたらページ上部にあるメニューから、拡張機能を選択。

Image in a image block

そこからApps Scriptを選択する

Image in a image block

選択すると、最初はGoogleからいろいろ言われるかもしれない。それを読んだり読まなかったり承諾したりしなかったりが必要かもしれない。忘れた。なんかたどり着いてほしい。

こういうページになる。プロジェクト名をとりあえず好きなものにしておく

Image in a image block

とりあえずここまでできたらDeepLのアカウントを作ってログインする。

特にAPIとかの設定をしていないのなら「無料で体験する」をクリックしていろいろやらないといけないことがある。

Image in a image block
Image in a image block
Image in a image block

このあとにクレカ情報を登録しないといけないのだけれど、無料プランなら勝手に課金されることはないのでその辺はまあ大丈夫だが、いろいろと面倒くさい。
登録が済んだら、自分のアカウントページに行く。右上の丸いアイコンをクリックすればいけるはず。

アカウントページにいったら、「APIキーと制限」をクリック

Image in a image block

「キーを作成」をクリックして、キーを作ってもらう。キーはなんかごちゃごちゃしたアルファベットの長いパスワードみたいなものになる。人に教えちゃいけないやつ。

Image in a image block

キーを作ったらApps Scriptの画面に戻って、↓のコードをコピペする。長いので折りたたんでおく。

←ここをクリックするとなげーコードが出てくる
/// ==========================================
// 設定エリア
// ==========================================
const DEEPL_API_KEY = 'ここにAPIキーをコピペする'; 
const SHEET_NAME = 'RSSlist';

/**
 * メイン関数:スプレッドシートを読み取り、RSSを取得・翻訳してメール送信
 */
function processSectionalRss() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(SHEET_NAME);
  if (!sheet) {
    console.error("シート「" + SHEET_NAME + "」が見つかりません。");
    return;
  }
  
  const data = sheet.getDataRange().getValues();
  let reportHtml = `
    <div style="font-family: 'Helvetica Neue', Arial, sans-serif; background-color: #f8f9fa; padding: 30px 10px;">
      <div style="max-width: 600px; margin: auto; background-color: #ffffff; padding: 25px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.08);">
        <h1 style="text-align: center; color: #1a73e8; border-bottom: 3px solid #1a73e8; padding-bottom: 15px; margin-bottom: 30px;">
          Daily RSS Report
        </h1>
  `;

  let currentLang = 'ja'; // デフォルトは日本語設定

  data.forEach((row, index) => {
    // A列(Tag)とB列(URL)を取得。空文字やundefined対策
    const tag = row[0] ? String(row[0]).trim().toLowerCase() : "";
    const url = row[1] ? String(row[1]).trim() : "";
    const sitename = row[2] ? String(row[2]).trim() : url;

    // 1. A列にタグ(ja, en, de等)があればセクション切り替え
    if (tag !== "") {
      currentLang = tag;
      return; 
    }

    // 2. B列がURLでない場合はスキップ
    if (!url || !url.startsWith('http')) {
      return;
    }

    try {
      console.log(`取得開始 [${currentLang.toUpperCase()}]: ${url}`);
      const items = fetchRssAllFormats(url);
      
      if (items.length > 0) {
        reportHtml += `
          <h2 style="color: #ffffff; background-color: #4285f4; padding: 8px 15px; border-radius: 5px; font-size: 16px; margin-top: 40px;">
            [${currentLang.toUpperCase()}] Source: ${sitename}
          </h2>
        `;

        // 各フィードから最新5件を処理
        items.slice(0, 5).forEach(item => {
          // ja(日本語)以外の場合は翻訳を実行
          let displayTitle = (currentLang !== 'ja') ? translateWithDeepL(item.title) : item.title;

          reportHtml += `
            <div style="margin-bottom: 35px; border-bottom: 1px solid #eeeeee; padding-bottom: 25px;">
          `;

          // 【修正ポイント】画像URLがある場合、HTMLに<img>タグを挿入
          if (item.img) {
            reportHtml += `
              <div style="margin-bottom: 15px;">
                <a href="${item.link}" target="_blank">
                  <img src="${item.img}" style="width: 100%; max-width: 550px; height: auto; display: block; border-radius: 10px; border: 1px solid #e0e0e0;">
                </a>
              </div>
            `;
          }

          reportHtml += `
              <div style="font-size: 18px; font-weight: bold; line-height: 1.5; margin-bottom: 10px;">
                <a href="${item.link}" style="color: #1a73e8; text-decoration: none;" target="_blank">
                  ${displayTitle}
                </a>
              </div>
              <div style="font-size: 12px; color: #888888; line-height: 1.4;">
                <strong>Original Title:</strong> ${item.title}
              </div>
            </div>
          `;
        });
      }
    } catch (e) {
      console.error(`エラー発生 行${index + 1} (${url}): ${e.message}`);
    }
  });

  reportHtml += `
        <p style="text-align: center; color: #bbbbbb; font-size: 11px; margin-top: 30px; border-top: 1px solid #eeeeee; padding-top: 20px;">
          Generated by Google Apps Script & DeepL API
        </p>
      </div>
    </div>
  `;

  // 自分宛にメール送信
  MailApp.sendEmail({
    to: Session.getActiveUser().getEmail(),
    subject: `【翻訳ニュース】${new Date().toLocaleDateString()} の更新`,
    htmlBody: reportHtml
  });
  
  console.log("全プロセスの実行が完了しました。");
}

/**
 * RSS 1.0 / 2.0 / Atom 全規格対応 & 画像URL抽出
 */
function fetchRssAllFormats(url) {
  const response = UrlFetchApp.fetch(url);
  const xml = XmlService.parse(response.getContentText());
  const root = xml.getRootElement();
  
  // 名前空間の設定(XmlServiceを使用)
  const mediaNs = XmlService.getNamespace('media', 'http://search.yahoo.com/mrss/');
  let items = [];

  // 1. Atom (<feed>)
  if (root.getName() === 'feed') {
    const ns = XmlService.getNamespace('http://www.w3.org/2005/Atom');
    items = root.getChildren('entry', ns).map(entry => ({
      title: entry.getChildText('title', ns),
      link: entry.getChild('link', ns) ? entry.getChild('link', ns).getAttribute('href').getValue() : "",
      img: extractImageUrl(entry, mediaNs, ns)
    }));
  } 
  // 2. RSS 1.0 (<RDF>)
  else if (root.getName() === 'RDF') {
    const ns = XmlService.getNamespace('http://purl.org/rss/1.0/');
    items = root.getChildren('item', ns).map(item => ({
      title: item.getChildText('title', ns),
      link: item.getChildText('link', ns),
      img: extractImageUrl(item, mediaNs, ns)
    }));
  } 
  // 3. RSS 2.0 (<rss>)
  else if (root.getName() === 'rss') {
    const channel = root.getChild('channel');
    items = channel.getChildren('item').map(item => ({
      title: item.getChildText('title'),
      link: item.getChildText('link'),
      img: extractImageUrl(item, mediaNs)
    }));
  }
  return items;
}

/**
 * 記事から画像を抽出する(標準タグ + 本文解析)
 */
function extractImageUrl(element, mediaNs, atomNs) {
  // A. Media RSS (<media:content> / <media:thumbnail>)
  const media = element.getChild('content', mediaNs) || element.getChild('thumbnail', mediaNs);
  if (media && media.getAttribute('url')) return media.getAttribute('url').getValue();
  
  // B. Enclosure (<enclosure url="...">)
  const enclosure = element.getChild('enclosure');
  if (enclosure && enclosure.getAttribute('url')) return enclosure.getAttribute('url').getValue();
  
  // C. 本文の中から <img> タグをスキャン
  const contentTags = ['description', 'summary', 'content'];
  for (let tagName of contentTags) {
    let contentText = atomNs ? element.getChildText(tagName, atomNs) : element.getChildText(tagName);
    if (contentText) {
      const imgMatch = contentText.match(/<img[^>]+src=["']([^"']+)["']/i);
      if (imgMatch && imgMatch[1]) {
        let imgUrl = imgMatch[1];
        if (imgUrl.startsWith('http')) return imgUrl;
      }
    }
  }

  // D. Atom Link Rel="enclosure"
  if (atomNs) {
    const links = element.getChildren('link', atomNs);
    for (let i = 0; i < links.length; i++) {
      const rel = links[i].getAttribute('rel');
      if (rel && rel.getValue() === 'enclosure') return links[i].getAttribute('href').getValue();
    }
  }
  return null;
}

/**
 * DeepL翻訳(最新のヘッダー認証方式)
 */
function translateWithDeepL(text) {
  if (!text) return "";
  const url = 'https://api-free.deepl.com/v2/translate';
  
  const options = {
    'method': 'post',
    'headers': { 'Authorization': 'DeepL-Auth-Key ' + DEEPL_API_KEY },
    'payload': { 'text': text, 'target_lang': 'JA' },
    'muteHttpExceptions': true
  };

  try {
    const res = UrlFetchApp.fetch(url, options);
    const content = res.getContentText();
    if (res.getResponseCode() !== 200) {
      console.error(`DeepLエラー: ${content}`);
      return text;
    }
    const json = JSON.parse(content);
    return json.translations[0].text;
  } catch (e) {
    console.error("翻訳エラー: " + e.message);
    return text;
  }
}

コードの最初のほうにある、

const DEEPL_API_KEY = 'ここにAPIキーをコピペする'; 

ここにDeepLのAPIキーをコピペする。
例えばこういう感じになる

const DEEPL_API_KEY = 'honyararara-0000-0000-honyarararar:ho'; 

APIキーはちょんちょんで挟まれてないとエラーが出ちゃうので注意。
ちなみにコードはGeminiで作った。こういうのが作りたいんやが?って何度かやりとりをして、実際に動かしたときの不具合などを報告したりで、意外と簡単に作れて便利ーってなった。

ついでに説明をしておくと、APIキーをコピペした、そのすぐ下の行にこういう記述がある

const SHEET_NAME = 'RSSlist';

これはさっきシートにつけた名前。ここがシート名と一致していないとコードがちゃんと動いてくれないので注意。

ここまでできたら、ページ上部の「デバッグ」をクリックして、ちゃんと動くか確認する。
一応、対応しているRSSなら動くはず。止まったりする場合、右に止まった時のURLなどが表示されるので、スプレットシートの該当部分を削除したりして試してみてほしい。誤ったURLの可能性ある。
エラーが出る箇所があっても、問題ない部分に関してはメールとなって送られてくるようにはなっている。
デバッグで問題ない場合、早速メールが送られてくるので確認されたし。

Apps Scriptのいいところは、定期的にメールが送られるような設定が簡単にできるところ。ページ左側にある目覚まし時計みたいなマーク「トリガー」で設定できる。

Image in a image block
Image in a image block
Image in a image block

だいたいこんな感じ。これで伝わっただろうか。

あんまり間隔を短くしたり、翻訳が必要なサイトが多かったりすると、意外とすぐにDeepLの無料範囲を超えてしまうっぽいので注意が必要かもしれない。DeepLのサイトでAPIキーがどこで作れるのか結構迷子になったりしたのだけれど、まあまあ不親切だと思うわ。とかそういうことは思っている。

繰り返しになるが、これを試すのなら自己責任で運用してほしい。そんな人がいるのかわからないけれど。でもここで伝えたいことは、こういうちょっとした仕組みはAIでかなり作りやすくなっているということで、これを真似してほしいってわけでもない。でも例えば、スプレットシートとGoogle Apps Script(通称GAS)の連携とか、便利風なやつだけど知ってる人しか知らないやつすぎると思う。世の中そういうもので溢れているっぽいので、どうか知ってる人は教えてほしい。みたいな話でもあります。