git-fit 0.7.1 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 726e06c730a1309c59072df3f52e3261f5bf0e9d3b931e7a7016a8756c3190fa
4
- data.tar.gz: 314a50f4b64054fe984e022421e464f22ae0a5469c9e7d369c5ef775ccdc0f14
3
+ metadata.gz: a5d6722be96ccba39d45fd0484e446158b826fe858eb33223ab31c15a8020db8
4
+ data.tar.gz: d0947ed101afc498ef96d1826c5560b93288b36719d88cab324c52205197405c
5
5
  SHA512:
6
- metadata.gz: 2e36dcd8e78e57bbec0a451f1ce8d98fe4a3ab83dbbce334c0b528f08b5a2cc3d050a3b5da1092dbbc808d3afb6fb48b4d8052030f7d3301ac557027c97072d4
7
- data.tar.gz: '00767969c5771dac29763dad5a65f4d1fd0c348ea127cfa36165dc46497000eb0db93ccff83aa13f5fc2c051d35ea618163ba1046e0abbb4c7cac315aac9f386'
6
+ metadata.gz: aaf3ba7f83299545ec1958713baa75a4af80a4945a30d4d13480b47a09a670b137519e6cb275f2a423407e0f849483092d3b6aec04bf4f7af52783e202e1be28
7
+ data.tar.gz: 6591ac01cfc11b881c9b2d9c8168564c511a3da8a32e52523da6ab8c0d050482172d85b2435e89a3ed010db09f443a27e3b1cf69a3d94b272cde10e11de51e85
data/lib/git-fit.rb CHANGED
@@ -79,4 +79,5 @@ require_relative "git_fit/cli/install_cli"
79
79
  require_relative "git_fit/cli/gh_cli"
80
80
  require_relative "git_fit/cli/export"
81
81
  require_relative "git_fit/cli/import_cli"
82
+ require_relative "git_fit/cli/geo_cli"
82
83
  require_relative "git_fit/cli"
@@ -0,0 +1,29 @@
1
+ require "thor"
2
+
3
+ module GitFit
4
+ class GeoCLI < Thor
5
+ desc "detect", "Detect administrative divisions for activities with GPS data"
6
+ option :limit, type: :numeric, aliases: "-l", desc: "Max activities to process"
7
+ option :batch, type: :numeric, aliases: "-b", desc: "Batch size", default: 20
8
+ option :strategy, type: :string, aliases: "-s", desc: "Strategy: proportional|start_end", default: "proportional"
9
+ option :time, type: :numeric, aliases: "-t", desc: "Time budget in seconds"
10
+ option :"dry-run", type: :boolean, desc: "Don't write to DB"
11
+ def detect
12
+ config = GitFit::Config.new
13
+ db = GitFit::DB::Connection.new(config.db_path).db
14
+ amap_key = ENV["AMAP_API_KEY"] || config.sync_config("amap")["api_key"]
15
+ detector = GitFit::Geo::DivisionDetector.new(
16
+ db: db,
17
+ amap_key: amap_key,
18
+ batch: options[:batch],
19
+ limit: options[:limit],
20
+ strategy: options[:strategy],
21
+ time_budget: options[:time],
22
+ dry_run: options[:"dry-run"],
23
+ )
24
+ detector.run
25
+ rescue => e
26
+ say_status :error, "Geo detection failed: #{e.message}", :red
27
+ end
28
+ end
29
+ end
data/lib/git_fit/cli.rb CHANGED
@@ -72,6 +72,9 @@ module GitFit
72
72
  desc "gh SUBCOMMAND", "Manage GitHub secrets and variables"
73
73
  subcommand "gh", GhCLI
74
74
 
75
+ desc "geo SUBCOMMAND", "Detect geographical administrative regions"
76
+ subcommand "geo", GeoCLI
77
+
75
78
  desc "install SUBCOMMAND", "Install project assets (actions)"
76
79
  subcommand "install", InstallCLI
77
80
 
