zip-codes-pl 0.1.1
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 +7 -0
- data/LICENSE.txt +27 -0
- data/README.md +204 -0
- data/README.pl.md +179 -0
- data/data/zip-codes-pl.manifest.json +10 -0
- data/data/zip-codes-pl.tsv +72900 -0
- data/lib/zip-codes-pl.rb +3 -0
- data/lib/zip_codes/pl/builder.rb +113 -0
- data/lib/zip_codes/pl/city.rb +66 -0
- data/lib/zip_codes/pl/configuration.rb +46 -0
- data/lib/zip_codes/pl/errors.rb +11 -0
- data/lib/zip_codes/pl/manifest.rb +39 -0
- data/lib/zip_codes/pl/normalize.rb +30 -0
- data/lib/zip_codes/pl/railtie.rb +11 -0
- data/lib/zip_codes/pl/reader.rb +116 -0
- data/lib/zip_codes/pl/record.rb +50 -0
- data/lib/zip_codes/pl/sources/geonames.rb +127 -0
- data/lib/zip_codes/pl/sources/poczta_polska/client.rb +91 -0
- data/lib/zip_codes/pl/sources/poczta_polska.rb +115 -0
- data/lib/zip_codes/pl/tasks/zip_codes_pl.rake +64 -0
- data/lib/zip_codes/pl/version.rb +7 -0
- data/lib/zip_codes/pl/voivodeship.rb +48 -0
- data/lib/zip_codes/pl.rb +61 -0
- metadata +86 -0
data/lib/zip-codes-pl.rb
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module ZipCodes
|
|
7
|
+
module PL
|
|
8
|
+
# Downloads the source and writes the dataset into the configured directory.
|
|
9
|
+
class Builder
|
|
10
|
+
Result = Data.define(:status, :data_path, :manifest) do
|
|
11
|
+
def built? = status == :built
|
|
12
|
+
def up_to_date? = status == :up_to_date
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# `administrative_names_source: false` builds with the raw upstream labels.
|
|
16
|
+
# The two sources default independently: tying the names to whether a custom
|
|
17
|
+
# row source was injected silently dropped the enrichment, and the manifest
|
|
18
|
+
# attribution with it.
|
|
19
|
+
def initialize(config: ZipCodes::PL.config, source: nil, administrative_names_source: nil)
|
|
20
|
+
@config = config
|
|
21
|
+
@source = source || Sources::Geonames.new(config: config)
|
|
22
|
+
@administrative_names_source =
|
|
23
|
+
case administrative_names_source
|
|
24
|
+
when nil then Sources::PocztaPolska.new(config: config)
|
|
25
|
+
when false then nil
|
|
26
|
+
else administrative_names_source
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call
|
|
31
|
+
previous = Manifest.read(config.manifest_path)
|
|
32
|
+
download = source.download(etag: reusable_etag(previous))
|
|
33
|
+
return Result.new(status: :up_to_date, data_path: config.data_path, manifest: previous) if download.nil?
|
|
34
|
+
|
|
35
|
+
names = administrative_names_source&.fetch
|
|
36
|
+
write(download, names)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :administrative_names_source, :config, :source
|
|
42
|
+
|
|
43
|
+
# Only claim a cached copy when the data file it describes is still there;
|
|
44
|
+
# a 304 with no file on disk would leave the caller with nothing.
|
|
45
|
+
def reusable_etag(previous)
|
|
46
|
+
return nil if previous.nil? || !File.exist?(config.data_path)
|
|
47
|
+
|
|
48
|
+
previous.etag
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def write(download, names)
|
|
52
|
+
FileUtils.mkdir_p(config.output_dir)
|
|
53
|
+
row_count = write_data(download, names)
|
|
54
|
+
manifest = build_manifest(download, row_count, names).write(config.manifest_path)
|
|
55
|
+
|
|
56
|
+
Result.new(status: :built, data_path: config.data_path, manifest: manifest)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Written to a temporary file and renamed, so an interrupted run never
|
|
60
|
+
# leaves a half-written dataset where a complete one used to be.
|
|
61
|
+
def write_data(download, names)
|
|
62
|
+
temporary_path = "#{config.data_path}.tmp"
|
|
63
|
+
row_count = 0
|
|
64
|
+
|
|
65
|
+
File.open(temporary_path, "w") do |file|
|
|
66
|
+
file.puts(Record::COLUMNS.join("\t"))
|
|
67
|
+
source.each_record(download.body) do |record|
|
|
68
|
+
record = apply_administrative_names(record, names) if names
|
|
69
|
+
file.puts(record.to_row.join("\t"))
|
|
70
|
+
row_count += 1
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
File.rename(temporary_path, config.data_path)
|
|
75
|
+
row_count
|
|
76
|
+
ensure
|
|
77
|
+
FileUtils.rm_f(temporary_path) if temporary_path && File.exist?(temporary_path)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def apply_administrative_names(record, names)
|
|
81
|
+
county = name_for!(names.counties, record.county_teryt, "county")
|
|
82
|
+
commune = name_for!(names.communes, record.commune_teryt, "commune")
|
|
83
|
+
county = "powiat #{county}" if county && record.county_teryt[2, 2].to_i < 60
|
|
84
|
+
|
|
85
|
+
Record.new(**record.to_h, county: county, commune: commune)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def name_for!(names, code, level)
|
|
89
|
+
return nil if code.nil?
|
|
90
|
+
|
|
91
|
+
names.fetch(code) do
|
|
92
|
+
raise DownloadError, "no #{level} name for TERYT code #{code} in the Poczta Polska response"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def build_manifest(download, row_count, names)
|
|
97
|
+
attribution = Sources::Geonames::ATTRIBUTION
|
|
98
|
+
attribution = "#{attribution}; #{Sources::PocztaPolska::ATTRIBUTION}" if names
|
|
99
|
+
|
|
100
|
+
Manifest.new(
|
|
101
|
+
source_url: config.source_url,
|
|
102
|
+
attribution: attribution,
|
|
103
|
+
etag: download.etag,
|
|
104
|
+
last_modified: download.last_modified,
|
|
105
|
+
row_count: row_count,
|
|
106
|
+
built_at: Time.now.utc.iso8601,
|
|
107
|
+
gem_version: VERSION,
|
|
108
|
+
format_version: Manifest::FORMAT_VERSION
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZipCodes
|
|
4
|
+
module PL
|
|
5
|
+
# A place rather than a postal code: what a "cities" table wants.
|
|
6
|
+
class City < Data.define(
|
|
7
|
+
:name, :voivodeship, :voivodeship_teryt, :commune, :commune_teryt,
|
|
8
|
+
:latitude, :longitude, :postal_codes
|
|
9
|
+
)
|
|
10
|
+
class << self
|
|
11
|
+
# Keyed by commune as well as name, because a voivodeship can hold dozens
|
|
12
|
+
# of distinct villages sharing one - mazowieckie has 28 called "Nowa Wieś".
|
|
13
|
+
# Collapsing them by name alone puts the averaged point in a field, up to
|
|
14
|
+
# 158 km from the farthest place it claims to be.
|
|
15
|
+
def group_key(record)
|
|
16
|
+
[record.city, record.voivodeship_teryt, record.commune_teryt]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def sort_key(city)
|
|
20
|
+
[Normalize.key(city.name), city.voivodeship_teryt, city.commune_teryt.to_s]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Holds running sums rather than the rows behind them, so building every
|
|
25
|
+
# place in the country does not mean holding every postal code first. The
|
|
26
|
+
# coordinate ends up the mean of the group's rows, because the source gives
|
|
27
|
+
# one point per postal code and Bydgoszcz has 679 of them.
|
|
28
|
+
class Accumulator
|
|
29
|
+
def initialize(record)
|
|
30
|
+
# Copies the identity fields instead of holding the row, so the rest of
|
|
31
|
+
# each Record can be collected while the country is being aggregated.
|
|
32
|
+
@name = record.city
|
|
33
|
+
@voivodeship = record.voivodeship
|
|
34
|
+
@voivodeship_teryt = record.voivodeship_teryt
|
|
35
|
+
@commune = record.commune
|
|
36
|
+
@commune_teryt = record.commune_teryt
|
|
37
|
+
@latitude = 0.0
|
|
38
|
+
@longitude = 0.0
|
|
39
|
+
@count = 0
|
|
40
|
+
@postal_codes = {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def add(record)
|
|
44
|
+
@latitude += record.latitude
|
|
45
|
+
@longitude += record.longitude
|
|
46
|
+
@count += 1
|
|
47
|
+
@postal_codes[record.postal_code] = true
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_city
|
|
52
|
+
City.new(
|
|
53
|
+
name: @name,
|
|
54
|
+
voivodeship: @voivodeship,
|
|
55
|
+
voivodeship_teryt: @voivodeship_teryt,
|
|
56
|
+
commune: @commune,
|
|
57
|
+
commune_teryt: @commune_teryt,
|
|
58
|
+
latitude: (@latitude / @count).round(6),
|
|
59
|
+
longitude: (@longitude / @count).round(6),
|
|
60
|
+
postal_codes: @postal_codes.keys.sort.freeze
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZipCodes
|
|
4
|
+
module PL
|
|
5
|
+
class Configuration
|
|
6
|
+
DEFAULT_OUTPUT_DIR = "data"
|
|
7
|
+
DATA_FILENAME = "zip-codes-pl.tsv"
|
|
8
|
+
MANIFEST_FILENAME = "zip-codes-pl.manifest.json"
|
|
9
|
+
BUNDLED_DIR = File.expand_path("../../../data", __dir__)
|
|
10
|
+
|
|
11
|
+
attr_accessor :output_dir, :source_url, :user_agent, :open_timeout, :read_timeout, :poczta_request_interval
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@output_dir = ENV.fetch("ZIP_CODES_PL_DIR", DEFAULT_OUTPUT_DIR)
|
|
15
|
+
@source_url = Sources::Geonames::URL
|
|
16
|
+
@user_agent = "zip-codes-pl/#{VERSION} (+https://github.com/carlos-1410/zip-codes-pl)"
|
|
17
|
+
@open_timeout = 10
|
|
18
|
+
@read_timeout = 60
|
|
19
|
+
@poczta_request_interval = Sources::PocztaPolska::DEFAULT_REQUEST_INTERVAL
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Where a refresh writes.
|
|
23
|
+
def data_path
|
|
24
|
+
File.join(output_dir, DATA_FILENAME)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def manifest_path
|
|
28
|
+
File.join(output_dir, MANIFEST_FILENAME)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Where a read looks. Your own refreshed copy wins; without one the dataset
|
|
32
|
+
# shipped inside the gem answers, so nothing has to be built before first use.
|
|
33
|
+
def readable_data_path
|
|
34
|
+
File.exist?(data_path) ? data_path : bundled_data_path
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def readable_manifest_path
|
|
38
|
+
File.exist?(data_path) ? manifest_path : File.join(BUNDLED_DIR, MANIFEST_FILENAME)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def bundled_data_path
|
|
42
|
+
File.join(BUNDLED_DIR, DATA_FILENAME)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module ZipCodes
|
|
7
|
+
module PL
|
|
8
|
+
# Sits next to the dataset and records where it came from. The etag is what
|
|
9
|
+
# lets a later run ask the server "changed?" instead of downloading again.
|
|
10
|
+
class Manifest < Data.define(
|
|
11
|
+
:source_url, :attribution, :etag, :last_modified,
|
|
12
|
+
:row_count, :built_at, :gem_version, :format_version
|
|
13
|
+
)
|
|
14
|
+
FORMAT_VERSION = 1
|
|
15
|
+
|
|
16
|
+
def self.read(path)
|
|
17
|
+
return nil unless File.exist?(path)
|
|
18
|
+
|
|
19
|
+
payload = JSON.parse(File.read(path), symbolize_names: true)
|
|
20
|
+
return nil unless payload[:format_version] == FORMAT_VERSION
|
|
21
|
+
|
|
22
|
+
new(**payload.slice(*members))
|
|
23
|
+
rescue JSON::ParserError, ArgumentError
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write(path)
|
|
28
|
+
File.write(path, "#{JSON.pretty_generate(to_h)}\n")
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def built_at_time
|
|
33
|
+
Time.iso8601(built_at)
|
|
34
|
+
rescue ArgumentError, TypeError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZipCodes
|
|
4
|
+
module PL
|
|
5
|
+
# Folding used for lookups only. Stored names keep their diacritics; this is
|
|
6
|
+
# what makes "zlotow", "Złotów" and "ZŁOTÓW" find the same rows.
|
|
7
|
+
module Normalize
|
|
8
|
+
DIACRITICS = {
|
|
9
|
+
"ą" => "a", "ć" => "c", "ę" => "e", "ł" => "l", "ń" => "n",
|
|
10
|
+
"ó" => "o", "ś" => "s", "ź" => "z", "ż" => "z"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
POSTAL_CODE_PATTERN = /\A(\d{2})-?(\d{3})\z/
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def key(value)
|
|
18
|
+
value.to_s.strip.downcase.gsub(/[ąćęłńóśźż]/, DIACRITICS)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Accepts "86-010" and "86010"; returns nil for anything that is not a PNA.
|
|
22
|
+
def postal_code(value)
|
|
23
|
+
match = POSTAL_CODE_PATTERN.match(value.to_s.strip)
|
|
24
|
+
return nil if match.nil?
|
|
25
|
+
|
|
26
|
+
"#{match[1]}-#{match[2]}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZipCodes
|
|
4
|
+
module PL
|
|
5
|
+
# Reads the dataset by streaming the file, holding nothing between calls.
|
|
6
|
+
#
|
|
7
|
+
# One scan of 72 899 rows costs about 15 ms and 1.6 MB, against 222 ms and
|
|
8
|
+
# 99 MB to hold the same rows in indexed memory. The break-even is around
|
|
9
|
+
# fifteen lookups and the ordinary use of this gem is three, so there is no
|
|
10
|
+
# in-memory mode: past that point the rows belong in the caller's own database,
|
|
11
|
+
# and `cities` is the shape to import.
|
|
12
|
+
class Reader
|
|
13
|
+
HEADER = Record::COLUMNS.map(&:to_s).freeze
|
|
14
|
+
MIN_CITY_FRAGMENT_LENGTH = 3
|
|
15
|
+
|
|
16
|
+
attr_reader :path
|
|
17
|
+
|
|
18
|
+
def initialize(path)
|
|
19
|
+
@path = path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def each(&block)
|
|
23
|
+
return enum_for(:each) unless block
|
|
24
|
+
|
|
25
|
+
open_data do |file|
|
|
26
|
+
file.each_line { |line| block.call(Record.from_row(line.chomp.split("\t", -1))) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
include Enumerable
|
|
31
|
+
|
|
32
|
+
def find_by_postal_code(code)
|
|
33
|
+
normalized = Normalize.postal_code(code)
|
|
34
|
+
return [] if normalized.nil?
|
|
35
|
+
|
|
36
|
+
prefix = "#{normalized}\t"
|
|
37
|
+
scan { |line| line.start_with?(prefix) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def find_by_city(name, voivodeship: nil)
|
|
41
|
+
wanted_name = Normalize.key(name)
|
|
42
|
+
wanted_teryt = resolve_voivodeship(voivodeship)
|
|
43
|
+
# Asked to narrow to a voivodeship that does not exist: nothing matches.
|
|
44
|
+
# Treating it as "no filter" would answer with every place of that name.
|
|
45
|
+
return [] if wanted_teryt == :unknown
|
|
46
|
+
|
|
47
|
+
scan do |line|
|
|
48
|
+
fields = line.split("\t", 5)
|
|
49
|
+
# Folding every name costs more than reading the file, and the folding
|
|
50
|
+
# used here is length preserving (ł->l, ó->o, NFD plus mark stripping),
|
|
51
|
+
# so a length mismatch rules a row out before any of that work.
|
|
52
|
+
fields[1].length == wanted_name.length &&
|
|
53
|
+
Normalize.key(fields[1]) == wanted_name &&
|
|
54
|
+
(wanted_teryt.nil? || fields[3] == wanted_teryt)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def search_by_city(fragment, voivodeship: nil)
|
|
59
|
+
wanted_fragment = Normalize.key(fragment)
|
|
60
|
+
return [] if wanted_fragment.length < 2
|
|
61
|
+
|
|
62
|
+
wanted_teryt = resolve_voivodeship(voivodeship)
|
|
63
|
+
return [] if wanted_teryt == :unknown
|
|
64
|
+
|
|
65
|
+
scan do |line|
|
|
66
|
+
fields = line.split("\t", 5)
|
|
67
|
+
name = Normalize.key(fields[1])
|
|
68
|
+
matches = if wanted_fragment.length < MIN_CITY_FRAGMENT_LENGTH
|
|
69
|
+
name == wanted_fragment
|
|
70
|
+
else
|
|
71
|
+
name.include?(wanted_fragment)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
matches && (wanted_teryt.nil? || fields[3] == wanted_teryt)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Accumulates as it streams, so the 72 899 source rows are never all resident
|
|
79
|
+
# at once - only the 52 325 places they collapse into.
|
|
80
|
+
def cities
|
|
81
|
+
accumulators = {}
|
|
82
|
+
each { |record| (accumulators[City.group_key(record)] ||= City::Accumulator.new(record)).add(record) }
|
|
83
|
+
accumulators.each_value.map(&:to_city).sort_by { |city| City.sort_key(city) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def resolve_voivodeship(name)
|
|
89
|
+
return nil if name.nil?
|
|
90
|
+
|
|
91
|
+
Voivodeship.find_by_name(name)&.teryt_code || :unknown
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def scan
|
|
95
|
+
found = []
|
|
96
|
+
open_data do |file|
|
|
97
|
+
file.each_line do |line|
|
|
98
|
+
found << Record.from_row(line.chomp.split("\t", -1)) if yield(line)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
found
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def open_data
|
|
105
|
+
raise DatasetError, "dataset missing: #{path} (run `rake zip_codes:pl:update`)" unless File.exist?(path)
|
|
106
|
+
|
|
107
|
+
File.open(path) do |file|
|
|
108
|
+
header = file.gets&.chomp&.split("\t")
|
|
109
|
+
raise DatasetError, "unexpected header in #{path}" unless header == HEADER
|
|
110
|
+
|
|
111
|
+
yield file
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZipCodes
|
|
4
|
+
module PL
|
|
5
|
+
# One postal code in one place. A place with several codes has several
|
|
6
|
+
# records, and so does a code shared by several places.
|
|
7
|
+
#
|
|
8
|
+
# county and commune carry the source's own labels, which are uneven for
|
|
9
|
+
# Poland ("Powiat bydgoski", "Leszno County"). The *_teryt codes are the
|
|
10
|
+
# dependable identifiers and the join key for official names.
|
|
11
|
+
class Record < Data.define(
|
|
12
|
+
:postal_code, :city,
|
|
13
|
+
:voivodeship, :voivodeship_teryt,
|
|
14
|
+
:county, :county_teryt,
|
|
15
|
+
:commune, :commune_teryt,
|
|
16
|
+
:latitude, :longitude, :accuracy
|
|
17
|
+
)
|
|
18
|
+
COLUMNS = members.freeze
|
|
19
|
+
|
|
20
|
+
def self.from_row(row)
|
|
21
|
+
new(
|
|
22
|
+
postal_code: row[0],
|
|
23
|
+
city: row[1],
|
|
24
|
+
voivodeship: row[2],
|
|
25
|
+
voivodeship_teryt: row[3],
|
|
26
|
+
county: presence(row[4]),
|
|
27
|
+
county_teryt: presence(row[5]),
|
|
28
|
+
commune: presence(row[6]),
|
|
29
|
+
commune_teryt: presence(row[7]),
|
|
30
|
+
latitude: row[8].to_f,
|
|
31
|
+
longitude: row[9].to_f,
|
|
32
|
+
accuracy: presence(row[10])&.to_i
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.presence(value)
|
|
37
|
+
value.nil? || value.empty? ? nil : value
|
|
38
|
+
end
|
|
39
|
+
private_class_method :presence
|
|
40
|
+
|
|
41
|
+
def to_row
|
|
42
|
+
COLUMNS.map { |column| public_send(column) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def coordinates
|
|
46
|
+
[latitude, longitude]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "zip"
|
|
7
|
+
|
|
8
|
+
module ZipCodes
|
|
9
|
+
module PL
|
|
10
|
+
module Sources
|
|
11
|
+
# The GeoNames postal-code export for Poland: one tab separated row per
|
|
12
|
+
# (postal code, place), carrying WGS84 coordinates and the TERYT county and
|
|
13
|
+
# commune codes.
|
|
14
|
+
class Geonames
|
|
15
|
+
URL = "https://download.geonames.org/export/zip/PL.zip"
|
|
16
|
+
ENTRY = "PL.txt"
|
|
17
|
+
ATTRIBUTION = "GeoNames (https://www.geonames.org), CC BY 4.0"
|
|
18
|
+
MAX_REDIRECTS = 3
|
|
19
|
+
EXPECTED_FIELDS = 12
|
|
20
|
+
|
|
21
|
+
Download = Data.define(:body, :etag, :last_modified)
|
|
22
|
+
|
|
23
|
+
def initialize(config: ZipCodes::PL.config)
|
|
24
|
+
@config = config
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns nil when the server reports the file is unchanged, which is what
|
|
28
|
+
# makes re-running the rake task cheap.
|
|
29
|
+
def download(etag: nil)
|
|
30
|
+
response = get(URI.parse(config.source_url), etag: etag)
|
|
31
|
+
return nil if response.is_a?(Net::HTTPNotModified)
|
|
32
|
+
|
|
33
|
+
raise DownloadError, "#{config.source_url} answered #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
34
|
+
|
|
35
|
+
Download.new(
|
|
36
|
+
body: response.body,
|
|
37
|
+
etag: response["etag"],
|
|
38
|
+
last_modified: response["last-modified"]
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def each_record(archive, &block)
|
|
43
|
+
return enum_for(:each_record, archive) unless block
|
|
44
|
+
|
|
45
|
+
Zip::File.open_buffer(StringIO.new(archive)) do |zip|
|
|
46
|
+
entry = zip.find_entry(ENTRY)
|
|
47
|
+
raise DownloadError, "the archive has no #{ENTRY}" if entry.nil?
|
|
48
|
+
|
|
49
|
+
entry.get_input_stream do |stream|
|
|
50
|
+
# The entry stream yields ASCII-8BIT, and Polish names have to survive it.
|
|
51
|
+
stream.each_line { |line| parse_line(line.force_encoding(Encoding::UTF_8), &block) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
attr_reader :config
|
|
59
|
+
|
|
60
|
+
def get(uri, etag:, redirects: 0)
|
|
61
|
+
response = perform(uri, build_request(uri, etag))
|
|
62
|
+
|
|
63
|
+
# 304 is a subclass of Net::HTTPRedirection, and it carries no Location.
|
|
64
|
+
# Following it as a redirect is how the second run used to die.
|
|
65
|
+
return response if response.is_a?(Net::HTTPNotModified)
|
|
66
|
+
return response unless response.is_a?(Net::HTTPRedirection)
|
|
67
|
+
raise DownloadError, "too many redirects from #{config.source_url}" if redirects >= MAX_REDIRECTS
|
|
68
|
+
|
|
69
|
+
get(URI.parse(response["location"]), etag: etag, redirects: redirects + 1)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_request(uri, etag)
|
|
73
|
+
request = Net::HTTP::Get.new(uri)
|
|
74
|
+
request["user-agent"] = config.user_agent
|
|
75
|
+
request["if-none-match"] = etag if etag
|
|
76
|
+
request
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def perform(uri, request)
|
|
80
|
+
Net::HTTP.start(
|
|
81
|
+
uri.host, uri.port,
|
|
82
|
+
use_ssl: uri.scheme == "https",
|
|
83
|
+
open_timeout: config.open_timeout,
|
|
84
|
+
read_timeout: config.read_timeout
|
|
85
|
+
) { |http| http.request(request) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_line(line)
|
|
89
|
+
return if line.strip.empty?
|
|
90
|
+
|
|
91
|
+
fields = line.chomp.split("\t", -1)
|
|
92
|
+
unless fields.length == EXPECTED_FIELDS
|
|
93
|
+
raise DownloadError,
|
|
94
|
+
"malformed GeoNames row: expected #{EXPECTED_FIELDS} fields, got #{fields.length}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
voivodeship = Voivodeship.find_by_geonames_code(fields[4])
|
|
98
|
+
# An unknown admin1 code means the canonical table above is stale, and a
|
|
99
|
+
# silently dropped row would quietly corrupt the output.
|
|
100
|
+
raise DownloadError, "unknown GeoNames voivodeship code: #{fields[4].inspect}" if voivodeship.nil?
|
|
101
|
+
|
|
102
|
+
yield build_record(fields, voivodeship)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def build_record(fields, voivodeship)
|
|
106
|
+
Record.new(
|
|
107
|
+
postal_code: fields[1],
|
|
108
|
+
city: fields[2],
|
|
109
|
+
voivodeship: voivodeship.name,
|
|
110
|
+
voivodeship_teryt: voivodeship.teryt_code,
|
|
111
|
+
county: blank_to_nil(fields[5]&.tr("_", " ")),
|
|
112
|
+
county_teryt: blank_to_nil(fields[6]),
|
|
113
|
+
commune: blank_to_nil(fields[7]&.tr("_", " ")),
|
|
114
|
+
commune_teryt: blank_to_nil(fields[8]),
|
|
115
|
+
latitude: fields[9].to_f,
|
|
116
|
+
longitude: fields[10].to_f,
|
|
117
|
+
accuracy: blank_to_nil(fields[11])&.to_i
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def blank_to_nil(value)
|
|
122
|
+
value.nil? || value.strip.empty? ? nil : value.strip
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|