coordconver 0.1.4 → 1.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.
@@ -1,59 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # 经纬度(十进制度)与度分秒(DMS)互转。
4
+ #
5
+ # 经 Coordconver.extend(Degree) 挂载, 以下两种写法等价:
6
+ # Coordconver.degree(38.65777, 104.08296)
7
+ # Coordconver::Degree.degree(38.65777, 104.08296)
1
8
  module Coordconver
2
9
  module Degree
3
- def self.included(base)
4
- base.extend(ClassMethods)
5
- end
6
-
7
- module ClassMethods
8
-
9
- # 取整数位
10
- def num(n)
11
- n.to_i
12
- end
13
-
14
- # 取小数数位 Decimal digit
15
- def dig(n)
16
- n.to_f-num(n)
17
- end
18
-
19
- # 计算分
20
- def min(n)
21
- dig(n)*60
22
- end
23
-
24
- # 计算秒
25
- def sec(n)
26
- (min(n)-num(min(n)))*60
27
- end
10
+ extend self
28
11
 
29
- # 坐标转度数
30
- def degree(lng, lat)
31
- [cal_degree(lng),cal_degree(lat)]
32
- end
33
-
34
- def cal_degree(n)
35
- "#{num(n)}°#{num(min(n))}′#{num(sec(n))}″"
36
- end
37
-
38
-
39
- # 获取分
40
- def get_min(n)
41
- n.split('°').last.to_f
42
- end
12
+ # 坐标转度分秒, 返回 [经度, 纬度] 的 DMS 字符串。
13
+ # 负值按西经/南纬处理, 取绝对值后再格式化。
14
+ # Coordconver.degree(38.65777, 104.08296) # => ["38°39′27.97″", "104°4′58.66″"]
15
+ def degree(lng, lat)
16
+ [cal_degree(lng), cal_degree(lat)]
17
+ end
43
18
 
44
- # 获取秒
45
- def get_sec(n)
46
- n.split('′').last.to_f
19
+ # 单个十进制度数转度分秒字符串, 默认保留两位小数秒。
20
+ # Coordconver.cal_degree(38.65777) # => "38°39′27.97″"
21
+ # Coordconver.cal_degree(38.65777, 0) # => "38°39′27″"
22
+ def cal_degree(n, precision = 2)
23
+ sign = n.negative? ? "-" : ""
24
+ n = n.abs
25
+ d = n.to_i
26
+ m = (n - d) * 60
27
+ s = (m - m.to_i) * 60
28
+ if precision.zero?
29
+ "#{sign}#{d}°#{m.to_i}′#{s.round}″"
30
+ else
31
+ "#{sign}#{d}°#{m.to_i}′#{s.round(precision)}″"
47
32
  end
33
+ end
48
34
 
49
- # 度数转坐标
50
- def cal_undegree(n)
51
- (num(n) + get_min(n)/60 + get_sec(n)/3600).round(5)
52
- end
35
+ # 度分秒转坐标, 返回 [经度, 纬度] 浮点值。
36
+ # 兼容 "38°39′27.97″"、"38°39′27.97""、"38d39m27.97s"、"38:39:27.97" 等写法。
37
+ # Coordconver.undegree("38°39′27.97″", "104°4′58.66″") # => [38.65777, 104.08296]
38
+ def undegree(lng, lat)
39
+ [cal_undegree(lng), cal_undegree(lat)]
40
+ end
53
41
 
54
- def undegree(lng, lat)
55
- [cal_undegree(lng),cal_undegree(lat)]
56
- end
42
+ # 单个度分秒字符串转十进制度数。
43
+ # Coordconver.cal_undegree("104°4′58.66″") # => 104.08296
44
+ # Coordconver.cal_undegree("-38°39′27.97″") # => -38.65777
45
+ def cal_undegree(str)
46
+ parts = str.to_s.scan(/[-+]?\d+(?:\.\d+)?/)
47
+ return 0.0 if parts.empty?
48
+
49
+ deg, min, sec = parts.map(&:to_f)
50
+ min ||= 0.0
51
+ sec ||= 0.0
52
+ value = deg.abs + min / 60.0 + sec / 3600.0
53
+ value = -value if str.to_s =~ /^\s*[-–]/ || deg.negative?
54
+ value.round(6)
57
55
  end
