herald 0.1 → 0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -4,42 +4,23 @@ class Herald
4
4
 
5
5
  module Ping
6
6
 
7
- attr_reader :uri
8
-
9
- # lazy-load net/http when this Module is used
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.lazy_load('net/http')
12
- end
13
-
14
- # note: dupe between ping and post
15
- def parse_options(options)
16
- begin
17
- @uri = URI.parse(options.delete(:uri) || options.delete(:url))
18
- # if URI lib can't resolve a protocol (because it was missing from string)
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
- rescue URI::InvalidURIError
23
- raise ArgumentError, ":uri for :ping action not specified or invalid"
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
- begin
17
- @uri = URI.parse(options.delete(:uri) || options.delete(:url))
18
- # if URI lib can't resolve a protocol (because it was missing from string)
19
- if @uri.class == URI::Generic
20
- @uri = URI.parse("http://#{@uri.path}")
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
- response = Net::HTTP.new(@uri.host).head('/')
30
- return if response.kind_of?(Net::HTTPOK)
31
- # TODO raise custom error types
32
- if response.kind_of?(Net::HTTPFound)
33
- raise "URI #{@uri} is being redirected to #{response.header['location']}"
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
- Net::HTTP.post_form(@uri.to_s, { "title" => item.title, "message" => item.message })
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
@@ -0,0 +1,5 @@
1
+ require 'herald'
2
+ herald = Herald.watch do
3
+ check :website, :from => "http://www.google.co.nz", :within_tags => ["#some_id", #another_id]
4
+ _for "search"
5
+ end
@@ -1,3 +1,3 @@
1
1
  class Herald
2
- VERSION = "0.1"
2
+ VERSION = "0.2"
3
3
  end
@@ -2,33 +2,29 @@ class Herald
2
2
 
3
3
  class Watcher
4
4
 
5
- @@watcher_types = [:rss, :twitter] # :imap
5
+ @@watcher_types = [:rss, :twitter, :website]
6
6
  DEFAULT_TIMER = 60
7
7
 
8
- attr_reader :type, :keep_alive, :thread, :items
9
- attr_accessor :notifiers, :keywords, :timer
10
-
11
- def initialize(type, keep_alive, options, &block)
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
- # extend class with module
31
- send(:extend, eval(@type.to_s.capitalize))
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
@@ -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.extended(base)
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, URL: #{@uris}, Keywords: '#{@keywords}', Timer: #{@timer}, State: #{@keep_alive ? 'Watching' : 'Stopped'}"
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 string and parse to RSS
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["rss"]["channel"]["item"].each do |item|
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.extended(base)
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'], json['results']))
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.watch { check :twitter; _for "test" }
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.to_s.must_equal("test")
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, possible due to threading?")
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