🎄

RustでWeb漫画更新検知ツールを書いた!たのし~~!!

に公開

本記事はRust Advent Calendar2025の24日目の記事です。メリークリスマス!

https://qiita.com/advent-calendar/2025/rust

1. この記事のモチベーション

簡単な自分向けプログラムでもRust使えるし楽しいよ。Rust書いたことないor気になっている人も見ていってね、そしてRustを書こう!

2. 概要

昨今、様々な媒体でWeb漫画が公開されています。自分も30作品以上追っていますが、更新通知がないので全部をもれなく追うのは大変です。例えばTwitterでバズったWeb漫画が自分に刺さっても、次の話も同じようにバズって自分のTLに流れてくるわけじゃないのでいつの間にか追跡を忘れていたりしますよね。そこで、登録した漫画の最新話更新状況を一括でチェックして、更新されていたら通知してくれるツールをRustで作ります。理由はRustは書いてて楽しいからです。

また、ドメイン駆動設計とオニオンアーキテクチャを実践します。ソースコード自体は大きくなく、個人開発ですが、あとから見たときの仕様の理解、改修の容易性、そして何より自身の学習のために利用します。

本記事ではまず簡単なツールのデモをして、その後いくつかの楽しかった部分をピックアップします。ツール自体の詳細の使い方や設計に関する説明はここでは行いませんが、リポジトリにドキュメントを残しているので、気になった方はそちらから見てみてください。

https://github.com/ose20/manga-update-notifier

3. ツールのデモ/使用イメージ

実際に動かす流れを見て、ツールのイメージを掴みます。

step1 サーバーモードで立ち上げて、漫画情報の追加/編集/削除ができるAPIサーバーを起動する

cargo make run-server

step2 追跡したい漫画の情報を登録

curl -v -X POST "http://localhost:8000/mangas" \
  -H "Content-Type: application/json" \
  -d @- << 'EOF'
{
  "title": "ふつうの軽音部",
  "shortTitle": "normal_keionbu",
  "publicUrl": "https://shonenjumpplus.com/episode/16457717013869519536",
  "crawlUrl": "https://shonenjumpplus.com/rss/series/14079602755590623793",
  "portalKind": "JumpPlus"
}
EOF

step3 バッチモードを実行して、最新話更新状況をチェックし、更新があれば通知してもらう

cargo make run-notifier

更新があると、以下のような感じで通知が来ます。漫画の登録はそんなに頻繁にするものではないので、基本的にはこのstep3のcargo make run-notifierを実行するだけでいいです。

通知がされる様子

より正確な利用方法は、リポジトリのREADME.mdに記載があります。

4. トピックつまみ食い

4.1. アーキテクチャ概要

リポジトリにもアーキテクチャに関するドキュメントを用意していますが。ここではそこから特に話したいところをピックアップします。

4.1.1. ツール全体の構成

本ツールは、以下の3つの部分から構成されています。

  • PostgreSQL(ローカルで起動するdockerコンテナ)
    • 追跡するべき漫画の情報(urlなど)を永続化するコンポーネント
    • いつまでも無料で手軽にできるというメリットに最大の加点をして、ローカルでdockerイメージ立ち上げて管理することにしました
  • Selenium(ローカルで起動するdockerコンテナ)
    • Webブラウザ操作ツール
    • RSSを提供しておらず、単純なHTTPクライアントライブラリで情報が取得できないサイトに対して必要になります
  • Rustの部分
    • 以下の2つのバイナリクレートがある
      • 漫画情報の管理をするサーバーを起動するサーバーモード
      • 登録された漫画の最新話を見に行って、更新されていたら通知するバッチモード

このうち上2つのコンポーネントはdocker composeで管理しています。

4.1.2. Rust部分のアーキテクチャ

オニオンアーキテクチャによるドメイン駆動設計を試みています。

オニオンアーキテクチャやドメイン駆動設計が何かについては、詳しくは本記事の最後で引用している参考文献などを見てみてください。ここでは簡単に説明すると、ドメイン駆動設計とは、アプリケーションが本質的に解決したい問題領域(ドメイン)をどっしりと主役に添えて、他の事情(例えば永続化をどうやるのか、どうやってアプリケーションの外側とデータをやり取りするのか)の影響を受けないようにし、こいつの持つ複雑性の扱いや将来的な変更の容易性により貢献しようという意図をもって打ち出される、責任境界の引き方とそれぞれのレイヤーへの分類に対する原則のことです。オニオンアーキテクチャとはドメイン駆動設計の規則を体現するための具体例の1つで、大雑把には、内側からドメイン層、アプリケーション層、UI層と構成し、外側から内側へしか依存関係が発生しないようにします。

