hackernews2nntp
Version:
Post Hacker News Stories & Comments to an NNTP Server
319 lines (259 loc) • 8.85 kB
text/coffeescript
# 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: ()->
= 0
= {}
= 0
= []
# id -- item id
history: (iter_id, id, err, iter_id_next = null) ->
o = [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
.emit 'herr', err if info.err
iter_id_next: ->
++
has_been: (id) ->
id = id.toString()
for key,val of
return true if id of val
false
succinct: ->
ids = {}
for ikey,ival of
for id,val of ival
ids[id] ||= []
ids[id].push val
r =
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 = .indexOf iter_id
if idx == -1
.push iter_id
.emit 'iteration:begin', iter_id
iteration_end: (iter_id) ->
idx = .indexOf iter_id
unless idx == -1
.splice idx, 1
.emit 'iteration:end', iter_id
.emit 'finish' if .length == 0
class Crawler2
= (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
= (id, deferred) ->
unless u.isNum id
deferred.reject new Error "invalid id `#{id}`"
return false
true
= (iter_id, deferred) ->
unless u.isStr(iter_id) || u.isNum(iter_id)
deferred.reject new Error "invalid iter_id `#{iter_id}`"
return false
true
= 20
constructor: ( = Crawler2.CONN_PER_SEC) ->
= 'https://hacker-news.firebaseio.com/v0/item/%s.json'
= true
= console.error
= new events.EventEmitter()
= new Stat
= new RateLimiter(, 'sec')
.on 'items', (iter_id, idarr) =>
iter_id, idarr
.on 'poll', (iter_id, item) =>
iter_id, item
= {
'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
.iteration_begin iter_id if idx == 0
do (idx) =>
.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 = .iter_id_next() + '.poll'
.history iter_id, r.id, null, iter_id_next
.emit 'poll', iter_id_next, r
else
.emit 'data', iter_id, JSON.stringify r
.history iter_id, r.id, null, null unless iter_id, r
.catch (err) =>
if err instanceof CrawlerError
.history iter_id, err.id, err, null
else
throw err
.fin =>
.iteration_end iter_id if idx == idarr.length-1
.done()
iteration_4kids: (iter_id, item) ->
if item.kids &&
iter_id_next = .iter_id_next()
.history iter_id, item.id, null, iter_id_next
.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 for current iteration.
#
# Side effects: modifies
# Return: nothing
poll: (iter_id, item) ->
iter_id, item
# include poll item itself before its additional parts
parts = [JSON.stringify item]
promises = []
for id,idx in item.parts
.iteration_begin iter_id if idx == 0
do (idx) =>
p =
promises.push p
p.then (r) =>
.history iter_id, r.id, null, null
parts.push JSON.stringify r
.catch (err) =>
# current pollopt is invalid
if err instanceof CrawlerError
.history iter_id, err.id, err, null
else
throw err
.fin =>
.iteration_end iter_id if idx == item.parts.length-1
.done()
# wait for all promises to finish
Q.all(promises).then =>
.emit 'data', iter_id, parts.join "\n"
# Side effects: modifies , .bytes
# Return: a promise w/ a response body
http_get: (iter_id, id) ->
log = (msg) => "ii=#{iter_id}/id=#{id} #{msg}"
deferred = Q.defer()
opt =
url: util.format , id
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
.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 .has_been id
unless iter_id.toString().match /\.poll$/
deferred.reject makeError Dup, iter_id, id, "dup!"
cur_req.abort()
else
log "HTTP GET"
.history iter_id, id, null, 'http get'
deferred.promise
# Fetch id via http, parse it & set the result in `deferred`.
#
# Side effects: modifies
# Return: nothing
fetch_item_bare: (iter_id, id, deferred) ->
return unless Crawler2.idValidate id, deferred
return unless Crawler2.iterIdValidate iter_id, deferred
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 .
#
# Side effects: modifies
# Return: a promise w/ the result from #fetch_item_bare()
fetch_item: (iter_id, id) ->
deferred = Q.defer()
.removeTokens 1, (lim_err, remainingRequests) =>
iter_id, id, deferred
deferred.promise
module.exports = Crawler2