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

:::note
## Summary
- Semantic ScholarのAPIラッパーを実装しました
    - まずは論文のID検索と論文の詳細情報を取得できるAPIを実装
    - リファレンスや引用論文，著者情報などに関するAPIは後日実装

### Crates & Repositories
| crate | GitHub |
|:-----:|:------:|
|[rsrpp](https://crates.io/crates/rsrpp)|[rsrpp](https://github.com/akitenkrad/rsrpp)
|[rsrpp-cli](https://crates.io/crates/rsrpp-cli)|[rsrpp](https://github.com/akitenkrad/rsrpp)
|[arxiv-tools](https://crates.io/crates/arxiv-tools)|[rs-arxiv-tools](https://github.com/akitenkrad/rs-arxiv-tools)|
|[ss-tools](https://crates.io/crates/ss-tools)|[rs-ss-tools](https://github.com/akitenkrad/rs-ss-tools)|
:::

## 前回までのあらすじ

[前回：Rustで学術論文からテキストを抽出する #14](https://qiita.com/akitenkrad/items/14c68808b6cce6c9152e)

前回はarXivのAPIラッパーを実装して，日次で最新の論文情報を取得できるようにしました．
今回はさらに，[Semantic Schola](https://www.semanticscholar.org/)から論文の情報を取得できるようにします．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933655/2ec6a96f-1778-657c-9e0a-0a7afcba65b6.png)

## なぜSemantic Scholarが必要か？

![スクリーンショット 2024-12-14 22.54.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933655/06076810-65ba-9a54-99b7-e1ca4ec2e9cc.png)

arXivで論文の情報は取れているはずなのに，なぜ追加でSemantic Scholarが必要なのか？
その理由は．Semantic ScholarがarXivにはない重要な情報をいくつか提供してくれているからです．

具体的には，以下が欲しい．

- ある論文を引用している論文のメタ情報
- ある論文が参照している論文のメタ情報
- 著者の情報 (特にh-indexとか)
- (Influential Citation Count)

論文の情報を分析するときには，論文のテキストだけではなく，その論文がどんな論部を引用しているか，またどんな論文に引用されているかというCitation Networkの情報が非常に重要です．
毎日上がってくる玉石混交な論文の中から重要な論文を選ぶだけであればここまでは必要ないのですが，特定の領域について深く探究したい場合には，その領域全体の論文の配置がどのようになっているのかを知りたいです．

Citation Networkはそのためにうってつけの情報源なので，「玉」の論文を仕分けた後のNext Actionに必要な情報として，Semantic Scholarから情報検索します．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933655/98cca001-6ee7-e7f9-0162-b0c99c455696.png)

著者情報やInfluential Citation Countはあると便利な参考情報です．

## 実装すること

大きな構造は前回の`arixv-tools`と同じです．
今回はAPIのエンドポイントが複数あることと，APIキーを使用することができるという点が異なります．

Semantic Scholar APIの使い方は[こちらのチュートリアル](https://www.semanticscholar.org/product/api/tutorial)を参照すればOK．
ただし，APIラッパーの実装にあたってはAPIの仕様が必要なので，それは[こちら](https://api.semanticscholar.org/api-docs/#tag/Paper-Data)を参照します．

Semantic Scholarには用途によって幾つかのエンドポイントが用意されています．

| EndPoint | 用途 | Method |
|:-|:-|:-:|
| /paper/autocomplete | Suggest paper query completions  | GET |
| /paper/batch  | Get details for multiple papers at once | POST |
| /paper/search | Paper relevance search | GET |
| /paper/search/bulk | Ppaer bulk search | GET |
| /paper/search/match | Paper title search | GET |
| /paper/{paper_id} | Details about a paper | GET |
| /paper/{paper_id}/authors | Details about a paper's authors | GET |
| /paper/{paper_id}/citations | Details about a paper's citations | GET |
| /paper/{paper_id}/references | Details about a paper's references | GET |
| /author/batch | Get details for multiple authors at once | POST |
| /author/search | Search for authors by name | GET |
| /author/{author_id} | Details about an author | GET |
| /author/{author_id}/papers | Details about an author's papers | GET |

今回主に使用するのは，`/paper/search/match` および `/paper/{paper_id}` の2つです．
前者は論文のタイトルからSemantic Scholarの論文IDを検索するために使用します．
検索した論文IDを使用して，後者のAPIで論文の詳細情報を取得します．

[第13回](https://qiita.com/akitenkrad/items/73f5ca3ebe5e9685832d) で少し触れましたが，この`/paper/search/match` APIはクエリに与えた文字列と関連が深い論文をスコア順に複数出力します．そのため，返ってきた論文のリストの中から探しているタイトルの論文を抽出する必要があります．
一応，SemanticScholarの出力でも関連度のスコアを出してくれてはいるのですが，稀に全く異なるタイトルのスコアが高くなることがあるので，きちんとタイトルを使って判別した方が安全です．

今回は，まずはAPIのラッパーを実装することに注力するのでとりあえずリストの先頭の論文を返すようにしていますが，どこかでタイトルの類似度を計算する機能を追加しようと思います．

今回のAPIの完成系イメージはこちら．arXivのときとは少しUIが異なりますが，実装の規模は同程度になるように作ります．

```rust
let query_text = "attention is all you need";

let mut ss = SemanticScholar::new();
let paper_id = ss.query_paper_id(query_text.to_string()).await;
let mut ss = SemanticScholar::new();
let fields = vec![
    SsField::Title,
    SsField::Abstract,
];

let paper_details: SsResponse = ss.query_paper_details(paper_id.to_string(), fields).await;
```

## rs-ss-tools → [クレート](https://crates.io/crates/ss-tools) / [GitHub](https://github.com/akitenkrad/rs-ss-tools)

中心になる構造体はarXivのときと似た構造になっています．

```rust
pub struct SemanticScholar {
    pub api_key: String,
    pub base_url: String,
    pub endpoint: SsEndpoint,
    pub query_text: String,
    pub fields: Vec<SsField>,
}
```

ベースのURLは`https://api.semanticscholar.org/graph/v1/`となっており，これは固定です．
arXivのときと大きく異なるのは，`api_key`と`endpoint`がある点です．

Semantich Scholarでは，高速なレスポンスが必要なユーザー向けにAPIキーを発行しています．
https://www.semanticscholar.org/product/api

API自体はAPIキーがなくても使えるので，`ss-tools`もAPIキーがあればオプションで読み込むようにします．

APIキーを使用するには，`reqwest`を使ってリクエストのヘッダーに`x-api-key`を追加します．

```rust
let mut headers = header::HeaderMap::new();
if !self.api_key.is_empty() {          
    headers.insert("x-api-key", self.api_key.parse().unwrap());
}
let client = request::Client::builder()
    .default_headers(headers)
    .build()
    .unwrap();

let url = self.build();
let body = client.get(url).send().await.unwrap().text().await.unwrap();
```

なお，APIキー自体は環境変数から取得するようにしています．

arXivでは，クエリの設定を関数でチェインするように作っていましたが，SemanticScholarではエンドポイントが異なり，かつ設定する項目はほとんど`fields`なので，チェイン形式ではなくエンドポイントごとに関数を作る仕様にしました．

```rust
impl SemanticScholar {
    pub fn new() -> Self {
        dotenv().ok();
        let vars = FxHashMap::from_iter(std::env::vars().into_iter().map(|(k, v)| (k, v)));
        let api_key = vars
            .get("SEMANTIC_SCHOLAR_API_KEY")
            .unwrap_or(&"".to_string())
            .to_string();
        Self {
            api_key: api_key,
            base_url: "https://api.semanticscholar.org/graph/v1/".to_string(),
            endpoint: SsEndpoint::GetPaperTitle,
            query_text: "".to_string(),
            fields: vec![],
        }
    }

    fn build(&self) -> String {
        match &self.endpoint {
            SsEndpoint::GetPaperTitle => {
                let url = format!(
                    "{}paper/search/match?query={}",
                    self.base_url, self.query_text
                );
                return url;
            }
            SsEndpoint::GetPaperDetails => {
                let fields = self
                    .fields
                    .iter()
                    .map(|field| field.to_string())
                    .collect::<Vec<String>>()
                    .join(",");
                let url = format!(
                    "{}paper/{}?fields={}",
                    self.base_url, self.query_text, fields
                );
                return url;
            }
            SsEndpoint::GetAuthorDetails => {
                let fields = self
                    .fields
                    .iter()
                    .map(|field| field.to_string())
                    .collect::<Vec<String>>()
                    .join(",");
                let url = format!(
                    "{}author/{}?fields={}",
                    self.base_url, self.query_text, fields
                );
                return url;
            }
            SsEndpoint::GetReferencesOfAPaper(paper_id) => {
                // TODO: Implement
                return "".to_string();
            }
            SsEndpoint::GetCitationsOfAPaper(paper_id) => {
                // TODO: Implement
                return "".to_string();
            }
        }
    }
    pub async fn query_paper_id(&mut self, query_text: String) -> String {
        self.query_text = query_text;
        self.endpoint = SsEndpoint::GetPaperTitle;

        let mut headers = header::HeaderMap::new();
        if !self.api_key.is_empty() {
            headers.insert("x-api-key", self.api_key.parse().unwrap());
        }
        let client = request::Client::builder()
            .default_headers(headers)
            .build()
            .unwrap();

        let url = self.build();
        let body = client.get(url).send().await.unwrap().text().await.unwrap();
        let response = serde_json::from_str::<SsResponsePpaerIds>(&body).unwrap();

        // TODO: rerank paper ids based on the similarity of the query text
        let paper_id = response.data[0].paper_id.clone();

        return paper_id.unwrap();
    }

    pub async fn query_paper_details(
        &mut self,
        paper_id: String,
        fields: Vec<SsField>,
    ) -> SsResponse {
        self.query_text = paper_id;
        self.fields = fields.clone();
        self.endpoint = SsEndpoint::GetPaperDetails;

        if !fields.contains(&SsField::PaperId) {
            self.fields.push(SsField::PaperId);
        }

        let mut headers = header::HeaderMap::new();
        if !self.api_key.is_empty() {
            headers.insert("x-api-key", self.api_key.parse().unwrap());
        }
        let client = request::Client::builder()
            .default_headers(headers)
            .build()
            .unwrap();

        let url = self.build();
        let body = client.get(url).send().await.unwrap().text().await.unwrap();
        println!("{}", body);
        let response = serde_json::from_str::<SsResponse>(&body).unwrap();

        return response;
    }
}
```

Semantic Scholarでは，論文に関する欲しい情報を`fields`というパラメータで指定する仕様になっています．
そのため，これを簡単に扱える構造体があると便利です．　
`enum`で実装してみました．
`Authors`など一部のフィールドはさらに詳細を設定することができるようになっているので，それぞれの詳細パラメータは別の`enum`に定義しています．

```rust
#[derive(Debug, Clone, PartialEq)]
pub enum SsField {
    PaperId,
    Corpusid,
    Url,
    Title,
    Abstract,
    Venue,
    PublicationVenue,
    Year,
    ReferenceCount,
    CitationCount,
    InfluentialCitationCount,
    IsOpenAccess,
    OpenAccessPdf,
    FieldsOfStudy,
    S2FieldsOfStudy,
    PublicationTypes,
    PublicationDate,
    Journal,
    CitationStyles,
    Authors(Vec<SsAuthorField>),
    Citations(Vec<SsField>),
    References(Vec<SsField>),
    Embedding,
}
```

この`SsField`をリストで渡すイメージなのですが，最終的な`fields`のパラメータは`fields=tilte,abstract`のようにカンマ区切りになるので，これも簡単に扱えるように専用の関数を用意しておきます．

```rust
impl SsField {
    pub fn to_string(&self) -> String {
        match self {
            SsField::PaperId => "paperId".to_string(),
            SsField::Corpusid => "corpusId".to_string(),
            SsField::Url => "url".to_string(),
            SsField::Title => "title".to_string(),
            ...
        }
    }
}
```

これで，クエリパラメータを組み立てるときには，以下のように`SsField`のリストをイテレートするだけで済むようになります．

```rust
let fields = self
    .fields
    .iter()
    .map(|field| field.to_string())
    .collect::<Vec<String>>()
    .join(",");
```

最後に，APIのレスポンスをパースする構造体を定義します．
こちらもarXivと同じ方針ですが，SemanticScholarのレスポンスはJSONなので，そのまま構造体にデシリアライズします．
面倒なXMLのパースは必要ないので，適切に構造体を設計してあげるだけでパースは簡単に処理できます．
ただし，Semantic ScholarのAPIはクエリパラメータに含まれていないフィールドは返してくれないので，ほとんどの項目は`Option<T>`になります．arXivのときは使用しませんでしたが，`default_value`という関数を定義しておいて，レスポンスのJSONにフィールドが存在しなかった場合に`None`を返すようにしています．

```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SsResponse {
    #[serde(rename = "paperId", default = "default_value")]
    pub paper_id: Option<String>,
    #[serde(rename = "corpusId", default = "default_value")]
    pub corpus_id: Option<u32>,
    #[serde(default = "default_value")]
    pub url: Option<String>,
    #[serde(default = "default_value")]
    pub title: Option<String>,
    #[serde(rename = "abstract", default = "default_value")]
    pub abstract_text: Option<String>,
    #[serde(default = "default_value")]
    pub venue: Option<String>,
    #[serde(rename = "publicationVenue", default = "default_value")]
    pub publication_venue: Option<SsResponsePublicationVenue>,
    #[serde(default = "default_value")]
    pub year: Option<u32>,
    #[serde(rename = "referenceCount", default = "default_value")]
    pub reference_count: Option<u32>,
    #[serde(rename = "citationCount", default = "default_value")]
    pub citation_count: Option<u32>,
    #[serde(rename = "influentialCitationCount", default = "default_value")]
    pub influential_citation_count: Option<u32>,
    #[serde(rename = "isOpenAccess", default = "default_value")]
    pub is_open_access: Option<bool>,
    #[serde(rename = "openAccessPdf", default = "default_value")]
    pub open_access_pdf: Option<SsResponseOpenAccessPdf>,
    #[serde(rename = "fieldsOfStudy", default = "default_value")]
    pub fields_of_study: Option<Vec<String>>,
    #[serde(rename = "s2FieldsOfStudy", default = "default_value")]
    pub s2_fields_of_study: Option<Vec<SsResponseS2FieldsOfStudy>>,
    #[serde(rename = "publicationTypes", default = "default_value")]
    pub publication_types: Option<Vec<String>>,
    #[serde(rename = "publicationDate", default = "default_value")]
    pub publication_date: Option<String>,
    #[serde(default = "default_value")]
    pub journal: Option<SsResponseJournal>,
    #[serde(rename = "citationStyles", default = "default_value")]
    pub citation_styles: Option<SsResponseCitationStyles>,
    #[serde(default = "default_value")]
    pub authors: Option<Vec<SsResponseAuthor>>,
    #[serde(default = "default_value")]
    pub citations: Option<Vec<SsResponse>>,
    #[serde(default = "default_value")]
    pub references: Option<Vec<SsResponse>>,
    #[serde(default = "default_value")]
    pub embedding: Option<SsResponseEmbedding>,
    #[serde(rename = "matchScore", default = "default_value")]
    pub match_score: Option<f64>,
}
```

テスト結果がこちら．

```rust
et query_text = "attention is all you need";

let mut ss = SemanticScholar::new();
let paper_id = ss.query_paper_id(query_text.to_string()).await;

let mut ss = SemanticScholar::new();
let fields = vec![
    SsField::Title,
    SsField::Abstract,
    SsField::Authors(vec![
        SsAuthorField::Name,
        SsAuthorField::Affiliations,
        SsAuthorField::HIndex,
    ]),
    SsField::CitationCount,
    SsField::ReferenceCount,
    SsField::Year,
    SsField::IsOpenAccess,
    SsField::PublicationDate,
    SsField::Venue,
    SsField::FieldsOfStudy,
    SsField::Citations(vec![SsField::Title, SsField::Year, SsField::CitationCount]),
    SsField::References(vec![SsField::Title, SsField::Year, SsField::CitationCount]),
    SsField::Journal,
    SsField::PublicationVenue,
    SsField::OpenAccessPdf,
    SsField::S2FieldsOfStudy,
    SsField::PublicationTypes,
    SsField::CitationStyles,
    SsField::Embedding,
];

let paper_details: SsResponse = ss.query_paper_details(paper_id.to_string(), fields).await;
```

```json
{
  "paperId": "204e3073870fae3d05bcbc2f6a8e263d9b72e776",
  "corpusId": null,
  "url": null,
  "title": "Attention is All you Need",
  "abstract": "The dominant sequence transduction models are based on complex recurrent or convolutional neural networks in an encoder-decoder configuration. The best performing models also connect the encoder and decoder through an attention mechanism. We propose a new simple network architecture, the Transformer, based solely on attention mechanisms, dispensing with recurrence and convolutions entirely. Experiments on two machine translation tasks show these models to be superior in quality while being more parallelizable and requiring significantly less time to train. Our model achieves 28.4 BLEU on the WMT 2014 English-to-German translation task, improving over the existing best results, including ensembles by over 2 BLEU. On the WMT 2014 English-to-French translation task, our model establishes a new single-model state-of-the-art BLEU score of 41.8 after training for 3.5 days on eight GPUs, a small fraction of the training costs of the best models from the literature. We show that the Transformer generalizes well to other tasks by applying it successfully to English constituency parsing both with large and limited training data.",
  "venue": "Neural Information Processing Systems",
  "publicationVenue": {
    "id": "d9720b90-d60b-48bc-9df8-87a30b9a60dd",
    "name": "Neural Information Processing Systems",
    "type": "conference",
    "url": "http://neurips.cc/",
    "alternate_names": [
      "Neural Inf Process Syst",
      "NeurIPS",
      "NIPS"
    ]
  },
  "year": 2017,
  "referenceCount": 41,
  "citationCount": 111800,
  "influentialCitationCount": null,
  "isOpenAccess": false,
  "openAccessPdf": null,
  "fieldsOfStudy": [
    "Computer Science"
  ],
  "s2FieldsOfStudy": [
    {
      "category": "Computer Science",
      "source": "external"
    },
    {
      "category": "Computer Science",
      "source": "s2-fos-model"
    }
  ],
  "publicationTypes": [
    "JournalArticle",
    "Conference"
  ],
  "publicationDate": "2017-06-12",
  "journal": {
    "volume": null,
    "pages": "5998-6008",
    "name": null
  },
  "citationStyles": {
    "bibtex": "@Article{Vaswani2017AttentionIA,\n author = {Ashish Vaswani and Noam M. Shazeer and Niki Parmar and Jakob Uszkoreit and Llion Jones and Aidan N. Gomez and Lukasz Kaiser and Illia Polosukhin},\n booktitle = {Neural Information Processing Systems},\n pages = {5998-6008},\n title = {Attention is All you Need},\n year = {2017}\n}\n"
  },
...
```

毎度お世話になっている"Attention Is All You Need"ですが，欲しい情報は大体得られているようでした．

これで，arXivで論文のリストを取得し，SemanticScholarで引用情報などを補強して論文情報を整備することができるようになりました．

## 次回

次回はSemanticScholarのAPI実装で残っている課題を片付けます．

[次回：Rustで学術論文からテキストを抽出する #16](https://qiita.com/akitenkrad/items/7057383260f1c560f18d)