module AuspostScrape
AuspostScrape — scrape Australia Post public tracking via HTTP fast-path or headless browser. Requires Chromium/Chrome on the bot host (Ferrum) when HTTP is blocked. Guest JSON is fetched from digitalapi shipments-gateway/v1 (or intercepted from the tracking page network traffic). Public: scrape, parse_payload, parse_dom, solve_challenges, challenge_detected?, track_url, tracking_api_url?, fetch_via_http, blocked_response? Depends: BrowserHuman, AuspostTrack, RabbotHttp Tests: test/lib/auspost_scrape_test.rb
Constants
- CHALLENGE_BODY_PATTERN
- CHALLENGE_MAX_WAIT_SEC
- CHALLENGE_POLL_SEC
- CHALLENGE_SELECTORS
- CHALLENGE_TITLE_PATTERN
- GUEST_API_HEADERS
- GUEST_API_HOST
- GUEST_API_KEY
- GUEST_API_PATH_SEGMENT
- GUEST_API_URL
- TRACK_URL
- USER_AGENT
- WAIT_TIMEOUT_SEC
Public Instance Methods
Source
# File lib/auspost_scrape.rb, line 59 def blocked_response?(payload) payload.is_a?(Hash) && payload["url"].to_s.include?("captcha-delivery.com") end
Source
# File lib/auspost_scrape.rb, line 602 def challenge_click_target(page) CHALLENGE_SELECTORS.each do |selector| node = page.at_css(selector) next unless node box = node.box next unless box return { x: box["x"].to_f + (box["width"].to_f / 2.0), y: box["y"].to_f + (box["height"].to_f / 2.0) } end nil rescue StandardError nil end
Source
# File lib/auspost_scrape.rb, line 569 def challenge_detected?(page) title = page.title.to_s body = page.body.to_s return true if title.match?(CHALLENGE_TITLE_PATTERN) return true if body.match?(CHALLENGE_BODY_PATTERN) CHALLENGE_SELECTORS.any? { |selector| page.at_css(selector) } rescue StandardError false end
Source
# File lib/auspost_scrape.rb, line 319 def consignment_payload?(payload) payload.is_a?(Hash) && (payload.key?("articles") || payload.key?("consignmentId")) end
Source
# File lib/auspost_scrape.rb, line 671 def default_browser Ferrum::Browser.new( headless: true, window_size: [1280, 720], browser_options: { "disable-blink-features" => "AutomationControlled", "lang" => "en-AU" }, timeout: WAIT_TIMEOUT_SEC ) end
Source
# File lib/auspost_scrape.rb, line 515 def extract_guest_events(payload) Array(payload["trackingEvents"]).map do |event| { date: event["eventDateTime"] || event["date"], location: event["eventLocation"] || event["location"], description: event["eventDescription"] || event["description"] } end end
Source
# File lib/auspost_scrape.rb, line 84 def fetch_via_http(tracking_id, fetch: nil) fetch ||= RabbotHttp.method(:fetch) url = format(GUEST_API_URL, tracking_id) headers = GUEST_API_HEADERS.merge( "Referer" => track_url(tracking_id), "Origin" => "https://auspost.com.au" ) body = fetch.call(url, user_agent: USER_AGENT, headers: headers) return nil if body.nil? || body.to_s.strip.empty? payload = JSON.parse(body.to_s) return nil if blocked_response?(payload) payload rescue JSON::ParserError, OpenURI::HTTPError, SocketError, Errno::ECONNREFUSED, StandardError nil end
Source
# File lib/auspost_scrape.rb, line 201 def gateway_consignment_attempt(payload, tracking_id:) unless consignment_payload?(payload) return { step: step("gateway_v1", :skip, "not a consignment hash"), result: nil } end status = resolve_gateway_status(payload) latest_raw = payload["latestEvent"] || payload["latest_event"] if status.empty? reason = if latest_raw.nil? || latest_raw.to_s.strip.empty? "empty status; no latestEvent" else "empty status" end return { step: step("gateway_v1", :fail, reason), result: nil } end result = parse_gateway_consignment_payload(payload, tracking_id: tracking_id) if result && !result[:error] return { step: step("gateway_v1", :ok, "consignment status + latestEvent"), result: result } end { step: step("gateway_v1", :fail, "consignment without parseable latestEvent"), result: nil } end
Source
# File lib/auspost_scrape.rb, line 167 def gateway_v1_attempt(payload, tracking_id:) return { step: step("gateway_v1", :skip, "not a hash"), result: nil } unless payload.is_a?(Hash) errors = Array(payload["errors"]) if errors.any? message = errors.first["message"].to_s message = errors.first["name"].to_s if message.empty? return { step: step("gateway_v1", :fail, "API error — #{message}"), result: { tracking_id: tracking_id, error: AuspostTrack.user_error_message(message) } } end articles = Array(payload["articles"]) if articles.empty? return gateway_consignment_attempt(payload, tracking_id: tracking_id) end status = resolve_gateway_status(payload) if status.empty? return { step: step("gateway_v1", :fail, "empty status from articles"), result: nil } end result = parse_gateway_v1_payload(payload, tracking_id: tracking_id) if result && !result[:error] return { step: step("gateway_v1", :ok, "status + events"), result: result } end { step: step("gateway_v1", :fail, "articles present but no events"), result: nil } end
Source
# File lib/auspost_scrape.rb, line 225 def guest_attempt(payload, tracking_id:) return { step: step("guest", :skip, "not a hash"), result: nil } unless payload.is_a?(Hash) errors = Array(payload["errors"]) if errors.any? message = errors.first["message"].to_s message = errors.first["name"].to_s if message.empty? return { step: step("guest", :fail, "API error — #{message}"), result: { tracking_id: tracking_id, error: AuspostTrack.user_error_message(message) } } end if payload.key?("shipment") || payload.key?("trackingIds") return { step: step("guest", :skip, "has shipment/trackingIds wrapper"), result: nil } end status = payload["status"].to_s status = payload["statusMessage"].to_s if status.empty? if http_status_like?(status) return { step: step("guest", :skip, "HTTP status only (#{status})"), result: nil } end unless payload.key?("trackingEvents") || payload.key?("trackingId") || !status.empty? return { step: step("guest", :fail, "no trackingEvents or status"), result: nil } end result = parse_guest_payload(payload, tracking_id: tracking_id) if result && !result[:error] return { step: step("guest", :ok, "tracking events found"), result: result } end { step: step("guest", :fail, "no parseable guest events"), result: nil } end
Source
# File lib/auspost_scrape.rb, line 656 def guest_response_body(page, tracking_id) exchange = select_tracking_exchange(page.network.traffic.reverse, tracking_id) return nil unless exchange&.response&.loaded? body = exchange.response.body return nil if body.to_s.strip.empty? payload = JSON.parse(body.to_s) return nil if blocked_response?(payload) body rescue JSON::ParserError, StandardError nil end
Source
# File lib/auspost_scrape.rb, line 359 def http_status_like?(status) status.to_s.match?(/\A\d{3}\z/) end
Source
# File lib/auspost_scrape.rb, line 355 def http_status_ok?(status) status.to_i == 200 end
Source
# File lib/auspost_scrape.rb, line 525 def latest_gateway_event(events) return nil if events.empty? normalized = events.map { |event| symbolize_gateway_event(event) } latest = normalized.max_by { |event| AuspostTrack.event_time(event) || Time.at(0) } AuspostTrack.symbolize_event(latest) end
Source
# File lib/auspost_scrape.rb, line 541 def latest_guest_event(events) return nil if events.empty? latest = events.max_by { |event| AuspostTrack.event_time(event) || Time.at(0) } AuspostTrack.symbolize_event(latest) end
Source
# File lib/auspost_scrape.rb, line 260 def merchant_attempt(payload, tracking_id:) unless payload.is_a?(Hash) && payload.key?("tracking_results") return { step: step("merchant", :skip, "no tracking_results"), result: nil } end row = Array(payload["tracking_results"]).find { |entry| entry["tracking_id"].to_s == tracking_id.to_s } unless row return { step: step("merchant", :fail, "no matching tracking_results row"), result: nil } end errors = Array(row["errors"]) if errors.any? message = errors.first["message"].to_s message = errors.first["name"].to_s if message.empty? return { step: step("merchant", :fail, "row error — #{message}"), result: { tracking_id: tracking_id, error: AuspostTrack.user_error_message(message) } } end result = parse_merchant_payload(payload, tracking_id: tracking_id) if result && !result[:error] return { step: step("merchant", :ok, "tracking_results row matched"), result: result } end { step: step("merchant", :fail, "row matched but no events"), result: nil } end
Source
# File lib/auspost_scrape.rb, line 427 def merge_gateway_article(article) return article unless article.is_a?(Hash) detail = Array(article["details"]).first return article unless detail.is_a?(Hash) && !detail.empty? article.merge(detail) end
Source
# File lib/auspost_scrape.rb, line 288 def normalize_gateway_payload(payload, tracking_id:) id = tracking_id.to_s return payload if payload.nil? if payload.is_a?(Array) unwrapped = pick_from_query_array(payload, tracking_id: id) return unwrapped if unwrapped picked = pick_matching_consignment(payload, tracking_id: id) return picked if picked return payload end return payload unless payload.is_a?(Hash) %w[shipment data result].each do |key| nested = payload[key] next unless nested.is_a?(Hash) && consignment_payload?(nested) return nested end if payload["shipments"].is_a?(Array) picked = pick_matching_consignment(payload["shipments"], tracking_id: id) return picked if picked end payload end
Source
# File lib/auspost_scrape.rb, line 149 def normalize_step(raw, normalized) if raw == normalized keys = payload_key_summary(raw) step("normalize", :skip, keys.empty? ? "no wrapper" : "keys: #{keys}") else step("normalize", :ok, "unwrapped #{payload_key_summary(raw)}") end end
Source
# File lib/auspost_scrape.rb, line 107 def parse_attempts(payload, tracking_id:) steps = [] id = tracking_id.to_s if blocked_response?(payload) steps << step("blocked", :fail, "DataDome captcha URL in payload") return result_bundle(steps, tracking_id: id, error: "Could not fetch tracking details") end normalized = normalize_gateway_payload(payload, tracking_id: id) steps << normalize_step(payload, normalized) if blocked_response?(normalized) steps << step("blocked", :fail, "DataDome captcha URL after unwrap") return result_bundle(steps, tracking_id: id, error: "Could not fetch tracking details") end gateway = gateway_v1_attempt(normalized, tracking_id: id) steps << gateway[:step] return result_bundle(steps, tracking_id: id, result: gateway[:result]) if gateway[:result] guest = guest_attempt(normalized, tracking_id: id) steps << guest[:step] return result_bundle(steps, tracking_id: id, result: guest[:result]) if guest[:result] merchant = merchant_attempt(normalized, tracking_id: id) steps << merchant[:step] return result_bundle(steps, tracking_id: id, result: merchant[:result]) if merchant[:result] result_bundle(steps, tracking_id: id, error: "Tracking result not found") end
Source
# File lib/auspost_scrape.rb, line 548 def parse_dom(html, tracking_id:) doc = Nokogiri::HTML(html.to_s) status = doc.at_css(".tracking-status, [data-tracking-status]")&.text.to_s.strip event_node = doc.at_css(".tracking-event, [data-tracking-event]") return { tracking_id: tracking_id, error: "Tracking result not found" } if status.empty? || event_node.nil? event = { description: event_node.at_css(".event-description, [data-event-description]")&.text.to_s.strip, location: event_node.at_css(".event-location, [data-event-location]")&.text.to_s.strip, date: event_node.at_css("time")&.[]("datetime").to_s.strip } { tracking_id: tracking_id, status: status, latest_event: AuspostTrack.symbolize_event(event), last_event_key: AuspostTrack.event_key(event) } rescue StandardError { tracking_id: tracking_id, error: "Could not parse tracking page" } end
Source
# File lib/auspost_scrape.rb, line 411 def parse_gateway_consignment_payload(payload, tracking_id:) status = resolve_gateway_status(payload) return nil if status.empty? latest_raw = payload["latestEvent"] || payload["latest_event"] return nil unless latest_raw.is_a?(Hash) && !latest_raw.empty? latest_event = latest_gateway_event([latest_raw]) { tracking_id: tracking_id, status: status, latest_event: latest_event, last_event_key: latest_event ? AuspostTrack.event_key(latest_event) : nil } end
Source
# File lib/auspost_scrape.rb, line 384 def parse_gateway_v1_payload(payload, tracking_id:) return nil unless payload.is_a?(Hash) errors = Array(payload["errors"]) if errors.any? message = errors.first["message"].to_s message = errors.first["name"].to_s if message.empty? return { tracking_id: tracking_id, error: AuspostTrack.user_error_message(message) } end articles = Array(payload["articles"]) return parse_gateway_consignment_payload(payload, tracking_id: tracking_id) if articles.empty? status = resolve_gateway_status(payload) return nil if status.empty? events = articles.map { |article| merge_gateway_article(article) } .flat_map { |article| Array(article["events"]) } latest_event = latest_gateway_event(events) { tracking_id: tracking_id, status: status, latest_event: latest_event, last_event_key: latest_event ? AuspostTrack.event_key(latest_event) : nil } end
Source
# File lib/auspost_scrape.rb, line 482 def parse_guest_payload(payload, tracking_id:) return nil unless payload.is_a?(Hash) errors = Array(payload["errors"]) if errors.any? message = errors.first["message"].to_s message = errors.first["name"].to_s if message.empty? return { tracking_id: tracking_id, error: AuspostTrack.user_error_message(message) } end return nil if payload.key?("shipment") || payload.key?("trackingIds") events = extract_guest_events(payload) status = payload["status"].to_s status = payload["statusMessage"].to_s if status.empty? return nil if http_status_like?(status) return nil unless payload.key?("trackingEvents") || payload.key?("trackingId") || !status.empty? latest_event = latest_guest_event(events) { tracking_id: tracking_id, status: status, latest_event: latest_event, last_event_key: latest_event ? AuspostTrack.event_key(latest_event) : nil } end
Source
# File lib/auspost_scrape.rb, line 509 def parse_merchant_payload(payload, tracking_id:) return nil unless payload.is_a?(Hash) && payload.key?("tracking_results") AuspostTrack.parse_merchant_payload(payload, tracking_id: tracking_id) end
Source
# File lib/auspost_scrape.rb, line 102 def parse_payload(payload, tracking_id:) attempts = parse_attempts(payload, tracking_id: tracking_id) attempts[:result] end
Source
# File lib/auspost_scrape.rb, line 158 def payload_key_summary(payload) return "nil" if payload.nil? return "#{payload.length} entries" if payload.is_a?(Array) return payload.keys.map(&:to_s).join(", ") if payload.is_a?(Hash) payload.class.name end
Source
# File lib/auspost_scrape.rb, line 323 def pick_from_query_array(array, tracking_id:) id = tracking_id.to_s entries = array.select { |entry| entry.is_a?(Hash) } return nil if entries.empty? matching = entries.select do |entry| ids = Array(entry["trackingIds"]).map(&:to_s) + Array(entry["trackingId"]).map(&:to_s) ids.empty? || ids.include?(id) end candidates = matching.empty? ? entries : matching entry = candidates.find { |row| http_status_ok?(row["status"]) } || candidates.first return nil unless entry.is_a?(Hash) shipment = entry["shipment"] return shipment if shipment.is_a?(Hash) && consignment_payload?(shipment) return entry if consignment_payload?(entry) nil end
Source
# File lib/auspost_scrape.rb, line 344 def pick_matching_consignment(shipments, tracking_id:) id = tracking_id.to_s Array(shipments).find do |entry| next false unless entry.is_a?(Hash) consignment_id = entry["consignmentId"].to_s article_ids = Array(entry["articles"]).map { |article| article["articleId"].to_s } consignment_id == id || article_ids.include?(id) end end
Source
# File lib/auspost_scrape.rb, line 448 def resolve_article_status(article) merged = merge_gateway_article(article) return "" unless merged.is_a?(Hash) events = Array(merged["events"]) if events.any? && events.first.is_a?(Hash) milestone = resolve_event_milestone(events.first) return AuspostTrack.normalize_status(milestone) unless milestone.empty? end milestones = Array(merged["milestones"]) if milestones.any? && milestones.first.is_a?(Hash) name = milestones.first["name"].to_s return AuspostTrack.normalize_status(name) unless name.empty? end track_status = merged["trackStatusOfArticle"].to_s return AuspostTrack.normalize_status(track_status) unless track_status.empty? status = merged["status"].to_s return AuspostTrack.normalize_status(status) unless status.empty? "" end
Source
# File lib/auspost_scrape.rb, line 473 def resolve_event_milestone(event) return "" unless event.is_a?(Hash) milestone = event["milestone"] return milestone["name"].to_s if milestone.is_a?(Hash) milestone.to_s end
Source
# File lib/auspost_scrape.rb, line 436 def resolve_gateway_status(payload) top = payload.dig("milestone", "name").to_s return AuspostTrack.normalize_status(top) unless top.empty? Array(payload["articles"]).each do |article| status = resolve_article_status(article) return status unless status.empty? end "" end
Source
# File lib/auspost_scrape.rb, line 143 def result_bundle(steps, tracking_id:, result: nil, error: nil) entry = result || { tracking_id: tracking_id } entry = entry.merge(tracking_id: tracking_id, error: error) if error { steps: steps, result: entry } end
Source
# File lib/auspost_scrape.rb, line 63 def scrape(tracking_id, scrape: nil, browser_factory: nil, fetch: nil) id = AuspostTrack.normalize_tracking_id(tracking_id) return { tracking_id: tracking_id, error: "Invalid tracking ID" } unless id raw = if scrape scrape.call(id) else fetch_via_http(id, fetch: fetch) || scrape_with_browser(id, browser_factory: browser_factory) end return { tracking_id: id, error: "Could not fetch tracking details" } if raw.nil? || raw.to_s.strip.empty? return raw.merge(tracking_id: id) if raw.is_a?(Hash) && raw[:latest_event] payload = raw.is_a?(Hash) ? raw : JSON.parse(raw.to_s) parse_payload(payload, tracking_id: id) rescue JSON::ParserError { tracking_id: id, error: "Could not parse tracking response" } rescue StandardError { tracking_id: id, error: "Could not fetch tracking details" } end
Source
# File lib/auspost_scrape.rb, line 621 def scrape_with_browser(tracking_id, browser_factory: nil) require "ferrum" browser = browser_factory ? browser_factory.call : default_browser page = browser.create_page page.headers.set( "User-Agent" => USER_AGENT, "Accept-Language" => "en-AU,en;q=0.9" ) page.go_to(track_url(tracking_id)) solve_challenges(page) body = wait_for_guest_payload(page, tracking_id) return body if body dom = parse_dom(page.body, tracking_id: tracking_id) dom unless dom[:error] ensure browser&.quit end
Source
# File lib/auspost_scrape.rb, line 363 def select_tracking_exchange(traffic, tracking_id) id = tracking_id.to_s matching = Array(traffic).select do |entry| url = entry&.response&.url url && tracking_api_url?(url, id) && entry.response&.loaded? end return nil if matching.empty? detail_path = "/watchlist/shipments/#{id}" detail = matching.find do |entry| url = entry.response.url.to_s url.include?(detail_path) && !url.include?("trackingIds=") end return detail if detail query = matching.find { |entry| entry.response.url.to_s.include?("trackingIds=#{id}") } return query if query matching.first end
Source
# File lib/auspost_scrape.rb, line 580 def solve_challenges(page, mouse: nil, sleep_fn: method(:sleep)) return false unless challenge_detected?(page) pointer = mouse || page.mouse viewport = page.viewport || {} width = viewport[:width] || viewport["width"] || 1280 height = viewport[:height] || viewport["height"] || 720 BrowserHuman.idle_wander(pointer, width: width, height: height, sleep_fn: sleep_fn) deadline = Time.now + CHALLENGE_MAX_WAIT_SEC while Time.now < deadline target = challenge_click_target(page) if target BrowserHuman.natural_click(pointer, x: target[:x], y: target[:y], sleep_fn: sleep_fn) end sleep_fn.call(CHALLENGE_POLL_SEC) return true unless challenge_detected?(page) end false end
Source
# File lib/auspost_scrape.rb, line 139 def step(parser, outcome, reason) { parser: parser, outcome: outcome, reason: reason } end
Source
# File lib/auspost_scrape.rb, line 533 def symbolize_gateway_event(event) { date: event["dateTime"] || event["localeDateTime"] || event["date"], location: event["location"], description: event["description"] } end
Source
# File lib/auspost_scrape.rb, line 47 def track_url(tracking_id) format(TRACK_URL, tracking_id.to_s) end
Source
# File lib/auspost_scrape.rb, line 51 def tracking_api_url?(url, tracking_id) text = url.to_s return false unless text.include?(GUEST_API_HOST) && text.include?(GUEST_API_PATH_SEGMENT) id = tracking_id.to_s text.include?(id) || text.include?("trackingIds=#{id}") end
Source
# File lib/auspost_scrape.rb, line 640 def wait_for_guest_payload(page, tracking_id) deadline = Time.now + WAIT_TIMEOUT_SEC while Time.now < deadline body = guest_response_body(page, tracking_id) return body if body if challenge_detected?(page) solve_challenges(page) else sleep(CHALLENGE_POLL_SEC) end end nil end