westcoast.rb 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. # West Coast Council — Advertised Development Applications
  2. # Source list: https://www.westcoast.tas.gov.au/planning-and-development/planning/advertised-development-applications/
  3. require "date"
  4. require "nokogiri"
  5. require "cgi"
  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_westcoast
  12. URL = "https://www.westcoast.tas.gov.au/planning-and-development/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. 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 DA2025-26, DA 2025/26, DA2025/026, DA 26-2025
  22. REF_RXES = [
  23. %r{\bDA\s*(20\d{2})\s*[/\-]\s*([A-Za-z0-9\-_.]{1,})\b}i, # DA 2025/26 or DA2025-26
  24. %r{\bDA\s*(20\d{2})([A-Za-z0-9]{3,})\b}i, # DA2025012
  25. %r{\bDA\s*([0-9]{1,4})\s*-\s*(20\d{2})\b}i # DA 26-2025
  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*(20") && m[2] # hyphen reversed
  33. return "DA #{m[2]} / #{m[1]}"
  34. elsif rx.source.include?("(20\\d{2})([A-Za-z0-9]{3,})")
  35. return "DA #{m[1]} / #{m[2]}"
  36. else
  37. return "DA #{m[1]} / #{m[2]}"
  38. end
  39. end
  40. end
  41. nil
  42. end
  43. def extract_date_token(str)
  44. s = str.to_s
  45. return $1 if s =~ /(\b\d{1,2}\/\d{1,2}\/\d{2,4}\b)/
  46. return $1 if s =~ /(\b\d{1,2}\s+[A-Za-z]{3,}\s+\d{4}\b)/
  47. return $1 if s =~ /(\b[A-Za-z]{3,}\s+\d{1,2},?\s+\d{4}\b)/
  48. ""
  49. end
  50. def extract_on_notice_raw(text)
  51. s = text.to_s.gsub(/\s+/, " ")
  52. # West Coast detail pages usually say: "Representations must be received by 5pm on Monday 1 September 2025"
  53. if s =~ /representations? .*? (received|made) .*? by .*?([A-Za-z0-9\/ ,]+)/i
  54. d = extract_date_token($2)
  55. return d unless d.empty?
  56. end
  57. if s =~ /\bon\s*notice\s*(until|to)\s*[:\-]?\s*([A-Za-z0-9\/ ,]+)/i
  58. d = extract_date_token($2)
  59. return d unless d.empty?
  60. end
  61. extract_date_token(s)
  62. end
  63. def nearest_ctx(a)
  64. host = a.ancestors("article, li, p, div").first || a.parent
  65. host ? host.text.to_s.strip.gsub(/\s+/, " ") : ""
  66. end
  67. def parse_detail(url)
  68. html = Http.get(url)
  69. doc = Nokogiri::HTML(html)
  70. title_reference = doc.at_css("h1, .entry-title")&.text&.strip.to_s
  71. page_text = doc.text.to_s.gsub(/\s+/, " ")
  72. # Reference from title or page body
  73. council_reference = extract_ref(title_reference) || extract_ref(page_text)
  74. # Address from title after ":" if present, else from first " at <address>" phrase
  75. address = if title_reference.include?(":")
  76. title_reference.split(":", 2)[1].to_s.strip
  77. elsif (m = page_text.match(/\bat\s+([A-Z0-9].+?)(?:\.\s|, Representations| In accordance|$)/i))
  78. m[1].strip
  79. else
  80. title_reference
  81. end
  82. address = address[0, 140] if address.length > 140
  83. # Description often appears near the top: "Residential – Outbuilding", etc
  84. description = if (m = page_text.match(/(Residential|Commercial|Industrial|Subdivision|Outbuilding|Dwelling|Multiple Dwelling|Alterations|Additions|Use|Development)\s*[–-]\s*([A-Za-z ]+)/i))
  85. [m[1], m[2]].join(" - ").strip
  86. else
  87. "Development Application"
  88. end
  89. # On-notice
  90. on_notice_raw = extract_on_notice_raw(page_text)
  91. on_notice = Util.parse_aus_date(on_notice_raw)
  92. # First PDF on the page
  93. pdf = doc.at_css("a[href$='.pdf'], a[href*='.pdf?']")&.[]("href")
  94. document_url = pdf ? abs_url(url, pdf) : ""
  95. return nil if council_reference.to_s.strip.empty? || address.to_s.strip.empty?
  96. {
  97. council_reference: council_reference,
  98. address: address,
  99. description: description,
  100. on_notice: on_notice,
  101. on_notice_raw: on_notice_raw,
  102. document_url: document_url,
  103. title_reference: title_reference
  104. }
  105. end
  106. # 1) Open the list page and find links to individual Development Application posts
  107. list_html = Http.get(URL)
  108. list_doc = Nokogiri::HTML(list_html)
  109. detail_links = list_doc.css("a").map { |a|
  110. href = a["href"].to_s
  111. next if href.empty? || href.start_with?("#")
  112. u = abs_url(URL, href)
  113. u.include?("/development-application/")
  114. }.compact
  115. # Convert booleans from map to urls properly
  116. detail_links = list_doc.css("a").map { |a|
  117. href = a["href"].to_s
  118. u = abs_url(URL, href)
  119. u if u.include?("/development-application/")
  120. }.compact.uniq
  121. puts "Found #{detail_links.size} candidate link(s) for #{TABLE}"
  122. saved = 0
  123. date_received = Date.today
  124. detail_links.each do |u|
  125. begin
  126. item = parse_detail(u)
  127. rescue => e
  128. warn "Skip #{u}: #{e.class} #{e.message}"
  129. next
  130. end
  131. next unless item
  132. DB.upsert(TABLE, {
  133. description: item[:description],
  134. date_received: date_received,
  135. date_received_raw: date_received,
  136. on_notice: item[:date_received], # store close date here to be consistent
  137. on_notice_raw: item[:date_received_raw],
  138. address: item[:address],
  139. council_reference: item[:council_reference],
  140. applicant: "",
  141. owner: ""
  142. })
  143. enrich_after_upsert!(
  144. table: TABLE,
  145. council_reference: item[:council_reference],
  146. address: address
  147. )
  148. begin
  149. upd = DB.client.prepare("UPDATE `#{DB.client.escape(TABLE)}` SET document_url = ?, on_notice_to = ?, on_notice_to_raw = ?, title_reference = ? WHERE council_reference = ? AND address = ?")
  150. upd.execute(item[:document_url], item[:date_received], item[:date_received_raw], item[:title_reference], item[:council_reference], item[:address])
  151. rescue => e
  152. warn "Extras update skipped for #{item[:council_reference]}: #{e.class} #{e.message}"
  153. end
  154. puts "Upserted #{item[:council_reference]} -> #{item[:address]}"
  155. saved += 1
  156. end
  157. puts "Done #{TABLE}. Saved #{saved} item(s)."