アーキテクチャー図

レイヤーレベルでのアーキテクチャ図は以下のように作っています。より上に位置しているレイヤーからしか下に位置しているレイヤーに依存できないという制約があります。各レイヤーの責務はそれぞれ以下のようにしています。

  • domain層
    • ドメイン知識やドメインの不変条件を扱う
    • 今のところ集約は漫画(Manga)だけ
    • ドメイン知識
      • 漫画の最新話取得ロジックは、その漫画掲載されているポータルサイト(+URL)にのみ依存する
      • ポータルによってはRSSが提供されていたり、逆にされていないせいでseleniumを使わないといけないものがある
        • RSSが提供されているポータルの場合またその場合のみ、ユーザーが漫画を見るURLとクローラーが最新話情報を見に行くURLが異なる
  • application層
    • usecase層
      • 「漫画情報を作成したい」「最新話をチェックしたい」などのユースケースを記述する
      • コードの性質として一般に良いとされる高凝集/低結合を実現するための試みとして1ファイル1パブリックAPIでやってみている
    • port層
      • アプリケーションが外界とデータをやりとりする操作のうち、ドメイン知識が依存していない部分のインターフェース(trait)
  • infra層
    • domain層やapplication層で定義したインターフェースの実装部分
    • 「どうやって」の部分がこのレイヤーに隔離されることで、それを隔離した側が「何をするか」に集中できるようになる
  • registry層
    • DIコンテナ
      • まだあまり重くないのと、いまいちRustでのベストプラクティスを知らないので自力で管理している
    • やっていることの本質は、俗にいう依存性の逆転(実態は逆転というよりはインターフェースを間に挿入している)を実現するために、各インターフェースに、それに対応する実装を結びつけること
      • なのでインターフェース(trait)とその実装を持っているレイヤーへの依存が本質的に必要になる
  • server層
    • いわゆるUI層のようなもの
      • APIのインターフェース(エンドポイント、パスパラメータ、リクエストボディ)
    • APIサーバーとして起動するエントリポイントも含んでいる
  • batch層
    • 登録された漫画の最新話をチェックし、更新されていれば通知をするバッチを起動するためのエントリポイントを含んでいる
  • shared層
    • このアプリケーション特有のutilクレート的な立ち位置でどこからでも依存できる
    • あんまりここが膨らまないようにする
      • 他のレイヤーへの役割分担ができていないとここが肥大化してしまう

4.2. 自作WebDriverプール

RSSを提供していないポータルサイトから最新話情報を取得するために、ブラウザツールの自動化ツールであるseleniumを利用します。このseleniumはちょうどブラウザの1つのタブであるかのような直観に基づいて実装を進めます。今回は、このseleniumをdockerコンテナで4つ用意しています。つまり4つのブラウザタブを使えるわけです。

一方で、漫画の最新話をクロールするジョブは1つのタイトルにつき1つとするので、全部で数十のジョブでこの4つのseleniumを仲良く使わないといけません。

そこで、4つのseleniumの利用権を管理する機構が欲しくなり、下記のDriverPoolという構造体を定義しました(命名はDBなどの文脈でもよく使われるコネクションプールから)。DriverPoolは、4つのブラウザタブという資源を、利用者に重複なく貸出、また利用が済んだら他の人が使えるようにする機構を備えています。

use anyhow::Result;
use domain::manga::MangaEpisode;
use shared::config::WebdriverConfig;
use std::sync::Mutex;
use std::{collections::VecDeque, sync::Arc};
use thirtyfour::prelude::*;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};

use crate::fetcher::portal_kind::EpCrawler;

pub struct DriverPool {
    // まさにこいつが管理したい資源
    drivers: Mutex<VecDeque<WebDriver>>,
    // acquire で借用の permit ではなく OwnedPermit を獲得するために Arc
    // 貸出権利証の管理(同時にn枚まで)
    sem: Arc<Semaphore>,
}

pub struct Lease<'a> {
    // こいつが借りている WebDriver の持ち主
    pool: &'a DriverPool,
    // 借りている WebDriver
    driver: Option<WebDriver>,
    // driver とライフタイムを合わせるためにもつ
    // OwnedSemaphorePermit は Drop で自動解放される
    // WebDriver を借りている間だけ保持される貸出権利証
    _permit: OwnedSemaphorePermit,
}

