LoginSignup
0
0

[GAS/Gemini] 無料で運用! ニュース&要約をPostしてくれるSlackBotをコピペで作ろう

Last updated at Posted at 2024-04-24

情報収集どのようにされていますか?

QiitaやZenn、Publickey、GIGAZINEなど、常に情報をチェックしたいけど情報収集に時間を割いていると仕事が進まないし、情報を取り入れないと置いていかれるし・・・。
なかなか難しいですよね。

Gemini Proを利用した要約Bot

現在Gemini Proは無料で利用できます。
これを利用しない手はないということで、Gemini Proを利用した要約SlackBotを2ヶ月ほど運用しています。
個人的にはコレがなかなか良いので紹介しようと思います。

全体図

Slackニュース.png

  1. Google Apps Scriptのトリガーで10分ごとに実行
  2. 任意のRSSからニュースを取得
  3. 新着ニュースをSlackにPost
  4. Gemini Proを使用してニュースを要約
  5. 要約内容をニュースのスレッドにPost

という感じです
デフォルトでは、土日祝日は動きません。平日の9:40〜18:00のみ動きます。

お金をかけずに運用できます

動いている様子

スクリーンショット 2024-04-24 21.35.56.png

要約のプロンプトは、GAS内で以下のように定義しており、ちょっと面白い感じの要約もできます
上のスクリーンショットの要約は絵文字を混ぜながら要約してですね
追加したい人格とかあれば任意に追加できます

ギャルの口調で要約しては意外にわかりやすくてオススメ

const SUMMALIZE_PROMPT = ["コテコテの関西弁で要約して", "ギャルの口調で要約して", "ドラゴンボールの孫悟空の口調で要約して", "ドラゴンボールのベジータの口調で要約して", "絵文字を混ぜながら要約して", "ハイテンションで要約して"];

/**
 * 要約のプロンプトを取得する
 * @returns
 */
function getRandomPrompt() {
  const randomNum = Math.random();

  // 要約してが50%の確率で選ばれるようにする
  if (randomNum < 0.5) {
    return "要約して";
  } else {
    // ランダムに選択
    const randomIndex = Math.floor(Math.random() * SUMMALIZE_PROMPT.length);
    return SUMMALIZE_PROMPT[randomIndex];
  }
}

Bot作成

ニュースを投稿するSlackチャンネルの作成

ニュースの投稿は専用のチャンネルを作ったほうが良いです

  1. チャンネルを作ったら、チャンネル名(ここではowayo-test)をクリック
    スクリーンショット 2024-04-24 18.55.21.png
  2. ダイアログが開くので下にスクロールする
    image.png
    チャンネルIDが表示されるのでコピーして控えておきます

Slackアプリの作成

  1. 以下にアクセスしてSlackアカウントでログインします
    https://slack.com/signin
  2. 以下にアクセス
    https://api.slack.com/apps
  3. Create New App をクリック
  4. From an app manifest をクリック
  5. インストールするワークスペースを選択し Next
  6. 以下の内容を貼り付けて Next
{
    "display_information": {
        "name": "ニュース要約君",
        "description": "ニュースを要約してPostします",
        "background_color": "#333333"
    },
    "features": {
        "bot_user": {
            "display_name": "news_summarizer",
            "always_online": false
        }
    },
    "oauth_config": {
        "scopes": {
            "bot": [
                "channels:history",
                "chat:write",
                "groups:history"
            ]
        }
    },
    "settings": {
        "org_deploy_enabled": false,
        "socket_mode_enabled": false,
        "token_rotation_enabled": false
    }
}
  1. Create をクリック
  2. Basic InformationInstall to Workspaceをクリック
  3. 許可するをクリック
  4. Basic InformationVerification Tokenを控えておきます
  5. OAuth & Permissionsの、OAuth Tokens for Your Workspaceに表示されているBot User OAuth Tokenを控えておきます

SlackチャンネルにBotの追加

APIからチャンネルを見つけられるように、SlackチャンネルにBotを追加してあげます

  1. Slackのチャンネル名(ここではowayo-test)をクリック
    スクリーンショット 2024-04-24 18.55.21.png
  2. ダイアログが開くので、インテグレーションをクリック
    image.png
  3. アプリを追加するをクリック
    image.png
  4. アプリの一覧から、作成したアプリを見つけて追加をクリック
    image.png

