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 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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "sfdown"
5
+
6
+ Sfdown::CLI.main(ARGV)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sfdown
4
+ VERSION = "0.1.0"
5
+ end
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: []