brighton.rb 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. # Brighton Council — Advertised Development Applications (site page, not PlanBuild)
  2. require "date"
  3. require "nokogiri"
  4. require "cgi"
  5. require "fileutils"
  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 sets this from filename: da_brighton
  12. URL = "https://www.brighton.tas.gov.au/planning/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. # --- helpers ---------------------------------------------------------------
  18. # DA/APP refs like “DA2025-130”, “DA 2024/174”, etc → “DA YYYY / NNN…”
  19. REF_RX = %r{
  20. \b(?:DA|APP|APPLICATION)\s*
  21. (20\d{2})\s*[/\-]?\s*
  22. ([A-Za-z0-9\-_.]{2,})\b
  23. }ix
  24. def extract_ref_from(str)
  25. s = CGI.unescape(str.to_s)
  26. if (m = s.match(REF_RX))
  27. return "DA #{m[1]} / #{m[2]}"
  28. end
  29. # Compact like DA20250123
  30. if (m = s.match(/\bDA(20\d{2})(\d{3,})\b/i))
  31. return "DA #{m[1]} / #{m[2]}"
  32. end
  33. nil
  34. end
  35. def abs_url(base, href)
  36. return "" if href.to_s.strip.empty?
  37. URI.join(base, href).to_s
  38. rescue URI::InvalidURIError
  39. href.to_s
  40. end
  41. def safe_name(s) = s.to_s.gsub(/[^\w\-.]+/, "_")
  42. def strip_ordinals(s)
  43. s.to_s.gsub(/\b(\d{1,2})(st|nd|rd|th)\b/i, '\1')
  44. end
  45. def parse_close_date(s)
  46. Util.parse_aus_date(strip_ordinals(s.to_s))
  47. end
  48. def download_pdf(url, council_reference)
  49. return nil unless DOWNLOAD_ATTACHMENTS && !url.to_s.strip.empty?
  50. folder = File.join(DOWNLOAD_DIR, "brighton", safe_name(council_reference))
  51. FileUtils.mkdir_p(folder)
  52. begin
  53. res = Http.get_response(url) rescue Http.get(url)
  54. body = res.respond_to?(:body) ? res.body : res.to_s
  55. fname = safe_name(File.basename(URI.parse(url).path))
  56. fname += ".pdf" unless fname.downcase.end_with?(".pdf")
  57. path = File.join(folder, fname)
  58. File.binwrite(path, body)
  59. puts "Saved PDF #{path}"
  60. "/downloads/brighton/#{safe_name(council_reference)}/#{fname}"
  61. rescue => e
  62. warn "PDF download failed for #{url}: #{e.class} #{e.message}"
  63. nil
  64. end
  65. end
  66. # --- scrape ---------------------------------------------------------------
  67. html = Http.get(URL)
  68. doc = Nokogiri::HTML(html)
  69. # Find the advertised table by headers
  70. table = doc.css("table").find do |t|
  71. heads = t.css("thead th").map { |th| th.text.strip.downcase }
  72. heads.any? &&
  73. heads.any? { |h| h.include?("description") } &&
  74. heads.any? { |h| h.include?("address") } &&
  75. heads.any? { |h| h.include?("closing") || h.include?("closing date") } &&
  76. heads.any? { |h| h.include?("pdf") }
  77. end
  78. unless table
  79. puts "No advertised table found on #{URL}"
  80. exit 0
  81. end
  82. # Column indexes by header text (fallback to 0..3 if needed)
  83. heads = table.css("thead th").map { |th| th.text.strip.downcase }
  84. i_desc = heads.index { |h| h.include?("description") } || 0
  85. i_addr = heads.index { |h| h.include?("address") } || 1
  86. i_close= heads.index { |h| h.include?("closing") } || 2
  87. i_pdf = heads.index { |h| h.include?("pdf") } || 3
  88. rows = table.css("tbody tr")
  89. puts "Found #{rows.length} row(s) for #{TABLE}"
  90. saved = 0
  91. today = Date.today
  92. rows.each do |tr|
  93. tds = tr.css("td")
  94. next if tds.empty?
  95. description = tds[i_desc]&.text&.strip.to_s
  96. address = tds[i_addr]&.text&.strip.to_s
  97. close_raw = tds[i_close]&.text&.strip.to_s
  98. on_notice_to = parse_close_date(close_raw)
  99. date_received = on_notice_to && (on_notice_to - 14)
  100. # one or more PDF links
  101. pdf_links = tds[i_pdf]&.css('a[href]')&.map { |a| abs_url(URL, a["href"].to_s) } || []
  102. document_url = pdf_links.first.to_s
  103. # reference from link text or file name
  104. ref_texts = tds[i_pdf]&.css('a')&.map { |a| a.text.to_s } || []
  105. council_reference =
  106. ref_texts.map { |tx| extract_ref_from(tx) }.compact.first ||
  107. pdf_links.map { |u| extract_ref_from(File.basename(u)) }.compact.first
  108. # minimal keys
  109. next if address.empty? || council_reference.nil?
  110. DB.upsert(TABLE, {
  111. description: description.empty? ? "Development Application" : description,
  112. date_received: date_received,
  113. date_received_raw: today.strftime("%Y-%m-%d"),
  114. on_notice_to: on_notice_to, # store close date in DATE column
  115. on_notice_to_raw: strip_ordinals(close_raw),
  116. address: address,
  117. council_reference: council_reference,
  118. applicant: "",
  119. owner: ""
  120. })
  121. enrich_after_upsert!(
  122. table: TABLE,
  123. council_reference: council_reference,
  124. address: address
  125. )
  126. # remote URL
  127. begin
  128. upd = DB.client.prepare("UPDATE `#{DB.client.escape(TABLE)}` SET document_url = ? WHERE council_reference = ? AND address = ?")
  129. upd.execute(document_url, council_reference, address)
  130. rescue => e
  131. warn "document_url update skipped for #{council_reference}: #{e.class} #{e.message}"
  132. end
  133. # local copy
  134. local_doc_url = download_pdf(document_url, council_reference)
  135. if local_doc_url
  136. begin
  137. upd2 = DB.client.prepare("UPDATE `#{DB.client.escape(TABLE)}` SET local_document_url = ? WHERE council_reference = ? AND address = ?")
  138. upd2.execute(local_doc_url, council_reference, address)
  139. rescue => e
  140. warn "local_document_url update skipped for #{council_reference}: #{e.class} #{e.message}"
  141. end
  142. end
  143. puts "Upserted #{council_reference} -> #{address} (doc: #{document_url.empty? ? 0 : 1}, saved: #{local_doc_url ? 1 : 0})"
  144. saved += 1
  145. end
  146. puts "Done #{TABLE}. Saved #{saved} item(s)."