Google Cloud Projectの作成

Geminiの利用には、Google Cloud Projectの作成が必要なので作成しておきます

  1. 以下のページを開き、適当なプロジェクト名を入力し作成をクリック
    https://console.cloud.google.com/projectcreate
  2. 課金が無効になっていることを確認
    以下のURLでこのプロジェクトには請求先アカウントがありませんと表示されることを確認
    https://console.cloud.google.com/billing/linkedaccount

このプロジェクトには請求先アカウントがありませんとなっていない場合は、2024/5/2移行課金が行われます。
このプロジェクトには請求先アカウントがありませんとなっている場合は次の項目に進んでください。

Gemini Pro API Keyの発行

  1. 以下のページを開き、無料今すぐ試すをクリック
    https://ai.google.dev/pricing?hl=ja

  2. 右のメニューから鍵のアイコンをクリック
    image.png

  3. Create API keyをクリック

  4. 先ほど作成した、Google Cloud Projectを選択し、Create API key in existing projectをクリック

  5. API Keyが生成されるのでCopyをクリックし控えておく

Google Apps Scriptの作成

  1. GASの画面を開いて、新しいプロジェクトをクリックします
    https://script.google.com/home
  2. ライブラリの + アイコンをクリックし、スクリプトIDに 1ReeQ6WO8kKNxoaA_O0XEQ589cIrRvEBA9qcWpNqdOP17i47u6N9M5Xh0 を入れて 検索 をクリックします
  3. Cheerioのバージョン14が選択されていることを確認し、追加をクリックします
    このライブラリはDOM Parserとして使用します
  4. コード.gsの内容を以下で置き換えます
// チャンネルID
const CHANNEL_ID = PropertiesService.getScriptProperties().getProperty('SLACK_CHANNEL_ID')

// サイトごとのコンテンツのセレクタ
const CONTENT_SELECTOR_MAP = {
  'gigazine.net': "#article .cntimage",
  'www.publickey1.jp': "#maincol",
  'qiita.com': ".p-items_main",
  'zenn.dev': "article section[class^=View_content]"
};

// 要約のプロンプト
const SUMMALIZE_PROMPT = ["コテコテの関西弁で要約して", "ギャルの口調で要約して", "ドラゴンボールの孫悟空の口調で要約して", "ドラゴンボールのベジータの口調で要約して", "絵文字を混ぜながら要約して", "ハイテンションで要約して"];

// 通知する記事の最大数
const POST_LIMIT = 1;

function main() {
  if (shouldExecute()) {
    const notifiedUrls = getNotifiedUrls()
    notifyFeedByDate(notifiedUrls)
    notifyFeedByNonSequential(notifiedUrls)
  }
}

function debug() {
  const notifiedUrls = getNotifiedUrls()
  notifyFeedByDate(notifiedUrls)
  notifyFeedByNonSequential(notifiedUrls)
}

/**
 * 実行してもよいか判定する
 * @returns true: 実行する, false: 実行しない
 */
function shouldExecute() {
  const now = new Date();
  const day = now.getDay();
  const hour = now.getHours();
  const minute = now.getMinutes();

  // 土日の判定
  if (day === 0 || day === 6) return false;

  // 祝日の判定
  if (isHoliday(now)) return false;

  // 時間帯の判定(9:40〜18:00の間でない場合は実行しない)
  if (hour < 9 || (hour === 9 && minute < 40) || hour >= 18) return false;

  // 上記の条件をすべて満たさない場合は実行する
  return true;
}

/**
 * RSSフィードを日付順に通知する
 * @param notifiedUrls
 */