impl Lease<'_> {
    pub fn driver(&self) -> &WebDriver {
        self.driver.as_ref().unwrap()
    }
}

impl Drop for Lease<'_> {
    // Lease がスコープを抜けるときに自動で返却する(RAII)
    fn drop(&mut self) {
        if let Some(drv) = self.driver.take() {
            // 同期 Mutex なので await 不要 → Drop 中でも安全に返せる
            let mut q = self.pool.drivers.lock().unwrap();
            q.push_back(drv);
        }
        // _permit は自動で解放
    }
}

impl DriverPool {
    pub async fn new(config: WebdriverConfig) -> WebDriverResult<Self> {
        assert!(
          !config.endpoints.is_empty(),
          "WebDriver endpoints must not be empty"
        );
        let mut q = VecDeque::with_capacity(config.endpoints.len());
        for ep in config.endpoints.iter() {
            q.push_back(WebDriver::new(ep.to_string(), config.chrome_caps.clone()).await?);
        }
        Ok(Self {
            drivers: Mutex::new(q),
            sem: Arc::new(Semaphore::new(config.endpoints.len())),
        })
    }

    pub async fn acquire(&self) -> Lease<'_> {
        // acquire_owned するために Arc が必要
        let permit = self.sem.clone().acquire_owned().await.unwrap();
        let drv = {
            let mut q = self.drivers.lock().unwrap();
            q.pop_front().expect("logic error: semaphore > pool")
        };
        Lease {
            pool: self,
            driver: Some(drv),
            _permit: permit,
        }
    }

    // 使い方:await で借りて、スコープを抜けると自動返却
    pub async fn with_driver<C>(&self, crawler: C) -> Result<MangaEpisode>
    where
        C: EpCrawler,
    {
        let lease = self.acquire().await;
        crawler.crawl(lease.driver()).await
    }
}

一つずつ説明します。

pub struct DriverPool {
    // まさにこいつが管理したい資源
    drivers: Mutex<VecDeque<WebDriver>>,
    // acquire で借用の permit ではなく OwnedPermit を獲得するために Arc
    // 貸出権利証の管理(同時にn枚まで)
    sem: Arc<Semaphore>,
}

コード内コメントで説明が尽きている感もありますが、DriverPoolは2つの要素から成り立ちます。

driversは、まさにDriverPoolが管理したい資源です。この資源を適切に利用者に渡します。

semはセマフォです。セマフォとは、資源が複数あるバージョンのロック機構です。「ロックされているかどうか」の2値ではなく、「あと何人がロックできるか」というより多様な様相を表現できます。今回は資源が複数あることからセマフォが必要になります。

pub struct Lease<'a> {
    // こいつが借りている WebDriver の持ち主
    pool: &'a DriverPool,
    // 借りている WebDriver
    driver: Option<WebDriver>,
    // driver とライフタイムを合わせるためにもつ
    // OwnedSemaphorePermit は Drop で自動解放される
    // WebDriver を借りている間だけ保持される貸出権利証
    _permit: OwnedSemaphorePermit,
}

次はLease<'a>です。これはDriverPoolから資源を獲得したときに利用者が受け取るものです。イメージとしては、受付(DriverPool)に資源(ブラウザタブ)の利用を申し出て、そこで得られた利用許可証のようなものでしょうか。この許可証には、貸主の情報(pool)と、実際に借りている資源(driver)が含まれます。

_permitは、直観的には受付(DriverPool)が資源が今何個手元に残っているかをちゃんと管理するために必要になるメタ情報です。より実装の文脈で説明すると、セマフォからの資源の獲得に成功したときに得られる値であり、これがスコープから外れるとセマフォはロックされていた資源が1つ返ってきたと解釈します。driverメンバーとこの_permitのライフタイムを合わせることで、「ブラウザタブを貸し出している時、またその時のみ、セマフォが管理している資源の数が1つ減っている」という状態を実現し、正しいロック機構を実現できます。

impl Drop for Lease<'_> {
    // Lease がスコープを抜けるときに自動で返却する(RAII)
    fn drop(&mut self) {
        if let Some(drv) = self.driver.take() {
            // 同期 Mutex なので await 不要 → Drop 中でも安全に返せる
            let mut q = self.pool.drivers.lock().unwrap();
            q.push_back(drv);
        }
        // _permit は自動で解放
    }
}

