# West Coast Council — Advertised Development Applications # Source list: https://www.westcoast.tas.gov.au/planning-and-development/planning/advertised-development-applications/ require "date" require "nokogiri" require "cgi" require_relative "../lib/enrich" require_relative "../lib/log" require_relative "../lib/util" TABLE = ENV.fetch("TABLE_NAME") # run_all.sh -> da_westcoast URL = "https://www.westcoast.tas.gov.au/planning-and-development/planning/advertised-development-applications/" DOWNLOAD_ATTACHMENTS = ENV["DOWNLOAD_ATTACHMENTS"] == "1" DOWNLOAD_DIR = ENV["DOWNLOAD_DIR"] || "/app/downloads" DB.ensure_table!(TABLE) def abs_url(base, href) return "" if href.to_s.strip.empty? URI.join(base, href).to_s rescue href.to_s end # Accepts DA2025-26, DA 2025/26, DA2025/026, DA 26-2025 REF_RXES = [ %r{\bDA\s*(20\d{2})\s*[/\-]\s*([A-Za-z0-9\-_.]{1,})\b}i, # DA 2025/26 or DA2025-26 %r{\bDA\s*(20\d{2})([A-Za-z0-9]{3,})\b}i, # DA2025012 %r{\bDA\s*([0-9]{1,4})\s*-\s*(20\d{2})\b}i # DA 26-2025 ] def extract_ref(str) s = CGI.unescape(str.to_s) REF_RXES.each do |rx| if (m = s.match(rx)) # Normalize to "DA YYYY / NNN" if rx.source.include?("\\s*\\-\\s*(20") && m[2] # hyphen reversed return "DA #{m[2]} / #{m[1]}" elsif rx.source.include?("(20\\d{2})([A-Za-z0-9]{3,})") return "DA #{m[1]} / #{m[2]}" else return "DA #{m[1]} / #{m[2]}" end end end nil end def extract_date_token(str) s = str.to_s return $1 if s =~ /(\b\d{1,2}\/\d{1,2}\/\d{2,4}\b)/ return $1 if s =~ /(\b\d{1,2}\s+[A-Za-z]{3,}\s+\d{4}\b)/ return $1 if s =~ /(\b[A-Za-z]{3,}\s+\d{1,2},?\s+\d{4}\b)/ "" end def extract_on_notice_raw(text) s = text.to_s.gsub(/\s+/, " ") # West Coast detail pages usually say: "Representations must be received by 5pm on Monday 1 September 2025" if s =~ /representations? .*? (received|made) .*? by .*?([A-Za-z0-9\/ ,]+)/i d = extract_date_token($2) return d unless d.empty? end if s =~ /\bon\s*notice\s*(until|to)\s*[:\-]?\s*([A-Za-z0-9\/ ,]+)/i d = extract_date_token($2) return d unless d.empty? end extract_date_token(s) end def nearest_ctx(a) host = a.ancestors("article, li, p, div").first || a.parent host ? host.text.to_s.strip.gsub(/\s+/, " ") : "" end def parse_detail(url) html = Http.get(url) doc = Nokogiri::HTML(html) title_reference = doc.at_css("h1, .entry-title")&.text&.strip.to_s page_text = doc.text.to_s.gsub(/\s+/, " ") # Reference from title or page body council_reference = extract_ref(title_reference) || extract_ref(page_text) # Address from title after ":" if present, else from first " at
" phrase address = if title_reference.include?(":") title_reference.split(":", 2)[1].to_s.strip elsif (m = page_text.match(/\bat\s+([A-Z0-9].+?)(?:\.\s|, Representations| In accordance|$)/i)) m[1].strip else title_reference end address = address[0, 140] if address.length > 140 # Description often appears near the top: "Residential – Outbuilding", etc description = if (m = page_text.match(/(Residential|Commercial|Industrial|Subdivision|Outbuilding|Dwelling|Multiple Dwelling|Alterations|Additions|Use|Development)\s*[–-]\s*([A-Za-z ]+)/i)) [m[1], m[2]].join(" - ").strip else "Development Application" end # On-notice on_notice_raw = extract_on_notice_raw(page_text) on_notice = Util.parse_aus_date(on_notice_raw) # First PDF on the page pdf = doc.at_css("a[href$='.pdf'], a[href*='.pdf?']")&.[]("href") document_url = pdf ? abs_url(url, pdf) : "" return nil if council_reference.to_s.strip.empty? || address.to_s.strip.empty? { council_reference: council_reference, address: address, description: description, on_notice: on_notice, on_notice_raw: on_notice_raw, document_url: document_url, title_reference: title_reference } end # 1) Open the list page and find links to individual Development Application posts list_html = Http.get(URL) list_doc = Nokogiri::HTML(list_html) detail_links = list_doc.css("a").map { |a| href = a["href"].to_s u = abs_url(URL, href) u if u.include?("/development-application/") }.compact.uniq puts "Found #{detail_links.size} candidate link(s) for #{TABLE}" saved = 0 date_received = Date.today detail_links.each do |u| begin item = parse_detail(u) rescue StandardError => e Log.warn "scraper", "Skip #{u}: #{e.class} #{e.message}" next end next unless item DB.upsert(TABLE, { description: item[:description], date_received: date_received, date_received_raw: date_received.to_s, on_notice_to: item[:on_notice], on_notice_to_raw: item[:on_notice_raw], address: item[:address], council_reference: item[:council_reference], document_url: item[:document_url], title_reference: item[:title_reference], applicant: "", owner: "" }) enrich_after_upsert!( table: TABLE, council_reference: item[:council_reference], address: item[:address] ) puts "Upserted #{item[:council_reference]} -> #{item[:address]}" saved += 1 end puts "Done #{TABLE}. Saved #{saved} item(s)."