herald 0.1 → 0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +22 -0
- data/README.md +72 -59
- data/Rakefile +1 -1
- data/TODO.txt +5 -3
- data/herald.gemspec +15 -5
- data/lib/herald.rb +32 -12
- data/lib/herald/notifiers/callback.rb +5 -1
- data/lib/herald/notifiers/growl.rb +19 -15
- data/lib/herald/notifiers/ping.rb +14 -33
- data/lib/herald/notifiers/post.rb +31 -19
- data/lib/herald/untitled.txt +5 -0
- data/lib/herald/version.rb +1 -1
- data/lib/herald/watcher.rb +17 -15
- data/lib/herald/watchers/rss.rb +12 -5
- data/lib/herald/watchers/twitter.rb +6 -5
- data/lib/herald/watchers/website.rb +95 -0
- data/test/herald_test.rb +3 -9
- data/test/mocks/rss.xml +19 -0
- data/test/mocks/web.html +17 -0
- data/test/notifier_callback_test.rb +29 -0
- data/test/notifier_growl_test.rb +22 -0
- data/test/notifier_ping_test.rb +30 -0
- data/test/notifier_post_test.rb +30 -0
- data/test/notifier_stdout_test.rb +26 -0
- data/test/notifier_test.rb +38 -0
- data/test/watcher_rss_test.rb +59 -0
- data/test/watcher_test.rb +74 -0
- data/test/watcher_twitter_test.rb +17 -0
- data/test/watcher_website_test.rb +33 -0
- metadata +56 -25
- data/lib/herald/watchers/imap.rb +0 -55
@@ -4,42 +4,23 @@ class Herald
|
|
4
4
|
|
5
5
|
module Ping
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
#
|
7
|
+
# Ping and Post share a lot of code, so
|
8
|
+
# when Ping is extended, extend the calling class
|
9
|
+
# with the Post module
|
10
10
|
def self.extended(base)
|
11
|
-
Herald
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
if @uri.class == URI::Generic
|
20
|
-
@uri = URI.parse("http://#{@uri.path}")
|
11
|
+
Herald::lazy_load_module("notifiers/post")
|
12
|
+
base.send(:extend, Post)
|
13
|
+
# and redefine notify() to ping instead of post data
|
14
|
+
class << base
|
15
|
+
def notify(item)
|
16
|
+
@uris.each do |uri|
|
17
|
+
Net::HTTP.new(uri.host).head('/')
|
18
|
+
end
|
21
19
|
end
|
22
|
-
|
23
|
-
|
24
|
-
end
|
25
|
-
end
|
26
|
-
|
27
|
-
def test
|
28
|
-
response = Net::HTTP.new(@uri.host).head('/')
|
29
|
-
return if response.kind_of?(Net::HTTPOK)
|
30
|
-
# TODO raise custom error types
|
31
|
-
if response.kind_of?(Net::HTTPFound)
|
32
|
-
raise "URI #{@uri} is being redirected to #{response.header['location']}"
|
33
|
-
else
|
34
|
-
raise "URI #{@uri} cannot be reached. Ping returned status code: #{response.code}"
|
35
|
-
end
|
36
|
-
end
|
37
|
-
|
38
|
-
def notify(item)
|
39
|
-
Net::HTTP.new(@uri.host).head('/')
|
40
|
-
end
|
20
|
+
end # end
|
21
|
+
end # end method
|
41
22
|
|
42
|
-
end
|
23
|
+
end # end module
|
43
24
|
|
44
25
|
end
|
45
26
|
end
|
@@ -5,39 +5,51 @@ class Herald
|
|
5
5
|
# note most of this code is duplicated between ping and post
|
6
6
|
module Post
|
7
7
|
|
8
|
-
attr_reader :uri
|
9
|
-
|
10
8
|
# lazy-load net/http when this Module is used
|
11
9
|
def self.extended(base)
|
12
10
|
Herald.lazy_load('net/http')
|
11
|
+
class << base
|
12
|
+
attr_accessor :uri
|
13
|
+
end
|
13
14
|
end
|
14
15
|
|
15
16
|
def parse_options(options)
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
17
|
+
@uris = []
|
18
|
+
uris = Array(options.delete(:uri) || options.delete(:url) || options.delete(:uris) || options.delete(:urls))
|
19
|
+
if uris.empty?
|
20
|
+
raise ArgumentError, ":uri for :ping action not specified"
|
21
|
+
end
|
22
|
+
uris.each do |uri|
|
23
|
+
begin
|
24
|
+
uri = URI.parse(uri)
|
25
|
+
# if URI lib can't resolve a protocol (because it was missing from string)
|
26
|
+
if uri.class == URI::Generic
|
27
|
+
uri = URI.parse("http://#{uri.path}")
|
28
|
+
end
|
29
|
+
# add trailing slash if nil path. path() will return nil
|
30
|
+
# if uri passed was a domain missing a trailing slash. net/http's post
|
31
|
+
# methods require the trailing slash to be present otherwise they fail
|
32
|
+
uri.path = "/" if uri.path.empty?
|
33
|
+
@uris << uri
|
34
|
+
rescue URI::InvalidURIError
|
35
|
+
raise ArgumentError, ":uri for :ping action invalid"
|
21
36
|
end
|
22
|
-
rescue URI::InvalidURIError
|
23
|
-
raise ArgumentError, ":uri for :ping action not specified or invalid"
|
24
37
|
end
|
25
38
|
end
|
26
|
-
|
27
|
-
# test by pinging to URI throw exception if fail
|
39
|
+
|
28
40
|
def test
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
raise "
|
34
|
-
else
|
35
|
-
raise "URI #{@uri} cannot be reached. Ping returned status code: #{response.code}"
|
41
|
+
@uris.each do |uri|
|
42
|
+
response = Net::HTTP.new(uri.host).head('/')
|
43
|
+
return if response.kind_of?(Net::HTTPOK)
|
44
|
+
# TODO raise custom error types
|
45
|
+
raise "#{response.code} status code returned for URI #{uri}. 200 code expected"
|
36
46
|
end
|
37
47
|
end
|
38
48
|
|
39
49
|
def notify(item)
|
40
|
-
|
50
|
+
@uris.each do |uri|
|
51
|
+
Net::HTTP.post_form(uri, { "title" => item.title, "message" => item.message }.merge(item.data))
|
52
|
+
end
|
41
53
|
end
|
42
54
|
|
43
55
|
end
|
data/lib/herald/version.rb
CHANGED
data/lib/herald/watcher.rb
CHANGED
@@ -2,33 +2,29 @@ class Herald
|
|
2
2
|
|
3
3
|
class Watcher
|
4
4
|
|
5
|
-
@@watcher_types = [:rss, :twitter
|
5
|
+
@@watcher_types = [:rss, :twitter, :website]
|
6
6
|
DEFAULT_TIMER = 60
|
7
7
|
|
8
|
-
attr_reader :type, :keep_alive, :thread
|
9
|
-
attr_accessor :notifiers, :keywords, :timer
|
10
|
-
|
11
|
-
def initialize(type,
|
8
|
+
attr_reader :type, :keep_alive, :thread
|
9
|
+
attr_accessor :notifiers, :keywords, :timer, :items
|
10
|
+
|
11
|
+
def initialize(type, options, &block)
|
12
12
|
@type = type.to_sym
|
13
|
-
# TODO this is prepared to handle other protocols, but might not be necessary
|
14
|
-
# if @type == :inbox
|
15
|
-
# if options.key?(:imap)
|
16
|
-
# @type = :imap
|
17
|
-
# options[:host] = options[:imap]
|
18
|
-
# end
|
19
|
-
# end
|
20
13
|
# check watcher type
|
21
14
|
unless @@watcher_types.include?(@type)
|
22
15
|
raise ArgumentError, "#{@type} is not a valid Watcher type"
|
23
16
|
end
|
24
|
-
@keep_alive = keep_alive
|
25
17
|
@keywords = []
|
26
18
|
@notifiers = []
|
27
19
|
@items = []
|
20
|
+
@keep_alive = options.delete(:keep_alive)
|
28
21
|
@timer = Watcher::DEFAULT_TIMER
|
29
22
|
Herald.lazy_load_module("watchers/#{@type}")
|
30
|
-
#
|
31
|
-
|
23
|
+
# include module
|
24
|
+
@@type = @type
|
25
|
+
class << self
|
26
|
+
send(:include, eval(@@type.to_s.capitalize))
|
27
|
+
end
|
32
28
|
# each individual Watcher will handle their options
|
33
29
|
parse_options(options)
|
34
30
|
# eval the block, if given
|
@@ -40,11 +36,13 @@ class Herald
|
|
40
36
|
|
41
37
|
def _for(*keywords)
|
42
38
|
@keywords += keywords.flatten
|
39
|
+
self
|
43
40
|
end
|
44
41
|
|
45
42
|
# assign the Notifier
|
46
43
|
def action(type = :callback, options = {}, &block)
|
47
44
|
@notifiers << Herald::Watcher::Notifier.new(type, options, &block)
|
45
|
+
self
|
48
46
|
end
|
49
47
|
|
50
48
|
# parse a hash like { 120 => "seconds" }
|
@@ -63,6 +61,7 @@ class Herald
|
|
63
61
|
raise ArgumentError, "Invalid time unit for every. (Use seconds, minutes, hours or days)"
|
64
62
|
end
|
65
63
|
@timer = quantity * unit
|
64
|
+
self
|
66
65
|
end
|
67
66
|
|
68
67
|
# call the Notifier and pass it a message
|
@@ -70,6 +69,7 @@ class Herald
|
|
70
69
|
@notifiers.each do |notifier|
|
71
70
|
notifier.notify(item)
|
72
71
|
end
|
72
|
+
self
|
73
73
|
end
|
74
74
|
|
75
75
|
def start
|
@@ -85,6 +85,7 @@ class Herald
|
|
85
85
|
sleep @timer if @keep_alive
|
86
86
|
end while @keep_alive
|
87
87
|
}
|
88
|
+
self
|
88
89
|
end
|
89
90
|
|
90
91
|
def stop
|
@@ -92,6 +93,7 @@ class Herald
|
|
92
93
|
@keep_alive = false
|
93
94
|
# cleanup() is defined in the individual Watcher modules
|
94
95
|
cleanup()
|
96
|
+
self
|
95
97
|
end
|
96
98
|
|
97
99
|
end
|
data/lib/herald/watchers/rss.rb
CHANGED
@@ -4,10 +4,11 @@ class Herald
|
|
4
4
|
module Rss
|
5
5
|
|
6
6
|
attr_accessor :uris
|
7
|
-
|
7
|
+
|
8
8
|
# lazy-load open-uri when this Module is used
|
9
|
-
def self.
|
9
|
+
def self.included(base)
|
10
10
|
Herald.lazy_load('open-uri')
|
11
|
+
Herald.lazy_load('crack')
|
11
12
|
end
|
12
13
|
|
13
14
|
def parse_options(options)
|
@@ -15,20 +16,21 @@ class Herald
|
|
15
16
|
if @uris.empty?
|
16
17
|
raise ArgumentError, "RSS source not specified in :from Hash"
|
17
18
|
end
|
19
|
+
@uris.map!{ |uri| URI.escape(uri) }
|
18
20
|
end
|
19
21
|
|
20
22
|
def prepare; end
|
21
23
|
def cleanup; end
|
22
24
|
|
23
25
|
def to_s
|
24
|
-
"Herald RSS Watcher,
|
26
|
+
"Herald RSS Watcher, URIs: #{@uris}, Keywords: '#{@keywords}', Timer: #{@timer}, State: #{@keep_alive ? 'Watching' : 'Stopped'}"
|
25
27
|
end
|
26
28
|
|
27
29
|
private
|
28
30
|
|
29
31
|
def activities
|
30
32
|
@uris.each do |uri|
|
31
|
-
# return response as
|
33
|
+
# return response as a Hash
|
32
34
|
begin
|
33
35
|
rss = Crack::XML.parse(open(uri).read)
|
34
36
|
rescue
|
@@ -37,10 +39,15 @@ class Herald
|
|
37
39
|
# skip if rss variable is nil, or is missing
|
38
40
|
# rss elements in the expected nested format
|
39
41
|
next unless defined?(rss["rss"]["channel"]["item"])
|
42
|
+
rss = rss["rss"]["channel"]["item"]
|
43
|
+
# if there is only 1 <item> in the rss document,
|
44
|
+
# rss variable at the moment will be a Hash, so
|
45
|
+
# convert to a single element array
|
46
|
+
rss = [rss] if rss.is_a?(Hash)
|
40
47
|
# ignore items that have been part of a notification round
|
41
48
|
# or that don't contain the keywords being looked for
|
42
49
|
items = []
|
43
|
-
rss
|
50
|
+
rss.each do |item|
|
44
51
|
# if we've already seen this item, skip to next item
|
45
52
|
if @items.include?(item)
|
46
53
|
next
|
@@ -4,12 +4,13 @@ class Herald
|
|
4
4
|
# TODO, ignore retweets, if option passed
|
5
5
|
module Twitter
|
6
6
|
|
7
|
-
attr_accessor :uri, :last_tweet_id
|
8
7
|
TWITTER_API = "http://search.twitter.com/search.json"
|
9
|
-
|
8
|
+
attr_accessor :uri, :last_tweet_id
|
9
|
+
|
10
10
|
# lazy-load open-uri when this Module is used
|
11
|
-
def self.
|
11
|
+
def self.included(base)
|
12
12
|
Herald.lazy_load('open-uri')
|
13
|
+
Herald.lazy_load('crack')
|
13
14
|
end
|
14
15
|
|
15
16
|
def parse_options(options); end
|
@@ -37,13 +38,13 @@ class Herald
|
|
37
38
|
|
38
39
|
def activities
|
39
40
|
# return response as string from Twitter and parse it to JSON
|
40
|
-
json = Crack::JSON.parse(open(@uri.join("&")).read)
|
41
|
+
json = Crack::JSON.parse(open(URI.escape(@uri.join("&"))).read)
|
41
42
|
# will be nil if there are no results
|
42
43
|
return if json["results"].nil?
|
43
44
|
@last_tweet_id = json["max_id"]
|
44
45
|
json["results"].each do |tweet|
|
45
46
|
@items << tweet
|
46
|
-
notify(Item.new("@#{tweet['from_user']}", tweet['text'],
|
47
|
+
notify(Item.new("@#{tweet['from_user']}", tweet['text'], tweet))
|
47
48
|
end
|
48
49
|
@uri = [@uri.first, "since_id=#{@last_tweet_id}"]
|
49
50
|
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
class Herald
|
2
|
+
class Watcher
|
3
|
+
|
4
|
+
module Website
|
5
|
+
|
6
|
+
attr_accessor :uris, :selectors, :traverse
|
7
|
+
|
8
|
+
# lazy-load open-uri when this Module is used
|
9
|
+
def self.included(base)
|
10
|
+
%w(open-uri hpricot crack).each do |lib|
|
11
|
+
Herald.lazy_load(lib)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def parse_options(options)
|
16
|
+
# assign an array of selectors
|
17
|
+
# user can pass in either :only_in_tag[s], or :within_tag[s].
|
18
|
+
# :only_in_tag will limit hpricot to search for text
|
19
|
+
# in that particular tag only, whereas :within_tag will
|
20
|
+
# include all children of the tag in the search
|
21
|
+
# find the option param that matches "tag"
|
22
|
+
options.find do |k, v|
|
23
|
+
if k.to_s.match(/tag/)
|
24
|
+
@selectors = Array(v) # coerce to Array
|
25
|
+
# if the key is :only_tag[s]
|
26
|
+
@traverse = false if k.to_s.match(/only/)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
# default selector if no relevant option passed
|
30
|
+
@selectors ||= ["body"]
|
31
|
+
# if we should traverse, append a * (css for "every child") to all selectors
|
32
|
+
if @traverse
|
33
|
+
@selectors.map!{|s|"#{s} *"}
|
34
|
+
end
|
35
|
+
# parse uri Strings to URI objects
|
36
|
+
@uris = Array(options.delete(:from))
|
37
|
+
if @uris.empty?
|
38
|
+
raise ArgumentError, "Website source not specified in :from Hash"
|
39
|
+
end
|
40
|
+
@uris.map!{ |uri| URI.escape(uri) }
|
41
|
+
end
|
42
|
+
|
43
|
+
def prepare; end
|
44
|
+
def cleanup; end
|
45
|
+
|
46
|
+
def to_s
|
47
|
+
"Herald Website Watcher, URIs: #{@uris}, Elements #{@selectors}, Keywords: '#{@keywords}', Timer: #{@timer}, State: #{@keep_alive ? 'Watching' : 'Stopped'}"
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def activities
|
53
|
+
@uris.each do |uri|
|
54
|
+
# hpricot = Hpricot(open(uri).read, :fixup_tags => true)
|
55
|
+
hpricot = Hpricot(open(uri).read, :xhtml_strict => true)
|
56
|
+
hpricot.search("script").remove
|
57
|
+
if title = hpricot.search("title").inner_html
|
58
|
+
title.strip!
|
59
|
+
end
|
60
|
+
title ||= "Website"
|
61
|
+
# for every selector given
|
62
|
+
@selectors.each do |selector|
|
63
|
+
# and for every keyword given
|
64
|
+
@keywords.each do |keyword|
|
65
|
+
# return response as string and parse to RSS
|
66
|
+
begin
|
67
|
+
# search for elements in the page that match the
|
68
|
+
# selector, and contain the keyword as text
|
69
|
+
# TODO - keywords should be parsed and validated
|
70
|
+
# TODO - keywords at the moment are case-sensitive
|
71
|
+
# get the second to last element
|
72
|
+
elements = hpricot.search("#{selector} [text()*=#{keyword}]")
|
73
|
+
element = elements[elements.size - 2]
|
74
|
+
if text = element.inner_html
|
75
|
+
text.strip!
|
76
|
+
end
|
77
|
+
html = element.to_html
|
78
|
+
tag = element.name
|
79
|
+
data = {:title => title, :text => text, :tag => tag, :html => html}
|
80
|
+
# ignore items that have been part of a notification round
|
81
|
+
next if @items.include?(data)
|
82
|
+
# notify!
|
83
|
+
notify(Item.new(title, text, data))
|
84
|
+
@items << data
|
85
|
+
rescue
|
86
|
+
# TODO handle errors
|
87
|
+
end # end begin
|
88
|
+
end # end each keyword
|
89
|
+
end # end each selector
|
90
|
+
end # end each uri
|
91
|
+
end # end method
|
92
|
+
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
data/test/herald_test.rb
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
describe Herald do
|
4
4
|
before do
|
5
|
-
@herald = Herald.
|
5
|
+
@herald = Herald.watch_twitter { _for "test" }
|
6
6
|
end
|
7
7
|
|
8
8
|
after do
|
@@ -25,12 +25,6 @@ describe Herald do
|
|
25
25
|
end
|
26
26
|
|
27
27
|
describe "initialisation of twitter watcher" do
|
28
|
-
it "must throw an error if no keywords are given" do
|
29
|
-
skip("Test not working")
|
30
|
-
assert_raises(ArgumentError) do
|
31
|
-
Herald.watch { check :twitter }
|
32
|
-
end
|
33
|
-
end
|
34
28
|
it "must assign a twitter watcher" do
|
35
29
|
@herald.watchers.first.type.must_equal(:twitter)
|
36
30
|
end
|
@@ -52,7 +46,7 @@ describe Herald do
|
|
52
46
|
it "must assign keyword when passed as a string" do
|
53
47
|
@herald.watchers.first.keywords.must_be_kind_of(Array)
|
54
48
|
@herald.watchers.first.keywords.size.must_equal(1)
|
55
|
-
@herald.watchers.first.keywords.
|
49
|
+
@herald.watchers.first.keywords.join.must_equal("test")
|
56
50
|
end
|
57
51
|
it "must slurp keywords when passed as a multiargument strings" do
|
58
52
|
keywords = ["test1", "test2"]
|
@@ -70,7 +64,7 @@ describe Herald do
|
|
70
64
|
|
71
65
|
describe "initialisation with actions" do
|
72
66
|
it "should assign stdout as the default Notifier" do
|
73
|
-
skip("@notifiers appears as empty
|
67
|
+
skip("@notifiers appears as empty because anything assigned in the process fork can't be accessed outside it")
|
74
68
|
@herald.watchers.first.notifiers.size.must_equal(1)
|
75
69
|
end
|
76
70
|
end
|