| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178 |
- # 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 <address>" 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
- next if href.empty? || href.start_with?("#")
- u = abs_url(URL, href)
- u.include?("/development-application/")
- }.compact
- # Convert booleans from map to urls properly
- 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)."
|