1 /++
2     Lightshot `prnt.sc` (and `prntscr.com`) gallery downloader.
3 
4     See_Also: https://app.prntscr.com/en/index.html
5  +/
6 module prntscget.app;
7 
8 private:
9 
10 import std.array : Appender;
11 import std.getopt : GetoptResult;
12 import std.json : JSONValue;
13 import core.time : Duration;
14 
15 public:
16 
17 
18 /++
19     Embodies the notion of an image to be downloaded.
20  +/
21 struct RemoteImage
22 {
23     /// HTTP URL of the image.
24     string url;
25 
26     /// Local path to save the remote image to.
27     string localPath;
28 
29     /// Image index (number in list JSON).
30     size_t number;
31 
32     /// Constructor.
33     this(const string url, const string localPath, const size_t number)
34     {
35         this.url = url;
36         this.localPath = localPath;
37         this.number = number;
38     }
39 }
40 
41 
42 /++
43     Aggregate of values supplied at the command line.
44  +/
45 struct Configuration
46 {
47     /// File to save the JSON list of images to.
48     string listFile = "target.json";
49 
50     /++
51      +  How many times to try downloading a file before admitting failure and
52      +  proceeding with the next one.
53      +/
54     uint retriesPerFile = 100;
55 
56     /// Directory to save images to.
57     string targetDirectory = "images";
58 
59     /// Request timeout when downloading an image.
60     uint requestTimeoutSeconds = 60;
61 
62     /// How many seconds to wait between image downloads.
63     uint delayBetweenImagesSeconds = 60;
64 
65     /// The number of images to skip when downloading (the index starting position).
66     uint startingImagePosition;
67 
68     /// How many images to download.
69     uint numToDownload = uint.max;
70 
71     /// `__auth` Cookie string specified at the command line.
72     string specifiedCookie;
73 
74     /// Whether or not this is a dry run.
75     bool dryRun;
76 }
77 
78 
79 /++
80     Program entry point.
81 
82     Merely passes execution to [run], wrapped in a try-catch.
83 
84     Params:
85         args = Arguments passed at the command line.
86 
87     Returns:
88         zero on success, non-zero on errors.
89  +/
90 int main(string[] args)
91 {
92     try
93     {
94         return run(args);
95     }
96     catch (Exception e)
97     {
98         import std.stdio : writeln;
99         writeln("exception thrown: ", e.msg);
100         return 1;
101     }
102 
103     assert(0);
104 }
105 
106 
107 /++
108     Program main logic.
109 
110     Params:
111         args = Arguments passed at the command line.
112 
113     Returns:
114         zero on success, non-zero on errors.
115  +/
116 int run(string[] args)
117 {
118     import std.file : exists, readText;
119     import std.json : parseJSON;
120     import std.stdio : writefln, writeln;
121     import core.time : seconds;
122 
123     Configuration config;
124 
125     auto results = handleGetopt(args, config);
126 
127     if (results.helpWanted)
128     {
129         import prntscget.semver : PrntscgetSemVer, PrntscgetSemVerPrerelease;
130         import std.format : format;
131         import std.getopt : defaultGetoptPrinter;
132         import std.path : baseName;
133 
134         enum banner = "prntscget v%d.%d.%d%s, built on %s".format(
135             PrntscgetSemVer.majorVersion,
136             PrntscgetSemVer.minorVersion,
137             PrntscgetSemVer.patchVersion,
138             PrntscgetSemVerPrerelease,
139             __TIMESTAMP__);
140 
141         writeln(banner);
142 
143         immutable usageLine = "\nusage: %s [options] [json file]\n".format(args[0].baseName);
144         defaultGetoptPrinter(usageLine, results.options);
145         return 0;
146     }
147 
148     if (args.length > 1)
149     {
150         config.listFile = args[1];
151         //args = args[1..$];
152     }
153 
154     /// JSON image list, fetched from the server
155     JSONValue listJSON;
156 
157     if (config.specifiedCookie.length)
158     {
159         import std.algorithm.searching : canFind;
160         import std.stdio : File;
161 
162         writefln(`fetching image list JSON and saving into "%s"...`, config.listFile);
163         const listFileContents = getImageList(config.specifiedCookie);
164 
165         if (!listFileContents.canFind(`"result":{"success":true,`))
166         {
167             writeln("failed to fetch image list. incorrect cookie?");
168             return 1;
169         }
170 
171         listJSON = parseJSON(cast(string)listFileContents);
172         writefln("%d images found.", listJSON["result"]["total"].integer);
173         File(config.listFile, "w").writeln(listJSON.toPrettyString);
174     }
175     else if (!config.listFile.exists)
176     {
177         writefln(`image list JSON file "%s" does not exist.`, config.listFile);
178         return 1;
179     }
180 
181     if (!ensureImageDirectory(config.targetDirectory))
182     {
183         writefln(`"%s" is not a directory.`, config.targetDirectory);
184         return 1;
185     }
186 
187     if (listJSON == JSONValue.init)  // (listJSON.type == JSONType.null_)
188     {
189         // A cookie was not supplied and the list JSON was never read
190         listJSON = config.listFile
191             .readText
192             .parseJSON;
193     }
194 
195     immutable numImages = cast(size_t)listJSON["result"]["total"].integer;
196 
197     if (!numImages)
198     {
199         writeln("no images to fetch.");
200         return 0;
201     }
202 
203     Appender!(RemoteImage[]) images;
204     images.reserve(numImages);
205     immutable numExistingImages = enumerateImages(images, listJSON, config, numImages);
206 
207     if (!images.data.length)
208     {
209         writefln("no images to fetch -- all %d are already downloaded.", numImages);
210         return 0;
211     }
212 
213     if (numExistingImages > 0)
214     {
215         writefln("(skipping %d image(s) already in directory.)", numExistingImages);
216     }
217 
218     writefln("total images: %s -- this will take a MINIMUM of %s.",
219         images.data.length, images.data.length*config.delayBetweenImagesSeconds.seconds);
220 
221     downloadAllImages(images, config);
222 
223     writeln("done.");
224     return 0;
225 }
226 
227 
228 /++
229     Handles getopt arguments passed to the program.
230 
231     Params:
232         args = Command-line arguments passed to the program.
233         config = [Configuration] struct to set the members of.
234 
235     Returns:
236         [std.getopt.GetoptResult] as returned by the call to [std.getopt.getopt].
237  +/
238 GetoptResult handleGetopt(ref string[] args, out Configuration config)
239 {
240     import std.getopt : getopt, getoptConfig = config;
241 
242     return getopt(args,
243         getoptConfig.caseSensitive,
244         "c|cookie",
245             "Cookie to download gallery of (see README).",
246             &config.specifiedCookie,
247         "d|dir",
248             "Target image directory.",
249             &config.targetDirectory,
250         "s|start",
251             "Starting image position.",
252             &config.startingImagePosition,
253         "n|num",
254             "Number of images to download.",
255             &config.numToDownload,
256         "r|retries",
257             "How many times to retry downloading an image.",
258             &config.retriesPerFile,
259         "delay",
260             "Delay between image downloads, in seconds.",
261             &config.delayBetweenImagesSeconds,
262         "timeout",
263             "Download attempt read timeout, in seconds.",
264             &config.requestTimeoutSeconds,
265         "dry-run",
266             "Download nothing, only echo what would be done.",
267             &config.dryRun,
268     );
269 }
270 
271 
272 /++
273     Enumerate images, skipping existing ones.
274 
275     Params:
276         images = [std.array.Appender] containing references to all images to download.
277         listJSON = JSON list of images to download.
278         config = The current [Configuration] of all getopt values aggregated.
279         numImages = The number of images to download, when specified as a lower
280             number than the max by getopt.
281 
282     Returns:
283         The number of images that should be downloaded.
284  +/
285 uint enumerateImages(ref Appender!(RemoteImage[]) images, const JSONValue listJSON,
286     const Configuration config, const size_t numImages)
287 {
288     import std.algorithm.comparison : min;
289     import std.range : drop, enumerate, retro, take;
290 
291     uint numExistingImages;
292     bool needsLinebreak;
293 
294     scope(exit)
295     {
296         import std.stdio : writeln;
297         if (needsLinebreak) writeln();
298     }
299 
300     auto range = listJSON["result"]["screens"]
301         .array
302         .retro
303         .drop(config.startingImagePosition)
304         .take(min(config.numToDownload, numImages))
305         .enumerate;
306 
307     foreach (immutable i, imageJSON; range)
308     {
309         import std.array : replace, replaceFirst;
310         import std.file : exists;
311         import std.path : buildPath, extension;
312 
313         immutable url = imageJSON["url"].str;
314         immutable filename = imageJSON["date"].str
315             .replace(" ", "_")
316             .replaceFirst(":", "h")
317             .replaceFirst(":", "m") ~ url.extension;
318         immutable localPath = buildPath(config.targetDirectory, filename);
319 
320         if (localPath.exists)
321         {
322             import std.algorithm.comparison : max;
323             import std.file : getSize;
324             import std.stdio : File, stdout, write;
325 
326             enum maxImageEndingMarkerLength = 12;  // JPEG 2, PNG 12
327 
328             immutable localPathSize = getSize(localPath);
329             immutable seekPos = max(localPathSize-maxImageEndingMarkerLength, 0);
330             auto existingFile = File(localPath, "r");
331             ubyte[maxImageEndingMarkerLength] buf;
332 
333             if (!needsLinebreak)
334             {
335                 needsLinebreak = true;  // well, below
336                 write("verifying existing images ");
337             }
338 
339             scope(exit) stdout.flush();
340 
341             existingFile.seek(seekPos);
342             auto existingFileEnding = existingFile.rawRead(buf);
343 
344             if (hasValidJPEGEnding(existingFileEnding) || hasValidPNGEnding(existingFileEnding))
345             {
346                 write('.');
347                 ++numExistingImages;
348                 continue;
349             }
350             else
351             {
352                 write('!');
353             }
354         }
355 
356         images ~= RemoteImage(url, localPath, i);
357     }
358 
359     return numExistingImages;
360 }
361 
362 /++
363     Downloads all images in the passed `images` list.
364 
365     Images are saved to the filename specified in each [RemoteImage.localPath].
366 
367     Params:
368         images = The list of images to download.
369         config = The current program [Configuration].
370  +/
371 void downloadAllImages(const Appender!(RemoteImage[]) images, const Configuration config)
372 {
373     import core.time : seconds;
374 
375     immutable delayBetweenImages = config.delayBetweenImagesSeconds.seconds;
376     immutable requestTimeout = config.requestTimeoutSeconds.seconds;
377 
378     imageloop:
379     foreach (immutable i, const image; images)
380     {
381         import std.stdio : stdout, write;
382 
383         foreach (immutable retry; 0..config.retriesPerFile)
384         {
385             import requests : RequestException, TimeoutException;
386             import std.stdio : writeln;
387 
388             try
389             {
390                 if (!config.dryRun && (i != 0) && ((i != (images.data.length+(-1))) || (i == 1)))
391                 {
392                     import core.thread : Thread;
393                     Thread.sleep(delayBetweenImages);
394                 }
395 
396                 if (retry == 0)
397                 {
398                     import std.stdio : writef;
399                     writef("[%4d] %s --> %s: ", image.number, image.url, image.localPath);
400                     stdout.flush();
401                 }
402 
403                 immutable success = config.dryRun ||
404                     downloadImage(image.url, image.localPath, requestTimeout);
405 
406                 if (success)
407                 {
408                     writeln("ok");
409                     continue imageloop;
410                 }
411                 else
412                 {
413                     write('.');
414                     stdout.flush();
415                 }
416             }
417             catch (TimeoutException e)
418             {
419                 // Retry
420                 write('.');
421                 stdout.flush();
422             }
423             catch (RequestException e)
424             {
425                 // Unexpected network error; retry
426                 write('.');
427                 stdout.flush();
428             }
429             catch (Exception e)
430             {
431                 writeln();
432                 writeln(e.msg);
433             }
434         }
435     }
436 }
437 
438 
439 /++
440     Downloads an image from the `prnt.sc` server.
441 
442     Params:
443         url = HTTP URL to fetch.
444         imagePath = Filename to save the downloaded image to.
445         requestTimeout = Timeout to use when downloading.
446 
447     Returns:
448         `true` if a file was successfully downloaded (including passing the
449         size check); `false` if not.
450  +/
451 bool downloadImage(const string url, const string imagePath, const Duration requestTimeout)
452 {
453     import requests : Request;
454     import std.stdio : File;
455 
456     Request req;
457     req.timeout = requestTimeout;
458     req.keepAlive = false;
459     auto res = req.get(url);
460 
461     if (res.code != 200) return false;
462 
463     if (!hasValidPNGEnding(res.responseBody.data) && !hasValidJPEGEnding(res.responseBody.data))
464     {
465         // Interrupted download?
466         return false;
467     }
468 
469     File(imagePath, "w").rawWrite(res.responseBody.data);
470     return true;
471 }
472 
473 
474 /++
475     Detects whether a passed array of bytes has a valid JPEG ending.
476 
477     Params:
478         fileContents = Contents of a (possibly) JPEG file.
479  +/
480 bool hasValidJPEGEnding(const ubyte[] fileContents)
481 {
482     import std.algorithm.searching : endsWith;
483 
484     static immutable eoi = [ 0xFF, 0xD9 ];
485     return fileContents.endsWith(eoi);
486 }
487 
488 
489 /++
490     Detects whether a passed array of bytes has a valid PNG ending.
491 
492     Params:
493         fileContents = Contents of a (possibly) PNG file.
494  +/
495 bool hasValidPNGEnding(const ubyte[] fileContents)
496 {
497     import std.algorithm.searching : endsWith;
498 
499     static immutable iend = [ 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82 ];
500     return fileContents.endsWith(iend);
501 }
502 
503 
504 /++
505     Ensures the target image directory exists, creating it if it does not and
506     returning false if it fails to.
507 
508     Params:
509         targetDirectory = Target directory to ensure existence of.
510 
511     Returns:
512         `true` if the directory already exists or if it was succesfully created;
513         `false` if it could not be.
514  +/
515 bool ensureImageDirectory(const string targetDirectory)
516 {
517     import std.file : exists, isDir, mkdir;
518 
519     if (!targetDirectory.exists)
520     {
521         mkdir(targetDirectory);
522         return true;
523     }
524     else if (!targetDirectory.isDir)
525     {
526         return false;
527     }
528 
529     return true;
530 }
531 
532 
533 /++
534     Fetches the JSON list of images for a passed cookie from the `prnt.sc` server.
535 
536     Params:
537         cookie = `__auth` cookie to fetch the gallery of.
538 
539     Returns:
540         A buffer struct containing the response body of the request.
541  +/
542 auto getImageList(const string cookie)
543 {
544     import requests : Request;
545     import core.time : seconds;
546 
547     enum url = "https://api.prntscr.com/v1/";
548     enum post = `{"jsonrpc":"2.0","method":"get_user_screens","id":1,"params":{"count":10000}}`;
549     enum webform = "application/x-www-form-urlencoded";
550 
551     immutable headers =
552     [
553         "authority"       : "api.prntscr.com",
554         "pragma"          : "no-cache",
555         "cache-control"   : "no-cache",
556         "accept"          : "application/json, text/javascript, */*; q=0.01",
557         "user-agent"      : "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " ~
558             "(KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36",
559         "content-type"    : "application/json",
560         "origin"          : "https://prntscr.com",
561         "sec-fetch-site"  : "same-site",
562         "sec-fetch-mode"  : "cors",
563         "sec-fetch-dest"  : "empty",
564         "referer"         : "https://prntscr.com/gallery.html",
565         "accept-language" : "fr-CA,fr;q=0.9,fr-FR;q=0.8,en-US;q=0.7,en;q=0.6,it;q=0.5,ru;q=0.4",
566         "cookie"          : "__auth=" ~ cookie,
567     ];
568 
569     Request req;
570     req.timeout = 60.seconds;
571     req.keepAlive = false;
572     req.addHeaders(headers);
573     auto res = req.post(url, post, webform);
574     return res.responseBody.data;
575 }