美容情報をMisskeyに配信するbotを作ってみた

# 背景（読まなくてもいい）
Twitter(現：X）が色々あってから、使いづらい、見辛い、と感じてしまい、Misskeyへ移行しました。
Misskeyはいいところが多いのですが、困っていたこととして、Twitterに比べて「情報が入ってきにくい」という点がありました。広告や最適化アルゴリズムのおかげで「自分の関心がある情報」が勝手に流れてくるTwitterに比べて、Misskeyではフォローしている人や同じサーバの人の投稿しか流れていません。日常のニュースはMisskeyのウィジェット機能でRSSリーダーを配置できるので、YahooニュースやITMediaなどを置いてニュースをチェックできますが、美容系の情報サイトはRSSがなくて簡単に設置することができませんでした。（女性向けのサイトってあんまり機能が充実していないイメージ…。というか同年代の女性はみんな、SNSはインスタ見てる気がします。）
RSSがないサイトからRSSを作成するツールなどもありましたが、無料期間が1週間だったりとずっと使うには微妙だったため、自分で希望のサイトをスクレイピングしてBotがその記事を配信する形を取ることにしました。


## 対象のサイト
* 美的.com（https://www.biteki.com/）
* VOCE（ヴォーチェ）｜美容メディア『VOCE』公式サイト(https://i-voce.jp/)
* マキアオンライン | 美容雑誌『MAQUIA（マキア）』公式ビューティサイト(https://maquia.hpplus.jp/)
※スクレイピングをする際はrobots.txtを見て許可されているページかどうかを確認します。

## 使うもの
* Python
* Windowsタスクスケジューラ
* MisskeyのBot用アカウント
* chatGPT（わからなくなったら質問する用です）
* VSCode(あったほうがいいもの）

# 実装方法
### 1,対象サイトのWeb記事を取得してリストを返す関数を作ります。
まずは対象のサイトのWeb記事のタイトルとリンクにどういうclassのタグが使われているか確認します。
F12を押して記事の要素をじっくり見て、タグの共通点を見つけるという作業をしました。
Pythonで、BeautifulSoupを使ってHTMLを解析し、記事のタイトルやURLを抽出するコードを書きます。
抽出したものはタイトル、リンク、実行日をJSONファイルとして保存します。このJSONファイルは各サイト共通の1つのファイルにしています。
※運用し始めてからログが見たくなったので、ログを作る関数も追加しています。

```python:get_articles_biteki.py
import requests
from bs4 import BeautifulSoup
import json
import os
from datetime import datetime

#日付設定
exectuion_date = datetime.today().strftime("%Y-%m-%d")

#ログファイル設定
LOG_FILE = "log.txt"

def log_message(message):
    with open(LOG_FILE,"a",encoding="utf-8") as f:
        f.write(f"{datetime.now()} - {message}\n" )
        
def biteki_scraper():
    url = "https://www.biteki.com/"  # 取得したいWebサイトのURL
    headers = {"User-Agent": "Mozilla/5.0"}

    try:
        response = requests.get(url, headers=headers)
        response.raise_for_status()
    except requests.RequestException as e:
        log_message(f"スクレイピング失敗（美的）：{e}")
        return []
    
    soup = BeautifulSoup(response.text, "html.parser")
    articles = soup.select("div.texts")  # 記事リンクを含む親要素を取得
    results = []

    for article in articles:
        link_tag = article.find("a")  # div 内の最初の <a> タグを取得
        if link_tag:
            title = article.find("h3").text if article.find("h3") else "タイトルなし"
            link = link_tag["href"]
            results.append({"title": title, "link": link,"date":exectuion_date})

    return results  # 記事のリストを返す

def save_unique_results(results,filename="articles.json"):
    if os.path.exists(filename):
         with open(filename,"r",encoding="utf-8") as f:
            try:
                existing_results = json.load(f)
            except json.JSONDecodeError:
                existing_results = []
    else:
            existing_results = []

    #すでに存在するデータをチェック
    existing_links = {item["link"] for item in existing_results}
    unique_results = [item for item in results if item["link"] not in existing_links]

    # 新しいデータがあれば追加
    if unique_results:
        existing_results.extend(unique_results)
        with open(filename, "w", encoding="utf-8") as f:
            json.dump(existing_results, f, ensure_ascii=False, indent=4)
            log_message(f"{len(unique_results)}件の新規記事を保存しました。（美的）")
    else:
        log_message(f"新規記事はありませんでした。（美的）")
    return len(unique_results)  # 追加したデータ数を返す

```
実行するとこういったJSONファイルが得られます。
```json
[
    {
        "title": "話題の“美容液シャントリ”で髪に自信を【美髪のカギ「やわらかさ」をチャージする】",
        "link": "https://i-voce.jp/feed/3901090/",
        "date": "2025-02-12"
    },
    {
        "title": "【時短で韓国風ヘア】暗めロングヘアでもブラウンベ…",
        "link": "https://www.biteki.com/hair/style/1958288",
        "date": "2025-02-12"
    },
    {
        "title": "有村架純さん「いろんな経験を経て30代の美容がま…",
        "link": "https://www.biteki.com/make-up/others/1958547",
        "date": "2025-02-12"
    }
]
```
これを他のサイト分もつくって、それぞれを関数化しておきます。


### 2,MisskeyでBot用のアカウントを作成し、APIトークンを発行する。
MisskeyでBot用のアカウントを作成します。
その後、アカウントの設定→API→アクセストークンの発行を選択していくと、どういった権限を渡すかを設定する画面が出てきます。
自分で作成して自分のPCのローカル環境で動かすbotなので、めんどくさくて権限は全部有効にしました。
今回の用途でれば「ノートを作成・削除する」だけでいい気がします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3734886/2e197faa-62ce-4617-aeac-e412662bf003.png)
設定したらトークンが発行されるのでコピペしておきます。あとから同じトークンを確認することが出来ないので必ずどこかにコピペしておきます。

### 3,Misskeyへ投稿する関数をかく。
textを渡すと、そのtextをMisskeyへ投稿する関数です。
```python:post_to_misskey.py
import requests

MISSKEY_INSTANCE = "https://YOUR-Misskey.com/"  # 自分のMisskeyインスタンスのURL
API_TOKEN = "YOUR_ACCESS_TOKEN"  # 発行したAPIトークン

def post_to_misskey(text):
    url = f"{MISSKEY_INSTANCE}api/notes/create"
    payload = {
        "i": API_TOKEN,
        "visibility": "home",
        "text": text
    }

    try:
        response = requests.post(url, json=payload)
        response.raise_for_status()

        data = response.json()
        if "createdNote" in data:
            return True
        else:
            print(f"投稿失敗（レスポンス異常）：{data}")
            return False
    except requests.exceptions.RequestException as e:
        print(f"Misskey投稿エラー：{e}")
        return False
```
これでだいたいパーツが揃ったので最後にmain.pyを作っていきます。

### 4,main.pyを作ってbotを動かす。
記事リストであるjsonファイルを読み込み、3件を投稿します。
投稿が成功したものは記事リストから削除します。

```python:main.py
from post_to_misskey import post_to_misskey # Misskey投稿関数をインポート
from datetime import datetime
import os
import json


#ログファイル設定
LOG_FILE = "log.txt"

def log_message(message):
    with open(LOG_FILE,"a",encoding="utf-8") as f:
        f.write(f"{datetime.now()} - {message}\n" )

#jsonファイル読み込み
JSON_FILE = "articles.json"

#JSONファイルを読み込む
def load_articles(filename):   
    if os.path.exists(filename):
        with open(filename, "r", encoding="utf-8") as f:
            try:
                return json.load(f)
            except json.JSONDecodeError:
                return []
    return []

#JSONファイルを保存する
def save_articles(filename, articles):
    with open(filename, "w", encoding="utf-8") as f:
        json.dump(articles, f, ensure_ascii=False, indent=4)

# 記事リストを読み込む
articles = load_articles(JSON_FILE)

# Misskeyに投稿
POST_COUNT = 3

def post_articles():
    articles = load_articles(JSON_FILE)

    if not articles:
        log_message("投稿する記事がありません。")
        return
    
    remaining_articles = []
    post_count = 0

    for article in articles:
        if post_count >= POST_COUNT:
            break #3件投稿したら終了

        title = article["title"]
        link = article["link"]
        text = f"📰 {title}\n🔗 {link}"
            
        response = post_to_misskey(text)

        if response:
            post_count += 1 #投稿したらカウントを増やす
            log_message(f"投稿成功: {title}")
        else:
            remaining_articles.append(article) #投稿に失敗した記事は残す
            log_message(f"投稿失敗: {title}")

    save_articles(JSON_FILE,remaining_articles + articles[post_count:])
    log_message(f"{post_count}件の記事を投稿しました。")

if __name__ == "__main__":
    post_articles()

```
構成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3734886/1b20b2e0-06df-4f10-b137-aca2208e51d5.png)


同じフォルダ内にある「articles.json」が、各サイトのスクレイピング結果を保存しているファイルです。
ここのデータを読み込んで３件だけ投稿させています。
※JSONファイル内の記事を一気に投稿したらTL荒らしっぽく見えてしまったので、１時間に１回起動し、JSONファイル内の３件の記事を投稿するようにしました。

### 5,タスクスケジューラの設定
Botの起動をタスクスケジューラにやらせます。
* 投稿は１時間に１回
* スクレイピングは深夜０時に１回

Windowsタスクスケジューラの基本タスクでは、最小間隔が「日」なので、「タスクの作成」の方から作ります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3734886/f2df4bfa-c703-4305-9d0d-18b209f34163.png)
適当な名前を付けます。
深夜０時はPCにログオンしていない可能性が高いので、「ユーザーがログオンしているかどうかかかわらず実行する」をチェックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3734886/7108da3f-85d7-467b-8773-05cd8d13eab9.png)
トリガー設定は見れば誰でもわかると思うので割愛します。
操作設定に、作成したスクリプトを設定していきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3734886/117bdbc9-12f5-498b-9796-dc0524b753a1.png)
「プログラム／スクリプト」に、Pythonの実行ファイルがあるディレクトリのフルパスを設定し、引数の追加には、実行するPythonファイルのフルパスを記載します。「開始」には実行するPythonファイルを置いてるフォルダを指定します。
うまく設定出来ていたら、「実行」ボタンを押すと正常に動作します。


このような形で投稿されていました。
これで私のタイムラインに美容情報が勝手に流れてきてくれます！うれしい！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3734886/38f2e6b9-8757-48b7-acfa-2b36f34bec2d.png)

log.txtはこんな感じです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3734886/d9c2d792-e090-488f-b875-adcb72a24222.png)


## 課題や今後の展望
X時00分に毎度コマンドプロンプトが画面に現れて若干邪魔なので、いずれAWS LambdaやGCPのCloudRunなどに実行させる方法に移行するかもしれません。
その際トークン情報をファイル内に直書きしているのはよくないので、その点注意して実装したいと思います。
情報botだけでなく、クイズの復習ができるbotも同様の仕組みで作れると思うので、TL充実用に作成したいと思います。