dratools 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48f8f5324602da01372e8590c74a22ae9a3474b125c96c3d2bf8de501e80160a
4
- data.tar.gz: 9980db4882cefe19b2143496262ec785a6994e04d3c75ac6edc23deb0607f226
3
+ metadata.gz: 2890dc54bae2a8fe0c715daa8c778794d20292714d5d13d658ec538a0e95bb92
4
+ data.tar.gz: 409192a8111f7152f33c8ea586ac6db9cea775a1643151d70c4e5adce288ff66
5
5
  SHA512:
6
- metadata.gz: 687ca0459f6bead0d1ca7d4349fcb023b1c142b05e4b2e4e1eda461b54a42209a3ac7cec08d8552522921c691da38ffe89d367270859dd15cefc17af9bf99210
7
- data.tar.gz: e2ea8839bcb20737af0880e20694d266c0fc1ee9ab0a2f77e6b027fbe4750b09bba2e9f42ae839a4492d94dbb6f22ed4118ff3ffb72ebe206bd2689ce9e6d795
6
+ metadata.gz: 17f69d78de301843fb5d43b53fa800528f6404d5e8974f9764e875c2e7489e0691e4b51a10e74638c3d353d7eab2ed9df52e12b12bd3158f42bf36a02a21738e
7
+ data.tar.gz: c49a56c178b3f55be5b980ad3c7ebd0d4d76df33c55a160b943148206380c1853a8b40ffa790529ee0893f0d8ed6f7b359ac30fbe0ee0ddd5e9535f155ac0728
data/README.md CHANGED
@@ -1,7 +1,9 @@
1
1
  # dratools
2
2
 