先ほど、正しいロック機構を実現するために、Lease<'a>構造体のdriverと_permitのライフタイムを合わせる必要があると述べました。それを実現しているのがこの部分です。_permitが解放されるタイミング、つまりLease<'a>の値がDropされるタイミングで、実際に貸主にdriverを返さないといけません。今回のdrop関数内では、実際にpool(貸主)要素からdrivers(それが管理している資源の入れ物)にアクセスして、自分が持っていたdriverの所有権を手放しています。

このやり方はRAIIという有名な格言の体現です。RAIIとはResource Acquisition Is Initializationの略です。ある値の生存期間と、それに基づくデータ、資源の生存期間を一致させるという原則であり、その名前からリソースの確保時に目が行きがちですが、解放時の所作に関する格言でもあることに注意が必要です。今回の例では、_permit: OwnedSemaphorePermitが解放される時と同時に、実際に借りているdriver: WebDriverをself.pool.driversに返却しています。また、これらはDropトレイトの定義上自動で行われるため、利用者がリソース管理に関して気にすべきことは一切ありません。

今回はRAIIの実践(特にリソース解放面)をDropトレイトによって実践していますが、dropメソッドは同期メソッドのため、DriverPoolのdriversメンバーは非同期ではなく同期のMutexを使っています。DriverPool全体を通してこの同期Mutexのロック取得期間は短く、このロック獲得中にawaitもないのでプログラムの正しさという観点では問題がないです。他の手段だと例えばdropメソッド内ではtokio::sync::mpsc::UnboundedSenderなどでキューに(OwnedSemaphorePermitと併せて)送信するだけにして置き、その解放ジョブを切り離すなどがあるでしょう(OwnedSemaphorePermitとdriverのライフタイムをそろえるため、この場合は前者の所有権もキューに移す必要があります)。

4.3. ドメイン知識:漫画ポータル

今回このツールが解決したいドメインに関する知識や制約について説明します。

まずは最新話取得方法についてです。これは、Web漫画が掲載される漫画のポータルサイトの種類に依存します。例えば『ふつうの軽音部』が掲載されているジャンプ+ではRSSが提供されているので、ここから最新のitemを取得すればよさそうです。ジャンプ+に限らず、RSSを提供しているサイトは同じようにできます。一方でRSSを提供していないポータルサイトはサイト特有のロジックで最新話情報を取得する必要があります。例えば『RTA走者はゲーム世界から帰れない』などが掲載されているカドコミでは、以下のように、「class属性の値がEpisodeThumbnail_title__という文字列を含む最初のdiv要素」を取得します。これらのロジックはポータルサイトごとに異なります。

RSSを提供している漫画ポータルの例

#[async_trait::async_trait]
impl EpCrawler for KadoComiEpCrawler {
    async fn crawl(&self, driver: &thirtyfour::WebDriver) -> Result<MangaEpisode> {
        driver.goto(self.command.crawl_url.as_str()).await?;

        let episode = driver
            .find(thirtyfour::By::Css(
                "div[class*='EpisodeThumbnail_title__']",
            ))
            .await?
            .text()
            .await?;

        Ok(MangaEpisode::new(episode)?)
    }
}

以上から、漫画の最新話情報を取得するためには、取得先のURLと、それがどこのポータルサイトに掲載されているかという2つがあれば十分です。

次に、漫画のURLについてです。漫画集約が持つべきURLは最大2つになります。1つはユーザーへの更新通知の際に提供するURLです。そしてもう1つは、RSSを提供しているページのURLです。ただしこれは、その漫画のポータルサイトがRSSを提供している場合、またその場合にのみ必要になります。この持つべきURLの種類が1つなのか2つなのかという情報はポータルサイトに依存している形なので、ドメインモデルのそのようにします。

// domain/manga/portal/mod.rs
pub enum MangaPortal {
    WebAce(SplitUrl),
    KimiComi(SplitUrl),
    KadoComi(SameUrl),
    Tonarino(SplitUrl),
    HerosWeb(SplitUrl),
    JumpPlus(SplitUrl),
    YoungMagazine(SameUrl),
    ComicDays(SplitUrl),
    ComicFuz(SameUrl),
    ComicZenon(SplitUrl),
    MangaOne(SameUrl),
}

impl MangaPortal {
    pub fn new(kind: PortalKind, crawl: Url, public: Url) -> Result<Self> {
        ...
    }
    ...
}

