sfdown 0.1.0
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 +7 -0
- data/CHANGELOG.md +15 -0
- data/DOC.md +51 -0
- data/LICENSE.txt +21 -0
- data/README.md +30 -0
- data/exe/sfdown +6 -0
- data/lib/sfdown/version.rb +5 -0
- data/lib/sfdown.rb +601 -0
- metadata +70 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fa096fb3592683630bc0c865b1d5d0108e9a513f9a56585d429f7cc3e6d0d7b9
|
|
4
|
+
data.tar.gz: 9fc819fc96022335efc1c6f8f25a139c1cbe7a02ef9b4c20be5927b1952768fa
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fb087cc0e7247b3cb34d4382e5a01b65cc1f4d1aea1c1ecbedf7f80ff40055c047f35060b3e452e61fa30d223357bf50acf029148198e484af30262caadf7403
|
|
7
|
+
data.tar.gz: b57deab485055a92164f7d974239154022b09fc77a49d9d0a86991bd45948a50b57559a06750617295366db7c5e508476d4ce12ebb9d53f3892474d8bfc0dabf
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
## [1.0.0] - 2026-07-22
|
|
6
|
+
|
|
7
|
+
Initial release.
|
|
8
|
+
|
|
9
|
+
- Two-stage download: map the directory tree, then fetch files.
|
|
10
|
+
- Scrapes the SourceForge "Files" pages with Nokogiri (HTML table + the
|
|
11
|
+
`net.sf.files` JS metadata object).
|
|
12
|
+
- Preserves file and folder timestamps.
|
|
13
|
+
- Live two-line status bar with per-stage analytics.
|
|
14
|
+
- Options: `-c` concurrency, `-m` metadata, `-n` structure-only, `-o` output,
|
|
15
|
+
`-t` timeout, `-s` sleep (first two still unimplemented).
|
data/DOC.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# SourceForge project format
|
|
2
|
+
|
|
3
|
+
This document contains essential information for automatically parsing and downloading a SourceForge project.
|
|
4
|
+
|
|
5
|
+
The info is current as of 2026-07-21.
|
|
6
|
+
|
|
7
|
+
## URL templates
|
|
8
|
+
|
|
9
|
+
The first two templates allow to browse and map out the directory tree. The third one can be used to fetch each found file.
|
|
10
|
+
|
|
11
|
+
- Project tree root: `https://sourceforge.net/projects/[PROJECT_NAME]/files/`
|
|
12
|
+
- Directory page: `https://sourceforge.net/projects/[PROJECT_NAME]/files/[FULL_PATH]/`
|
|
13
|
+
- Direct file download: `https://downloads.sourceforge.net/project/[PROJECT_NAME]/[FULL_PATH]`
|
|
14
|
+
|
|
15
|
+
The direct file download link might actually redirect a few times (via HTTP 3xx codes). In those cases the `location` header link should be followed until the final link can be resolved.
|
|
16
|
+
|
|
17
|
+
## HTML structure
|
|
18
|
+
|
|
19
|
+
The current relevant structure of each directory's HTML page:
|
|
20
|
+
|
|
21
|
+
- Contents stored in a **<table>** with id `files_list`.
|
|
22
|
+
- It has the following 4 **<col>** elements (not within a **<colgroup>**) with self-explanatory classes: `name-column`, `date-column`, `size-column` and `downloads-column`.
|
|
23
|
+
- Each **<tr>** in its **<tbody>** represents an entry of the directory. The class is either `folder` or `file`, and the title is the folder / file name.
|
|
24
|
+
- Each **<th>** / **<td>** represents an attribute of the corresponding entry:
|
|
25
|
+
- The first one has a headers attribute of `files_name_h`, and contains a **<span>** element with class `name` whose content should match the title of the row, i.e., the folder / file name. It also contains an **<a>** link relative to SourceForge's root:
|
|
26
|
+
- For folders this link can be used to navigate the directory tree.
|
|
27
|
+
- For files this link should be ignored, as it directs to the user-facing download page, instead of the direct link found in [URL templates](#url-templates).
|
|
28
|
+
- The second one has a headers attribute of `files_date_h`, and contains the timestamp inside an **<abbr>** element. The full one follows the format `YYYY-mm-dd HH:MM:SS UTC`, the short one `YYYY-mm-dd`.
|
|
29
|
+
- The third one has a headers attribute of `files_size_h`. It is empty for folders, and is in human-readable form for files (e.g. `3.4 MB`).
|
|
30
|
+
- The fourth one has a headers attribute of `files_downloads_h`, and contains a **<span>** element with class `count` whose content is an integer representing the number of weekly downloads. For folders these are the counts of its contents aggregated recursively.
|
|
31
|
+
|
|
32
|
+
## Additional metadata
|
|
33
|
+
|
|
34
|
+
Additional file information can be found inside a **<script>** element that defines the object `net.sf.files` with metadata for each folder / file:
|
|
35
|
+
|
|
36
|
+
- The **key** is the folder / file name, i.e. the title attribute of the corresponding table row.
|
|
37
|
+
- The **value** is another object with the corresponding properties, potentially including the following ones:
|
|
38
|
+
- **name**: String, the folder / file name, should probably coincide with all other mentioned instances of this name.
|
|
39
|
+
- **path**: String, file base path relative to project's root.
|
|
40
|
+
- **download_url**: String, absolute link. For files, links to user-facing download page, same as **<a>** attribute in the **<td>** element. For folders, redirects to project root.
|
|
41
|
+
- **url**: String, relative link. For folders, links to directory page. For files, redirects to user-facing download page.
|
|
42
|
+
- **full_path**: String, file path relative to project's root (base path + name).
|
|
43
|
+
- **type**: String, `d` for directories and `f` for files.
|
|
44
|
+
- **downloads**: Integer, total download count, aggregated recursively for folders.
|
|
45
|
+
- **sha1**: String, SHA1 hash as lowercase hexdump for files, empty for folders.
|
|
46
|
+
- **md5**: String, MD5 hash as lowercase hexdump for files, empty for folders.
|
|
47
|
+
- **default**: String, comma-separated list of supported OS's for files, empty for folders.
|
|
48
|
+
- **downloadable**: Boolean, generally `true` for files and `false` for folders.
|
|
49
|
+
- **files_url**: String, relative link to project tree root (see [URL templates](#url-templates)).
|
|
50
|
+
|
|
51
|
+
Note that in the above descriptions a "paths" are w.r.t. the project tree itself, whereas "urls" are w.r.t SourceForge's server, either absolute or relative.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 edelkas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# sfdown
|
|
2
|
+
|
|
3
|
+
SourceForge project downloader. Will clone the project's directory tree and fetch all files.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
gem install sfdown
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
Run with:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
sfdown project_name [-mn] [-c concurrent] [-o output] [-t timeout] [-s sleep]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Arguments:
|
|
20
|
+
|
|
21
|
+
| Arguments | Default | Description |
|
|
22
|
+
|--|--|--|
|
|
23
|
+
| `-c` or `--concurrent` | 1 | Number of parallel downloads |
|
|
24
|
+
|`-m` or `--metadata`|False|Save metadata to disk at project's root|
|
|
25
|
+
|`-n` or `--no`|False|Only fetch directory tree structure and file metadata, not files|
|
|
26
|
+
|`-o` or `--output`|Current dir|Path to store project's root|
|
|
27
|
+
|`-t` or `--timeout`|5|Timeout for each GET request|
|
|
28
|
+
|`-s` or `--sleep`|0|Wait in-between requests|
|
|
29
|
+
|
|
30
|
+
**Note**: `-c` and `-m` are still unimplemented.
|
data/exe/sfdown
ADDED
data/lib/sfdown.rb
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# sfdown — SourceForge project downloader.
|
|
4
|
+
# Clones a project's directory tree and downloads its files by scraping the
|
|
5
|
+
# SF "Files" pages.
|
|
6
|
+
|
|
7
|
+
require "optparse"
|
|
8
|
+
require "net/http"
|
|
9
|
+
require "uri"
|
|
10
|
+
require "json"
|
|
11
|
+
require "time"
|
|
12
|
+
require "io/console"
|
|
13
|
+
require "fileutils"
|
|
14
|
+
require "nokogiri"
|
|
15
|
+
|
|
16
|
+
require_relative "sfdown/version"
|
|
17
|
+
|
|
18
|
+
module Sfdown
|
|
19
|
+
# Parsed CLI configuration.
|
|
20
|
+
Config = Struct.new(
|
|
21
|
+
:project, :concurrent, :metadata, :no, :output, :timeout, :sleep,
|
|
22
|
+
keyword_init: true
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
module Options
|
|
26
|
+
DEFAULTS = {
|
|
27
|
+
concurrent: 1,
|
|
28
|
+
metadata: false,
|
|
29
|
+
no: false,
|
|
30
|
+
output: ".",
|
|
31
|
+
timeout: 5,
|
|
32
|
+
sleep: 0.0
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# Build the OptionParser and the options hash it fills.
|
|
36
|
+
def self.parser(opts)
|
|
37
|
+
OptionParser.new do |o|
|
|
38
|
+
o.banner = "Usage: sfdown project_name [-mn] " \
|
|
39
|
+
"[-c concurrent] [-o output] [-t timeout] [-s sleep]"
|
|
40
|
+
|
|
41
|
+
o.on("-c", "--concurrent N", Integer, "Number of parallel downloads (default 1) (unimplemented)") do |v|
|
|
42
|
+
raise OptionParser::InvalidArgument, "#{v} (must be >= 1)" if v < 1
|
|
43
|
+
|
|
44
|
+
opts[:concurrent] = v
|
|
45
|
+
end
|
|
46
|
+
o.on("-m", "--metadata", "Save metadata to disk at project's root (unimplemented)") { opts[:metadata] = true }
|
|
47
|
+
o.on("-n", "--no", "Only fetch directory tree structure and file metadata, not files") { opts[:no] = true }
|
|
48
|
+
o.on("-o", "--output PATH", String, "Path to store project's root (default current dir)") { |v| opts[:output] = v }
|
|
49
|
+
o.on("-t", "--timeout SECS", Integer, "Timeout for each GET request (default 5)") do |v|
|
|
50
|
+
raise OptionParser::InvalidArgument, "#{v} (must be > 0)" if v <= 0
|
|
51
|
+
|
|
52
|
+
opts[:timeout] = v
|
|
53
|
+
end
|
|
54
|
+
o.on("-s", "--sleep SECS", Float, "Wait in-between requests (default 0)") do |v|
|
|
55
|
+
raise OptionParser::InvalidArgument, "#{v} (must be >= 0)" if v.negative?
|
|
56
|
+
|
|
57
|
+
opts[:sleep] = v
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Parse argv into a Config, or print the banner and exit non-zero on misuse.
|
|
63
|
+
def self.parse(argv)
|
|
64
|
+
opts = DEFAULTS.dup
|
|
65
|
+
parser = parser(opts)
|
|
66
|
+
rest = parser.parse(argv)
|
|
67
|
+
|
|
68
|
+
raise OptionParser::ParseError, "missing project_name" if rest.empty?
|
|
69
|
+
raise OptionParser::ParseError, "unexpected arguments: #{rest[1..].join(' ')}" if rest.size > 1
|
|
70
|
+
|
|
71
|
+
Config.new(project: rest.first, **opts)
|
|
72
|
+
rescue OptionParser::ParseError => e
|
|
73
|
+
warn "Error: #{e.message}\n\n"
|
|
74
|
+
warn parser
|
|
75
|
+
exit 1
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# SourceForge URL templates (see DOC.md).
|
|
80
|
+
module Sf
|
|
81
|
+
BASE = "https://sourceforge.net"
|
|
82
|
+
DOWNLOADS = "https://downloads.sourceforge.net"
|
|
83
|
+
|
|
84
|
+
module_function
|
|
85
|
+
|
|
86
|
+
# Percent-encode a single path segment, byte-wise (keeps UTF-8 safe; space -> %20).
|
|
87
|
+
def encode_segment(name)
|
|
88
|
+
name.b.gsub(%r{[^A-Za-z0-9\-_.~]}n) { |c| format("%%%02X", c.ord) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def encode_path(path)
|
|
92
|
+
path.split("/").map { |s| encode_segment(s) }.join("/")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Directory page URL; path is the full path relative to the project root ("" = root).
|
|
96
|
+
def dir_url(project, path = "")
|
|
97
|
+
suffix = path.empty? ? "" : "#{encode_path(path)}/"
|
|
98
|
+
"#{BASE}/projects/#{encode_segment(project)}/files/#{suffix}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Direct file download URL (redirects to a mirror).
|
|
102
|
+
def file_url(project, full_path)
|
|
103
|
+
"#{DOWNLOADS}/project/#{encode_segment(project)}/#{encode_path(full_path)}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# One tree entry (folder or file).
|
|
108
|
+
Node = Struct.new(:name, :type, :path, :timestamp, :size, :downloads, :children, keyword_init: true) do
|
|
109
|
+
def dir? = type == :d
|
|
110
|
+
def file? = type == :f
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# HTTP GET/download with timeout, redirect-following and per-request sleep.
|
|
114
|
+
class Http
|
|
115
|
+
Error = Class.new(StandardError)
|
|
116
|
+
MAX_REDIRECTS = 10
|
|
117
|
+
|
|
118
|
+
def initialize(timeout: 5, sleep: 0)
|
|
119
|
+
@timeout = timeout
|
|
120
|
+
@sleep = sleep
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# GET url (following redirects), returning the response body.
|
|
124
|
+
def get(url)
|
|
125
|
+
with_sleep { with_response(url) { |res| res.body } }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Stream url to dest in chunks (never buffered whole); yields each chunk's bytesize.
|
|
129
|
+
def download(url, dest)
|
|
130
|
+
with_sleep do
|
|
131
|
+
with_response(url) do |res|
|
|
132
|
+
File.open(dest, "wb") do |f|
|
|
133
|
+
res.read_body do |chunk|
|
|
134
|
+
f.write(chunk)
|
|
135
|
+
yield chunk.bytesize if block_given?
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# Sleep once per logical fetch (not per redirect hop).
|
|
145
|
+
def with_sleep
|
|
146
|
+
yield
|
|
147
|
+
ensure
|
|
148
|
+
sleep(@sleep) if @sleep.positive?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def with_response(url, limit = MAX_REDIRECTS, &block)
|
|
152
|
+
uri = url.is_a?(URI) ? url : URI(url)
|
|
153
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
154
|
+
open_timeout: @timeout, read_timeout: @timeout) do |http|
|
|
155
|
+
http.request(Net::HTTP::Get.new(uri)) do |res|
|
|
156
|
+
case res
|
|
157
|
+
when Net::HTTPRedirection
|
|
158
|
+
raise Error, "too many redirects for #{url}" if limit <= 0
|
|
159
|
+
|
|
160
|
+
return with_response(URI.join(uri.to_s, res["location"]), limit - 1, &block)
|
|
161
|
+
when Net::HTTPSuccess
|
|
162
|
+
return block.call(res)
|
|
163
|
+
else
|
|
164
|
+
raise Error, "HTTP #{res.code} #{res.message} for #{uri}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Turns a directory page's HTML into child Nodes, merging table rows with
|
|
172
|
+
# the net.sf.files JS object.
|
|
173
|
+
class Parser
|
|
174
|
+
UNITS = { "b" => 1, "kb" => 1024, "mb" => 1024**2, "gb" => 1024**3, "tb" => 1024**4 }.freeze
|
|
175
|
+
|
|
176
|
+
def initialize(project)
|
|
177
|
+
@project = project
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Parse HTML into child Nodes. parent_path is the directory's full path ("" = root).
|
|
181
|
+
def parse(html, parent_path = "")
|
|
182
|
+
doc = Nokogiri::HTML(html)
|
|
183
|
+
meta = extract_metadata(html)
|
|
184
|
+
doc.css("table#files_list > tbody > tr").filter_map { |row| node_from_row(row, parent_path, meta) }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def node_from_row(row, parent_path, meta)
|
|
190
|
+
name = row["title"].to_s
|
|
191
|
+
return nil if name.empty?
|
|
192
|
+
|
|
193
|
+
type = row["class"].to_s.split.include?("folder") ? :d : :f
|
|
194
|
+
path = parent_path.empty? ? name : "#{parent_path}/#{name}"
|
|
195
|
+
info = meta[name] || {}
|
|
196
|
+
|
|
197
|
+
Node.new(
|
|
198
|
+
name: name,
|
|
199
|
+
type: type,
|
|
200
|
+
path: path,
|
|
201
|
+
timestamp: parse_time(row),
|
|
202
|
+
size: type == :f ? parse_size(cell_text(row, "files_size_h")) : 0,
|
|
203
|
+
downloads: downloads_for(row, info),
|
|
204
|
+
children: []
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def cell(row, header)
|
|
209
|
+
row.at_css(%([headers="#{header}"]))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def cell_text(row, header)
|
|
213
|
+
cell(row, header)&.text.to_s.strip
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def parse_time(row)
|
|
217
|
+
full = cell(row, "files_date_h")&.at_css("abbr")&.[]("title")
|
|
218
|
+
full && !full.empty? ? Time.parse(full).utc : nil
|
|
219
|
+
rescue ArgumentError
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# "1.7 MB" / "602.1 kB" -> bytes (approximate; SF exposes no exact byte count).
|
|
224
|
+
def parse_size(text)
|
|
225
|
+
m = text.to_s.match(/([\d.]+)\s*([kmgt]?b|bytes?)/i) or return 0
|
|
226
|
+
|
|
227
|
+
unit = m[2].downcase
|
|
228
|
+
unit = "b" if unit.start_with?("byte")
|
|
229
|
+
(m[1].to_f * (UNITS[unit] || 1)).round
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Prefer the JS object's total downloads; fall back to the HTML weekly count.
|
|
233
|
+
def downloads_for(row, info)
|
|
234
|
+
return info["downloads"] if info["downloads"].is_a?(Integer)
|
|
235
|
+
|
|
236
|
+
count = cell(row, "files_downloads_h")&.at_css("span.count")&.text
|
|
237
|
+
count ? count.delete(",").to_i : 0
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Extract and JSON-parse the `net.sf.files = { ... }` object literal.
|
|
241
|
+
def extract_metadata(html)
|
|
242
|
+
idx = html.index("net.sf.files") or return {}
|
|
243
|
+
start = html.index("{", idx) or return {}
|
|
244
|
+
finish = matching_brace(html, start) or return {}
|
|
245
|
+
|
|
246
|
+
JSON.parse(html[start..finish])
|
|
247
|
+
rescue JSON::ParserError
|
|
248
|
+
{}
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Index of the brace matching the one at `start`, honoring string literals.
|
|
252
|
+
def matching_brace(str, start)
|
|
253
|
+
depth = 0
|
|
254
|
+
in_str = false
|
|
255
|
+
esc = false
|
|
256
|
+
(start...str.length).each do |i|
|
|
257
|
+
c = str[i]
|
|
258
|
+
if in_str
|
|
259
|
+
if esc then esc = false
|
|
260
|
+
elsif c == "\\" then esc = true
|
|
261
|
+
elsif c == '"' then in_str = false
|
|
262
|
+
end
|
|
263
|
+
elsif c == '"' then in_str = true
|
|
264
|
+
elsif c == "{" then depth += 1
|
|
265
|
+
elsif c == "}"
|
|
266
|
+
depth -= 1
|
|
267
|
+
return i if depth.zero?
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
nil
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Stage 1: walk the directory tree from the root, building the in-memory Node
|
|
275
|
+
# tree. Network only — never touches the filesystem. Sequential for now
|
|
276
|
+
# (concurrency arrives in a later milestone).
|
|
277
|
+
class Mapper
|
|
278
|
+
attr_reader :folders, :files, :total_size, :failures
|
|
279
|
+
|
|
280
|
+
# log: callback for non-fatal diagnostics (defaults to stderr; the CLI routes
|
|
281
|
+
# it through StatusBar#log so the bar stays pinned at the bottom).
|
|
282
|
+
def initialize(project, http, parser, log: ->(m) { warn m })
|
|
283
|
+
@project = project
|
|
284
|
+
@http = http
|
|
285
|
+
@parser = parser
|
|
286
|
+
@log = log
|
|
287
|
+
@folders = 0
|
|
288
|
+
@files = 0
|
|
289
|
+
@total_size = 0
|
|
290
|
+
@failures = 0
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Build and return the synthetic root Node with the whole tree mapped.
|
|
294
|
+
# Yields each directory Node right after its page is parsed (progress hook).
|
|
295
|
+
def map(&on_page)
|
|
296
|
+
@on_page = on_page
|
|
297
|
+
root = Node.new(name: @project, type: :d, path: "", timestamp: nil, size: 0, downloads: 0, children: [])
|
|
298
|
+
walk(root)
|
|
299
|
+
aggregate(root)
|
|
300
|
+
# Root timestamp isn't provided by SF; use the newest child's.
|
|
301
|
+
root.timestamp = root.children.map(&:timestamp).compact.max
|
|
302
|
+
root
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
def walk(node)
|
|
308
|
+
begin
|
|
309
|
+
html = @http.get(Sf.dir_url(@project, node.path))
|
|
310
|
+
node.children = @parser.parse(html, node.path)
|
|
311
|
+
rescue Http::Error => e
|
|
312
|
+
@failures += 1
|
|
313
|
+
@log.call("WARN: failed to map #{node.path.empty? ? '(root)' : node.path}: #{e.message}")
|
|
314
|
+
node.children = []
|
|
315
|
+
end
|
|
316
|
+
@on_page&.call(node)
|
|
317
|
+
|
|
318
|
+
node.children.each do |child|
|
|
319
|
+
if child.dir?
|
|
320
|
+
@folders += 1
|
|
321
|
+
walk(child)
|
|
322
|
+
else
|
|
323
|
+
@files += 1
|
|
324
|
+
@total_size += child.size
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Post-order pass: recompute folder size/downloads from their leaves.
|
|
330
|
+
def aggregate(node)
|
|
331
|
+
return if node.file?
|
|
332
|
+
|
|
333
|
+
node.children.each { |c| aggregate(c) }
|
|
334
|
+
node.size = node.children.sum(&:size)
|
|
335
|
+
node.downloads = node.children.sum(&:downloads)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Stage 2: create the local tree and download files. Counters are mutex-guarded
|
|
340
|
+
# so a ticker thread can snapshot progress while workers advance it. Sequential
|
|
341
|
+
# for now (concurrency arrives in a later milestone).
|
|
342
|
+
class Downloader
|
|
343
|
+
ILLEGAL = /[<>:"\\|?*\x00-\x1f]/.freeze # Windows-illegal chars (per path segment)
|
|
344
|
+
|
|
345
|
+
attr_reader :total_files, :total_bytes
|
|
346
|
+
|
|
347
|
+
# dest is the project root directory (<output>/<project>).
|
|
348
|
+
def initialize(project, http, dest, log: ->(m) { warn m })
|
|
349
|
+
@project = project
|
|
350
|
+
@http = http
|
|
351
|
+
@dest = dest
|
|
352
|
+
@log = log
|
|
353
|
+
@mutex = Mutex.new
|
|
354
|
+
@files = []
|
|
355
|
+
@total_files = 0
|
|
356
|
+
@total_bytes = 0
|
|
357
|
+
@files_done = 0
|
|
358
|
+
@bytes_done = 0
|
|
359
|
+
@failures = 0
|
|
360
|
+
@current = ""
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def files_done = @mutex.synchronize { @files_done }
|
|
364
|
+
def bytes_done = @mutex.synchronize { @bytes_done }
|
|
365
|
+
def failures = @mutex.synchronize { @failures }
|
|
366
|
+
def current = @mutex.synchronize { @current }
|
|
367
|
+
|
|
368
|
+
# Create every directory up front and collect the flat file list + totals.
|
|
369
|
+
def prepare(root)
|
|
370
|
+
FileUtils.mkdir_p(@dest)
|
|
371
|
+
each_node(root) do |n|
|
|
372
|
+
FileUtils.mkdir_p(local_path(n)) if n.dir? && !n.path.empty?
|
|
373
|
+
@files << n if n.file?
|
|
374
|
+
end
|
|
375
|
+
@total_files = @files.size
|
|
376
|
+
@total_bytes = @files.sum(&:size)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def download_all
|
|
380
|
+
@files.each { |f| download_one(f) }
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Apply folder timestamps last, deepest-first — writing files into a directory
|
|
384
|
+
# bumps its mtime, so folders must be stamped after their contents are final.
|
|
385
|
+
def apply_folder_times(root)
|
|
386
|
+
dirs = []
|
|
387
|
+
each_node(root) { |n| dirs << n if n.dir? }
|
|
388
|
+
dirs.sort_by { |n| -n.path.count("/") }.each do |n|
|
|
389
|
+
path = local_path(n)
|
|
390
|
+
File.utime(n.timestamp, n.timestamp, path) if n.timestamp && File.directory?(path)
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
private
|
|
395
|
+
|
|
396
|
+
def download_one(node)
|
|
397
|
+
@mutex.synchronize { @current = node.path }
|
|
398
|
+
dest = local_path(node)
|
|
399
|
+
@http.download(Sf.file_url(@project, node.path), dest) do |n|
|
|
400
|
+
@mutex.synchronize { @bytes_done += n }
|
|
401
|
+
end
|
|
402
|
+
File.utime(node.timestamp, node.timestamp, dest) if node.timestamp
|
|
403
|
+
@mutex.synchronize { @files_done += 1 }
|
|
404
|
+
rescue Http::Error => e
|
|
405
|
+
@mutex.synchronize { @failures += 1 }
|
|
406
|
+
@log.call("WARN: failed to download #{node.path}: #{e.message}")
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def each_node(node, &blk)
|
|
410
|
+
blk.call(node)
|
|
411
|
+
node.children.each { |c| each_node(c, &blk) }
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Local path for a node; sanitizes each segment for the filesystem.
|
|
415
|
+
def local_path(node)
|
|
416
|
+
return @dest if node.path.empty?
|
|
417
|
+
|
|
418
|
+
File.join(@dest, *node.path.split("/").map { |s| s.gsub(ILLEGAL, "_") })
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Human-readable formatting for sizes and durations.
|
|
423
|
+
module Fmt
|
|
424
|
+
module_function
|
|
425
|
+
|
|
426
|
+
UNITS = %w[B KB MB GB TB].freeze
|
|
427
|
+
|
|
428
|
+
def size(bytes)
|
|
429
|
+
v = bytes.to_f
|
|
430
|
+
i = 0
|
|
431
|
+
while v >= 1024 && i < UNITS.size - 1
|
|
432
|
+
v /= 1024
|
|
433
|
+
i += 1
|
|
434
|
+
end
|
|
435
|
+
i.zero? ? "#{bytes} B" : format("%.1f %s", v, UNITS[i])
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def duration(secs)
|
|
439
|
+
return format("%.1fs", secs) if secs < 60
|
|
440
|
+
|
|
441
|
+
m, s = secs.round.divmod(60)
|
|
442
|
+
h, m = m.divmod(60)
|
|
443
|
+
h.zero? ? format("%dm%02ds", m, s) : format("%dh%02dm%02ds", h, m, s)
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Two-line status region pinned to the bottom of the terminal, redrawn in
|
|
448
|
+
# place with ANSI. Thread-safe: a mutex serializes updates, logs and teardown
|
|
449
|
+
# so the ticker and log() never interleave. Assumes a VT-capable terminal.
|
|
450
|
+
class StatusBar
|
|
451
|
+
def initialize(out: $stdout, width: nil)
|
|
452
|
+
@out = out
|
|
453
|
+
@width = width
|
|
454
|
+
@mutex = Mutex.new
|
|
455
|
+
@top = ""
|
|
456
|
+
@bottom = ""
|
|
457
|
+
@drawn = false
|
|
458
|
+
@out.sync = true
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Set both lines and redraw in place.
|
|
462
|
+
def update(top, bottom)
|
|
463
|
+
@mutex.synchronize do
|
|
464
|
+
@top = top
|
|
465
|
+
@bottom = bottom
|
|
466
|
+
erase
|
|
467
|
+
draw
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Print a message above the bar (scrolls into history), then redraw the bar.
|
|
472
|
+
def log(msg)
|
|
473
|
+
@mutex.synchronize do
|
|
474
|
+
erase
|
|
475
|
+
@out.puts(msg)
|
|
476
|
+
draw
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Remove the bar entirely (on completion).
|
|
481
|
+
def finish
|
|
482
|
+
@mutex.synchronize { erase }
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
private
|
|
486
|
+
|
|
487
|
+
# Park the cursor at the region's top-left and clear from there down.
|
|
488
|
+
def erase
|
|
489
|
+
return unless @drawn
|
|
490
|
+
|
|
491
|
+
@out.print("\r\e[1A\e[J")
|
|
492
|
+
@drawn = false
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def draw
|
|
496
|
+
@out.print("#{truncate(@top)}\n#{truncate(@bottom)}")
|
|
497
|
+
@drawn = true
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Truncate to terminal width so a line never wraps (wrapping corrupts the
|
|
501
|
+
# up-count on the next redraw).
|
|
502
|
+
def truncate(line)
|
|
503
|
+
w = width
|
|
504
|
+
line.length > w ? line[0, w] : line
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def width
|
|
508
|
+
@width || IO.console&.winsize&.last || 80
|
|
509
|
+
rescue StandardError
|
|
510
|
+
80
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Command-line interface: wires the stages together and drives the status bar.
|
|
515
|
+
module CLI
|
|
516
|
+
module_function
|
|
517
|
+
|
|
518
|
+
# Debug helper: indented tree listing.
|
|
519
|
+
def dump_tree(node, depth = 0)
|
|
520
|
+
puts "#{' ' * depth}#{node.name}#{node.dir? ? '/' : ''}"
|
|
521
|
+
node.children.each { |c| dump_tree(c, depth + 1) }
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Stage-1 analytics line: starts with the stage number, ends with elapsed time.
|
|
525
|
+
def stage1_line(mapper, elapsed)
|
|
526
|
+
format("[1] folders: %d | files: %d | size: %s | elapsed: %s",
|
|
527
|
+
mapper.folders, mapper.files, Fmt.size(mapper.total_size), Fmt.duration(elapsed))
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Stage-2 analytics line: elapsed is the global (both-stage) time, complemented
|
|
531
|
+
# by an estimated total = elapsed + remaining_bytes / speed.
|
|
532
|
+
def stage2_line(dl, global_elapsed, speed, total_est)
|
|
533
|
+
format("[2] files: %d/%d | %s/%s | speed: %s/s | elapsed: %s / ~%s",
|
|
534
|
+
dl.files_done, dl.total_files, Fmt.size(dl.bytes_done), Fmt.size(dl.total_bytes),
|
|
535
|
+
Fmt.size(speed), Fmt.duration(global_elapsed), Fmt.duration(total_est))
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def render_stage2(bar, dl, global_start, stage2_start)
|
|
539
|
+
now = Time.now
|
|
540
|
+
s2 = now - stage2_start
|
|
541
|
+
speed = s2.positive? ? dl.bytes_done / s2 : 0
|
|
542
|
+
remaining = [dl.total_bytes - dl.bytes_done, 0].max
|
|
543
|
+
eta = speed.positive? ? remaining / speed : 0
|
|
544
|
+
bar.update("Fetching #{dl.current}", stage2_line(dl, now - global_start, speed, (now - global_start) + eta))
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Drive stage 2: prepare the tree, download files while a ~1s ticker refreshes
|
|
548
|
+
# the bar, apply folder timestamps, then log the stage summary.
|
|
549
|
+
def run_stage2(downloader, root, bar, global_start)
|
|
550
|
+
downloader.prepare(root)
|
|
551
|
+
stage2_start = Time.now
|
|
552
|
+
ticker = Thread.new do
|
|
553
|
+
loop do
|
|
554
|
+
render_stage2(bar, downloader, global_start, stage2_start)
|
|
555
|
+
sleep 1
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
downloader.download_all
|
|
560
|
+
ticker.kill
|
|
561
|
+
ticker.join
|
|
562
|
+
render_stage2(bar, downloader, global_start, stage2_start) # final 100% frame
|
|
563
|
+
downloader.apply_folder_times(root)
|
|
564
|
+
|
|
565
|
+
s2 = Time.now - stage2_start
|
|
566
|
+
avg = s2.positive? ? downloader.bytes_done / s2 : 0
|
|
567
|
+
summary = format("Stage 2: %d/%d files, %s in %s (avg %s/s)",
|
|
568
|
+
downloader.files_done, downloader.total_files,
|
|
569
|
+
Fmt.size(downloader.bytes_done), Fmt.duration(s2), Fmt.size(avg))
|
|
570
|
+
summary += " (#{downloader.failures} failures)" if downloader.failures.positive?
|
|
571
|
+
bar.log(summary)
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def main(argv)
|
|
575
|
+
config = Options.parse(argv)
|
|
576
|
+
http = Http.new(timeout: config.timeout, sleep: config.sleep)
|
|
577
|
+
parser = Parser.new(config.project)
|
|
578
|
+
bar = StatusBar.new
|
|
579
|
+
mapper = Mapper.new(config.project, http, parser, log: bar.method(:log))
|
|
580
|
+
|
|
581
|
+
start = Time.now
|
|
582
|
+
root = mapper.map do |node|
|
|
583
|
+
bar.update("Parsing #{node.path}/", stage1_line(mapper, Time.now - start))
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
summary = format("Stage 1: %d folders, %d files, %s total in %s",
|
|
587
|
+
mapper.folders, mapper.files, Fmt.size(mapper.total_size),
|
|
588
|
+
Fmt.duration(Time.now - start))
|
|
589
|
+
summary += " (#{mapper.failures} failures)" if mapper.failures.positive?
|
|
590
|
+
bar.log(summary)
|
|
591
|
+
|
|
592
|
+
unless config.no
|
|
593
|
+
dest = File.join(config.output, config.project)
|
|
594
|
+
downloader = Downloader.new(config.project, http, dest, log: bar.method(:log))
|
|
595
|
+
run_stage2(downloader, root, bar, start)
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
bar.finish
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sfdown
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- edelkas
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-22 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: nokogiri
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.11'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.11'
|
|
27
|
+
description: Clones a SourceForge project's directory tree and downloads its files
|
|
28
|
+
by scraping the Files pages (SourceForge has no official API). Runs in two stages
|
|
29
|
+
— map the tree, then download — with a live status bar and optional JSON metadata.
|
|
30
|
+
email:
|
|
31
|
+
- edlucasma@gmail.com
|
|
32
|
+
executables:
|
|
33
|
+
- sfdown
|
|
34
|
+
extensions: []
|
|
35
|
+
extra_rdoc_files: []
|
|
36
|
+
files:
|
|
37
|
+
- CHANGELOG.md
|
|
38
|
+
- DOC.md
|
|
39
|
+
- LICENSE.txt
|
|
40
|
+
- README.md
|
|
41
|
+
- exe/sfdown
|
|
42
|
+
- lib/sfdown.rb
|
|
43
|
+
- lib/sfdown/version.rb
|
|
44
|
+
homepage: https://github.com/edelkas/sfdown
|
|
45
|
+
licenses:
|
|
46
|
+
- MIT
|
|
47
|
+
metadata:
|
|
48
|
+
homepage_uri: https://github.com/edelkas/sfdown
|
|
49
|
+
source_code_uri: https://github.com/edelkas/sfdown
|
|
50
|
+
changelog_uri: https://github.com/edelkas/sfdown/blob/master/CHANGELOG.md
|
|
51
|
+
post_install_message:
|
|
52
|
+
rdoc_options: []
|
|
53
|
+
require_paths:
|
|
54
|
+
- lib
|
|
55
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '3.0'
|
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '0'
|
|
65
|
+
requirements: []
|
|
66
|
+
rubygems_version: 3.4.19
|
|
67
|
+
signing_key:
|
|
68
|
+
specification_version: 4
|
|
69
|
+
summary: SourceForge project downloader.
|
|
70
|
+
test_files: []
|