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.NodeVisitor; 010import org.jsoup.select.Selector; 011import org.jspecify.annotations.Nullable; 012 013import java.io.Closeable; 014import java.io.IOException; 015import java.io.Reader; 016import java.io.StringReader; 017import java.io.UncheckedIOException; 018import java.util.HashSet; 019import java.util.Iterator; 020import java.util.LinkedList; 021import java.util.List; 022import java.util.NoSuchElementException; 023import java.util.Queue; 024import java.util.Spliterator; 025import java.util.Spliterators; 026import java.util.stream.Stream; 027import java.util.stream.StreamSupport; 028 029/** 030 A StreamParser provides a progressive parse of its input. As each Element is completed, it is emitted via a Stream or 031 Iterator interface. Elements returned will be complete with all their children, and an (empty) next sibling, if 032 applicable. 033 <p>To conserve memory, you can {@link Node#remove() remove()} Elements (or their children) from the DOM during the 034 parse. This provides a mechanism to parse an input document that would otherwise be too large to fit into memory, yet 035 still providing a DOM interface to the document and its elements.</p> 036 <p> 037 Additionally, the parser provides a {@link #selectFirst(String query)} / {@link #selectNext(String query)}, which will 038 run the parser until a hit is found, at which point the parse is suspended. It can be resumed via another 039 {@code select()} call, or via the {@link #stream()} or {@link #iterator()} methods. 040 </p> 041 <p>Once the input has been fully read, the input Reader will be closed. Or, if the whole document does not need to be 042 read, call {@link #stop()} and {@link #close()}.</p> 043 <p>The {@link #document()} method will return the Document being parsed into, which will be only partially complete 044 until the input is fully consumed.</p> 045 <p>A StreamParser can be reused via a new {@link #parse(Reader, String)}, but is not thread-safe for concurrent inputs. 046 New parsers should be used in each thread.</p> 047 <p>If created via {@link Connection.Response#streamParser()}, or another Reader that is I/O backed, the iterator and 048 stream consumers will throw an {@link java.io.UncheckedIOException} if the underlying Reader errors during read.</p> 049 <p>For examples, see the jsoup 050 <a href="https://jsoup.org/cookbook/input/streamparser-dom-sax">StreamParser cookbook.</a></p> 051 <p> 052 Selectors that depend on knowing all siblings (e.g. {@code :last-child}, {@code :last-of-type}, {@code :nth-last-child}, 053 {@code :only-child} and their negations) cannot be correctly evaluated while streaming, because the parser does not know 054 if a later sibling will appear. For those cases, run {@link #complete()} first to finish the parse (which is effectively 055 the same as using {@code Jsoup.parse(...)} unless you have already removed nodes during streaming). 056 </p> 057 @since 1.18.1 */ 058public class StreamParser implements Closeable { 059 final private Parser parser; 060 final private TreeBuilder treeBuilder; 061 final private ElementIterator it = new ElementIterator(); 062 @Nullable private Document document; 063 private boolean stopped = false; 064 065 /** 066 Construct a new StreamParser, using the supplied base Parser. 067 @param parser the configured base parser 068 */ 069 public StreamParser(Parser parser) { 070 this.parser = parser; 071 treeBuilder = parser.getTreeBuilder(); 072 treeBuilder.nodeListener(it); 073 } 074 075 /** 076 Provide the input for a Document parse. The input is not read until a consuming operation is called. 077 @param input the input to be read. 078 @param baseUri the URL of this input, for absolute link resolution 079 @return this parser, for chaining 080 */ 081 public StreamParser parse(Reader input, String baseUri) { 082 close(); // probably a no-op, but ensures any previous reader is closed 083 it.reset(); 084 treeBuilder.initialiseParse(input, baseUri, parser); // reader is not read, so no chance of IO error 085 document = treeBuilder.doc; 086 return this; 087 } 088 089 /** 090 Provide the input for a Document parse. The input is not read until a consuming operation is called. 091 @param input the input to be read 092 @param baseUri the URL of this input, for absolute link resolution 093 @return this parser 094 */ 095 public StreamParser parse(String input, String baseUri) { 096 return parse(new StringReader(input), baseUri); 097 } 098 099 /** 100 Provide the input for a fragment parse. The input is not read until a consuming operation is called. 101 @param input the input to be read 102 @param context the optional fragment context element 103 @param baseUri the URL of this input, for absolute link resolution 104 @return this parser 105 @see #completeFragment() 106 */ 107 public StreamParser parseFragment(Reader input, @Nullable Element context, String baseUri) { 108 parse(input, baseUri); 109 treeBuilder.initialiseParseFragment(context); 110 return this; 111 } 112 113 /** 114 Provide the input for a fragment parse. The input is not read until a consuming operation is called. 115 @param input the input to be read 116 @param context the optional fragment context element 117 @param baseUri the URL of this input, for absolute link resolution 118 @return this parser 119 @see #completeFragment() 120 */ 121 public StreamParser parseFragment(String input, @Nullable Element context, String baseUri) { 122 return parseFragment(new StringReader(input), context, baseUri); 123 } 124 125 /** 126 Creates a {@link Stream} of {@link Element}s, with the input being parsed as each element is consumed. Each 127 Element returned will be complete (that is, all of its children will be included, and if it has a next sibling, that 128 (empty) sibling will exist at {@link Element#nextElementSibling()}). The stream will be emitted in document order as 129 each element is closed. That means that child elements will be returned prior to their parents. 130 <p>The stream will start from the current position of the backing iterator and the parse.</p> 131 <p>When consuming the stream, if the Reader that the Parser is reading throws an I/O exception (for example a 132 SocketTimeoutException), that will be emitted as an {@link UncheckedIOException}</p> 133 @return a stream of Element objects 134 @throws UncheckedIOException if the underlying Reader excepts during a read (in stream consuming methods) 135 */ 136 public Stream<Element> stream() { 137 return StreamSupport.stream( 138 Spliterators.spliteratorUnknownSize( 139 it, Spliterator.DISTINCT | Spliterator.NONNULL | Spliterator.ORDERED), 140 false); 141 } 142 143 /** 144 Returns an {@link Iterator} of {@link Element}s, with the input being parsed as each element is consumed. Each 145 Element returned will be complete (that is, all of its children will be included, and if it has a next sibling, that 146 (empty) sibling will exist at {@link Element#nextElementSibling()}). The elements will be emitted in document order as 147 each element is closed. That means that child elements will be returned prior to their parents. 148 <p>The iterator will start from the current position of the parse.</p> 149 <p>The iterator is backed by this StreamParser, and the resources it holds.</p> 150 @return a stream of Element objects 151 */ 152 public Iterator<Element> iterator() { 153 //noinspection ReturnOfInnerClass 154 return it; 155 } 156 157 /** 158 Flags that the parse should be stopped; the backing iterator will not return any more Elements. 159 @return this parser 160 */ 161 public StreamParser stop() { 162 stopped = true; 163 return this; 164 } 165 166 /** 167 Closes the input and releases resources including the underlying parser and reader. 168 <p>The parser will also be closed when the input is fully read.</p> 169 <p>The parser can be reused with another call to {@link #parse(Reader, String)}.</p> 170 */ 171 @Override public void close() { 172 it.deferOpenElements(); 173 stopped = true; 174 treeBuilder.closeParse(); // closes the reader, frees resources 175 } 176 177 /** 178 Get the current {@link Document} as it is being parsed. It will be only partially complete until the input is fully 179 read. Structural changes (e.g. insert, remove) may be made to the Document contents. 180 @return the (partial) Document 181 */ 182 public Document document() { 183 document = treeBuilder.doc; 184 Validate.notNull(document, "Must run parse() before calling."); 185 return document; 186 } 187 188 /** 189 Runs the parser until the input is fully read, and returns the completed Document. 190 @return the completed Document 191 @throws IOException if an I/O error occurs 192 */ 193 public Document complete() throws IOException { 194 Document doc = document(); 195 treeBuilder.runParser(); 196 return doc; 197 } 198 199 /** 200 When initialized as a fragment parse, runs the parser until the input is fully read, and returns the completed 201 fragment child nodes. 202 @return the completed child nodes 203 @throws IOException if an I/O error occurs 204 @see #parseFragment(Reader, Element, String) 205 */ 206 public List<Node> completeFragment() throws IOException { 207 treeBuilder.runParser(); 208 return treeBuilder.completeParseFragment(); 209 } 210 211 /** 212 Finds the first Element that matches the provided query. If the parsed Document does not already have a match, the 213 input will be parsed until the first match is ready for stream emission, or the input is completely read. 214 @param query the {@link org.jsoup.select.Selector} query. 215 @return the first matching {@link Element}, or {@code null} if there's no match 216 @throws IOException if an I/O error occurs 217 @see #selectFirst(Evaluator) 218 */ 219 public @Nullable Element selectFirst(String query) throws IOException { 220 return selectFirst(Selector.evaluatorOf(query)); 221 } 222 223 /** 224 Just like {@link #selectFirst(String)}, but if there is no match, throws an {@link IllegalArgumentException}. This 225 is useful if you want to simply abort processing on a failed match. 226 @param query the {@link org.jsoup.select.Selector} query. 227 @return the first matching element 228 @throws IllegalArgumentException if no match is found 229 @throws IOException if an I/O error occurs 230 */ 231 public Element expectFirst(String query) throws IOException { 232 return Validate.expectNotNull( 233 selectFirst(query), 234 "No elements matched the query '%s' in the document." 235 , query 236 ); 237 } 238 239 /** 240 Finds the first Element that matches the provided query. If the parsed Document does not already have a match, the 241 input will be parsed until the first match is ready for stream emission, or the input is completely read. 242 <p>By providing a compiled evaluator vs. a CSS selector, this method may be more efficient when executing the same 243 query against multiple documents.</p> 244 @param eval the {@link org.jsoup.select.Selector} evaluator. 245 @return the first matching {@link Element}, or {@code null} if there's no match 246 @throws IOException if an I/O error occurs 247 @see Selector#evaluatorOf(String css) 248 */ 249 public @Nullable Element selectFirst(Evaluator eval) throws IOException { 250 final Document doc = document(); 251 252 // a ready match can be returned without advancing the iterator 253 Element first = doc.selectFirst(eval); 254 if (first != null && it.isPending(first)) { 255 if (!it.awaitReady(first)) return null; 256 first = doc.selectFirst(eval); 257 } 258 if (first != null && !it.isPending(first)) return first; 259 260 // use the iterator for linear progress 261 Element emitted = selectNext(eval); 262 if (emitted == null) { 263 if (!treeBuilder.isComplete()) return null; 264 return doc.selectFirst(eval); 265 } 266 267 // reconcile the iterator's child-first emission order with document order 268 while ((first = doc.selectFirst(eval)) != null && it.isPending(first)) { 269 if (!it.awaitReady(first)) return null; 270 } 271 return first != null ? first : emitted; 272 } 273 274 /** 275 Finds the next Element that matches the provided query. The input will be parsed until the next match is found, or 276 the input is completely read. 277 @param query the {@link org.jsoup.select.Selector} query. 278 @return the next matching {@link Element}, or {@code null} if there's no match 279 @throws IOException if an I/O error occurs 280 @see #selectNext(Evaluator) 281 */ 282 public @Nullable Element selectNext(String query) throws IOException { 283 return selectNext(Selector.evaluatorOf(query)); 284 } 285 286 /** 287 Just like {@link #selectFirst(String)}, but if there is no match, throws an {@link IllegalArgumentException}. This 288 is useful if you want to simply abort processing on a failed match. 289 @param query the {@link org.jsoup.select.Selector} query. 290 @return the first matching element 291 @throws IllegalArgumentException if no match is found 292 @throws IOException if an I/O error occurs 293 */ 294 public Element expectNext(String query) throws IOException { 295 return Validate.expectNotNull( 296 selectNext(query), 297 "No elements matched the query '%s' in the document." 298 , query 299 ); 300 } 301 302 /** 303 Finds the next Element that matches the provided query. The input will be parsed until the next match is found, or 304 the input is completely read. 305 <p>By providing a compiled evaluator vs a CSS selector, this method may be more efficient when executing the same 306 query against multiple documents.</p> 307 @param eval the {@link org.jsoup.select.Selector} evaluator. 308 @return the next matching {@link Element}, or {@code null} if there's no match 309 @throws IOException if an I/O error occurs 310 @see Selector#evaluatorOf(String css) 311 */ 312 public @Nullable Element selectNext(Evaluator eval) throws IOException { 313 try { 314 final Document doc = document(); // validates the parse was initialized, keeps stack trace out of stream 315 return stream() 316 .filter(eval.asPredicate(doc)) 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}