fcc 0.1.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,139 @@
1
+ require 'httparty'
2
+ require 'open-uri'
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'date'
6
+ require_relative './parsers/lms_data'
7
+
8
+ module FCC
9
+ module Station
10
+ class LmsData
11
+ BASE_URI = 'https://enterpriseefiling.fcc.gov/dataentry/api/download/dbfile'
12
+ # include HTTParty
13
+
14
+ def find_all_related(call_sign: )
15
+ stations = find_related_stations(call_sign: call_sign)
16
+ translators = find_translators_for(call_sign: stations.keys)
17
+ stations.merge(translators)
18
+ end
19
+
20
+ def find_translators_for(call_sign: )
21
+ call_signs = [call_sign].flatten
22
+
23
+ records = common_station_data.entries.select do |entry|
24
+ call_signs.any? { |call_sign| call_signs_match?(call_sign, entry['callsign']) }
25
+ end
26
+
27
+ facility_ids = records.map { |r| r['facility_id'] }.uniq.compact
28
+
29
+ matched_facilities = facilities.entries.select do |facility|
30
+ facility_ids.include?(facility['primary_station']) #{}|| facility_ids.include?(facility['facility_id'])
31
+ end
32
+
33
+ {}.tap do |hash|
34
+ matched_facilities.each do |record|
35
+ hash[record['callsign']] = record['facility_id']
36
+ end
37
+ end
38
+ end
39
+
40
+ def find_related_stations(call_sign: )
41
+ call_signs = [call_sign].flatten
42
+
43
+ records = common_station_data.entries.select do |entry|
44
+ call_signs.any? { call_signs_match?(call_sign, entry['callsign']) }
45
+ end
46
+
47
+ correlated_app_ids = records.map { |m| m['eeo_application_id'] }
48
+ matches = common_station_data.entries.select do |entry|
49
+ correlated_app_ids.include?(entry['eeo_application_id'])
50
+ end
51
+
52
+ {}.tap do |hash|
53
+ matches.each do |record|
54
+ hash[record['callsign']] = record['facility_id']
55
+ end
56
+ end
57
+ end
58
+
59
+ def facilities
60
+ @facilities ||= read(:facility)
61
+ end
62
+
63
+ def common_station_data
64
+ @common_station_data ||= read(:common_station)
65
+ end
66
+
67
+ def find_facilities(facility_ids:, call_signs: [])
68
+ matched_facilities = facilities.entries.select do |facility|
69
+ facility_ids.include?(facility['primary_station']) || facility_ids.include?(facility['facility_id']) || call_signs.include?(facility['callsign'])
70
+ end
71
+
72
+ {}.tap do |hash|
73
+ matched_facilities.each do |record|
74
+ hash[record['callsign']] = record['facility_id']
75
+ end
76
+ end
77
+ end
78
+
79
+ def find_call_signs(call_signs:)
80
+ common_station_data.entries.select do |entry|
81
+ call_signs.any? do |call_sign|
82
+ call_signs_match?(call_sign, entry['callsign'])
83
+ end
84
+ end
85
+ end
86
+
87
+ def read(file_name)
88
+ key = "#{lms_date}-#{file_name}"
89
+ remote_url = URI("#{BASE_URI}/#{lms_date}/#{file_name}.zip")
90
+ FCC.log remote_url
91
+ contents = FCC.cache.fetch key do
92
+ begin
93
+ temp_file = http_download_uri(remote_url)
94
+ break if temp_file.empty?
95
+
96
+ contents = ""
97
+ Zip::File.open_buffer(temp_file) do |zf|
98
+ contents = zf.read(zf.entries.first)
99
+ break
100
+ end
101
+
102
+ value = contents
103
+ rescue Exception => e
104
+ FCC.error(e.message)
105
+ value = nil
106
+ ensure
107
+ value
108
+ end
109
+ end
110
+
111
+ if contents
112
+ CSV.parse(contents, col_sep: '|', headers: true)
113
+ end
114
+ end
115
+
116
+ protected
117
+
118
+ def call_signs_match?(ours, theirs)
119
+ theirs.to_s.upcase.to_s == ours.to_s.upcase.to_s || theirs.to_s.upcase =~ Regexp.new("^#{ours.to_s.upcase}[-—–][A-Z0-9]+$")
120
+ end
121
+
122
+ def http_download_uri(uri)
123
+ FCC.log 'Downloading ' + uri.to_s
124
+ begin
125
+ Tempfile.create { HTTParty.get(uri)&.body }
126
+ rescue Exception => e
127
+ FCC.error "=> Exception: '#{e}'. Skipping download."
128
+
129
+ raise e
130
+ return false
131
+ end
132
+ end
133
+
134
+ def lms_date
135
+ Date.today.strftime('%m-%d-%Y')
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,78 @@
1
+ require 'httparty'
2
+
3
+ module FCC
4
+ module Station
5
+ module Parsers
6
+ class ExtendedInfo < HTTParty::Parser
7
+ def parse
8
+ results = []
9
+ body.each_line do |row|
10
+ attrs = {}
11
+ attrs[:raw] = row
12
+ fields = row.split('|').slice(1...-1).collect(&:strip).map { |v| v == '-' ? "" : v }
13
+
14
+ attrs[:call_sign] = fields[0]
15
+ attrs[:frequency] = parse_frequency(fields[1])
16
+ attrs[:band] = fields[2]
17
+ attrs[:channel] = fields[3]
18
+ attrs[:antenna_type] = fields[4] # Directional Antenna (DA) or NonDirectional (ND)
19
+ attrs[:operating_hours] = fields[5] if fields[5] && attrs[:band]&.upcase == "AM" # (Only used for AM)
20
+ attrs[:station_class] = fields[6]
21
+ attrs[:region_2_station_class] = fields[7] if fields[7] && attrs[:band]&.upcase == "AM" # (only used for AM)
22
+ attrs[:status] = fields[8]
23
+ attrs[:city] = fields[9]
24
+ attrs[:state] = fields[10]
25
+ attrs[:country] = fields[11]
26
+ attrs[:file_number] = fields[12] #File Number (Application, Construction Permit or License) or
27
+ attrs[:signal_strength] = parse_signal_strength(fields[13]) # Effective Radiated Power --
28
+ attrs[:effective_radiated_power] = parse_signal_strength(fields[14]) # Effective Radiated Power -- vertically polarized (maximum)
29
+ attrs[:haat_horizontal] = fields[15] # Antenna Height Above Average Terrain (HAAT) -- horizontal polarization
30
+ attrs[:haat_vertical] = fields[16] # Antenna Height Above Average Terrain (HAAT) -- vertical polarization
31
+ attrs[:fcc_id] = fields[17] # Facility ID Number (unique to each station)
32
+ attrs[:latitude] = parse_latitude(fields[18], fields[19], fields[20], fields[21])
33
+ attrs[:longitude] = parse_longitude(fields[22], fields[23], fields[24], fields[25])
34
+ attrs[:licensed_to] = fields[26] # Licensee or Permittee
35
+
36
+ results << attrs
37
+ end
38
+
39
+ results
40
+ end
41
+
42
+ def parse_longitude(direction, degrees, minutes, seconds)
43
+ decimal_degrees = degrees.to_i + (minutes.to_f / 60) + (seconds.to_f / 3600)
44
+
45
+ "#{direction =~ /W/ ? '-' : ''}#{decimal_degrees}"
46
+ end
47
+
48
+ def parse_latitude(direction, degrees, minutes, seconds)
49
+ decimal_degrees = degrees.to_i + (minutes.to_f / 60) + (seconds.to_f / 3600)
50
+
51
+ "#{direction =~ /S/ ? '-' : ''}#{decimal_degrees}"
52
+ end
53
+
54
+ def parse_signal_strength(power_string)
55
+ return unless power_string.present?
56
+
57
+ number, unit = power_string.strip.scan(/^([0-9.]+)\s+(\w+)$?/).flatten
58
+ multiplier = case unit&.downcase
59
+ when "w"
60
+ 1
61
+ when "kw"
62
+ 1000
63
+ when "mw"
64
+ 1000000
65
+ else
66
+ 1
67
+ end
68
+
69
+ number.to_f * multiplier
70
+ end
71
+
72
+ def parse_frequency(freq)
73
+ freq.to_f
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,13 @@
1
+ require 'httparty'
2
+
3
+ module FCC
4
+ module Station
5
+ module Parsers
6
+ class LmsData < HTTParty::Parser
7
+ def parse
8
+ body
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,121 @@
1
+ require 'active_support/inflector'
2
+
3
+ module FCC
4
+ module Station
5
+ class RecordDelegate
6
+ def initialize(result)
7
+ @result = result
8
+ end
9
+
10
+ def method_missing(m, *args, &block)
11
+ return find_result(@result, m) unless @result.is_a?(Array)
12
+ return find_result(@result.first, m) if @result.size == 1
13
+
14
+ filtered_results = @result.filter { |result|
15
+ result[:status] == 'LIC' # Licensed only, no construction permits
16
+ }
17
+
18
+ filtered_results = filtered_results.collect { |res|
19
+ find_result(res, m)
20
+ }.uniq
21
+
22
+ filtered_results.size == 1 ? filtered_results.first : filtered_results
23
+ end
24
+
25
+ def to_json
26
+ return {}.tap do |record|
27
+ [Station::Result::EXTENDED_ATTRIBUTES | Station::Result::BASIC_ATTRIBUTES].flatten.each do |attr|
28
+ record[attr.to_sym] = send(attr.to_sym)
29
+ end
30
+
31
+ %i[contact owner community].each do |attr|
32
+ result = send(attr.to_sym)
33
+ next unless result
34
+
35
+ record[attr] ||= if result.is_a?(Struct)
36
+ result.to_h.compact
37
+ elsif result.is_a?(Array) && result.compact.size > 0
38
+ result
39
+ elsif result.present?
40
+ result.to_s
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ def has_data?
47
+ @result.present?
48
+ end
49
+
50
+ def id
51
+ super || send(:fcc_id)
52
+ end
53
+
54
+ def frequency
55
+ super&.to_f
56
+ end
57
+
58
+ def rf_channel
59
+ super || send(:channel)
60
+ end
61
+
62
+ def operating_hours
63
+ super&.downcase
64
+ end
65
+
66
+ def owner
67
+ @owner ||= begin
68
+ contact = Contact.new(
69
+ name: party_name || licensed_to,
70
+ address: party_address_1,
71
+ address2: party_address_2,
72
+ city: (party_city || city),
73
+ state: (party_state || state),
74
+ zip_code: party_zip_1,
75
+ country: country,
76
+ phone: party_phone
77
+ )
78
+
79
+ contact if contact.to_h.compact.any?
80
+ end
81
+ end
82
+
83
+ def community
84
+ @community ||= begin
85
+ community = Community.new(city: community_city || city, state: community_state || state, country: country)
86
+ community if community.to_h.compact.any?
87
+ end
88
+ end
89
+
90
+ def contact
91
+ contact = main_studio_contact
92
+
93
+ return unless contact
94
+ @contact ||= begin
95
+ contact = Contact.new(name: contact['contactName'], title: contact['contactTitle'], address: contact['contactAddress1'], address2: contact['contactAddress2'], city: contact['contactCity'], state: contact['contactState'], zip_code: contact['contactZip'], phone: contact['contactPhone'], fax: contact['contactFax'], email: contact['contactEmail'], website: contact['contactWebsite'])
96
+ contact if contact.to_h.compact.any?
97
+ end
98
+ end
99
+
100
+ def inspect
101
+ "<RecordDelegate:[#{band}] #{frequency} #{call_sign} — #{community.city} #{community.state} />"
102
+ end
103
+
104
+ private
105
+
106
+ def find_key(result, name)
107
+ result&.keys&.detect { |d| name.to_s == d.to_s } || result&.keys&.detect { |d| name.to_s == d.to_s.underscore }
108
+ end
109
+
110
+ def find_result(result, name)
111
+ matched_key = find_key(result, name)
112
+
113
+ if matched_key
114
+ result[matched_key]
115
+ else
116
+ nil
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,49 @@
1
+ require 'active_support/inflector'
2
+
3
+ module FCC
4
+ module Station
5
+ class RecordGroup
6
+ def initialize(results = [])
7
+ @results = results.map do |result|
8
+ result.is_a?(RecordDelegate) ? result : RecordDelegate.new(result)
9
+ end
10
+ end
11
+
12
+ def to_json
13
+ return {}.tap do |record|
14
+ [Station::Result::EXTENDED_ATTRIBUTES | Station::Result::BASIC_ATTRIBUTES].flatten.each do |attr|
15
+ record[attr.to_sym] = result_attribute(attr.to_sym)
16
+ end
17
+
18
+ %i[contact owner community].each do |attr|
19
+ result = result_attribute(attr.to_sym)
20
+ next unless result
21
+
22
+ record[attr] ||= if result.is_a?(Struct)
23
+ result.to_h.compact
24
+ elsif result.is_a?(Array) && result.compact.size > 0
25
+ result
26
+ elsif result.present?
27
+ result.to_s
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ def result_attribute(attr)
34
+ @results.collect { |r| r.send(attr.to_sym) }.compact.first
35
+ end
36
+
37
+ def method_missing(m, *args, &block)
38
+ result = result_attribute(m.to_sym)
39
+
40
+ if result.is_a?(Array) && result.size == 1
41
+ result = result.first
42
+ end
43
+
44
+ result
45
+ end
46
+
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,180 @@
1
+ require 'zip'
2
+
3
+ module FCC
4
+ module Station
5
+ class Result
6
+ EXTENDED_ATTRIBUTES = %i[band signal_strength latitude longitude station_class file_number effective_radiated_power haat_horizontal haat_vertical antenna_type operating_hours licensed_to city state country] # these take a long time to query
7
+ BASIC_ATTRIBUTES = %i[id call_sign status rf_channel license_expiration_date facility_type frequency band]
8
+
9
+ def initialize(service, call_sign, options = {})
10
+ @call_sign = call_sign.upcase
11
+ @service = service
12
+ @options = options
13
+
14
+ data
15
+
16
+ self
17
+ end
18
+
19
+ def details_available?
20
+ exists? && data.latitude.present?
21
+ end
22
+
23
+ def licensed?
24
+ exists? && data.status == 'LICENSED' && data.license_expiration_date && Time.parse(data.license_expiration_date) > Time.now
25
+ end
26
+
27
+ def exists?
28
+ grouped_records.any?
29
+ end
30
+
31
+ def to_json(*_args)
32
+ [].tap do |records|
33
+ grouped_records.each do |rg|
34
+ records << rg.to_json
35
+ end
36
+ end.flatten.compact.uniq
37
+ end
38
+
39
+ def coordinates_url
40
+ "https://www.google.com/maps/search/#{latitude},#{longitude}" if latitude.present? && longitude.present?
41
+ end
42
+
43
+ def extended_data_url
44
+ "https://transition.fcc.gov/fcc-bin/#{@service.to_s.downcase}q?list=4&facid=#{id}"
45
+ end
46
+
47
+ def enterprise_data_url
48
+ "https://enterpriseefiling.fcc.gov/dataentry/public/tv/publicFacilityDetails.html?facilityId=#{id}"
49
+ end
50
+
51
+ def data
52
+ @data ||= RecordDelegate.new(Info.new(@service).find(@call_sign))
53
+ end
54
+ alias public_data data
55
+
56
+ def grouped_records
57
+ grouped = all_records.group_by do |record|
58
+ [record.id, record.call_sign, record.band, record.frequency].compact.join('/')
59
+ end
60
+
61
+ [].tap do |res|
62
+ grouped.each do |_key, values|
63
+ res << RecordGroup.new(values)
64
+ end
65
+ end
66
+ end
67
+ alias records grouped_records
68
+
69
+ def all_records
70
+ [public_records, transition_records, related_translators].flatten.compact.filter { |f| f.has_data? }
71
+ end
72
+
73
+ def related_translators
74
+ @related_translators ||= begin
75
+ records = lms_data.find_translators_for(call_sign: @call_sign)
76
+ records.keys.map do |call|
77
+ RecordDelegate.new(ExtendedInfo.new(@service).find(call))
78
+ end.select { |f| f.status.upcase == "LIC" }
79
+ rescue
80
+ []
81
+ end
82
+ end
83
+
84
+ def related_stations
85
+ @related_stations ||= begin
86
+ records = lms_data.find_related_stations(call_sign: @call_sign)
87
+ records.keys.map do |call|
88
+ RecordDelegate.new(ExtendedInfo.new(@service).find(call))
89
+ end.select { |f| f.status.upcase == "LIC" }
90
+ end
91
+ end
92
+
93
+ def related
94
+ @related ||= begin
95
+ records = lms_data.find_all_related(call_sign: @call_sign)
96
+ records.keys.map do |call|
97
+ ExtendedInfo.new(@service).find(call).collect do |info|
98
+ RecordDelegate.new(info)
99
+ end
100
+ end.flatten.select { |f| f.status.upcase == "LIC" }
101
+ end
102
+ end
103
+
104
+ def print_broadcast_summary
105
+ FCC.log "[primary]"
106
+ transition_records.each do |record|
107
+ FCC.log "[#{record.band}] #{record.frequency} #{record.call_sign} — #{record.community.city} #{record.community.state}"
108
+ end
109
+
110
+ FCC.log "[translators]"
111
+ related_translators.each do |record|
112
+ FCC.log "[#{record.band}] #{record.frequency} #{record.call_sign} — #{record.community.city} #{record.community.state}"
113
+ end
114
+
115
+ FCC.log "[related stations]"
116
+ related_stations.each do |record|
117
+ FCC.log "[#{record.band}] #{record.frequency} #{record.call_sign} — #{record.community.city} #{record.community.state}"
118
+ end
119
+
120
+ FCC.log "[all related]"
121
+ related.each do |record|
122
+ FCC.log "[#{record.band}] #{record.frequency} #{record.call_sign} — #{record.community.city} #{record.community.state}"
123
+ end
124
+
125
+ nil
126
+ end
127
+
128
+ def lms_data
129
+ @lms_data ||= LmsData.new
130
+ end
131
+
132
+ def call_signs_match?(ours, theirs)
133
+ theirs.to_s.upcase.to_s == ours.to_s.upcase.to_s || theirs.to_s.upcase =~ Regexp.new("^#{ours.to_s.upcase}[-—–][A-Z0-9]+$")
134
+ end
135
+
136
+ private
137
+
138
+ def public_records
139
+ public_data_info.map { |r| RecordDelegate.new(r) }
140
+ end
141
+
142
+ def transition_records
143
+ transition_data_info.map { |r| RecordDelegate.new(r) }
144
+ end
145
+
146
+ def related_records
147
+ results = related.keys.collect do |call_sign|
148
+ RecordDelegate.new(ExtendedInfo.new(@service).find(call_sign))
149
+ end
150
+ end
151
+
152
+ def public_data_info
153
+ @public_data_info ||= [Info.new(@service).find(@call_sign)]
154
+ end
155
+
156
+ def transition_data_info
157
+ @transition_data_info ||= ExtendedInfo.new(@service).find(@call_sign)
158
+ end
159
+
160
+ def method_missing(m, *_args)
161
+ service = if @service == :fm
162
+ fm_record = grouped_records.find { |gr| FCC::FM_FULL_SERVICE == gr.band.upcase }
163
+ fm_low_power = grouped_records.find { |gr| FCC::FM_LOW_POWER == gr.band.upcase }
164
+ fm_booster = grouped_records.find { |gr| FCC::FM_BOOSTER == gr.band.upcase }
165
+ fm_translator = grouped_records.find { |gr| FCC::FM_TRANSLATOR == gr.band.upcase }
166
+
167
+ [fm_record, fm_low_power, fm_booster, fm_translator].compact.find { |r| r.send(m.to_sym) }
168
+ else
169
+ grouped_records.find { |r| r.send(m.to_sym) }
170
+ end
171
+
172
+ result = service.send(m.to_sym) if service
173
+
174
+ result = result.first if result.is_a?(Array) && result.size == 1
175
+
176
+ result
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,45 @@
1
+ require 'active_support/core_ext/module/delegation'
2
+ require_relative 'station/extended_info'
3
+ require_relative 'station/cache'
4
+ require_relative 'station/index'
5
+ require_relative 'station/info'
6
+ require_relative 'station/result'
7
+ require_relative 'station/lms_data'
8
+ require_relative 'station/record_group'
9
+ require_relative 'station/record_delegate'
10
+
11
+ module FCC
12
+ module Station
13
+ Contact = Struct.new(:name, :title, :address, :address2, :city, :state, :country, :zip_code, :phone, :fax, :email, :website, keyword_init: true)
14
+ Community = Struct.new(:city, :state, :country, keyword_init: true)
15
+
16
+ def self.find_each(service, &block)
17
+ results = index(service).results
18
+
19
+ results.each do |result|
20
+ yield find(service, result['callSign'])
21
+ end
22
+ end
23
+
24
+ def self.find(service, call_sign, options = {})
25
+ Result.new(service, call_sign, options)
26
+ end
27
+
28
+ def self.index(service)
29
+ case service.to_s.downcase.to_sym
30
+ when :fm
31
+ @fm_index ||= Index.new(:fm)
32
+ when :am
33
+ @am_index ||= Index.new(:am)
34
+ when :tv
35
+ @tv_index ||= Index.new(:tv)
36
+ else
37
+ raise "unsupported service #{service}. :fm, :am, and :tv are valid"
38
+ end
39
+ end
40
+
41
+ def self.extended_info_cache
42
+ @cache ||= Station::Cache.new
43
+ end
44
+ end
45
+ end
data/lib/fcc.rb CHANGED
@@ -1,3 +1,31 @@
1
- require 'open-uri'
2
- require 'fm'
3
- require 'am'
1
+ # frozen_string_literal: true
2
+ require_relative './fcc/station'
3
+ require_relative './fcc/station/cache'
4
+ require_relative './fcc/station/info'
5
+ require_relative './fcc/station/extended_info'
6
+ require_relative './fcc/station/record_delegate'
7
+
8
+ module FCC
9
+ FM_FULL_SERVICE = 'FM'
10
+ FM_LOW_POWER = 'FL'
11
+ FM_BOOSTER = 'FB'
12
+ FM_TRANSLATOR = 'FX'
13
+
14
+ def self.cache
15
+ @cache ||= Station::Cache.new
16
+ end
17
+
18
+ def self.cache=(cache_service)
19
+ @cache = cache_service
20
+ end
21
+
22
+ def self.log(message)
23
+ @logger ||= Logger.new($stdout)
24
+ @logger.info(message)
25
+ end
26
+
27
+ def self.error(message)
28
+ @error_logger ||= Logger.new($stderr)
29
+ @error_logger.error(message)
30
+ end
31
+ end
data/lib/version.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FCC
4
+ VERSION = '1.0.0'
5
+ end