meandervalley.rb 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. # Meander Valley Council — Advertised & Approved Planning Applications
  2. # Source: https://www.meander.tas.gov.au/advertised-approved-planning-applications
  3. require "nokogiri"
  4. require "uri"
  5. require "cgi"
  6. require "date"
  7. require_relative "../lib/http"
  8. require_relative "../lib/db"
  9. require_relative "../lib/util"
  10. require_relative "../lib/geocode"
  11. require_relative "../lib/enrich"
  12. require "json"
  13. DEBUG = ENV["DEBUG"] == "1"
  14. DRY_RUN = ENV["DRY_RUN"] == "1"
  15. TABLE = ENV.fetch("TABLE_NAME") # run_all.sh -> da_meandervalley
  16. URL = "https://www.meander.tas.gov.au/advertised-approved-planning-applications"
  17. DOWNLOAD_ATTACHMENTS = ENV["DOWNLOAD_ATTACHMENTS"] == "1"
  18. DOWNLOAD_DIR = ENV["DOWNLOAD_DIR"] || "/app/downloads"
  19. DB.ensure_table!(TABLE)
  20. ensure_extra_columns!(TABLE)
  21. # Pull nearest text around an anchor for parsing
  22. def host_block_for(a)
  23. n = a.ancestors("li, p, div, article").first || a.parent
  24. n ? n.text.to_s.gsub(/\s+/, " ").strip : ""
  25. end
  26. # Extract value after a label up to the next label token
  27. def field_value(text, key_rx, stops)
  28. s = text.to_s
  29. m = s.match(/#{key_rx.source}\s*:\s*(.+)/i)
  30. return "" unless m
  31. tail = m[1]
  32. cut = tail.length
  33. stops.each do |st|
  34. i = tail.index(st)
  35. cut = [cut, i].min if i
  36. end
  37. tail[0, cut].strip
  38. end
  39. # Normalize refs like "PA\26\0010", "PA.26.0010", "PA 26/0010" to "PA 26/0010"
  40. def normalize_pa_ref(text, pdf = "")
  41. s = [text.to_s, File.basename(pdf.to_s)].join(" ")
  42. s = s.tr("\\", "/")
  43. if (m = s.match(/\bPA\s*([0-9]{2})[\/\.\s]+([0-9]{3,4})\b/i))
  44. return "PA #{m[1]}/#{m[2]}"
  45. end
  46. nil
  47. end
  48. def extract_between(text, key)
  49. text[/#{key}\s*:\s*(.+?)\s*$/i, 1].to_s.strip
  50. end
  51. def find_pdf_in(nodes, base)
  52. if (a = nodes.css('a[href$=".pdf"], a[href*=".pdf?"]').first)
  53. u = a["href"].to_s
  54. return URI.join(base, u).to_s rescue u
  55. end
  56. ""
  57. end
  58. def block_from_anchor(start_node)
  59. out = [start_node]
  60. cur = start_node
  61. 20.times do
  62. cur = cur.next_element
  63. break if cur.nil?
  64. t = cur.text.to_s.strip
  65. break if t =~ /^\s*Application\s*:/i
  66. break if t =~ /^\s*(Approved|Refused)\b/i
  67. out << cur
  68. end
  69. out
  70. end
  71. def safe_name(s) = s.to_s.gsub(/[^\w\-.]+/, "_")
  72. # Download the PDF (if enabled) and return a web path:
  73. # /downloads/meandervalley/<council_reference>/<filename.pdf>
  74. def download_pdf(url, council_reference)
  75. return nil if DRY_RUN
  76. return nil unless DOWNLOAD_ATTACHMENTS && !url.to_s.strip.empty?
  77. folder = File.join(DOWNLOAD_DIR, "meandervalley", safe_name(council_reference))
  78. FileUtils.mkdir_p(folder)
  79. begin
  80. res = Http.get_response(url) rescue Http.get(url)
  81. body = res.respond_to?(:body) ? res.body : res.to_s
  82. fname = safe_name(File.basename(URI.parse(url).path))
  83. fname += ".pdf" unless fname.downcase.end_with?(".pdf")
  84. path = File.join(folder, fname)
  85. File.binwrite(path, body)
  86. puts "Saved PDF #{path}"
  87. "/downloads/meandervalley/#{safe_name(council_reference)}/#{fname}"
  88. rescue => e
  89. warn "PDF download failed for #{url}: #{e.class} #{e.message}"
  90. nil
  91. end
  92. end
  93. html = Http.get(URL, referer: URL)
  94. doc = Nokogiri::HTML(html)
  95. # Items are listed inline under the "Advertised" section with lines like:
  96. # Application: <link> Applicant: ... Property: ... Proposal: ... Closes: <date>
  97. items = []
  98. # Looser, case-insensitive match for the advertised PDFs
  99. all_anchors = doc.css('a[href]')
  100. if DEBUG
  101. puts "[DEBUG] anchors total=#{all_anchors.size}"
  102. end
  103. pdf_anchors = all_anchors.select do |a|
  104. href = a["href"].to_s
  105. hd = href.downcase
  106. hd.include?("/planning-applications/advertised/") && hd.include?(".pdf")
  107. end
  108. puts "[DEBUG] matched advertised pdf anchors=#{pdf_anchors.size}" if DEBUG
  109. pdf_anchors.each do |a|
  110. href = a["href"].to_s
  111. pdf = begin
  112. URI.join(URL, href).to_s
  113. rescue URI::InvalidURIError
  114. href
  115. end
  116. blk_text = host_block_for(a)
  117. # Try to normalize reference from nearby text or the file name
  118. ref = normalize_pa_ref(blk_text, pdf)
  119. ref ||= a.text.to_s.gsub(/\s+/, " ")[0, 100] # backstop so it never overflows
  120. address = field_value(blk_text, /Property/, [/Proposal/i, /Closes/i, /Application/i])
  121. desc = field_value(blk_text, /Proposal/, [/Closes/i, /Application/i])
  122. close_r = field_value(blk_text, /Closes/, [/Application/i])
  123. address = address[0, 255]
  124. desc = desc.empty? ? "Development Application" : desc[0, 1000]
  125. items << {
  126. council_reference: ref,
  127. address: address,
  128. description: desc,
  129. on_notice_raw: close_r,
  130. on_notice: Util.parse_aus_date(close_r),
  131. pdf: pdf,
  132. title_reference: a.text.to_s.strip
  133. }
  134. end
  135. items.uniq! { |r| [r[:council_reference], r[:address]] }
  136. puts "Found #{items.length} item(s) for #{TABLE}"
  137. items.each_with_index do |r, idx|
  138. if DEBUG
  139. puts "[DEBUG] ##{idx+1} "\
  140. "ref=#{r[:council_reference].inspect} (#{r[:council_reference].to_s.length}) | "\
  141. "addr=#{r[:address].inspect} (#{r[:address].to_s.length}) | "\
  142. "desc.len=#{r[:description].to_s.length} | "\
  143. "date_raw=#{r[:on_notice_raw].inspect} (#{r[:on_notice_raw].to_s.length}) | "\
  144. "pdf=#{r[:pdf].to_s[0,120]}"
  145. # If you want to see the full source text that was parsed:
  146. # puts JSON.pretty_generate(raw: r[:_raw].to_s[0,2000])
  147. end
  148. if DRY_RUN
  149. next
  150. end
  151. #items.each do |r|
  152. ref = r[:council_reference].to_s.tr("\\", "/")[0, 100]
  153. addr = r[:address].to_s[0, 255]
  154. desc = r[:description].to_s[0, 1000]
  155. date_received = Date.today
  156. DB.upsert(TABLE, {
  157. description: r[:description],
  158. date_received: date_received,
  159. on_notice_to: r[:on_notice], # store close date for consistency
  160. on_notice_to_raw: r[:on_notice_raw],
  161. address: r[:address],
  162. council_reference: r[:council_reference],
  163. applicant: "",
  164. owner: ""
  165. })
  166. enrich_after_upsert!(
  167. table: TABLE,
  168. council_reference: r[:council_reference],
  169. address: r[:address]
  170. )
  171. # Download & set local_document_url
  172. local_doc_url = download_pdf(r[:pdf], r[:council_reference])
  173. begin
  174. upd = DB.client.prepare(
  175. "UPDATE `#{DB.client.escape(TABLE)}` SET " \
  176. " document_url = ?, " \
  177. " local_document_url = COALESCE(?, local_document_url), " \
  178. " on_notice_to = ?, on_notice_to_raw = ?, title_reference = ? " \
  179. "WHERE council_reference = ? AND address = ?"
  180. )
  181. upd.execute(
  182. r[:pdf],
  183. local_doc_url,
  184. r[:on_notice],
  185. r[:on_notice_raw],
  186. r[:title_reference],
  187. r[:council_reference],
  188. r[:address]
  189. )
  190. rescue => e
  191. warn "Extras update skipped for #{r[:council_reference]}: #{e.class} #{e.message}"
  192. end
  193. puts "Upserted #{r[:council_reference]} -> #{r[:address]} saved: #{local_doc_url ? 1 : 0}"
  194. end
  195. puts "Done #{TABLE}."