geostats 0.1.0

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.
Files changed (49) hide show
  1. data/LICENSE +13 -0
  2. data/README.md +58 -0
  3. data/bin/geostats +33 -0
  4. data/lib/geostats.rb +36 -0
  5. data/lib/geostats/commands/base.rb +73 -0
  6. data/lib/geostats/commands/console.rb +17 -0
  7. data/lib/geostats/commands/generate.rb +21 -0
  8. data/lib/geostats/commands/help.rb +20 -0
  9. data/lib/geostats/commands/init.rb +31 -0
  10. data/lib/geostats/commands/migrate.rb +21 -0
  11. data/lib/geostats/commands/push.rb +41 -0
  12. data/lib/geostats/commands/set.rb +35 -0
  13. data/lib/geostats/commands/update.rb +175 -0
  14. data/lib/geostats/console.rb +2 -0
  15. data/lib/geostats/core_ext.rb +5 -0
  16. data/lib/geostats/database.rb +15 -0
  17. data/lib/geostats/generator.rb +43 -0
  18. data/lib/geostats/grabber/cache.rb +95 -0
  19. data/lib/geostats/grabber/log.rb +27 -0
  20. data/lib/geostats/grabber/logs.rb +37 -0
  21. data/lib/geostats/grabber/user.rb +15 -0
  22. data/lib/geostats/http.rb +97 -0
  23. data/lib/geostats/migrations/001_create_caches.rb +27 -0
  24. data/lib/geostats/migrations/002_create_cache_types.rb +16 -0
  25. data/lib/geostats/migrations/003_create_logs.rb +13 -0
  26. data/lib/geostats/migrations/004_create_log_types.rb +15 -0
  27. data/lib/geostats/migrations/005_create_settings.rb +9 -0
  28. data/lib/geostats/migrations/006_add_cito_cache_type.rb +5 -0
  29. data/lib/geostats/models/cache.rb +47 -0
  30. data/lib/geostats/models/cache_type.rb +9 -0
  31. data/lib/geostats/models/log.rb +33 -0
  32. data/lib/geostats/models/log_type.rb +9 -0
  33. data/lib/geostats/models/setting.rb +16 -0
  34. data/lib/geostats/stats.rb +220 -0
  35. data/lib/geostats/templates/default.mustache +10 -0
  36. data/lib/geostats/templates/difficulty_terrain_matrix.mustache +40 -0
  37. data/lib/geostats/templates/founds_by_cache_type.mustache +17 -0
  38. data/lib/geostats/templates/milestones.mustache +19 -0
  39. data/lib/geostats/templates/monthly_founds.mustache +18 -0
  40. data/lib/geostats/templates/stylesheet.css +160 -0
  41. data/lib/geostats/utils.rb +42 -0
  42. data/lib/geostats/version.rb +3 -0
  43. data/lib/geostats/views/base.rb +19 -0
  44. data/lib/geostats/views/default.rb +13 -0
  45. data/lib/geostats/views/difficulty_terrain_matrix.rb +34 -0
  46. data/lib/geostats/views/founds_by_cache_type.rb +24 -0
  47. data/lib/geostats/views/milestones.rb +23 -0
  48. data/lib/geostats/views/monthly_founds.rb +24 -0
  49. metadata +165 -0