@@ -0,0 +1,55 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module GitFit
6
+ module Geo
7
+ class AMapClient
8
+ BASE_URL = "https://restapi.amap.com/v3/geocode/regeo".freeze
9
+
10
+ def initialize(api_key:)
11
+ @api_key = api_key
12
+ @mutex = Mutex.new
13
+ @last_request = 0.0
14
+ @min_interval = 0.2
15
+ end
16
+
17
+ def reverse_geocode(lat, lng)
18
+ rate_limit
19
+ uri = URI(BASE_URL)
20
+ uri.query = URI.encode_www_form(
21
+ key: @api_key,
22
+ location: "#{lng},#{lat}",
23
+ output: "JSON",
24
+ radius: 1000,
25
+ extensions: "all",
26
+ )
27
+ resp = Net::HTTP.get_response(uri)
28
+ body = JSON.parse(resp.body)
29
+ if body["status"] != "1"
30
+ raise "AMap API error: #{body["info"]}"
31
+ end
32
+ parse_response(body)
33
+ end
34
+
35
+ private
36
+
37
+ def parse_response(body)
38
+ comp = body.dig("regeocode", "addressComponent") || {}
39
+ province = comp["province"].to_s.empty? ? nil : comp["province"]
40
+ city = comp["city"].to_s.empty? || comp["city"] == comp["province"] ? nil : comp["city"]
41
+ district = comp["district"].to_s.empty? ? nil : comp["district"]
42
+ township = comp["township"].to_s.empty? ? nil : comp["township"]
43
+ { province: province, city: city, district: district, township: township, source: "amap" }
44
+ end
45
+
46
+ def rate_limit
47
+ @mutex.synchronize do
48
+ elapsed = Time.now.to_f - @last_request
49
+ sleep(@min_interval - elapsed) if elapsed < @min_interval
50
+ @last_request = Time.now.to_f
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,245 @@
1
+ require "json"
2
+ require "fileutils"
3
+ require_relative "reverse_geocode"
4
+
5
+ module GitFit
6
+ module Geo
7
+ class DivisionDetector
8
+ KEEP_PLACEHOLDER = "gqqrFurkeU??".freeze
9
+ SAMPLE_INTERVAL_DEG = 0.01
10
+ CACHE_DIR = "data/cache"
11
+ POINT_CACHE_FILE = File.join(CACHE_DIR, "geo-point.jsonl")
12
+
13
+ def initialize(db:, amap_key: nil, batch: 20, limit: nil, strategy: "proportional", time_budget: nil, dry_run: false)
14
+ @db = db
15
+ @geocoder = ReverseGeocode.new(amap_key: amap_key)
16
+ @amap_key = amap_key
17
+ @batch = batch
18
+ @limit = limit
19
+ @strategy = strategy
20
+ @time_budget = time_budget
21
+ @start_time = Time.now
22
+ @dry_run = dry_run
23
+ @processed = 0
24
+ @skipped = 0
25
+ @errors = 0
26
+ @attempted = 0
27
+ @total = 0
28
+ FileUtils.mkdir_p(CACHE_DIR)
29
+ load_caches
30
+ end
31
+
32
+ def run
33
+ $stdout.sync = true
34
+ activities = pending_activities
35
+ @total = activities.size
36
+
37
+ if @total > 0 && @amap_key.nil?
38
+ $stdout.puts "ERROR: AMAP_API_KEY not configured."
39
+ $stdout.puts "Set via: ENV[\"AMAP_API_KEY\"] or config.yml sync.amap.api_key"
40
+ $stdout.puts "Run again after configuring the key to process #{@total} pending activities."
41
+ return
42
+ end
43
+
44
+ $stdout.puts "Geo detection: #{@total} activities to process"
45
+ activities.each_slice(@batch) do |batch|
46
+ break if budget_exhausted?
47
+ process_batch(batch)
48
+ break if @limit && @processed >= @limit
49
+ end
50
+ print_summary
51
+ end
52
+
53
+ private
54
+
55
+ def pending_activities
56
+ dataset = @db[:activities]
57
+ .where(Sequel.lit("summary_polyline IS NOT NULL"))
58
+ .where(Sequel.lit("summary_polyline != ''"))
59
+ .where(Sequel.lit("divisions IS NULL"))
60
+ .order(Sequel.desc(:start_date))
61
+ dataset = dataset.limit(@limit) if @limit
62
+ dataset.all
63
+ end
64
+
65
+ def process_batch(batch)
66
+ @db.transaction do
67
+ batch.each do |activity|
68
+ break if budget_exhausted? || (@limit && @processed >= @limit)
69
+ process_one(activity)
70
+ end
71
+ end
72
+ end
73
+
74
+ def process_one(activity)
75
+ run_id = activity[:run_id]
76
+ polyline = activity[:summary_polyline]
77
+
78
+ if polyline.nil? || polyline.empty? || polyline == KEEP_PLACEHOLDER
79
+ mark_empty_divisions(run_id) unless @dry_run
80
+ @skipped += 1
81
+ return
82
+ end
83
+
84
+ points = decode_points(polyline)
85
+ unless points && points.size >= 2
86
+ mark_empty_divisions(run_id) unless @dry_run
87
+ @skipped += 1
88
+ return
89
+ end
90
+
91
+ samples = sample_points(points)
92
+ if samples.empty?
93
+ mark_empty_divisions(run_id) unless @dry_run
94
+ @skipped += 1
95
+ return
96
+ end
97
+
98
+ @attempted += 1
99
+ prefix = "[#{@attempted}/#{@total}]"
100
+
101
+ divisions = resolve_divisions(samples)
102
+ if divisions.empty?
103
+ $stdout.puts " #{prefix} #{run_id} → skipped (no divisions resolved)"
104
+ @skipped += 1
105
+ return
106
+ end
107
+
108
+ summary = build_summary(divisions, samples.size)
109
+ update_db(run_id, summary) unless @dry_run
110
+ $stdout.puts " #{prefix} #{run_id} → #{format_divisions(divisions)} (#{samples.size} samples)"
111
+ @processed += 1
112
+ rescue => e
113
+ $stdout.puts " #{prefix} #{run_id} → error: #{e.message}"
114
+ @errors += 1
115
+ end
116
+
117
+ def format_divisions(divisions)
118
+ divisions.map { |d| [d[:province], d[:city], d[:district], d[:township]].compact.reject(&:empty?).join(" ") }.join(" / ")
119
+ end
120
+
121
+ def decode_points(polyline)
122
+ GitFit::Geo::Polyline.decode(polyline)
123
+ end
124
+
125
+ def sample_points(points)
126
+ samples = []
127
+ last_lat = nil
128
+ last_lng = nil
129
+ points.each do |lat, lng|
130
+ if last_lat.nil? ||
131
+ (lat - last_lat).abs >= SAMPLE_INTERVAL_DEG ||
132
+ (lng - last_lng).abs >= SAMPLE_INTERVAL_DEG
133
+ samples << [lat.round(4), lng.round(4)]
134
+ last_lat = lat
135
+ last_lng = lng
136
+ end
137
+ end
138
+ samples
139
+ end
140
+
141
+ def resolve_divisions(samples)
142
+ key_counts = Hash.new(0)
143
+ samples.each do |lat, lng|
144
+ coord_key = "#{lat},#{lng}"
145
+ raw = lookup_point_cache(coord_key)
146
+ unless raw
147
+ raw = @geocoder.lookup(lat, lng)
148
+ save_point_cache(coord_key, raw) if raw[:province]
149
+ end
150
+ result = normalize_entry(raw)
151
+ next unless result
152
+ key = division_key(result)
153
+ key_counts[key] += 1
154
+ end
155
+ key_counts.map do |key, count|
156
+ province, city, district, township = key.split("|")
157
+ { province: province, city: city, district: district, township: township, count: count }
158
+ end.sort_by { |r| -r[:count] }
159
+ end
160
+
161
+ def normalize_entry(raw)
162
+ return nil unless raw
163
+ province = raw[:province] || raw["p"] || raw["province"]
164
+ province = nil if province.is_a?(Array) || province.to_s.empty? || province == "中华人民共和国"
165
+ return nil unless province
166
+ {
167
+ province: province,
168
+ city: pick_field(raw, :city, "city", "c", "city") { |v| valid_name?(v) },
169
+ district: pick_field(raw, :district, "dist", "d"),
170
+ township: pick_field(raw, :township, "town", "t"),
171
+ }
172
+ end
173
+
174
+ def pick_field(raw, *keys)
175
+ keys.each do |k|
176
+ v = raw[k] || raw[k.to_s]
177
+ return v if block_given? ? yield(v) : v.is_a?(String) && !v.empty?
178
+ end
179
+ nil
180
+ end
181
+
182
+ def valid_name?(v)
183
+ v.is_a?(String) && !v.empty? && !v.match?(/^-?\d+\.\d+,-?\d+\.\d+$/)
184
+ end
185
+
186
+ def division_key(result)
187
+ [result[:province], result[:city], result[:district], result[:township]].join("|")
188
+ end
189
+
190
+ def build_summary(divisions, total_points)
191
+ total = total_points.to_f
192
+ division_list = divisions.map do |r|
193
+ h = { p: r[:province] }
194
+ h[:c] = r[:city] if r[:city]
195
+ h[:d] = r[:district] if r[:district]
196
+ h[:t] = r[:township] if r[:township]
197
+ h[:w] = (r[:count] / total).round(4)
198
+ h[:n] = r[:count]
199
+ h
200
+ end
201
+ { divisions: division_list, total_sampled: total_points, source: "amap", processed_at: Time.now.utc.iso8601 }
202
+ end
203
+
204
+ def lookup_point_cache(coord_key)
205
+ return nil unless File.exist?(POINT_CACHE_FILE)
206
+ File.open(POINT_CACHE_FILE, "r") do |f|
207
+ f.each_line do |line|
208
+ entry = JSON.parse(line) rescue next
209
+ return entry if entry["ck"] == coord_key
210
+ end
211
+ end
212
+ nil
213
+ end
214
+
215
+ def save_point_cache(coord_key, result)
216
+ entry = { ck: coord_key, p: result[:province], source: result[:source] }
217
+ entry[:city] = result[:city] if result[:city]
218
+ entry[:dist] = result[:district] if result[:district]
219
+ entry[:town] = result[:township] if result[:township]
220
+ File.open(POINT_CACHE_FILE, "a") { |f| f.puts(JSON.generate(entry)) }
221
+ end
222
+
223
+ def update_db(run_id, summary)
224
+ @db[:activities].where(run_id: run_id).update(divisions: JSON.generate(summary[:divisions]))
225
+ end
226
+
227
+ def mark_empty_divisions(run_id)
228
+ @db[:activities].where(run_id: run_id).update(divisions: "[]")
229
+ end
230
+
231
+ def load_caches
232
+ # Preload: nothing needed, read on-demand
233
+ end
234
+
235
+ def budget_exhausted?
236
+ return false unless @time_budget
237
+ Time.now - @start_time > @time_budget
238
+ end
239
+
240
+ def print_summary
241
+ $stdout.puts "Geo detection complete: #{@processed} processed, #{@skipped} skipped, #{@errors} errors"
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,57 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module GitFit
6
+ module Geo
7
+ class NominatimClient
8
+ BASE_URL = "https://nominatim.openstreetmap.org/reverse".freeze
9
+ USER_AGENT = "git-fit/1.0 (geo-detector)"
10
+
11
+ def initialize
12
+ @mutex = Mutex.new
13
+ @last_request = 0.0
14
+ @min_interval = 1.0
15
+ end
16
+
17
+ def reverse_geocode(lat, lng)
18
+ rate_limit
19
+ uri = URI(BASE_URL)
20
+ uri.query = URI.encode_www_form(
21
+ lat: lat,
22
+ lon: lng,
23
+ format: "jsonv2",
24
+ addressdetails: 1,
25
+ zoom: 10,
26
+ )
27
+ resp = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
28
+ req = Net::HTTP::Get.new(uri)
29
+ req["User-Agent"] = USER_AGENT
30
+ http.request(req)
31
+ end
32
+ body = JSON.parse(resp.body)
33
+ if body["error"]
34
+ raise "Nominatim error: #{body["error"]}"
35
+ end
36
+ parse_response(body)
37
+ end
38
+
39
+ private
40
+
41
+ def parse_response(body)
42
+ addr = body["address"] || {}
43
+ country = addr["country"]
44
+ state = addr["state"]
45
+ { province: country, city: state, district: nil, township: nil, source: "nominatim" }
46
+ end
47
+
48
+ def rate_limit
49
+ @mutex.synchronize do
50
+ elapsed = Time.now.to_f - @last_request
51
+ sleep(@min_interval - elapsed) if elapsed < @min_interval
52
+ @last_request = Time.now.to_f
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,48 @@
1
+ require_relative "coord_transform"
2
+ require_relative "amap_client"
3
+ require_relative "nominatim_client"
4
+
5
+ module GitFit
6
+ module Geo
7
+ class ReverseGeocode
8
+ CHINA_BBOX = {
9
+ min_lng: 73, max_lng: 135,
10
+ min_lat: 18, max_lat: 54,
11
+ }.freeze
12
+
13
+ def initialize(amap_key: nil)
14
+ @amap = amap_key ? AMapClient.new(api_key: amap_key) : nil
15
+ @nominatim = NominatimClient.new
16
+ end
17
+
18
+ def amap_available?
19
+ !@amap.nil?
20
+ end
21
+
22
+ def lookup(lat, lng)
23
+ in_china?(lat, lng) ? lookup_china(lat, lng) : lookup_intl(lat, lng)
24
+ end
25
+
26
+ private
27
+
28
+ def in_china?(lat, lng)
29
+ lat >= CHINA_BBOX[:min_lat] && lat <= CHINA_BBOX[:max_lat] &&
30
+ lng >= CHINA_BBOX[:min_lng] && lng <= CHINA_BBOX[:max_lng]
31
+ end
32
+
33
+ def lookup_china(lat, lng)
34
+ raise "AMap API key not configured" unless @amap
35
+ gcj_lat, gcj_lng = CoordTransform.wgs84_to_gcj02(lat, lng)
36
+ @amap.reverse_geocode(gcj_lat, gcj_lng)
37
+ rescue => e
38
+ { province: nil, city: nil, district: nil, township: nil, source: "error", error: e.message }
39
+ end
40
+
41
+ def lookup_intl(lat, lng)
42
+ @nominatim.reverse_geocode(lat, lng)
43
+ rescue => e
44
+ { province: nil, city: nil, district: nil, township: nil, source: "error", error: e.message }
45
+ end
46
+ end
47
+ end
48
+ end
data/lib/git_fit/geo.rb CHANGED
@@ -5,3 +5,7 @@ end
5
5
 
