Module: CLI

Defined in:
lib/CLI.rb

Overview

All CLI-side concerns for the ‘ZMediumToMarkdown` executable. Pulled out of bin/ so it can be exercised by unit tests without spawning processes.

Constant Summary collapse

'https://github.com/ZhgChgLi/ZMediumToMarkdown/blob/main/wiki/Setting-Up-Medium-Cookies-and-a-Cloudflare-Worker-Proxy.md'.freeze
DEFAULT_MEDIUM_HOST =
'https://medium.com/_/graphql'.freeze

Class Method Summary collapse

Class Method Details

.buildSetupBanner(missingCookies:, missingProxy:) ⇒ Object

One-line warning. The wiki has the actual setup steps; we just nudge the user toward it instead of dumping a wall of guidance.



183
184
185
186
187
188
189
190
# File 'lib/CLI.rb', line 183

def buildSetupBanner(missingCookies:, missingProxy:)
    missing = []
    missing << 'Medium cookies (sid / uid)' if missingCookies
    missing << 'Cloudflare Worker proxy (MEDIUM_HOST)' if missingProxy
    return '' if missing.empty?

    "⚠  Missing #{missing.join(' / ')}. Medium / Cloudflare may block the run. Setup guide: #{COOKIE_SETUP_URL}"
end

.cookieMissing?(name) ⇒ Boolean

Returns:

  • (Boolean)


148
149
150
151
# File 'lib/CLI.rb', line 148

def cookieMissing?(name)
    return true unless defined?($cookies) && $cookies.is_a?(Hash)
    $cookies[name].to_s.empty?
end

.cookiesPresent?Boolean

Returns:

  • (Boolean)


153
154
155
# File 'lib/CLI.rb', line 153

def cookiesPresent?
    !cookieMissing?('sid') || !cookieMissing?('uid')
end

.loadCookies!Object

Cookie precedence (highest → lowest):

1. CLI flags          (already written to $cookies in parseArgs)
2. Env vars           (MEDIUM_COOKIE_*)
3. On-disk cache      (~/.config/ZMediumToMarkdown/cookies.json)

Each layer only fills slots the higher layer left empty.



126
127
128
129
# File 'lib/CLI.rb', line 126

def loadCookies!
    loadCookiesFromEnv!
    loadCookiesFromCache!
end

.loadCookiesFromCache!Object



138
139
140
141
142
143
144
145
146
# File 'lib/CLI.rb', line 138

def loadCookiesFromCache!
    cached = CookieCache.load
    return if cached.empty?
    ChromeAuth::TARGET_COOKIES.each do |name|
        value = cached[name]
        next if value.to_s.empty?
        $cookies[name] = value if cookieMissing?(name)
    end
end

.loadCookiesFromEnv!Object



131
132
133
134
135
136
# File 'lib/CLI.rb', line 131

def loadCookiesFromEnv!
    $cookies['sid'] = ENV['MEDIUM_COOKIE_SID'] if cookieMissing?('sid') && !ENV['MEDIUM_COOKIE_SID'].to_s.empty?
    $cookies['uid'] = ENV['MEDIUM_COOKIE_UID'] if cookieMissing?('uid') && !ENV['MEDIUM_COOKIE_UID'].to_s.empty?
    $cookies['cf_clearance'] = ENV['MEDIUM_COOKIE_CF_CLEARANCE'] if cookieMissing?('cf_clearance') && !ENV['MEDIUM_COOKIE_CF_CLEARANCE'].to_s.empty?
    $cookies['_cfuvid'] = ENV['MEDIUM_COOKIE_CFUVID'] if cookieMissing?('_cfuvid') && !ENV['MEDIUM_COOKIE_CFUVID'].to_s.empty?
end

.main(argv, output: $stdout, errput: $stderr, cwd: ENV['PWD'] || ::Dir.pwd) ⇒ Object



20
21
22
23
24
25
26
27
28
# File 'lib/CLI.rb', line 20

def main(argv, output: $stdout, errput: $stderr, cwd: ENV['PWD'] || ::Dir.pwd)
    argv = argv.dup
    argv << '-h' if argv.empty?

    options = parseArgs(argv, errput: errput)
    loadCookies!
    warnAboutMissingSetup(options, errput: errput)
    run(options, cwd, output: output, errput: errput)
end

.parseArgs(argv, errput: $stderr) ⇒ Object



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/CLI.rb', line 30

def parseArgs(argv, errput: $stderr)
    options = {}
    parser = OptionParser.new do |opts|
        opts.banner = "Usage: ZMediumToMarkdown [options]"

        opts.on('-s', '--cookie_sid SID', 'Medium logged-in cookie sid value (or set $MEDIUM_COOKIE_SID)') do |v|
            $cookies['sid'] = v
        end

        opts.on('-d', '--cookie_uid UID', 'Medium logged-in cookie uid value (or set $MEDIUM_COOKIE_UID)') do |v|
            $cookies['uid'] = v
        end

        opts.on('--cookie_cf_clearance VALUE', 'Cloudflare cf_clearance cookie value (or set $MEDIUM_COOKIE_CF_CLEARANCE)') do |v|
            $cookies['cf_clearance'] = v
        end

        opts.on('--cookie_cfuvid VALUE', 'Cloudflare _cfuvid cookie value (or set $MEDIUM_COOKIE_CFUVID)') do |v|
            $cookies['_cfuvid'] = v
        end

        opts.on('-x', '--medium_host URL', 'Cloudflare Worker proxy URL for Medium GraphQL (or set $MEDIUM_HOST). Strongly recommended for CI / bulk runs — see the wiki setup guide.') do |v|
            ENV['MEDIUM_HOST'] = v
        end

        opts.on('-u', '--username USERNAME', 'Download all posts from a Medium username') do |v|
            options[:username] = v
        end

        opts.on('-p', '--postURL POST_URL', 'Download a single post URL') do |v|
            options[:postURL] = v
        end

        opts.on('--jekyll', 'Emit Jekyll-friendly output (combine with -u or -p)') do
            options[:jekyll] = true
        end

        opts.on('-j', '--jekyllUsername USERNAME', 'DEPRECATED: use `--jekyll -u USERNAME`') do |v|
            options[:username] = v
            options[:jekyll] = true
            errput.puts '[deprecated] -j/--jekyllUsername is deprecated; use `--jekyll -u USERNAME`.'
        end

        opts.on('-k', '--jekyllPostURL POST_URL', 'DEPRECATED: use `--jekyll -p POST_URL`') do |v|
            options[:postURL] = v
            options[:jekyll] = true
            errput.puts '[deprecated] -k/--jekyllPostURL is deprecated; use `--jekyll -p POST_URL`.'
        end

        opts.on('--stdout', 'Render Markdown of -p/-u directly to stdout. Skips all image/asset downloads (image links stay as remote URLs). Logs and banners go to stderr so stdout stays pure markdown.') do
            options[:stdout] = true
        end

        opts.on('--list', 'With -u <username>, emit one NDJSON line per post (title, url, creator, dates, tags) to stdout. Skips bodies and image downloads.') do
            options[:list] = true
        end

        opts.on('--limit N', Integer, 'Cap the number of posts processed when used with -u (in --stdout or --list mode).') do |v|
            options[:limit] = v
        end

        opts.on('-n', '--new', 'Update to latest version') do
            options[:upgrade] = true
        end

        opts.on('-c', '--clean', 'Remove all downloaded posts data under cwd') do
            options[:clean] = true
        end

        opts.on('-v', '--version', 'Print current ZMediumToMarkdown version') do
            options[:version] = true
        end

        opts.on('--non-interactive', 'Never prompt or open a browser. CI runners auto-detect this; use the flag to force the same behavior on a TTY.') do
            options[:nonInteractive] = true
            ENV['MEDIUM_NO_AUTO_BROWSER'] = '1'
        end

        opts.on('--auth', 'Open Chrome to sign in, capture sid / uid / cf_clearance / _cfuvid into the encrypted cookie cache, and exit. Run once before bulk / scheduled jobs to seed the cache.') do
            options[:auth] = true
        end

        opts.on('-h', '--help', 'Show this help message') do
            options[:help] = opts.to_s
        end
    end

    parser.parse!(argv)
    options
end

.pathPolicyFor(cwd, isForJekyll) ⇒ Object

Jekyll mode writes into the cwd (so files land in ‘_posts/…` and `assets/…` of an existing Jekyll site). Plain mode nests under `Output/` to keep the user’s cwd tidy.



