001package org.jsoup.parser; 002 003import org.jsoup.Connection; 004import org.jsoup.helper.Validate; 005import org.jsoup.nodes.Document; 006import org.jsoup.nodes.Element; 007import org.jsoup.nodes.Node; 008import org.jsoup.select.Evaluator; 009import org.jsoup.select.EvaluatorInternals; 010import org.jsoup.select.NodeVisitor; 011import org.jsoup.select.Selector; 012import org.jspecify.annotations.Nullable; 013 014import java.io.Closeable; 015import java.io.IOException; 016import java.io.Reader; 017import java.io.StringReader; 018import java.io.UncheckedIOException; 019import java.util.HashSet; 020import java.util.Iterator; 021import java.util.LinkedList; 022import java.util.List; 023import java.util.NoSuchElementException; 024import java.util.Queue; 025import java.util.Spliterator; 026import java.util.Spliterators; 027import java.util.stream.Stream; 028import java.util.stream.StreamSupport; 029 030/** 031 A StreamParser provides a progressive parse of its input. As each Element is completed, it is emitted via a Stream or 032 Iterator interface. Elements returned will be complete with all their children, and an (empty) next sibling, if 033 applicable. 034 <p>To conserve memory, you can {@link Node#remove() remove()} Elements (or their children) from the DOM during the 035 parse. This provides a mechanism to parse an input document that would otherwise be too large to fit into memory, yet 036 still providing a DOM interface to the document and its elements.</p> 037 <p> 038 Additionally, the parser provides a {@link #selectFirst(String query)} / {@link #selectNext(String query)}, which will 039 run the parser until a hit is found, at which point the parse is suspended. It can be resumed via another 040 {@code select()} call, or via the {@link #stream()} or {@link #iterator()} methods. 041 </p> 042 <p>Once the input has been fully read, the input Reader will be closed. Or, if the whole document does not need to be 043 read, call {@link #stop()} and {@link #close()}.</p> 044 <p>The {@link #document()} method will return the Document being parsed into, which will be only partially complete 045 until the input is fully consumed.</p> 046 <p>A StreamParser can be reused via a new {@link #parse(Reader, String)}, but is not thread-safe for concurrent inputs. 047 New parsers should be used in each thread.</p> 048 <p>If created via {@link Connection.Response#streamParser()}, or another Reader that is I/O backed, the iterator and 049 stream consumers will throw an {@link java.io.UncheckedIOException} if the underlying Reader errors during read.</p> 050 <p>For examples, see the jsoup 051 <a href="https://jsoup.org/cookbook/input/streamparser-dom-sax">StreamParser cookbook.</a></p> 052 <p> 053 Selectors that depend on knowing all siblings (e.g. {@code :last-child}, {@code :last-of-type}, {@code :nth-last-child}, 054 {@code :only-child} and their negations) cannot be correctly evaluated while streaming, because the parser does not know 055 if a later sibling will appear. For those cases, run {@link #complete()} first to finish the parse (which is effectively 056 the same as using {@code Jsoup.parse(...)} unless you have already removed nodes during streaming). 057 </p> 058 @since 1.18.1 */ 059public class StreamParser implements Closeable { 060 final private Parser parser; 061 final TreeBuilder treeBuilder; 062 final private ElementIterator it = new ElementIterator(); 063 @Nullable private Document document; 064 private boolean stopped = true; 065 066 /** 067 Construct a new StreamParser, using the supplied base Parser. 068 The supplied parser provides configuration and collects parse errors; streaming uses an independent tree builder. 069 @param parser the configured base parser 070 */ 071 public StreamParser(Parser parser) { 072 this.parser = parser; 073 treeBuilder = parser.getTreeBuilder().newInstance(); 074 treeBuilder.nodeListener(it); 075 } 076 077 /** 078 Provide the input for a Document parse. The input is not read until a consuming operation is called. 079 @param input the input to be read. 080 @param baseUri the URL of this input, for absolute link resolution 081 @return this parser, for chaining 082 */ 083 public StreamParser parse(Reader input, String baseUri) { 084 close(); // probably a no-op, but ensures any previous reader is closed 085 it.reset(); 086 treeBuilder.initialiseParse(input, baseUri, parser); // reader is not read, so no chance of IO error 087 document = treeBuilder.doc; 088 return this; 089 } 090 091 /** 092 Provide the input for a Document parse. The input is not read until a consuming operation is called. 093 @param input the input to be read 094 @param baseUri the URL of this input, for absolute link resolution 095 @return this parser 096 */ 097 public StreamParser parse(String input, String baseUri) { 098 return parse(new StringReader(input), baseUri); 099 } 100 101 /** 102 Provide the input for a fragment parse. The input is not read until a consuming operation is called. 103 @param input the input to be read 104 @param context the optional fragment context element 105 @param baseUri the URL of this input, for absolute link resolution 106 @return this parser 107 @see #completeFragment() 108 */ 109 public StreamParser parseFragment(Reader input, @Nullable Element context, String baseUri) { 110 parse(input, baseUri); 111 treeBuilder.initialiseParseFragment(context); 112 return this; 113 } 114 115 /** 116 Provide the input for a fragment parse. The input is not read until a consuming operation is called. 117 @param input the input to be read 118 @param context the optional fragment context element 119 @param baseUri the URL of this input, for absolute link resolution 120 @return this parser 121 @see #completeFragment() 122 */ 123 public StreamParser parseFragment(String input, @Nullable Element context, String baseUri) { 124 return parseFragment(new StringReader(input), context, baseUri); 125 } 126 127 /** 128 Creates a {@link Stream} of {@link Element}s, with the input being parsed as each element is consumed. Each 129 Element returned will be complete (that is, all of its children will be included, and if it has a next sibling, that 130 (empty) sibling will exist at {@link Element#nextElementSibling()}). The stream will be emitted in document order as 131 each element is closed. That means that child elements will be returned prior to their parents. 132 <p>The stream will start from the current position of the backing iterator and the parse.</p> 133 <p>When consuming the stream, if the Reader that the Parser is reading throws an I/O exception (for example a 134 SocketTimeoutException), that will be emitted as an {@link UncheckedIOException}</p> 135 @return a stream of Element objects 136 @throws UncheckedIOException if the underlying Reader excepts during a read (in stream consuming methods) 137 */ 138 public Stream<Element> stream() { 139 return StreamSupport.stream( 140 Spliterators.spliteratorUnknownSize( 141 it, Spliterator.DISTINCT | Spliterator.NONNULL | Spliterator.ORDERED), 142 false); 143 } 144 145 /** 146 Returns an {@link Iterator} of {@link Element}s, with the input being parsed as each element is consumed. Each 147 Element returned will be complete (that is, all of its children will be included, and if it has a next sibling, that 148 (empty) sibling will exist at {@link Element#nextElementSibling()}). The elements will be emitted in document order as 149 each element is closed. That means that child elements will be returned prior to their parents. 150 <p>The iterator will start from the current position of the parse.</p> 151 <p>The iterator is backed by this StreamParser, and the resources it holds.</p> 152 @return a stream of Element objects 153 */ 154 public Iterator<Element> iterator() { 155 //noinspection ReturnOfInnerClass 156 return it; 157 } 158 159 /** 160 Flags that the parse should be stopped; the backing iterator will not return any more Elements. 161 @return this parser 162 */ 163 public StreamParser stop() { 164 stopped = true; 165 return this; 166 } 167 168 /** 169 Closes the input and releases resources including the underlying parser and reader. 170 <p>The parser will also be closed when the input is fully read.</p> 171 <p>The parser can be reused with another call to {@link #parse(Reader, String)}.</p> 172 */ 173 @Override public void close() { 174 it.deferOpenElements(); 175 stopped = true; 176 treeBuilder.closeParse(); // closes the reader, frees resources 177 } 178 179 /** 180 Get the current {@link Document} as it is being parsed. It will be only partially complete until the input is fully 181 read. Structural changes (e.g. insert, remove) may be made to the Document contents. 182 @return the (partial) Document 183 */ 184 public Document document() { 185 Validate.notNull(document, "Must run parse() before calling."); 186 return document; 187 } 188 189 /** 190 Runs the parser until the input is fully read, and returns the completed Document. 191 @return the completed Document 192 @throws IOException if an I/O error occurs 193 */ 194 public Document complete() throws IOException { 195 Document doc = document(); 196 treeBuilder.runParser(); 197 return doc; 198 } 199 200 /** 201 When initialized as a fragment parse, runs the parser until the input is fully read, and returns the completed 202 fragment child nodes. 203 @return the completed child nodes 204 @throws IOException if an I/O error occurs 205 @see #parseFragment(Reader, Element, String) 206 */ 207 public List<Node> completeFragment() throws IOException { 208 treeBuilder.runParser(); 209 return treeBuilder.completeParseFragment(); 210 } 211 212 /** 213 Finds the first Element that matches the provided query. If the parsed Document does not already have a match, the 214 input will be parsed until the first match is ready for stream emission, or the input is completely read. 215 @param query the {@link org.jsoup.select.Selector} query. 216 @return the first matching {@link Element}, or {@code null} if there's no match 217 @throws IOException if an I/O error occurs 218 @see #selectFirst(Evaluator) 219 */ 220 public @Nullable Element selectFirst(String query) throws IOException { 221 return selectFirst(Selector.evaluatorOf(query)); 222 } 223 224 /** 225 Just like {@link #selectFirst(String)}, but if there is no match, throws an {@link IllegalArgumentException}. This 226 is useful if you want to simply abort processing on a failed match. 227 @param query the {@link org.jsoup.select.Selector} query. 228 @return the first matching element 229 @throws IllegalArgumentException if no match is found 230 @throws IOException if an I/O error occurs 231 */ 232 public Element expectFirst(String query) throws IOException { 233 return Validate.expectNotNull( 234 selectFirst(query), 235 "No elements matched the query '%s' in the document." 236 , query 237 ); 238 } 239 240 /** 241 Finds the first Element that matches the provided query. If the parsed Document does not already have a match, the 242 input will be parsed until the first match is ready for stream emission, or the input is completely read. 243 <p>By providing a compiled evaluator vs. a CSS selector, this method may be more efficient when executing the same 244 query against multiple documents.</p> 245 @param eval the {@link org.jsoup.select.Selector} evaluator. 246 @return the first matching {@link Element}, or {@code null} if there's no match 247 @throws IOException if an I/O error occurs 248 @see Selector#evaluatorOf(String css) 249 */ 250 public @Nullable Element selectFirst(Evaluator eval) throws IOException { 251 final Document doc = document(); 252 253 // a ready match can be returned without advancing the iterator 254 Element first = doc.selectFirst(eval); 255 if (first != null && it.isPending(first)) { 256 if (!it.awaitReady(first)) return null; 257 first = doc.selectFirst(eval); 258 } 259 if (first != null && !it.isPending(first)) return first; 260 261 // use the iterator for linear progress 262 Element emitted = selectNext(eval); 263 if (emitted == null) { 264 if (!treeBuilder.isComplete()) return null; 265 return doc.selectFirst(eval); 266 } 267 268 // reconcile the iterator's child-first emission order with document order 269 while ((first = doc.selectFirst(eval)) != null && it.isPending(first)) { 270 if (!it.awaitReady(first)) return null; 271 } 272 return first != null ? first : emitted; 273 } 274 275 /** 276 Finds the next Element that matches the provided query. The input will be parsed until the next match is found, or 277 the input is completely read. 278 @param query the {@link org.jsoup.select.Selector} query. 279 @return the next matching {@link Element}, or {@code null} if there's no match 280 @throws IOException if an I/O error occurs 281 @see #selectNext(Evaluator) 282 */ 283 public @Nullable Element selectNext(String query) throws IOException { 284 return selectNext(Selector.evaluatorOf(query)); 285 } 286 287 /** 288 Just like {@link #selectFirst(String)}, but if there is no match, throws an {@link IllegalArgumentException}. This 289 is useful if you want to simply abort processing on a failed match. 290 @param query the {@link org.jsoup.select.Selector} query. 291 @return the first matching element 292 @throws IllegalArgumentException if no match is found 293 @throws IOException if an I/O error occurs 294 */ 295 public Element expectNext(String query) throws IOException { 296 return Validate.expectNotNull( 297 selectNext(query), 298 "No elements matched the query '%s' in the document." 299 , query 300 ); 301 } 302 303 /** 304 Finds the next Element that matches the provided query. The input will be parsed until the next match is found, or 305 the input is completely read. 306 <p>By providing a compiled evaluator vs a CSS selector, this method may be more efficient when executing the same 307 query against multiple documents.</p> 308 @param eval the {@link org.jsoup.select.Selector} evaluator. 309 @return the next matching {@link Element}, or {@code null} if there's no match 310 @throws IOException if an I/O error occurs 311 @see Selector#evaluatorOf(String css) 312 */ 313 public @Nullable Element selectNext(Evaluator eval) throws IOException { 314 try { 315 final Document doc = document(); // validates the parse was initialized, keeps stack trace out of stream 316 return EvaluatorInternals.stream(eval, doc, stream()) 317 .findFirst() 318 .orElse(null); 319 } catch (UncheckedIOException e) { 320 // Reader threw an IO exception emitted via Iterator's next() 321 throw e.getCause(); 322 } 323 } 324 325 final class ElementIterator implements Iterator<Element>, NodeVisitor { 326 final private Queue<Element> emitQueue = new LinkedList<>(); // ready elements waiting for iterator delivery, in emission order 327 final private HashSet<Element> deferredEls = new HashSet<>(); // off-stack elements not yet ready for emission 328 private @Nullable Element current; // most recently emitted 329 private @Nullable Element next; // element waiting to be picked up 330 private @Nullable Element tail; // The last tailed element (</html>), on hold for final pop 331 332 void reset() { 333 emitQueue.clear(); 334 deferredEls.clear(); 335 current = next = tail = null; 336 stopped = false; 337 } 338 339 /** Defers open Elements before an early close releases the tree-builder stack. */ 340 void deferOpenElements() { 341 treeBuilder.copyOpenElementsTo(deferredEls); 342 } 343 344 /** Tests whether an Element has reached its iterator emission point. */ 345 boolean isReady(Element element) { 346 return current == element || next == element || emitQueue.contains(element); 347 } 348 349 /** Tests whether an Element still needs parser advancement before selection. */ 350 boolean isPending(Element element) { 351 // the document is pending until EOF; other pending elements are open or explicitly deferred 352 return !treeBuilder.isComplete() && 353 (element == treeBuilder.doc || treeBuilder.isOpen(element) || deferredEls.contains(element)); 354 } 355 356 /** Advances parsing until an Element is ready; returns false if parsing was stopped. */ 357 boolean awaitReady(Element element) throws IOException { 358 try { 359 while (isPending(element) && !stopped) { 360 if (!hasNext()) break; 361 if (isPending(element)) next(); // hasNext may have buffered the target itself 362 } 363 return !isPending(element); 364 } catch (UncheckedIOException e) { 365 // preserve the checked I/O contract when iterator advancement reads input 366 throw e.getCause(); 367 } 368 } 369 370 /** Marks an Element ready and queues it for iterator emission. */ 371 void queueForEmission(Element element) { 372 deferredEls.remove(element); 373 emitQueue.add(element); 374 } 375 376 // Iterator Interface: 377 /** 378 {@inheritDoc} 379 @throws UncheckedIOException if the underlying Reader errors during a read 380 */ 381 @Override public boolean hasNext() { 382 maybeFindNext(); 383 return next != null; 384 } 385 386 /** 387 {@inheritDoc} 388 @throws UncheckedIOException if the underlying Reader errors during a read 389 */ 390 @Override public Element next() { 391 maybeFindNext(); 392 if (next == null) throw new NoSuchElementException(); 393 current = next; 394 next = null; 395 return current; 396 } 397 398 private void maybeFindNext() { 399 if (stopped || next != null) return; 400 401 // drain the current queue before stepping to get more 402 if (!emitQueue.isEmpty()) { 403 next = emitQueue.remove(); 404 return; 405 } 406 407 // step the parser, which will hit the node listeners to add to the queue: 408 while (treeBuilder.stepParser()) { 409 if (!emitQueue.isEmpty()) { 410 next = emitQueue.remove(); 411 return; 412 } 413 } 414 close(); 415 416 // send the final element out: 417 if (tail != null) { 418 deferredEls.remove(tail); 419 next = tail; 420 tail = null; 421 } 422 } 423 424 @Override public void remove() { 425 if (current == null) throw new NoSuchElementException(); 426 current.remove(); 427 } 428 429 // NodeVisitor Interface: 430 @Override public void head(Node node, int depth) { 431 if (node instanceof Element) { 432 Element prev = node.previousElementSibling(); 433 // We prefer to wait until an element has a next sibling before emitting it; otherwise, get it in tail 434 if (prev != null) { 435 queueForEmission(prev); 436 if (tail == prev) tail = null; // queueing marks it ready 437 } 438 } 439 } 440 441 @Override public void tail(Node node, int depth) { 442 if (node instanceof Element) { 443 tail = (Element) node; // kept for final hit 444 if (!isReady(tail)) deferredEls.add(tail); 445 Element lastChild = tail.lastElementChild(); // won't get a nextsib, so emit that: 446 if (lastChild != null) queueForEmission(lastChild); 447 if (tail == treeBuilder.doc) deferredEls.clear(); // completed document makes stale recovery state irrelevant 448 } 449 } 450 } 451}