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.
@@ -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