58
56
  end
59
57
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coordconver
4
+ # 统一错误基类, 便于业务侧 rescue Coordconver::Error
5
+ class Error < StandardError; end
6
+
7
+ # 缺少 API key 等配置问题时抛出
8
+ class ConfigurationError < Error; end
9
+
10
+ # 网络超时、连接失败、响应非 JSON 时抛出
11
+ class NetworkError < Error; end
12
+
13
+ # 服务端返回业务错误(如 key 无效、配额超限)时抛出
14
+ class ApiError < Error
15
+ attr_reader :code, :raw
16
+
17
+ def initialize(message = nil, code: nil, raw: nil)
18
+ super(message)
19
+ @code = code
20
+ @raw = raw
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ # WKT / GeoJSON 与坐标互转。
6
+ # 经 Coordconver.extend(Formats) 挂载:
7
+ # Coordconver.parse_wkt("POINT (121.47 31.25)") # => [121.47, 31.25]
8
+ # Coordconver.to_wkt(121.47, 31.25) # => "POINT (121.47 31.25)"
9
+ # Coordconver.to_geojson(121.47, 31.25) # => {"type"=>"Point", "coordinates"=>[121.47, 31.25]}
10
+ module Coordconver
11
+ module Formats
12
+ extend self
13
+
14
+ # 解析 WKT 点: "POINT (121.47 31.25)" / "POINT(121.47 31.25)" -> [lng, lat]
15
+ def parse_wkt(wkt)
16
+ match = wkt.to_s.match(/point\s*z?m?\s*\(\s*([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)/i)
17
+ raise ArgumentError, "无法解析 WKT: #{wkt.inspect} (仅支持 POINT)" unless match
18
+
19
+ [match[1].to_f, match[2].to_f]
20
+ end
21
+
22
+ # 生成 WKT 点
23
+ def to_wkt(lng, lat)
24
+ "POINT (#{lng} #{lat})"
25
+ end
26
+
27
+ # 解析 GeoJSON: 接受 Hash 或 JSON 字符串。
28
+ # 支持 Point 与包着 Point 的 Feature。
29
+ # 返回 [lng, lat]
30
+ def parse_geojson(input)
31
+ obj = input.is_a?(Hash) ? input : JSON.parse(input.to_s)
32
+ obj = obj["geometry"] if obj["type"] == "Feature"
33
+ raise ArgumentError, "仅支持 GeoJSON Point, 实际: #{obj['type'].inspect}" unless obj["type"] == "Point"
34
+
35
+ coords = obj["coordinates"]
36
+ raise ArgumentError, "GeoJSON Point 缺少 coordinates" unless coords.is_a?(Array) && coords.size >= 2
37
+
38
+ [coords[0].to_f, coords[1].to_f]
39
+ end
40
+
41
+ # 生成 GeoJSON Point Hash (GeoJSON 规范中坐标为 [lng, lat] 顺序)
42
+ def to_geojson(lng, lat)
43
+ { "type" => "Point", "coordinates" => [lng, lat] }
44
+ end
45
+
46
+ # 生成 GeoJSON Point JSON 字符串
47
+ def to_geojson_string(lng, lat)
48
+ JSON.generate(to_geojson(lng, lat))
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coordconver
4
+ module Geo
5
+ # 百度/高德/腾讯 API 的统一调度层:
6
+ # 组装 key -> LRU 缓存 -> 请求 -> 识别 key 类失败 -> 轮换下一个 key 重试。
7
+ module Api
8
+ # provider: :baidu / :gaode / :tencent
9
+ # params: 业务参数(不含 key)
10
+ # key_param: key 参数名 (:ak / :key)
11
+ # sign: 可选 Proc, 对含 key 的完整参数做签名(百度 SN 校验)
12
+ # bad_result: Proc, 判断响应是否属于"key 无效/配额超限"类失败(应轮换 key)
13
+ def self.call(provider:, url:, params:, key_param:, sign: nil, bad_result:)
14
+ keys = Configuration.keys_for(provider)
15
+ attempts = 0
16
+ loop do
17
+ key = KeyPool.next_key(provider, keys)
18
+ full_params = params.merge(key_param => key)
19
+ full_params = sign.call(full_params) if sign
20
+ cache_key = [provider, url, full_params]
21
+
22
+ hit = Cache.get(cache_key)
23
+ if hit
24
+ return hit unless bad_result.call(hit)
25
+
26
+ Cache.delete(cache_key) # 防御: 不应缓存过失败响应
27
+ next
28
+ end
29
+
30
+ response = Utils::Request.get(url, params: full_params)
31
+ return Cache.set(cache_key, response) unless bad_result.call(response)
32
+
33
+ KeyPool.mark_bad(provider, key)
34
+ attempts += 1
35
+ if attempts >= keys.size
36
+ raise ApiError.new(
37
+ "#{provider} 请求失败: 所有 #{keys.size} 个 key 均无效或配额超限",
38
+ code: provider, raw: response
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,45 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
1
5
  module Coordconver
2
6
  module Geo
7
+ # 百度地图 Web 服务 API (Geocoding API v3)
8
+ # 申请 key: https://lbsyun.baidu.com/apiconsole/key
9
+ # 建议创建"服务端"类型 AK 并配置 IP 白名单; 若 AK 类型为"SN 校验", 需同时配置 baidu_sn
3
10
  module Baidu
4
11
  extend self
5
12
 
6
- # Coordconver::Baidu.regeo(39.983424,116.322987)
7
- def regeo(lng, lat)
8
- url = regeo_url(lng, lat)
9
- Utils::Request.get(url, params: nil)
10
- end
11
-
12
-
13
- def geo(address)
14
- url = base_url
13
+ # 视为"key 无效/配额超限"的状态码, 触发自动轮换下一个 key
14
+ # 200 AK不存在 201 AK被禁用 210 AK参数错误 401 配额超限 402 每秒请求量超限
15
+ KEY_ERROR_STATUSES = [200, 201, 210, 401, 402].freeze
15
16
 
17
+ # 地理编码: 结构化地址 -> 坐标
18
+ # Coordconver::Baidu.geo("百度大厦")["result"]["location"] # => {"lng"=>116.30..., "lat"=>40.05...}
19
+ # 支持可选参数: city(城市名), ret_coordtype(返回坐标类型, 默认 bd09ll)
20
+ def geo(address, options = {})
16
21
  params = {
17
- ak: baidu_map_ak,
18
- output: 'json',
19
- address: address
20
- }
22
+ address: address,
23
+ output: "json"
24
+ }.merge(options)
25
+ request("/geocoding/v3/", params)
26
+ end
21
27
 
22
- Utils::Request.get(url, params: params)
28
+ # 逆地理编码: 坐标 -> 结构化地址
29
+ # Coordconver::Baidu.regeo(116.322987, 39.983424)["result"]["formatted_address"]
30
+ # options: coordtype(输入坐标类型, 默认 bd09ll, 可选 wgs84ll / gcj02ll 等)
31
+ def regeo(lng, lat, options = {})
32
+ params = {
33
+ location: "#{lat},#{lng}",
34
+ output: "json",
35
+ coordtype: "bd09ll"
36
+ }.merge(options)
37
+ request("/reverse_geocoding/v3/", params)
23
38
  end
24
39
 
25
- private
40
+ # IP 定位: IP -> 城市 + 坐标 (BD-09)
41
+ def ip(ip = nil)
42
+ params = { coor: "bd09ll", output: "json" }
43
+ params[:ip] = ip unless ip.nil? || ip.empty?
44
+ request("/location/ip", params)
45
+ end
26
46
 
27
- def regeo_url(lng, lat)
28
- base_url + "?location=#{lat},#{lng}&output=json&ak=#{baidu_map_ak}"
47
+ # 便捷方法: 地址 -> [lng, lat] (BD-09), 解析失败返回 nil
48
+ def geo_location(address, options = {})
49
+ loc = geo(address, options).dig("result", "location")
50
+ loc && [loc["lng"], loc["lat"]]
29
51
  end
30
52
 
31
- # http://api.map.baidu.com/geocoder/v2/?location=39.983424,116.322987&output=json&ak=uzInpBxCpE0DiPnln3UbDv4OQTdOgmXu
53
+ # 便捷方法: 坐标 -> 地址字符串 (BD-09), 解析失败返回 nil
54
+ def regeo_address(lng, lat, options = {})
55
+ regeo(lng, lat, options).dig("result", "formatted_address")
56
+ end
32
57
 
33
- # http://api.map.baidu.com/geocoder/v2?location=39,116.480881&output=json&ak=uzInpBxCpE0DiPnln3UbDv4OQTdOgmXu
58
+ private
34
59
 
35
- # http://api.map.baidu.com/geocoder/v2/?output=json&address=百度大厦&city=北京市&ak=您的ak
60
+ def request(path, params)
61
+ sn_key = Configuration.settings[:baidu_sn]
62
+ Api.call(
63
+ provider: :baidu,
64
+ url: base_url + path,
65
+ params: params,
66
+ key_param: :ak,
67
+ sign: sn_key ? ->(p) { p.merge(sn: sn(path, p, sn_key)) } : nil,
68
+ bad_result: ->(r) { KEY_ERROR_STATUSES.include?(r["status"].to_i) }
69
+ )
70
+ end
36
71
 
37
72
  def base_url
38
- Configuration.settings[:baidu_base_url]
73
+ Configuration.settings[:baidu_base_url].sub(%r{/\z}, "")
74
+ end
75
+
76
+ # SN 校验: sn = md5(urlencode(path + "?" + 按参数名字典序拼接的 query + sk))
77
+ def sn(path, params, sk)
78
+ query = params.sort.map { |k, v| "#{k}=#{v}" }.join("&")
79
+ Digest::MD5.hexdigest(custom_uri_encode("#{path}?#{query}") + sk)
39
80
  end
40
81
 
41
- def baidu_map_ak
42
- Configuration.settings[:baidu_map_aks].sample
82
+ # 百度 SN 校验要求的 URI 编码: 仅 A-Za-z0-9-_.~ 不编码, 其余按字节转 %XX 大写
83
+ def custom_uri_encode(str)
84
+ str.gsub(/[^A-Za-z0-9\-_.~]/) do |char|
85
+ char.bytes.map { |b| format("%%%02X", b) }.join
86
+ end
43
87
  end
44
88
  end
45
89
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coordconver
4
+ module Geo
5
+ # 进程内 LRU + TTL 缓存, 用于地理编码结果去重以节省配额。
6
+ # 默认关闭, 通过 config.cache = true 开启; 可配置 cache_ttl / cache_size。
7
+ module Cache
8
+ DEFAULT_TTL = 3600
9
+ DEFAULT_SIZE = 1024
10
+
11
+ @store = {}
12
+ @mutex = Mutex.new
13
+
14
+ class << self
15
+ def enabled?
16
+ Configuration.configured? && Configuration.settings[:cache] ? true : false
17
+ end
18
+
19
+ # 命中且未过期返回值, 否则返回 nil
20
+ def get(key)
21
+ return nil unless enabled?
22
+
23
+ @mutex.synchronize do
24
+ entry = @store[key]
25
+ return nil unless entry
26
+ return nil if Time.now - entry[:at] > ttl
27
+
28
+ @store.delete(key)
29
+ @store[key] = entry # LRU: 重新插入到队尾
30
+ entry[:value]
31
+ end
32
+ end
33
+
34
+ # 存入并返回 value; 未启用时原样返回 value
35
+ def set(key, value)
36
+ return value unless enabled?
37
+
38
+ @mutex.synchronize do
39
+ @store.delete(key)
40
+ @store[key] = { at: Time.now, value: value }
41
+ @store.shift while @store.size > size
42
+ value
43
+ end
44
+ end
45
+
46
+ def delete(key)
47
+ @mutex.synchronize { @store.delete(key) }
48
+ end
49
+
50
+ def clear!
51
+ @mutex.synchronize { @store.clear }
52
+ end
53
+
54
+ private
55
+
56
+ def ttl
57
+ Configuration.settings[:cache_ttl] || DEFAULT_TTL
58
+ end
59
+
60
+ def size
61
+ Configuration.settings[:cache_size] || DEFAULT_SIZE
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,12 +1,25 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Coordconver
2
4
  module Geo
3
-
5
+ # 初始化配置。保留旧写法, 也可用 Coordconver.configure 块:
6
+ # Coordconver::Client.new(["百度ak1", "百度ak2"], ["高德key1"])
4
7
  class Client
5
- def initialize(baidu_map_aks, gaode_map_aks)
6
- Configuration.init(baidu_map_aks, gaode_map_aks)
8
+ def initialize(baidu_map_aks = [], gaode_map_keys = [], options = {})
9
+ Configuration.init(baidu_map_aks, gaode_map_keys, options)
7
10
  end
8
11
  end
9
12
  end
10
- end
11
13
 
12
- # Coordconver::Geo::Client.new
14
+ module_function
15
+
16
+ # 全局配置块:
17
+ # Coordconver.configure do |config|
18
+ # config.baidu_map_aks = ["ak1"]
19
+ # config.gaode_map_keys = ["key1"]
20
+ # end
21
+ def configure
22
+ Geo::Configuration.init unless Geo::Configuration.configured?
23
+ yield Geo::Configuration
24
+ end
25
+ end
@@ -1,22 +1,112 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Coordconver
2
4
  module Geo
5
+ # 全局配置。支持两种写法:
6
+ #
7
+ # # 推荐: 全局配置块, key 支持数组以实现多 key 轮询
8
+ # Coordconver.configure do |config|
9
+ # config.baidu_map_aks = ["你的百度ak1", "你的百度ak2"]
10
+ # config.gaode_map_keys = ["你的高德key1", "你的高德key2"]
11
+ # config.tencent_map_keys = ["你的腾讯key1"]
12
+ # # 可选: 百度 SN 校验(仅当 AK 类型为"SN 校验"时需要), 服务端 AK 用 IP 白名单则无需配置
13
+ # config.baidu_sn = "你的百度SK"
14
+ # # 可选: 进程内缓存地理编码结果, 节省配额 (默认 false)
15
+ # config.cache = true
16
+ # config.cache_ttl = 3600 # 缓存秒数, 默认 3600
17
+ # config.cache_size = 1024 # 缓存条目上限, 默认 1024 (LRU 淘汰)
18
+ # # 可选: 超时(秒)
19
+ # config.open_timeout = 5
20
+ # config.read_timeout = 10
21
+ # end
22
+ #
23
+ # # 兼容旧写法
24
+ # Coordconver::Client.new(["百度ak"], ["高德key"], tencent_map_keys: ["腾讯key"])
3
25
  module Configuration
4
26
  extend self
5
27
 
6
28
  DEFAULT_MAP_OPTIONS = {
7
- gaode_base_url: 'http://restapi.amap.com/v3/geocode',
8
- baidu_base_url: 'http://api.map.baidu.com/geocoder/v2/',
9
- output: 'json'
10
- }
29
+ baidu_base_url: "https://api.map.baidu.com",
30
+ gaode_base_url: "https://restapi.amap.com/v3",
31
+ tencent_base_url: "https://apis.map.qq.com/ws",
32
+ open_timeout: 5,
33
+ read_timeout: 10,
34
+ cache: false,
35
+ cache_ttl: 3600,
36
+ cache_size: 1024
37
+ }.freeze
38
+
39
+ PROVIDER_KEY_FIELD = {
40
+ baidu: :baidu_map_aks,
41
+ gaode: :gaode_map_keys,
42
+ tencent: :tencent_map_keys
43
+ }.freeze
11
44
 
12
45
  attr_reader :settings
13
46
 
14
- def init(baidu_map_aks, gaode_map_keys)
15
- @settings = DEFAULT_MAP_OPTIONS.merge!(baidu_map_aks: baidu_map_aks, gaode_map_keys: gaode_map_keys)
47
+ def configured?
48
+ !@settings.nil?
49
+ end
50
+
51
+ def init(baidu_map_aks = nil, gaode_map_keys = nil, options = {})
52
+ tencent_keys = Array(options.delete(:tencent_map_keys))
53
+ @settings = DEFAULT_MAP_OPTIONS.merge(
54
+ baidu_map_aks: Array(baidu_map_aks).compact.reject(&:empty?),
55
+ gaode_map_keys: Array(gaode_map_keys).compact.reject(&:empty?),
56
+ tencent_map_keys: tencent_keys.compact.reject(&:empty?)
57
+ ).merge(options)
58
+ self
16
59
  end
17
- end
18
- end
19
- end
20
60
 
61
+ def reset!
62
+ @settings = nil
63
+ KeyPool.reset!
64
+ Cache.clear!
65
+ end
66
+
67
+ def [](key)
68
+ settings[key]
69
+ end
70
+
71
+ # 按 provider (:baidu/:gaode/:tencent) 取可用 key 数组, 未配置时抛出清晰错误
72
+ def keys_for(provider)
73
+ field = PROVIDER_KEY_FIELD[provider]
74
+ raise ArgumentError, "未知的服务商: #{provider.inspect}" unless field
21
75
 
76
+ keys = settings[field]
77
+ raise ConfigurationError, "未配置 #{provider} 的 key (#{field}), 请通过 Coordconver.configure 或 Coordconver::Client.new 设置" if keys.empty?
22
78
 
79
+ keys
80
+ end
81
+
82
+ def method_missing(name, *args)
83
+ if name.to_s.end_with?("=") && settings.key?(name.to_s.chomp("=").to_sym)
84
+ settings[name.to_s.chomp("=").to_sym] = args.first
85
+ else
86
+ super
87
+ end
88
+ end
89
+
90
+ def respond_to_missing?(name, include_private = false)
91
+ (name.to_s.end_with?("=") && settings.key?(name.to_s.chomp("=").to_sym)) || super
92
+ end
93
+
94
+ def settings
95
+ @settings or raise ConfigurationError, "尚未配置地图 API key。请先执行 Coordconver.configure { |c| c.baidu_map_aks = [...]; c.gaode_map_keys = [...] } 或 Coordconver::Client.new([百度ak数组], [高德key数组])"
96
+ end
97
+
98
+ # 兼容旧接口: 取一个 key (现为实现为轮询)
99
+ def sample_baidu_ak
100
+ KeyPool.next_key(:baidu, keys_for(:baidu))
101
+ end
102
+
103
+ def sample_gaode_key
104
+ KeyPool.next_key(:gaode, keys_for(:gaode))
105
+ end
106
+
107
+ def sample_tencent_key
108
+ KeyPool.next_key(:tencent, keys_for(:tencent))
109
+ end
110
+ end
111
+ end
112
+ end