6
6
  require_relative "geo/polyline"
7
7
  require_relative "geo/coord_transform"
8
+ require_relative "geo/amap_client"
9
+ require_relative "geo/nominatim_client"
10
+ require_relative "geo/reverse_geocode"
11
+ require_relative "geo/division_detector"
@@ -1,3 +1,3 @@
1
1
  module GitFit
2
- VERSION = "0.7.1"
2
+ VERSION = "0.7.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git-fit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -190,6 +190,7 @@ files:
190
190
  - lib/git-fit.rb
191
191
  - lib/git_fit/cli.rb
192
192
  - lib/git_fit/cli/export.rb
193
+ - lib/git_fit/cli/geo_cli.rb
193
194
  - lib/git_fit/cli/gh_cli.rb
194
195
  - lib/git_fit/cli/import_cli.rb
195
196
  - lib/git_fit/cli/install_cli.rb
@@ -206,8 +207,12 @@ files:
206
207
  - lib/git_fit/fit.rb
207
208
  - lib/git_fit/fit/decoder.rb
208
209
  - lib/git_fit/geo.rb
210
+ - lib/git_fit/geo/amap_client.rb
209
211
  - lib/git_fit/geo/coord_transform.rb
212
+ - lib/git_fit/geo/division_detector.rb
213
+ - lib/git_fit/geo/nominatim_client.rb
210
214
  - lib/git_fit/geo/polyline.rb
215
+ - lib/git_fit/geo/reverse_geocode.rb
211
216
  - lib/git_fit/import.rb
212
217
  - lib/git_fit/import/apple_health.rb
213
218
  - lib/git_fit/import/local_file.rb