301
302
303
304
305
306
307
# File 'lib/CLI.rb', line 301

def pathPolicyFor(cwd, isForJekyll)
    if isForJekyll
        PathPolicy.new(cwd, "")
    else
        PathPolicy.new("#{cwd}/Output", "Output")
    end
end

.proxyConfigured?Boolean

Worker proxy is “configured” when MEDIUM_HOST is set to something other than the default upstream Medium URL — i.e. user pointed it at their own Cloudflare Worker (or another proxy).

Returns:

  • (Boolean)


160
161
162
# File 'lib/CLI.rb', line 160

def proxyConfigured?
    !Request.mediumProxyOrigin.nil?
end

.run(options, cwd, output: $stdout, errput: $stderr) ⇒ Object



192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/CLI.rb', line 192

def run(options, cwd, output: $stdout, errput: $stderr)
    if options[:help]
        output.puts options[:help]
        return
    end

    if options[:version]
        output.puts "Version:#{Helper.getLocalVersion()}"
        Helper.printNewVersionMessageIfExists()
        return
    end

    if options[:clean]
        outputFilePath = PathPolicy.new(cwd, "")
        FileUtils.rm_rf(Dir[outputFilePath.getAbsolutePath(nil)])
        output.puts "All downloaded posts data has been removed."
        Helper.printNewVersionMessageIfExists()
        return
    end

    if options[:upgrade]
        remote = Helper.getRemoteVersionFromGithub()
        local  = Helper.getLocalVersion()
        if remote && local && remote > local
            Helper.downloadLatestVersion()
        else
            output.puts "You're using the latest version :)"
        end
        return
    end

    if options[:auth]
        runAuth(errput: errput)
        return
    end

    # --stdout / --list path: render to the given output stream, skip
    # all filesystem writes and asset downloads. Progress goes to errput
    # so stdout stays pure markdown / NDJSON for embedding callers.
    # Handled before willHitMedium? so the --list-without-username guard
    # surfaces an error instead of silently no-op'ing.
    if options[:stdout] || options[:list]
        if options[:list] && options[:username].nil?
            errput.puts '--list requires -u/--username'
            return
        end
        return unless willHitMedium?(options)

        fetcher = ZMediumFetcher.new
        fetcher.isForJekyll = options[:jekyll] == true
        fetcher.stdoutIO = output
        fetcher.stdoutMode = true
        fetcher.progress.io = errput

        if options[:list]
            fetcher.listPostsByUsername(options[:username], options[:limit])
        elsif options[:postURL]
            fetcher.downloadPost(options[:postURL], nil, nil)
        elsif options[:username]
            fetcher.downloadPostsByUsername(options[:username], nil, limit: options[:limit])
        end
        return
    end

    return unless willHitMedium?(options)

    fetcher = ZMediumFetcher.new
    fetcher.isForJekyll = options[:jekyll] == true

    targetPolicy = pathPolicyFor(cwd, fetcher.isForJekyll)

    if options[:postURL]
        fetcher.downloadPost(options[:postURL], targetPolicy, nil)
    elsif options[:username]
        fetcher.downloadPostsByUsername(options[:username], targetPolicy, limit: options[:limit])
    end

    Helper.printNewVersionMessageIfExists()
