0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rustで学術論文からテキスト抽出するクレートを実装するAdvent Calendar 2024

Day 21

Rustで学術論文からテキストを抽出する #21 応用編- arXiv論文収集システム構築⑨ Notion APIラッパーの実装 (1)

Last updated at Posted at 2024-12-20

Summary

  • NotionのAPIラッパーの実装方針を検討し,一部のAPIを実装しました
    • NotionはAPIが充実しており,ラッパーの実装も重厚になるため,arXivのチェインパターンとSemantic Scholarのエンドポイントパターンを組み合わせた実装方針を採用しました
    • GitHub Copilotがないと,もうコーディングできない身体になってしまった
crate GitHub
rsrpp rsrpp
rsrpp-cli rsrpp
arxiv-tools rs-arxiv-tools
ss-tools rs-ss-tools
keyword-tools keywords
openai-tools rs-openai-tools

前回までのあらすじ

前回:Rustで学術論文からテキストを抽出する #20

image.png

⑤論文要約まで一区切りついたので,今回から最後のパーツであるNotion周りを実装していきます.

今回実装する機能

今回から⑥のNotionへのアップロードを実装していきます.
NotionはAPIが充実していて,その分実装が重たいので,2回に分けて必要な機能を実装していこうと思います.

今回のシステムに必要な機能は,NotionのデータベースのCreate,Read,Update,Delete (CRUD) です.
また,Notionではデータベースの1レコードにページが紐づいており,さらにページは複数のブロックから成っているので,一部PagesやBlocksのCRUDも実装します.

Notionでは,arXivやSemantic Scholarと比較すると機能ごとのエンドポイントの数が多く,またやりとりするデータの構造も複雑です.

NotionのAPI Referenceはこちら
エンドポイントと今回の実装対象はこちら.

機能 Method Endpoint 実装要否
Create a token Post /oauth/token -
Append block children Patch /blocks/{block_id}/children
Retrieve a block Get /blocks/{block_id}
Retrieve block children Get /blocks/{block_id}/children
Update a block Patch /blocks/{block_id}
Delete a block Delete blocks/{block_id}
Create a page Post /pages
Retrieve a page Get /pages/{page_id}
Retrieve a page property item Get /pages/{page_id}/properties/{property_id}
Update page properties Patch /pages/{page_id}
Create a database Post /databases -
Query a database Post /databases/{database_id}/query
Retrieve a database Get /databases/{database_id}
Update a database Patch /databases/{database_id}
List all users Get /users -
Retrieve a user Get /users/{user_id} -
Retrieve your token's bot user Get /users/me -
Create comment Post /comments -
Retrieve comments Get /comments -
Search by title Post /search -

やはり,Blocks,Pages,Database周りは大体実装することになりそうです.
実装する機能が多いので,今回は大まかな方針の検討とDatabaseのQueryを例にとって,設計がうまく回りそうかどうか確認します.
次回で残りを実装し,動作確認を行います.

実装方針の検討

Notionでは,ページやブロックなどを構成する要素 (テキストやURL,チェックボックスなど) をAPIで割と細かく制御できるようになっています.
そのため,arXivのときに実装したようなシンプルなチェイン方式では鎖が長くなりすぎてしまい,一方Semantic Scholarのようにエンドポイントごとに実装をまとめようとすると,これまたクエリのための設定が複雑になりすぎてしまう可能性が高いです.

NGパターン① チェインタイプ
// 鎖が長くなりすぎて実装しづらい
let notion = Notion::new();
notion
    .checkbox()
    .equals(XXX)
    .and()
    .rich_text(XXXX)
    .is_empty()
    .or()
    ...
    ...
NGパターン② エンドポイントタイプ
// エンドポイントに全て突っ込むので,ネストが深くなりすぎで困る
let notion = Notion::new();
notion.query_database(
    DATABASE_ID,
    DatabaseFilter::new(
        CheckboxFilter::new(
            
        ),
        RichtextFilter::new(
            Text::new(
                ...
                ...
            )
        )
    )
)

そこで,今回はNotion APIラッパーの実装にあたっては,エンドポイントの数が多いことからチェインは向かないので,基本的にエンドポイント型を採用しつつ,PagesやBlocksなどNotionのパーツを切り分けることでモジュールを分割します.
よって,Queryの場合は検索条件になるFilterを別モジュールに分割し,コードの見通しをよくする戦略でいこうと思います.

完成系のイメージは以下のような感じ.

Notion APIラッパーのイメージ
// 検索条件を構築
let mut filter = DatabaseFilter::new();
filter.or(vec![
    CheckboxFilter::new(),
    DateFilter::new(),
    and(vec![
        SelectFilter::new(),
        RichTextFilter::new(),
    ])
]);
let filter = filter.build();

// 検索を実行
let notion = Notion::new();
let response = notion.query_database(
    DATABASE_ID,
    filter
);

理想的...とは言えませんが,多少マシになったかなと思っています.

Query a databaseの実装

では,試しにDatabaseをクエリするAPIを実装してみます.

API Referenceによれば,curlの場合は以下のようになります.

curl -X POST 'https://api.notion.com/v1/databases/897e5a76ae524b489fdfe71f5945d1af/query' \
  -H 'Authorization: Bearer '"$NOTION_API_KEY"'' \
  -H 'Notion-Version: 2022-06-28' \
  -H "Content-Type: application/json" \
