📑

Zennの隠しAPIから記事の一覧を取得して、X(Twitter)に定期自動投稿するGASを作成する方法🌟

2024/02/04に公開

こんにちは、AIQ株式会社のフロントエンドエンジニアのまさぴょんです!
今回は、ZennのAPIから記事の一覧を取得して、X(Twitter)に定期自動投稿するGASを作成する方法について、ご紹介していきます。

以前、X(Twitter)にTech Blog の記事をランダムに定期・自動投稿するScriptをGASで作る方法について、解説しました。

https://zenn.dev/aiq_dev/articles/0c48837f4c8849

ただ、こちらは、私の個人 Tech Blog(WordPress)でのやり方なので、Zennの記事の場合は、どうするかについて解説していきます🌟

サービス構成 Overview (Zenn Developer's Guides)

https://zenn-dev.github.io/zenn-docs-for-developers/basics/overview

記事の全体像(構成)のSummary

この記事の説明の全体像(構成)のサマリーをまとめさせていただくと、次のとおり。

Zennの記事一覧を取得できる隠しAPIが存在することを発見する🐱

Zennの記事一覧を取得する方法を調査していたら、RSSで情報を取得する方法以外にも、Zennの記事一覧を取得できる隠しAPIが存在することを発見しました👀✨

  1. RSSを活用する方法

    • https://zenn.dev/ユーザー名/feed?all=1
    • 上記のような形で、そのユーザーの記事情報をすべて取得することができる。
    • データフォーマット・構造は、RSS(XML)です。
  2. 非公式のZenn APIを使用する方法

    • https://zenn.dev/api/articles?username=ユーザー名&order=latest
    • 上記のような形で、RSS形式で、そのユーザーの記事情報を取得することができる。
    • データのフォーマット・構造は、JSONです。
    • 記事の数が多い場合は、page番号で、記事一覧を取得する。
    • https://zenn.dev/api/articles?username=manase&order=latest&page=1
    • 上記のページネーションでの取得については、後述します。

調査スクラップ

https://zenn.dev/manase/scraps/489f556f7ff15b

Zenn APIの記事取得APIは、ページネーションを実装している🌟

先述の調査から、JSON形式で、自分のZennの記事を取得してみたところ、JSONで取得できたのは、記事数は48件でした。

  • https://zenn.dev/api/articles?username=manase&order=latest

https://zenn.dev/api/articles?username=manase&order=latest

2/4 時点で、私の記事は、57 件を公開中 なので、おそらくページネーションが実装されているAPIだと考えられます。
その証拠に、JSONに、"next_page": 2というデータがあることを発見しました👀✨

Zenn APIの記事取得APIが返却してくれる JSONのデータ構造は、次のようなものです。

zenn-api-article.json
{
  "articles": [
    {
      "id": 237226,
      "post_type": "Article",
      "title": "【無料】X(Twitter)にTech Blog の記事をランダムに定期・自動投稿するScriptをGASで作る!",
      "slug": "0c48837f4c8849",
      "comments_count": 0,
      "liked_count": 0,
      "body_letters_count": 9200,
      "article_type": "tech",
      "emoji": "🐦",
      "is_suspending_private": false,
      "published_at": "2024-01-21T15:18:39.222+09:00",
      "body_updated_at": "2024-01-21T15:18:39.222+09:00",
      "source_repo_updated_at": null,
      "pinned": false,
      "path": "/aiq_dev/articles/0c48837f4c8849",
      "user": {
        "id": 42864,
        "username": "manase",
        "name": "まさぴょん",
        "avatar_small_url": "https://lh3.googleusercontent.com/a-/AOh14GhYX8r5eB8cdfa1yTA6hD1axnAibrQQzBwMDmxHuQQ=s96-c"
      },
      "publication": {
        "id": 415,
        "name": "aiq_dev",
        "display_name": "AIQ Tech Blog (有志)",
        "avatar_small_url": "https://res.cloudinary.com/zenn/image/fetch/s--zcjAmyH_--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_50/https://storage.googleapis.com/zenn-user-upload/avatar/d936fa57c0.jpeg",
        "pro": false,
        "avatar_registered": true
      }
    }
  ],
  "next_page": 2
}

QueryParameterにpageを追加してみる🐱

試しに、QueryParameterにpageを追加してみたら、ページネーション設定ごとに、記事一覧を取得できることを確認しました🐱

  • https://zenn.dev/api/articles?username=manase&order=latest&page=1

    • "next_page":2 になっている。
    • つまり、次のページが存在しており、ページ番号は2であるということ。

  • https://zenn.dev/api/articles?username=manase&order=latest&page=2

    • "next_page":null になっている。
    • つまり、次のページは存在しない。

GASで、Zennの特定のユーザーの記事一覧を取得してCSV作成・Driveに保存する

Zennの記事取得APIについて、仕様の一部を理解したので、すべての記事を取得できそうだということがわかりました。
それでは、GAS(Google Apps Script)で、Zennの特定のユーザーの記事一覧を取得してCSV作成して、Google Driveに保存する方法について解説していきます。

とりあえず、GAS上で、次のSrcCodeを使ってもらば、OKです🙆‍♂️
(USER_NAMEFOLDER_IDだけ、ご自身の状況に応じて変えてください)

getZennMyAllArticleList.js
// NOTE: 定数・定義
/** Zenn の API Base URL */
const ZENN_BASE_URL = "https://zenn.dev";
/** Zenn の記事一覧を取得する API Path */
const ZENN_API_ARTICLE_LIST_PATH = "/api/articles?username=";
/** 記事一覧を取得したい User の userName */
const USER_NAME = "manase";

/** 保存する CSV のファイル名 */
const FINE_NAME = "zenn_articles.csv";

// Google Drive の Folder ID
const FOLDER_ID = "";

/**
 * NOTE: 該当のページ番号の Zenn の記事一覧を取得する関数
 * @param {Number} pageNumber ページ番号
 * @returns {Array} zennArticleList: Zenn の記事一覧
 * @returns {Number | null} NextPage: 次のページ番号
 */
function getZennArticleList(pageNumber) {
  console.log("Zenn の記事一覧を取得する・処理 Start");

  /** Zennの記事一覧を取得するAPIのURL */
  const userArticleListURL =
    ZENN_BASE_URL +
    ZENN_API_ARTICLE_LIST_PATH +
    USER_NAME +
    "&order=latest&page=" +
    pageNumber;
  console.log("Zenn API Call URL:", userArticleListURL);

  try {
    /** Zenn API からの レスポンス */
    const response = UrlFetchApp.fetch(userArticleListURL);
    // console.log("response: ", response);

    /** Fetch Result */
    const result = JSON.parse(response.getContentText());
    // console.log("result: ", result);

    /** Zenn の記事一覧リスト */
    const zennArticleList = result.articles;
    // console.log("Zenn の記事一覧:", zennArticleList);

    /**
     * 次のページ番号
     * - Number or null
     * - null の場合は、次のページがないことを意味する
     */
    const NextPage = result.next_page;
    // console.log("NextPage:", NextPage);
    console.log("Zenn の記事一覧を取得する・処理 End");

    return {
      zennArticleList,
      NextPage,
    };
  } catch (error) {
    console.error("Zenn の記事一覧取得 Block でエラーが発生しました", error);
  }
}

/**
 *  NOTE: Zenn のすべての記事一覧を取得する関数
 * @returns {Array} Zenn のすべての記事一覧
 */
function getZennAllArticleList() {
  console.log("Zenn のすべての記事一覧を取得する・処理 Start");

  /**
   * NOTE: Zenn の ページ番号
   * - Zenn の記事一覧取得 API は PageNation を実装しているため Page番号で、順に一覧取得をする
   * - 最初は、1ページ目から取得する
   */
  let pageNumber = 1;

  /**
   *  Fetch 制御 Flag
   * - true: Fetch を続ける
   * - false: Fetch を終了する
   */
  let isFetch = true;

  /** Zenn のすべての記事一覧リスト */
  let zennAllArticleList = [];

  try {
    /** 最後のページになるまで、Fetchを実行する */
    while (isFetch) {
      console.log("ページ番号:", pageNumber);

      /** 該当のページ番号の Zennの 記事一覧 を取得する */
      const results = getZennArticleList(pageNumber);

      console.log("Update前の記事の数", zennAllArticleList.length);
      console.log("追加で取得した記事の数", results.zennArticleList.length);

      /** 既存と取得したリストを Merge する */
      const updatedList = [...zennAllArticleList, ...results.zennArticleList];

      /** List を Update する */
      zennAllArticleList = updatedList;

      console.log("Update後の記事の数", zennAllArticleList.length);

      /**
       * 次のページ番号
       * - Number or null
       * - null の場合は、次のページがないことを意味する
       */
      const NextPage = results.NextPage;
      console.log("NextPage:", NextPage);

      // まだ、つづきのページがあるかどうかを確認する
      if (NextPage === null) {
        console.log("最後のページです");

        // 最後のページなので、Fetch を終了する
        isFetch = false;
      } else {
        console.log("次のページがあります");

        pageNumber = pageNumber + 1;
      }
    }
    console.log("Zenn のすべての記事一覧を取得する・処理 End");

    console.log("最終的な記事の総数", zennAllArticleList.length);

    return zennAllArticleList;
  } catch (error) {
    console.error(
      "Zenn のすべての記事一覧取得 Block でエラーが発生しました",
      error
    );
  }
}

/**
 * NOTE: Object の配列を受け取り CSV形式の文字列に変換する Func
 * @param {Array} objArray - Object の配列
 * @returns {String} csv - CSV形式の文字列
 */
function convertToCSV(objArray) {
  const array = typeof objArray !== "object" ? JSON.parse(objArray) : objArray;

  /** 1. Objectの Key を headerとして取り出す */
  let str =
    `${Object.keys(array[0])
      .map((value) => `"${value}"`)
      .join(",")}` + "\r\n";

  // 2. 各オブジェクトの値をCSVの行として追加する
  return array.reduce((str, next) => {
    str +=
      `${Object.values(next)
        .map((value) => `"${value}"`)
        .join(",")}` + "\r\n";
    return str;
  }, str);
}

/**
 * NOTE: CSV形式の文字列を Blob Object に変換する
 * @param {String} data - CSV形式の文字列
 * @param {String} name - ファイル名
 * @returns {Blob} blob - Blob Object
 */
function createBlob(csv, fileName) {
  const contentType = "text/csv";
  const charset = "utf-8";

  /** GAS の記法で Blob Object を作成する Type. CSV */
  const blob = Utilities.newBlob("", contentType, fileName).setDataFromString(
    csv,
    charset
  );
  return blob;
}

/**
 * NOTE: Google Drive にファイルを書き込む
 * @param {Blob} blob - Blob Object
 * @param {String} folderId - Google Drive の Folder ID
 * @returns {void}
 */
function writeDrive(blob, folderId) {
  // Google Drive の Folder を取得する
  const drive = DriveApp.getFolderById(folderId);
  // Google Drive にファイルを書き込む
  drive.createFile(blob);
}

/**
 * NOTE: Main Function
 */
function main() {
  // Zenn のすべての記事一覧を取得する
  const zennAllArticleList = getZennAllArticleList();
  // console.log("Zenn のすべての記事一覧:", zennAllArticleList);

  if (zennAllArticleList.length > 0) {
    console.log("Zenn に記事が投稿されています");

    /** Zenn のすべての記事一覧の中から必要なデータだけを抽出した Zenn の記事一覧 */
    const extractedZennArticleList = zennAllArticleList.map((element) => {
      return {
        title: element.title,
        url: `${ZENN_BASE_URL}${element.path}`,
        publishedAt: element.published_at,
        likesCount: element.liked_count,
      };
    });
    console.log(
      "必要なデータだけを抽出した Zenn の記事一覧:",
      extractedZennArticleList
    );

    /** CSV 形式の データ */
    const csvData = convertToCSV(extractedZennArticleList);

    // CSV ファイルを Blob Object に変換する
    const blob = createBlob(csvData, FINE_NAME);

    // Google Drive にファイルを書き込む
    writeDrive(blob, FOLDER_ID);
  } else {
    console.log("Zenn に記事が投稿されていません");
  }
}

CSVファイルをGoogleスプレッドシートにimportして、X投稿のためのMetaデータを作成する

上記のGAS Scriptを実行すると、次のようなCSVファイルが、Drive上に保存されるはずなので、
これをGoogle スプレッドシートに importして、Xに定期投稿するためのMetaデータを作成します。

ファイルタブからGoogle スプレッドシートに、CSVファイルの importを実施して、次のようなSheetを作成しておきました。

GASの定期実行プログラムの要件定義

Zennの記事一覧のMetaデータが準備できたので、次は、今回作成する定期実行プログラムの要件を定義します。
今回は、次の内容をプログラムで自動化することを要件定義とします。

  1. Zenn の記事を定期的に、Xに投稿したい。
  2. 投稿する記事は、ランダムに選びたい。
  3. 毎日、朝(午前7~8時)と昼(午後11時〜12時)と夕方(午後6~7時)の3回、定期的に投稿をしたい。

GASとX(Twitter)APIの設定をする💪🥺💪✨

Google Apps Script(GAS)を使って、X(Twitter)に自動投稿を行うには、GASとX(Twitter)それぞれの設定が必要です。
こちらの記事を参考に、X(Twitter)APIの登録と、Google Apps Scriptのプロジェクトを作成・OAuth2ライブラリの追加やOAuth2の認証を進めてください。

GoogleのOAuth2の認証

X(Twitter)のOAuth2認証

https://prtn-life.com/blog/gas-twitter-api

X(Twitter)にTech Blog の記事をランダムに定期・自動投稿するScript

X(Twitter)にTech Blog の記事をランダムに定期・自動投稿するScriptの内容は、次のとおりです。
こちらのGASのCodeは、こちらの『GAS×スプレッドシートでTwitterに定期自動投稿する方法』のSampleCodeをベースにハッシュタグに使用するカテゴリー情報などを追加しています。
(素晴らしい記事をありがとうございます🙌)

/** ツイッターのクライアント識別子 */
const CLIENT_ID = "";
/** ツイッターのClient Secret */
const CLIENT_SECRET = "";
/** スプレッドシートのID */
const SHEET_ID = "";
/** 編集権限のある Google Acount の G_Mail */
const G_MAIL = "";

// 初回だけ必要な OAuth2・認証処理
function main() {
  const service = getService();

  Logger.log("初回だけ必要な OAuth2・認証処理 Start");

  if (service.hasAccess()) {
    Logger.log("認証済みです");
  } else {
    Logger.log("認証ができていません");
    const authorizationUrl = service.getAuthorizationUrl();
    Logger.log("次の URL を開いて、認証をします。: %s", authorizationUrl);
  }
}

// スプレッドシートからツイッターに投稿する記事をランダムに取得して、ツイート本文を作成する
function autoTweetFromSheet() {
  Logger.log("ツイート本文を作成する・処理 Start");
  let today = new Date();
  let todayStr = Utilities.formatDate(today, "JST", "YYYY/MM/dd");

  const mySpreadSheet = SpreadsheetApp.openById(SHEET_ID);
  Logger.log(mySpreadSheet);

  // 編集権限のある Google Acount の G_Mail を渡す
  mySpreadSheet.addEditor(G_MAIL);

  const sheetList = mySpreadSheet.getSheets();
  Logger.log(sheetList);

  // 1番目のスプレッドシートを取得する
  const sheet = mySpreadSheet.getSheets()[0];
  Logger.log(sheet);

  const lastRow = sheet.getLastRow();

  const targetRow = makeRundom(lastRow);

  /** タイトル */
  const title = sheet.getRange(targetRow, 1).getValue();
  /** URL */
  const link = sheet.getRange(targetRow, 2).getValue();

  /** 投稿本文を作成する */
  const msg =
    `【Tech Blog おすすめ記事紹介 ${todayStr}】\n` +
    `\n${title}\n` +
    `\n記事はこちら🌟\n${link}\n` +
    `\n#Web開発\n#Webエンジニア\n#エンジニアと繋がりたい\n#プログラミング`;

  return msg;
}

// 乱数作成
function makeRundom(count) {
  let random = Math.random();
  random = Math.floor(random * count) + 1;
  return random;
}

// X(Service) に X-API, OAuth2 を使って Access する
function getService() {
  pkceChallengeVerifier();
  const userProps = PropertiesService.getUserProperties();
  const scriptProps = PropertiesService.getScriptProperties();

  // X-Service作成
  return OAuth2.createService("twitter")
    .setAuthorizationBaseUrl("https://twitter.com/i/oauth2/authorize")
    .setTokenUrl(
      "https://api.twitter.com/2/oauth2/token?code_verifier=" +
        userProps.getProperty("code_verifier")
    )
    .setClientId(CLIENT_ID)
    .setClientSecret(CLIENT_SECRET)
    .setCallbackFunction("authCallback")
    .setPropertyStore(userProps)
    .setScope("users.read tweet.read tweet.write offline.access")
    .setParam("response_type", "code")
    .setParam("code_challenge_method", "S256")
    .setParam("code_challenge", userProps.getProperty("code_challenge"))
    .setTokenHeaders({
      Authorization:
        "Basic " + Utilities.base64Encode(CLIENT_ID + ":" + CLIENT_SECRET),
      "Content-Type": "application/x-www-form-urlencoded",
    });
}

// 認証後のCallBack
function authCallback(request) {
  const service = getService();
  const authorized = service.handleCallback(request);
  if (authorized) {
    return HtmlService.createHtmlOutput("Success!");
  } else {
    return HtmlService.createHtmlOutput("Denied.");
  }
}

function pkceChallengeVerifier() {
  var userProps = PropertiesService.getUserProperties();
  if (!userProps.getProperty("code_verifier")) {
    var verifier = "";
    var possible =
      "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";

    for (var i = 0; i < 128; i++) {
      verifier += possible.charAt(Math.floor(Math.random() * possible.length));
    }

    var sha256Hash = Utilities.computeDigest(
      Utilities.DigestAlgorithm.SHA_256,
      verifier
    );

    var challenge = Utilities.base64Encode(sha256Hash)
      .replace(/\+/g, "-")
      .replace(/\//g, "_")
      .replace(/=+$/, "");
    userProps.setProperty("code_verifier", verifier);
    userProps.setProperty("code_challenge", challenge);
  }
}

function logRedirectUri() {
  var service = getService();
  Logger.log(service.getRedirectUri());
}

// ツイートを実行する
function sendTweet() {
  const payload = {
    text: autoTweetFromSheet(),
  };
  Logger.log("ツイートを実行する");

  const service = getService();
  if (service.hasAccess()) {
    Logger.log("サービスアクセス完了 ツイート処理を続行する");

    const url = `https://api.twitter.com/2/tweets`;
    const response = UrlFetchApp.fetch(url, {
      method: "POST",
      contentType: "application/json",
      headers: {
        Authorization: "Bearer " + service.getAccessToken(),
      },
      muteHttpExceptions: true,
      payload: JSON.stringify(payload),
    });
    const result = JSON.parse(response.getContentText());
    Logger.log(JSON.stringify(result, null, 2));

    const sheet = SpreadsheetApp.openById(SHEET_ID).getSheets()[0];
    const lastRow = sheet.getLastRow();
    let now = new Date();
    sheet.getRange(lastRow + 1, 1).setValue(result["data"]["id"]);
    sheet.getRange(lastRow + 1, 2).setValue(result["data"]["text"]);
    sheet.getRange(lastRow + 1, 3).setValue(now);
  } else {
    Logger.log("認証ができていません");
    var authorizationUrl = service.getAuthorizationUrl();
    Logger.log("次の URL を開いて、認証をします。: %s", authorizationUrl);
  }
}

GASで、トリガーの設定をする

最後に「毎日、朝, 昼, 夕の3回、定期的に投稿する」ためのGAS・トリガーの設定をしていきます。
まずは、GASのトリガーを選択します。

続いて、朝, 昼, 夕の3タイプの定期実行トリガーを作成していきます。

朝(午前7~8時)のトリガー設定

昼(午後11時〜12時)のトリガー設定

夕方(午後6~7時)のトリガー設定

これで、Blogの記事をランダムに定期・自動投稿する仕組みが完成しました🙌✨

まとめ

個人で、Blogもやっています、よかったら見てみてください。

https://masanyon.com/

注意事項

この記事は、AIQ 株式会社の社員による個人の見解であり、所属する組織の公式見解ではありません。

求む、冒険者!

AIQ株式会社では、一緒に働いてくれるエンジニアを絶賛、募集しております🐱🐹✨

詳しくは、Wantedly (https://www.wantedly.com/companies/aiqlab)を見てみてください。

参考・引用

https://zenn.dev/niiharamegumu/articles/8f00cfdf9753d1

https://zenn.dev/catnose99/articles/cb72a73368a547756862#comment-de8c8e9f253aa4

https://zenn.dev/manase/scraps/489f556f7ff15b

https://zenn-dev.github.io/zenn-docs-for-developers/basics/overview

https://zenn.dev/aiq_dev/articles/0c48837f4c8849

AIQ Tech Blog (有志)

Discussion