jp_address 1.0.1 → 2.0.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -0
  3. data/MIT-LICENSE +0 -0
  4. data/README.md +98 -97
  5. data/Rakefile +14 -15
  6. data/app/controllers/jp_address/application_controller.rb +1 -0
  7. data/app/controllers/jp_address/zipcodes_controller.rb +9 -4
  8. data/app/models/jp_address/application_record.rb +5 -0
  9. data/app/models/jp_address/zipcode.rb +166 -36
  10. data/config/routes.rb +0 -0
  11. data/db/migrate/20160312045953_create_jp_address_zipcodes.rb +1 -1
  12. data/lib/jp_address/engine.rb +0 -0
  13. data/lib/jp_address/version.rb +1 -1
  14. data/lib/jp_address.rb +3 -1
  15. data/lib/tasks/jp_address_tasks.rake +0 -0
  16. metadata +48 -157
  17. data/app/assets/javascripts/jp_address/application.js +0 -13
  18. data/app/assets/stylesheets/jp_address/application.css +0 -15
  19. data/app/helpers/jp_address/application_helper.rb +0 -4
  20. data/app/views/layouts/jp_address/application.html.erb +0 -14
  21. data/spec/controllers/jp_address/zipcodes_controller_spec.rb +0 -25
  22. data/spec/dummy/README.rdoc +0 -28
  23. data/spec/dummy/Rakefile +0 -6
  24. data/spec/dummy/app/assets/config/manifest.js +0 -2
  25. data/spec/dummy/app/assets/javascripts/application.js +0 -13
  26. data/spec/dummy/app/assets/stylesheets/application.css +0 -15
  27. data/spec/dummy/app/controllers/application_controller.rb +0 -5
  28. data/spec/dummy/app/helpers/application_helper.rb +0 -2
  29. data/spec/dummy/app/views/layouts/application.html.erb +0 -14
  30. data/spec/dummy/bin/bundle +0 -3
  31. data/spec/dummy/bin/rails +0 -4
  32. data/spec/dummy/bin/rake +0 -4
  33. data/spec/dummy/bin/setup +0 -29
  34. data/spec/dummy/config/application.rb +0 -31
  35. data/spec/dummy/config/boot.rb +0 -5
  36. data/spec/dummy/config/database.yml +0 -25
  37. data/spec/dummy/config/environment.rb +0 -5
  38. data/spec/dummy/config/environments/development.rb +0 -41
  39. data/spec/dummy/config/environments/production.rb +0 -79
  40. data/spec/dummy/config/environments/test.rb +0 -42
  41. data/spec/dummy/config/initializers/assets.rb +0 -11
  42. data/spec/dummy/config/initializers/backtrace_silencers.rb +0 -7
  43. data/spec/dummy/config/initializers/cookies_serializer.rb +0 -3
  44. data/spec/dummy/config/initializers/filter_parameter_logging.rb +0 -4
  45. data/spec/dummy/config/initializers/inflections.rb +0 -16
  46. data/spec/dummy/config/initializers/mime_types.rb +0 -4
  47. data/spec/dummy/config/initializers/session_store.rb +0 -3
  48. data/spec/dummy/config/initializers/wrap_parameters.rb +0 -14
  49. data/spec/dummy/config/locales/en.yml +0 -23
  50. data/spec/dummy/config/routes.rb +0 -3
  51. data/spec/dummy/config/secrets.yml +0 -22
  52. data/spec/dummy/config.ru +0 -4
  53. data/spec/dummy/db/schema.rb +0 -25
  54. data/spec/dummy/db/test.sqlite3 +0 -0
  55. data/spec/dummy/log/test.log +0 -6
  56. data/spec/dummy/public/404.html +0 -67
  57. data/spec/dummy/public/422.html +0 -67
  58. data/spec/dummy/public/500.html +0 -66
  59. data/spec/dummy/public/favicon.ico +0 -0
  60. data/spec/factories/jp_address_zipcodes.rb +0 -10
  61. data/spec/models/jp_address/zipcode_spec.rb +0 -29
  62. data/spec/rails_helper.rb +0 -15
  63. data/spec/spec_helper.rb +0 -92
  64. data/spec/support/files/sample_ken.csv +0 -5
  65. data/spec/vcr/download_master_file_from_japanpost.yml +0 -37476
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ef948c1cac5b64467a6007a4679ce93ef6be1e68e367dbf52cabd69f7d3099f5
4
- data.tar.gz: e08a3c24c255492740a037f02f27b09b3522034186753711c1f0fe1f524afa8a
3
+ metadata.gz: 67701127b1fbec6ae9292647b6373acdeca9dcb51b78addeefaeed7544852531
4
+ data.tar.gz: d3fb7f7ee44c1976825cb22c0394414a9dec14aa220536e17bf57daa382a7575
5
5
  SHA512:
