westtamar.rb 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. # West Tamar Council — Advertised Planning Applications
  2. require "nokogiri"
  3. require_relative "../lib/http"
  4. require_relative "../lib/db"
  5. require_relative "../lib/util"
  6. require_relative "../lib/enrich"
  7. TABLE = ENV.fetch("TABLE_NAME") # run_all.sh -> da_westtamar
  8. URL = "https://www.wtc.tas.gov.au/advertised-planning-applications/"
  9. DB.ensure_table!(TABLE)
  10. begin
  11. DB.client.query("ALTER TABLE `#{DB.client.escape(TABLE)}` ADD COLUMN IF NOT EXISTS document_url TEXT NULL")
  12. DB.client.query("ALTER TABLE `#{DB.client.escape(TABLE)}` ADD COLUMN IF NOT EXISTS on_notice_to DATE NULL")
  13. DB.client.query("ALTER TABLE `#{DB.client.escape(TABLE)}` ADD COLUMN IF NOT EXISTS on_notice_to_raw VARCHAR(80) NULL")
  14. DB.client.query("ALTER TABLE `#{DB.client.escape(TABLE)}` ADD COLUMN IF NOT EXISTS title_reference TEXT NULL")
  15. rescue => e
  16. warn "Optional column add skipped: #{e.class} #{e.message}"
  17. end
  18. def abs_url(base, href)
  19. return "" if href.to_s.strip.empty?
  20. URI.join(base, href).to_s rescue href.to_s
  21. end
  22. REF_RX_SLASH = %r{\bDA\s*(20\d{2})\s*/\s*([A-Za-z0-9\-_.]+)}i
  23. REF_RX_HYPHEN = %r{\bDA\s*(\d{1,4})\s*-\s*(20\d{2})\b}i
  24. def extract_ref(text)
  25. s = text.to_s
  26. if (m = s.match(REF_RX_SLASH))
  27. return "DA #{m[1]} / #{m[2]}"
  28. end
  29. if (m = s.match(REF_RX_HYPHEN))
  30. return "DA #{m[2]} / #{m[1]}"
  31. end
  32. if (m = s.match(/\bDA(20\d{2})(\d{3,})\b/i))
  33. return "DA #{m[1]} / #{m[2]}"
  34. end
  35. nil
  36. end
  37. def extract_date_like(str)
  38. s = str.to_s
  39. return $1 if s =~ /(\b\d{1,2}\/\d{1,2}\/\d{2,4}\b)/
  40. return $1 if s =~ /(\b\d{1,2}\s+[A-Za-z]{3,}\s+\d{4}\b)/
  41. return $1 if s =~ /(\b[A-Za-z]{3,}\s+\d{1,2},?\s+\d{4}\b)/
  42. ""
  43. end
  44. def extract_on_notice_raw(text)
  45. s = text.to_s.gsub(/\s+/, " ")
  46. if s =~ /\bon\s*notice\s*(until|to)\s*[:\-]?\s*([A-Za-z0-9\/ ,]+)/i
  47. d = extract_date_like($2)
  48. return d unless d.empty?
  49. end
  50. if s =~ /clos(?:e|ing|es)\s*(on)?\s*[:\-]?\s*([A-Za-z0-9\/ ,]+)/i
  51. d = extract_date_like($2)
  52. return d unless d.empty?
  53. end
  54. extract_date_like(s)
  55. end
  56. def parse_detail(url)
  57. html = Http.get(url)
  58. doc = Nokogiri::HTML(html)
  59. # Try two-column detail table first
  60. kv = {}
  61. doc.css("table tr").each do |tr|
  62. cells = tr.css("th, td")
  63. next unless cells.length >= 2
  64. key = cells[0].text.strip
  65. val = cells[1].text.strip
  66. kv[key] = val unless key.empty?
  67. end
  68. find = ->(rx) { kv.find { |k,_| k =~ rx }&.last.to_s.strip }
  69. council_reference = find.call(/(Application\s*(No|Number|ID)|Reference)/i)
  70. address = find.call(/(Address|Location|Property)/i)
  71. description = find.call(/(Proposal|Description)/i)
  72. on_notice_raw = find.call(/(On\s*Notice\s*(until|to)|Closing\s*Date|Closes)/i)
  73. on_notice = Util.parse_aus_date(on_notice_raw)
  74. title_reference = doc.at_css("h1, .entry-title")&.text&.strip.to_s
  75. # Fallbacks from page text if labels are missing
  76. if council_reference.empty?
  77. council_reference = extract_ref(title_reference) || extract_ref(doc.text)
  78. end
  79. address = title_reference if address.empty?
  80. description = "Development Application" if description.to_s.strip.empty?
  81. if on_notice.nil?
  82. guess = extract_on_notice_raw(doc.text)
  83. on_notice = Util.parse_aus_date(guess)
  84. on_notice_raw = guess if on_notice
  85. end
  86. pdf = doc.at_css("a[href$='.pdf'], a[href*='.pdf?']")&.[]("href")
  87. document_url = pdf ? abs_url(url, pdf) : ""
  88. return nil if council_reference.empty? || address.empty?
  89. {
  90. council_reference: council_reference,
  91. address: address,
  92. description: description,
  93. date_received: on_notice,
  94. date_received_raw: on_notice_raw.to_s,
  95. document_url: document_url,
  96. title_reference: title_reference
  97. }
  98. end
  99. list_html = Http.get(URL)
  100. list_doc = Nokogiri::HTML(list_html)
  101. detail_links = list_doc.css("article h2 a, .entry-content a").map { |a|
  102. href = a["href"].to_s
  103. next if href.strip.empty? || href.start_with?("#")
  104. abs_url(URL, href)
  105. }.compact.uniq
  106. puts "Found #{detail_links.size} candidate link(s) for #{TABLE}"
  107. saved = 0
  108. detail_links.each do |u|
  109. begin
  110. item = parse_detail(u)
  111. rescue => e
  112. warn "Skip #{u}: #{e.class} #{e.message}"
  113. next
  114. end
  115. next unless item
  116. DB.upsert(TABLE, {
  117. description: item[:description],
  118. date_received: item[:date_received],
  119. date_received_raw: item[:date_received_raw],
  120. address: item[:address],
  121. council_reference: item[:council_reference],
  122. applicant: "",
  123. owner: ""
  124. })
  125. enrich_after_upsert!(
  126. table: TABLE,
  127. council_reference: council_reference,
  128. address: address
  129. )
  130. begin
  131. 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 = ?")
  132. upd.execute(item[:document_url], item[:date_received], item[:date_received_raw], item[:title_reference], item[:council_reference], item[:address])
  133. rescue Mysql2::Error => e
  134. warn "[westtamar] db update skipped for #{item[:council_reference]}: #{e.message}"
  135. end
  136. puts "Upserted #{item[:council_reference]} -> #{item[:address]}"
  137. saved += 1
  138. end
  139. puts "Done #{TABLE}. Saved #{saved} item(s)."