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 }