UNPKG

nyaatorrents

Version:

Interact with NyaaTorrents (nyaa.eu, formerly nyaatorrents.info)

433 lines (338 loc) 13.3 kB
// This is where all the fun happens. Please take a moment to read through this // file to acquaint yourself with the functionality provided and the // implementation thereof. var cheerio = require("cheerio"), ent = require("ent"), filesize_parser = require("filesize-parser"), request = require("request"), url = require("url"); // Main entry point. This is the client class. It takes a single optional // argument, being the base URL of the NyaaTorrents site you want to interact // with. If left out, it will default to "http://www.nyaa.se/". var NyaaTorrents = module.exports = function NyaaTorrents(options) { options = options || {}; this.baseUrl = options.baseUrl || "http://www.nyaa.se/"; this.username = options.username; this.password = options.password; this.cookies = request.jar(); }; NyaaTorrents.prototype.login = function login(cb) { var uri = url.parse(this.baseUrl, true); uri.query.page = "login"; var options = { uri: url.format(uri), form: { method: 1, login: this.username, password: this.password, submit: "Submit", }, jar: this.cookies, }; request.post(options, function(err, res, data) { if (err) { return cb(err); } if (data.indexOf("Login successful") === -1) { return cb(Error("login was not successful")); } if (data.indexOf("Login failed!") !== -1) { return cb(Error("login failed; incorrect password?")); } return cb(); }); }; NyaaTorrents.prototype.upload = function upload(options, cb) { if (!options.category) { return cb(Error("a category is required")); } if (options.category.match(/^\d+_\d+$/)) { options.catid = options.category; } var uri = url.parse(this.baseUrl, true); uri.query.page = "upload"; var config = { uri: url.format(uri), jar: this.cookies, }; var req = request.post(config, function(err, res, data) { if (err) { return cb(err); } if (res.statusCode < 200 || res.statusCode >= 300) { return cb(Error("invalid status code; expected 2xx but got " + res.statusCode)); } if (!res.headers["record-id"]) { return cb(Error("couldn't find record-id header")); } return cb(null, {id: res.headers["record-id"]}); }); var form = req.form(); form.append("rules", "1"); form.append("submit", "Upload"); ["name", "torrenturl", "catid", "info", "description"].forEach(function(k) { form.append(k, options[k] || ""); }); ["hidden", "remake", "anonymous"].forEach(function(k) { if (options[k]) { form.append(k, "1"); } }); if (options.torrent) { form.append("torrent", options.torrent, { filename: "uploaded.torrent", contentType: "application/x-bittorrent", }); } }; NyaaTorrents.prototype.manage = function manage(options, cb) { var uri = url.parse(this.baseUrl, true); uri.query.page = "manage"; uri.query.op = 1; var form = { tid: options.id, mod: options.mod, submit: "Submit", }; form[["tid", options.id].join("-")] = options.id; if (options.category) { form.move = options.category; } var config = { uri: url.format(uri), form: form, jar: this.cookies, }; request.post(config, function(err, res, data) { if (err) { return cb(err); } if (res.statusCode !== 200) { return cb(Error("invalid status code; expected 200 but got " + res.statusCode)); } var $ = cheerio.load(data); var content = $($(".content")[0]).text(); if (content.indexOf("You will be redirected") === -1) { return cb(Error(content.trim())); } var message = content.replace(/You will be redirected.+$/, "").trim(); if (message === "Access restricted.") { return cb(Error(message)); } return cb(null, {message: message}, data); }); }; NyaaTorrents.prototype.remove = function remove(id, cb) { return this.manage({mod: 1, id: id}, cb); }; NyaaTorrents.prototype.hide = function hide(id, cb) { return this.manage({mod: 2, id: id}, cb); }; NyaaTorrents.prototype.unhide = function unhide(id, cb) { return this.manage({mod: 3, id: id}, cb); }; NyaaTorrents.prototype.markRemake = function markRemake(id, cb) { return this.manage({mod: 4, id: id}, cb); }; NyaaTorrents.prototype.unmarkRemake = function unmarkRemake(id, cb) { return this.manage({mod: 5, id: id}, cb); }; // 6 through 13 all return "access restricted" - I'm guessing they're for things // like trusted status and other admin/moderator-only actions. NyaaTorrents.prototype.move = function move(id, category, cb) { return this.manage({mod: 14, id: id, category: category}, cb); }; NyaaTorrents.prototype.addComment = function addComment(id, content, cb) { var uri = url.parse(this.baseUrl, true); uri.query.page = "view"; uri.query.tid = id; uri.query.post = 1; var config = { uri: url.format(uri), form: { message: content, submit: "Submit", }, jar: this.cookies, }; request.post(config, function(err, res, data) { if (err) { return cb(err); } if (res.statusCode !== 303) { return cb(Error("invalid status code; expected 303 but got " + res.statusCode)); } if (!res.headers.location) { return cb(Error("couldn't find location header to get comment ID")); } if (!res.headers.location.match(/#/)) { return cb(Error("comment not posted; maybe duplicate?")); } var commentId = res.headers.location.split("#").pop(); if (!commentId || !commentId.match(/^c\d+$/)) { return cb(Error("malformed location header; couldn't find comment ID")); } return cb(null, { commentId: commentId.replace(/^c/, ""), }); }); }; // Search method. This maps pretty transparently to [the search page](http://www.nyaa.se/?page=search), // passing through the `query` hash verbatim as url parameters. If the `query` // argument is left out, you'll get a list of the latest torrents as you will // have provided no filter arguments. The second argument is a callback that // will be called on completion with `err` and `results` arguments. `err` will // be null in the case of success. NyaaTorrents.prototype.search = function search(query, cb) { var uri = url.parse(this.baseUrl, true); if (typeof query === "function") { cb = query; query = null; } query = query || {}; for (var k in query) { uri.query[k] = query[k]; } uri.query.page = "search"; request({uri: url.format(uri), jar: this.cookies}, function(err, res, data) { if (err) { return cb(err); } var $ = cheerio.load(data); // Our results are found in a table with a predictable structure. Some of // this code might be fragile - expect updates here to improve performance // or stability. PATCHES WELCOME LOL. var torrents = Array.prototype.slice.apply($("table.tlist .tlistrow")).map(function(row) { var obj = {}; // If we can't find the download link or the category image, we just give // up on this row. It shouldn't happen, but it might indicate bad markup // or unhandled stuff. var download_link = $(row).find(".tlistdownload a")[0]; if (!download_link) { return null; } var category_image = $(row).find(".tlisticon a")[0]; if (!category_image) { return null; } obj.id = parseInt(download_link.attribs.href.trim().replace(/^.+?(\d+)$/, "$1"), 10); obj.href = ent.decode(download_link.attribs.href); obj.name = $(row).find(".tlistname").text().trim(); obj.categories = ent.decode(category_image.attribs.title).trim().split(/ >> /g).map(function(e) { return e.toLowerCase().trim().replace(/\s+/g, "-"); }); obj.flags = row.attribs.class.split(/ /g).filter(function(e) { return e !== "tlistrow"; }); obj.size = filesize_parser($(row).find(".tlistsize").text().trim()); obj.seeds = parseInt($(row).find(".tlistsn").text().trim(), 10); obj.leeches = parseInt($(row).find(".tlistln").text().trim(), 10); obj.downloads = parseInt($(row).find(".tlistdn").text().trim(), 10); obj.comments = parseInt($(row).find(".tlistmn").text().trim(), 10); return obj; }).filter(function(e) { return e !== null; }); return cb(null, torrents); }); }; // This method gets the information about a specific torrent, identified by ID. // First argument is a number or a string containing the torrent ID, second is // a callback to be called on completion with `err` and `result` arguments. As // with the previous method, `err` will be null in the case of success. NyaaTorrents.prototype.get = function get(id, cb) { var uri = url.parse(this.baseUrl, true); uri.query.page = "torrentinfo"; uri.query.tid = id; request({uri: uri, jar: this.cookies}, function(err, res, data) { if (err) { return cb(err); } var $ = cheerio.load(data); var content = $(".content")[0]; // When there's an error, it's displayed as text in the spot where the page // content would usually go. We pass that through as-is to the user. if (content.children.length === 1 && content.children[0].type === "text") { return cb(Error(ent.decode(content.children[0].data).trim())); } var obj = {}; // These are things like "trusted" or "remake". See [the NyaaTorrents manual](http://www.nyaa.se/?page=manual) // for more information. obj.flags = content.attribs.class.split(/ /g).filter(function(e) { return e !== "content"; }); // Categories. Super simple stuff. These are lower-cased, hyphen-delimited, // human-readable strings. obj.categories = Array.prototype.slice.apply($(content).find("td.viewinfocategory a")).map(function(e) { return $(e).text().toLowerCase().trim().replace(/\s+/g, "-"); }); // The torrent details are displayed in a wonky table thing. Each field is // displayed as a pair of cells, with the former containing the field name // and the latter displaying the field value. Based on the name, we do some // field-specific transformations on some fields. Others just get passed on // through as text. var tds = $(content).find("table.viewtable tr > td"); for (var i=0;i<tds.length;i+=2) { // This is the field name. var k = $(tds[i]).text().replace(/:$/, "").replace(/\s+/g, "_").trim().toLowerCase(); switch (k) { // "information" is basically synonymous with "website" case "information": var link = $(tds[i+1]).find("a"); if (link.length) obj.website = link[0].attribs.href; break; // "file_size" is exactly what it sounds like, and it has to be turned // into a real number. case "file_size": obj.size = filesize_parser($(tds[i+1]).text().trim()); break; // This is the user that submitted the torrent. We parse it out into the // separate `id` and `name` values. case "submitter": obj.user = { id: parseInt($(tds[i+1]).find("a")[0].attribs.href.replace(/^.+?(\d+)$/, "$1"), 10), name: $(tds[i+1]).text(), }; break; // This might not work on anything except V8. Will have to check that // if this ever works on anything except node. case "date": obj.date = new Date($(tds[i+1]).text()); break; // The "stardom" field just displays the number of people who've set // themselves as "fans" of this torrent. I don't really know what the // deal is here. case "stardom": obj.fans = parseInt($(tds[i+1]).text().replace(/^.+(\d+).+$/, "$1"), 10); break; // All these need to be turned to real numbers instead of strings. case "seeders": case "leechers": case "downloads": obj[k] = parseInt($(tds[i+1]).text(), 10); break; // Anything not otherwise handled is just sent through as text. default: obj[k] = $(tds[i+1]).text(); } } // This is the torrent ID... You already have it, but this seemed like a // good idea for completeness. obj.id = parseInt($(content).find("div.viewdownloadbutton a")[0].attribs.href.replace(/^.+?(\d+)$/, "$1"), 10); // This is a chunk of HTML. You'll probably want to turn it into some other // kind of markup. obj.description = $($(content).find("div.viewdescription")[0]).html(); // Yay comments! obj.comments = []; var commentElements = $(content).find(".comment"); // Each of these will have a blob of HTML as the "content". Same deal as // above with the description. for (var i=0;i<commentElements.length;++i) { obj.comments.push({ id: parseInt(commentElements[i].attribs.id.replace(/[^0-9]/g, ""), 10), time: $(commentElements[i]).find(".chead").html().split(/<br>/).pop(), user: { id: parseInt($(commentElements[i]).find(".chead > a")[0].attribs.href.replace(/^.+?([0-9])$/, "$1"), 10), name: $(commentElements[i]).find(".chead > a > span").text(), }, content: $(commentElements[i]).find(".cmain").html(), }); } return cb(null, obj); }); };