@@ -0,0 +1,2 @@
1
+ include Geostats
2
+ Database.connect!(ENV["GEOSTATS_DIR"])
@@ -0,0 +1,5 @@
1
+ class Array
2
+ def avg
3
+ length.zero? ? 0 : sum.to_f / length.to_f
4
+ end
5
+ end
@@ -0,0 +1,15 @@
1
+ module Geostats
2
+ class Database
3
+ def self.connect!(directory=nil)
4
+ directory ||= File.expand_path("~/.geostats")
5
+
6
+ file = File.join(directory, "database.sqlite3")
7
+ raise "file does not exist: #{file}" unless File.exists?(file)
8
+
9
+ ActiveRecord::Base.establish_connection(
10
+ :adapter => "sqlite3",
11
+ :database => file
12
+ )
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,43 @@
1
+ require "hpricot"
2
+ require "css_parser"
3
+
4
+ require "geostats/views/base"
5
+ require "geostats/views/default"
6
+
7
+ module Geostats
8
+ class Generator
9
+ PARTS = %w(difficulty_terrain_matrix milestones monthly_founds
10
+ founds_by_cache_type)
11
+
12
+ def self.generate
13
+ new.generate
14
+ end
15
+
16
+ def initialize
17
+ @view = Views::Default.new
18
+ @css = CssParser::Parser.new
19
+
20
+ file = File.join(File.dirname(__FILE__), "templates", "stylesheet.css")
21
+ @css.add_block!(File.read(file))
22
+ end
23
+
24
+ def generate
25
+ PARTS.each do |part|
26
+ require "geostats/views/#{part}"
27
+ @view[part.to_sym] = "Geostats::Views::#{part.camelcase}".constantize.render
28
+ end
29
+
30
+ @doc = Hpricot(@view.render)
31
+
32
+ @css.each_selector do |selector, style|
33
+ @doc.search(selector) do |el|
34
+ if el.elem?
35
+ el["style"] = "#{el.attributes['style']} #{style}".strip
36
+ end
37
+ end
38
+ end
39
+
40
+ @doc.to_html
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,95 @@
1
+ module Geostats
2
+ module Grabber
3
+ class Cache
4
+ def initialize(uuid)
5
+ @resp, @data = HTTP.get("/seek/cache_details.aspx?guid=#{uuid}&log=y")
6
+ end
7
+
8
+ def code
9
+ if @data =~ /<div id="ctl00_cacheCodeWidget".*?>\s*<p>\s*([A-Z0-9]+)<\/p>\s*<\/div>/
10
+ $1
11
+ end
12
+ end
13
+
14
+ def name
15
+ if @data =~ /<span id="ctl00_ContentBody_CacheName">(.*?)<\/span>/
16
+ name = Utils.unescape($1)
17
+ name =~ /^<strike>(.*)<\/strike>$/ ? $1 : name
18
+ end
19
+ end
20
+
21
+ def type
22
+ if @data =~ /<h2.*?WptTypes\/(\d+)\.gif".*?h2>/
23
+ $1.to_i
24
+ end
25
+ end
26
+
27
+ def hidden_at
28
+ if @data =~ /<span id="ctl00_ContentBody_DateHidden">(\d{1,2})\/(\d{1,2})\/(\d{4})<\/span>/
29
+ Time.parse([$2, $1, $3].join("."))
30
+ end
31
+ end
32
+
33
+ def size
34
+ if @data =~ /<img src="\/images\/icons\/container\/(.*?)\.gif" alt="Size: .*?" \/>/
35
+ $1 if %w(micro small regular large other not_chosen).include?($1)
36
+ end
37
+ end
38
+
39
+ def owner
40
+ if @data =~ /<span id="ctl00_ContentBody_CacheOwner">by.*?\/profile\/\?guid=(.*?)&wid=.*<\/a><\/span>/
41
+ user = Grabber::User.new($1)
42
+ if name = user.username
43
+ name
44
+ end
45
+ end
46
+ end
47
+
48
+ def difficulty
49
+ if @data =~ /<span id="ctl00_ContentBody_Difficulty"><img src=".*?" alt="([0-9\.]+) out of 5" \/><\/span>/
50
+ $1.to_f
51
+ end
52
+ end
53
+
54
+ def terrain
55
+ if @data =~ /<span id="ctl00_ContentBody_Terrain"><img src=".*?" alt="([0-9\.]+) out of 5" \/><\/span>/
56
+ $1.to_f
57
+ end
58
+ end
59
+
60
+ def latitude
61
+ if @data =~ /\/wpt\/\?lat=(.*)&amp;lon=(.*)&amp;detail=1/
62
+ $1.to_f
63
+ end
64
+ end
65
+
66
+ def longitude
67
+ if @data =~ /\/wpt\/\?lat=(.*)&amp;lon=(.*)&amp;detail=1/
68
+ $2.to_f
69
+ end
70
+ end
71
+
72
+ def location
73
+ if @data =~ /<span id="ctl00_ContentBody_Location">In (.*?)<\/span>/
74
+ $1
75
+ end
76
+ end
77
+
78
+ def founds_count
79
+ @data.scan(/<strong><img src=".*?icon_smile\.gif" alt="" \/>&nbsp;/).length
80
+ end
81
+
82
+ def dnf_count
83
+ @data.scan(/<strong><img src=".*?icon_sad\.gif" alt="" \/>&nbsp;/).length
84
+ end
85
+
86
+ def archived?
87
+ !!(@data =~ /<li>This cache has been archived/)
88
+ end
89
+
90
+ def pmonly?
91
+ !!(@data =~ /<p class="Warning">Sorry, the owner of this listing has made it viewable to Premium Members only./)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,27 @@
1
+ module Geostats
2
+ module Grabber
3
+ class Log
4
+ def initialize(uuid)
5
+ @resp, @data = HTTP.get("/seek/log.aspx?LUID=#{uuid}")
6
+ end
7
+
8
+ def icon
9
+ if @data =~ /<img id="ctl00_ContentBody_LogBookPanel1_LogImage".*?src="\/images\/icons\/(.*?).gif" .*? \/>/
10
+ $1
11
+ end
12
+ end
13
+
14
+ def message
15
+ if @data =~ /<p><span id="ctl00_ContentBody_LogBookPanel1_LogText">(.*)<\/span><\/p>/
16
+ Utils.replace_smilie_img_tags(Utils.unescape($1))
17
+ end
18
+ end
19
+
20
+ def logged_at
21
+ if @data =~ /<span id="ctl00_ContentBody_LogBookPanel1_LogDate">(.*?)<\/span>/
22
+ Time.parse($1) rescue nil
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,37 @@
1
+ module Geostats
2
+ module Grabber
3
+ class Logs
4
+ class Log < Struct.new(:icon, :logged_at, :cache_uuid,
5
+ :cache_name, :location, :log_uuid)
6
+ end
7
+
8
+ REGEX = %r{<tr.*?>\s*<td><img src="/images/icons/([a-z_]+)\.gif" width="16" height="16" alt=".*?" /></td>\s*<td>([0-9\/]+)</td>\s*<td><a href="http://www\.geocaching\.com/seek/cache_details\.aspx\?guid=([a-f0-9\-]+)">(.*?)</a>&nbsp;</td>\s*<td>(.*?) &nbsp;</td>\s*<td><a href="http://www\.geocaching\.com/seek/log\.aspx\?LUID=([a-f0-9\-]+)" target="_blank" title="Visit Log">Visit Log</a></td>\s*</tr>}
9
+
10
+ def initialize
11
+ @resp, @data = HTTP.get("/my/logs.aspx?s=1")
12
+ end
13
+
14
+ def logs
15
+ @data.scan(REGEX).map do |match|
16
+ log = Log.new
17
+
18
+ log.icon = match[0]
19
+ log.cache_uuid = match[2]
20
+ log.cache_name = Utils.unescape(match[3])
21
+ log.location = Utils.unescape(match[4])
22
+ log.log_uuid = match[5]
23
+ log.logged_at = match[1]
24
+
25
+ log.cache_name = Utils.unescape($1) if log.cache_name =~ /^<span class="Strike Warning">(.*)<\/span>$/
26
+ log.cache_name = Utils.unescape($1) if log.cache_name =~ /^<strike>(.*)<\/strike>$/
27
+
28
+ if log.logged_at =~ /^(\d+)\/(\d+)\/(\d+)$/
29
+ log.logged_at = Time.parse([$2, $1, $3].join("."))
30
+ end
31
+
32
+ log
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,15 @@
1
+ module Geostats
2
+ module Grabber
3
+ class User
4
+ def initialize(uuid)
5
+ @resp, @data = HTTP.get("/profile/?guid=#{uuid}")
6
+ end
7
+
8
+ def username
9
+ if @data =~ /<h2><span id="ctl00_ContentBody_lblUserProfile".*?>Profile for User: (.*?)<\/span><\/h2>/
10
+ Utils.unescape($1)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,97 @@
1
+ require "net/http"
2
+
3
+ module Geostats
4
+ class HTTP
5
+ attr_writer :username, :password
6
+
7
+ def self.instance
8
+ @instance ||= new
9
+ end
10
+
11
+ def self.login(username, password)
12
+ self.instance.username = username
13
+ self.instance.password = password
14
+ self.instance.login
15
+ end
16
+
17
+ def self.logout
18
+ self.instance.logout
19
+ end
20
+
21
+ def self.get(path)
22
+ self.instance.get(path)
23
+ end
24
+
25
+ def self.post(path, data={})
26
+ self.instance.post(path, data)
27
+ end
28
+
29
+ def login
30
+ raise ArgumentError, "Missing username" unless @username
31
+ raise ArgumentError, "Missing password" unless @password
32
+
33
+ resp, data = post("/login/default.aspx", {
34
+ "ctl00$ContentBody$myUsername" => @username,
35
+ "ctl00$ContentBody$myPassword" => @password,
36
+ "ctl00$ContentBody$Button1" => "Login",
37
+ "ctl00$ContentBody$cookie" => "on"
38
+ })
39
+
40
+ @cookie = resp.response["set-cookie"]
41
+ end
42
+
43
+ def logout
44
+ get("/login/default.aspx?RESET=Y")
45
+ @cookie = nil
46
+ end
47
+
48
+ def get(path)
49
+ header = default_header
50
+ header["Cookie"] = @cookie if @cookie
51
+
52
+ http.get(path, header)
53
+ end
54
+
55
+ def post(path, data={})
56
+ metadata(path).each do |key, value|
57
+ data[key] = value
58
+ end
59
+
60
+ header = default_header
61
+ header["Cookie"] = @cookie if @cookie
62
+
63
+ http.post(path, data.map { |k,v| "#{k}=#{v}" }.join("&"), header)
64
+ end
65
+
66
+ protected
67
+
68
+ def metadata(path)
69
+ resp, data = get(path)
70
+ meta = {}
71
+
72
+ data.scan(/<input type="hidden" name="__([A-Z]+)" id="__[A-Z]+" value="(.*?)" \/>/).each do |match|
73
+ meta["__#{match[0]}"] = CGI.escape(match[1])
74
+ end
75
+
76
+ meta
77
+ end
78
+
79
+ def user_agent
80
+ "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-GB; rv:1.9.0.13) Gecko/2009073022 Firefox/3.0.13 (.NET CLR 3.5.30729)"
81
+ end
82
+
83
+ def default_header
84
+ {
85
+ "User-Agent" => user_agent
86
+ }
87
+ end
88
+
89
+ def http
90
+ @http ||= begin
91
+ @http = Net::HTTP.new("www.geocaching.com")
92
+ @http.open_timeout = 5
93
+ @http
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,27 @@
1
+ class CreateCaches < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :caches do |t|
4
+ t.integer :cache_type_id
5
+ t.string :uuid
6
+ t.string :code
7
+ t.string :name
8
+ t.float :latitude
9
+ t.float :longitude
10
+ t.float :difficulty
11
+ t.float :terrain
12
+ t.string :owner
13
+ t.string :size
14
+ t.string :location
15
+ t.datetime :hidden_at
16
+ t.boolean :is_pmonly
17
+ t.boolean :is_archived
18
+ t.integer :gcvote_votes
19
+ t.float :gcvote_average
20
+ t.float :gcvote_median
21
+ t.integer :founds_count
22
+ t.integer :dnf_count
23
+ t.datetime :synced_at
24
+ t.timestamps
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ class CreateCacheTypes < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :cache_types do |t|
4
+ t.string :name
5
+ end
6
+
7
+ cache_types = ["Traditional Cache", "Multi-Cache", "Mystery Cache",
8
+ "Letterbox Hybrid", "Wherigo Cache", "EarthCache", "Virtual Cache",
9
+ "Webcam Cache", "Reverse Cache", "Event Cache", "Mega-Event Cache",
10
+ "10 Years! Event Cache"]
11
+
12
+ cache_types.each do |cache_type|
13
+ Geostats::CacheType.create! :name => cache_type
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ class CreateLogs < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :logs do |t|
4
+ t.integer :cache_id
5
+ t.integer :log_type_id
6
+ t.string :uuid
7
+ t.text :message
8
+ t.datetime :logged_at
9
+ t.datetime :synced_at
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ class CreateLogTypes < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :log_types do |t|
4
+ t.string :name
5
+ end
6
+
7
+ log_types = ["Found it", "Didn't find it", "Note", "Needs archived",
8
+ "Needs maintenance", "Archive", "Enable Listing","Temporarily Disable Listing",
9
+ "Update Coordinates", "Owner Maintenance", "Will attend", "Attended"]
10
+
11
+ log_types.each do |log_type|
12
+ Geostats::LogType.create! :name => log_type
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ class CreateSettings < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :settings do |t|
4
+ t.string :key
5
+ t.string :value
6
+ t.timestamps
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ class AddCitoCacheType < ActiveRecord::Migration
2
+ def self.up
3
+ Geostats::CacheType.create! :name => "Cache In Trash Out Event"
4
+ end
5
+ end
@@ -0,0 +1,47 @@
1
+ module Geostats
2
+ class Cache < ActiveRecord::Base
3
+ belongs_to :cache_type, :class_name => "Geostats::CacheType"
4
+ has_many :logs, :class_name => "Geostats::Log"
5
+
6
+ scope :found_or_attended, lambda { where("logs.log_type_id IN (?)", [1, 12]) }
7
+
8
+ validates :uuid, :presence => true, :uniqueness => true
9
+ validates :code, :presence => true, :uniqueness => true
10
+ validates :name, :presence => true
11
+ validates :longitude, :presence => true, :numericality => true
12
+ validates :latitude, :presence => true, :numericality => true
13
+ validates :owner, :presence => true
14
+ validates :hidden_at, :presence => true
15
+ validates :size, :presence => true
16
+ validates :location, :presence => true
17
+ validates :founds_count, :presence => true, :numericality => true
18
+ validates :dnf_count, :presence => true, :numericality => true
19
+
20
+ def self.create_from_website(uuid)
21
+ cache = Cache.new(:uuid => uuid)
22
+ cache.update_from_website
23
+ cache
24
+ end
25
+
26
+ def update_from_website
27
+ info = Grabber::Cache.new(self.uuid)
28
+
29
+ [:name, :code, :size, :latitude, :longitude, :difficulty, :terrain,
30
+ :location, :owner, :hidden_at, :founds_count, :dnf_count
31
+ ].each do |attribute|
32
+ if value = info.send(attribute)
33
+ self.attributes = { attribute => value }
34
+ end
35
+ end
36
+
37
+ self.is_pmonly = info.pmonly?
38
+ self.is_archived = info.archived?
39
+
40
+ if type = info.type and index = CacheType::MAPPING.index(type)
41
+ self.cache_type = CacheType.where(:id => index + 1).first
42
+ end
43
+
44
+ self.synced_at = Time.now
45
+ end
46
+ end
47
+ end