6
- metadata.gz: 489a71906ce28abc642138bc4129d8f3608098d1d6a666d43bef11ee083e6a31ccc4364f51a55a37ddae0eaad5e98bac1abe57041b5fbafd9aaf1a83d8cb2afb
7
- data.tar.gz: 44d2f3ea727acff307d755a186817f341b6fb7e0b539822e65f39a294b78ec066903a5fa062835b367e3353bd05414ead9a84eadfbb4bb9d003ac7fa1a3d6d95
6
+ metadata.gz: 33814d00bd9491bce91816e44066be23025af90b8c68ff62a3924fe6ed1316d5f08339864642a3848f55a7cff23838712816ec5747edabe633e8ace33c91a8fa
7
+ data.tar.gz: becaad6991cc0cc30d8dc05f479488500b02707c3028c6b8841af2ed3e35a2042e01c5332a15ce20caa9962deb4231b629f9f83503f4e09a0bd0b9b7e1a36c74
data/CHANGELOG.md ADDED
@@ -0,0 +1,57 @@
1
+ # CHANGELOG
2
+
3
+ ## 2.0.0
4
+
5
+ 最新の Ruby / Rails への対応と、郵便番号データの取り込みまわりのバグ修正。
6
+
7
+ ### 対応バージョン
8
+
9
+ * Ruby 3.1 〜 3.4、Rails 7.1 / 8.0 / 8.1 に対応。
10
+ (Ruby 3.1 + Rails 7.1 〜 Ruby 3.4 + Rails 8.1 で実際にテスト済み)
11
+ * Rails 6.x 以前のサポートを終了。
12
+ * Ruby 3.4 で `csv` が bundled gem になったため、gemspec の依存に追加。
13
+ これがないと `require 'csv'` に失敗する。
14
+ * `railties` / `activerecord` を runtime 依存として明示。
15
+ * rubyzip 3.x に対応(`>= 2.3, < 4.0`)。
16
+
17
+ ### バグ修正
18
+
19
+ * **町域名が複数行に分かれている郵便番号で町名が消えていた**。
20
+ ken_all.csv は町域名が長いと括弧の途中で改行して次の行に続くが、その
21
+ 継続行を別の町名として扱っていたため、共通部分が見つからず町名が空に
22
+ なっていた。括弧が閉じるまでの行を読み飛ばすようにした。
23
+ 例:9292225(石川県羽咋郡宝達志水町 米出)が `""` → `"米出"`。
24
+ * **同じ郵便番号の行が全部同じ町名でも町名が空になっていた**。
25
+ `_find_shared_name_from` が「差分が見つからない=共通部分なし」と
26
+ 判定していたのを修正。短い方の地名がまるごと共通部分になる場合
27
+ (例:東中島/東中島本町)もその地名を返すようにした。
28
+ * 日本郵便へのリクエストがリダイレクト・エラーレスポンスを一切見ていな
29
+ かったのを修正。リダイレクトは最大 5 回まで追い、200 以外は
30
+ `JpAddress::Zipcode::DownloadError` を投げる。
31
+ * zip の中から CSV エントリだけを取り出すようにした(従来は全エントリを
32
+ 同じパスに展開しようとしていた)。
33
+ * 存在しない CSV パスを `load_master_data` に渡すと、無駄にダウンロード
34
+ してから `Errno::ENOENT` になっていたのを `ArgumentError` に。
35
+ * `row[8]` が空の行で `NoMethodError` にならないようにした。
36
+ * `FileUtils` を require していなかったのを修正。
37
+
38
+ ### 変更
39
+
40
+ * `load_master_data` を全面的に書き直し。行ごとの生 SQL を `insert_all!`
41
+ でのバッチ投入に変え、統合処理も CSV 読み込み中にメモリ上で行うように
42
+ したので、大幅に速くなった(本番でのロードが数分 → 数十秒程度)。
43
+ これに伴い内部メソッド `_merge_same_zip_addresses` は削除。
44
+ * API のレスポンスを `render plain:` から `render json:` に変更。
45
+ Content-Type が `text/plain` から `application/json` になる。
46
+ * 郵便番号の全角数字(`533-0033`)を受け付けるようにした。
47
+ * `JpAddress::ApplicationRecord` を追加し、`Zipcode` の親クラスにした。
48
+ * Sprockets 前提の app/assets・レイアウト・空の ApplicationHelper を削除。
49
+ JSON しか返さない engine には不要で、propshaft / importmap 環境では
50
+ むしろ壊れるため。
51
+ * テストを VCR カセット(2.5MB のバイナリ)から WebMock のスタブに置き換え。
52
+ コントローラスペックはリクエストスペックに変更。
53
+ * GitHub Actions で Ruby × Rails のマトリクステストを追加。
54
+
55
+ ## 1.0.2 以前
56
+
57
+ 省略。
data/MIT-LICENSE CHANGED
File without changes
data/README.md CHANGED
@@ -1,10 +1,10 @@
1
- # Ruby on Rails 6 で 郵便番号住所検索 な gem
1
+ # Ruby on Rails で 郵便番号住所検索 な gem
2
2
 
