herald 0.1 → 0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|