MangaPortalを作成するnewメソッドの引数のうち、crawlは最新話取得用のURL、publicはユーザー通知用のURLです。対応するヴァリアントによってこの2つが同じであるべきか、また異なっているべきかが異なるので、その制約をドメインモデルの生成時に検査することで、仕様を満たさないドメインモデルが生成できないようにします。

4.4. お手軽タスクランナー:cargo-make

本ツールの起動やCI/CD周りにはcargo-makeというタスクランナーを利用しています。

https://github.com/sagiegurari/cargo-make

DBコンテナで使う各種認証情報を環境変数として設定する作業や、ツール起動前のdockerコンテナの準備、利用後のコンテナの終了などを1つのタスクとして組み上げられるようになり、動作確認などが非常に楽になっています。 デフォルトではMakefile.tomlという名前の.tomlファイルでタスクを定義します。本ツールでは以下のように定義しています。

https://github.com/ose20/manga-update-notifier/blob/main/Makefile.toml

5. おわりに

最初に簡単にツールの概観を見せた後、いくつかのトピックをかい摘まんで要素技術を紹介しました。現在自分は30ほどのWeb漫画を追っていますが、更新を忘れることなくチェックできて助かっています。また、通知が自動で行われるので、ちょっと気になるくらいのレベルの作品も気軽に登録して続きを読めるようにもなりました。そしてそして、自分の生活をちょっと楽に楽しくしてくれるツールを、自分の大好きなRustで書けるというのは何物にも代えがたい喜びになります。今回のツール実装に関してRust部分で特別難しかったところはなく、むしろオールインワンですぐ快適に開発環境が整えられる初心者向けのエコシステムと、プログラマの直観、ドメインの制約を自然にエンコーディングできる現代的な言語機構のおかげでとてもスムーズに楽しく書くことができました。Rustはよいです。万人にお勧めしたいですが、特に、もし何かをするときにどの言語で実装するか悩んでいたら、ぜひ一考してみてください。

6. 参考文献

『RustによるWebアプリケーション開発 設計からリリース・運用まで』
Rustの入門には公式のThe Rust Programming Languageがあれば十分ですが、この本はRustでWebアプリケーションを作成するときにこれさえあれば(ひとまず)十分といえるほどの範囲とわかりやすさです。簡単なアーキテクチャ、テスト、モック、オブザーバビリティ、CI/CDと、Rustらしい書き方と、幅広いトピックを実践レベルまでカバーしていて大変ありがたい本です。

https://www.kodansha.co.jp/book/products/0000398182

『Async Rust ―高いパフォーマンスと安全性を両立するRustによる非同期処理』
ロック機構やチャネルなど、非同期プログラミングでよく使う道具をRustの文脈で再発明しながら非同期処理のエッセンスを伝えようとする本です。今回のWebDriverプール実装の直観を獲得するのに大いに役に立ちました。

https://www.oreilly.co.jp/books/9784814401185/

『並行プログラミング入門』
こちらはそこまで今回のツール作成には関係してなく、内容もちょっと難しいですが、すくなからず影響を受けていると信じて紹介します。いい本です。

https://www.oreilly.co.jp//books/9784873119595/

『ドメイン駆動設計 モデリング/実装ガイド』
DDDについて短い本にぎゅっと凝縮されていて非常に学習体験のよい本でした。章立ても粒度がそろっているかつ細かくて、情報の構造を頭に入れるのが容易な素晴らしい本です。

https://little-hands.booth.pm/items/1835632

『ドメイン駆動設計 サンプルコード&FAQ』
上述の本が大反響で来た大量の質問に答える形で続編が出ています。いわば大量の具体例なので、理解を深めるのにうってつけでした。

https://little-hands.booth.pm/items/3363104

『関数型ドメインモデリング』
アプリケーション側で行う処理の本質は「データの変換」であり、その変換の様子を(プラレールのような)線路の一部品に見立てるという直観が非常に美しいです。時に失敗したり、取りうるヴァリアントが違うことで線路は分岐する可能性がありますが、そういうのを現代的なプログラミング言語を使うとどう筋よく、認知コストを低く保ってあつかうことができるのかという説明が書いてあります。あとは代数的データ型のうち直和型の威力がいかんなく発揮されていて、他人に布教するときによく参考にします。

https://tatsu-zine.com/books/domain-modeling-made-functional

Discussion