# Derwent Valley Council — Development Applications being advertised # Primary list: https://www.derwentvalley.tas.gov.au/home/card-listing/development-applications # Fallback list (Public Notice posts): https://www.derwentvalley.tas.gov.au/home/latest-news?f.News+category%7CnewsCategory=Public+Notice require "nokogiri" require_relative "../lib/scraper_helpers" require_relative "../lib/util" require_relative "../lib/log" TABLE = ENV.fetch("TABLE_NAME") # run_all.sh -> da_derwentvalley LIST_URL = "https://www.derwentvalley.tas.gov.au/home/card-listing/development-applications" NEWS_URL = "https://www.derwentvalley.tas.gov.au/home/latest-news?f.News+category%7CnewsCategory=Public+Notice" DB.ensure_table!(TABLE) # Common reference forms: "DA 2025/097" REF_RX = %r{\bDA\s*(20\d{2})\s*/\s*([A-Za-z0-9\-_.]+)}i def extract_ref(s) t = s.to_s if (m = t.match(REF_RX)) return "DA #{m[1]} / #{m[2]}" end nil end def extract_date_token(s) text = s.to_s return $1 if text =~ /(\b\d{1,2}\/\d{1,2}\/\d{2,4}\b)/ return $1 if text =~ /(\b\d{1,2}\s+[A-Za-z]{3,}\s+\d{4}\b)/ return $1 if text =~ /(\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+/, " ") # Look for wording like "Submissions must be received by ...", "close on ...", "on notice until ..." if s =~ /(submissions?|representations?)\s+(must\s+be\s+)?(received|made|close|closing)\s+(by|on)\s*[:\-]?\s*([A-Za-z0-9\/ ,]+)/i d = extract_date_token($5) 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 parse_detail(url) html = Http.get(url) doc = Nokogiri::HTML(html) title = doc.at_css("h1, .entry-title")&.text&.strip.to_s body_text = doc.at_css("main")&.text.to_s body_text = doc.text.to_s if body_text.strip.empty? council_reference = extract_ref(title) || extract_ref(body_text) # Address often sits in the title after " - " address = if title.include?(" - ") title.split(" - ", 2)[1].to_s.strip else # Fallback: first line with a number and street line = body_text.split(/\n/).find { |l| l =~ /\d{1,4}\s+\S+/ } line.to_s.strip end address = title if address.to_s.strip.empty? pdf_a = doc.at_css("a[href$='.pdf'], a[href*='.pdf?']") pdf = pdf_a ? abs_url(url, pdf_a["href"].to_s) : "" on_raw = extract_on_notice_raw(body_text) on_dt = Util.parse_aus_date(on_raw) return nil if council_reference.to_s.strip.empty? || address.to_s.strip.empty? { council_reference: council_reference, address: address, description: "Development Application", date_received_raw: on_raw, date_received: on_dt, document_url: pdf, title_reference: title } end def detail_links_from_list(list_url) html = Http.get(list_url) doc = Nokogiri::HTML(html) # Cards or list items link to detail posts links = doc.css("a").map { |a| href = a["href"].to_s next if href.empty? || href.start_with?("#") abs_url(list_url, href) }.compact.uniq # Keep obvious news or notice items links.select { |u| u.include?("/home/latest-news/") || u.include?("/news/") || u =~ /application-for-planning-approval/i } end def detail_links_from_news(news_url) html = Http.get(news_url) doc = Nokogiri::HTML(html) doc.css("a").map { |a| href = a["href"].to_s next if href.empty? || href.start_with?("#") u = abs_url(news_url, href) u if u =~ /application-for-planning-approval/i }.compact.uniq end def cloudflare_blocked?(html) html.to_s.include?("Just a moment") || html.to_s.include?("Enable JavaScript and cookies") end links = [] begin links = detail_links_from_list(LIST_URL) rescue StandardError => e Log.warn "derwentvalley", "List fetch failed, will try news listing: #{e.class} #{e.message}" end if links.empty? begin links = detail_links_from_news(NEWS_URL) rescue StandardError => e Log.warn "derwentvalley", "News fetch failed: #{e.class} #{e.message}" end end # Both URLs return a Cloudflare JS-challenge page (HTTP 200 with challenge HTML). # We can't solve this without browser-level JS execution. # Derwent Valley DAs are also published on PlanBuild (council code DER), # so planbuild.rb covers this council independently. if links.empty? begin probe = Http.get(LIST_URL) if cloudflare_blocked?(probe) Log.warn "derwentvalley", "Site is returning a Cloudflare challenge page — cannot scrape without browser-level JS execution. DAs for this council are available via planbuild.rb (council code DER)." puts "Done #{TABLE}. Saved 0 item(s) — site blocked by Cloudflare." exit 0 end rescue StandardError => e Log.warn "derwentvalley", "Probe fetch failed: #{e.class} #{e.message}" end end links.uniq! puts "Found #{links.length} candidate link(s) for #{TABLE}" saved = 0 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 upsert_and_enrich!( table: TABLE, row: { description: item[:description], date_received: item[:date_received], date_received_raw: item[:date_received_raw], address: item[:address], council_reference: item[:council_reference], applicant: "", owner: "" }, extras: { document_url: item[:document_url], on_notice_to: item[:date_received], on_notice_to_raw: item[:date_received_raw], title_reference: item[:title_reference] } ) saved += 1 end puts "Done #{TABLE}. Saved #{saved} item(s)."