UNPKG

hackernews2nntp

Version:

Post Hacker News Stories & Comments to an NNTP Server

319 lines (259 loc) 8.85 kB
# All this rigmarole only to concurrently make some http get requests # :( events = require 'events' util = require 'util' Q = require 'q' RateLimiter = require('limiter').RateLimiter request = require 'request' u = require './utils' meta = require '../package.json' # Example: # # class MyError extends Error # constructor: (msg) -> superError MyError, this, msg # superError = (klass, errObj, msg) -> errObj.name = klass.name errObj.message = msg Error.captureStackTrace errObj, klass class CrawlerError extends Error constructor: (msg) -> superError CrawlerError, this, msg class Dup extends CrawlerError constructor: (msg) -> superError Dup, this, msg class InvalidJSON extends CrawlerError constructor: (msg) -> superError InvalidJSON, this, msg class InvalidItem extends CrawlerError constructor: (msg) -> superError InvalidItem, this, msg class NoItem extends CrawlerError constructor: (msg) -> superError NoItem, this, msg class PolloptInIteration extends CrawlerError constructor: (msg) -> superError PolloptInIteration, this, msg class NoConnect extends CrawlerError constructor: (msg) -> superError NoConnect, this, msg makeError = (klass, iter_id, id, msg) -> err = new klass msg err.iter_id = iter_id err.id = id err class Stat constructor: (@event)-> @iter_id = 0 @_history = {} @bytes = 0 @_iterations = [] # id -- item id history: (iter_id, id, err, iter_id_next = null) -> o = @_history[iter_id] ||= {} o[id] ||= [] last = o[id][o[id].length-1] o[id].pop() if last?.iter_id_next == 'http get' info = {} info.err = err if err info.iter_id_next = iter_id_next.toString() if iter_id_next o[id].push info @event.emit 'herr', err if info.err iter_id_next: -> ++@iter_id has_been: (id) -> id = id.toString() for key,val of @_history return true if id of val false succinct: -> ids = {} for ikey,ival of @_history for id,val of ival ids[id] ||= [] ids[id].push val r = bytes: @bytes items: 0 neterr: 0 invalid: 0 dup: 0 uniq: (Object.keys ids).length errclass2desc = 'Dup': 'dup' 'InvalidJSON': 'invalid' 'InvalidItem': 'invalid' 'NoItem': 'neterr' 'PolloptInIteration': 'invalid' 'NoConnect': 'neterr' for id,val of ids for items in val for item in items if !item.err r.items++ else r[errclass2desc[item.err.constructor.name]]++ r iteration_begin: (iter_id) -> idx = @_iterations.indexOf iter_id if idx == -1 @_iterations.push iter_id @event.emit 'iteration:begin', iter_id iteration_end: (iter_id) -> idx = @_iterations.indexOf iter_id unless idx == -1 @_iterations.splice idx, 1 @event.emit 'iteration:end', iter_id @event.emit 'finish' if @_iterations.length == 0 class Crawler2 @ItemValidate = (json) -> return false unless json return false if !json.id return false if !json.type?.toString().match /^(story|comment|poll|pollopt)$/ return false if json.kids && !util.isArray json.kids return false if json.type == 'poll' && !json.parts return false if json.type == 'poll' && !util.isArray json.parts return false if json.type == 'poll' && json.parts.length == 0 true @idValidate = (id, deferred) -> unless u.isNum id deferred.reject new Error "invalid id `#{id}`" return false true @iterIdValidate = (iter_id, deferred) -> unless u.isStr(iter_id) || u.isNum(iter_id) deferred.reject new Error "invalid iter_id `#{iter_id}`" return false true @CONN_PER_SEC = 20 constructor: (@conn_per_sec = Crawler2.CONN_PER_SEC) -> @url_pattern = 'https://hacker-news.firebaseio.com/v0/item/%s.json' @look4kids = true @logger = console.error @event = new events.EventEmitter() @stat = new Stat @event @limiter = new RateLimiter(@conn_per_sec, 'sec') @event.on 'items', (iter_id, idarr) => @iteration iter_id, idarr @event.on 'poll', (iter_id, item) => @poll iter_id, item @http_headers = { 'User-Agent': [meta.name, meta.version].join '/' } # Emit 'data' event if an item was fetched. Emit 'poll' event if the # fetched item was a poll. Emit 'item' event if the fetched item had # kids. iteration: (iter_id, idarr) -> throw new Error "array of IDs is expected" unless util.isArray idarr for id,idx in idarr @stat.iteration_begin iter_id if idx == 0 do (idx) => @fetch_item(iter_id, id) .then (r) => if r.type == 'pollopt' throw makeError PolloptInIteration, iter_id, r.id, "must be processed by Crawler2#poll()" if r.type == 'poll' iter_id_next = @stat.iter_id_next() + '.poll' @stat.history iter_id, r.id, null, iter_id_next @event.emit 'poll', iter_id_next, r else @event.emit 'data', iter_id, JSON.stringify r @stat.history iter_id, r.id, null, null unless @iteration_4kids iter_id, r .catch (err) => if err instanceof CrawlerError @stat.history iter_id, err.id, err, null else throw err .fin => @stat.iteration_end iter_id if idx == idarr.length-1 .done() iteration_4kids: (iter_id, item) -> if item.kids && @look4kids iter_id_next = @stat.iter_id_next() @stat.history iter_id, item.id, null, iter_id_next @event.emit 'items', iter_id_next, item.kids # Emit a 'data' msg if a poll was collected w/o errors. # # If there are errors in collecting of any poll parts, record the fact # in the @stat for current iteration. # # Side effects: modifies @stat # Return: nothing poll: (iter_id, item) -> @iteration_4kids iter_id, item # include poll item itself before its additional parts parts = [JSON.stringify item] promises = [] for id,idx in item.parts @stat.iteration_begin iter_id if idx == 0 do (idx) => p = @fetch_item(iter_id, id) promises.push p p.then (r) => @stat.history iter_id, r.id, null, null parts.push JSON.stringify r .catch (err) => # current pollopt is invalid if err instanceof CrawlerError @stat.history iter_id, err.id, err, null else throw err .fin => @stat.iteration_end iter_id if idx == item.parts.length-1 .done() # wait for all promises to finish Q.all(promises).then => @event.emit 'data', iter_id, parts.join "\n" # Side effects: modifies @stat, @stat.bytes # Return: a promise w/ a response body http_get: (iter_id, id) -> log = (msg) => @logger "ii=#{iter_id}/id=#{id} #{msg}" deferred = Q.defer() opt = url: util.format @url_pattern, id headers: @http_headers cur_req = request.get opt, (err, res, body) => if err deferred.reject makeError NoConnect, iter_id, id, err.message return deferred.promise if res.statusCode == 200 @stat.bytes += Buffer.byteLength body log "HTTP 200" deferred.resolve body else deferred.reject makeError NoItem, iter_id, id, "HTTP #{res.statusCode}" cur_req.on 'request', => # check in history, mark as dup; ignore polls iterations if @stat.has_been id unless iter_id.toString().match /\.poll$/ deferred.reject makeError Dup, iter_id, id, "dup!" cur_req.abort() else log "HTTP GET" @stat.history iter_id, id, null, 'http get' deferred.promise # Fetch id via http, parse it & set the result in `deferred`. # # Side effects: modifies @stat # Return: nothing fetch_item_bare: (iter_id, id, deferred) -> return unless Crawler2.idValidate id, deferred return unless Crawler2.iterIdValidate iter_id, deferred @http_get iter_id, id .then (item) -> try item = JSON.parse item catch e deferred.reject makeError InvalidJSON, iter_id, id, "invalid JSON" return if Crawler2.ItemValidate item deferred.resolve item else deferred.reject makeError InvalidItem, iter_id, id, "invalid data in the item" .catch (err) -> deferred.reject err .done() # A wrapper for #fetch_item_bare(). Limits executions of # #fetch_item_bare() by @conn_per_sec. # # Side effects: modifies @stat # Return: a promise w/ the result from #fetch_item_bare() fetch_item: (iter_id, id) -> deferred = Q.defer() @limiter.removeTokens 1, (lim_err, remainingRequests) => @fetch_item_bare iter_id, id, deferred deferred.promise module.exports = Crawler2