break_oday.rb 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. # Break O'Day Council — Advertised Development Applications
  2. # List page: https://www.bodc.tas.gov.au/council/advertised-development-applications/
  3. require "nokogiri"
  4. require "cgi"
  5. require "uri"
  6. require_relative "../lib/http"
  7. require_relative "../lib/db"
  8. require_relative "../lib/util"
  9. require_relative "../lib/geocode"
  10. require_relative "../lib/enrich"
  11. TABLE = ENV.fetch("TABLE_NAME") # run_all.sh -> da_break_oday
  12. URL = "https://www.bodc.tas.gov.au/council/advertised-development-applications/"
  13. DOWNLOAD_ATTACHMENTS = ENV["DOWNLOAD_ATTACHMENTS"] == "1"
  14. DOWNLOAD_DIR = ENV["DOWNLOAD_DIR"] || "/app/downloads"
  15. DB.ensure_table!(TABLE)
  16. ensure_extra_columns!(TABLE)
  17. def abs_url(base, href)
  18. return "" if href.to_s.strip.empty?
  19. URI.join(base, href).to_s rescue href.to_s
  20. end
  21. # Accepts "DA 2025/123", "DA2025/0123", "DA 054-2026", etc
  22. REF_RXES = [
  23. %r{\bDA\s*(20\d{2})\s*/\s*([A-Za-z0-9\-_.]+)}i, # DA 2025/123
  24. %r{\bDA(20\d{2})\s*[-/]?\s*([0-9]{3,})\b}i, # DA2025-0123
  25. %r{\bDA\s*([0-9]{1,4})\s*-\s*(20\d{2})\b}i # DA 054-2026
  26. ]
  27. def extract_ref(str)
  28. s = CGI.unescape(str.to_s)
  29. REF_RXES.each do |rx|
  30. if (m = s.match(rx))
  31. # normalize to "DA YYYY / NNN"
  32. if rx.source.include?("\\s*-\\s*") # hyphen form "054-2026"
  33. return "DA #{m[2]} / #{m[1]}"
  34. else
  35. return "DA #{m[1]} / #{m[2]}"
  36. end
  37. end
  38. end
  39. nil
  40. end
  41. def find_list_table(doc)
  42. doc.css("table").find do |t|
  43. heads = t.css("thead th").map { |th| th.text.strip.downcase }
  44. heads.any? && (
  45. heads.any? { |h| h.include?("closing") } ||
  46. heads.any? { |h| h.include?("pdf") } ||
  47. heads.any? { |h| h.include?("name") }
  48. )
  49. end
  50. end
  51. def safe_name(s) = s.to_s.gsub(/[^\w\-.]+/, "_")
  52. def download_pdf(url, council_reference)
  53. return nil unless DOWNLOAD_ATTACHMENTS && !url.to_s.strip.empty?
  54. folder = File.join(DOWNLOAD_DIR, 'breakoday', safe_name(council_reference))
  55. FileUtils.mkdir_p(folder)
  56. begin
  57. res = Http.get_response(url) rescue Http.get(url)
  58. # If Http.get already gives us the body, use it directly
  59. body = res.respond_to?(:body) ? res.body : res.to_s
  60. fname = safe_name(File.basename(URI.parse(url).path))
  61. fname += ".pdf" unless fname.downcase.end_with?(".pdf")
  62. path = File.join(folder, fname)
  63. File.binwrite(path, body)
  64. puts "Saved PDF #{path}"
  65. # return web-accessible relative path if needed
  66. "/downloads/breakoday/#{safe_name(council_reference)}/#{fname}"
  67. rescue => e
  68. warn "PDF download failed for #{url}: #{e.class} #{e.message}"
  69. nil
  70. end
  71. end
  72. html = Http.get(URL)
  73. doc = Nokogiri::HTML(html)
  74. table = find_list_table(doc) || doc.at_css("table")
  75. unless table
  76. puts "No table found on #{URL}"
  77. exit 0
  78. end
  79. # Work out the column indexes by header text if possible
  80. headers = table.css("thead th").map { |th| th.text.strip.downcase }
  81. idx_name = headers.index { |h| h.include?("name") } || 0
  82. idx_addr = headers.index { |h| h.include?("address") } || 1
  83. idx_close = headers.index { |h| h.include?("closing") || h.include?("notice") } || 2
  84. idx_pdf = headers.index { |h| h.include?("pdf") } || 3
  85. rows = table.css("tbody tr")
  86. puts "Found #{rows.length} row(s) for #{TABLE}"
  87. saved = 0
  88. rows.each do |tr|
  89. tds = tr.css("td")
  90. next if tds.empty?
  91. name_text = tds[idx_name]&.text&.strip.to_s
  92. address = tds[idx_addr]&.text&.strip.to_s
  93. close_raw = tds[idx_close]&.text&.strip.to_s
  94. pdf_cell = tds[idx_pdf]
  95. pdf_a = pdf_cell&.at_css("a[href]")
  96. document_url = pdf_a ? abs_url(URL, pdf_a["href"].to_s) : ""
  97. row_text = tr.text.to_s.gsub(/\s+/, " ")
  98. raw_ref = extract_ref(pdf_cell&.text) || extract_ref(File.basename(document_url)) || extract_ref(row_text)
  99. council_reference = raw_ref&.gsub(/\s*\/\s*/, "_")&.gsub(/\s+/, "_")
  100. next if address.empty? || council_reference.nil?
  101. on_notice = Util.parse_aus_date(close_raw)
  102. description = name_text.empty? ? "Development Application" : name_text
  103. local_doc_url = download_pdf(document_url, council_reference)
  104. DB.upsert(TABLE, {
  105. description: description,
  106. address: address,
  107. council_reference: council_reference,
  108. applicant: "",
  109. owner: "",
  110. document_url: document_url,
  111. local_document_url: local_doc_url,
  112. on_notice_to: on_notice,
  113. on_notice_to_raw: close_raw,
  114. })
  115. enrich_after_upsert!(table: TABLE, council_reference: council_reference, address: address)
  116. tn = DB.client.escape(TABLE)
  117. sql = %Q{
  118. SELECT address_std, lat, lng
  119. FROM `#{tn}`
  120. WHERE council_reference = ? AND address = ?
  121. LIMIT 1
  122. }
  123. begin
  124. row = DB.client.prepare(sql).execute(council_reference, address).first
  125. puts " enriched -> #{row ? row.inspect : 'nil'}"
  126. rescue => e
  127. warn " enriched probe failed: #{e.class} #{e.message}"
  128. end
  129. puts "Upserted #{council_reference} -> #{address}"
  130. saved += 1
  131. end
  132. puts "Done #{TABLE}. Saved #{saved} item(s)."