end

.runAuth(errput: $stderr) ⇒ Object

‘–auth` entry point: drive the Chrome login flow on demand so users can seed the cookie cache before kicking off a bulk / CI job. Errors are surfaced to errput; we never raise — `–auth` is best-effort setup, not a critical path.



276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/CLI.rb', line 276

def runAuth(errput: $stderr)
    unless ChromeAuth.available?
        errput.puts <<~MSG
          ⚠  Chrome was not detected, so --auth can't run the auto-login flow.
             Install Google Chrome (or any Chromium-based browser ferrum can
             detect), or extract sid / uid manually — see:
             #{COOKIE_SETUP_URL}
        MSG
        return
    end

    cookies = ChromeAuth.login!(errput: errput)
    if cookies.empty?
        errput.puts '⚠  No cookies were captured. Make sure you finished signing in on a medium.com page before pressing Enter.'
        return
    end
    cookies.each { |k, v| $cookies[k] = v unless v.to_s.empty? }
    errput.puts "✅ Captured #{cookies.keys.join(' / ')}#{CookieCache.path}"
rescue StandardError => e
    errput.puts "(Auto-login failed: #{e.class}: #{e.message})"
end

.warnAboutMissingSetup(options, errput: $stderr) ⇒ Object

Only warn when the invocation will actually hit Medium — skip for –version, –clean, –help, –new.



166
167
168
169
170
171
172
173
174
175
# File 'lib/CLI.rb', line 166

def warnAboutMissingSetup(options, errput: $stderr)
    return unless willHitMedium?(options)

    missingCookies = !cookiesPresent?
    missingProxy   = !proxyConfigured?
    return if !missingCookies && !missingProxy

    errput.puts buildSetupBanner(missingCookies: missingCookies,
                                 missingProxy: missingProxy)
end

.willHitMedium?(options) ⇒ Boolean

Returns:

  • (Boolean)


177
178
179
# File 'lib/CLI.rb', line 177

def willHitMedium?(options)
    !options[:postURL].nil? || !options[:username].nil?
end