function notifyFeedByDate(notifiedUrls) {
  const feeds = [
    { url: 'https://gigazine.net/news/rss_2.0/', id: 'gigazine' },
    { url: 'https://www.publickey1.jp/atom.xml', id: 'publickey' },
  ];

  feeds.forEach(function (feed) {
    const lastPublishedDateKey = 'lastPublishedDate_' + feed.id;
    // プロパティからエポックタイムを取得し、それを基にDateオブジェクトを生成
    const lastPublishedDateEpoch = PropertiesService.getScriptProperties().getProperty(lastPublishedDateKey);
    const lastPublishedDate = lastPublishedDateEpoch ? new Date(Number(lastPublishedDateEpoch)) : new Date();
    let latestDate = lastPublishedDate;

    let rssData = parseFeed(feed.url);

    let notifyCount = 0;
    rssData.forEach(rss => {
      if (notifyCount >= POST_LIMIT) {
        return;
      }
      if (rss.pubDate > lastPublishedDate && !notifiedUrls.includes(rss.link)) {
        if (rss.pubDate > latestDate) {
          latestDate = rss.pubDate;
        }

        const ts = sendMessageToSlack(CHANNEL_ID, `<${rss.link}|${rss.title}>`);
        const content = getContent(rss.link);
        if (content) {
          const summalize = runGemini(getRandomPrompt(), content);
          sendMessageToSlack(CHANNEL_ID, summalize, ts);
          notifyCount++;
        }
      }
    })

    if (latestDate > lastPublishedDate) {
      // 最新の日付をエポックタイムとして保存
      PropertiesService.getScriptProperties().setProperty(lastPublishedDateKey, latestDate.getTime().toString());
    }
  });
}

/**
 * 日付順ではないRSSフィードを通知する
 * @param notifiedUrls
 */
function notifyFeedByNonSequential(notifiedUrls) {
  const feeds = [
    { url: 'https://qiita.com/popular-items/feed', id: 'qiita', prefix: 'https://qiita.com/' },
    { url: 'https://zenn.dev/feed', id: 'zenn', prefix: 'https://zenn.dev/' }
  ];

  feeds.forEach(function (feed) {
    const notifiedUrlsKey = 'notifiedUrls_' + feed.id;
    // PropertiesServiceから通知済みURLを取得
    const notifiedUrlsString = PropertiesService.getScriptProperties().getProperty(notifiedUrlsKey);
    const notifiedUrlsArray = notifiedUrlsString ? notifiedUrlsString.split(' ') : [];

    const rssData = parseFeed(feed.url);
    let notifyCount = 0;

    rssData.forEach(rss => {
      if (notifyCount >= POST_LIMIT) {
        return;
      }

      const relativeUrl = rss.link.replace(feed.prefix, '');
      if (!notifiedUrlsArray.includes(relativeUrl) && !notifiedUrls.includes(rss.link)) {
        const message = `{
          "blocks": [
            {
              "type": "section",
              "text": {
                "type": "mrkdwn",
                "text": "<${rss.link}|${rss.title}>"
              }
            }
          ]
        }`;
        console.info(message);

        const ts = sendMessageToSlack(CHANNEL_ID, `<${rss.link}|${rss.title}>`);
        const content = getContent(rss.link);
        if (content) {
          const summalize = runGemini(getRandomPrompt(), content);
          sendMessageToSlack(CHANNEL_ID, summalize, ts);
          notifiedUrlsArray.push(relativeUrl);
          notifyCount++;
        }
      }
    });

    // 通知済みURLを更新(最新の50個を保持)
    if (notifiedUrlsArray.length > 50) {
      notifiedUrlsArray.splice(0, notifiedUrlsArray.length - 50); // 古いURLを削除
    }
    PropertiesService.getScriptProperties().setProperty(notifiedUrlsKey, notifiedUrlsArray.join(' '));
  });
}

/**
 * 要約のプロンプトを取得する
 * @returns
 */
function getRandomPrompt() {
  const randomNum = Math.random();

  // 要約してが50%の確率で選ばれるようにする
  if (randomNum < 0.5) {
    return "要約して";
  } else {
    // ランダムに選択
    const randomIndex = Math.floor(Math.random() * SUMMALIZE_PROMPT.length);
    return SUMMALIZE_PROMPT[randomIndex];
  }
}

/**
 * JSONをパースする
 * @param text
 * @returns
 */
function jsonParse(text) {
  try {
    return JSON.parse(text);
  } catch (e) {
    console.error('Error parsing JSON:', e);
    return {};
  }
}

/**
 * ルートメッセージを取得し、URLを抽出してコンテンツを取得する
 * @param channelId
 * @param threadTs
 * @returns
 */
