# Meander Valley Council — Advertised & Approved Planning Applications # Source: https://www.meander.tas.gov.au/advertised-approved-planning-applications require "nokogiri" require "uri" require "cgi" require "date" require "json" require_relative "../lib/enrich" require_relative "../lib/log" require_relative "../lib/util" DEBUG = ENV["DEBUG"] == "1" DRY_RUN = ENV["DRY_RUN"] == "1" TABLE = ENV.fetch("TABLE_NAME") # run_all.sh -> da_meandervalley URL = "https://www.meander.tas.gov.au/advertised-approved-planning-applications" DOWNLOAD_ATTACHMENTS = ENV["DOWNLOAD_ATTACHMENTS"] == "1" DOWNLOAD_DIR = ENV["DOWNLOAD_DIR"] || "/app/downloads" DB.ensure_table!(TABLE) # Pull nearest text around an anchor for parsing def host_block_for(a) n = a.ancestors("li, p, div, article").first || a.parent n ? n.text.to_s.gsub(/\s+/, " ").strip : "" end # Extract value after a label up to the next label token def field_value(text, key_rx, stops) s = text.to_s m = s.match(/#{key_rx.source}\s*:\s*(.+)/i) return "" unless m tail = m[1] cut = tail.length stops.each do |st| i = tail.index(st) cut = [cut, i].min if i end tail[0, cut].strip end # Normalize refs like "PA\26\0010", "PA.26.0010", "PA 26/0010" to "PA 26/0010" def normalize_pa_ref(text, pdf = "") s = [text.to_s, File.basename(pdf.to_s)].join(" ") s = s.tr("\\", "/") if (m = s.match(/\bPA\s*([0-9]{2})[\/\.\s]+([0-9]{3,4})\b/i)) return "PA #{m[1]}/#{m[2]}" end nil end def extract_between(text, key) text[/#{key}\s*:\s*(.+?)\s*$/i, 1].to_s.strip end def find_pdf_in(nodes, base) if (a = nodes.css('a[href$=".pdf"], a[href*=".pdf?"]').first) u = a["href"].to_s return URI.join(base, u).to_s rescue u end "" end def block_from_anchor(start_node) out = [start_node] cur = start_node 20.times do cur = cur.next_element break if cur.nil? t = cur.text.to_s.strip break if t =~ /^\s*Application\s*:/i break if t =~ /^\s*(Approved|Refused)\b/i out << cur end out end def safe_name(s) = s.to_s.gsub(/[^\w\-.]+/, "_") # Download the PDF (if enabled) and return a web path: # /downloads/meandervalley// def download_pdf(url, council_reference) return nil if DRY_RUN return nil unless DOWNLOAD_ATTACHMENTS && !url.to_s.strip.empty? folder = File.join(DOWNLOAD_DIR, "meandervalley", safe_name(council_reference)) FileUtils.mkdir_p(folder) begin res = Http.get_response(url) rescue Http.get(url) body = res.respond_to?(:body) ? res.body : res.to_s fname = safe_name(File.basename(URI.parse(url).path)) fname += ".pdf" unless fname.downcase.end_with?(".pdf") path = File.join(folder, fname) File.binwrite(path, body) puts "Saved PDF #{path}" "/files/meandervalley/#{safe_name(council_reference)}/#{fname}" rescue StandardError => e Log.warn "scraper", "PDF download failed for #{url}: #{e.class} #{e.message}" nil end end html = Http.get(URL, referer: URL) doc = Nokogiri::HTML(html) # Items are listed inline under the "Advertised" section with lines like: # Application: Applicant: ... Property: ... Proposal: ... Closes: items = [] # Looser, case-insensitive match for the advertised PDFs all_anchors = doc.css('a[href]') if DEBUG puts "[DEBUG] anchors total=#{all_anchors.size}" end pdf_anchors = all_anchors.select do |a| href = a["href"].to_s hd = href.downcase hd.include?("/planning-applications/advertised/") && hd.include?(".pdf") end puts "[DEBUG] matched advertised pdf anchors=#{pdf_anchors.size}" if DEBUG pdf_anchors.each do |a| href = a["href"].to_s pdf = begin URI.join(URL, href).to_s rescue URI::InvalidURIError href end blk_text = host_block_for(a) # Try to normalize reference from nearby text or the file name ref = normalize_pa_ref(blk_text, pdf) ref ||= a.text.to_s.gsub(/\s+/, " ")[0, 100] # backstop so it never overflows address = field_value(blk_text, /Property/, [/Proposal/i, /Closes/i, /Application/i]) desc = field_value(blk_text, /Proposal/, [/Closes/i, /Application/i]) close_r = field_value(blk_text, /Closes/, [/Application/i]) address = address[0, 255] desc = desc.empty? ? "Development Application" : desc[0, 1000] items << { council_reference: ref, address: address, description: desc, on_notice_raw: close_r, on_notice: Util.parse_aus_date(close_r), pdf: pdf, title_reference: a.text.to_s.strip } end items.uniq! { |r| [r[:council_reference], r[:address]] } puts "Found #{items.length} item(s) for #{TABLE}" items.each_with_index do |r, idx| if DEBUG puts "[DEBUG] ##{idx+1} "\ "ref=#{r[:council_reference].inspect} (#{r[:council_reference].to_s.length}) | "\ "addr=#{r[:address].inspect} (#{r[:address].to_s.length}) | "\ "desc.len=#{r[:description].to_s.length} | "\ "date_raw=#{r[:on_notice_raw].inspect} (#{r[:on_notice_raw].to_s.length}) | "\ "pdf=#{r[:pdf].to_s[0,120]}" # If you want to see the full source text that was parsed: # puts JSON.pretty_generate(raw: r[:_raw].to_s[0,2000]) end if DRY_RUN next end #items.each do |r| ref = r[:council_reference].to_s.tr("\\", "/")[0, 100] addr = r[:address].to_s[0, 255] desc = r[:description].to_s[0, 1000] date_received = Date.today DB.upsert(TABLE, { description: r[:description], date_received: date_received, on_notice_to: r[:on_notice], # store close date for consistency on_notice_to_raw: r[:on_notice_raw], address: r[:address], council_reference: r[:council_reference], applicant: "", owner: "" }) enrich_after_upsert!( table: TABLE, council_reference: r[:council_reference], address: r[:address] ) # Download & set local_document_url local_doc_url = download_pdf(r[:pdf], r[:council_reference]) begin upd = DB.client.prepare( "UPDATE `#{DB.client.escape(TABLE)}` SET " \ " document_url = ?, " \ " local_document_url = COALESCE(?, local_document_url), " \ " on_notice_to = ?, on_notice_to_raw = ?, title_reference = ? " \ "WHERE council_reference = ? AND address = ?" ) upd.execute( r[:pdf], local_doc_url, r[:on_notice], r[:on_notice_raw], r[:title_reference], r[:council_reference], r[:address] ) rescue StandardError => e Log.warn "scraper", "Extras update skipped for #{r[:council_reference]}: #{e.class} #{e.message}" end puts "Upserted #{r[:council_reference]} -> #{r[:address]} saved: #{local_doc_url ? 1 : 0}" end puts "Done #{TABLE}."