derwentvalley.rb 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. # Derwent Valley Council — Development Applications being advertised
  2. # Primary list: https://www.derwentvalley.tas.gov.au/home/card-listing/development-applications
  3. # Fallback list (Public Notice posts): https://www.derwentvalley.tas.gov.au/home/latest-news?f.News+category%7CnewsCategory=Public+Notice
  4. require "nokogiri"
  5. require_relative "../lib/http"
  6. require_relative "../lib/db"
  7. require_relative "../lib/util"
  8. require_relative "../lib/enrich"
  9. TABLE = ENV.fetch("TABLE_NAME") # run_all.sh -> da_derwentvalley
  10. LIST_URL = "https://www.derwentvalley.tas.gov.au/home/card-listing/development-applications"
  11. NEWS_URL = "https://www.derwentvalley.tas.gov.au/home/latest-news?f.News+category%7CnewsCategory=Public+Notice"
  12. DB.ensure_table!(TABLE)
  13. begin
  14. DB.client.query("ALTER TABLE `#{DB.client.escape(TABLE)}` ADD COLUMN IF NOT EXISTS document_url TEXT NULL")
  15. DB.client.query("ALTER TABLE `#{DB.client.escape(TABLE)}` ADD COLUMN IF NOT EXISTS on_notice_to DATE NULL")
  16. DB.client.query("ALTER TABLE `#{DB.client.escape(TABLE)}` ADD COLUMN IF NOT EXISTS on_notice_to_raw VARCHAR(80) NULL")
  17. DB.client.query("ALTER TABLE `#{DB.client.escape(TABLE)}` ADD COLUMN IF NOT EXISTS title_reference TEXT NULL")
  18. rescue => e
  19. warn "Optional column add skipped: #{e.class} #{e.message}"
  20. end
  21. def abs_url(base, href)
  22. return "" if href.to_s.strip.empty?
  23. URI.join(base, href).to_s rescue href.to_s
  24. end
  25. # Common reference forms: "DA 2025/097"
  26. REF_RX = %r{\bDA\s*(20\d{2})\s*/\s*([A-Za-z0-9\-_.]+)}i
  27. def extract_ref(s)
  28. t = s.to_s
  29. if (m = t.match(REF_RX))
  30. return "DA #{m[1]} / #{m[2]}"
  31. end
  32. nil
  33. end
  34. def extract_date_token(s)
  35. text = s.to_s
  36. return $1 if text =~ /(\b\d{1,2}\/\d{1,2}\/\d{2,4}\b)/
  37. return $1 if text =~ /(\b\d{1,2}\s+[A-Za-z]{3,}\s+\d{4}\b)/
  38. return $1 if text =~ /(\b[A-Za-z]{3,}\s+\d{1,2},?\s+\d{4}\b)/
  39. ""
  40. end
  41. def extract_on_notice_raw(text)
  42. s = text.to_s.gsub(/\s+/, " ")
  43. # Look for wording like "Submissions must be received by ...", "close on ...", "on notice until ..."
  44. if s =~ /(submissions?|representations?)\s+(must\s+be\s+)?(received|made|close|closing)\s+(by|on)\s*[:\-]?\s*([A-Za-z0-9\/ ,]+)/i
  45. d = extract_date_token($5)
  46. return d unless d.empty?
  47. end
  48. if s =~ /\bon\s*notice\s*(until|to)\s*[:\-]?\s*([A-Za-z0-9\/ ,]+)/i
  49. d = extract_date_token($2)
  50. return d unless d.empty?
  51. end
  52. extract_date_token(s)
  53. end
  54. def parse_detail(url)
  55. html = Http.get(url)
  56. doc = Nokogiri::HTML(html)
  57. title = doc.at_css("h1, .entry-title")&.text&.strip.to_s
  58. body_text = doc.at_css("main")&.text.to_s
  59. body_text = doc.text.to_s if body_text.strip.empty?
  60. council_reference = extract_ref(title) || extract_ref(body_text)
  61. # Address often sits in the title after " - "
  62. address = if title.include?(" - ")
  63. title.split(" - ", 2)[1].to_s.strip
  64. else
  65. # Fallback: first line with a number and street
  66. line = body_text.split(/\n/).find { |l| l =~ /\d{1,4}\s+\S+/ }
  67. line.to_s.strip
  68. end
  69. address = title if address.to_s.strip.empty?
  70. pdf_a = doc.at_css("a[href$='.pdf'], a[href*='.pdf?']")
  71. pdf = pdf_a ? abs_url(url, pdf_a["href"].to_s) : ""
  72. on_raw = extract_on_notice_raw(body_text)
  73. on_dt = Util.parse_aus_date(on_raw)
  74. return nil if council_reference.to_s.strip.empty? || address.to_s.strip.empty?
  75. {
  76. council_reference: council_reference,
  77. address: address,
  78. description: "Development Application",
  79. date_received_raw: on_raw,
  80. date_received: on_dt,
  81. document_url: pdf,
  82. title_reference: title
  83. }
  84. end
  85. def detail_links_from_list(list_url)
  86. html = Http.get(list_url)
  87. doc = Nokogiri::HTML(html)
  88. # Cards or list items link to detail posts
  89. links = doc.css("a").map { |a|
  90. href = a["href"].to_s
  91. next if href.empty? || href.start_with?("#")
  92. abs_url(list_url, href)
  93. }.compact.uniq
  94. # Keep obvious news or notice items
  95. links.select { |u|
  96. u.include?("/home/latest-news/") || u.include?("/news/") || u =~ /application-for-planning-approval/i
  97. }
  98. end
  99. def detail_links_from_news(news_url)
  100. html = Http.get(news_url)
  101. doc = Nokogiri::HTML(html)
  102. doc.css("a").map { |a|
  103. href = a["href"].to_s
  104. next if href.empty? || href.start_with?("#")
  105. u = abs_url(news_url, href)
  106. u if u =~ /application-for-planning-approval/i
  107. }.compact.uniq
  108. end
  109. links = []
  110. begin
  111. links = detail_links_from_list(LIST_URL)
  112. rescue => e
  113. warn "List fetch failed, will try news listing: #{e.class} #{e.message}"
  114. end
  115. if links.empty?
  116. begin
  117. links = detail_links_from_news(NEWS_URL)
  118. rescue => e
  119. warn "News fetch failed: #{e.class} #{e.message}"
  120. end
  121. end
  122. links.uniq!
  123. puts "Found #{links.length} candidate link(s) for #{TABLE}"
  124. saved = 0
  125. links.each do |u|
  126. begin
  127. item = parse_detail(u)
  128. rescue => e
  129. warn "Skip #{u}: #{e.class} #{e.message}"
  130. next
  131. end
  132. next unless item
  133. DB.upsert(TABLE, {
  134. description: item[:description],
  135. date_received: item[:date_received],
  136. date_received_raw: item[:date_received_raw],
  137. address: item[:address],
  138. council_reference: item[:council_reference],
  139. applicant: "",
  140. owner: ""
  141. })
  142. enrich_after_upsert!(
  143. table: TABLE,
  144. council_reference: council_reference,
  145. address: address
  146. )
  147. begin
  148. 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 = ?")
  149. upd.execute(item[:document_url], item[:date_received], item[:date_received_raw], item[:title_reference], item[:council_reference], item[:address])
  150. rescue => e
  151. warn "Extras update skipped for #{item[:council_reference]}: #{e.class} #{e.message}"
  152. end
  153. puts "Upserted #{item[:council_reference]} -> #{item[:address]}"
  154. saved += 1
  155. end
  156. puts "Done #{TABLE}. Saved #{saved} item(s)."