Qiitaのいいね数を取得して自分のポートフォリオページを自動で更新する

## これは何?

Qiitaのピックアップ記事をもっとたくさん選択できたらいいのに!と思うことはありませんか?

自分はいいねが多い記事だけをあつめたQiitaのページを作っています。

https://qiita.com/sigma_devsecops/items/59af6d7f45397217ddd2

しかし，↑をみればわかるようにQiitaのリンクを貼るだけでは，いいね数やストック数は記事を開くまでは表示されません。これではパット見ただけではどのくらいいいねをいただいたかを見て悦に浸ることができません。

そのため，Qiitaのいいね数，ストック数，viewsを自動で取得して記事を定期的にアップデートするAWS Lambdaを作成してみました。

---

## 作ったもの

[GitHub repository](https://www.serverless.com/)

### 成果物イメージ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3718390/39f23856-b1e3-94ec-9210-770c0c4f2e5f.png)


こんな感じでurlと雛形の文字列は手動で作る必要がある。数値だけ正規表現で置換して更新しているだけ。

    ```
    https://qiita.com/記事のurl
    
    views: 5705,いいね数: 15,ストック数: 13
    ```
以下処理の流れ

1. 記事のurl(ポートフォリオ用)を指定して実行
2. この記事の中からqiita.comがふくまれるurl一覧を取得
3. いいね数，ストック数，views数を取得
4. 正規表現でurlの下にある文字列を更新

### 環境

- AWS Lambda
    - Node.js: QiitaのAPIを叩いてデータを取得し，記事を更新する
- AWS Event Bridge: Lambdaを定期実行する

### デプロイ方法

[ServerlessFramework](https://www.serverless.com/)を使ってデプロイする

---

## 解説

### APIキーの取得方法

[Qiitaの個人設定ページ](https://qiita.com/settings/applications)から取得できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3718390/1c5c8f4c-5f5f-0c6d-b271-1052ed8c296c.png)

今回は記事の更新も行うので書き込み権限もつけていますが，APIで記事を消すことも可能なので取り扱いには十分注意が必要。

### node.jsを書く

```handler.ts
import axios from "axios";
import dotenv from "dotenv";
// import { APIGatewayProxyEvent } from "aws-lambda";

dotenv.config();
const BASE_URL = "https://qiita.com/api/v2";
const QIITA_ACCESS_TOKEN = process.env.QIITA_ACCESS_TOKEN;


/**
 * URLから記事IDをパース
 */
function getArticleIdFromUrl(url: string): string {
  const urlArray = url.split("/");
  return urlArray[urlArray.length - 1];
}


/**
 * 記事の内容を取得する。
 * @param articleURL - 取得したい記事のURL
 */
async function getArticleContent(articleURL: string): Promise<string> {
  const articleId = getArticleIdFromUrl(articleURL);
  try {
    const response = await axios.get(`${BASE_URL}/items/${articleId}`, {
      headers: {
        Authorization: `Bearer ${QIITA_ACCESS_TOKEN}`,
      },
    });

    return response.data.body;
  } catch (error: unknown) {
    console.error("記事内容の取得に失敗しました:", (error as any).message);
    throw new Error("記事内容の取得に失敗しました");
  }
}


/**
 * 
 * 記事内容からadvent-calendarのURLを除いたQiitaのURLを抽出する
 * @param content - 記事内容
 * @returns - 抽出したQiitaのURLの配列
 */
function extractQiitaUrls(content: string): string[] {
  const urlRegex = /https:\/\/qiita\.com\/[^\s]+/g;
  const urls = content.match(urlRegex) || [];
  return urls.filter(url => !url.includes("advent-calendar"));
}



/**
 * 記事URLからviews，いいね数及びストック数を取得する
 * @param articleURL - 取得したい記事のURL
 * @returns - views，いいね数，ストック数
 */
async function getArticleInfo(articleURL: string): Promise<{ pageViewsCount: number, likesCount: number, stocksCount: number }> {
  const articleId = getArticleIdFromUrl(articleURL);
  try {
    const response = await axios.get(`${BASE_URL}/items/${articleId}`, {
      headers: {
        Authorization: `Bearer ${QIITA_ACCESS_TOKEN}`,
      },
    });

    const data = response.data;
    const likesCount = data.likes_count; // いいね数
    const stocksCount = data.stocks_count; // ストック数
    const pageViewsCount = data.page_views_count; // PV数
    console.log(`記事: ${articleURL}, いいね数: ${likesCount}, ストック数: ${stocksCount}, PV数: ${pageViewsCount}`);

    return {pageViewsCount, likesCount, stocksCount };
  } catch (error: unknown) {
    console.error("記事情報の取得に失敗しました:", (error as any).message);
    throw new Error("記事情報の取得に失敗しました");
  }
}



/**
 * 記事のタイトルを取得する。
 * @param articleURL - 取得したい記事のURL
 */
async function getArticleTitle(articleURL: string): Promise<string> {
  const articleId = getArticleIdFromUrl(articleURL);
  try {
    const response = await axios.get(`${BASE_URL}/items/${articleId}`, {
      headers: {
        Authorization: `Bearer ${QIITA_ACCESS_TOKEN}`,
      },
    });

    return response.data.title;
  } catch (error: unknown) {
    console.error("記事タイトルの取得に失敗しました:", (error as any).message);
    throw new Error("記事タイトルの取得に失敗しました");
  }
}



/**
 * 記事をのviews，いいね数，ストック数の値を更新する。
 * @param articleURL - 更新したい記事のURL
 * @param updatedContent - 更新後の内容
 */
async function updateArticle(articleURL: string, updatedContent: string): Promise<void> {
  const articleId = getArticleIdFromUrl(articleURL);
  console.log("更新後の内容:", updatedContent);
  try {
    // NOTE: 記事のタイトルがないと更新に失敗するため，タイトルを取得
    const articleTitle = await getArticleTitle(articleURL);

    await axios.patch(`${BASE_URL}/items/${articleId}`, {
      title: articleTitle,
      body: updatedContent,
    }, {
      headers: {
        Authorization: `Bearer ${QIITA_ACCESS_TOKEN}`,
        'Content-Type': 'application/json'
      },
    });
  } catch (error: unknown) {
    console.error("記事の更新に失敗しました:", (error as any).message);
    throw new Error("記事の更新に失敗しました");
  }
}


/**
 * AWS Lambdaのエントリーポイント
 * @param event - AWS Lambdaのイベント
 * @returns - Lambdaのレスポンス
 */
// NOTE: 特にイベント情報は使っていないのでany型に戻した
export const run = async (event: any) => {
  console.log("Lambda function executed", event);
  const updateURL = "https://qiita.com/sigma_devsecops/items/59af6d7f45397217ddd2"; // FIXME: 任意の記事URLに変更
  try {
    // 記事の内容を取得し，QiitaのURLを抽出
    let content = await getArticleContent(updateURL);
    const qiitaUrls = extractQiitaUrls(content);

    // 各QiitaのURLに対していいね数とストック数を取得し，記事内容を更新
    for (const url of qiitaUrls) {
      const { pageViewsCount, likesCount, stocksCount } = await getArticleInfo(url);
      const viewsLikeStockInfo = `views: ${pageViewsCount},いいね数: ${likesCount},ストック数: ${stocksCount}\n`;

      if (content.includes(url)) {
        const regex = new RegExp(`(${url}\\s*\\n)(views: \\d+,いいね数: \\d+,ストック数: \\d+\\n)?`);
        content = content.replace(regex, `$1${viewsLikeStockInfo}`);
      }
    }

    await updateArticle(updateURL, content);

    return {
      statusCode: 200,
      body: JSON.stringify({ message: "Success" }),
    };
  } catch (error) {
    return {
      statusCode: 500,
      body: JSON.stringify({ message: (error as any).message }),
    };
  }
};


// ローカルから実行するための設定
if (require.main === module) {
  (async () => {
    const result = await run({});
    console.log("Lambda Execution Result:", result);
  })();
}
```

node.jsを書くのは初めてだったので変なところがあるかも?

APIキーは直書きせずに，.envに保存しています。

### ServerlessFrameworkの設定

```serverless.yaml
# "org" ensures this Service is used with the correct Serverless Framework Access Key.
org: sigma18
# "app" enables Serverless Framework Dashboard features and sharing them with other Services.
app: qiita-update-app
# "service" is the name of this project. This will also be added to your AWS resource names.
service: qiita-auto-update

provider:
  name: aws
  runtime: nodejs22.x
  timeout: 10
  environment:
    QIITA_ACCESS_TOKEN: ${env:QIITA_ACCESS_TOKEN}

functions:
  rateHandler:
    handler: handler.run
    events:
      - schedule: rate(1 day)

plugins:
  - serverless-dotenv-plugin

custom:
  dotenv:
    basePath: ./

package:
  include:
    - ../node_modules/**
```

ポイントとしては，以下です。

- `events`でjobの実行頻度を決めている
- `dotenv`でserverlessが.envファイルを読み取り，AWSのLambdaにENVを設定してもらっている。

---

## 感想

- 自動で記事のデータが更新されることができるようになり，モチベーションアップにつながった。
- QiitaのAPI普通に使いやすかった。

---

## 追記

GitHubの方，ソース少しリファクタしました