3
3
  [![CI](https://github.com/kojix2/dratools/actions/workflows/ci.yml/badge.svg)](https://github.com/kojix2/dratools/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/dratools.svg)](https://badge.fury.io/rb/dratools)
4
5
  [![Lines of Code](https://img.shields.io/endpoint?url=https%3A%2F%2Ftokei.kojix2.net%2Fbadge%2Fgithub%2Fkojix2%2Fdratools%2Flines)](https://tokei.kojix2.net/github/kojix2/dratools)
6
+ [![DOI](https://zenodo.org/badge/1281844096.svg)](https://doi.org/10.5281/zenodo.20967539)
5
7
 
6
8
  [dratools](https://github.com/kojix2/dratools) は、日本国内の [DDBJ](https://www.ddbj.nig.ac.jp) からゲノムデータをダウンロードするためのツールです。
7
9
 
@@ -13,7 +15,7 @@ dratools は非公式のツールです。DDBJ や国立遺伝学研究所が提
13
15
 
14
16
  必要なものは次のとおりです。
15
17
 
16
- - [ruby](https://www.ruby-lang.org/)
18
+ - [ruby](https://www.ruby-lang.org/) 3.0 以上
17
19
  - [curl](https://curl.se/) または [wget](https://www.gnu.org/software/wget/)
18
20
 
19
21
  Rubyのgemとしてインストールできます。
@@ -80,7 +82,7 @@ printf 'DRR000001\nDRR000002\n' | dratools get -O ~/Downloads
80
82
  dratools probe DRR000001 # URL の到達性だけ確認する
81
83
  dratools url --json DRR000001 # URL 情報を JSON で表示する
82
84
  dratools url --tsv DRR000001 # run/type/url/size/md5 を TAB 区切りで
83
- dratools meta --json DRR000001 # resource JSON を表示する
85
+ dratools meta --json DRR000001 # entry JSON を表示する
84
86
  dratools runs PRJNA341783 | dratools get -O ~/Downloads # run 一覧をダウンロードへ渡す
85
87
  dratools size --bytes PRJNA341783 # 合計サイズをバイト数で表示する
86
88
  dratools size --per-run DRX000001 # 親 accession を run ごとに集計する
@@ -89,7 +91,7 @@ dratools get --skip-existing -O ~/Downloads DRR000001 # 既存ファイルは
89
91
 
90
92
  詳しくは [使い方](docs/usage.md) をご覧ください。親 accession を扱うときの設定は [環境変数](docs/environment.md) にまとめています。
91
93
 
92
- 実際のファイル転送には `curl` または `wget` を使います。ダウンロード中は進捗表示を端末にそのまま流します。md5 が得られる場合だけ、ダウンロード後に照合します。
94
+ 実際のファイル転送には通常 `curl` または `wget` を使います。`aria2c` も環境変数で明示した場合だけ使えます。ダウンロード中は進捗表示を端末にそのまま流します。同名のファイルが既にある場合は、サーバのファイルサイズと比べて再取得の要否を判断します。md5 が得られる場合は md5 で照合します。
93
95
 
94
96
  ツールは全てコーディングエージェントによって実装されました。
95
97
 
@@ -102,11 +104,9 @@ dratools get --skip-existing -O ~/Downloads DRR000001 # 既存ファイルは
102
104
 
103
105
  ## お役立ちノート
104
106
 
105
- 海外からゲノムデータをダウンロードするのは大変な作業です。
106
- 日本国内のサーバーからゲノムデータをダウンロードすると作業が楽になります。
107
+ 海外のサーバーからゲノムデータをダウンロードするのは大変です。日本国内のサーバーを使うと作業が楽になります。
107
108
 
108
- なるべく手間をかけずに、寝ている間にデータをダウンロードしたい方は「NASに課金する」という手段もあります。
109
- NASの付属ソフトにURLを入力すると、何もしなくても自動でダウンロードが進むのでストレスが少ないです。
109
+ 手間をかけずにダウンロードする方法として、NAS を使う方法があります。まず `dratools url` で URL の一覧を出します。次に NAS の付属ソフトの GUI 画面に、その URL をまとめて貼り付けます。あとは放置します。ダウンロードは自動で進みます。
110
110
 
111
111
  ## 開発
112
112
 
data/docs/design.md CHANGED
@@ -12,24 +12,33 @@ accession から URL までの流れは次のとおりです。
12
12
 
13
13
  ```text
14
14
  accession
15
- -> DDBJ Search resource JSON
15
+ -> DDBJ Search entry JSON (/search/api/entries/{type}/{id}.json)
16
16
  -> sra-run record
17
- -> downloadUrl
17
+ -> distribution
18
18
  -> https / ftp URL
19
19
  ```
20
20
 
21
+ `/resource/{type}/{id}.json` は DDBJ Search の後方互換入口です。現在は
22
+ `/search/entry/{type}/{id}.json` へリダイレクトされ、さらに nginx で
23
+ `/search/api/entries/{type}/{id}.json` へ rewrite されます。dratools は
24
+ リダイレクトを避けるため、正規の `/search/api/entries` endpoint を直接使います。
25
+
26
+ run 一覧だけが必要な場合は DBLinks endpoint を使います。多数の run record が必要な
27
+ 場合は Bulk endpoint を使います。どちらも同じ DDBJ Search API サーバーの機能です。
28
+
21
29
  ## ダウンロード確認
22
30
 
23
31
  ゲノムデータはサイズが大きいです。`probe` は完全なダウンロードを行いません。
24
32
 
25
33
  - `curl` がある場合: `--range 0-0` と `--max-time` を使う
26
34
  - `wget` がある場合: `--spider` と `--timeout` を使う
35
+ - `aria2c` がある場合: `--dry-run=true` と `--timeout` を使う
27
36
 
28
37
  これは URL が使えるかどうかを短時間で確認するためです。完全な整合性の確認ではありません。
29
38
 
30
39
  ## サイズ確認
31
40
 
32
- `size` は resource JSON のサイズ情報を使いません。実レコードにはサイズや md5 が含まれないことが多いためです。代わりに、解決した URL に HTTP `HEAD` を送ります。`Content-Length` を合計します。
41
+ `size` は entry JSON のサイズ情報を使いません。実レコードにはサイズや md5 が含まれないことが多いためです。代わりに、解決した URL に HTTP `HEAD` を送ります。`Content-Length` を合計します。
33
42
 
34
43
  FASTQ はディレクトリ URL で返ることがあります。その場合はディレクトリ一覧を取得します。`*.fastq*` のリンクを取り出します。各ファイルに `HEAD` を送ります。取得できないものは失敗にしません。`unresolved` として数えます。
35
44
 
@@ -37,12 +46,17 @@ FASTQ はディレクトリ URL で返ることがあります。その場合は
37
46
 
38
47
  ## 実ダウンロード
39
48
 
40
- 実ダウンロードでは総時間の上限を設けません。数十 GB のファイルでは長時間かかるためです。代わりに、接続のタイムアウトと失速検知を `curl` / `wget` に渡します。
49
+ 実ダウンロードでは総時間の上限を設けません。数十 GB のファイルでは長時間かかるためです。代わりに、接続のタイムアウトと失速検知を `curl` / `wget` / `aria2c` に渡します。
41
50
 
42
51
  - `curl`: `--connect-timeout`, `--speed-limit`, `--speed-time`, `--retry`
43
52
  - `wget`: `--connect-timeout`, `--read-timeout`, `--tries`, `--waitretry`
53
+ - `aria2c`: `--connect-timeout`, `--timeout`, `--lowest-speed-limit`, `--max-tries`, `--retry-wait`
54
+
55
+ `aria2c` は分割ダウンロードができますが、既定では `--split=1` と `--max-connection-per-server=1` で単一接続にします。公共アーカイブへの負荷を既定で増やさないためです。
56
+
57
+ `DRATOOLS_DOWNLOAD_RETRY_COUNT` はリトライ回数として扱います。`curl --retry` はリトライ回数ですが、`wget --tries` と `aria2c --max-tries` は総試行回数なので、外部コマンドには `DRATOOLS_DOWNLOAD_RETRY_COUNT + 1` を渡します。
44
58
 
45
- ダウンロードは `system(*command)` で実行します。`Open3.capture3` は使いません。`curl` / `wget` の進捗を端末にそのまま表示するためです。失敗した場合は、コマンド行と終了ステータスを `CommandError` にします。
59
+ ダウンロードは `system(*command)` で実行します。`Open3.capture3` は使いません。外部コマンドの進捗を端末にそのまま表示するためです。失敗した場合は、コマンド行と終了ステータスを `CommandError` にします。
46
60
 
47
61
  ## チェックサムと既存ファイル
48
62
 
@@ -53,7 +67,7 @@ md5 が得られる候補では、ダウンロード後に `Digest::MD5.file`
53
67
  1. `--force` があれば既存ファイルを使わず再取得する
54
68
  2. `--skip-existing` があれば md5 を見ずに既存ファイルを使う
55
69
  3. md5 があり、既存ファイルの md5 が一致すれば再取得せず `Skipped` にする
56
- 4. それ以外は `curl --continue-at -` または `wget --continue` でレジュームを試みる
70
+ 4. それ以外は `curl --continue-at -`, `wget --continue`, `aria2c --continue=true` でレジュームを試みる
57
71
 
58
72
  md5 が無い候補では、既定では既存ファイルをスキップしません。SRA ファイルとしての検証は dratools の既定動作には含めません。
59
73
 
@@ -69,7 +83,7 @@ Ruby 側の依存は増やしません。次の標準ライブラリを使いま
69
83
  - `digest/md5`
70
84
  - `minitest`
71
85
 
72
- 実ファイルの転送だけは外部コマンドに任せます。環境にある `curl` または `wget` を使います。
86
+ 実ファイルの転送だけは外部コマンドに任せます。自動選択では環境にある `curl` または `wget` を使います。`aria2c` は `DRATOOLS_DOWNLOAD_COMMAND=aria2c` で明示指定された場合だけ使います。
73
87
 
74
88
  ## 今後の候補
75
89
 
data/docs/environment.md CHANGED
@@ -15,15 +15,20 @@
15
15
 
16
16
  ## ダウンロード開始と失速検知
17
17
 
18
- `get` は大きいファイルを扱います。このため、総ダウンロード時間の上限を設けません。代わりに、接続タイムアウト、失速検知、リトライの設定を `curl` / `wget` に渡します。
18
+ `get` は大きいファイルを扱います。このため、総ダウンロード時間の上限を設けません。代わりに、接続タイムアウト、失速検知、リトライの設定を `curl` / `wget` / `aria2c` に渡します。
19
19
 
20
20
  | 環境変数 | 既定値 | 役割 |
21
21
  | --- | ---: | --- |
22
22
  | `DRATOOLS_DOWNLOAD_CONNECT_TIMEOUT` | `30` | 接続確立のタイムアウト秒数 |
23
23
  | `DRATOOLS_DOWNLOAD_STALL_TIMEOUT` | `60` | この秒数のあいだ転送速度が閾値を下回ると失速扱いにする |
24
- | `DRATOOLS_DOWNLOAD_STALL_SPEED` | `1024` | 失速判定に使う最低転送速度。単位は bytes/sec |
25
- | `DRATOOLS_DOWNLOAD_RETRY_COUNT` | `3` | ダウンロード失敗時のリトライ回数。`0` も指定可能 |
26
- | `DRATOOLS_DOWNLOAD_RETRY_WAIT` | `5` | `wget` のリトライ待ち秒数 |
24
+ | `DRATOOLS_DOWNLOAD_STALL_SPEED` | `1024` | `curl` / `aria2c` の失速判定に使う最低転送速度。単位は bytes/sec |
25
+ | `DRATOOLS_DOWNLOAD_RETRY_COUNT` | `3` | ダウンロード失敗時のリトライ回数。`0` はリトライなし |
26
+ | `DRATOOLS_DOWNLOAD_RETRY_WAIT` | `5` | `wget` / `aria2c` のリトライ待ち秒数 |
27
+ | `DRATOOLS_DOWNLOAD_COMMAND` | 自動 | 実ダウンロードと `probe` に使う外部コマンド。`curl`, `wget`, `aria2c` のいずれか |
28
+
29
+ `DRATOOLS_DOWNLOAD_COMMAND` を指定しない場合は、`curl`, `wget` の順で PATH にあるものを使います。`aria2c` は自動探索しません。`aria2c` を使う場合は `DRATOOLS_DOWNLOAD_COMMAND=aria2c` を指定してください。指定した場合はそのコマンドだけを使います。見つからない場合は、別のコマンドへ自動では切り替えません。
30
+
31
+ `DRATOOLS_DOWNLOAD_RETRY_COUNT` は、初回の試行を含まない「リトライ回数」です。`curl` にはそのまま渡します。`wget` と `aria2c` は総試行回数を受け取るため、内部で `リトライ回数 + 1` に変換します。
27
32
 
28
33
  ## 例
29
34
 
@@ -62,6 +67,18 @@ DRATOOLS_DOWNLOAD_RETRY_COUNT=0 \
62
67
  dratools get --no-verify -O ~/Downloads DRR000001
63
68
  ```
64
69
 
70
+ `wget` を明示してダウンロードする:
71
+
72
+ ```sh
73
+ DRATOOLS_DOWNLOAD_COMMAND=wget dratools get -O ~/Downloads DRR000001
74
+ ```
75
+
76
+ `aria2c` を明示してダウンロードする:
77
+
78
+ ```sh
79
+ DRATOOLS_DOWNLOAD_COMMAND=aria2c dratools get -O ~/Downloads DRR000001
80
+ ```
81
+
65
82
  ## 注意
66
83
 
67
84
  これらは上級の設定です。上限を大きくすると、DDBJ Search API へのリクエスト数が増えます。`size` の HTTP `HEAD` の回数も増えます。
data/docs/usage.md CHANGED
@@ -35,7 +35,7 @@ bundle exec rake install
35
35
 
36
36
  ## メタ情報を表示する (`meta`)
37
37
 
38
- `meta` は DDBJ Search の resource JSON を要約して表示します。
38
+ `meta` は DDBJ Search の entry JSON を要約して表示します。
39
39
 
40
40
  ```sh
41
41
  dratools meta DRR300000
@@ -158,7 +158,7 @@ dratools url --json DRR000001
158
158
 
159
159
  ## 接続確認 (`probe`)
160
160
 
161
- `probe` は接続確認だけを行います。ファイルを最後までダウンロードしません。
161
+ `probe` は接続確認だけを行います。ファイルをダウンロードしません。
162
162
 
163
163
  ```sh
164
164
  dratools probe --timeout 5 DRR000001
@@ -190,29 +190,30 @@ direct run を多数持つ親 accession では、`tree` は各 run を個別取
190
190
  dratools get -O ~/Downloads DRR000001
191
191
  ```
192
192
 
193
- ダウンロード中は `curl` または `wget` の進捗が標準エラーに出ます。取得したファイルは `Downloaded<TAB>PATH` と表示します。既存ファイルを再利用した場合は `Skipped<TAB>PATH` と表示します。これらは標準エラーに出ます。最後に `dratools get: N downloaded, M skipped` のサマリを出します。状態とパスは TAB 区切りです。パスだけを取り出すには `cut -f2` を使います。
193
+ ダウンロード中は `curl`, `wget`, `aria2c` のいずれかの進捗が標準エラーに出ます。取得したファイルは `Downloaded<TAB>PATH` と表示します。既存ファイルを再利用した場合は `Skipped<TAB>PATH` と表示します。これらは標準エラーに出ます。最後に `dratools get: N downloaded, M skipped` のサマリを出します。状態とパスは TAB 区切りです。パスだけを取り出すには `cut -f2` を使います。
194
194
 
195
- ### ダウンロード時の検証と既存ファイル
195
+ ### 既存ファイルの扱い
196
196
 
197
- md5 が得られる場合、`get` はダウンロード後に照合します。md5 が無い場合、既定では既存ファイルをスキップしません。
197
+ DDBJ のメタデータに md5 が含まれることは多くありません。ほとんどの場合、md5 は得られません。以下では、まず md5 が無い場合の動作を示します。
198
198
 
199
- 検証を省略する場合は `--no-verify` を付けます。
199
+ 同名のファイルが既にある場合、`get` はサーバにファイルサイズを問い合わせます。それをローカルのファイルサイズと比べます。
200
200
 
201
- ```sh
202
- dratools get --no-verify -O ~/Downloads DRR000001
203
- ```
201
+ - サイズが同じなら、再取得しません。`Skipped` と表示します。
202
+ - ローカルのほうが小さいなら、再取得します。途中で中断したファイルが対象です。
203
+ - ローカルのほうが大きいなら、エラーになります。別物の可能性があるためです。`--force` で上書きできます。
204
204
 
205
- 同名のファイルが既にある場合を考えます。その md5 DDBJ の値と一致すれば、再ダウンロードしません。`Skipped` と表示します。
205
+ md5 が得られる場合は、サイズではなく md5 で判定します。既存ファイルの md5 が一致すれば `Skipped` と表示します。ダウンロード後にも md5 を照合します。
206
206
 
207
- 既存ファイルがあっても必ず再取得する場合は `--force` を付けます。
208
-
209
- ```sh
210
- dratools get --force -O ~/Downloads DRR000001
211
- ```
207
+ ### オプション
212
208
 
213
- md5 を確認せずに既存ファイルを再取得しない場合は `--skip-existing` を付けます。
209
+ | オプション | 動作 |
210
+ | --- | --- |
211
+ | `--force` | 既存ファイルがあっても再取得します。 |
212
+ | `--skip-existing` | 同名のファイルがあれば、確認せずスキップします。サーバへの問い合わせを省きます。 |
213
+ | `--no-verify` | ダウンロード後の md5 照合を省きます。md5 が無い場合は照合しないので、効果はありません。 |
214
214
 
215
215
  ```sh
216
+ dratools get --force -O ~/Downloads DRR000001
216
217
  dratools get --skip-existing -O ~/Downloads DRR000001
217
218
  ```
218
219
 
@@ -72,12 +72,39 @@ module Dratools
72
72
  @client.fetch_resource_record(resource_type, accession)
73
73
  end
74
74
 
75
+ def direct_run_accessions_for(accession)
76
+ accession = accession.to_s.upcase
77
+ resource_type = resource_type_for(accession)
78
+ return [accession] if resource_type == DdbjRecordFields::SRA_RUN_RESOURCE_TYPE
79
+
80
+ @client.fetch_db_links(
81
+ resource_type,
82
+ accession,
83
+ target: DdbjRecordFields::SRA_RUN_RESOURCE_TYPE
84
+ ).filter_map { |xref| xref_accession(xref) }
85
+ end
86
+
87
+ def direct_run_count_for(accession)
88
+ accession = accession.to_s.upcase
89
+ resource_type = resource_type_for(accession)
90
+ return 1 if resource_type == DdbjRecordFields::SRA_RUN_RESOURCE_TYPE
91
+
92
+ counts = @client.fetch_db_link_counts([{ type: resource_type, id: accession }])
93
+ counts.fetch([resource_type, accession], {}).fetch(DdbjRecordFields::SRA_RUN_RESOURCE_TYPE, 0)
94
+ end
95
+
75
96
  def resource_type_for(accession)
76
97
  @resource_type_classifier.resource_type_for(accession)
77
98
  end
78
99
 
79
100
  private
80
101
 
102
+ def xref_accession(xref)
103
+ xref[DdbjRecordFields::IDENTIFIER_KEY] ||
104
+ xref[DdbjRecordFields::ID_KEY] ||
105
+ xref[DdbjRecordFields::ACCESSION_KEY]
106
+ end
107
+
81
108
  def attach_downloads(node, file_type:)
82
109
  if node.run? && node.record
83
110
  downloads = @download_candidate_builder.build_from_run_record(node.record)
@@ -4,14 +4,14 @@ require_relative 'ddbj_record_fields'
4
4
  require_relative 'errors'
5
5
 
6
6
  module Dratools
7
- # accession の接頭辞から DDBJ resource API type を判定する。
7
+ # accession の接頭辞から DDBJ Search entry type を判定する。
8
8
  class AccessionResourceTypeClassifier
9
9
  RUN_PREFIXES = /\A[DES]RR\d+\z/
10
10
  EXPERIMENT_PREFIXES = /\A[DES]RX\d+\z/
11
11
  SAMPLE_PREFIXES = /\A[DES]RS\d+\z/
12
12
  STUDY_PREFIXES = /\A[DES]RP\d+\z/
13
13
  SUBMISSION_PREFIXES = /\A[DES]RA\d+\z/
14
- BIOPROJECT_PREFIXES = /\APRJ(?:DA|DB|NA|EB)\d+\z/
14
+ BIOPROJECT_PREFIXES = /\APRJ[DEN][A-Z]\d+\z/
15
15
  BIOSAMPLE_PREFIXES = /\ASAM(?:D|N|EA|EG)?\d+\z/
16
16
 
17
17
  TYPE_BY_ACCESSION = [
@@ -124,12 +124,7 @@ module Dratools
124
124
  stream.puts format(' %-7<name>s %<summary>s', name: name, summary: summary)
125
125
  end
126
126
  stream.puts ''
127
- stream.puts 'Aliases:'
128
- SUBCOMMAND_ALIASES.each do |alias_name, canonical|
129
- stream.puts format(' %-7<a>s -> %<c>s', a: alias_name, c: canonical)
130
- end
131
- stream.puts ''
132
- stream.puts "Run '#{COMMAND_NAME} <command> --help' for command options."
127
+ stream.puts "各コマンドのオプションは '#{COMMAND_NAME} <command> --help' で確認できます。"
133
128
  stream.puts ''
134
129
  stream.puts 'Examples:'
135
130
  USAGE_EXAMPLES.each { |example| stream.puts " #{example}" }
@@ -6,7 +6,7 @@ require_relative 'base_command'
6
6
 
7
7
  module Dratools
8
8
  module Commands
9
- # DDBJ resource JSON のメタ情報を要約表示する。
9
+ # DDBJ Search entry JSON のメタ情報を要約表示する。
10
10
  class MetaCommand < BaseCommand
11
11
  LABEL_WIDTH = 18
12
12
 
@@ -21,7 +21,7 @@ module Dratools
21
21
  end
22
22
 
23
23
  def configure_parser(parser)
24
- parser.on('--json', '生の resource JSON を整形して表示する') { @options[:json] = true }
24
+ parser.on('--json', '生の entry JSON を整形して表示する') { @options[:json] = true }
25
25
  end
26
26
 
27
27
  def usage_examples
@@ -6,8 +6,6 @@ module Dratools
6
6
  module Commands
7
7
  # accession を run accession のフラットな一覧に展開する。
8
8
  class RunsCommand < BaseCommand
9
- XREF_URL_PATTERN = %r{/(?:resource|search/entry)/sra-run/([^/?#.]+)}
10
-
11
9
  private
12
10
 
13
11
  def command_name
@@ -41,29 +39,7 @@ module Dratools
41
39
  end
42
40
 
43
41
  def direct_run_accessions_for(accession)
44
- record = @resolver.fetch_record_for(accession)
45
- if record[DdbjRecordFields::TYPE_KEY] == DdbjRecordFields::SRA_RUN_RESOURCE_TYPE
46
- return [record_accession(record)].compact
47
- end
48
-
49
- record.fetch(DdbjRecordFields::DB_XREFS_KEY, []).filter_map do |xref|
50
- next unless xref[DdbjRecordFields::TYPE_KEY] == DdbjRecordFields::SRA_RUN_RESOURCE_TYPE
51
-
52
- xref[DdbjRecordFields::IDENTIFIER_KEY] ||
53
- xref[DdbjRecordFields::ID_KEY] ||
54
- run_accession_from_url(xref[DdbjRecordFields::URL_KEY])
55
- end
56
- end
57
-
58
- def record_accession(record)
59
- record[DdbjRecordFields::ACCESSION_KEY] ||
60
- record[DdbjRecordFields::IDENTIFIER_KEY] ||
61
- record[DdbjRecordFields::ID_KEY] ||
62
- record[DdbjRecordFields::PRIMARY_ID_KEY]
63
- end
64
-
65
- def run_accession_from_url(url)
66
- url.to_s.match(XREF_URL_PATTERN)&.[](1)
42
+ @resolver.direct_run_accessions_for(accession)
67
43
  end
68
44
  end
69
45
  end
@@ -140,19 +140,20 @@ module Dratools
140
140
 
141
141
  def fetch_record_for_size(accession)
142
142
  max_direct_runs = Config.size_max_direct_runs
143
- ddbj_record = @resolver.fetch_record_for(accession)
144
- validate_direct_run_expansion_size!(accession, ddbj_record, max_direct_runs)
145
- ddbj_record
143
+ validate_direct_run_expansion_count!(accession, max_direct_runs)
144
+ @resolver.fetch_record_for(accession)
146
145
  end
147
146
 
148
- def validate_direct_run_expansion_size!(accession, ddbj_record, max_direct_runs)
147
+ def validate_direct_run_expansion_count!(accession, max_direct_runs)
149
148
  return unless max_direct_runs
150
149
 
151
- direct_run_count = ddbj_record.fetch(DdbjRecordFields::DB_XREFS_KEY, []).count do |xref|
152
- xref[DdbjRecordFields::TYPE_KEY] == DdbjRecordFields::SRA_RUN_RESOURCE_TYPE
153
- end
150
+ direct_run_count = @resolver.direct_run_count_for(accession)
154
151
  return if direct_run_count <= max_direct_runs
155
152
 
153
+ raise_direct_run_limit_error(accession, direct_run_count, max_direct_runs)
154
+ end
155
+
156
+ def raise_direct_run_limit_error(accession, direct_run_count, max_direct_runs)
156
157
  raise InvalidRecordError,
157
158
  "#{accession.to_s.upcase} has #{direct_run_count} direct runs; " \
158
159
  "size expands at most #{max_direct_runs} direct runs from one parent accession. " \
@@ -73,19 +73,20 @@ module Dratools
73
73
 
74
74
  def fetch_record_for_url(accession)
75
75
  max_direct_runs = Config.url_max_direct_runs
76
- ddbj_record = @resolver.fetch_record_for(accession)
77
- validate_direct_run_expansion_size!(accession, ddbj_record, max_direct_runs)
78
- ddbj_record
76
+ validate_direct_run_expansion_count!(accession, max_direct_runs)
77
+ @resolver.fetch_record_for(accession)
79
78
  end
80
79
 
81
- def validate_direct_run_expansion_size!(accession, ddbj_record, max_direct_runs)
80
+ def validate_direct_run_expansion_count!(accession, max_direct_runs)
82
81
  return unless max_direct_runs
83
82
 
84
- direct_run_count = ddbj_record.fetch(DdbjRecordFields::DB_XREFS_KEY, []).count do |xref|
85
- xref[DdbjRecordFields::TYPE_KEY] == DdbjRecordFields::SRA_RUN_RESOURCE_TYPE
86
- end
83
+ direct_run_count = @resolver.direct_run_count_for(accession)
87
84
  return if direct_run_count <= max_direct_runs
88
85
 
86
+ raise_direct_run_limit_error(accession, direct_run_count, max_direct_runs)
87
+ end
88
+
89
+ def raise_direct_run_limit_error(accession, direct_run_count, max_direct_runs)
89
90
  raise InvalidRecordError,
90
91
  "#{accession.to_s.upcase} has #{direct_run_count} direct runs; " \
91
92
  "url expands at most #{max_direct_runs} direct runs from one parent accession. " \
@@ -14,6 +14,7 @@ module Dratools
14
14
  DOWNLOAD_STALL_SPEED_ENV = 'DRATOOLS_DOWNLOAD_STALL_SPEED'
15
15
  DOWNLOAD_RETRY_COUNT_ENV = 'DRATOOLS_DOWNLOAD_RETRY_COUNT'
16
16
  DOWNLOAD_RETRY_WAIT_ENV = 'DRATOOLS_DOWNLOAD_RETRY_WAIT'
17
+ DOWNLOAD_COMMAND_ENV = 'DRATOOLS_DOWNLOAD_COMMAND'
17
18
 
18
19
  DEFAULT_MAX_RECURSIVE_NON_RUN_XREFS = 100
19
20
  DEFAULT_TREE_MAX_DIRECT_RUNS = 50
@@ -24,6 +25,7 @@ module Dratools
24
25
  DEFAULT_DOWNLOAD_STALL_SPEED_BYTES_PER_SECOND = 1024
25
26
  DEFAULT_DOWNLOAD_RETRY_COUNT = 3
26
27
  DEFAULT_DOWNLOAD_RETRY_WAIT_SECONDS = 5
28
+ SUPPORTED_DOWNLOAD_COMMANDS = %w[curl wget aria2c].freeze
27
29
  UNLIMITED_VALUE = 'unlimited'
28
30
 
29
31
  module_function
@@ -70,6 +72,18 @@ module Dratools
70
72
  positive_integer(DOWNLOAD_RETRY_WAIT_ENV, DEFAULT_DOWNLOAD_RETRY_WAIT_SECONDS)
71
73
  end
72
74
 
75
+ def download_command
76
+ value = ENV.fetch(DOWNLOAD_COMMAND_ENV, '').strip
77
+ return nil if value.empty?
78
+ return value if SUPPORTED_DOWNLOAD_COMMANDS.include?(value)
79
+
80
+ invalid_environment_value!(
81
+ DOWNLOAD_COMMAND_ENV,
82
+ value,
83
+ SUPPORTED_DOWNLOAD_COMMANDS.join(' or ')
84
+ )
85
+ end
86
+
73
87
  def positive_integer_or_unlimited(name, default)
74
88
  value = ENV.fetch(name, '').strip
75
89
  return default if value.empty?
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dratools
4
- # DDBJ Search resource JSON で使う resource type とキー名をまとめる。
4
+ # DDBJ Search entry JSON で使う resource type とキー名をまとめる。
5
5
  module DdbjRecordFields
6
6
  SRA_RUN_RESOURCE_TYPE = 'sra-run'
7
7
  SRA_EXPERIMENT_RESOURCE_TYPE = 'sra-experiment'
@@ -18,18 +18,13 @@ module Dratools
18
18
  DB_XREFS_KEY = 'dbXrefs'
19
19
  CHILD_BIOPROJECTS_KEY = 'childBioProjects'
20
20
  TYPE_KEY = 'type'
21
- URL_KEY = 'url'
22
- FTP_URL_KEY = 'ftpUrl'
23
21
  ID_KEY = 'id'
24
22
  IDENTIFIER_KEY = 'identifier'
25
23
  ACCESSION_KEY = 'accession'
26
24
  PRIMARY_ID_KEY = 'primaryId'
27
- DOWNLOAD_URL_KEY = 'downloadUrl'
28
25
  DISTRIBUTION_KEY = 'distribution'
29
26
  CONTENT_URL_KEY = 'contentUrl'
30
27
  CONTENT_SIZE_KEY = 'contentSize'
31
- SIZE_KEY = 'size'
32
- FILE_SIZE_KEY = 'fileSize'
33
28
  MD5_KEY = 'md5'
34
29
  MD5_SUM_KEY = 'md5sum'
35
30
  ENCODING_FORMAT_KEY = 'encodingFormat'
@@ -7,12 +7,17 @@ require 'uri'
7
7
 
8
8
  require_relative 'errors'
9
9
  require_relative 'version'
10
+ require_relative 'ddbj_record_fields'
10
11
 
11
12
  module Dratools
12
- # DDBJ resource API を呼び出す薄い HTTP クライアント。
13
+ # DDBJ Search API を呼び出す薄い HTTP クライアント。
13
14
  class DdbjResourceClient
14
- DDBJ_RESOURCE_BASE_URL = 'https://ddbj.nig.ac.jp/resource'
15
- RESOURCE_RECORD_EXTENSION = '.json'
15
+ DDBJ_SEARCH_API_BASE_URL = 'https://ddbj.nig.ac.jp/search/api'
16
+ ENTRIES_PATH = 'entries'
17
+ DBLINK_PATH = 'dblink'
18
+ ENTRY_RECORD_EXTENSION = '.json'
19
+ BULK_MAX_IDS = 1000
20
+ DBLINK_COUNTS_MAX_ITEMS = 100
16
21
  HTTPS_SCHEME = 'https'
17
22
  HTTP_LOCATION_HEADER = 'location'
18
23
  USER_AGENT_HEADER = 'User-Agent'
@@ -20,7 +25,7 @@ module Dratools
20
25
  DEFAULT_OPEN_TIMEOUT_SECONDS = 10
21
26
  DEFAULT_READ_TIMEOUT_SECONDS = 30
22
27
 
23
- def initialize(base_url: DDBJ_RESOURCE_BASE_URL, open_timeout: DEFAULT_OPEN_TIMEOUT_SECONDS,
28
+ def initialize(base_url: DDBJ_SEARCH_API_BASE_URL, open_timeout: DEFAULT_OPEN_TIMEOUT_SECONDS,
24
29
  read_timeout: DEFAULT_READ_TIMEOUT_SECONDS)
25
30
  @base_url = base_url.delete_suffix('/')
26
31
  @open_timeout = open_timeout
@@ -28,32 +33,93 @@ module Dratools
28
33
  end
29
34
 
30
35
  def fetch_resource_record(type, accession)
31
- fetch_json("#{@base_url}/#{type}/#{accession}#{RESOURCE_RECORD_EXTENSION}")
36
+ fetch_json("#{@base_url}/#{ENTRIES_PATH}/#{type}/#{accession}#{ENTRY_RECORD_EXTENSION}")
37
+ end
38
+
39
+ def fetch_db_links(type, accession, target: nil)
40
+ request_uri = URI("#{@base_url}/#{DBLINK_PATH}/#{type}/#{accession}")
41
+ request_uri.query = URI.encode_www_form(target: target) if target
42
+ fetch_json(request_uri.to_s).fetch(DdbjRecordFields::DB_XREFS_KEY, [])
43
+ end
44
+
45
+ def fetch_resource_records_bulk(type, accessions, include_db_xrefs: false)
46
+ accessions.each_slice(BULK_MAX_IDS).with_object({}) do |chunk, records|
47
+ records.merge!(
48
+ fetch_resource_records_bulk_chunk(type, chunk, include_db_xrefs: include_db_xrefs)
49
+ )
50
+ end
51
+ end
52
+
53
+ def fetch_db_link_counts(items)
54
+ items.each_slice(DBLINK_COUNTS_MAX_ITEMS).with_object({}) do |chunk, counts|
55
+ counts.merge!(fetch_db_link_counts_chunk(chunk))
56
+ end
32
57
  end
33
58
 
34
59
  private
35
60
 
61
+ def fetch_db_link_counts_chunk(items)
62
+ request_url = "#{@base_url}/#{DBLINK_PATH}/counts"
63
+ payload = post_json(request_url, items: items)
64
+ payload.fetch('items', []).to_h do |item|
65
+ [[item['type'], item[DdbjRecordFields::IDENTIFIER_KEY]], item.fetch('counts', {})]
66
+ end
67
+ end
68
+
69
+ def fetch_resource_records_bulk_chunk(type, accessions, include_db_xrefs:)
70
+ request_uri = URI("#{@base_url}/#{ENTRIES_PATH}/#{type}/bulk")
71
+ request_uri.query = URI.encode_www_form(includeDbXrefs: include_db_xrefs)
72
+ payload = post_json(request_uri.to_s, ids: accessions)
73
+ payload.fetch('entries', []).to_h do |record|
74
+ accession = record[DdbjRecordFields::IDENTIFIER_KEY] ||
75
+ record[DdbjRecordFields::ACCESSION_KEY] ||
76
+ record[DdbjRecordFields::ID_KEY] ||
77
+ record[DdbjRecordFields::PRIMARY_ID_KEY]
78
+ [accession, record]
79
+ end
80
+ end
81
+
36
82
  def fetch_json(request_url, redirects_remaining = DEFAULT_REDIRECT_LIMIT)
37
- request_uri = URI(request_url)
38
- response = get_http_response(request_uri)
39
-
40
- case response
41
- when Net::HTTPSuccess
42
- JSON.parse(response.body)
43
- when Net::HTTPRedirection
44
- raise NetworkError, "too many redirects: #{request_url}" if redirects_remaining <= 0
45
-
46
- location = response[HTTP_LOCATION_HEADER]
47
- raise NetworkError, "redirect without location: #{request_url}" if location.to_s.empty?
48
-
49
- fetch_json(URI.join(request_uri, location).to_s, redirects_remaining - 1)
50
- when Net::HTTPNotFound
51
- raise NotFoundError, "not found: #{request_url}"
52
- else
83
+ with_network_errors(request_url) do
84
+ request_uri = URI(request_url)
85
+ response = get_http_response(request_uri)
86
+
87
+ case response
88
+ when Net::HTTPSuccess
89
+ parse_json_response(response, request_url)
90
+ when Net::HTTPRedirection
91
+ raise NetworkError, "too many redirects: #{request_url}" if redirects_remaining <= 0
92
+
93
+ location = response[HTTP_LOCATION_HEADER]
94
+ raise NetworkError, "redirect without location: #{request_url}" if location.to_s.empty?
95
+
96
+ fetch_json(URI.join(request_uri, location).to_s, redirects_remaining - 1)
97
+ when Net::HTTPNotFound
98
+ raise NotFoundError, "not found: #{request_url}"
99
+ else
100
+ raise NetworkError, "HTTP #{response.code}: #{request_url}"
101
+ end
102
+ end
103
+ end
104
+
105
+ def post_json(request_url, payload)
106
+ with_network_errors(request_url) do
107
+ response = post_http_response(URI(request_url), payload)
108
+ return parse_json_response(response, request_url) if response.is_a?(Net::HTTPSuccess)
109
+ raise NotFoundError, "not found: #{request_url}" if response.is_a?(Net::HTTPNotFound)
110
+
53
111
  raise NetworkError, "HTTP #{response.code}: #{request_url}"
54
112
  end
113
+ end
114
+
115
+ def parse_json_response(response, request_url)
116
+ JSON.parse(response.body)
55
117
  rescue JSON::ParserError => error
56
118
  raise NetworkError, "invalid JSON from #{request_url}: #{error.message}", cause: error
119
+ end
120
+
121
+ def with_network_errors(request_url)
122
+ yield
57
123
  rescue Timeout::Error, IOError, SocketError, SystemCallError => error
58
124
  message = "failed to fetch #{request_url}: #{error.class}: #{error.message}"
59
125
  raise NetworkError, message, cause: error
@@ -71,6 +137,21 @@ module Dratools
71
137
  end
72
138
  end
73
139
 
140
+ def post_http_response(request_uri, payload)
141
+ Net::HTTP.start(
142
+ request_uri.host,
143
+ request_uri.port,
144
+ use_ssl: request_uri.scheme == HTTPS_SCHEME,
145
+ open_timeout: @open_timeout,
146
+ read_timeout: @read_timeout
147
+ ) do |http|
148
+ request = Net::HTTP::Post.new(request_uri.request_uri, USER_AGENT_HEADER => user_agent)
149
+ request['Content-Type'] = 'application/json'
150
+ request.body = JSON.generate(payload)
151
+ http.request(request)
152
+ end
153
+ end
154
+
74
155
  def user_agent
75
156
  "#{NAME}/#{VERSION}"
76
157
  end
@@ -4,12 +4,12 @@ require_relative 'ddbj_record_fields'
4
4
  require_relative 'download_candidate'
5
5
 
6
6
  module Dratools
7
- # DDBJ run レコードの downloadUrl/distribution から DownloadCandidate を作る。
7
+ # DDBJ run レコードの distribution から DownloadCandidate を作る。
8
8
  class DownloadCandidateBuilder
9
9
  def build_from_run_record(run_record)
10
10
  run_accession = run_accession_from(run_record)
11
11
  downloads = download_items_from(run_record).filter_map do |download_item|
12
- build_from_download_item(run_accession, download_item)
12
+ build_from_distribution_item(run_accession, download_item)
13
13
  end
14
14
  downloads.uniq { |download| download_key(download) }
15
15
  end
@@ -24,21 +24,12 @@ module Dratools
24
24
  end
25
25
 
26
26
  def download_items_from(ddbj_record)
27
- ddbj_record.fetch(DdbjRecordFields::DOWNLOAD_URL_KEY, []) +
28
- ddbj_record.fetch(DdbjRecordFields::DISTRIBUTION_KEY, [])
27
+ Array(ddbj_record[DdbjRecordFields::DISTRIBUTION_KEY])
29
28
  end
30
29
 
31
- def build_from_download_item(run_accession, download_item)
30
+ def build_from_distribution_item(run_accession, download_item)
32
31
  return unless download_item.is_a?(Hash)
33
32
 
34
- if download_item[DdbjRecordFields::CONTENT_URL_KEY]
35
- build_from_distribution_item(run_accession, download_item)
36
- else
37
- build_from_download_url_item(run_accession, download_item)
38
- end
39
- end
40
-
41
- def build_from_distribution_item(run_accession, download_item)
42
33
  file_type = file_type_from_distribution(download_item)
43
34
  return unless file_type
44
35
 
@@ -52,28 +43,10 @@ module Dratools
52
43
  )
53
44
  end
54
45
 
55
- def build_from_download_url_item(run_accession, download_item)
56
- file_type = file_type_from_download_url(download_item)
57
- return unless file_type
58
-
59
- DownloadCandidate.new(
60
- run_accession: run_accession,
61
- type: file_type,
62
- url: download_item[DdbjRecordFields::URL_KEY],
63
- ftp_url: download_item[DdbjRecordFields::FTP_URL_KEY],
64
- size: download_item[DdbjRecordFields::SIZE_KEY] || download_item[DdbjRecordFields::FILE_SIZE_KEY],
65
- md5: download_item[DdbjRecordFields::MD5_KEY] || download_item[DdbjRecordFields::MD5_SUM_KEY]
66
- )
67
- end
68
-
69
46
  def file_type_from_distribution(download_item)
70
47
  file_type_from(download_item[DdbjRecordFields::ENCODING_FORMAT_KEY])
71
48
  end
72
49
 
73
- def file_type_from_download_url(download_item)
74
- file_type_from(download_item[DdbjRecordFields::TYPE_KEY])
75
- end
76
-
77
50
  def file_type_from(value)
78
51
  case value.to_s.downcase
79
52
  when DdbjRecordFields::FILE_TYPE_SRA
@@ -8,77 +8,146 @@ require_relative 'config'
8
8
  require_relative 'errors'
9
9
 
10
10
  module Dratools
11
- # curl wget を使って URL の確認とダウンロードを行うラッパー。
11
+ # curl, wget, aria2c のいずれかを使って URL の確認とダウンロードを行うラッパー。
12
12
  #
13
13
  # probe は短時間・無出力で済ませ、download は外部コマンドの進捗を端末へ流す。
14
14
  # 巨大ファイルを扱うため、download には総時間制限ではなく失速検知を使う。
15
15
  class ExternalCommandRunner
16
16
  CURL_COMMAND = 'curl'
17
17
  WGET_COMMAND = 'wget'
18
- SUPPORTED_COMMANDS = [CURL_COMMAND, WGET_COMMAND].freeze
18
+ ARIA2_COMMAND = 'aria2c'
19
+ AUTO_COMMANDS = [CURL_COMMAND, WGET_COMMAND].freeze
20
+ SUPPORTED_COMMANDS = [*AUTO_COMMANDS, ARIA2_COMMAND].freeze
21
+
19
22
  COMMAND_NOT_FOUND_MESSAGE = 'curl または wget が見つかりません'
23
+ PREFERRED_COMMAND_NOT_FOUND_MESSAGE = '指定されたダウンロードコマンドが見つかりません'
24
+ UNSUPPORTED_COMMAND_MESSAGE = '未対応のダウンロードコマンドです'
20
25
  DEFAULT_PROBE_TIMEOUT_SECONDS = 5
21
26
  PROBE_BYTE_RANGE = '0-0'
22
27
  SINGLE_ATTEMPT_COUNT = 1
23
28
 
29
+ # リダイレクト、HTTP エラー、静かな probe、範囲取得。
24
30
  CURL_PROBE_OPTIONS = ['--location', '--fail', '--silent', '--show-error', '--range'].freeze
31
+ # probe 全体のタイムアウト。
25
32
  CURL_TIMEOUT_OPTION = '--max-time'
26
33
  CURL_CONNECT_TIMEOUT_OPTION = '--connect-timeout'
34
+ # 失速判定に使う最低転送速度。
27
35
  CURL_SPEED_LIMIT_OPTION = '--speed-limit'
36
+ # 最低速度を下回ってよい秒数。
28
37
  CURL_SPEED_TIME_OPTION = '--speed-time'
29
38
  CURL_RETRY_OPTION = '--retry'
30
39
  CURL_OUTPUT_OPTION = '--output'
40
+ # 部分ファイルがあれば続きから再開。
31
41
  CURL_DOWNLOAD_OPTIONS = ['--location', '--fail', '--continue-at', '-'].freeze
32
42
 
43
+ # probe でファイルを保存しない。
33
44
  WGET_PROBE_OPTIONS = ['--spider'].freeze
45
+ # probe 全体のタイムアウト。
34
46
  WGET_TIMEOUT_OPTION = '--timeout'
35
47
  WGET_CONNECT_TIMEOUT_OPTION = '--connect-timeout'
48
+ # wget で失速検知に近い意味で使う。
36
49
  WGET_READ_TIMEOUT_OPTION = '--read-timeout'
50
+ # probe では 1 回だけにする。
37
51
  WGET_TRIES_OPTION = '--tries'
38
52
  WGET_WAITRETRY_OPTION = '--waitretry'
53
+ # 部分ファイルがあれば続きから再開。
39
54
  WGET_CONTINUE_OPTION = '--continue'
40
55
  WGET_OUTPUT_OPTION = '--output-document'
41
56
 
42
- def initialize(preferred: nil)
57
+ # probe で保存せず、通常出力も抑える。
58
+ ARIA2_PROBE_OPTIONS = ['--dry-run=true', '--quiet=true'].freeze
59
+ ARIA2_CONNECT_TIMEOUT_OPTION = '--connect-timeout'
60
+ # aria2c で失速検知に近い意味で使う。
61
+ ARIA2_TIMEOUT_OPTION = '--timeout'
62
+ # curl の --speed-limit に相当する最低転送速度。
63
+ ARIA2_LOWEST_SPEED_LIMIT_OPTION = '--lowest-speed-limit'
64
+ ARIA2_MAX_TRIES_OPTION = '--max-tries'
65
+ ARIA2_RETRY_WAIT_OPTION = '--retry-wait'
66
+ # 部分ファイルがあれば続きから再開。
67
+ ARIA2_CONTINUE_OPTION = '--continue=true'
68
+ # aria2c は保存先をディレクトリとファイル名に分ける。
69
+ ARIA2_DIR_OPTION = '--dir'
70
+ ARIA2_OUT_OPTION = '--out'
71
+ # 既定では並列取得しない。
72
+ ARIA2_SINGLE_CONNECTION_OPTIONS = ['--split=1', '--max-connection-per-server=1'].freeze
73
+
74
+ def initialize(preferred: Config.download_command)
43
75
  @preferred = preferred
44
76
  end
45
77
 
46
78
  def available_command
47
- candidates = [@preferred, *SUPPORTED_COMMANDS].compact
79
+ candidates = @preferred ? [@preferred] : AUTO_COMMANDS
48
80
  candidates.find { |command_name| executable_command?(command_name) }
49
81
  end
50
82
 
51
83
  def probe_url(url, timeout: DEFAULT_PROBE_TIMEOUT_SECONDS)
52
- tool = available_command || raise(CommandError, COMMAND_NOT_FOUND_MESSAGE)
84
+ tool = available_command || raise(CommandError, command_not_found_message)
53
85
  # 巨大ファイルを落とさないよう、短時間・最小範囲の確認に留める。
54
86
  command =
55
- if File.basename(tool) == CURL_COMMAND
87
+ case File.basename(tool)
88
+ when CURL_COMMAND
89
+ # 例: curl --location --fail --silent --show-error --range 0-0
90
+ # --max-time 5 --output /dev/null URL
56
91
  [tool, *CURL_PROBE_OPTIONS, PROBE_BYTE_RANGE, CURL_TIMEOUT_OPTION, timeout.to_s,
57
92
  CURL_OUTPUT_OPTION, null_device, url]
58
- else
93
+ when WGET_COMMAND
94
+ # 例: wget --spider --timeout=5 --tries=1 URL
59
95
  [tool, *WGET_PROBE_OPTIONS, "#{WGET_TIMEOUT_OPTION}=#{timeout}",
60
96
  "#{WGET_TRIES_OPTION}=#{SINGLE_ATTEMPT_COUNT}", url]
97
+ when ARIA2_COMMAND
98
+ # 例: aria2c --dry-run=true --quiet=true --connect-timeout=5 --timeout=5 --max-tries=1 URL
99
+ [tool, *ARIA2_PROBE_OPTIONS,
100
+ "#{ARIA2_CONNECT_TIMEOUT_OPTION}=#{timeout}",
101
+ "#{ARIA2_TIMEOUT_OPTION}=#{timeout}",
102
+ "#{ARIA2_MAX_TRIES_OPTION}=#{SINGLE_ATTEMPT_COUNT}", url]
103
+ else
104
+ unsupported_command!(tool)
61
105
  end
62
106
  run_quietly(command)
63
107
  end
64
108
 
65
109
  def download_url(url, output_path)
66
- tool = available_command || raise(CommandError, COMMAND_NOT_FOUND_MESSAGE)
110
+ tool = available_command || raise(CommandError, command_not_found_message)
67
111
  command =
68
- if File.basename(tool) == CURL_COMMAND
112
+ case File.basename(tool)
113
+ when CURL_COMMAND
114
+ # curl の低速検知は「指定秒数のあいだ指定速度を下回ったら失敗」。
115
+ # ネットワークが完全に切れず低速で固まるケースを、総時間制限なしで検出する。
116
+ # 例: curl --location --fail --continue-at - --connect-timeout 30
117
+ # --speed-limit 1024 --speed-time 60 --retry 3 --output OUT URL
69
118
  [tool, *CURL_DOWNLOAD_OPTIONS,
70
119
  CURL_CONNECT_TIMEOUT_OPTION, Config.download_connect_timeout_seconds.to_s,
71
120
  CURL_SPEED_LIMIT_OPTION, Config.download_stall_speed_bytes_per_second.to_s,
72
121
  CURL_SPEED_TIME_OPTION, Config.download_stall_timeout_seconds.to_s,
73
122
  CURL_RETRY_OPTION, Config.download_retry_count.to_s,
74
123
  CURL_OUTPUT_OPTION, output_path, url]
75
- else
124
+ when WGET_COMMAND
125
+ # wget では --read-timeout を失速検知に近い意味で使う。
126
+ # --continue は部分ファイルの続きから再開し、--output-document は保存先を固定する。
127
+ # 例: wget --continue --connect-timeout=30 --read-timeout=60
128
+ # --tries=4 --waitretry=5 --output-document OUT URL
76
129
  [tool, WGET_CONTINUE_OPTION,
77
130
  "#{WGET_CONNECT_TIMEOUT_OPTION}=#{Config.download_connect_timeout_seconds}",
78
131
  "#{WGET_READ_TIMEOUT_OPTION}=#{Config.download_stall_timeout_seconds}",
79
- "#{WGET_TRIES_OPTION}=#{Config.download_retry_count}",
132
+ "#{WGET_TRIES_OPTION}=#{download_attempt_count}",
80
133
  "#{WGET_WAITRETRY_OPTION}=#{Config.download_retry_wait_seconds}",
81
134
  WGET_OUTPUT_OPTION, output_path, url]
135
+ when ARIA2_COMMAND
136
+ # aria2c は保存先をディレクトリとファイル名に分けて指定する。
137
+ # --continue=true は部分ファイルがあれば続きから再開する。
138
+ # 例: aria2c --continue=true --split=1 --max-connection-per-server=1
139
+ # --connect-timeout=30 --timeout=60 --lowest-speed-limit=1024
140
+ # --max-tries=4 --retry-wait=5 --dir DIR --out FILE URL
141
+ [tool, ARIA2_CONTINUE_OPTION, *ARIA2_SINGLE_CONNECTION_OPTIONS,
142
+ "#{ARIA2_CONNECT_TIMEOUT_OPTION}=#{Config.download_connect_timeout_seconds}",
143
+ "#{ARIA2_TIMEOUT_OPTION}=#{Config.download_stall_timeout_seconds}",
144
+ "#{ARIA2_LOWEST_SPEED_LIMIT_OPTION}=#{Config.download_stall_speed_bytes_per_second}",
145
+ "#{ARIA2_MAX_TRIES_OPTION}=#{download_attempt_count}",
146
+ "#{ARIA2_RETRY_WAIT_OPTION}=#{Config.download_retry_wait_seconds}",
147
+ "#{ARIA2_DIR_OPTION}=#{File.dirname(output_path)}",
148
+ "#{ARIA2_OUT_OPTION}=#{File.basename(output_path)}", url]
149
+ else
150
+ unsupported_command!(tool)
82
151
  end
83
152
  run_streaming(command)
84
153
  end
@@ -93,7 +162,7 @@ module Dratools
93
162
  end
94
163
 
95
164
  def run_streaming(command)
96
- # 配列形式で渡すことでシェルを介さず、curl/wget stderr 進捗はそのまま見せる。
165
+ # 配列形式で渡すことでシェルを介さず、外部コマンドの stderr 進捗はそのまま見せる。
97
166
  return true if system(*command)
98
167
 
99
168
  status = $CHILD_STATUS
@@ -108,6 +177,20 @@ module Dratools
108
177
  end
109
178
  end
110
179
 
180
+ def command_not_found_message
181
+ return "#{PREFERRED_COMMAND_NOT_FOUND_MESSAGE}: #{@preferred}" if @preferred
182
+
183
+ COMMAND_NOT_FOUND_MESSAGE
184
+ end
185
+
186
+ def download_attempt_count
187
+ Config.download_retry_count + 1
188
+ end
189
+
190
+ def unsupported_command!(tool)
191
+ raise CommandError, "#{UNSUPPORTED_COMMAND_MESSAGE}: #{tool}"
192
+ end
193
+
111
194
  def null_device
112
195
  File::NULL
113
196
  end
@@ -10,7 +10,6 @@ require_relative 'traversal_node'
10
10
  module Dratools
11
11
  # BioProject などの上位レコードから DDBJ sra-run レコードを集める。
12
12
  class RunRecordCollector
13
- XREF_URL_PATTERN = %r{/(?:resource|search/entry)/([^/]+)/([^/?#.]+)}
14
13
  TRAVERSABLE_XREF_TYPES = [
15
14
  DdbjRecordFields::SRA_RUN_RESOURCE_TYPE,
16
15
  DdbjRecordFields::SRA_EXPERIMENT_RESOURCE_TYPE,
@@ -40,14 +39,26 @@ module Dratools
40
39
  return node
41
40
  end
42
41
 
43
- direct_children = explore_edges(run_xrefs, TraversalNode::DB_XREF_RELATION, seen_keys,
44
- tolerant: tolerant,
45
- direct_run_fetch_limit: direct_run_fetch_limit)
42
+ direct_children = explore_run_xrefs(
43
+ run_xrefs,
44
+ seen_keys,
45
+ tolerant: tolerant,
46
+ direct_run_fetch_limit: direct_run_fetch_limit
47
+ )
46
48
  if direct_children.any? { |child| child.run? || child.run_records.any? }
47
49
  node.children.concat(direct_children)
48
50
  return node
49
51
  end
50
52
 
53
+ node.children.concat(
54
+ recursive_children(ddbj_record, xrefs, seen_keys, tolerant, direct_run_fetch_limit)
55
+ )
56
+ node
57
+ end
58
+
59
+ private
60
+
61
+ def recursive_children(ddbj_record, xrefs, seen_keys, tolerant, direct_run_fetch_limit)
51
62
  recursive_xrefs = xrefs.select { |xref| traversable_xref?(xref) }
52
63
  validate_recursive_non_run_xref_count!(ddbj_record, recursive_xrefs)
53
64
  db_xref_edges = explore_edges(
@@ -64,12 +75,9 @@ module Dratools
64
75
  tolerant: tolerant,
65
76
  direct_run_fetch_limit: direct_run_fetch_limit
66
77
  )
67
- node.children.concat(db_xref_edges + child_edges)
68
- node
78
+ db_xref_edges + child_edges
69
79
  end
70
80
 
71
- private
72
-
73
81
  def lightweight_direct_run_nodes(run_xrefs, direct_run_fetch_limit)
74
82
  return nil unless direct_run_fetch_limit && run_xrefs.length > direct_run_fetch_limit
75
83
 
@@ -111,6 +119,53 @@ module Dratools
111
119
  end
112
120
  end
113
121
 
122
+ def explore_run_xrefs(run_xrefs, seen_keys, tolerant:, direct_run_fetch_limit:)
123
+ fetchable_xrefs = unseen_fetchable_xrefs(run_xrefs, seen_keys)
124
+ return [] if fetchable_xrefs.empty?
125
+
126
+ accessions = fetchable_xrefs.map { |xref| xref_accession(xref) }
127
+ records = @client.fetch_resource_records_bulk(
128
+ DdbjRecordFields::SRA_RUN_RESOURCE_TYPE,
129
+ accessions,
130
+ include_db_xrefs: false
131
+ )
132
+ fetchable_xrefs.map do |xref|
133
+ accession = xref_accession(xref)
134
+ if (record = records[accession])
135
+ explore(
136
+ record,
137
+ seen_keys: seen_keys,
138
+ relation: TraversalNode::DB_XREF_RELATION,
139
+ tolerant: tolerant,
140
+ direct_run_fetch_limit: direct_run_fetch_limit
141
+ )
142
+ elsif tolerant
143
+ node_from_xref(
144
+ xref,
145
+ relation: TraversalNode::DB_XREF_RELATION,
146
+ error: "not found: #{accession}"
147
+ )
148
+ else
149
+ raise NotFoundError, "not found: sra-run/#{accession}"
150
+ end
151
+ end
152
+ end
153
+
154
+ def unseen_fetchable_xrefs(xrefs, seen_keys)
155
+ xrefs.each_with_object([]) do |xref, selected|
156
+ next unless traversable_xref?(xref)
157
+
158
+ accession = xref_accession(xref)
159
+ next if accession.empty?
160
+
161
+ reference_key = xref_key(xref)
162
+ next if reference_key.empty? || seen_keys.include?(reference_key)
163
+
164
+ seen_keys.add(reference_key)
165
+ selected << xref
166
+ end
167
+ end
168
+
114
169
  def explore_xref(xref, relation, seen_keys, tolerant:, direct_run_fetch_limit:)
115
170
  linked_record = fetch_xref_record(xref)
116
171
  explore(
@@ -127,20 +182,17 @@ module Dratools
127
182
  end
128
183
 
129
184
  def xref_key(xref)
130
- reference_url = xref[DdbjRecordFields::URL_KEY].to_s
131
- return reference_url unless reference_url.empty?
185
+ xref_accession(xref)
186
+ end
132
187
 
188
+ def xref_accession(xref)
133
189
  (xref[DdbjRecordFields::ID_KEY] || xref[DdbjRecordFields::IDENTIFIER_KEY]).to_s
134
190
  end
135
191
 
136
192
  def fetch_xref_record(xref)
137
- if (resource_match = xref[DdbjRecordFields::URL_KEY].to_s.match(XREF_URL_PATTERN))
138
- @client.fetch_resource_record(resource_match[1], resource_match[2])
139
- elsif xref[DdbjRecordFields::ID_KEY] || xref[DdbjRecordFields::IDENTIFIER_KEY]
140
- fetch_xref_by_identifier(xref)
141
- else
142
- raise InvalidRecordError, 'sra-run xref has no URL or id'
143
- end
193
+ raise InvalidRecordError, 'xref has no identifier' if xref_accession(xref).empty?
194
+
195
+ fetch_xref_by_identifier(xref)
144
196
  end
145
197
 
146
198
  def fetch_xref_by_identifier(xref)
@@ -160,15 +212,13 @@ module Dratools
160
212
 
161
213
  def run_record?(ddbj_record)
162
214
  record_type = ddbj_record[DdbjRecordFields::TYPE_KEY]
163
- return record_type == DdbjRecordFields::SRA_RUN_RESOURCE_TYPE if record_type
164
-
165
- ddbj_record[DdbjRecordFields::DOWNLOAD_URL_KEY].is_a?(Array)
215
+ record_type == DdbjRecordFields::SRA_RUN_RESOURCE_TYPE
166
216
  end
167
217
 
168
218
  def node_from_record(ddbj_record, relation:)
169
219
  TraversalNode.new(
170
220
  relation: relation,
171
- type: ddbj_record[DdbjRecordFields::TYPE_KEY] || inferred_record_type(ddbj_record),
221
+ type: ddbj_record[DdbjRecordFields::TYPE_KEY],
172
222
  accession: record_accession(ddbj_record),
173
223
  object_type: ddbj_record['objectType'],
174
224
  record: run_record?(ddbj_record) ? ddbj_record : nil
@@ -190,9 +240,5 @@ module Dratools
190
240
  ddbj_record[DdbjRecordFields::ID_KEY] ||
191
241
  ddbj_record[DdbjRecordFields::PRIMARY_ID_KEY]
192
242
  end
193
-
194
- def inferred_record_type(ddbj_record)
195
- DdbjRecordFields::SRA_RUN_RESOURCE_TYPE if run_record?(ddbj_record)
196
- end
197
243
  end
198
244
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Dratools
4
4
  NAME = 'dratools'
5
- VERSION = '0.0.1'
5
+ VERSION = '0.0.2'
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dratools
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - kojix2