Module: Html2rss::AutoSource::Scraper::JsonState::DocumentScanner
- Defined in:
- lib/html2rss/auto_source/scraper/json_state/document_scanner.rb
Overview
Scans DOM nodes for JSON payloads containing article data.
Constant Summary collapse
- GLOBAL_ASSIGNMENT_PATTERNS =
Regex patterns for known global JavaScript state assignments.
[ /(?:window|self|globalThis)\.__NEXT_DATA__\s*=\s*/m, /(?:window|self|globalThis)\.__NUXT__\s*=\s*/m, /(?:window|self|globalThis)\.STATE\s*=\s*/m, /(?:window|self|globalThis)\.__REDUX_STATE__\s*=\s*/m, /(?:window|self|globalThis)\.__PRELOADED_STATE__\s*=\s*/m, /(?:window|self|globalThis)\.__APOLLO_STATE__\s*=\s*/m, /(?:window|self|globalThis)\.__remixContext\s*=\s*/m, /(?:window|self|globalThis)\.__sveltekit_data\s*=\s*/m, /(?:window|self|globalThis)\.GATSBY_STATE\s*=\s*/m, /(?:window|self|globalThis)\.__ember_meta\s*=\s*/m, /(?:window|self|globalThis)\.angular\s*=\s*/m ].freeze
- GLOBAL_ASSIGNMENT_REGEXP =
Combined regex for faster matching of global assignments.
Regexp.union(GLOBAL_ASSIGNMENT_PATTERNS).freeze
Class Method Summary collapse
-
.assignment_documents(parsed_body) ⇒ Array<Hash, Array>
JSON documents extracted from global assignments.
-
.assignment_payload(text) ⇒ String?
Extracted JSON-like assignment payload.
-
.coerce_javascript_object(payload) ⇒ String
JSON-compatible payload string.
-
.extract_assignment_payload(text) ⇒ String?
Normalized assignment payload.
-
.extract_json_block(text) ⇒ String?
Extracted JSON block spanning balanced brackets.
-
.json_documents(parsed_body) ⇒ Array<Hash, Array>
Parsed JSON documents discovered in scripts.
-
.parse_assignment(text) ⇒ Hash, ...
Parsed assignment payload when available.
-
.parse_js_object(payload, _original_error) ⇒ Hash, ...
Parsed payload after JavaScript coercion.
-
.parse_json(payload) ⇒ Hash, ...
Parsed payload or nil when parsing fails.
-
.quote_unquoted_keys(jsonish) ⇒ String
Payload with unquoted object keys quoted.
-
.scan_for_json_end(text, start_index) ⇒ Integer?
Index where the balanced JSON payload ends.
-
.script_documents(parsed_body) ⇒ Array<Hash, Array>
JSON documents extracted from JSON script tags.
-
.strip_trailing_commas(jsonish) ⇒ String
Payload without trailing commas before closing tokens.
Class Method Details
.assignment_documents(parsed_body) ⇒ Array<Hash, Array>
Returns JSON documents extracted from global assignments.
50 51 52 |
# File 'lib/html2rss/auto_source/scraper/json_state/document_scanner.rb', line 50 def assignment_documents(parsed_body) parsed_body.css('script').filter_map { parse_assignment(_1.text) } end |
.assignment_payload(text) ⇒ String?
Returns extracted JSON-like assignment payload.
63 64 65 66 67 68 69 70 |
# File 'lib/html2rss/auto_source/scraper/json_state/document_scanner.rb', line 63 def assignment_payload(text) trimmed = text.to_s.strip return if trimmed.empty? return unless trimmed.match?(GLOBAL_ASSIGNMENT_REGEXP) payload = trimmed.sub(GLOBAL_ASSIGNMENT_REGEXP, '') extract_assignment_payload(payload) end |
.coerce_javascript_object(payload) ⇒ String
Returns JSON-compatible payload string.
152 153 154 155 156 157 |
# File 'lib/html2rss/auto_source/scraper/json_state/document_scanner.rb', line 152 def coerce_javascript_object(payload) string = payload.dup # KISS approach: mutate common JS literal quirks instead of a full parser. strip_trailing_commas(quote_unquoted_keys(string)) end |
.extract_assignment_payload(text) ⇒ String?
Returns normalized assignment payload.
74 75 76 |
# File 'lib/html2rss/auto_source/scraper/json_state/document_scanner.rb', line 74 def extract_assignment_payload(text) extract_json_block(text) || text end |
.extract_json_block(text) ⇒ String?
Returns extracted JSON block spanning balanced brackets.
80 81 82 83 84 85 86 |
# File 'lib/html2rss/auto_source/scraper/json_state/document_scanner.rb', line 80 def extract_json_block(text) start_index = text.index(/[\[{]/) return unless start_index stop_index = scan_for_json_end(text, start_index) text[start_index..stop_index] if stop_index end |
.json_documents(parsed_body) ⇒ Array<Hash, Array>
Returns parsed JSON documents discovered in scripts.
33 34 35 36 37 38 39 |
# File 'lib/html2rss/auto_source/scraper/json_state/document_scanner.rb', line 33 def json_documents(parsed_body) # Use identity-based cache to avoid double-parsing of the same document. # WeakMap allows the Nokogiri Document (key) to be garbage collected. # rubocop:disable-next ThreadSafety/ClassInstanceVariable (@cache ||= ObjectSpace::WeakMap.new)[parsed_body] ||= script_documents(parsed_body) + assignment_documents(parsed_body) end |
.parse_assignment(text) ⇒ Hash, ...
Returns parsed assignment payload when available.
56 57 58 59 |
# File 'lib/html2rss/auto_source/scraper/json_state/document_scanner.rb', line 56 def parse_assignment(text) payload = assignment_payload(text) parse_json(payload) if payload end |
.parse_js_object(payload, _original_error) ⇒ Hash, ...
Returns parsed payload after JavaScript coercion.
138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/html2rss/auto_source/scraper/json_state/document_scanner.rb', line 138 def parse_js_object(payload, _original_error) coerced = coerce_javascript_object(payload) return unless coerced # Some sites emit JavaScript object literals (unquoted keys, trailing commas). # Coerce those payloads into valid JSON so we keep the same parsing pipeline. JSON.parse(coerced, symbolize_names: true) rescue JSON::ParserError => error Html2rss::Log.debug("JsonState: failed to parse coerced JavaScript object (#{error.message})") nil end |
.parse_json(payload) ⇒ Hash, ...
Returns parsed payload or nil when parsing fails.
127 128 129 130 131 132 133 |
# File 'lib/html2rss/auto_source/scraper/json_state/document_scanner.rb', line 127 def parse_json(payload) return unless payload JSON.parse(payload, symbolize_names: true) rescue JSON::ParserError => error parse_js_object(payload, error) end |
.quote_unquoted_keys(jsonish) ⇒ String
Returns payload with unquoted object keys quoted.
161 162 163 164 165 166 |
# File 'lib/html2rss/auto_source/scraper/json_state/document_scanner.rb', line 161 def quote_unquoted_keys(jsonish) jsonish.gsub(/(?<prefix>\A\s*|[{,\[]\s*)(?<key>[A-Za-z_]\w*)(?<suffix>\s*:)/) do captures = Regexp.last_match.named_captures(symbolize_names: true) "#{captures[:prefix]}\"#{captures[:key]}\"#{captures[:suffix]}" end end |
.scan_for_json_end(text, start_index) ⇒ Integer?
Returns index where the balanced JSON payload ends.
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
# File 'lib/html2rss/auto_source/scraper/json_state/document_scanner.rb', line 91 def scan_for_json_end(text, start_index) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity stack = [] in_string = false escape = false i = start_index len = text.length while i < len char = text[i] if in_string if escape escape = false elsif char == '\\' escape = true elsif char == '"' in_string = false end else case char when '"' then in_string = true when '{' then stack << '}' when '[' then stack << ']' when '}', ']' expected = stack.pop return i if expected == char && stack.empty? end end i += 1 end nil end |
.script_documents(parsed_body) ⇒ Array<Hash, Array>
Returns JSON documents extracted from JSON script tags.
43 44 45 46 |
# File 'lib/html2rss/auto_source/scraper/json_state/document_scanner.rb', line 43 def script_documents(parsed_body) ::Html2rss::Html::Probe.scripts(parsed_body, ::Html2rss::Html::Probe::APPLICATION_JSON) .filter_map { parse_json(_1.text) } end |
.strip_trailing_commas(jsonish) ⇒ String
Returns payload without trailing commas before closing tokens.
170 171 172 |
# File 'lib/html2rss/auto_source/scraper/json_state/document_scanner.rb', line 170 def strip_trailing_commas(jsonish) jsonish.gsub(/,(\s*[\]}])/, '\1') end |