3
3
  ## JpAddressとは
4
- 日本郵便の「[郵便番号データ](https://www.post.japanpost.jp/zipcode/dl/oogaki-zip.html)」を Rails 6.1 で使用するための gem です。
4
+ 日本郵便の「[郵便番号データ](https://www.post.japanpost.jp/zipcode/download.html)」を用いて、あなたの Rails サイトに「郵便番号からの住所検索機能」を組み込むための gem です。
5
5
  以下の機能を提供します。
6
6
 
7
- * [[郵便番号データ](https://www.post.japanpost.jp/zipcode/dl/oogaki/zip/ken_all.zip "ken_all.zip")]をダウンロードして自前DBのテーブル(jp_address_zipcodes)にロードするクラスメソッド。(```JpAddress::Zipcode.load_master_data```)
7
+ * [[郵便番号データ](https://www.post.japanpost.jp/zipcode/dl/kogaki/zip/ken_all.zip "ken_all.zip")]をダウンロードして自前DBのテーブル(jp_address_zipcodes)にロードするクラスメソッド。(```JpAddress::Zipcode.load_master_data```)
8
8
  * 郵便番号を受け取り都道府県名と住所をJSONで返却するAPI。
9
9
  (```jp_address/zipcodes#search```)
10
10
 
@@ -14,8 +14,17 @@ APIはお使いのRailsアプリケーションにマウントして使います
14
14
  あと必要なのは、戻ってくるJSONを加工してHTML要素にセットするJavaScriptの記述だけです。<br>
15
15
  (本記事下部にサンプルコードを掲載しています。)
16
16
 
17
+ ### 対応バージョン
18
+
19
+ | | 対応 |
20
+ |---|---|
21
+ | Ruby | 3.1 以降 |
22
+ | Rails | 7.1 / 8.0 / 8.1 |
23
+
24
+ Rails 6.x 以前をお使いの場合は 1.0.2 をご利用ください。
25
+
17
26
  ### インストール
18
- GemFileに追記
27
+ Gemfileに追記
19
28
  ```ruby
20
29
  gem 'jp_address'
21
30
  ```
@@ -36,13 +45,34 @@ $ bundle exec rails runner -e development 'JpAddress::Zipcode.load_master_data'
36
45
  $ bundle exec rails runner -e production 'JpAddress::Zipcode.load_master_data'
37
46
  ```
38
47
 
39
- 環境にもよりますが、1~3分ぐらいかかると思います。
48
+ APP_ROOT/tmp/ を作業ディレクトリに使用しています。<br>
49
+ 最初にテーブルをトランケートしますので、毎回「全件insert」になります。<br>
40
50
 
41
- APP_ROOT/tmp/ を作業ディレクトリに使用しています。<br>
42
- 最初にテーブルをトランケートしますので、毎回「全件insert」になります。
51
+ すでに手元に ken_all.csv がある場合は、パスを渡せばダウンロードを省略できます。<br>
52
+ (文字コードは日本郵便の配布どおり CP932 のままで構いません。)
53
+ ```ruby
54
+ JpAddress::Zipcode.load_master_data('tmp/ken_all.csv')
55
+ ```
56
+
57
+ 同じ郵便番号を持つレコードは統合されます。<br>
58
+ <br>
59
+ 例:9896712<br>
60
+ ```
61
+ "宮城県","大崎市","鳴子温泉水沼"
62
+ "宮城県","大崎市","鳴子温泉南山"
63
+ "宮城県","大崎市","鳴子温泉山際"
64
+ "宮城県","大崎市","鳴子温泉和田"
65
+ ```
66
+ これらは先頭から共通する地名を探し、うまく見つかれば
67
+ ```
68
+ "宮城県","大崎市","鳴子温泉"
69
+ ```
70
+ として1つのレコードにします。
71
+ 共通する地名が抜き出せない場合は空の町名にします。
43
72
 
44
73
  ### APIのマウント
45
74
  Railsアプリの config/routes.rb に追記。
75
+ at: に渡す値は /jp_address でなくても /service や /api などでも構いません。
46
76
  ```ruby
47
77
  mount JpAddress::Engine, at: "/jp_address"
48
78
  ```
@@ -61,6 +91,14 @@ http://localhost:3000/jp_address/zipcodes/search?zip=5330033
61
91
  {"id":84280,"zip":"5330033","prefecture":"大阪府","city":"大阪市東淀川区","town":"東中島"}
62
92
  ```
63
93
 
94
+ 該当する郵便番号がない場合は、各項目が null の JSON が返ります。
95
+ ```js script
96
+ {"id":null,"zip":null,"prefecture":null,"city":null,"town":null}
97
+ ```
98
+
99
+ 半角ハイフン・空白・全角数字は自動的に取り除かれるので、`533-0033` や `533-0033` を
100
+ そのまま投げても構いません。
101
+
64
102
  ### APIを利用するサンプル JavaScript
65
103
  フォームに
66
104
  1. #zipcode (郵便番号を入力するテキストボックス)
@@ -68,15 +106,11 @@ http://localhost:3000/jp_address/zipcodes/search?zip=5330033
68
106
  3. #address (住所を表示するテキストボックス)
69
107
 
70
108
  の3要素があるとします。<br>
71
- #zipcodeに入れられた値を keyup イベントで拾ってAPIを叩き、都道府県プルダウンを選択し、住所をセットするサンプルです。
109
+ #zipcodeに入れられた値を input イベントで拾ってAPIを叩き、都道府県プルダウンを選択し、住所をセットするサンプルです。<br>
72
110
 
73
111
  都道府県プルダウンは、戻ってくるJSONの "prefecture" すなわち都道府県名で選択します。<br>
74
112
  ですので、お持ちの都道府県マスターの各レコードがどのようなIDを持っていても構いません。
75
113
 
76
- ※ JQuery の存在を前提にしています。<br>
77
- ※ 郵便番号の半角ハイフンは自動でカットされます。<br>
78
- ※ もともと CoffeeScript で書いてあったソースを decaffeinate したものですので冗長です。本質的な処理はAddressSearch が担っているだけで、他の関数は decaffeinate に必要なだけです。
79
-
80
114
  #### フォーム
81
115
  ```
82
116
  <form>
@@ -134,106 +168,73 @@ http://localhost:3000/jp_address/zipcodes/search?zip=5330033
134
168
  </form>
135
169
  ```
136
170
 
137
- #### application.js など共通に読み込まれるファイルに配置するJavaScript
171
+ #### JavaScript
172
+ jQuery などのライブラリは不要です。app/javascript 配下など、フォームのあるページで
173
+ 読み込まれる場所に置いてください。
138
174
 
139
175
  ```js script
140
- function _classCallCheck(instance, Constructor) {
141
- if (!(instance instanceof Constructor)) {
142
- throw new TypeError("Cannot call a class as a function");
143
- }
176
+ class AddressSearch {
177
+ constructor(zipSelector, prefectureSelector, addressSelector, endpoint = '/jp_address/zipcodes/search') {
178
+ this.zip = document.querySelector(zipSelector);
179
+ this.prefecture = document.querySelector(prefectureSelector);
180
+ this.address = document.querySelector(addressSelector);
181
+ this.endpoint = endpoint;
144
182
  }
145
183
 
146
- function _defineProperties(target, props) {
147
- for (var i = 0; i < props.length; i++) {
148
- var descriptor = props[i];
149
- descriptor.enumerable = descriptor.enumerable || false;
150
- descriptor.configurable = true;
184
+ start() {
185
+ this.zip.addEventListener('input', () => this.execute());
186
+ }
151
187
 
152
- if ("value" in descriptor)
153
- descriptor.writable = true;
154
- Object.defineProperty(target, descriptor.key, descriptor);
188
+ async execute() {
189
+ const zip = this.zip.value.replace(/[^0-90-9]/g, '');
190
+ if (zip.length !== 7) return;
191
+
192
+ const url = `${this.endpoint}?zip=${encodeURIComponent(zip)}`;
193
+ const res = await fetch(url, { headers: { Accept: 'application/json' } });
194
+ if (!res.ok) return;
195
+
196
+ const json = await res.json();
197
+ if (json.id === null) {
198
+ this.clear();
199
+ } else {
200
+ this.setPrefecture(json.prefecture);
201
+ this.address.value = `${json.city}${json.town}`;
155
202
  }
156
203
  }
157
204
 
158
- function _createClass(Constructor, protoProps, staticProps) {
159
- if (protoProps)
160
- _defineProperties(Constructor.prototype, protoProps);
161
- if (staticProps)
162
- _defineProperties(Constructor, staticProps);
163
- return Constructor;
205
+ clear() {
206
+ this.prefecture.selectedIndex = 0;
207
+ this.address.value = '';
164
208
  }
165
209
 
166
- var AddressSearch = function() {
167
- "use strict";
168
- function AddressSearch(zip_elem_id, prefecture_elem_id, address_elem_id) {
169
- _classCallCheck(this, AddressSearch);
170
- this.zip = $(zip_elem_id);
171
- this.prefecture = $(prefecture_elem_id);
172
- this.address = $(address_elem_id);
173
- this.prefecture_elem_id = prefecture_elem_id;
210
+ setPrefecture(name) {
211
+ for (const option of this.prefecture.options) {
212
+ if (option.text === name) {
213
+ option.selected = true;
214
+ return;
215
+ }
174
216
  }
217
+ }
218
+ }
175
219
 
176
- _createClass(AddressSearch, [{
177
- key: "_remove_hyphen",
178
- value: function _remove_hyphen() {
179
- return this.zip.val(this.zip.val().replace(/-/, ''));
180
- }
181
- }, {
182
- key: "_clear_current_value",
183
- value: function _clear_current_value() {
184
- $(this.prefecture_elem_id + ' >option:eq(0)').prop('selected', true);
185
- return this.address.val('');
186
- }
187
- }, {
188
- key: "_set_prefecture",
189
- value: function _set_prefecture(json) {
190
- return $(this.prefecture_elem_id + ' > option').each(function() {
191
- if ($(this).text() === json['prefecture']) {
192
- return $(this).prop('selected', true);
193
- }
194
- });
195
- }
196
- }, {
197
- key: "_set_address",
198
- value: function _set_address(json) {
199
- return this.address.val(json['city'] + json['town']);
200
- }
201
- }, {
202
- key: "_call_api",
203
- value: function _call_api() {
204
- var _this = this;
205
- return $.getJSON('/jp_address/zipcodes/search', {zip: this.zip.val()}, function(json) {
206
- if (json['id'] === null) {
207
- return _this._clear_current_value();
208
- } else {
209
- _this._set_prefecture(json);
210
- return _this._set_address(json);
211
- }
212
- });
213
- }
214
- }, {
215
- key: "execute",
216
- value: function execute() {
217
- this._remove_hyphen();
218
- if (this.zip.val().length === 7) {
219
- return this._call_api();
220
- }
221
- }
222
- }]);
220
+ // #zipcode, #prefecture_id, #address を各自の環境に合わせて書き換えてください。
221
+ document.addEventListener('DOMContentLoaded', () => {
222
+ new AddressSearch('#zipcode', '#prefecture_id', '#address').start();
223
+ });
224
+ ```
225
+
226
+ ### 開発
223
227
 
224
- return AddressSearch;
225
- }();
228
+ ```
229
+ $ bundle install
230
+ $ bundle exec rspec
226
231
  ```
227
232
 
228
- #### フォームのあるページに配置するJavaScript
229
- ```js script
230
- // #zipcode, #prefecture_id, #address を各自の環境に合わせて書き換えてください。
231
- $(function() {
232
- var address_search = new AddressSearch('#zipcode', '#prefecture_id', '#address');
233
- $('#zipcode').keyup(function() {
234
- address_search.execute();
235
- });
236
- });
233
+ 複数バージョンの Rails で試す場合は gemfiles/ 配下の Gemfile を使ってください。
234
+
235
+ ```
236
+ $ BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle install
237
+ $ BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rspec
237
238
  ```
238
239
 
239
240
  ##### 作者
data/Rakefile CHANGED
@@ -4,23 +4,22 @@ rescue LoadError
4
4
  puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
5
  end
6
6
 
7
- require 'rdoc/task'
8
-
9
- RDoc::Task.new(:rdoc) do |rdoc|
10
- rdoc.rdoc_dir = 'rdoc'
11
- rdoc.title = 'JpAddress'
12
- rdoc.options << '--line-numbers'
13
- rdoc.rdoc_files.include('lib/**/*.rb')
7
+ begin
8
+ require 'rdoc/task'
9
+
10
+ RDoc::Task.new(:rdoc) do |rdoc|
11
+ rdoc.rdoc_dir = 'rdoc'
12
+ rdoc.title = 'JpAddress'
13
+ rdoc.options << '--line-numbers'
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+ rescue LoadError
17
+ # rdoc is optional; skip the task when it is not installed.
14
18
  end
15
19
 
16
- APP_RAKEFILE = File.expand_path("../spec/dummy/Rakefile", __FILE__)
20
+ APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
17
21
  load 'rails/tasks/engine.rake'
18
22
 
19
-
20
- load 'rails/tasks/statistics.rake'
21
-
22
-
23
-
24
23
  Bundler::GemHelper.install_tasks
25
24
 
26
25
  require 'rspec/core'
@@ -29,5 +28,5 @@ RSpec::Core::RakeTask.new(:spec) do |spec|
29
28
  spec.pattern = FileList['spec/**/*_spec.rb']
30
29
  end
31
30
 
32
- task :default => :spec
33
- task :test => :spec
31
+ task default: :spec
32
+ task test: :spec
@@ -1,4 +1,5 @@
1
1
  module JpAddress
2
2
  class ApplicationController < ActionController::Base
3
+ protect_from_forgery with: :null_session
3
4
  end
4
5
  end
@@ -1,10 +1,15 @@
1
- require_dependency "jp_address/application_controller"
2
-
3
1
  module JpAddress
4
2
  class ZipcodesController < ApplicationController
5
3
  def search
6
- @zipcode = Zipcode.find_by(:zip => params[:zip].to_s.gsub(/[^0-9]/, '')) || Zipcode.new
7
- render plain: @zipcode.to_json
4
+ @zipcode = Zipcode.find_by(zip: _normalized_zip) || Zipcode.new
5
+ render json: @zipcode
6
+ end
7
+
8
+ private
9
+
10
+ # 全角数字・ハイフン・空白などを取り除いて半角数字だけにします。
11
+ def _normalized_zip
12
+ params[:zip].to_s.tr('0-9', '0-9').gsub(/[^0-9]/, '')
8
13
  end
9
14
  end
10
15
  end
@@ -0,0 +1,5 @@
1
+ module JpAddress
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -1,82 +1,212 @@
1
1
  module JpAddress
2
- class Zipcode < ActiveRecord::Base
2
+ class Zipcode < ApplicationRecord
3
3
 
4
- MASTER_CSV_PATH = 'tmp/ken_all.csv'
4
+ # 日本郵便からダウンロードした CSV の一時保存先。
5
+ MASTER_CSV_PATH = 'tmp/ken_all.csv'.freeze
6
+
7
+ # 郵便番号データ(住所の郵便番号/小書き)の配布 URL。
8
+ MASTER_FILE_URL = 'https://www.post.japanpost.jp/zipcode/dl/kogaki/zip/ken_all.zip'.freeze
9
+
10
+ # insert_all で一度に流し込む件数。
11
+ INSERT_BATCH_SIZE = 1_000
12
+
13
+ # リダイレクトを追う上限。
14
+ MAX_REDIRECTS = 5
15
+
16
+ class DownloadError < StandardError; end
5
17
 
6
18
  def self.download_master_file_from_japanpost
7
19
  _setup_directory
8
- zip = _save_zip(_request_to_japanpost.body)
9
- _save_csv(zip.path)
20
+ tmp_zip = _save_zip(_request_to_japanpost)
21
+ begin
22
+ _save_csv(tmp_zip.path)
23
+ ensure
24
+ tmp_zip.close!
25
+ end
10
26
  :success if File.exist?(MASTER_CSV_PATH)
11
27
  end
12
28
 
13
29
  def self._setup_directory
14
- FileUtils.mkdir_p 'tmp'
30
+ FileUtils.mkdir_p File.dirname(MASTER_CSV_PATH)
15
31
  _remove_csv
16
32
  end
17
33
  private_class_method :_setup_directory
18
34
 
19
- def self._request_to_japanpost
20
- res = nil
21
- https = Net::HTTP.new('www.post.japanpost.jp',443)
22
- https.use_ssl = true
23
- https.start {
24
- res = https.get('/zipcode/dl/kogaki/zip/ken_all.zip')
25
- }
26
- res
35
+ def self._request_to_japanpost(url = MASTER_FILE_URL, redirect_limit = MAX_REDIRECTS)
36
+ raise DownloadError, "too many redirects while downloading #{MASTER_FILE_URL}" if redirect_limit <= 0
37
+
38
+ uri = URI.parse(url)
39
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |https|
40
+ https.get(uri.request_uri)
41
+ end
42
+
43
+ case res
44
+ when Net::HTTPSuccess
45
+ res.body
46
+ when Net::HTTPRedirection
47
+ location = res['location']
48
+ raise DownloadError, "got a redirect without a location from #{url}" if location.nil?
49
+
50
+ _request_to_japanpost(URI.join(url, location).to_s, redirect_limit - 1)
51
+ else
52
+ raise DownloadError, "failed to download #{url} (#{res.code} #{res.message})"
53
+ end
27
54
  end
28
55
  private_class_method :_request_to_japanpost
29
56
 
30
57
  def self._save_zip(binary)
31
- tmp_zip = Tempfile.open('ken_all.zip') do |f|
32
- f.binmode
33
- f.write binary
34
- f
35
- end
58
+ tmp_zip = Tempfile.new(['ken_all', '.zip'])
59
+ tmp_zip.binmode
60
+ tmp_zip.write binary
61
+ tmp_zip.flush
62
+ tmp_zip.close
36
63
  tmp_zip
37
64
  end
38
65
  private_class_method :_save_zip
39
66
 
40
67
  def self._save_csv(zip_path)
41
68
  Zip::File.open(zip_path) do |zip_file|
42
- zip_file.each do |entry|
43
- entry.extract(MASTER_CSV_PATH)
44
- end
69
+ entry = zip_file.find { |e| e.file? && File.extname(e.name).downcase == '.csv' }
70
+ raise DownloadError, "no CSV file found in the archive from #{MASTER_FILE_URL}" if entry.nil?
71
+
72
+ entry.extract(MASTER_CSV_PATH)
45
73
  end
46
74
  end
47
75
  private_class_method :_save_csv
48
76
 
77
+ # CSV を読み込んでテーブルを作り直します。
78
+ #
79
+ # 同じ郵便番号を持つ行は1レコードに統合してから insert するので、
80
+ # 郵便番号はテーブル内で一意になります。
49
81
  def self.load_master_data(csv_path = MASTER_CSV_PATH)
50
- download_master_file_from_japanpost if !File.exist?(csv_path)
82
+ unless File.exist?(csv_path)
83
+ raise ArgumentError, "#{csv_path} does not exist" unless csv_path == MASTER_CSV_PATH
84
+
85
+ download_master_file_from_japanpost
86
+ end
87
+
51
88
  _clear_table
52
- CSV.foreach(csv_path, encoding:'SJIS:UTF-8') do |row|
53
- connection.execute(
54
- sanitize_sql(
55
- ["insert into jp_address_zipcodes (zip, prefecture, city, town)
56
- values ('%s', '%s', '%s', '%s')", row[2], row[6], row[7], _remove_needless_words(row[8])
57
- ]
58
- )
59
- )
89
+ transaction do
90
+ _each_record_batch_from(csv_path) { |batch| insert_all!(batch) }
60
91
  end
61
92
  _remove_csv
93
+ nil
94
+ end
95
+
96
+ # CSV を郵便番号ごとにまとめ、insert_all に渡せる Hash の配列を
97
+ # INSERT_BATCH_SIZE 件ずつ yield します。
98
+ def self._each_record_batch_from(csv_path)
99
+ _read_master_csv(csv_path).each_value.each_slice(INSERT_BATCH_SIZE) do |slice|
100
+ yield slice.map { |rec|
101
+ {
102
+ zip: rec[:zip],
103
+ prefecture: rec[:prefecture],
104
+ city: rec[:city],
105
+ town: _find_shared_name_from(rec[:towns])
106
+ }
107
+ }
108
+ end
109
+ end
110
+ private_class_method :_each_record_batch_from
111
+
112
+ # 郵便番号をキーにして CSV の行をまとめます。
113
+ # 都道府県名・市区町村名は同じ郵便番号の最初の行のものを採用します。
114
+ #
115
+ # 例:9896712
116
+ # "宮城県","大崎市","鳴子温泉水沼"
117
+ # "宮城県","大崎市","鳴子温泉南山"
118
+ # "宮城県","大崎市","鳴子温泉山際"
119
+ # "宮城県","大崎市","鳴子温泉和田"
120
+ # これらは
121
+ # "宮城県","大崎市","鳴子温泉" として1つのレコードにします。
122
+ # 共通する地名が抜き出せない場合は空の町名にします。
123
+ #
124
+ # ken_all.csv では町域名が長いと括弧の途中で改行され、次の行に続きます。
125
+ # "米出(124、125、127、"
126
+ # "148、149、154番地)"
127
+ # 続きの行は町域名ではないので、括弧が閉じるまでの行は読み飛ばします。
128
+ def self._read_master_csv(csv_path)
129
+ records = {}
130
+ # 郵便番号ごとの「閉じていない括弧の数」。0 より大きい間は継続行。
131
+ open_parens = Hash.new(0)
132
+
133
+ # ken_all.csv の文字コードは CP932(Ruby では SJIS が CP932 の別名)。
134
+ CSV.foreach(csv_path, encoding: 'CP932:UTF-8') do |row|
135
+ zip = row[2].to_s.strip
136
+ next if zip.empty?
137
+
138
+ raw_town = row[8].to_s
139
+ if open_parens[zip] > 0
140
+ open_parens[zip] += _paren_balance(raw_town)
141
+ next
142
+ end
143
+ open_parens[zip] = [_paren_balance(raw_town), 0].max
144
+
145
+ record = (records[zip] ||= {
146
+ zip: zip,
147
+ prefecture: row[6].to_s.strip,
148
+ city: row[7].to_s.strip,
149
+ towns: []
150
+ })
151
+ town = _remove_needless_words(raw_town)
152
+ record[:towns] << town if town.present?
153
+ end
154
+ records
155
+ end
156
+ private_class_method :_read_master_csv
157
+
158
+ def self._paren_balance(text)
159
+ text.count('(') - text.count(')')
160
+ end
161
+ private_class_method :_paren_balance
162
+
163
+ # 引数に渡された地名群から、先頭から見て共通となる地名を返します。
164
+ #
165
+ # input = %w[鳴子温泉小身川
166
+ # 鳴子温泉川袋
167
+ # 鳴子温泉木戸脇
168
+ # ]
169
+ #
170
+ # return => 鳴子温泉
171
+ #
172
+ # 共通部分が1文字しかない場合(例:大分/大阪)は、地名として意味を
173
+ # なさないので空文字を返します。ただし短い方の地名がまるごと共通部分に
174
+ # なる場合(例:東中島/東中島本町)は、その地名をそのまま返します。
175
+ def self._find_shared_name_from(names)
176
+ names = Array(names).reject { |name| name.blank? }
177
+ return '' if names.empty?
178
+
179
+ shortest = names.min_by { |name| name.length }
180
+ shared_length = 0
181
+ shortest.each_char.with_index do |char, pos|
182
+ break unless names.all? { |name| name[pos] == char }
183
+
184
+ shared_length = pos + 1
185
+ end
186
+
187
+ return shortest if shared_length == shortest.length
188
+
189
+ shared_length > 1 ? shortest[0, shared_length] : ''
62
190
  end
63
191
 
64
192
  def self._clear_table
65
- begin
66
- connection.truncate 'jp_address_zipcodes'
67
- rescue NotImplementedError
68
- connection.execute 'delete from jp_address_zipcodes'
193
+ connection_pool.with_connection do |conn|
194
+ begin
195
+ conn.truncate table_name
196
+ rescue NotImplementedError
197
+ conn.delete "delete from #{conn.quote_table_name(table_name)}"
198
+ end
69
199
  end
70
200
  end
71
201
  private_class_method :_clear_table
72
202
 
73
203
  def self._remove_needless_words(base)
74
- base.sub(/以下に掲載がない場合/, '').sub(/(.*/, '')
204
+ base.to_s.sub(/以下に掲載がない場合/, '').sub(/(.*/, '').strip
75
205
  end
76
206
  private_class_method :_remove_needless_words
77
207
 
78
208
  def self._remove_csv
79
- FileUtils.rm_rf MASTER_CSV_PATH
209
+ FileUtils.rm_f MASTER_CSV_PATH
80
210
  end
81
211
  private_class_method :_remove_csv
82
212
 
data/config/routes.rb CHANGED
File without changes
@@ -1,4 +1,4 @@
1
- class CreateJpAddressZipcodes < ActiveRecord::Migration[5.0]
1
+ class CreateJpAddressZipcodes < ActiveRecord::Migration[7.1]
2
2
  def change
3
3
  create_table :jp_address_zipcodes do |t|
4
4
  t.string :zip , null: false, index: true