url_tracker 0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/bin/ut +5 -0
- data/bin/utd +5 -0
- data/lib/url_tracker.rb +70 -0
- data/test/test_client.rb +88 -0
- data/test/test_helper.rb +42 -0
- data/test/test_page.rb +52 -0
- data/test/test_periodic.rb +78 -0
- data/test/test_server.rb +71 -0
- data/test/test_socket_communication.rb +89 -0
- metadata +110 -0
data/bin/ut
ADDED
data/bin/utd
ADDED
data/lib/url_tracker.rb
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'notifier'
|
2
|
+
|
3
|
+
module UrlTracker
|
4
|
+
require_relative 'url_tracker/version'
|
5
|
+
require_relative 'url_tracker/page'
|
6
|
+
require_relative 'url_tracker/periodic'
|
7
|
+
require_relative 'url_tracker/socket_communication'
|
8
|
+
require_relative 'url_tracker/client'
|
9
|
+
require_relative 'url_tracker/server'
|
10
|
+
|
11
|
+
extend self
|
12
|
+
|
13
|
+
require 'set'
|
14
|
+
|
15
|
+
# Tracks +url+ fetching its content every +time+ seconds (defaults
|
16
|
+
# to 5*60 - 5 minutes).
|
17
|
+
def track_uri(uri, time = 5*60)
|
18
|
+
init_ivars
|
19
|
+
|
20
|
+
p = Page.new(URI(uri))
|
21
|
+
|
22
|
+
if @pages.add?(p)
|
23
|
+
@scheduler.task(uri).every(:minute) { check_change(p) }
|
24
|
+
'ok'
|
25
|
+
else
|
26
|
+
'error'
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Returns an array with all URIs currently being tracked
|
31
|
+
def list_all
|
32
|
+
init_ivars
|
33
|
+
uri_list = @pages.map { |page| page.uri }
|
34
|
+
|
35
|
+
uri_list.empty? ? ',' : uri_list.join(',')
|
36
|
+
end
|
37
|
+
|
38
|
+
# Stops tracking given URI
|
39
|
+
def release_uri(uri)
|
40
|
+
init_ivars
|
41
|
+
|
42
|
+
p = Page.new(URI(uri))
|
43
|
+
if @pages.include?(p)
|
44
|
+
@pages.delete(p)
|
45
|
+
@scheduler.remove_task(uri)
|
46
|
+
'ok'
|
47
|
+
else
|
48
|
+
'error'
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Forget about current tracked URIs
|
53
|
+
def restart
|
54
|
+
init_ivars
|
55
|
+
@pages.clear
|
56
|
+
@scheduler.restart
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def check_change(page)
|
62
|
+
Notifier.notify(title: 'Change!', message: "Page #{page.uri} changed!") if page.changed?
|
63
|
+
end
|
64
|
+
|
65
|
+
def init_ivars
|
66
|
+
@pages ||= Set.new
|
67
|
+
@scheduler ||= Periodic.new
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
data/test/test_client.rb
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
require_relative 'test_helper'
|
2
|
+
|
3
|
+
class TestClient < MiniTest::Unit::TestCase
|
4
|
+
|
5
|
+
def setup
|
6
|
+
remove_socket
|
7
|
+
@server = Object.new.extend(UrlTracker::SocketCommunication)
|
8
|
+
@server.bind(socket_file)
|
9
|
+
|
10
|
+
async do
|
11
|
+
@server.wait_for_connection
|
12
|
+
end
|
13
|
+
|
14
|
+
@client = UrlTracker::Client.new(socket_file)
|
15
|
+
|
16
|
+
wait_sync
|
17
|
+
end
|
18
|
+
|
19
|
+
def teardown
|
20
|
+
@client.close_connection
|
21
|
+
@server.close_connection
|
22
|
+
end
|
23
|
+
|
24
|
+
def test_successful_track_new_url
|
25
|
+
async { @success = @client.track(fake_url) }
|
26
|
+
@server.write('ok')
|
27
|
+
wait_sync
|
28
|
+
|
29
|
+
assert_equal "track #{fake_url}", @server.next_message
|
30
|
+
assert @success
|
31
|
+
end
|
32
|
+
|
33
|
+
def test_server_could_not_track_url
|
34
|
+
async { @success = @client.track(fake_url) }
|
35
|
+
@server.write('error')
|
36
|
+
wait_sync
|
37
|
+
|
38
|
+
assert !@success
|
39
|
+
end
|
40
|
+
|
41
|
+
def test_list_urls_empty
|
42
|
+
async { @urls = @client.list }
|
43
|
+
@server.write(',')
|
44
|
+
wait_sync
|
45
|
+
|
46
|
+
assert_empty @urls
|
47
|
+
end
|
48
|
+
|
49
|
+
def test_list_single_url
|
50
|
+
async { @urls = @client.list }
|
51
|
+
@server.write(fake_url)
|
52
|
+
wait_sync
|
53
|
+
|
54
|
+
assert_equal [fake_url], @urls
|
55
|
+
end
|
56
|
+
|
57
|
+
def test_list_multiple_urls
|
58
|
+
async { @urls = @client.list }
|
59
|
+
@server.write(fake_url + ',http://foo.bar,http://google.com')
|
60
|
+
wait_sync
|
61
|
+
|
62
|
+
assert_equal [fake_url, 'http://foo.bar', 'http://google.com'], @urls
|
63
|
+
end
|
64
|
+
|
65
|
+
def test_release_tracked_url
|
66
|
+
async { @client.track(fake_url) }
|
67
|
+
@server.write('ok')
|
68
|
+
wait_sync
|
69
|
+
|
70
|
+
@server.next_message # so that we don't get the tracking request on the assertion
|
71
|
+
|
72
|
+
async { @success = @client.release(fake_url) }
|
73
|
+
@server.write('ok')
|
74
|
+
wait_sync
|
75
|
+
|
76
|
+
assert_equal "release #{fake_url}", @server.next_message
|
77
|
+
assert @success
|
78
|
+
end
|
79
|
+
|
80
|
+
def test_release_url_not_tracked
|
81
|
+
async { @success = @client.release(fake_url) }
|
82
|
+
@server.write('error')
|
83
|
+
wait_sync
|
84
|
+
|
85
|
+
assert !@success
|
86
|
+
end
|
87
|
+
|
88
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'minitest/autorun'
|
2
|
+
require 'minitest/pride'
|
3
|
+
require_relative '../lib/url_tracker'
|
4
|
+
|
5
|
+
# messages sent via sockets in the tests cannot be longer than 100 bytes
|
6
|
+
MAX_TEST_SOCK_MESSAGE = 100
|
7
|
+
|
8
|
+
puts "Url Tracker: running on Ruby version #{RUBY_VERSION}"
|
9
|
+
|
10
|
+
def assert_socket_received(message, socket)
|
11
|
+
assert_block("Expected #{socket.inspect} to receive #{message.inspect}") do
|
12
|
+
socket.recvfrom(MAX_TEST_SOCK_MESSAGE).first == message
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def async(stop = false, &block)
|
17
|
+
@t = Thread.new(&block)
|
18
|
+
@t.abort_on_exception = true
|
19
|
+
|
20
|
+
while !@t.stop?; end if stop
|
21
|
+
end
|
22
|
+
|
23
|
+
def wait_sync
|
24
|
+
@t.join
|
25
|
+
end
|
26
|
+
|
27
|
+
def finish_async
|
28
|
+
@t.terminate
|
29
|
+
@t.join
|
30
|
+
end
|
31
|
+
|
32
|
+
def remove_socket
|
33
|
+
File.unlink(socket_file) if File.exists?(socket_file)
|
34
|
+
end
|
35
|
+
|
36
|
+
def socket_file
|
37
|
+
'/tmp/.dk29ei39kd3.sock'
|
38
|
+
end
|
39
|
+
|
40
|
+
def fake_url
|
41
|
+
'http://fake.com'
|
42
|
+
end
|
data/test/test_page.rb
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
require_relative 'test_helper'
|
2
|
+
|
3
|
+
class TestPage < MiniTest::Unit::TestCase
|
4
|
+
|
5
|
+
def setup
|
6
|
+
@page_fetcher = MiniTest::Mock.new
|
7
|
+
@content = 'web page'
|
8
|
+
|
9
|
+
# should receive method get with argument fake_url and return @content
|
10
|
+
@page_fetcher.expect(:get, @content, [fake_url])
|
11
|
+
|
12
|
+
@page = UrlTracker::Page.new(fake_url, @page_fetcher)
|
13
|
+
end
|
14
|
+
|
15
|
+
def test_page_content
|
16
|
+
assert_equal @content, @page.content
|
17
|
+
end
|
18
|
+
|
19
|
+
def test_page_content_bang
|
20
|
+
assert_equal @content, @page.content
|
21
|
+
|
22
|
+
change_page_content('new content')
|
23
|
+
assert_equal 'new content', @page.content!
|
24
|
+
end
|
25
|
+
|
26
|
+
def test_page_changed_before_cached_content
|
27
|
+
assert !@page.changed?, 'Expected no changes when there is no cache of the page'
|
28
|
+
end
|
29
|
+
|
30
|
+
def test_page_changed_when_no_changes_happened
|
31
|
+
@page.content # we now have a cache of the page
|
32
|
+
|
33
|
+
assert !@page.changed?, 'Expected a false result when no changes occur to a page'
|
34
|
+
end
|
35
|
+
|
36
|
+
def test_page_changed_when_changes_really_happened
|
37
|
+
@page.content
|
38
|
+
change_page_content('new content')
|
39
|
+
|
40
|
+
assert @page.changed?, 'Expected Page to indicate change when its content was changed'
|
41
|
+
end
|
42
|
+
|
43
|
+
def change_page_content(new_content)
|
44
|
+
@page_fetcher.expect(:get, new_content, [fake_url])
|
45
|
+
end
|
46
|
+
|
47
|
+
def test_page_equality
|
48
|
+
other_page = UrlTracker::Page.new(fake_url)
|
49
|
+
assert_equal @page, other_page
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
require_relative 'test_helper'
|
2
|
+
|
3
|
+
class TestPeriodic < MiniTest::Unit::TestCase
|
4
|
+
|
5
|
+
def setup
|
6
|
+
@p = UrlTracker::Periodic.new
|
7
|
+
end
|
8
|
+
|
9
|
+
def teardown
|
10
|
+
@p.stop
|
11
|
+
end
|
12
|
+
|
13
|
+
def test_every_with_symbol_argument
|
14
|
+
assert_equal 60, @p.every(:minute) {}
|
15
|
+
end
|
16
|
+
|
17
|
+
def test_every_with_hour_symbol
|
18
|
+
assert_equal 60*60, @p.every(:hour) {}
|
19
|
+
end
|
20
|
+
|
21
|
+
def test_two_minutes
|
22
|
+
assert_equal 2*60, @p.every(2, :minutes) {}
|
23
|
+
end
|
24
|
+
|
25
|
+
def test_two_hours
|
26
|
+
assert_equal 2*60*60, @p.every(2, :hours) {}
|
27
|
+
end
|
28
|
+
|
29
|
+
def test_invalid_first_argument
|
30
|
+
assert_raises(RuntimeError) do
|
31
|
+
@p.every(:invalid) {}
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def test_invalid_time_unit
|
36
|
+
assert_raises(RuntimeError) do
|
37
|
+
@p.every(2, :invalid) {}
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def test_add_named_task
|
42
|
+
@p.task("dummy").every(:minute) {}
|
43
|
+
assert_equal ["dummy"], @p.named_tasks
|
44
|
+
end
|
45
|
+
|
46
|
+
def test_remove_named_task
|
47
|
+
@p.task("dummy").every(:minute) {}
|
48
|
+
@p.remove_task("dummy")
|
49
|
+
|
50
|
+
assert_empty @p.named_tasks
|
51
|
+
end
|
52
|
+
|
53
|
+
def test_remove_inexistent_task
|
54
|
+
assert_raises(RuntimeError) do
|
55
|
+
@p.remove_task("dummy")
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def test_restart
|
60
|
+
@p.stop
|
61
|
+
assert !@p.running?
|
62
|
+
|
63
|
+
@p.restart
|
64
|
+
assert @p.running?
|
65
|
+
end
|
66
|
+
|
67
|
+
def test_running
|
68
|
+
assert @p.running?, 'Expected Periodic to be running when `stop` was not called.'
|
69
|
+
end
|
70
|
+
|
71
|
+
def test_running_when_asked_to_stop
|
72
|
+
@p.stop
|
73
|
+
|
74
|
+
assert !@p.running?, 'Expected Periodic to stop'
|
75
|
+
@p.restart
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
data/test/test_server.rb
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
require_relative 'test_helper'
|
2
|
+
|
3
|
+
class TestServer < MiniTest::Unit::TestCase
|
4
|
+
|
5
|
+
def setup
|
6
|
+
remove_socket
|
7
|
+
@server = UrlTracker::Server.new(Logger.new('/dev/null'))
|
8
|
+
|
9
|
+
async(true) { @server.run(socket_file: socket_file) }
|
10
|
+
|
11
|
+
@client = UrlTracker::Client.new(socket_file) # connected!
|
12
|
+
end
|
13
|
+
|
14
|
+
def teardown
|
15
|
+
@client.close_connection
|
16
|
+
finish_async
|
17
|
+
@server.close_connection
|
18
|
+
UrlTracker.restart
|
19
|
+
end
|
20
|
+
|
21
|
+
def test_successfully_tracks_url
|
22
|
+
assert @client.track(fake_url), 'Expected to be able to track new URI'
|
23
|
+
end
|
24
|
+
|
25
|
+
def test_track_same_url
|
26
|
+
@client.track(fake_url)
|
27
|
+
restart_client
|
28
|
+
|
29
|
+
assert !@client.track(fake_url), 'Expected to return false when tracking same URI'
|
30
|
+
end
|
31
|
+
|
32
|
+
def test_list_empty
|
33
|
+
assert_equal [], @client.list
|
34
|
+
end
|
35
|
+
|
36
|
+
def test_list_with_single_uri
|
37
|
+
@client.track(fake_url)
|
38
|
+
restart_client
|
39
|
+
|
40
|
+
assert_equal [fake_url], @client.list
|
41
|
+
end
|
42
|
+
|
43
|
+
def test_list_two_uris
|
44
|
+
@client.track(fake_url)
|
45
|
+
restart_client
|
46
|
+
@client.track(fake_url + '/fake')
|
47
|
+
restart_client
|
48
|
+
|
49
|
+
assert_equal [fake_url, fake_url + '/fake'], @client.list
|
50
|
+
end
|
51
|
+
|
52
|
+
def test_release_when_tracking_nothing
|
53
|
+
assert !@client.release(fake_url), 'Expected to return false when releasing not tracked URI'
|
54
|
+
end
|
55
|
+
|
56
|
+
def test_release_with_tracked_uri
|
57
|
+
@client.track(fake_url)
|
58
|
+
restart_client
|
59
|
+
assert @client.release(fake_url), 'Expected to release tracked URI'
|
60
|
+
|
61
|
+
restart_client
|
62
|
+
assert_equal [], @client.list
|
63
|
+
end
|
64
|
+
|
65
|
+
def restart_client
|
66
|
+
@client.close_connection
|
67
|
+
|
68
|
+
@client = UrlTracker::Client.new(socket_file)
|
69
|
+
end
|
70
|
+
|
71
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
require_relative 'test_helper'
|
2
|
+
|
3
|
+
class TestSocketCommunication < MiniTest::Unit::TestCase
|
4
|
+
|
5
|
+
def setup
|
6
|
+
@client = Object.new.extend(UrlTracker::SocketCommunication)
|
7
|
+
@server = Object.new.extend(UrlTracker::SocketCommunication)
|
8
|
+
@path = '/tmp/.dk29ei39kd3.sock'
|
9
|
+
remove_socket
|
10
|
+
end
|
11
|
+
|
12
|
+
def teardown
|
13
|
+
[@client, @server].each { |o| o.close_connection }
|
14
|
+
end
|
15
|
+
|
16
|
+
def test_connect_to_valid_socket
|
17
|
+
@server.bind(@path)
|
18
|
+
assert @client.connect(@path), 'Should connect when given a valid socket file'
|
19
|
+
end
|
20
|
+
|
21
|
+
def test_raises_invalid_socket_with_bind_and_connect
|
22
|
+
@server.bind(@path)
|
23
|
+
|
24
|
+
assert_raises(UrlTracker::SocketCommunication::InvalidSocketError) do
|
25
|
+
@server.connect(@path)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def test_raises_invalid_socket_with_connect_and_bind
|
30
|
+
@server.bind(@path)
|
31
|
+
@client.connect(@path)
|
32
|
+
|
33
|
+
assert_raises(UrlTracker::SocketCommunication::InvalidSocketError) do
|
34
|
+
@client.bind(@path)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def test_communication_client_to_server
|
39
|
+
connect_server_and_client
|
40
|
+
|
41
|
+
@client.write('message')
|
42
|
+
|
43
|
+
assert_equal 'message', @server.next_message
|
44
|
+
end
|
45
|
+
|
46
|
+
def test_communication_server_to_client
|
47
|
+
connect_server_and_client
|
48
|
+
|
49
|
+
@server.write('message')
|
50
|
+
|
51
|
+
assert_equal 'message', @client.next_message
|
52
|
+
end
|
53
|
+
|
54
|
+
def test_communication_bidirectional
|
55
|
+
connect_server_and_client
|
56
|
+
|
57
|
+
@server.write('server mesg')
|
58
|
+
@client.write('client mesg')
|
59
|
+
|
60
|
+
assert_equal 'client mesg', @server.next_message
|
61
|
+
assert_equal 'server mesg', @client.next_message
|
62
|
+
end
|
63
|
+
|
64
|
+
def test_closing_client_connection_doesnt_remove_socket_file
|
65
|
+
connect_server_and_client
|
66
|
+
@client.close_connection
|
67
|
+
|
68
|
+
assert File.exists?(@path), 'Expected socket file to exist when closing client connection'
|
69
|
+
end
|
70
|
+
|
71
|
+
def test_closing_server_connection_removes_socket_file
|
72
|
+
connect_server_and_client
|
73
|
+
@server.close_connection
|
74
|
+
|
75
|
+
assert !File.exists?(@path), 'Expected socket file to be removed when server closes connection'
|
76
|
+
end
|
77
|
+
|
78
|
+
def remove_socket
|
79
|
+
File.unlink @path if File.exists? @path
|
80
|
+
end
|
81
|
+
|
82
|
+
def connect_server_and_client
|
83
|
+
@server.bind(@path)
|
84
|
+
t = Thread.new { @server.wait_for_connection }
|
85
|
+
@client.connect(@path)
|
86
|
+
t.join # server is connected to client
|
87
|
+
end
|
88
|
+
|
89
|
+
end
|
metadata
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: url_tracker
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: '0.1'
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Renato Mascarenhas
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-06-08 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: eventmachine
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>='
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '0'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: libnotify
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '0'
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '0'
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: notifier
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ! '>='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
type: :runtime
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ! '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
description: A simple tool for tracking URLs and getting informed when they are changed.
|
63
|
+
email: renato.mascosta@gmail.com
|
64
|
+
executables:
|
65
|
+
- ut
|
66
|
+
- utd
|
67
|
+
extensions: []
|
68
|
+
extra_rdoc_files: []
|
69
|
+
files:
|
70
|
+
- lib/url_tracker.rb
|
71
|
+
- bin/ut
|
72
|
+
- bin/utd
|
73
|
+
- test/test_server.rb
|
74
|
+
- test/test_periodic.rb
|
75
|
+
- test/test_socket_communication.rb
|
76
|
+
- test/test_client.rb
|
77
|
+
- test/test_page.rb
|
78
|
+
- test/test_helper.rb
|
79
|
+
homepage:
|
80
|
+
licenses:
|
81
|
+
- MIT
|
82
|
+
post_install_message:
|
83
|
+
rdoc_options: []
|
84
|
+
require_paths:
|
85
|
+
- lib
|
86
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
87
|
+
none: false
|
88
|
+
requirements:
|
89
|
+
- - ! '>='
|
90
|
+
- !ruby/object:Gem::Version
|
91
|
+
version: '0'
|
92
|
+
segments:
|
93
|
+
- 0
|
94
|
+
hash: -2248500158094718750
|
95
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
96
|
+
none: false
|
97
|
+
requirements:
|
98
|
+
- - ! '>='
|
99
|
+
- !ruby/object:Gem::Version
|
100
|
+
version: '0'
|
101
|
+
segments:
|
102
|
+
- 0
|
103
|
+
hash: -2248500158094718750
|
104
|
+
requirements: []
|
105
|
+
rubyforge_project:
|
106
|
+
rubygems_version: 1.8.23
|
107
|
+
signing_key:
|
108
|
+
specification_version: 3
|
109
|
+
summary: A simple tool for tracking URLs and getting informed when they are changed.
|
110
|
+
test_files: []
|