function fetchRootMessageAndUrls(channelId, threadTs) {
  if (threadTs == undefined) return null;
  const token = PropertiesService.getScriptProperties().getProperty('SLACK_OAUTH_TOKEN');
  const url = `https://slack.com/api/conversations.replies?channel=${channelId}&ts=${threadTs}&limit=1`;
  const options = {
    'method': 'get',
    'headers': {
      'Authorization': 'Bearer ' + token
    },
    'muteHttpExceptions': true
  };
  const response = UrlFetchApp.fetch(url, options);
  const result = jsonParse(response.getContentText());
  console.info(result);
  if (result.ok && result.messages.length > 0) {
    const rootMessage = result.messages[0].text;
    console.info(rootMessage)
    const url = extractUrls(rootMessage);

    const content = getContent(url);
    return content;
  }
  return null
}

/**
 * Geminiを実行する
 * @param message
 * @param content
 * @returns
 */
function runGemini(message, content) {
  try {
    const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY')
    const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=${apiKey}`
    const payload = {
      'contents': [
        {
          'role': 'user',
          'parts': [{
            'text': message + "\n\n-----\n\n" + content
          }]
        }
      ]
    }
    const options = {
      'method': 'post',
      'contentType': 'application/json',
      'payload': JSON.stringify(payload)
    };
    const response = UrlFetchApp.fetch(url, options)
    const json = jsonParse(response.getContentText());
    console.info('Gemini response')
    console.info(json)
    if (json) {
      if (json.candidates && json.candidates.length > 0) {
        console.info(`finishReason: ${json.candidates[0].finishReason}`)
        console.info(json.candidates[0].safetyRatings)
        if (json.candidates[0].content) {
          console.info(json.candidates[0])
          console.info(json.candidates[0].content)
          console.info(json.candidates[0].content.parts[0])
          console.info(json.candidates[0].content.parts[0].text)
          return json.candidates[0].content.parts[0].text;
        } else {
          console.error('Gemini no content')
          return `Gemini error
finishReason: ${json.candidates[0].finishReason}
${JSON.stringify(json.candidates[0].safetyRatings, null, "\t")}
        `
        }
      } else if (json.error) {
        console.error(json.error)
        return `Gemini error
Error code: ${json.error.code}
${json.error.message}`
      }
    }
    return null
  } catch (e) {
    console.error('Gemini error: ', e);
    return e.message
  }
}

/**
 * メッセージからURLを抽出する
 * @param text
 * @returns
 */
function extractUrls(text) {
  let matches = text.match(/<([^|]+)(?:\||>)/);
  if (matches && matches.length > 1) {
    return matches[1];
  }
  matches = text.match(/(https?:\/\/[^\s]+)/);
  if (matches && matches.length > 1) {
    return matches[1];
  }
  return null;
}

/**
 * Slackにメッセージを送信する
 * @param channel
 * @param text
 * @param threadTs
 * @returns
 */
function sendMessageToSlack(channel, text, threadTs = null) {
  if (text == null) {
    return
  }
  const token = PropertiesService.getScriptProperties().getProperty('SLACK_OAUTH_TOKEN');
  const url = 'https://slack.com/api/chat.postMessage';
  const blocks = [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": text
      }
    }
  ];
  console.info("Send Slack")
  console.info(blocks)
  const payload = {
    "channel": channel,
    "blocks": blocks,
    "thread_ts": threadTs
  };
  const options = {
    'method': 'post',
    'contentType': 'application/json',
    'headers': {
      'Authorization': 'Bearer ' + token
    },
    'payload': JSON.stringify(payload),
    'muteHttpExceptions': true
  };
  const response = UrlFetchApp.fetch(url, options);
  const result = jsonParse(response.getContentText());
  console.info(result);
  if (result.ok) {
    return result.ts; // 成功した場合、レスポンスからtsを返す
  } else {
    throw new Error('Failed to send message to Slack: ' + result.error);
  }
}

/**
 * ホスト名を取得する
 * @param url
 * @returns
 */
function getHostname(url) {
  const hostPattern = /https?:\/\/([^\/]+)/;
  const matches = url.match(hostPattern);
  if (matches && matches.length > 1) {
    return matches[1];
  }
  return null;
}

/**
 * URLからコンテンツを取得する
 * @param url
 * @returns
 */
function getContent(url) {
  const response = UrlFetchApp.fetch(url);
  const html = response.getContentText();

  // CheerioでHTMLを解析
  const $ = Cheerio.load(html);

  // URLのホスト名に基づいて適切なセレクタを選択
  const hostname = getHostname(url);
  const selector = CONTENT_SELECTOR_MAP[hostname];

  if (selector) {
    // セレクタにマッチする要素のテキストを抽出
    const matchedText = $(selector).text().replace(/[ \t\u3000]+/g, ' ').replace(/ +$/g, '').replace(/[\r\n]+/g, "\n");
    console.info(matchedText);
    return matchedText;
  } else {
    console.info(`No selector found for hostname: ${hostname}`);
  }
  return null
}

/**
 * RSSをパースする
 * @param url URL
 * @returns
 */
function parseFeed(url) {
  console.info(url)
  try {
    const response = UrlFetchApp.fetch(url);
    const xml = response.getContentText();
    const document = XmlService.parse(xml);
    const root = document.getRootElement();

    // フィードの形式(RSSまたはAtom)を判定
    const isAtom = root.getName() === 'feed';
    const namespace = isAtom ? XmlService.getNamespace('http://www.w3.org/2005/Atom') : root.getNamespace();
    const dcNamespace = XmlService.getNamespace('dc', 'http://purl.org/dc/elements/1.1/')
    console.info(`isAtom: ${isAtom}`)
    let entries;
    if (isAtom) {
      // Atomフィードの処理: 名前空間を考慮して子要素を取得
      entries = root.getChildren('entry', namespace);
    } else {
      // RSSフィードの処理
      entries = root.getChildren('channel', namespace)[0].getChildren('item', namespace);
    }
    console.info(`count: ${entries.length}`)
    const rssData = [];
    for (let i = 0; i < entries.length; i++) {
      const entry = entries[i];
      let title, pubDate, link;
      if (isAtom) {
        // Atomフィードの要素取得
        title = entry.getChild('title', namespace).getText();
        pubDate = new Date(entry.getChild('published', namespace).getText());
        link = entry.getChildren('link', namespace).find(link => link.getAttribute('rel') === null || link.getAttribute('rel').getValue() === 'alternate').getAttribute('href').getValue();
      } else {
        // RSSフィードの要素取得
        title = entry.getChild('title', namespace).getText();
        pubDate = new Date(entry.getChild('pubDate', namespace).getText());
        link = entry.getChild('link', namespace).getText();
      }

      rssData.push({
        title: title,
        pubDate: pubDate,
        link: link.replace(/\?.*$/, '')
      });
    }
    rssData.sort(function (a, b) {
      return a.pubDate - b.pubDate;
    });
    console.info(rssData);
    return rssData;
  } catch (e) {
    console.error('parseFeed error: ', e);
    return [];
  }
}

/**
 * 祝日か判定する
 * @param date 判定対象の日付
 * @returns true: 祝日, false: 平日
 */
function isHoliday(date) {
  const calendars = CalendarApp.getCalendarsByName('日本の祝日');
  if (calendars.length > 0) {
    const events = calendars[0].getEventsForDay(date);
    return events.length > 0;
  }
  return false;
}

/**
 * メッセージからURLを抽出する
 * @param messages
 * @returns URLのリスト
 */
function extractUrlsFromMessages(messages) {
  const urlPattern = /<([^|]+)\|/; // URLを抽出するための正規表現パターン

  const urls = messages.map(message => {
    const matches = message.text.match(urlPattern);
    if (matches && matches.length > 1) {
      // 正規表現の第一マッチンググループがURL
      return matches[1];
    } else {
      return null; // URLが見つからない場合はnullを返す
    }
  }).filter(url => url !== null); // nullでない値のみをフィルタリング

  return urls;
}

function extractTitleUrlsFromMessages(messages) {
  const pattern = /<([^|]+)\|([^>]+)>/; // URLを抽出するための正規表現パターン

  return messages.map(message => {
    const matches = message.text.match(pattern);
    if (matches && matches.length > 1) {
      // 正規表現の第一マッチンググループがURL
      return { title: matches[2], url: matches[1] };
    } else {
      return null; // URLが見つからない場合はnullを返す
    }
  }).filter(url => url !== null); // nullでない値のみをフィルタリング
}

/**
 * すでにSlackに通知済みのURLを取得する
 * @returns URLのリスト
 */
function getNotifiedUrls() {
  const url = 'https://slack.com/api/conversations.history';
  const token = PropertiesService.getScriptProperties().getProperty('SLACK_OAUTH_TOKEN');
  const params = {
    'channel': CHANNEL_ID,
    'limit': 500
  };

  const options = {
    'method': 'get',
    'headers': {
      'Authorization': `Bearer ${token}`
    },
    'muteHttpExceptions': true
  };

  const queryString = Object.keys(params).map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&');
  const response = UrlFetchApp.fetch(`${url}?${queryString}`, options);
  const result = jsonParse(response.getContentText());
  let urls = []
  if (result.ok) {
    urls = extractUrlsFromMessages(result.messages);
  } else {
    console.error('Failed to fetch messages:', result.error);
  }
  return urls
}

  1. フロッピーアイコンをクリックし保存します
    image.png
  2. 左のメニューから歯車アイコンをクリックします
    image.png
  3. 画面下の、スクリプト プロパティにてスクリプト プロパティを追加をクリックします
  4. 以下を追加します
    最初は入力欄が1つしか表示されないので、スクリプト プロパティを追加を3回クリックすることで追加で3つ入力欄が表示されます
プロパティ
SLACK_VERIFICATION_TOKEN Verification Tokenで控えた値を入力
SLACK_OAUTH_TOKEN ot User OAuth Tokenで控えた値を入力
SLACK_CHANNEL_ID 控えたチャンネルIDを入力
GEMINI_API_KEY 控えたGemini Pro API Keyを入力
  1. 入力が終わったら、スクリプト プロパティを保存をクリックします
  2. 左のメニューからエディタのアイコンをクリックします
    image.png
  3. 実行する関数にmainが選択されていると思うのでdebugを選択します
    image.png
  4. 実行ボタンをクリックします
    image.png
  5. このような確認ダイアログが表示されるので、権限を確認をクリックします
    インターネットからの情報取得やGoogleカレンダーを使用した祝日判定を行っているため権限を確認して許可する必要があります
    image.png
  6. Googleアカウントの選択画面が表示されるので選択します
  7. 許可をクリックします
  8. Slackチャンネルにニュースが通知され、そのニュースにリプライする形で要約がPostされると思います
    スクリーンショット 2024-04-24 21.53.51.png
  9. ここまで気たらもう少し!
    定期的に実行されるようにしましょう
    左のメニューから時計のアイコンをクリックします
    image.png
  10. トリガーを追加をクリック
  11. 時間ベースのトリガーのタイプを選択分ベースのタイマー時間の間隔を選択を任意の値に設定
  12. 保存をクリック
  13. これで指定した間隔ごとにニュースが通知されるようになりました!

あとはスクリプトをカスタマイズしたりして情報収集を効率化しましょう!

オマケ

アイコン変更&表示名&名前変更

  1. 以下のページを開きます
    https://api.slack.com/apps
  2. ニュース要約君を探してクリックします
  3. Basic Informationの下部のDisplay Informationで表示名とアイコンを変更できます
  4. App HomeYour App’s Presence in Slackで名前等できます

収集先を追加する方法

notifyFeedByDatefeeds にURLと任意の識別子を追加してください。

  const feeds = [
    { url: 'https://gigazine.net/news/rss_2.0/', id: 'gigazine' },
    { url: 'https://www.publickey1.jp/atom.xml', id: 'publickey' },
    { url: 'https://example.com/atom.xml', id: 'example' }, <<- 追加
  ];

その後、CONTENT_SELECTOR_MAP に、URLのホスト名をキーとして、該当のサイトの本文が入力されているセレクタを追加してください。

const CONTENT_SELECTOR_MAP = {
  'gigazine.net': "#article .cntimage",
  'www.publickey1.jp': "#maincol",
  'qiita.com': ".p-items_main",
  'zenn.dev': "article section[class^=View_content]",
  'example.com': "#main-content", <<- 追加
};

debug を実行して記事がポストされるか確認してください

注意

時々、プロンプトに不適切な内容が含まれているとGeminiに判定され、要約を作ってくれません。
ただ何もPostしないと何が起きているかわからないので、不適切と判定されたらその内容をPostするようにしています
スクリーンショット 2024-04-24 22.22.25.png

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0