--data '{
    "filter": {
        "or": [
            {
                "property": "In stock",
                "checkbox": {
                    "equals": true
                }
            },
            {
                "property": "Cost of next trip",
                "number": {
                    "greater_than_or_equal_to": 2
                }
            }
        ]
    },
    "sorts": [
        {
            "property": "Last ordered",
            "direction": "ascending"
        }
    ]
}'

URLやヘッダ部分に関しては特に問題になりそうなところはないので,bodyのJSON構築に集中すれば良さそうです.

まずは,クエリ本体.

pub struct Notion {
    pub api_key: String,
    pub database_id: String,
}

impl Notion {
    pub fn new() -> Self {
        dotenv().ok();
        let api_key = std::env::var("NOTION_API_KEY").expect("NOTION_API_KEY must be set");
        let database_id = std::env::var("NOTION_DATABASE_ID").unwrap_or("".to_string());

        Notion {
            api_key,
            database_id,
        }
    }

    pub async fn query_database(&self, filter: DatabaseFilter) -> Result<Vec<Record>> {
        let url = format!("https://api.notion.com/v1/databases/{}/query", self.database_id);
        let body = serde_json::to_string(&filter).unwrap()
        let client = request::Client::new();
        let content = client
            .post(&url)
            .header("Content-Type", "application/json")
            .header("Authorization", format!("Bearer {}", self.api_key))
            .header("Notion-Version", "2022-06-28")
            .body(body)
            .send()
            .await?
            .text()
            .await?;
        let response = serde_json::from_str::<Response>(&content).unwrap()
        return Ok(response.records);
    }
}

Filterの構築を外部に切り出しているので,ここはシンプルにまとまります.

さて,問題のフィルター部分の実装ですが,予想通り割と長いコードになりました.と言ってもCopilotのおかげでだいぶコーディング量は少なくて済みましたが.

Checkboxのサンプルだけ載せておくと,以下のようなコードになりました.

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CheckboxFilterItem {
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub equals: Option<bool>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub does_not_equal: Option<bool>,
}

impl CheckboxFilterItem {
    pub fn equals(&mut self) -> CheckboxFilterItem {
        self.equals = Some(true);
        self.clone()
    }

    pub fn does_not_equal(&mut self) -> CheckboxFilterItem {
        self.does_not_equal = Some(true);
        self.clone()
    }
}

次のように使う想定です.

// Checkboxのフィルター条件
let checkbox = CheckboxFilterItem::default().equals()

これを,フィルタのタイプ数分,14種類実装します.全体で700行くらいのコードになりました.GitHub Copilot様様です.

  • checkbox
  • date
  • files
  • formula
  • multi_select
  • number
  • people
  • phone_number
  • relation
  • rich_text
  • select
  • status
  • timestamp
  • ID

最後に,検索条件をまとめて扱うための構造体を定義して,Filterの実装は完了です.
検索条件は指定したものだけをJSONに落としたいので,基本的にOption<>つきで定義します.
最後に build()することで構築した検索条件をJSONのテキストに変換できる仕組みとしました.

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DatabaseFilterItems {
    #[serde(default = "String::new", skip_serializing_if = "String::is_empty")]
    pub property: String,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub and: Option<Vec<DatabaseFilterItems>>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub or: Option<Vec<DatabaseFilterItems>>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub checkbox: Option<CheckboxFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub date: Option<DateFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub files: Option<FilesFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub formula: Option<FormulaFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub multi_select: Option<MultiSelectFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub number: Option<NumberFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub people: Option<PeopleFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub relation: Option<RelationFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub rich_text: Option<RichTextFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub select: Option<SelectFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub status: Option<StatusFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub timestamp: Option<TimestampFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub id: Option<IdFilterItem>,
}

impl DatabaseFilterItems {
    pub fn and(items: Vec<DatabaseFilterItems>) -> Self {
        DatabaseFilterItems {
            and: Some(items),
            ..Default::default()
        }
    }

    pub fn or(items: Vec<DatabaseFilterItems>) -> Self {
        DatabaseFilterItems {
            or: Some(items),
            ..Default::default()
        }
    }

    pub fn checkbox(property: &str, item: CheckboxFilterItem) -> Self {
        DatabaseFilterItems {
            property: property.to_string(),
            checkbox: Some(item),
            ..Default::default()
        }
    }

    pub fn date(property: &str, item: DateFilterItem) -> Self {
        DatabaseFilterItems {
            property: property.to_string(),
            date: Some(item),
            ..Default::default()
        }
    }
    ...
    ...
}

pub struct DatabaseFilter {
    pub args: DatabaseFilterItems,
}

impl DatabaseFilter {
    pub fn new() -> Self {
        DatabaseFilter {
            args: DatabaseFilterItems::default(),
        }
    }

    pub fn and(&mut self, items: Vec<DatabaseFilterItems>) {
        self.args.and = Some(items);
    }

    pub fn or(&mut self, items: Vec<DatabaseFilterItems>) {
        self.args.or = Some(items);
    }

    pub fn build(&self) -> String {
        serde_json::to_string(&self.args).unwrap()
    }
}

APIが充実している分,なかなか重厚な作りになってしまいましたが,今後RustでNotionを操作するときには役に立つクレートになりそうな予感がします.

ということで,実装方針はこちらでなんとかなりそうなので,次回まとめて残りのAPIを実装し,動作確認を行います.

次回

Notion APIのラッパーを完成させます.

次回:Rustで学術論文からテキストを抽出する #22

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?