From c02959555f8d819ae8fe028ecfc7e36a21a1f3a2 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 29 Oct 2026 08:21:07 +0400 Subject: [PATCH 01/25] Remove redundant after_tag() parse_next_tag() is called in exactly 1 place, and it a few lines earlier `after_tag()` is called. after_tag() does some significant processing, so removing the redundant call has an impact on parsing speed --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index c39368e9a40c1..7ae805ecbfd96 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -1720,8 +1720,6 @@ private function skip_script_data(): bool { * @phpstan-impure */ private function parse_next_tag(): bool { - $this->after_tag(); - $html = $this->html; $doc_length = strlen( $html ); $was_at = $this->bytes_already_parsed; From 0554baaf2c5f2e74c3a49f847fabfc4aca77c978 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 28 Oct 2026 17:19:09 +0400 Subject: [PATCH 02/25] Simplify tag processor attribute parsing The private `parse_next_attribute()` method was always called in a loop, until all attributes were exhausted and the end of the tag was reached. The method is replaced with `parse_all_attributes()` that loops internally until no more attributes are found. `parse_all_attributes()` also consistently sets the incomplete state flag if the attribute parsing does not terminate before exhausting the input string, indicating an incomplete token. This simplifies calling code and eliminates duplicate and redundant checks, establishing the invariant that `parse_all_attributes()` will always terminate on the tag's closing `>` _OR_ will set the incomplete input state. --- .../html-api/class-wp-html-tag-processor.php | 335 ++++++++---------- 1 file changed, 152 insertions(+), 183 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index c39368e9a40c1..c48c5819ded68 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -1009,33 +1009,17 @@ private function base_class_next_token(): bool { return true; } - // Parse all of its attributes. - while ( $this->parse_next_attribute() ) { - continue; - } + $this->parse_all_attributes(); // Ensure that the tag closes before the end of the document. - if ( - self::STATE_INCOMPLETE_INPUT === $this->parser_state || - $this->bytes_already_parsed >= strlen( $this->html ) - ) { - // Does this appropriately clear state (parsed attributes)? - $this->parser_state = self::STATE_INCOMPLETE_INPUT; + if ( self::STATE_INCOMPLETE_INPUT === $this->parser_state ) { $this->bytes_already_parsed = $was_at; - return false; } - $tag_ends_at = strpos( $this->html, '>', $this->bytes_already_parsed ); - if ( false === $tag_ends_at ) { - $this->parser_state = self::STATE_INCOMPLETE_INPUT; - $this->bytes_already_parsed = $was_at; - - return false; - } - $this->parser_state = self::STATE_MATCHED_TAG; - $this->bytes_already_parsed = $tag_ends_at + 1; - $this->token_length = $this->bytes_already_parsed - $this->token_starts_at; + $this->parser_state = self::STATE_MATCHED_TAG; + ++$this->bytes_already_parsed; + $this->token_length = $this->bytes_already_parsed - $this->token_starts_at; /* * Certain tags require additional processing. The first-letter pre-check @@ -1473,28 +1457,15 @@ private function skip_rcdata( string $tag_name ): bool { continue; } - while ( $this->parse_next_attribute() ) { - continue; - } - - $at = $this->bytes_already_parsed; - if ( $at >= strlen( $this->html ) ) { - return false; - } + $this->parse_all_attributes(); - if ( '>' === $html[ $at ] ) { - $this->bytes_already_parsed = $at + 1; - return true; - } - - if ( $at + 1 >= strlen( $this->html ) ) { + // Ensure that the tag closes before the end of the document. + if ( self::STATE_INCOMPLETE_INPUT === $this->parser_state ) { return false; } - if ( '/' === $html[ $at ] && '>' === $html[ $at + 1 ] ) { - $this->bytes_already_parsed = $at + 2; - return true; - } + ++$this->bytes_already_parsed; + return true; } return false; @@ -1683,18 +1654,15 @@ private function skip_script_data(): bool { return false; } - while ( $this->parse_next_attribute() ) { - continue; - } + $this->parse_all_attributes(); - if ( $this->bytes_already_parsed >= $doc_length ) { + // Ensure that the tag closes before the end of the document. + if ( self::STATE_INCOMPLETE_INPUT === $this->parser_state ) { return false; } - if ( '>' === $html[ $this->bytes_already_parsed ] ) { - ++$this->bytes_already_parsed; - return true; - } + ++$this->bytes_already_parsed; + return true; } ++$at; @@ -2191,168 +2159,169 @@ private function parse_next_tag(): bool { } /** - * Parses the next attribute. + * Parses attributes. * - * @since 6.2.0 - * @ignore + * This method must parse all attributes in a tag up to its closing `>`. After calling this + * function, the parser is guaranteed to be positioned at the closing `>` of the tag or at the + * end of the document if the tag is incomplete. + * + * This function will set the parser state to `STATE_INCOMPLETE_INPUT` if the tag is incomplete + * and the parser reaches the end of the document before finding a closing `>`. * - * @return bool Whether an attribute was found. Returns `false` upon reaching the end - * of the tag (`>` or `/>`) or the end of the document. + * @since 7.3.0 + * @ignore */ - private function parse_next_attribute(): bool { + private function parse_all_attributes(): void { $doc_length = strlen( $this->html ); - // Skip whitespace and slashes. - $skipped_length = strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed ); - $this->bytes_already_parsed += $skipped_length; - if ( $this->bytes_already_parsed >= $doc_length ) { - $this->parser_state = self::STATE_INCOMPLETE_INPUT; - - return false; - } - - /** - * This block serves two purposes: - * - * - A fast path for common tag-ending `>`. - * - A check for the self-closing flag which must appear as `/>`. - * - * In a tag like ``, `/` is the attribute value, not a self-closing - * flag. When it appears in this form, the parser has already consumed the - * attribute value, `$skipped_length` is 0, and this checks below correctly - * identify whether there is a self-closing flag. - * - * Note: Both start and end tags may have the self-closing flag. - */ - if ( '>' === $this->html[ $this->bytes_already_parsed ] ) { - if ( $skipped_length > 0 && '/' === $this->html[ $this->bytes_already_parsed - 1 ] ) { - $this->has_self_closing_flag = true; + while ( true ) { + // Skip whitespace and slashes. + $skipped_length = strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed ); + $this->bytes_already_parsed += $skipped_length; + if ( $this->bytes_already_parsed >= $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + break; } - return false; - } - - /* - * Treat the equal sign as a part of the attribute - * name if it is the first encountered byte. - * - * @link https://html.spec.whatwg.org/multipage/parsing.html#before-attribute-name-state - */ - $name_length = '=' === $this->html[ $this->bytes_already_parsed ] - ? 1 + strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed + 1 ) - : strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed ); - // No attribute, just tag closer. - if ( 0 === $name_length || $this->bytes_already_parsed + $name_length >= $doc_length ) { - return false; - } + /** + * This block serves two purposes: + * + * - A fast path for common tag-ending `>`. + * - A check for the self-closing flag which must appear as `/>`. + * + * In a tag like ``, `/` is the attribute value, not a self-closing + * flag. When it appears in this form, the parser has already consumed the + * attribute value, `$skipped_length` is 0, and this checks below correctly + * identify whether there is a self-closing flag. + * + * Note: Both start and end tags may have the self-closing flag. + */ + if ( '>' === $this->html[ $this->bytes_already_parsed ] ) { + if ( $skipped_length > 0 && '/' === $this->html[ $this->bytes_already_parsed - 1 ] ) { + $this->has_self_closing_flag = true; + } + break; + } - $attribute_start = $this->bytes_already_parsed; - $attribute_name = substr( $this->html, $attribute_start, $name_length ); - $this->bytes_already_parsed += $name_length; - if ( $this->bytes_already_parsed >= $doc_length ) { - $this->parser_state = self::STATE_INCOMPLETE_INPUT; + /* + * Treat the equal sign as a part of the attribute + * name if it is the first encountered byte. + * + * @link https://html.spec.whatwg.org/multipage/parsing.html#before-attribute-name-state + */ + $name_length = '=' === $this->html[ $this->bytes_already_parsed ] + ? 1 + strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed + 1 ) + : strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed ); - return false; - } + // No attribute, just tag closer. + if ( 0 === $name_length ) { + break; + } - $this->skip_whitespace(); - if ( $this->bytes_already_parsed >= $doc_length ) { - $this->parser_state = self::STATE_INCOMPLETE_INPUT; + if ( $this->bytes_already_parsed + $name_length >= $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + break; + } - return false; - } + $attribute_start = $this->bytes_already_parsed; + $attribute_name = substr( $this->html, $attribute_start, $name_length ); + $this->bytes_already_parsed += $name_length; - $has_value = '=' === $this->html[ $this->bytes_already_parsed ]; - if ( $has_value ) { - ++$this->bytes_already_parsed; $this->skip_whitespace(); if ( $this->bytes_already_parsed >= $doc_length ) { $this->parser_state = self::STATE_INCOMPLETE_INPUT; - - return false; + break; } - switch ( $this->html[ $this->bytes_already_parsed ] ) { - case "'": - case '"': - $quote = $this->html[ $this->bytes_already_parsed ]; - $value_start = $this->bytes_already_parsed + 1; - $end_quote_at = strpos( $this->html, $quote, $value_start ); - $end_quote_at = false === $end_quote_at ? $doc_length : $end_quote_at; - $value_length = $end_quote_at - $value_start; - $attribute_end = $end_quote_at + 1; - $this->bytes_already_parsed = $attribute_end; + $has_value = '=' === $this->html[ $this->bytes_already_parsed ]; + if ( $has_value ) { + ++$this->bytes_already_parsed; + $this->skip_whitespace(); + if ( $this->bytes_already_parsed >= $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; break; + } - default: - $value_start = $this->bytes_already_parsed; - $value_length = strcspn( $this->html, "> \t\f\r\n", $value_start ); - $attribute_end = $value_start + $value_length; - $this->bytes_already_parsed = $attribute_end; + switch ( $this->html[ $this->bytes_already_parsed ] ) { + case "'": + case '"': + $quote = $this->html[ $this->bytes_already_parsed ]; + $value_start = $this->bytes_already_parsed + 1; + $end_quote_at = strpos( $this->html, $quote, $value_start ); + $end_quote_at = false === $end_quote_at ? $doc_length : $end_quote_at; + $value_length = $end_quote_at - $value_start; + $attribute_end = $end_quote_at + 1; + $this->bytes_already_parsed = $attribute_end; + break; + + default: + $value_start = $this->bytes_already_parsed; + $value_length = strcspn( $this->html, "> \t\f\r\n", $value_start ); + $attribute_end = $value_start + $value_length; + $this->bytes_already_parsed = $attribute_end; + } + } else { + $value_start = $this->bytes_already_parsed; + $value_length = 0; + $attribute_end = $attribute_start + $name_length; } - } else { - $value_start = $this->bytes_already_parsed; - $value_length = 0; - $attribute_end = $attribute_start + $name_length; - } - - if ( $attribute_end >= $doc_length ) { - $this->parser_state = self::STATE_INCOMPLETE_INPUT; - - return false; - } - if ( $this->is_closing_tag ) { - return true; - } + if ( $attribute_end >= $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + break; + } - /* - * > There must never be two or more attributes on - * > the same start tag whose names are an ASCII - * > case-insensitive match for each other. - * - HTML 5 spec - * - * @link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive - * - * The tokenizer replaces U+0000 NULL bytes in - * attribute names with U+FFFD. - * - * @link https://html.spec.whatwg.org/#attribute-name-state - */ - $comparable_name = strtolower( str_replace( "\x00", "\u{FFFD}", $attribute_name ) ); - - // If an attribute is listed many times, only use the first declaration and ignore the rest. - if ( ! isset( $this->attributes[ $comparable_name ] ) ) { - $this->attributes[ $comparable_name ] = new WP_HTML_Attribute_Token( - $attribute_name, - $value_start, - $value_length, - $attribute_start, - $attribute_end - $attribute_start, - ! $has_value - ); + if ( $this->is_closing_tag ) { + continue; + } - return true; - } + /* + * > There must never be two or more attributes on + * > the same start tag whose names are an ASCII + * > case-insensitive match for each other. + * - HTML 5 spec + * + * @link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive + * + * The tokenizer replaces U+0000 NULL bytes in + * attribute names with U+FFFD. + * + * @link https://html.spec.whatwg.org/#attribute-name-state + */ + $comparable_name = strtolower( str_replace( "\x00", "\u{FFFD}", $attribute_name ) ); + + // If an attribute is listed many times, only use the first declaration and ignore the rest. + if ( ! isset( $this->attributes[ $comparable_name ] ) ) { + $this->attributes[ $comparable_name ] = new WP_HTML_Attribute_Token( + $attribute_name, + $value_start, + $value_length, + $attribute_start, + $attribute_end - $attribute_start, + ! $has_value + ); + continue; + } - /* - * Track the duplicate attributes so if we remove it, all disappear together. - * - * While `$this->duplicated_attributes` could always be stored as an `array()`, - * which would simplify the logic here, storing a `null` and only allocating - * an array when encountering duplicates avoids needless allocations in the - * normative case of parsing tags with no duplicate attributes. - */ - $duplicate_span = new WP_HTML_Span( $attribute_start, $attribute_end - $attribute_start ); - if ( null === $this->duplicate_attributes ) { - $this->duplicate_attributes = array( $comparable_name => array( $duplicate_span ) ); - } elseif ( ! isset( $this->duplicate_attributes[ $comparable_name ] ) ) { - $this->duplicate_attributes[ $comparable_name ] = array( $duplicate_span ); - } else { - $this->duplicate_attributes[ $comparable_name ][] = $duplicate_span; + /* + * Track the duplicate attributes so if we remove it, all disappear together. + * + * While `$this->duplicated_attributes` could always be stored as an `array()`, + * which would simplify the logic here, storing a `null` and only allocating + * an array when encountering duplicates avoids needless allocations in the + * normative case of parsing tags with no duplicate attributes. + */ + $duplicate_span = new WP_HTML_Span( $attribute_start, $attribute_end - $attribute_start ); + if ( null === $this->duplicate_attributes ) { + $this->duplicate_attributes = array( $comparable_name => array( $duplicate_span ) ); + } elseif ( ! isset( $this->duplicate_attributes[ $comparable_name ] ) ) { + $this->duplicate_attributes[ $comparable_name ] = array( $duplicate_span ); + } else { + $this->duplicate_attributes[ $comparable_name ][] = $duplicate_span; + } } - return true; + assert( self::STATE_INCOMPLETE_INPUT === $this->parser_state || '>' === $this->html[ $this->bytes_already_parsed ] ); } /** From 6d2dbf0604ea3a821a5bf396e9bfb771e2fae021 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 28 Oct 2026 18:47:35 +0400 Subject: [PATCH 03/25] Replace unreachable block with assertion - strspn skipped whitespace - leading "=" gives at least length 1 - strcspn must be at least 1 --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index c48c5819ded68..8a1498fca9229 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -2213,10 +2213,7 @@ private function parse_all_attributes(): void { ? 1 + strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed + 1 ) : strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed ); - // No attribute, just tag closer. - if ( 0 === $name_length ) { - break; - } + assert( $name_length > 0 ); if ( $this->bytes_already_parsed + $name_length >= $doc_length ) { $this->parser_state = self::STATE_INCOMPLETE_INPUT; From 2faddd6d814aedc2b079e66210317e37eb15a566 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 28 Oct 2026 18:52:37 +0400 Subject: [PATCH 04/25] Improve documentation --- .../html-api/class-wp-html-tag-processor.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index 8a1498fca9229..2b754c9a180c0 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -2159,14 +2159,11 @@ private function parse_next_tag(): bool { } /** - * Parses attributes. + * Parses attributes exhaustively in a tag. * - * This method must parse all attributes in a tag up to its closing `>`. After calling this - * function, the parser is guaranteed to be positioned at the closing `>` of the tag or at the - * end of the document if the tag is incomplete. - * - * This function will set the parser state to `STATE_INCOMPLETE_INPUT` if the tag is incomplete - * and the parser reaches the end of the document before finding a closing `>`. + * After calling this function, the processor will either be at the tag-closing `>` or the + * parser state will be set to `self::STATE_INCOMPLETE_INPUT` if the end of the document is + * reached before attribute parsing finishes. * * @since 7.3.0 * @ignore From 5b3707e3d225e82606f3eb4df614caa202d2be10 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 28 Oct 2026 19:28:40 +0400 Subject: [PATCH 05/25] Remove assertions --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index 2b754c9a180c0..4ba3c81c5cb8a 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -2210,8 +2210,6 @@ private function parse_all_attributes(): void { ? 1 + strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed + 1 ) : strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed ); - assert( $name_length > 0 ); - if ( $this->bytes_already_parsed + $name_length >= $doc_length ) { $this->parser_state = self::STATE_INCOMPLETE_INPUT; break; @@ -2314,8 +2312,6 @@ private function parse_all_attributes(): void { $this->duplicate_attributes[ $comparable_name ][] = $duplicate_span; } } - - assert( self::STATE_INCOMPLETE_INPUT === $this->parser_state || '>' === $this->html[ $this->bytes_already_parsed ] ); } /** From d597cc2d21dca3af38b4f468bcaa03193a390ad9 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 29 Oct 2026 08:16:54 +0400 Subject: [PATCH 06/25] Sketching attribute whitespace fast path --- .../html-api/class-wp-html-tag-processor.php | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index 4ba3c81c5cb8a..c3ca48c0a986d 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -2219,25 +2219,33 @@ private function parse_all_attributes(): void { $attribute_name = substr( $this->html, $attribute_start, $name_length ); $this->bytes_already_parsed += $name_length; - $this->skip_whitespace(); - if ( $this->bytes_already_parsed >= $doc_length ) { - $this->parser_state = self::STATE_INCOMPLETE_INPUT; - break; + // There is no whitespace to skip when `=` follows the name. + if ( '=' !== $this->html[ $this->bytes_already_parsed ] ) { + $this->skip_whitespace(); + if ( $this->bytes_already_parsed >= $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + break; + } } $has_value = '=' === $this->html[ $this->bytes_already_parsed ]; if ( $has_value ) { ++$this->bytes_already_parsed; - $this->skip_whitespace(); - if ( $this->bytes_already_parsed >= $doc_length ) { - $this->parser_state = self::STATE_INCOMPLETE_INPUT; - break; + + // There is no whitespace to skip when a quote follows `=`. + $quote = $this->html[ $this->bytes_already_parsed ] ?? ''; + if ( '"' !== $quote && "'" !== $quote ) { + $this->skip_whitespace(); + if ( $this->bytes_already_parsed >= $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + break; + } + $quote = $this->html[ $this->bytes_already_parsed ]; } - switch ( $this->html[ $this->bytes_already_parsed ] ) { + switch ( $quote ) { case "'": case '"': - $quote = $this->html[ $this->bytes_already_parsed ]; $value_start = $this->bytes_already_parsed + 1; $end_quote_at = strpos( $this->html, $quote, $value_start ); $end_quote_at = false === $end_quote_at ? $doc_length : $end_quote_at; From b3766919e55d04273e4eb2bbf4bdd735d8833b97 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 29 Oct 2026 20:48:42 +0400 Subject: [PATCH 07/25] Annotate non-empty-string --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index c3ca48c0a986d..bbcb2a89a6a3d 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -2215,7 +2215,8 @@ private function parse_all_attributes(): void { break; } - $attribute_start = $this->bytes_already_parsed; + $attribute_start = $this->bytes_already_parsed; + /** @phpstan-var non-empty-string $attribute_name */ $attribute_name = substr( $this->html, $attribute_start, $name_length ); $this->bytes_already_parsed += $name_length; From 14c91240e7320f0425ed17fe10928370622c638d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 30 Oct 2026 07:27:51 +0400 Subject: [PATCH 08/25] Break on a tag-closing `>` before the attribute whitespace scan When the next byte is `>`, parse_all_attributes() now stops without calling strspn() for whitespace and slashes. On the matching base this made the Tag Processor 2.8% faster with tracing JIT and 1.8% faster with JIT off, with no difference for the HTML Processor. --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index bbcb2a89a6a3d..a315ed3afc3ec 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -2172,6 +2172,11 @@ private function parse_all_attributes(): void { $doc_length = strlen( $this->html ); while ( true ) { + // A tag-closing `>` needs no whitespace scan. + if ( $this->bytes_already_parsed < $doc_length && '>' === $this->html[ $this->bytes_already_parsed ] ) { + break; + } + // Skip whitespace and slashes. $skipped_length = strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed ); $this->bytes_already_parsed += $skipped_length; From c10e0d38e1da77dcbcf0e7400224cf4d92d103fd Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 30 Oct 2026 07:39:16 +0400 Subject: [PATCH 09/25] Revert "Break on a tag-closing `>` before the attribute whitespace scan" This reverts commit 03d8747fb562027fb55aed25c5727a3f70f67ff2. --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index a315ed3afc3ec..bbcb2a89a6a3d 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -2172,11 +2172,6 @@ private function parse_all_attributes(): void { $doc_length = strlen( $this->html ); while ( true ) { - // A tag-closing `>` needs no whitespace scan. - if ( $this->bytes_already_parsed < $doc_length && '>' === $this->html[ $this->bytes_already_parsed ] ) { - break; - } - // Skip whitespace and slashes. $skipped_length = strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed ); $this->bytes_already_parsed += $skipped_length; From 26ad7767e4617e33ab11828962f4b7ccb91a82f7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 30 Oct 2026 08:01:30 +0400 Subject: [PATCH 10/25] Explain annotation --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index bbcb2a89a6a3d..b57e46cd38d35 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -2216,7 +2216,14 @@ private function parse_all_attributes(): void { } $attribute_start = $this->bytes_already_parsed; - /** @phpstan-var non-empty-string $attribute_name */ + /** + * Attribute name. + * + * The name is non-empty because whitespace and slashes are skipped, `>` ends parsing, + * and a leading `=` is included. PHPStan cannot infer this, so the type is annotated below. + * + * @var non-empty-string $attribute_name + */ $attribute_name = substr( $this->html, $attribute_start, $name_length ); $this->bytes_already_parsed += $name_length; From 36aaa5180ba6ea6656963d5e471f19a2da7a3b4a Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Sat, 31 Oct 2026 20:09:12 +0000 Subject: [PATCH 11/25] HTML API: Add a CI workflow to analyze changes against CommonCrawl documents. Adds a workflow which compares how the HTML API parses real-world HTML with and without a pull request. It fetches a fresh batch of documents from the latest CommonCrawl crawl, replays the same batch through the target branch and through the pull request merged into it, and leaves a report as a comment on the pull request. The report covers: - Tag Processor and HTML Processor parse times, with totals, per-document deltas, and percentage changes for all, small, medium, and large documents. - A chart of median parse time by document size for both checkouts, leaving out sizes at either end with too few documents for a meaningful median. - How many documents the HTML Processor does not support with each checkout, grouped by the reason it stopped and the line it stopped on. - Which documents became newly supported or unsupported. The analysis takes a while, so it only runs when the `html-api-commoncrawl` label is added to a pull request which changes the HTML API, or when the workflow is run manually with a pull request number. The label is removed as soon as it is added, so it can be added again to request another analysis. How many documents to fetch, how long to spend fetching them, and how many timing rounds to run are configurable. Documents are fetched and replayed with a vendored build of cc-analyzer, which is built without its default HTTP identity. The analysis scripts load the HTML API, and what `wp_kses()` needs, from an explicit list of files without loading WordPress, presenting a UTF-8 site at example.com. The label removal and the comment are handled by a separate `pull_request_target`/`workflow_run` workflow, because workflows triggered by pull requests from forks cannot write to them. It replaces the report from any earlier analysis, searching every page of comments for it. That workflow only runs from the default branch, so it can only be tested after this is merged, or on a fork's default branch. See #66430. Co-Authored-By: Claude Opus 5.5 --- .../html-api-commoncrawl-comment.yml | 151 +++++ .github/workflows/html-api-commoncrawl.yml | 166 +++++ tools/html-api-commoncrawl/.gitattributes | 1 + tools/html-api-commoncrawl/README.md | 72 +++ tools/html-api-commoncrawl/analysis.php | 66 ++ tools/html-api-commoncrawl/benchmark.php | 74 +++ tools/html-api-commoncrawl/bootstrap.php | 102 +++ tools/html-api-commoncrawl/cc-analyzer.phar | Bin 0 -> 489566 bytes tools/html-api-commoncrawl/report.php | 608 ++++++++++++++++++ 9 files changed, 1240 insertions(+) create mode 100644 .github/workflows/html-api-commoncrawl-comment.yml create mode 100644 .github/workflows/html-api-commoncrawl.yml create mode 100644 tools/html-api-commoncrawl/.gitattributes create mode 100644 tools/html-api-commoncrawl/README.md create mode 100644 tools/html-api-commoncrawl/analysis.php create mode 100644 tools/html-api-commoncrawl/benchmark.php create mode 100644 tools/html-api-commoncrawl/bootstrap.php create mode 100755 tools/html-api-commoncrawl/cc-analyzer.phar create mode 100644 tools/html-api-commoncrawl/report.php diff --git a/.github/workflows/html-api-commoncrawl-comment.yml b/.github/workflows/html-api-commoncrawl-comment.yml new file mode 100644 index 0000000000000..a6e29b527bb3a --- /dev/null +++ b/.github/workflows/html-api-commoncrawl-comment.yml @@ -0,0 +1,151 @@ +# Manages the pull request side of html-api-commoncrawl.yml. +# +# Pull requests from forks cannot be written to by the workflows they trigger, so this removes the request label and +# posts the report from here instead. +name: HTML API CommonCrawl Comments + +on: + pull_request_target: + types: [ 'labeled' ] + branches: + - trunk + - '6.[4-9]' + - '[7-9].[0-9]' + paths: + - 'src/wp-includes/html-api/**' + - 'src/wp-includes/class-wp-token-map.php' + - 'tools/html-api-commoncrawl/**' + - '.github/workflows/html-api-commoncrawl.yml' + - '.github/workflows/html-api-commoncrawl-comment.yml' + workflow_run: + workflows: [ 'HTML API CommonCrawl Analysis' ] + types: + - completed + +# Disable permissions for all available scopes by default. +# Any needed permissions should be configured at the job level. +permissions: {} + +jobs: + # Removes the label which requested the analysis, so that it can be applied again to request another one. + remove-label: + name: Remove the analysis request label + runs-on: ubuntu-24.04 + permissions: + issues: write + pull-requests: write + timeout-minutes: 5 + if: ${{ github.event_name == 'pull_request_target' && github.event.label.name == 'html-api-commoncrawl' }} + steps: + - name: Remove the label + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + await github.rest.issues.removeLabel( { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + name: 'html-api-commoncrawl', + } ); + + # Leaves the report on the pull request, replacing the report from any earlier analysis. + comment: + name: Comment with the analysis report + runs-on: ubuntu-24.04 + permissions: + actions: read + issues: write + pull-requests: write + timeout-minutes: 5 + if: > + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + ( github.event.workflow_run.event == 'pull_request' || github.event.workflow_run.event == 'workflow_dispatch' ) + steps: + - name: Download the report + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require( 'fs' ); + + const artifacts = await github.rest.actions.listWorkflowRunArtifacts( { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + } ); + + const artifact = artifacts.data.artifacts.find( ( artifact ) => artifact.name === 'html-api-commoncrawl-comment' ); + + const download = await github.rest.actions.downloadArtifact( { + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: artifact.id, + archive_format: 'zip', + } ); + + fs.writeFileSync( `${ process.env.RUNNER_TEMP }/comment.zip`, Buffer.from( download.data ) ); + + - name: Unzip the report + run: unzip "$RUNNER_TEMP/comment.zip" -d "$RUNNER_TEMP/comment" + + - name: Leave or replace the report comment + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require( 'fs' ); + const { owner, repo } = context.repo; + const run = context.payload.workflow_run; + const marker = ''; + + const issue_number = Number( fs.readFileSync( `${ process.env.RUNNER_TEMP }/comment/NR`, 'utf8' ) ); + const body = fs.readFileSync( `${ process.env.RUNNER_TEMP }/comment/report.md`, 'utf8' ); + + if ( ! Number.isInteger( issue_number ) || issue_number < 1 ) { + core.setFailed( 'The report does not name a pull request.' ); + return; + } + + const { data: pr } = await github.rest.pulls.get( { owner, repo, pull_number: issue_number } ); + + /* + * The report and the pull request number come from a workflow run which the pull request + * itself controls, so only comment on the pull request whose branch triggered the run. + * Manual runs can only be started by people with write access. + */ + if ( + run.event === 'pull_request' && + ( pr.head.repo?.full_name !== run.head_repository.full_name || pr.head.ref !== run.head_branch ) + ) { + core.setFailed( `Pull request #${ issue_number } did not trigger this workflow run.` ); + return; + } + + if ( pr.state !== 'open' ) { + core.info( 'The pull request has been closed. No comment will be left.' ); + return; + } + + // Search every page of comments for an earlier report, stopping at the first match. + let existing; + for await ( const { data: comments } of github.paginate.iterator( github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + } ) ) { + existing = comments.find( ( comment ) => + comment.user.login === 'github-actions[bot]' && comment.body.startsWith( marker ) + ); + if ( existing ) { + break; + } + } + + if ( existing ) { + await github.rest.issues.updateComment( { owner, repo, comment_id: existing.id, body } ); + core.notice( `Replaced the report comment: ${ existing.html_url }` ); + return; + } + + const { data: comment } = await github.rest.issues.createComment( { owner, repo, issue_number, body } ); + core.notice( `Left the report comment: ${ comment.html_url }` ); diff --git a/.github/workflows/html-api-commoncrawl.yml b/.github/workflows/html-api-commoncrawl.yml new file mode 100644 index 0000000000000..5c5d0034bf1d4 --- /dev/null +++ b/.github/workflows/html-api-commoncrawl.yml @@ -0,0 +1,166 @@ +# Compares HTML API parsing performance and support with and without a pull request, using documents from CommonCrawl. +# +# This takes tens of minutes, so it only runs on request: either by adding the `html-api-commoncrawl` label to a pull +# request, which is removed again as soon as it is added, or by running this workflow manually with a pull request +# number. The report is left as a comment on the pull request by `html-api-commoncrawl-comment.yml`. +name: HTML API CommonCrawl Analysis + +on: + # The HTML Processor was introduced in WordPress 6.4. + pull_request: + types: [ 'labeled' ] + branches: + - trunk + - '6.[4-9]' + - '[7-9].[0-9]' + paths: + - 'src/wp-includes/html-api/**' + - 'src/wp-includes/class-wp-token-map.php' + - 'tools/html-api-commoncrawl/**' + - '.github/workflows/html-api-commoncrawl.yml' + - '.github/workflows/html-api-commoncrawl-comment.yml' + workflow_dispatch: + inputs: + pull-request: + description: 'Pull request number to analyze' + required: true + type: number + documents: + description: 'How many documents to fetch from CommonCrawl' + required: false + type: number + default: 10000 + fetch-duration: + description: 'Longest time to spend fetching documents, e.g. 90s, 30m, 1h' + required: false + type: string + default: '30m' + rounds: + description: 'How many times to time each document with each checkout' + required: false + type: number + default: 3 + small-max-bytes: + description: 'Documents smaller than this many bytes are small' + required: false + type: number + default: 32768 + large-min-bytes: + description: 'Documents at least this many bytes are large' + required: false + type: number + default: 262144 + +# Cancels all previous workflow runs for the same pull request that have not completed. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.pull-request }} + cancel-in-progress: true + +# Disable permissions for all available scopes by default. +# Any needed permissions should be configured at the job level. +permissions: {} + +env: + # The analysis stops fetching documents when it has this many or when the fetch duration has passed, whichever is first. + HTML_API_BENCH_DOCUMENTS: ${{ inputs.documents || 10000 }} + HTML_API_BENCH_FETCH_DURATION: ${{ inputs.fetch-duration || '30m' }} + HTML_API_BENCH_ROUNDS: ${{ inputs.rounds || 3 }} + HTML_API_BENCH_SMALL_MAX_BYTES: ${{ inputs.small-max-bytes || 32768 }} + HTML_API_BENCH_LARGE_MIN_BYTES: ${{ inputs.large-min-bytes || 262144 }} + +jobs: + # Runs the analysis and uploads the report for html-api-commoncrawl-comment.yml to post. + # + # Performs the following steps: + # - Checks out the pull request merged into its target branch, and the target branch beside it. + # - Sets up PHP. + # - Fetches a batch of documents from the latest CommonCrawl crawl. + # - Times parsing each document with both checkouts, alternating which goes first in each round. + # - Records which documents each checkout supports, and why the others are unsupported. + # - Writes the report. + # - Uploads the report and the pull request number. + analyze: + name: Analyze HTML API changes against CommonCrawl + runs-on: ubuntu-24.04 + permissions: + contents: read + timeout-minutes: 90 + if: ${{ github.event_name == 'workflow_dispatch' || github.event.label.name == 'html-api-commoncrawl' }} + env: + CC_ANALYZER: merge/tools/html-api-commoncrawl/cc-analyzer.phar + SCRIPTS: merge/tools/html-api-commoncrawl + steps: + - name: Checkout the pull request merged into its target branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format( 'refs/pull/{0}/merge', inputs.pull-request ) || '' }} + path: merge + fetch-depth: 2 + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + persist-credentials: false + + # The merge commit's first parent is the target branch commit which the pull request is merged into. + - name: Checkout the target branch + id: checkouts + run: | + git -C merge worktree add --detach ../base HEAD^1 + echo "base-sha=$( git -C base rev-parse HEAD )" >> "$GITHUB_OUTPUT" + echo "merge-sha=$( git -C merge rev-parse HEAD )" >> "$GITHUB_OUTPUT" + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.5' + coverage: none + extensions: curl, mbstring, pdo_sqlite, zlib + ini-values: memory_limit=1G + + # Each WARC file from a crawl holds around 20,000 HTML documents, so documents are taken from up to 10,000 per file. + - name: Fetch documents from CommonCrawl + run: | + warcs=$(( ( HTML_API_BENCH_DOCUMENTS + 9999 ) / 10000 )) + php "$CC_ANALYZER" --workspace "$RUNNER_TEMP/cc-analyzer" batch fetch corpus \ + --limit "$(( ( HTML_API_BENCH_DOCUMENTS + warcs - 1 ) / warcs ))" \ + --limit-warcs "$warcs" \ + --max-jobs "$warcs" \ + --duration "$HTML_API_BENCH_FETCH_DURATION" \ + --progress text + + - name: Time parsing with both checkouts + run: | + for round in $( seq 1 "$HTML_API_BENCH_ROUNDS" ); do + if (( round % 2 )); then order='base merge'; else order='merge base'; fi + for checkout in $order; do + echo "Round ${round}: ${checkout}" + HTML_API_BENCH_WP_SRC="$GITHUB_WORKSPACE/$checkout/src" \ + CC_ANALYZER_OUTPUT_DIR="$RUNNER_TEMP/results/$checkout/round-$round" \ + php "$CC_ANALYZER" --workspace "$RUNNER_TEMP/cc-analyzer" batch run corpus "$SCRIPTS/benchmark.php" + done + done + + - name: Record support with both checkouts + run: | + for checkout in base merge; do + HTML_API_BENCH_WP_SRC="$GITHUB_WORKSPACE/$checkout/src" \ + CC_ANALYZER_OUTPUT_DIR="$RUNNER_TEMP/results/$checkout/analysis" \ + php "$CC_ANALYZER" --workspace "$RUNNER_TEMP/cc-analyzer" batch run corpus "$SCRIPTS/analysis.php" + done + + - name: Write the report + run: | + mkdir "$RUNNER_TEMP/comment" + echo "$PR_NUMBER" > "$RUNNER_TEMP/comment/NR" + php "$SCRIPTS/report.php" "$RUNNER_TEMP/results/base" "$RUNNER_TEMP/results/merge" > "$RUNNER_TEMP/comment/report.md" + env: + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pull-request }} + HTML_API_BENCH_BASE_SHA: ${{ steps.checkouts.outputs.base-sha }} + HTML_API_BENCH_MERGE_SHA: ${{ steps.checkouts.outputs.merge-sha }} + HTML_API_BENCH_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + - name: Upload the report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: html-api-commoncrawl-comment + path: ${{ runner.temp }}/comment/ + if-no-files-found: error + retention-days: 1 diff --git a/tools/html-api-commoncrawl/.gitattributes b/tools/html-api-commoncrawl/.gitattributes new file mode 100644 index 0000000000000..374d62b9c7ee4 --- /dev/null +++ b/tools/html-api-commoncrawl/.gitattributes @@ -0,0 +1 @@ +cc-analyzer.phar binary diff --git a/tools/html-api-commoncrawl/README.md b/tools/html-api-commoncrawl/README.md new file mode 100644 index 0000000000000..5c9a18769208d --- /dev/null +++ b/tools/html-api-commoncrawl/README.md @@ -0,0 +1,72 @@ +# HTML API CommonCrawl analysis + +Compares how the HTML API parses real-world documents with and without a pull request. It fetches a batch of HTML documents from the latest [CommonCrawl](https://commoncrawl.org/) crawl, then replays the same documents through both checkouts to measure: + +- how long the Tag Processor and the HTML Processor take to parse each document, for all documents and for small, medium, and large ones; +- which documents the HTML Processor does not support, and why it stopped on them. + +The [`HTML API CommonCrawl Analysis`](../../.github/workflows/html-api-commoncrawl.yml) workflow runs this for a pull request when: +- the `html-api-commoncrawl` label is added to the pull request, if it changes the HTML API. The label is removed straight away, so add it again to run another analysis. +- or it is run manually from the Actions tab with a pull request number. + +The report is left as a comment on the pull request. Each new analysis replaces the previous comment. + +## Files + +- `cc-analyzer.phar`: [cc-analyzer](#rebuilding-cc-analyzerphar), which fetches documents from CommonCrawl into a local batch, and replays a batch through an analysis script. +- `bootstrap.php`: loads the HTML API, and what `wp_kses()` needs, from the `src/` directory in `HTML_API_BENCH_WP_SRC`, without loading WordPress. It presents a UTF-8 site at example.com. When a change adds a file to the HTML API, add it to the list of files here. +- `benchmark.php`: an analysis script which times the Tag Processor and the HTML Processor on each document and writes `benchmark.csv`. +- `analysis.php`: an analysis script which records whether the HTML Processor supports each document and writes `analysis.csv`. +- `report.php`: compares the results from two checkouts and prints the Markdown report. + +## Running locally + +This needs PHP 8.5, as used by the workflow, with the `curl`, `pdo_sqlite`, and `zlib` extensions. Each analysis script runs in a separate process for each checkout, because PHP cannot load two versions of the HTML API into one process. + +```sh +# Fetch 1,000 documents, or as many as can be fetched in 5 minutes. +php -d memory_limit=1G tools/html-api-commoncrawl/cc-analyzer.phar --workspace /tmp/cc batch fetch corpus \ + --limit 1000 --duration 5m --progress text + +# Time each document three times with each checkout, and record support once. +for checkout in base merge; do + for round in 1 2 3; do + HTML_API_BENCH_WP_SRC="/path/to/$checkout/src" CC_ANALYZER_OUTPUT_DIR="/tmp/results/$checkout/round-$round" \ + php -d memory_limit=1G tools/html-api-commoncrawl/cc-analyzer.phar --workspace /tmp/cc \ + batch run corpus tools/html-api-commoncrawl/benchmark.php + done + + HTML_API_BENCH_WP_SRC="/path/to/$checkout/src" CC_ANALYZER_OUTPUT_DIR="/tmp/results/$checkout/analysis" \ + php -d memory_limit=1G tools/html-api-commoncrawl/cc-analyzer.phar --workspace /tmp/cc \ + batch run corpus tools/html-api-commoncrawl/analysis.php +done + +php tools/html-api-commoncrawl/report.php /tmp/results/base /tmp/results/merge > report.md +``` + +A batch can be replayed as many times as needed without fetching it again. + +## Settings + +The workflow reads these environment variables. When it is run manually, each one can be set from an input of the same name. + +| Variable | Default | Meaning | +| --- | --- | --- | +| `HTML_API_BENCH_DOCUMENTS` | `10000` | How many documents to fetch. | +| `HTML_API_BENCH_FETCH_DURATION` | `30m` | Longest time to spend fetching. Fetching stops at whichever limit is reached first. | +| `HTML_API_BENCH_ROUNDS` | `3` | How many times to time each document with each checkout. Each document's time is its median. | +| `HTML_API_BENCH_SMALL_MAX_BYTES` | `32768` | Documents smaller than this are small. | +| `HTML_API_BENCH_LARGE_MIN_BYTES` | `262144` | Documents at least this large are large. Everything between is medium. | + +## Rebuilding `cc-analyzer.phar` + +`cc-analyzer.phar` was built from cc-analyzer commit `cdf4b35` with `tools/build-phar.php`. Before building, the default HTTP identity was removed so that requests carry only a generic `User-Agent` and no `From` header: + +- `src/Http/CurlStreamClient.php`: `DEFAULT_USER_AGENT` set to `'cc-analyzer/0.1 PHP-cURL'` and `DEFAULT_CONTACT_HEADER` set to `''`. +- `src/Cli/Application.php`: the `--user-agent` and `--contact-email` defaults set to the same values, and the help text to match. + +To rebuild, make the same changes in a copy of cc-analyzer and run: + +```sh +php -d phar.readonly=0 tools/build-phar.php --output /path/to/wordpress-develop/tools/html-api-commoncrawl/cc-analyzer.phar +``` diff --git a/tools/html-api-commoncrawl/analysis.php b/tools/html-api-commoncrawl/analysis.php new file mode 100644 index 0000000000000..83737ed0912a9 --- /dev/null +++ b/tools/html-api-commoncrawl/analysis.php @@ -0,0 +1,66 @@ +body ); + while ( $processor->next_token() ) { + continue; + } + + $last_error = $processor->get_last_error(); + $reason = ''; + $line = ''; + $token_name = ''; + + if ( WP_HTML_Processor::ERROR_UNSUPPORTED === $last_error ) { + $exception = $processor->get_unsupported_exception(); + $reason = $exception->getMessage(); + $line = $exception->getTrace()[0]['line']; + $token_name = $exception->token_name; + } elseif ( null !== $last_error ) { + $reason = $last_error; + } + + fputcsv( + $csv, + array( + "{$document->inputStateKey}|{$document->recordId}", + $document->targetUri, + strlen( $document->body ), + $last_error ?? '', + $reason, + $line, + $token_name, + $processor->paused_at_incomplete_token() ? '1' : '0', + ), + ',', + '"', + '' + ); +}; diff --git a/tools/html-api-commoncrawl/benchmark.php b/tools/html-api-commoncrawl/benchmark.php new file mode 100644 index 0000000000000..ffe652fed5186 --- /dev/null +++ b/tools/html-api-commoncrawl/benchmark.php @@ -0,0 +1,74 @@ +Warm & up

One

Two
Three
'; +for ( $i = 0; $i < 100; $i++ ) { + $processor = new WP_HTML_Tag_Processor( $warm_up ); + while ( $processor->next_token() ) { + continue; + } + + $processor = WP_HTML_Processor::create_full_parser( $warm_up ); + while ( $processor->next_token() ) { + continue; + } +} + +return static function ( $document ) use ( $csv ) { + $html = $document->body; + + $started_at = hrtime( true ); + $tag = new WP_HTML_Tag_Processor( $html ); + while ( $tag->next_token() ) { + continue; + } + $tag_ns = hrtime( true ) - $started_at; + + // Free the Tag Processor here so that its destruction is not timed with the HTML Processor. + unset( $tag ); + + $started_at = hrtime( true ); + $processor = WP_HTML_Processor::create_full_parser( $html ); + while ( $processor->next_token() ) { + continue; + } + $html_ns = hrtime( true ) - $started_at; + + fputcsv( + $csv, + array( + "{$document->inputStateKey}|{$document->recordId}", + $document->targetUri, + strlen( $html ), + $tag_ns, + $html_ns, + ), + ',', + '"', + '' + ); +}; diff --git a/tools/html-api-commoncrawl/bootstrap.php b/tools/html-api-commoncrawl/bootstrap.php new file mode 100644 index 0000000000000..cf86d20dc80f0 --- /dev/null +++ b/tools/html-api-commoncrawl/bootstrap.php @@ -0,0 +1,102 @@ +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 XkAMFAy?^-kKl|SIzW4C{`u+a{kwk3x literal 0 HcmV?d00001 diff --git a/tools/html-api-commoncrawl/report.php b/tools/html-api-commoncrawl/report.php new file mode 100644 index 0000000000000..96aac8ea87962 --- /dev/null +++ b/tools/html-api-commoncrawl/report.php @@ -0,0 +1,608 @@ + report.md + * + * Environment variables: + * + * HTML_API_BENCH_SMALL_MAX_BYTES Documents smaller than this are "small". Default 32768. + * HTML_API_BENCH_LARGE_MIN_BYTES Documents at least this large are "large". Default 262144. + * HTML_API_BENCH_BASE_SHA Commit of the target branch, shown in the report. + * HTML_API_BENCH_MERGE_SHA Commit of the PR merged into the target branch, shown in the report. + * HTML_API_BENCH_RUN_URL Link to the workflow run, shown in the report. + * + * @package WordPress + */ + +const HTML_API_BENCH_REPORT_MARKER = ''; +const HTML_API_BENCH_EXAMPLES = 10; + +// The smallest share of documents a size bin at either end of the chart needs to be drawn. +const HTML_API_BENCH_CHART_MIN_BIN_SHARE = 0.02; + +$small_max_bytes = (int) ( getenv( 'HTML_API_BENCH_SMALL_MAX_BYTES' ) ? getenv( 'HTML_API_BENCH_SMALL_MAX_BYTES' ) : 32768 ); +$large_min_bytes = (int) ( getenv( 'HTML_API_BENCH_LARGE_MIN_BYTES' ) ? getenv( 'HTML_API_BENCH_LARGE_MIN_BYTES' ) : 262144 ); + +/** + * Reads a CSV file with a header row into a list of associative arrays. + * + * @param string $path Path to the CSV file. + * @return array[] Rows keyed by column name. + */ +function read_csv( $path ) { + $handle = fopen( $path, 'rb' ); + $header = fgetcsv( $handle, null, ',', '"', '' ); + $rows = array(); + $row = fgetcsv( $handle, null, ',', '"', '' ); + while ( false !== $row ) { + $rows[] = array_combine( $header, $row ); + $row = fgetcsv( $handle, null, ',', '"', '' ); + } + fclose( $handle ); + + return $rows; +} + +/** + * Returns the median of a list of numbers. + * + * @param array $values Numbers. + * @return float Median. + */ +function median( $values ) { + sort( $values ); + $count = count( $values ); + $middle = intdiv( $count, 2 ); + + return 0 === $count % 2 ? ( $values[ $middle - 1 ] + $values[ $middle ] ) / 2 : $values[ $middle ]; +} + +/** + * Returns the given percentile of a list of numbers using the nearest-rank method. + * + * @param array $values Numbers. + * @param int $percentile Percentile from 1 to 100. + * @return float Value at the percentile. + */ +function percentile( $values, $percentile ) { + sort( $values ); + + return $values[ max( 0, (int) ceil( $percentile / 100 * count( $values ) ) - 1 ) ]; +} + +/** + * Loads one checkout's results, keyed by document. + * + * @param string $dir Results directory. + * @return array[] Each document's URL, size, median parse times, and support details. + */ +function load_results( $dir ) { + $timings = array(); + foreach ( glob( "{$dir}/round-*/benchmark.csv" ) as $path ) { + foreach ( read_csv( $path ) as $row ) { + $timings[ $row['key'] ]['url'] = $row['url']; + $timings[ $row['key'] ]['bytes'] = (int) $row['bytes']; + $timings[ $row['key'] ]['tag_ns'][] = (int) $row['tag_ns']; + $timings[ $row['key'] ]['html_ns'][] = (int) $row['html_ns']; + } + } + + $documents = array(); + foreach ( read_csv( "{$dir}/analysis/analysis.csv" ) as $row ) { + $timing = $timings[ $row['key'] ]; + if ( $timing['url'] !== $row['url'] ) { + throw new Exception( "Benchmark and analysis disagree on the URL for {$row['key']}." ); + } + + $documents[ $row['key'] ] = array( + 'url' => $row['url'], + 'bytes' => $timing['bytes'], + 'tag_ns' => median( $timing['tag_ns'] ), + 'html_ns' => median( $timing['html_ns'] ), + 'supported' => '' === $row['last_error'], + 'reason' => $row['reason'], + 'line' => $row['line'], + 'incomplete' => '1' === $row['incomplete'], + ); + } + + if ( count( $documents ) !== count( $timings ) ) { + throw new Exception( "Benchmark and analysis cover different documents in {$dir}." ); + } + + return $documents; +} + +/** + * Formats a duration in nanoseconds with a unit suited to its size. + * + * @param float $ns Duration in nanoseconds. + * @param bool $signed Whether to always show the sign. + * @return string Formatted duration. + */ +function format_ns( $ns, $signed = false ) { + $sign = $ns < 0 ? '−' : ( $signed && $ns > 0 ? '+' : '' ); + $ns = abs( $ns ); + + if ( $ns >= 1e9 ) { + return sprintf( '%s%.2f s', $sign, $ns / 1e9 ); + } + if ( $ns >= 1e6 ) { + return sprintf( '%s%.2f ms', $sign, $ns / 1e6 ); + } + if ( $ns >= 1e3 ) { + return sprintf( '%s%.1f µs', $sign, $ns / 1e3 ); + } + + return sprintf( '%s%d ns', $sign, $ns ); +} + +/** + * Formats a percentage change. + * + * @param float $percent Percentage. + * @return string Formatted percentage. + */ +function format_percent( $percent ) { + return sprintf( '%s%.2f%%', $percent < 0 ? '−' : ( $percent > 0 ? '+' : '' ), abs( $percent ) ); +} + +/** + * Formats a byte count. + * + * @param int $bytes Byte count. + * @return string Formatted size. + */ +function format_bytes( $bytes ) { + if ( $bytes >= 1048576 ) { + return sprintf( '%.1f MiB', $bytes / 1048576 ); + } + if ( $bytes >= 1024 ) { + return sprintf( '%d KiB', round( $bytes / 1024 ) ); + } + + return "{$bytes} B"; +} + +/** + * Formats a count with its share of a total. + * + * @param int $count Count. + * @param int $total Total. + * @return string Formatted count. + */ +function format_share( $count, $total ) { + return 0 === $total ? '0' : sprintf( '%s (%.1f%%)', number_format( $count ), 100 * $count / $total ); +} + +/** + * Formats a signed count. + * + * @param int $count Count. + * @return string Formatted count. + */ +function format_delta( $count ) { + return $count > 0 ? '+' . number_format( $count ) : ( $count < 0 ? '−' . number_format( -$count ) : '0' ); +} + +/** + * Formats text from a crawled document for safe display as inline code in a Markdown table. + * + * @param string $text Text to display. + * @param int $max_length Longest text to show before truncating. + * @return string Inline code. + */ +function format_code( $text, $max_length = 100 ) { + if ( mb_strlen( $text ) > $max_length ) { + $text = mb_substr( $text, 0, $max_length - 1 ) . '…'; + } + + return '`' . str_replace( array( '`', '|', "\n", "\r" ), array( "'", '\|', ' ', ' ' ), $text ) . '`'; +} + +/** + * Renders a Markdown table. + * + * @param string[] $header Column headings. + * @param array[] $rows Table cells. + * @return string Markdown table. + */ +function table( $header, $rows ) { + $lines = array(); + $lines[] = '| ' . implode( ' | ', $header ) . ' |'; + $lines[] = '|' . str_repeat( ' --- |', count( $header ) ); + foreach ( $rows as $row ) { + $lines[] = '| ' . implode( ' | ', $row ) . ' |'; + } + + return implode( "\n", $lines ) . "\n"; +} + +/** + * Renders a table comparing parse times for each size group. + * + * @param array[] $pairs Documents, each with `base` and `merge` results. + * @param array $groups Group names mapped to a predicate on document size. + * @param string $parser Either `tag_ns` or `html_ns`. + * @return string Markdown table. + */ +function timing_table( $pairs, $groups, $parser ) { + $rows = array(); + foreach ( $groups as $label => $in_group ) { + $base_total = 0; + $merge_total = 0; + $deltas = array(); + $percents = array(); + foreach ( $pairs as $pair ) { + if ( ! $in_group( $pair['base']['bytes'] ) ) { + continue; + } + + $base = $pair['base'][ $parser ]; + $merge = $pair['merge'][ $parser ]; + $base_total += $base; + $merge_total += $merge; + $deltas[] = $merge - $base; + $percents[] = 100 * ( $merge - $base ) / $base; + } + + if ( array() === $deltas ) { + $rows[] = array( $label, '0', '', '', '', '', '', '', '', '' ); + continue; + } + + $rows[] = array( + $label, + number_format( count( $deltas ) ), + format_ns( $base_total ), + format_ns( $merge_total ), + format_ns( $merge_total - $base_total, true ), + '**' . format_percent( 100 * ( $merge_total - $base_total ) / $base_total ) . '**', + format_ns( array_sum( $deltas ) / count( $deltas ), true ), + format_ns( median( $deltas ), true ), + format_percent( median( $percents ) ), + format_ns( percentile( $deltas, 95 ), true ), + ); + } + + return table( + array( 'Group', 'Docs', 'Target total', 'PR total', 'Δ total', 'Δ total %', 'Mean Δ/doc', 'Median Δ/doc', 'Median Δ%/doc', 'p95 Δ/doc' ), + $rows + ); +} + +/** + * Renders a Mermaid chart of median parse time by document size for both checkouts. + * + * Documents are binned by size in powers of two, with every document under 2 KiB in the first bin. + * Bins at either end with too few documents for a meaningful median are left out, as otherwise + * a handful of very large documents sets the scale and hides the differences everywhere else. + * + * @param array[] $pairs Documents, each with `base` and `merge` results. + * @param string $parser Either `tag_ns` or `html_ns`. + * @param string $title Chart title. + * @return string Mermaid code block. + */ +function size_chart( $pairs, $parser, $title ) { + $bins = array(); + foreach ( $pairs as $pair ) { + $bin = max( 10, (int) floor( log( max( 1, $pair['base']['bytes'] ), 2 ) ) ); + $bins[ $bin ]['base'][] = $pair['base'][ $parser ] / 1e6; + $bins[ $bin ]['merge'][] = $pair['merge'][ $parser ] / 1e6; + } + ksort( $bins ); + + $min_count = HTML_API_BENCH_CHART_MIN_BIN_SHARE * count( $pairs ); + $left_out = 0; + foreach ( array( 'array_key_first', 'array_key_last' ) as $end ) { + while ( count( $bins[ $end( $bins ) ]['base'] ) < $min_count ) { + $left_out += count( $bins[ $end( $bins ) ]['base'] ); + unset( $bins[ $end( $bins ) ] ); + } + } + + $labels = array(); + $base = array(); + $merge = array(); + foreach ( $bins as $bin => $times ) { + $labels[] = 10 === $bin ? '"0"' : ( $bin < 20 ? '"' . 2 ** ( $bin - 10 ) . 'K"' : '"' . 2 ** ( $bin - 20 ) . 'M"' ); + $base[] = round( median( $times['base'] ), 3 ); + $merge[] = round( median( $times['merge'] ), 3 ); + } + + return implode( + "\n", + array( + '```mermaid', + '%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#2271b1, #d63638"}}}}%%', + 'xychart-beta', + " title \"{$title}\"", + ' x-axis "Document size (bytes, at least)" [' . implode( ', ', $labels ) . ']', + ' y-axis "Median parse time (ms)"', + ' line [' . implode( ', ', $base ) . ']', + ' line [' . implode( ', ', $merge ) . ']', + '```', + '', + 'Blue: target branch. Red: PR merged into the target branch.' . ( + $left_out > 0 + ? sprintf( ' Sizes at either end with fewer than %d%% of documents are not drawn (%s documents).', 100 * HTML_API_BENCH_CHART_MIN_BIN_SHARE, number_format( $left_out ) ) + : '' + ) . '', + '', + ) + ); +} + +/** + * Renders the documents whose parse time grew the most. + * + * @param array[] $pairs Documents, each with `base` and `merge` results. + * @param string $parser Either `tag_ns` or `html_ns`. + * @return string Markdown table. + */ +function slowdowns_table( $pairs, $parser ) { + usort( + $pairs, + static function ( $a, $b ) use ( $parser ) { + return ( $b['merge'][ $parser ] - $b['base'][ $parser ] ) <=> ( $a['merge'][ $parser ] - $a['base'][ $parser ] ); + } + ); + + $rows = array(); + foreach ( array_slice( $pairs, 0, HTML_API_BENCH_EXAMPLES ) as $pair ) { + $base = $pair['base'][ $parser ]; + $merge = $pair['merge'][ $parser ]; + $rows[] = array( + format_code( $pair['base']['url'] ), + format_bytes( $pair['base']['bytes'] ), + format_ns( $base ), + format_ns( $merge ), + format_ns( $merge - $base, true ), + format_percent( 100 * ( $merge - $base ) / $base ), + ); + } + + return table( array( 'URL', 'Size', 'Target', 'PR', 'Δ', 'Δ%' ), $rows ); +} + +/** + * Renders example documents whose support changed. + * + * @param array[] $pairs Documents, each with `base` and `merge` results. + * @return string Markdown table. + */ +function examples_table( $pairs ) { + $rows = array(); + foreach ( array_slice( $pairs, 0, HTML_API_BENCH_EXAMPLES ) as $pair ) { + $rows[] = array( + format_code( $pair['base']['url'] ), + format_bytes( $pair['base']['bytes'] ), + $pair['base']['supported'] ? 'supported' : format_code( "L{$pair['base']['line']}: {$pair['base']['reason']}" ), + $pair['merge']['supported'] ? 'supported' : format_code( "L{$pair['merge']['line']}: {$pair['merge']['reason']}" ), + ); + } + + return table( array( 'URL', 'Size', 'Target', 'PR' ), $rows ); +} + +$base_results = load_results( $argv[1] ); +$merge_results = load_results( $argv[2] ); + +$pairs = array(); +foreach ( $base_results as $key => $base ) { + $pairs[ $key ] = array( + 'base' => $base, + 'merge' => $merge_results[ $key ], + ); +} + +if ( count( $pairs ) !== count( $merge_results ) ) { + throw new Exception( 'The target and PR results cover different documents.' ); +} + +$supported_in_both = array_filter( + $pairs, + static function ( $pair ) { + return $pair['base']['supported'] && $pair['merge']['supported']; + } +); + +$groups = array( + 'All' => static function () { + return true; + }, + 'Small (< ' . format_bytes( $small_max_bytes ) . ')' => static function ( $bytes ) use ( $small_max_bytes ) { + return $bytes < $small_max_bytes; + }, + 'Medium' => static function ( $bytes ) use ( $small_max_bytes, $large_min_bytes ) { + return $bytes >= $small_max_bytes && $bytes < $large_min_bytes; + }, + 'Large (≥ ' . format_bytes( $large_min_bytes ) . ')' => static function ( $bytes ) use ( $large_min_bytes ) { + return $bytes >= $large_min_bytes; + }, +); + +$rounds = count( glob( "{$argv[1]}/round-*/benchmark.csv" ) ); +preg_match( '~/crawl-data/([^/]+)/~', array_key_first( $pairs ), $crawl ); +$base_sha = getenv( 'HTML_API_BENCH_BASE_SHA' ); +$merge_sha = getenv( 'HTML_API_BENCH_MERGE_SHA' ); +$run_url = getenv( 'HTML_API_BENCH_RUN_URL' ); + +$report = HTML_API_BENCH_REPORT_MARKER . "\n"; +$report .= "## HTML API CommonCrawl analysis\n\n"; +$report .= sprintf( + "Parsed %s documents from CommonCrawl `%s` with the target branch%s and with this PR merged into it%s. Each parse time is the median of %d rounds. Negative deltas mean the PR is faster.%s\n\n", + number_format( count( $pairs ) ), + $crawl[1], + $base_sha ? ' (`' . substr( $base_sha, 0, 10 ) . '`)' : '', + $merge_sha ? ' (`' . substr( $merge_sha, 0, 10 ) . '`)' : '', + $rounds, + $run_url ? " [Workflow run]({$run_url})." : '' +); + +$report .= "### HTML Processor parse time\n\n"; +$report .= "All documents, including those where parsing stopped early on unsupported markup:\n\n"; +$report .= timing_table( $pairs, $groups, 'html_ns' ) . "\n"; +$report .= sprintf( "Documents supported by both the target branch and the PR (%s):\n\n", number_format( count( $supported_in_both ) ) ); +$report .= timing_table( $supported_in_both, $groups, 'html_ns' ) . "\n"; +$report .= size_chart( $pairs, 'html_ns', 'HTML Processor: median parse time by document size' ) . "\n"; +$report .= "
Largest HTML Processor slowdowns\n\n" . slowdowns_table( $pairs, 'html_ns' ) . "\n
\n\n"; + +$report .= "### Tag Processor parse time\n\n"; +$report .= timing_table( $pairs, $groups, 'tag_ns' ) . "\n"; +$report .= size_chart( $pairs, 'tag_ns', 'Tag Processor: median parse time by document size' ) . "\n"; +$report .= "
Largest Tag Processor slowdowns\n\n" . slowdowns_table( $pairs, 'tag_ns' ) . "\n
\n\n"; + +$report .= "### HTML Processor support\n\n"; +$rows = array(); +foreach ( $groups as $label => $in_group ) { + $count = 0; + $base_unsupported = 0; + $merge_unsupported = 0; + $base_incomplete = 0; + $merge_incomplete = 0; + foreach ( $pairs as $pair ) { + if ( ! $in_group( $pair['base']['bytes'] ) ) { + continue; + } + + ++$count; + $base_unsupported += $pair['base']['supported'] ? 0 : 1; + $merge_unsupported += $pair['merge']['supported'] ? 0 : 1; + $base_incomplete += $pair['base']['incomplete'] ? 1 : 0; + $merge_incomplete += $pair['merge']['incomplete'] ? 1 : 0; + } + + $rows[] = array( + $label, + number_format( $count ), + format_share( $base_unsupported, $count ), + format_share( $merge_unsupported, $count ), + '**' . format_delta( $merge_unsupported - $base_unsupported ) . '**', + number_format( $base_incomplete ), + number_format( $merge_incomplete ), + ); +} +$report .= table( array( 'Group', 'Docs', 'Unsupported (target)', 'Unsupported (PR)', 'Δ', 'Incomplete (target)', 'Incomplete (PR)' ), $rows ) . "\n"; +$report .= "Incomplete documents end inside a token, usually because the crawler truncated them. They are not counted as unsupported.\n\n"; + +$reasons = array(); +foreach ( $pairs as $pair ) { + foreach ( array( 'base', 'merge' ) as $side ) { + $result = $pair[ $side ]; + if ( $result['supported'] ) { + continue; + } + + $reasons[ $result['reason'] ][ $side ]['count'] = ( $reasons[ $result['reason'] ][ $side ]['count'] ?? 0 ) + 1; + if ( '' !== $result['line'] ) { + $reasons[ $result['reason'] ][ $side ]['lines'][ $result['line'] ] = ( $reasons[ $result['reason'] ][ $side ]['lines'][ $result['line'] ] ?? 0 ) + 1; + } + } +} +uasort( + $reasons, + static function ( $a, $b ) { + return max( $b['base']['count'] ?? 0, $b['merge']['count'] ?? 0 ) <=> max( $a['base']['count'] ?? 0, $a['merge']['count'] ?? 0 ); + } +); + +/* + * Links call sites to the source when running on GitHub Actions, which provides + * the repository. Both the target and the merge commits are in the repository. + */ +$source_url = static function ( $sha ) { + $server = getenv( 'GITHUB_SERVER_URL' ); + $repository = getenv( 'GITHUB_REPOSITORY' ); + + return $server && $repository && $sha + ? "{$server}/{$repository}/blob/{$sha}/src/wp-includes/html-api/class-wp-html-processor.php" + : null; +}; +$format_lines = static function ( $lines, $url ) { + ksort( $lines ); + $parts = array(); + foreach ( $lines as $line => $count ) { + $parts[] = $url ? "[L{$line}]({$url}#L{$line}) ({$count})" : "L{$line} ({$count})"; + } + + return implode( ', ', $parts ); +}; + +$rows = array(); +foreach ( $reasons as $reason => $sides ) { + $base_count = $sides['base']['count'] ?? 0; + $merge_count = $sides['merge']['count'] ?? 0; + $rows[] = array( + format_code( $reason, 120 ), + number_format( $base_count ), + number_format( $merge_count ), + format_delta( $merge_count - $base_count ), + $format_lines( $sides['base']['lines'] ?? array(), $source_url( $base_sha ) ), + $format_lines( $sides['merge']['lines'] ?? array(), $source_url( $merge_sha ) ), + ); +} +$report .= "Unsupported documents by the reason the HTML Processor stopped. Call sites are lines in `class-wp-html-processor.php` of each checkout.\n\n"; +$report .= table( array( 'Reason', 'Target', 'PR', 'Δ', 'Target call sites', 'PR call sites' ), $rows ) . "\n"; + +$newly_unsupported = array_values( + array_filter( + $pairs, + static function ( $pair ) { + return $pair['base']['supported'] && ! $pair['merge']['supported']; + } + ) +); +$newly_supported = array_values( + array_filter( + $pairs, + static function ( $pair ) { + return ! $pair['base']['supported'] && $pair['merge']['supported']; + } + ) +); +$changed_reason = array_values( + array_filter( + $pairs, + static function ( $pair ) { + return ! $pair['base']['supported'] && ! $pair['merge']['supported'] && $pair['base']['reason'] !== $pair['merge']['reason']; + } + ) +); + +foreach ( + array( + 'Newly unsupported with this PR' => $newly_unsupported, + 'Newly supported with this PR' => $newly_supported, + 'Unsupported for a different reason with this PR' => $changed_reason, + ) as $label => $changed +) { + $report .= sprintf( "- **%s:** %s\n", $label, number_format( count( $changed ) ) ); +} +$report .= "\n"; + +foreach ( + array( + 'Newly unsupported examples' => $newly_unsupported, + 'Newly supported examples' => $newly_supported, + 'Different-reason examples' => $changed_reason, + ) as $label => $changed +) { + if ( array() !== $changed ) { + $report .= "
{$label}\n\n" . examples_table( $changed ) . "\n
\n\n"; + } +} + +// GitHub rejects comments longer than this. +if ( mb_strlen( $report ) > 65536 ) { + throw new Exception( sprintf( 'The report is %d characters, which is too long for a GitHub comment.', mb_strlen( $report ) ) ); +} + +echo $report; From ff9fd403d8ad7834e6f840eb2195739c3ef1d37e Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Sat, 31 Oct 2026 20:49:48 +0000 Subject: [PATCH 12/25] HTML API: Time wp_kses() in the CommonCrawl analysis. Adds `wp_kses( $html, 'post' )` as a third measurement beside the Tag Processor and the HTML Processor. Each document's time is recorded for both checkouts, and the report gains a section for it with the same per-size-group table, chart, and largest slowdowns as the parsers. Only the files `wp_kses()` needs are loaded, without WordPress's default filters such as those on `pre_kses`, so its times show how a change affects `wp_kses()` itself rather than what a full site would measure. The report notes this. Since `wp_kses()` is now measured, pull requests which change `kses.php` can also request the analysis. See #66430. Co-Authored-By: Claude Opus 5.5 --- .../html-api-commoncrawl-comment.yml | 1 + .github/workflows/html-api-commoncrawl.yml | 7 ++++-- tools/html-api-commoncrawl/README.md | 6 ++--- tools/html-api-commoncrawl/benchmark.php | 25 ++++++++++++++----- tools/html-api-commoncrawl/report.php | 18 +++++++++---- 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/.github/workflows/html-api-commoncrawl-comment.yml b/.github/workflows/html-api-commoncrawl-comment.yml index a6e29b527bb3a..68b48bbb783f1 100644 --- a/.github/workflows/html-api-commoncrawl-comment.yml +++ b/.github/workflows/html-api-commoncrawl-comment.yml @@ -14,6 +14,7 @@ on: paths: - 'src/wp-includes/html-api/**' - 'src/wp-includes/class-wp-token-map.php' + - 'src/wp-includes/kses.php' - 'tools/html-api-commoncrawl/**' - '.github/workflows/html-api-commoncrawl.yml' - '.github/workflows/html-api-commoncrawl-comment.yml' diff --git a/.github/workflows/html-api-commoncrawl.yml b/.github/workflows/html-api-commoncrawl.yml index 5c5d0034bf1d4..175c426eb97e8 100644 --- a/.github/workflows/html-api-commoncrawl.yml +++ b/.github/workflows/html-api-commoncrawl.yml @@ -1,4 +1,5 @@ -# Compares HTML API parsing performance and support with and without a pull request, using documents from CommonCrawl. +# Compares HTML API parsing performance and support, and wp_kses() performance, with and without a pull request, using +# documents from CommonCrawl. # # This takes tens of minutes, so it only runs on request: either by adding the `html-api-commoncrawl` label to a pull # request, which is removed again as soon as it is added, or by running this workflow manually with a pull request @@ -16,6 +17,7 @@ on: paths: - 'src/wp-includes/html-api/**' - 'src/wp-includes/class-wp-token-map.php' + - 'src/wp-includes/kses.php' - 'tools/html-api-commoncrawl/**' - '.github/workflows/html-api-commoncrawl.yml' - '.github/workflows/html-api-commoncrawl-comment.yml' @@ -75,7 +77,8 @@ jobs: # - Checks out the pull request merged into its target branch, and the target branch beside it. # - Sets up PHP. # - Fetches a batch of documents from the latest CommonCrawl crawl. - # - Times parsing each document with both checkouts, alternating which goes first in each round. + # - Times the Tag Processor, the HTML Processor, and wp_kses() on each document with both checkouts, alternating which + # goes first in each round. # - Records which documents each checkout supports, and why the others are unsupported. # - Writes the report. # - Uploads the report and the pull request number. diff --git a/tools/html-api-commoncrawl/README.md b/tools/html-api-commoncrawl/README.md index 5c9a18769208d..d3a9b39844391 100644 --- a/tools/html-api-commoncrawl/README.md +++ b/tools/html-api-commoncrawl/README.md @@ -2,11 +2,11 @@ Compares how the HTML API parses real-world documents with and without a pull request. It fetches a batch of HTML documents from the latest [CommonCrawl](https://commoncrawl.org/) crawl, then replays the same documents through both checkouts to measure: -- how long the Tag Processor and the HTML Processor take to parse each document, for all documents and for small, medium, and large ones; +- how long the Tag Processor and the HTML Processor take to parse each document, and how long `wp_kses( $html, 'post' )` takes on it, for all documents and for small, medium, and large ones; - which documents the HTML Processor does not support, and why it stopped on them. The [`HTML API CommonCrawl Analysis`](../../.github/workflows/html-api-commoncrawl.yml) workflow runs this for a pull request when: -- the `html-api-commoncrawl` label is added to the pull request, if it changes the HTML API. The label is removed straight away, so add it again to run another analysis. +- the `html-api-commoncrawl` label is added to the pull request, if it changes the HTML API or `kses.php`. The label is removed straight away, so add it again to run another analysis. - or it is run manually from the Actions tab with a pull request number. The report is left as a comment on the pull request. Each new analysis replaces the previous comment. @@ -15,7 +15,7 @@ The report is left as a comment on the pull request. Each new analysis replaces - `cc-analyzer.phar`: [cc-analyzer](#rebuilding-cc-analyzerphar), which fetches documents from CommonCrawl into a local batch, and replays a batch through an analysis script. - `bootstrap.php`: loads the HTML API, and what `wp_kses()` needs, from the `src/` directory in `HTML_API_BENCH_WP_SRC`, without loading WordPress. It presents a UTF-8 site at example.com. When a change adds a file to the HTML API, add it to the list of files here. -- `benchmark.php`: an analysis script which times the Tag Processor and the HTML Processor on each document and writes `benchmark.csv`. +- `benchmark.php`: an analysis script which times the Tag Processor, the HTML Processor, and `wp_kses( $html, 'post' )` on each document and writes `benchmark.csv`. - `analysis.php`: an analysis script which records whether the HTML Processor supports each document and writes `analysis.csv`. - `report.php`: compares the results from two checkouts and prints the Markdown report. diff --git a/tools/html-api-commoncrawl/benchmark.php b/tools/html-api-commoncrawl/benchmark.php index ffe652fed5186..33be1a2f05317 100644 --- a/tools/html-api-commoncrawl/benchmark.php +++ b/tools/html-api-commoncrawl/benchmark.php @@ -1,10 +1,11 @@ Warm & up

One

Two
Three
'; for ( $i = 0; $i < 100; $i++ ) { $processor = new WP_HTML_Tag_Processor( $warm_up ); @@ -36,6 +37,8 @@ while ( $processor->next_token() ) { continue; } + + wp_kses( $warm_up, 'post' ); } return static function ( $document ) use ( $csv ) { @@ -58,6 +61,15 @@ } $html_ns = hrtime( true ) - $started_at; + // Free the HTML Processor here so that its destruction is not timed with wp_kses(). + unset( $processor ); + + $started_at = hrtime( true ); + $kses = wp_kses( $html, 'post' ); + $kses_ns = hrtime( true ) - $started_at; + + unset( $kses ); + fputcsv( $csv, array( @@ -66,6 +78,7 @@ strlen( $html ), $tag_ns, $html_ns, + $kses_ns, ), ',', '"', diff --git a/tools/html-api-commoncrawl/report.php b/tools/html-api-commoncrawl/report.php index 96aac8ea87962..d958f173c7ad4 100644 --- a/tools/html-api-commoncrawl/report.php +++ b/tools/html-api-commoncrawl/report.php @@ -91,6 +91,7 @@ function load_results( $dir ) { $timings[ $row['key'] ]['bytes'] = (int) $row['bytes']; $timings[ $row['key'] ]['tag_ns'][] = (int) $row['tag_ns']; $timings[ $row['key'] ]['html_ns'][] = (int) $row['html_ns']; + $timings[ $row['key'] ]['kses_ns'][] = (int) $row['kses_ns']; } } @@ -106,6 +107,7 @@ function load_results( $dir ) { 'bytes' => $timing['bytes'], 'tag_ns' => median( $timing['tag_ns'] ), 'html_ns' => median( $timing['html_ns'] ), + 'kses_ns' => median( $timing['kses_ns'] ), 'supported' => '' === $row['last_error'], 'reason' => $row['reason'], 'line' => $row['line'], @@ -230,7 +232,7 @@ function table( $header, $rows ) { * * @param array[] $pairs Documents, each with `base` and `merge` results. * @param array $groups Group names mapped to a predicate on document size. - * @param string $parser Either `tag_ns` or `html_ns`. + * @param string $parser One of `tag_ns`, `html_ns`, or `kses_ns`. * @return string Markdown table. */ function timing_table( $pairs, $groups, $parser ) { @@ -286,7 +288,7 @@ function timing_table( $pairs, $groups, $parser ) { * a handful of very large documents sets the scale and hides the differences everywhere else. * * @param array[] $pairs Documents, each with `base` and `merge` results. - * @param string $parser Either `tag_ns` or `html_ns`. + * @param string $parser One of `tag_ns`, `html_ns`, or `kses_ns`. * @param string $title Chart title. * @return string Mermaid code block. */ @@ -325,7 +327,7 @@ function size_chart( $pairs, $parser, $title ) { 'xychart-beta', " title \"{$title}\"", ' x-axis "Document size (bytes, at least)" [' . implode( ', ', $labels ) . ']', - ' y-axis "Median parse time (ms)"', + ' y-axis "Median time (ms)"', ' line [' . implode( ', ', $base ) . ']', ' line [' . implode( ', ', $merge ) . ']', '```', @@ -344,7 +346,7 @@ function size_chart( $pairs, $parser, $title ) { * Renders the documents whose parse time grew the most. * * @param array[] $pairs Documents, each with `base` and `merge` results. - * @param string $parser Either `tag_ns` or `html_ns`. + * @param string $parser One of `tag_ns`, `html_ns`, or `kses_ns`. * @return string Markdown table. */ function slowdowns_table( $pairs, $parser ) { @@ -438,7 +440,7 @@ static function ( $pair ) { $report = HTML_API_BENCH_REPORT_MARKER . "\n"; $report .= "## HTML API CommonCrawl analysis\n\n"; $report .= sprintf( - "Parsed %s documents from CommonCrawl `%s` with the target branch%s and with this PR merged into it%s. Each parse time is the median of %d rounds. Negative deltas mean the PR is faster.%s\n\n", + "Parsed %s documents from CommonCrawl `%s` with the target branch%s and with this PR merged into it%s. Each time is the median of %d rounds. Negative deltas mean the PR is faster.%s\n\n", number_format( count( $pairs ) ), $crawl[1], $base_sha ? ' (`' . substr( $base_sha, 0, 10 ) . '`)' : '', @@ -460,6 +462,12 @@ static function ( $pair ) { $report .= size_chart( $pairs, 'tag_ns', 'Tag Processor: median parse time by document size' ) . "\n"; $report .= "

Largest Tag Processor slowdowns\n\n" . slowdowns_table( $pairs, 'tag_ns' ) . "\n
\n\n"; +$report .= "### wp_kses() time\n\n"; +$report .= "Time for `wp_kses( \$html, 'post' )`. Only the files it needs are loaded, without WordPress's default filters such as those on `pre_kses`, so times differ from a full site.\n\n"; +$report .= timing_table( $pairs, $groups, 'kses_ns' ) . "\n"; +$report .= size_chart( $pairs, 'kses_ns', 'wp_kses(): median time by document size' ) . "\n"; +$report .= "
Largest wp_kses() slowdowns\n\n" . slowdowns_table( $pairs, 'kses_ns' ) . "\n
\n\n"; + $report .= "### HTML Processor support\n\n"; $rows = array(); foreach ( $groups as $label => $in_group ) { From ba073ab3651ac89e1aac200fee14d9513969c9e4 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Sun, 1 Nov 2026 04:24:07 +0000 Subject: [PATCH 13/25] HTML API: Sample CommonCrawl documents from the index. The first run of the CommonCrawl analysis spent half an hour fetching one WARC file: cc-analyzer re-summed its whole cache before storing each document, and read every record of the file, though only about a third are HTML responses. Rebuild cc-analyzer.phar from a cc-analyzer branch which keeps a running total of cached bytes, can sample captures from random blocks of a crawl's URL index, and downloads those records concurrently, retrying throttled transfers without holding up the others. The fetch now samples successful `text/html` captures from many hosts across the whole crawl, downloads only those records four at a time, and retries transfers which CommonCrawl throttles. See #66430. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/html-api-commoncrawl.yml | 16 ++++++++++------ tools/html-api-commoncrawl/README.md | 10 +++++----- tools/html-api-commoncrawl/cc-analyzer.phar | Bin 489566 -> 520691 bytes 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/html-api-commoncrawl.yml b/.github/workflows/html-api-commoncrawl.yml index 175c426eb97e8..30f7a33cb1eff 100644 --- a/.github/workflows/html-api-commoncrawl.yml +++ b/.github/workflows/html-api-commoncrawl.yml @@ -76,7 +76,7 @@ jobs: # Performs the following steps: # - Checks out the pull request merged into its target branch, and the target branch beside it. # - Sets up PHP. - # - Fetches a batch of documents from the latest CommonCrawl crawl. + # - Fetches a random sample of HTML documents from the latest CommonCrawl crawl. # - Times the Tag Processor, the HTML Processor, and wp_kses() on each document with both checkouts, alternating which # goes first in each round. # - Records which documents each checkout supports, and why the others are unsupported. @@ -118,14 +118,18 @@ jobs: extensions: curl, mbstring, pdo_sqlite, zlib ini-values: memory_limit=1G - # Each WARC file from a crawl holds around 20,000 HTML documents, so documents are taken from up to 10,000 per file. + # Samples successful HTML captures from random blocks of the crawl's URL index, from many hosts across the whole crawl, + # and downloads only those records, four at a time. CommonCrawl answers bursts of requests with HTTP 503, so each + # transfer is retried with backoff. The cache limit is raised so that pruning never runs during the fetch. - name: Fetch documents from CommonCrawl run: | - warcs=$(( ( HTML_API_BENCH_DOCUMENTS + 9999 ) / 10000 )) php "$CC_ANALYZER" --workspace "$RUNNER_TEMP/cc-analyzer" batch fetch corpus \ - --limit "$(( ( HTML_API_BENCH_DOCUMENTS + warcs - 1 ) / warcs ))" \ - --limit-warcs "$warcs" \ - --max-jobs "$warcs" \ + --sample index \ + --resource-types html \ + --limit "$HTML_API_BENCH_DOCUMENTS" \ + --concurrency 4 \ + --http-retries 6 \ + --cache-max-bytes 17179869184 \ --duration "$HTML_API_BENCH_FETCH_DURATION" \ --progress text diff --git a/tools/html-api-commoncrawl/README.md b/tools/html-api-commoncrawl/README.md index d3a9b39844391..51bb3292bfe11 100644 --- a/tools/html-api-commoncrawl/README.md +++ b/tools/html-api-commoncrawl/README.md @@ -1,6 +1,6 @@ # HTML API CommonCrawl analysis -Compares how the HTML API parses real-world documents with and without a pull request. It fetches a batch of HTML documents from the latest [CommonCrawl](https://commoncrawl.org/) crawl, then replays the same documents through both checkouts to measure: +Compares how the HTML API parses real-world documents with and without a pull request. It fetches a random sample of HTML documents from the latest [CommonCrawl](https://commoncrawl.org/) crawl, then replays the same documents through both checkouts to measure: - how long the Tag Processor and the HTML Processor take to parse each document, and how long `wp_kses( $html, 'post' )` takes on it, for all documents and for small, medium, and large ones; - which documents the HTML Processor does not support, and why it stopped on them. @@ -13,7 +13,7 @@ The report is left as a comment on the pull request. Each new analysis replaces ## Files -- `cc-analyzer.phar`: [cc-analyzer](#rebuilding-cc-analyzerphar), which fetches documents from CommonCrawl into a local batch, and replays a batch through an analysis script. +- `cc-analyzer.phar`: [cc-analyzer](#rebuilding-cc-analyzerphar), which fetches documents from CommonCrawl into a local batch, and replays a batch through an analysis script. It samples random blocks of the crawl's URL index, keeps successful `text/html` captures from up to ten different hosts in each block, and downloads only those records, four at a time. - `bootstrap.php`: loads the HTML API, and what `wp_kses()` needs, from the `src/` directory in `HTML_API_BENCH_WP_SRC`, without loading WordPress. It presents a UTF-8 site at example.com. When a change adds a file to the HTML API, add it to the list of files here. - `benchmark.php`: an analysis script which times the Tag Processor, the HTML Processor, and `wp_kses( $html, 'post' )` on each document and writes `benchmark.csv`. - `analysis.php`: an analysis script which records whether the HTML Processor supports each document and writes `analysis.csv`. @@ -26,7 +26,7 @@ This needs PHP 8.5, as used by the workflow, with the `curl`, `pdo_sqlite`, and ```sh # Fetch 1,000 documents, or as many as can be fetched in 5 minutes. php -d memory_limit=1G tools/html-api-commoncrawl/cc-analyzer.phar --workspace /tmp/cc batch fetch corpus \ - --limit 1000 --duration 5m --progress text + --sample index --resource-types html --limit 1000 --concurrency 4 --http-retries 6 --duration 5m --progress text # Time each document three times with each checkout, and record support once. for checkout in base merge; do @@ -53,14 +53,14 @@ The workflow reads these environment variables. When it is run manually, each on | Variable | Default | Meaning | | --- | --- | --- | | `HTML_API_BENCH_DOCUMENTS` | `10000` | How many documents to fetch. | -| `HTML_API_BENCH_FETCH_DURATION` | `30m` | Longest time to spend fetching. Fetching stops at whichever limit is reached first. | +| `HTML_API_BENCH_FETCH_DURATION` | `30m` | Longest time to spend fetching. Fetching stops at whichever limit is reached first. At most half of it is spent sampling the index. | | `HTML_API_BENCH_ROUNDS` | `3` | How many times to time each document with each checkout. Each document's time is its median. | | `HTML_API_BENCH_SMALL_MAX_BYTES` | `32768` | Documents smaller than this are small. | | `HTML_API_BENCH_LARGE_MIN_BYTES` | `262144` | Documents at least this large are large. Everything between is medium. | ## Rebuilding `cc-analyzer.phar` -`cc-analyzer.phar` was built from cc-analyzer commit `cdf4b35` with `tools/build-phar.php`. Before building, the default HTTP identity was removed so that requests carry only a generic `User-Agent` and no `From` header: +`cc-analyzer.phar` was built from cc-analyzer commit `b13eb07` (branch `index-sampled-fetch`) with `tools/build-phar.php`. Before building, the default HTTP identity was removed so that requests carry only a generic `User-Agent` and no `From` header: - `src/Http/CurlStreamClient.php`: `DEFAULT_USER_AGENT` set to `'cc-analyzer/0.1 PHP-cURL'` and `DEFAULT_CONTACT_HEADER` set to `''`. - `src/Cli/Application.php`: the `--user-agent` and `--contact-email` defaults set to the same values, and the help text to match. diff --git a/tools/html-api-commoncrawl/cc-analyzer.phar b/tools/html-api-commoncrawl/cc-analyzer.phar index 6da6a48887def0c85bead5caae123837d14a6d92..2fb8cf7b8e18de8692fd46b9b80857f4eb6a199a 100755 GIT binary patch delta 20988 zcmb_^3w%`NnK$R0nKQX3O z)V2bR@K~)H?bmLv3fuuNX|Ge)xGbaIU zzYD*g&dfRIec$JO-skdvE+;>^H2*(-w&29$-dieW&5E4-^h)*6(i=X&@a`Jb2GmrE8#0vdB@#K_m3txQ8x2c3FZpZ$<_0XlK{+lvO{HvW@ zkXc!|B@#5pHiV-C`x|4!u@^uLKg*wAC|$p8vMobUawtPg^<2hAo-rr0jX$<#iq-DP zY%BO-t{lgo_rG=XJs4MGh^co5Mq*(z&Xb21$%pte(e(1Yli$y*<9U>C#!QGnNRvs{ryH*^>JL;@if#Y|qn{3IQJNbNW3E%rk zF3Z!Q|8VrIS`(5#KwOC#e2Lo><6}FP3e~7Z=Cn$KU5K=O^FD z7n?3ExQ>7I7YL4irl5m={jy*Kf3(bQ=a0l}aq%B#U$d3Z%eGYwC)dgJxo2I20M$nTVT#k&bZ4j3k)tU&eX!c>es%k*cMW|211sP*=F+dcOWR z=J_8_e*AzlFHYz3dClO=X-;QC^5@!n#=!DouaG5)?-%(=2eD8guoc;sw}(4+q- z5gfdxbTjkp&Qc+o-;f_#nKcQaZ6jsjl0|qj7FVV%Rheh*{$1fzeRhw2XjZ;ZE2z z1C5syFJOi zyA{=2*%1Yq;AeGWIAk@nrKc7*=%xFVMbvx0l2dHd;h~wgPu{1@r6cz#`B%K1#O>Wh z)V4#(?hwS-!;SuNBN}nI-OAtV!L6`qSw<*f#LZYVp0JGgXha;CW!`4S1K~!x_`G*P z9+B8}QY`okm2)ic(n-RJeZ zYf`J>v_ONH$-cIy2HWZeHSwU;9tniUZ#RQ-?Z{|2Oz)i5$|?4Q;;VE>zVZM%8_~+> z!&6Fja?PX4=?p4crRHX1A?M`jT&r(B9oeTAkoqO1o(f3wB&+^b`Ho8GE~?o+TR!N( zCbfb-|5+t7yQZN57aLe~>EX{RwW&v{9#%@|1LxI(c_VFLmBfnLK ztN5(a>)ZCtbVKzv zoK9?2d^J}Z#>bC&T??(Iiyzd>PH)pIsjE@ZlW+e{c}ck{5lsZby;78~?cTmk3SqF< zQI%ftfMx97+}Yh}go2o~g#tS~C3Iw5^H9zolny$ts0G=smFQ@PuarLg?_Pg#%@uSt zJv@ow4`k>)OoK&dV@k2u5Pkhk{TiC+QL`tm7cMT4GRuM*BRXi4Y3m@wo24S9{z6Vz zl&sMasgFiHdXPmxqYkh9Eh@s|uyWS`&e=F)$jB!)6G*6uNB)b&l6V zV+O*bL6-GsI0((_4@Cm;kZCznTEqB>yKEaQR+d3{)<$ECaK#p^2JFUQJUSvCFvmic z&>K){2|EJ{X)?z%k6QnzWO)UX=<$8Na@xMh>!bgC6$Z9>$XjyiMWBrr-_SE?>d(p| zT^<;n$ncia7c#tMxgDE3JFYkU&e7eilobgs+K0a7hMvw%o!wM*!COHmio69hmDCF; z_L@?hn%URWy=4;_}|){WE1 z{{Os@Ae&aPkvy@Hjh$ONc_aVN48NT6Y3lwS46n~qGpPB9H{UND*X1??GFR_D*jg4A zy5)J@r?%A7*MG09ppPYCjv#AO)VBz}{(_=5_0zFum5S@P1FOM*#o&q`JB2WGZhWfJM(;N zsP6OXwN&<9PsJ@=fw(2`__RToFk-eEeQT`Ic%sc%lK=vQgB*#q?Mu|b;Y-{48fnMh!mQ2Tq8DVdu6Ibf=+)1wHK$e8Lm&CRmbZTI&N^dn zux@X{kC_|zTjS8}wfKpz=@0y)*B1=#uwJoe};WkwEjAEF0DVS zX40=7@n+4B@xF`>V1Q_(gL#HfA;lD$GYY7dH|8s(^9NwayPj6OEdcOvv!=DDWnVok z1~4ZhJ9%l@<(HQ4ql?#iE4BURI34&G)u1CjPex76Vyks=5M1;U4y?m9s~13LDJ? zp21nKHEL>T_IKGA?Nfd9s~@Vx$$$I2`Y$To)u$BF+9y?G0geoVYieyYf%vA{JqNWu zsV>UpD-A(=T6*G1^{3^aRu09NX#)=QsEyR{&)&@BSD#XQeR2mcyrnjgah2-Lb_MIy zb(Pvo;g5P+Pv7t9rZb;Y=26$MdNrLHRtrlIiW%M5XLMw*{0QA{#!vrREvK#(YQ}^J zIXOs-i)Yw>AlD*2Uo0`o08O}#v^wnS-YV6>I2cL{M@KoP2?*~b(dF*;?hYZe790xz z?@|FS9c7v!`xDS6_CgG5@xPeBm6t0cdp4*cPbM_6(dgv(G!~EcGrkQ3c?W~$L1TX? z9F-sgZaTFoDG9tO;l5c?>Ul2U-TeaTnMTAMO9(pv(feEd8bf!VSaEfm>(cIF&;AkyanE{4=NZM95fk9@NC0TD}kLLyhsH0BXSYX zh^NI?CIn`;fde1B1w$y#BGi=`WhNWB)a(IFmM9w5`^G8XbeUVM#9km1C~BSqY;UT2FCjgahz-$ z8{L+MsR~4_gNN8>`41|2rZh1p6AnGxb@Z0_mlGFCJ%7yAwvH%So!fr1L2Po6CT ztTeha4O_PZQ85s1S*fm= zrt+U%q*O0X>$6d9Q-W9o@|C{fuceEhQp)mOyc82U)~tYD45>r3;x#p&hF`}2P8`#+ zXw6Aj%l}%ew9?3aEt?*_Rjrs1JlquzV|cX?goZYT;<)06%wS`K{+fY-2Ju+aKs3zu z6~TekwAgAA7fq|lk;SM(uLtNR9H?$$^wgm~DW=!egUIPT@@jAW4Cv(l=6I=sn8=af?7VLme7SebkCBi5Fi@zR_^8Y$X^X@ z>_yG%&GCH{_$U&b=N?0}a=u;l(uD&`(Pptqnlv$k5E^?nZ1(+UwYfpfKSRFA=<#JP zv!_H<&Edas#~m;Mg1odq?vcA|XajQb*ItpbIq&`S$p@5*49On*@U9Pr;tX-pI#6pg z8Z}Kd_`kE13kHd?4Q)a4D)}ProVt`*#|jVMf%V-G2CH_o#$1N-?&w#P?jj;a-Uh-tLgIuz3jAHdF5 zM^!&f+^Kk}%|Zkh=AZemdgpNlI43MqFc<{y?uceb7IQn=(Ms>*g zMJ(VW$Irv7C!*}tjlduRIT6(jh5#ju0l>8VA_G`CKW$e15Ef3hxa=qe)*>jmt3)e8 zspWQaXDG5C!orRiIe365VPl3kz^pxe++)zHKg+Zkk%8}qX{-C~sE*ypA&KNltGk)!` zo;4Z!WG-#*@MKZe%RmEdqv~vG3gRRRU|#nM&Hx2SSz#Ru4H$zFBgH4dMu@YhbFFQY z#VzQd!zwrgnaF}S2n~W+&W(mPYWcKhZejYjITjNU8C|$qTXcF{sh~&xUd@r1!w4et z#JMvA`)Fj*?K^gF@^39&kewW9|)>4$QM zphLhOFmT|fnrZk?0~Fwm_Sf=^dp#&UjNEl0?xDc z3UlsvxWKqB%}93CRnXyH%xv{?2aTcMt*x%y%vSKrp7WyRt)uln#hg#yr~9Zffuw45m6}8I z?t+dh-&PjPo9=WRBgDR^-k_UbRx9b9hdkx<$fmSbjP2) zbEr0^W>>(;$oR2GhL20qUh2_^TFM6~0&EeKA)gi5#16~vgnJqsaI!L^U>HcFuU4QP0xKqT}W>q zR7Q&9#yz^CI5Y^sq= zokiuAg$Q1#_7o3s8UoWnY!lIN6!E&>_7Am&ILcl?u@qFYfmzq)u3jXYdN*(1+2f87 zS&UfuZMe--TYH$e#6CLvMQwG?v@1Z-B_-9oUrU~yO`S&)Cf>YIZ zm100x0RM<@u2+nps^5Ft>G<2KH)$kQ?=sq61w3|t5sbmPSCq`;;y2V|YR(phAtL@a zlbqULy zdt3eVd_Sz04cR1U+QwEN<_*|OZbCw;nxvVleQzWEX`QxQX`wGfwIi&LPCkWY*zSo) zU!()`wIcfG39T+aAdB~LJSS?KT>)=(tJ5HXi|$AoHBt0G;NswDM3#g7_}yg#5ThD3 zcRGJwshC%9>~ysiLEc1rQ7t!%7`nRMS)wmH+Ad@#J@Cm(?&O}sC4ijTzX@iaiv^Dg0 zT+M4p&4f0I6laDLBVoIVA?{BNtVMZK@f6cf0^a;iL4Xyqx+xkYK@}F$2DV9>U*`*bO#lhW^Oqp-OX6-I%v@Ph+4c! zlnS`OA@m)evZZSa%3sxCGhmrLiSSNv9mob(Ab=QYe{+i>73|VVuY*9Qbsvv&c#jXM z&h-W$f#g4bbx6Dng>qPWh??$EO3Iw$l36rfnSHCDX7AF9ON4!O@+6$b_179}n2r)^ zf69ZP?jb}Xo4d93)OZ4+DyMtsBi-73;HeC>xFI?QIx6H8LK#imhbP`S279qMs4b<_ zJGGTGug8~L=-Q9bX0%{=dZ|t+YZHq%3r{amPea>qz+$x&>;);D>~n4j;oU0h!E0a* z=+@o7nuQJr_N1O;Bq0(`I5%PBE+(MIC$3hqb@>cE)2(J#J#(G~zUqMDoBQ4m?c-Iq@UNA~ai>RIUx_sNn zz#w;oXZO|8*~5DAWZzANwEhFSC&O{9^wQ6@3VQu2aEDP|hQl5D$iqOO0!9k^{=JRL z{0v8XNkG5VyR=H-Te3J8$9NZ6zoTzzB@-eyCw?O%CzJ>|xs;RJBLftG?_LhL5I;Na zuXf*b&uAy4()Xv|mh<4QUw#`2!~oJ@tnGll8@_6X&r{UGp(%4hPBbpOn6xjJ?J(IO zrI)D%^g$LPdz03XoZ6-3mvhS=tNy=_X!GfjrAR8gc1SBszA>UbpXsr4y}q#{1(|k5xS;V<%pB2?o^7p zQV$z@8XFs@&wv%DIbV>F!qAt4If)7B(mF&hbN6Volcy{#3+<7NraUpFB-_<-47t>9 zNaoz86=l$GZpD#2d05G%TRs3xy6Ld?!OAq!WK$<`WBQtejn_S(<|bc0to>TKmPJ=c z={iHO3K?VwrKl@2S`~U`qg2TAbT;DInuCG(KuvA(;t}l?jiyFn?En3Cn8(k*3G-NU zOj|(B_h{X8{(|nymU6O{`yr|7Pe^`#E~d^qjfNlwMx~62Dkk1z>6u|Z#BL|k$PSx> zST2_`ED;Hj;Q->eJiin6!2Tf^8)$-M?;`F0MM(7FE-T~{a*z}nd& z+eiog4*ubl+qHt#?l{3QN-l4fO2NCXIiQ~!j%f)0?$K8K6)X{0v521e1{x5b%GEM+ zp~}I~ZGJ!nRF8yG=Ao-vI%s6{mesOBt4_XtkM@?5mF6~`ybrCs=PD5|ys}Cwpzq$L z?WdfpK||thZJ7G@E7>AQ^~$OlBGmGU&FGAC!b5=xW#6pRL?G(J$P$io`X|VzFteS& z@b|H3^nos}*CIdVoEI}a-In0*6WN}=S{rXI6cZj=~FxbTpn${773qPd;vpaLE^E%1#+i0x;w_9$VmqG2B~jTLpK_#0ptTFIb9B4`yq*)OB3T$xJyg+EEJ5i$|v3 zMiJwVg<1wZwiO{w?h{&uZkw+I|AuBkZqPJ0jD|R0#3!9vm#eG+hTg5|>)3)}4a7q+ zbUolUKQG&ZZP9~NaTkRBo(qQC>0RzVlG)mvX`5gv$#JugW`7A0`MaM1%@1Y6bMdy2 z&N(xs)zF4*z^I%>cWvjbDQz|_UZ52@dpdg@iG=gZ!InSeX@&Ha;t(O*E)GCv0PiSTNx)d7PaaQ|Cw#y<=(^s|9Hp?6?V4%py))0V zh%SB8>q$QInl>>Z!xn?j(=IVJtY~fqAeg3gO0VbT+4dDy<2$4>PKiI9#5wK>c*9$JrwWw9Pmy2b!fAmJ&tfvY*(hskZiDeZz$?kBt9^=xcis4 zG_;`P95e?5qu~UP_iUQ#{cDe0W=G@>zH5#cw6;>8U#lS7B; z;sU*j);{ZLpbKw!3n$hAzuQpO0q5iCG7Sbm^lU`$I8I|dlCyykS&Opc`|H>`q|An} z6^sZSGvQMKGk zY4+D(h+p~xJj5^Nqva7jwl(zIXFb(mi;LD)rXIbl&XV+s*EdA*=RDi!J2#@5x_ghZ zgl;y_>-2Tq*M86Qo-aS|9X(Fx2K8);EmVB8eW6mgGz*iM@j(!` z=*3&~1rrTy>Akwz)ct7z>@x*2+%z=pL_Z|2WYNXmsv3aO$tEM-}gWya7Y?BYNSfKcXTN ze#o0e7rfrmd^AXNQ-NI;T1{sr0fLr(3}_jhDm&>oYW&wAWrJoE`;jz`<81Ex+(O$5 z9UVp6-cw_mL0cb!LHx?|=mAcj=X8lqK6{IPcSdFh;N~`%oe;GR>6_dwhRM5!^sDh= zX!?D)>&+N~XQ`Gc5vqlE3x_$WDy1|Gi3IF|>T{C?Cs;YX&#BgE48RMP&wgo4z6fS} zH*pFqBAly1I@zffE~N)^Jb9JtQevk6SiiCR^qf_E%<@37`e0}XP0ex|L|S4xBrBYRs!JS5pU^iX|8-pdb%DT2e}zul z$OvW`FhF)jC*tGS*I%XJ&_U$!V}5aVwo_qWFLS%fKDF76mO5C zspW%bl(H)0O3BD2Xn$~s6X{1$KFNZd8z09@!}sZnvR%`2LACrZpLV@?gNub0^#>FL`rL@M&!qxLVh)DU z5uTW7rTeNmm)pn95dn)qp$KSXYw-l!7N>GKTw$MnS?PllrKo-gQ|p+0=|%8%BR3>6 zMrUur78?KI?Zy!lsT)COG_nyo)(71jFuCj@yT|SI7{EeZQ!j!FL+Q+ApsO4&mCN34 zyJ~_=8;=PXFhQ8oilONjWC0|J>w*@5{E%JrK^ys%Q@@Q!5j%FnI5;6X><3Z)mTlbV zemosdYXwdgOp!?w^@EAOsh>%o#^&ne>!Sqzs~M>7;$+Y9>HNxL5qdn{*-Y z&ed)O<8G|KEdb%->irr4l*HFVLKrS@L4>p=5?mj&99YFF)Sj3SKR}+QT$n;D6;<5^l*4tH zTzkDeyx2ZEIf4dF{>up>9kEHAL67Z}{Zd%q^-{~T2Lx?`XM$ij~yuRFN!8;YBBfy3BK_ju_h(j&4qd|rGkZM~d zGr1h|a9(%XWDUk}Tx52Iyn@i)=H}+wS}7~&mtQ>LvK8i-Igl0TV3-g{4v?rhm3vI$&Ei01X2`J`(DIFx8JQ-)6|IK@w=$U-#}>$lPft+u*{=fIV|~RQY?6!!6@w#0OR|b2pRi zkCnDu!6>Ir)iHc7lU=iqdf&vKDPW{{725IOg+viQ%>|d`Vq+>L?n~jgl)-S5Fg<>hp4H(PSbN5=u$44|&UI%NWAKy@&Li=_gTCG~);v zVA0tp(E=2^0qOG_BHrvx?Lp+qUClBwQ-0-C1p81^rfca#Ox^lQz#tjBRt9Zp=j*&Xn~sWVVPLyE&mi(AWzX?vjC#9^C%~ULGZCG! z$)o>uM6aMP-mc$WnPQtDc%~xK`X8VL0V0<{mzDuPK)DKO>0$l)MVF6b*ypBdAKi0U zzmslx3+39v34I3#ehu{5V`^>*zn%a7)K%8ZVC788L^}khI0W7i{Xi!kg?daKMf)2Y zm2z}|;s_ioW;8`x=%?$awxs%HnM#2NZ+--p{{?HeqBG_UP zxFD{w{{+<6>z=FWxr2H=efvjh z*6A^Q6~CgNO^tWyS=2SA@22~|fjrpT*TFm-z7Lu*cU-@P_KoT_McwT;Z0dv?fZh$6 zn+0*S<6X5R%k{hES-hb#)uR_AUqz(s6Ac=uQFQk5yR^%Y-U}KX>EFFOd%8Q@w>hb4 zH1Vciq(zP!U6QThCETWEh+k!YoEeWtkt*UAKSpKFUlkJ7J@^xJ(b@KhafDH9pcphg zkiTYGvhHJg_Ur!Bt3J1|;zMWh9)945zdSYThDY!H*gqVOHT~P{%}=i``R6aK{icS$ LOSfsmwhjLqkJQ_X delta 2589 zcmZ8jc~n%_8J~OaTV|if%rGz`=ukB?=m6pZ0!joyBxpg6rX)Qo;#fpb)rtNp8AUP}kJ1@bp-BwdP!faND4xM%m`nM13X_`g zG^2mgjY37I(flXn)f<5vad^N$tjsooJkDS-naW@9Au@J*-^#Z5u6ecrC5T`MiAtrL#dZN(;$8{Q1XJ_CI(ZOs-MPA^2 zk;hGtID47I%Wae5l)PC(L=3OKSzQzOw=|B$d1cP9MCP9Rc#C1-G=6t%z8y9WLc zaOyo!v{p9iqe}`(_Nnx$Q-_xO8gywaWR5PGLA{Qr`?D@txnlgjH5Hq^rX_*J`mxMq zyM8=_6MF9XjXr@%afT>m+3SeUiD6>_c`Zd+HL2(UbZvtz_4Im^MYWmYzXH4oC)S* zBo02Y6bGMCwS&7Haqt^_;NbnFh0J7GYD4(eyN19V$_G9_lpn^vP~P74&{*af>g0U$ zoLu?JkaW?>cVcw$@u#^s?F%mMdDO+WzG=`!2VH2SAL6RfQW$}F@GX&yRM~=V3aA|VYOo#LNk|KKwyBTx+to+SRul$V4aiJSY)3IR zSeS{dux>w!hx>0J18w;YYShAa6lKx&R&)-7>AI|^N*mfa9{%yP*g~7GBO3;K3!Q>C zon)c(OLV}_Ob)pu{2{8*@EGt z4OoH8qlq0lyRZ+cJ;Vr$Jft1!ykt0C?y_sYtmB51ya9F3cZ2d?ZU6CwDnV1?}OBqQvNS7%YsLcH*0BZ-4O zzatfpR;?0y-Xt%><_2Pg=j+HMc%~McU}F;rS1QUX)uf}!D~pTs%Bmuh;KFJ$7RNRcK~}~(JAr* zwVoyqk!|YKsk~hG<8IG$d1d(?cOq0bs*BqGYuQe#I?1~PPM4D)=!{SsICPQZ!nY5Q z4x;KsCsa4dW@!6_m>}g6DS%n85EtA&N0h)6odM=`ky5a1#vw4!MP}2|3*;7px|2jt z4n_U zP&-}-qY<&f`VD~I5&)j<5Mp4&Q9%dvj1UQyc45s=I@;YXB*?VoPwLO_kJ==7_l)3$ z*(Zd9P;E7|x%Q#T2Lw7xgch!$oQimsaecF$bkNTG-Pn>FKWyOKY@{@w7Msf%c}6-!tP$)(l+i?92xTqus2}6TK+8Sp7qsVNX_o?h*JUk?+aM`a zFUTWhSgDmQVKbk6GH1LeQoWw(^XJY=U&Lkgh}bRB4ufpC;q@N5+xM5Ww*KAzPYah8 hNN;~LEdR)-r7vFC-gm0u`9;--)qgUYjMUkw{{=j~YOnwR From d24252c42e2d20d76ece30bc06ed3f4051161a7f Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Mon, 2 Nov 2026 19:33:49 +0000 Subject: [PATCH 14/25] HTML API: Print why the CommonCrawl fetch failed. The fetch in the CommonCrawl analysis failed on GitHub's runner with "Stop reason: worker-failures" and no error, because cc-analyzer did not print why its worker failed. Rebuild cc-analyzer.phar from a version which lists each failed worker's error after a batch fetch. See #66430. Co-Authored-By: Claude Opus 5.5 --- tools/html-api-commoncrawl/README.md | 2 +- tools/html-api-commoncrawl/cc-analyzer.phar | Bin 520691 -> 520999 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/html-api-commoncrawl/README.md b/tools/html-api-commoncrawl/README.md index 51bb3292bfe11..0fc1c8bf746ee 100644 --- a/tools/html-api-commoncrawl/README.md +++ b/tools/html-api-commoncrawl/README.md @@ -60,7 +60,7 @@ The workflow reads these environment variables. When it is run manually, each on ## Rebuilding `cc-analyzer.phar` -`cc-analyzer.phar` was built from cc-analyzer commit `b13eb07` (branch `index-sampled-fetch`) with `tools/build-phar.php`. Before building, the default HTTP identity was removed so that requests carry only a generic `User-Agent` and no `From` header: +`cc-analyzer.phar` was built from cc-analyzer commit `8f38286` (branch `index-sampled-fetch`) with `tools/build-phar.php`. Before building, the default HTTP identity was removed so that requests carry only a generic `User-Agent` and no `From` header: - `src/Http/CurlStreamClient.php`: `DEFAULT_USER_AGENT` set to `'cc-analyzer/0.1 PHP-cURL'` and `DEFAULT_CONTACT_HEADER` set to `''`. - `src/Cli/Application.php`: the `--user-agent` and `--contact-email` defaults set to the same values, and the help text to match. diff --git a/tools/html-api-commoncrawl/cc-analyzer.phar b/tools/html-api-commoncrawl/cc-analyzer.phar index 2fb8cf7b8e18de8692fd46b9b80857f4eb6a199a..10529be73ba53041ef5eb99f2c34e15f985efb25 100755 GIT binary patch delta 1082 zcmXX_TSyd97@pZVv)7r~advmy87d2+EHW>7$xO}lvKmAd;n2^3f7CM zCs_=VBqVz9K`8dA4WpOR=%p7SO1el7*+s+b0yX~MJ{-RNFX#XM@0+vB?{n@g=KI!r^G#kU&K2la7JBh^298VF0g zF~Tk{Y?=0=uJ~#xf7k~R!#>QJ_0>>b_oF}Ihm8aNLdrk!qwlXDBFh3h^v7vYShI2s zm)p)8yfctb%@YA!nhRivt{|>c5O*sW_E3SdD+)*}Rfy?UVZs9yqQ9vyBVR*&Mk^tn zyBbWJ*NVv5D;_~;`I%kCgSyF!gBCS42SG9v1nEo=3fwxdMlYhi%ldW<)td;v>Ts$g zE8}Dq97<>Hqq=Ay-(X-)j{!%g4TxMd;CO8a%U=zFCl$g2WX=P8CeB{x2aO01g>4#I z5Qg$oVK{a>48?O{kgkh>ED=GTMx>FHgv`*n!x8P3q3l delta 1072 zcmXX_O-K}B7@pbrW@mq9XJ%)2#+|`ZI+R7QX8vThmaDdQs3b(Y2#Iy5paP*lAutaj zL8YIzNMuP!&?zI?lM99y)94a}5F|u<$_yhD3~cv(_VDrW{(SHAKF@sfGFSHdTiK&8 z(m*yh`gMN+xKFGgJSw&j_KVeov*K~WpJFZH0T1HKo(464@7k6u2otFr<6M&+zUbLQ zNy9G)+f9=>-Y)OuLCK_>rKz~;NHa-X(q5Qi5`u+tVvMzYoxIZrn1uF}< zQ}(Bq_XXqBJQKvF)gXo#D&iIu@qmh9b1IzOQbC#sLCio1CcF(n^sf-ih--+iYc<64 zLW5~*S`|5aM-hb1za@=aH*8+5JJi&!gJe_(>9P(5UIW-+)>VpMRX2Zdx4bnjyee*Wn;I0i(ttBbqdsu>{KNeAs z;Y)PJu>5okB6G1y>USLQcR0vT7UNG2_71uDGOaH57;@2{bxF_AraQdpiuvQoA1Slx zvhRs>dA~^1d(3|P$R;DH@9@o9v=Z?JA ko{WE9pAgPX4Uf&;y%k>#w`Ciwr8kMi{0}Cil4q0u0U<%eKL7v# From 510a1de29abab2148b17e6b33832cdd762d6145f Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Mon, 2 Nov 2026 21:01:59 +0000 Subject: [PATCH 15/25] HTML API: Keep the CommonCrawl fetch going when records can't be fetched. On GitHub's runner, CommonCrawl answered the first record of the sample with HTTP 403, and that one record failed the whole fetch, so the analysis had no documents. Rebuild cc-analyzer.phar from a version which honors CommonCrawl's rate limiting, waiting out any Retry-After after HTTP 429 or 503, and which skips and lists a record it can't download instead of failing. A 403 means the runner has been blocked, so fetching stops there and the analysis runs on the documents already fetched. See #66430. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/html-api-commoncrawl.yml | 6 ++++-- tools/html-api-commoncrawl/README.md | 4 ++-- tools/html-api-commoncrawl/cc-analyzer.phar | Bin 520999 -> 530253 bytes 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/html-api-commoncrawl.yml b/.github/workflows/html-api-commoncrawl.yml index 30f7a33cb1eff..8034585832f92 100644 --- a/.github/workflows/html-api-commoncrawl.yml +++ b/.github/workflows/html-api-commoncrawl.yml @@ -119,8 +119,10 @@ jobs: ini-values: memory_limit=1G # Samples successful HTML captures from random blocks of the crawl's URL index, from many hosts across the whole crawl, - # and downloads only those records, four at a time. CommonCrawl answers bursts of requests with HTTP 503, so each - # transfer is retried with backoff. The cache limit is raised so that pruning never runs during the fetch. + # and downloads only those records, four at a time. When CommonCrawl asks to slow down, with HTTP 429 or 503, no + # request is sent until its Retry-After or the backoff has passed. A record which still can't be downloaded is listed + # and skipped, and a 403, which means this runner has been blocked, stops fetching; the analysis then runs on the + # documents already fetched. The cache limit is raised so that pruning never runs during the fetch. - name: Fetch documents from CommonCrawl run: | php "$CC_ANALYZER" --workspace "$RUNNER_TEMP/cc-analyzer" batch fetch corpus \ diff --git a/tools/html-api-commoncrawl/README.md b/tools/html-api-commoncrawl/README.md index 0fc1c8bf746ee..820cc6a0efe89 100644 --- a/tools/html-api-commoncrawl/README.md +++ b/tools/html-api-commoncrawl/README.md @@ -13,7 +13,7 @@ The report is left as a comment on the pull request. Each new analysis replaces ## Files -- `cc-analyzer.phar`: [cc-analyzer](#rebuilding-cc-analyzerphar), which fetches documents from CommonCrawl into a local batch, and replays a batch through an analysis script. It samples random blocks of the crawl's URL index, keeps successful `text/html` captures from up to ten different hosts in each block, and downloads only those records, four at a time. +- `cc-analyzer.phar`: [cc-analyzer](#rebuilding-cc-analyzerphar), which fetches documents from CommonCrawl into a local batch, and replays a batch through an analysis script. It samples random blocks of the crawl's URL index, keeps successful `text/html` captures from up to ten different hosts in each block, and downloads only those records, four at a time. It honors CommonCrawl's rate limiting, skips and lists any record it can't download, and stops fetching if CommonCrawl blocks the runner with HTTP 403; the analysis then runs on the documents it has. - `bootstrap.php`: loads the HTML API, and what `wp_kses()` needs, from the `src/` directory in `HTML_API_BENCH_WP_SRC`, without loading WordPress. It presents a UTF-8 site at example.com. When a change adds a file to the HTML API, add it to the list of files here. - `benchmark.php`: an analysis script which times the Tag Processor, the HTML Processor, and `wp_kses( $html, 'post' )` on each document and writes `benchmark.csv`. - `analysis.php`: an analysis script which records whether the HTML Processor supports each document and writes `analysis.csv`. @@ -60,7 +60,7 @@ The workflow reads these environment variables. When it is run manually, each on ## Rebuilding `cc-analyzer.phar` -`cc-analyzer.phar` was built from cc-analyzer commit `8f38286` (branch `index-sampled-fetch`) with `tools/build-phar.php`. Before building, the default HTTP identity was removed so that requests carry only a generic `User-Agent` and no `From` header: +`cc-analyzer.phar` was built from cc-analyzer commit `1237e83` (branch `index-sampled-fetch`) with `tools/build-phar.php`. Before building, the default HTTP identity was removed so that requests carry only a generic `User-Agent` and no `From` header: - `src/Http/CurlStreamClient.php`: `DEFAULT_USER_AGENT` set to `'cc-analyzer/0.1 PHP-cURL'` and `DEFAULT_CONTACT_HEADER` set to `''`. - `src/Cli/Application.php`: the `--user-agent` and `--contact-email` defaults set to the same values, and the help text to match. diff --git a/tools/html-api-commoncrawl/cc-analyzer.phar b/tools/html-api-commoncrawl/cc-analyzer.phar index 10529be73ba53041ef5eb99f2c34e15f985efb25..7652eb5a1435f4b4c97cd76142a2f5c99f6dd0d8 100755 GIT binary patch delta 7842 zcmcIp3v?7!nx-CoD@o_kd3PR!3nb|zbV49JB#?yg6h(pI%5Hh0! ziZkjeoWurxK@o5qXIy6#)<>^8$hza|g7O+XD=MR~3i41Ph~Uu`_rJHQL&D&mJ!j8) zj@+)Q`}m*V|J^^oKQUzQ^R)Yj=>5Lu6A%6yQG`-9j25P|p-ULchV8;cHhd+FXG5{b zpPI!<>}i`gInn$5$va)~g9d$9zwtz*!BG%#N@?ud5UGw0jZzI8wn){k{E>qOvDs|A z{bWT}by;X~f2|{JHl;8WTpaZ)*}pv*FDIzTk%huGlzd zx$D(;1a1O0emXt*bi{CuaP2R!c46ahpL_B*k>{NyZ24j5DA#Ka>l+*04*EnSCy{@f zn#i5=i$s3HONrdp-zIY5Cna(Hev!lt@?sJiTDh?MPCcCQGlnxhH`YG29?CEP4x7nCf z%buy+SUXee@!O+m{N404zIs(!wd)qqQjd+JA2#Pl_N4K>E~Rn%Pf6!O-}^FGq}B;$l!V(&R}vx@-n#u zw`TH}k7jb+-pk}Bm9n_?>a+OKtFri%KWFhUyqv{#o0fe&+huJw4~u=)knHBhU*P7F zZ*_B3kGn^)neH5Z-n<Zv|N7deYt$*&RiBit9x^!y}81k)?{k` zSJ)KLo0oy`te6>n>ptN+=Wy$d=A$*V@8{ij(?YY&ZFgN3zi%rdIj8jLP(dnq@giF7W~(0AvxZ|Wzl>d_}kNBH9Ym0 z01jK59b!8CxLQae)*R3`3hwBHjl%Fad&V7Ggj{%kJ<6W@JK+YnKTUE$?=~SltGJk! z6#BGg-KY5$T{&S#jw2ts-j=c-Sh7usF4-nNSq|qsk__j%MNjmHed0rMLO^eAMeWp} z4^|%%Ju4Gv8Ew^dUj@B>{`>|yrm~8*YiiJ-Q}yL;D8Nj_Eyng^CeplgnWlxEmn(FJKcXenTny&(ycA$=- zUy4rnale!pJ$6VmrK|nkQ!Zo@W0^lhYoO-@83FZ8V)mRC)gQnuODVbw%@GRgt(Hv1 zv4|+GrptA|kNz?t7D#N2eZ4Al;5|jj7b~lv=ctqple}^~{KE;^34wHFC~U>Bfql

M6a|ic;Xu9EyR|QqG`Q8R42J_% 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 QH|)Yc@(!9g<)$hB4Sh3xJ^%m! delta 2823 zcmZ`*Yj6|S753e`yVsUvYb99^%YfHdIP&`e0b;PhU?)II02`c825ebgTLM{fq*aYm zD6h;ACg94b$qAH)!R1+oaZ`qECn4DRkxmH-1g10)449z_On~4?0%@9r?p}q2AFY1q zyXT&B&*M8s$Nu1`c;M5l*kvYgqvy&K>UW%=JPpcGtp;7_Ne%kZ6bpJWYcpT&clsTp_$yn3SYhYgm8Zuqu8F z7i+omO>9PdA7|5aCph~2TaLaP z&(nBeo+jAOS7;`;6W}J)XEUL?UNT$LeNLXyU_$at4Z4zP?E}eFr%$QTE*GR!Y0#5G zpTD0nLz9fUY9Wx5s7OP(W1t z%G2=R+U)%JDKqu;z)bm{VWCN{v(PxlEt3V7OFYo<@96ZV_&l>UZb^4)kxSCY3rP(L zy@qe3tQ?4k(k zXl*t2S(=w_I~|J;6OiVhvrlMh38@C0pU%9>xFz zze7Cq??AlrQ8y~m!P-oYh4;6iZOVb|C?|R2n|(-mvya(bq^$ag*~EfyfyrgXwonL6 zcgQHXX2q$>93M;RPqwj$-1SD9BBxI4_aP#$D& zU>!>Nb!Pvwpt?;~^u>619JsphWH`JKdx13LbmW4$J~ks;oF=+tpFdJm6_Gt*xpt*r zo*8P9+^$Hpz1!5*4{jfu zs&p^Lmu3|CWuH!b^vM^CioBtqHyRF0L2sAnX%78L5<@|$Q0(-D0uprYz&UQ&=aCB6(S@e{CmH_lg% z?Z!Ki`7xbXC6`Ln%{1*h%`mkM>>uQx`ug@QB17SP1#Rv8!gHVcCINXOr z`jQf{S`16xP`Cv$8gSMme?+AAQj6$m^Z0+RM)pafH{h3ovPP9(B_J$ygu-f#oxZMO z*mH}w!>jx8B&(}A)Y2tZR#u9^Xds|=bjeb&0tWZvWzcw*ONF)r_$2f`$MQPAT6eZ; zw`3`l9mGr3YNA2;QLj~HULmZ#%cR2}4`M$o`CgsnN*NmsLkxSj+E~(J9>292ir? zy#+o8GQPe9IbcpPHxK^#2eKH3@@8iVama+wet zV@gM82k-~1XC!oWE`1?HaPla+#Vyc;Re^Sq4*Z-6)F_=B=IxB7*~y4r5-bc#*~!!~4vix+tld~g#B@a{$a9?V*eir|_Z8I_(-c(rS* zHzA$!?p6LYf|tJLb;`A2{=WU^Y=C*lI^oi{{FjzIF&dFXcdn}=EdAJ0IstIDjwD0D zcl@$=)2va-=zM?`fPWq&X#y>^7GfD%Mr26UBoz#T-^!*3aqsS?!B5T|mDA;}1qsrL}# zh!aXSusUT9AzxrPvlnxmB!|1IVXB_w!&jM{8Qcb9%Lz&=Cmff#9i5hhY=$Y;bfHKq1!?T zI0Z5uKIv1p#zKLV!u&LbgCdK{(H22y`7FWU~>Bg~S2+3?gP#i4iH# zT2b+TrWY1$;Yy+R66WAYff_VeKwg7(6V@rgLLzb&m$b4&-LL9scmg$2z@x4PST)3t zgEi$O$2{eymFQ~uQ8{>@%J}W0xH{4~1f-e@g|NBbH_C?O?vtO~rUVi>hX7d6$`zymp R*__mF^*_m^XJ*Wu@n28g)zJU| From fcae06b2f22836c11543bf90830ba8942b7dd8df Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Mon, 2 Nov 2026 21:26:48 +0000 Subject: [PATCH 16/25] HTML API: Check CommonCrawl access around the fetch. CommonCrawl refused every WARC record the runner requested with HTTP 403, while serving the index throughout sampling. That fits either a block on the runner's address after sampling's burst of requests, or a refusal of byte-range requests for records from GitHub's network. Temporarily request a known record by range, the same file without a range, and the index, before the fetch and again if the fetch fails, printing each status, the CDN's headers, and the body of any refusal. See #66430. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/html-api-commoncrawl.yml | 9 +++++ .../check-commoncrawl-access.sh | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 tools/html-api-commoncrawl/check-commoncrawl-access.sh diff --git a/.github/workflows/html-api-commoncrawl.yml b/.github/workflows/html-api-commoncrawl.yml index 8034585832f92..37b32dfa276ad 100644 --- a/.github/workflows/html-api-commoncrawl.yml +++ b/.github/workflows/html-api-commoncrawl.yml @@ -118,6 +118,10 @@ jobs: extensions: curl, mbstring, pdo_sqlite, zlib ini-values: memory_limit=1G + # Temporary: shows whether CommonCrawl serves WARC records to this runner before anything else is requested. + - name: Check CommonCrawl access + run: bash "$SCRIPTS/check-commoncrawl-access.sh" + # Samples successful HTML captures from random blocks of the crawl's URL index, from many hosts across the whole crawl, # and downloads only those records, four at a time. When CommonCrawl asks to slow down, with HTTP 429 or 503, no # request is sent until its Retry-After or the backoff has passed. A record which still can't be downloaded is listed @@ -135,6 +139,11 @@ jobs: --duration "$HTML_API_BENCH_FETCH_DURATION" \ --progress text + # Temporary: shows whether CommonCrawl still serves this runner after a failed fetch. + - name: Check CommonCrawl access after the fetch + if: ${{ failure() }} + run: bash "$SCRIPTS/check-commoncrawl-access.sh" + - name: Time parsing with both checkouts run: | for round in $( seq 1 "$HTML_API_BENCH_ROUNDS" ); do diff --git a/tools/html-api-commoncrawl/check-commoncrawl-access.sh b/tools/html-api-commoncrawl/check-commoncrawl-access.sh new file mode 100644 index 0000000000000..f5fd0c7c0041b --- /dev/null +++ b/tools/html-api-commoncrawl/check-commoncrawl-access.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# +# Temporary: reports whether CommonCrawl serves this runner, to tell whether a 403 for WARC records comes from a block on +# this address after too many requests, or from a refusal of byte-range requests for records from this network. +# +# Requests one known WARC record by range, the same file without a range (stopped once its size is known), and a range of +# the URL index, with the same User-Agent as cc-analyzer. Prints each status, the CDN's headers, and the start of any +# response which is not a success. + +set -u + +data='https://data.commoncrawl.org' +record="$data/crawl-data/CC-MAIN-2026-43/segments/1791170054432.19/warc/CC-MAIN-20261010041422-20261010071422-00381.warc.gz" +index="$data/cc-index/collections/CC-MAIN-2026-43/indexes/cluster.idx" + +probe() { + local label=$1 + shift + local body + body=$( mktemp ) + + curl -sS -o "$body" -D "$body.headers" -A 'cc-analyzer/0.1 PHP-cURL' --max-time 5 --max-filesize 65536 \ + -w "${label}: HTTP %{http_code}, %{size_download} bytes\n" "$@" || true + grep -iE '^(server|x-cache|x-amz-cf-pop|retry-after|content-type):' "$body.headers" | sed 's/^/ /' + if ! grep -qE '^HTTP/[0-9.]+ 2' "$body.headers"; then + head -c 600 "$body" | sed 's/^/ | /' + echo + fi + + rm -f "$body" "$body.headers" +} + +probe 'WARC record by range' -r 5766330-5777984 "$record" +probe 'WARC file without a range' "$record" +probe 'URL index by range' -r 0-4095 "$index" From dad7235bed879aa404eaa020715e19c7a7709c7b Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Mon, 2 Nov 2026 22:18:35 +0000 Subject: [PATCH 17/25] HTML API: Stream whole WARC files for the CommonCrawl analysis. CommonCrawl's CDN blocked the runner outright ("Request blocked." for every path, ranged or not) after the fetch had sampled its URL index with about 2,000 requests. Fetching 10,000 records one request each would draw the same block, so go back to streaming whole WARC files, which takes a handful of requests in all, and lower the default to 2,000 documents to reduce the demand of each run. One WARC file holds around 20,000 HTML documents, so the default reads about a tenth of one. Rebuild cc-analyzer.phar from a version which keeps the documents a WARC file delivered even if its download fails, lists the failure, and finishes with what it has. Remove the temporary access check. See #66430. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/html-api-commoncrawl.yml | 30 ++++++--------- tools/html-api-commoncrawl/README.md | 10 ++--- tools/html-api-commoncrawl/cc-analyzer.phar | Bin 530253 -> 530409 bytes .../check-commoncrawl-access.sh | 35 ------------------ 4 files changed, 16 insertions(+), 59 deletions(-) delete mode 100644 tools/html-api-commoncrawl/check-commoncrawl-access.sh diff --git a/.github/workflows/html-api-commoncrawl.yml b/.github/workflows/html-api-commoncrawl.yml index 37b32dfa276ad..4d8d26c5a5973 100644 --- a/.github/workflows/html-api-commoncrawl.yml +++ b/.github/workflows/html-api-commoncrawl.yml @@ -31,7 +31,7 @@ on: description: 'How many documents to fetch from CommonCrawl' required: false type: number - default: 10000 + default: 2000 fetch-duration: description: 'Longest time to spend fetching documents, e.g. 90s, 30m, 1h' required: false @@ -64,7 +64,7 @@ permissions: {} env: # The analysis stops fetching documents when it has this many or when the fetch duration has passed, whichever is first. - HTML_API_BENCH_DOCUMENTS: ${{ inputs.documents || 10000 }} + HTML_API_BENCH_DOCUMENTS: ${{ inputs.documents || 2000 }} HTML_API_BENCH_FETCH_DURATION: ${{ inputs.fetch-duration || '30m' }} HTML_API_BENCH_ROUNDS: ${{ inputs.rounds || 3 }} HTML_API_BENCH_SMALL_MAX_BYTES: ${{ inputs.small-max-bytes || 32768 }} @@ -118,32 +118,24 @@ jobs: extensions: curl, mbstring, pdo_sqlite, zlib ini-values: memory_limit=1G - # Temporary: shows whether CommonCrawl serves WARC records to this runner before anything else is requested. - - name: Check CommonCrawl access - run: bash "$SCRIPTS/check-commoncrawl-access.sh" - - # Samples successful HTML captures from random blocks of the crawl's URL index, from many hosts across the whole crawl, - # and downloads only those records, four at a time. When CommonCrawl asks to slow down, with HTTP 429 or 503, no - # request is sent until its Retry-After or the backoff has passed. A record which still can't be downloaded is listed - # and skipped, and a 403, which means this runner has been blocked, stops fetching; the analysis then runs on the - # documents already fetched. The cache limit is raised so that pruning never runs during the fetch. + # Streams whole WARC files, a handful of requests in all: CommonCrawl blocks runners which send many requests, such as + # one per record. Each WARC file from a crawl holds around 20,000 HTML documents, so documents are taken from up to + # 10,000 per file. When CommonCrawl asks to slow down, with HTTP 429 or 503, no request is sent until its Retry-After + # or the backoff has passed. A download which still fails is listed, and the analysis runs on the documents already + # fetched. The cache limit is raised so that pruning never runs during the fetch. - name: Fetch documents from CommonCrawl run: | + warcs=$(( ( HTML_API_BENCH_DOCUMENTS + 9999 ) / 10000 )) php "$CC_ANALYZER" --workspace "$RUNNER_TEMP/cc-analyzer" batch fetch corpus \ - --sample index \ --resource-types html \ - --limit "$HTML_API_BENCH_DOCUMENTS" \ - --concurrency 4 \ + --limit "$(( ( HTML_API_BENCH_DOCUMENTS + warcs - 1 ) / warcs ))" \ + --limit-warcs "$warcs" \ + --max-jobs "$warcs" \ --http-retries 6 \ --cache-max-bytes 17179869184 \ --duration "$HTML_API_BENCH_FETCH_DURATION" \ --progress text - # Temporary: shows whether CommonCrawl still serves this runner after a failed fetch. - - name: Check CommonCrawl access after the fetch - if: ${{ failure() }} - run: bash "$SCRIPTS/check-commoncrawl-access.sh" - - name: Time parsing with both checkouts run: | for round in $( seq 1 "$HTML_API_BENCH_ROUNDS" ); do diff --git a/tools/html-api-commoncrawl/README.md b/tools/html-api-commoncrawl/README.md index 820cc6a0efe89..c649a022637a2 100644 --- a/tools/html-api-commoncrawl/README.md +++ b/tools/html-api-commoncrawl/README.md @@ -13,7 +13,7 @@ The report is left as a comment on the pull request. Each new analysis replaces ## Files -- `cc-analyzer.phar`: [cc-analyzer](#rebuilding-cc-analyzerphar), which fetches documents from CommonCrawl into a local batch, and replays a batch through an analysis script. It samples random blocks of the crawl's URL index, keeps successful `text/html` captures from up to ten different hosts in each block, and downloads only those records, four at a time. It honors CommonCrawl's rate limiting, skips and lists any record it can't download, and stops fetching if CommonCrawl blocks the runner with HTTP 403; the analysis then runs on the documents it has. +- `cc-analyzer.phar`: [cc-analyzer](#rebuilding-cc-analyzerphar), which fetches documents from CommonCrawl into a local batch, and replays a batch through an analysis script. It streams whole WARC files and keeps their HTML responses. That takes only a handful of requests: CommonCrawl blocks runners which send many, such as one per record. It honors CommonCrawl's rate limiting, and if a download still fails, it lists the failure and the analysis runs on the documents it has. - `bootstrap.php`: loads the HTML API, and what `wp_kses()` needs, from the `src/` directory in `HTML_API_BENCH_WP_SRC`, without loading WordPress. It presents a UTF-8 site at example.com. When a change adds a file to the HTML API, add it to the list of files here. - `benchmark.php`: an analysis script which times the Tag Processor, the HTML Processor, and `wp_kses( $html, 'post' )` on each document and writes `benchmark.csv`. - `analysis.php`: an analysis script which records whether the HTML Processor supports each document and writes `analysis.csv`. @@ -26,7 +26,7 @@ This needs PHP 8.5, as used by the workflow, with the `curl`, `pdo_sqlite`, and ```sh # Fetch 1,000 documents, or as many as can be fetched in 5 minutes. php -d memory_limit=1G tools/html-api-commoncrawl/cc-analyzer.phar --workspace /tmp/cc batch fetch corpus \ - --sample index --resource-types html --limit 1000 --concurrency 4 --http-retries 6 --duration 5m --progress text + --resource-types html --limit 1000 --limit-warcs 1 --max-jobs 1 --http-retries 6 --duration 5m --progress text # Time each document three times with each checkout, and record support once. for checkout in base merge; do @@ -52,15 +52,15 @@ The workflow reads these environment variables. When it is run manually, each on | Variable | Default | Meaning | | --- | --- | --- | -| `HTML_API_BENCH_DOCUMENTS` | `10000` | How many documents to fetch. | -| `HTML_API_BENCH_FETCH_DURATION` | `30m` | Longest time to spend fetching. Fetching stops at whichever limit is reached first. At most half of it is spent sampling the index. | +| `HTML_API_BENCH_DOCUMENTS` | `2000` | How many documents to fetch. | +| `HTML_API_BENCH_FETCH_DURATION` | `30m` | Longest time to spend fetching. Fetching stops at whichever limit is reached first. | | `HTML_API_BENCH_ROUNDS` | `3` | How many times to time each document with each checkout. Each document's time is its median. | | `HTML_API_BENCH_SMALL_MAX_BYTES` | `32768` | Documents smaller than this are small. | | `HTML_API_BENCH_LARGE_MIN_BYTES` | `262144` | Documents at least this large are large. Everything between is medium. | ## Rebuilding `cc-analyzer.phar` -`cc-analyzer.phar` was built from cc-analyzer commit `1237e83` (branch `index-sampled-fetch`) with `tools/build-phar.php`. Before building, the default HTTP identity was removed so that requests carry only a generic `User-Agent` and no `From` header: +`cc-analyzer.phar` was built from cc-analyzer commit `a17b1f6` (branch `index-sampled-fetch`) with `tools/build-phar.php`. Before building, the default HTTP identity was removed so that requests carry only a generic `User-Agent` and no `From` header: - `src/Http/CurlStreamClient.php`: `DEFAULT_USER_AGENT` set to `'cc-analyzer/0.1 PHP-cURL'` and `DEFAULT_CONTACT_HEADER` set to `''`. - `src/Cli/Application.php`: the `--user-agent` and `--contact-email` defaults set to the same values, and the help text to match. diff --git a/tools/html-api-commoncrawl/cc-analyzer.phar b/tools/html-api-commoncrawl/cc-analyzer.phar index 7652eb5a1435f4b4c97cd76142a2f5c99f6dd0d8..6ea5952995bb63b253cf0a7248c9e79bb2eb9e15 100755 GIT binary patch delta 1908 zcmZ8iZ){Ul6z}bQum4`xc3s=8FAVMnvJDtpha1SoHaB2X6JbGQEHP@=zPZOrTie1M zd_W|GL?Z}qcpMQF0f`@+kT{zVC9q&ZL|8D2P8PQ>k`NRS6C@(R+k47Dmc0CO&iUQ* z=XXxC^TX`w=j`1HdP%vud$KGChK*DRe1a?!xQWyXJWlEbUMC9$mYdOznj4(_-MZVd z$+WjUK4`e)pmD}rDx4m>$#kb>=!(%H&omV36)zcW5*VgS1b##t1y0dL0#yk!cS=tR zdyj+_&q)}U`9DRcPO%HbtOTB#P~BMLqmS1^gkiS{xl+MAu2Y{ZGm z-gaWq#sVB>a{-F*MF9?dvjD}YcA?$psuOh%xlptz*8-tzztv<~^LKHR;r0ZKT6a*8 zI^0-fw;PL|bK?XS4`PdFf#7ZQ%*CXhX9V8xpi*^(c_j-`p_7GAi8%41zs-wzwt7*~ z(_S3%ju#bQT7=ENS%h`6MRgw z7VGq*-|*v5=lxhz3ZR0m0W7*TfV{5*c!T!?IMnjuc2RFvF*ZGxM|TNsVoeD)f3O6H zyiyVp{9q94uMVPrC~yA|#J&qu+{|(n``D%;|D-DB*>+3aaZ4rqGiPt9bbCD{-=cQ7 z)mg;KO z!g|7bOpCH;qCXx>grjVIc!2eWH)<@Z#q^C@ipNq~B$0}yS-2;orI?;cvvrB?@-&Nu z(-{`&)gpb#gdWdSvi@E@(#tZv8tYG_`ZzZdj>i+6lh_c?R5@7wPw!zBv(p=3YAr3d zH8wV~5GyN#3$IxN*@-UteG`mrB^LPP0G(5S-L1j~AUU5DXNTXX-K0L3s%$ofE2lG~ zw>y@I^l8z@-T7SbXB{bl)eTlJoNAF|DBnkS!G#eLfaY^@0A^mJHs~0k4)}E+U2b%g zNiaUbwF^y{x&J7CP&Gu|XbxYJEf5NkQYihB7DK3p*x*JDDTl%kQDMtv{^HnW>Vh|# zdBDGbl)s@Q6-=YtjK85WL* zV*{HwuUqR$q%=XF-d=v2VeaqKl9?(P8|1^TI81$T?-z3k+&yHT2_GJ&MKCo;tiimn zEe}%8YvfJ5UJ%b_*tcLhnEJzFGG2R4li9YtJZje zt>=2cngjIN?AQU?;855+mOqhIxvkHDdnsbg&ia(LedT%3UOKNckl!q`vWGOuE4kYK z_nX);s!RNn`De+F>ek3-6>Co<&lqpNJb8G3bIpmJKTZC)a!1F&&lV%x-4(tTJ9w7= LlQOlpKG*sm^|HLO delta 1780 zcmah~TWl0%6yBMCX19Bv?yb8k)W1S{A-!yC3$~RlTWCv(mx8*h(G;e;Q+9iqtvg$r zmIqTC-R_P5^J#vt;5q()gaTByN*A|lZk8VT(KHfj{K4?w`A&i-ekz8E)|d^wl@ zeCK@UB>VF*eC}QR;w4mm>!;w-3nOew{?nQatDm|BXnOasNM%tneOEZ8x&(W1TU zxz6i!FSp4rsW#rHo?_Qhr(o3SZiXh8)egQ;H6sUoiBXWEi0-90jW$zULYpZ1Ij9`r z?xFTU4j9jJ5a;Tl!gDakm}>D(K?G^=|!pL&S*(BK>oMQ-!(xKnkkOtfh_ZR5Vsh&QvWU^3~|s1b-pr&s9!Kae#8Va z9xzo?|7Rx1`_lwNZZek(ADw1&St+x)*kb!dRm@d1`H&eNEtsK*U;%rZ1?+tmDE5H` zl)Y{N(#=*FW}g*Ac;5;`|84~_s%&8Q+Uh9JTQ(4F$yP&^9Wv;2;Tr`()h>9{TDy-X zb=ZMq+zzB??J$8}01OE=H18>40~8h7DP9vmsk;1}lKG&}T>cgsXB^;fbwHg_2Piu4 zfFW->K=G|kX#N=|@MN5DfS+pzc=*kavrzlsNpi78OBBesWM6l*oH$Gyy*R z7I{gqn==tEjBb*M zucU-zv2S=pijnFm#znq4tS^GJ)i82rtdF7#!AvzlT@3m074$Z#Z08K*ok`S9#*QO5 z30z`1GCj)c$;pezL>7P0+M9cxHIQQ};>r0PjEPLwF?mFO72QFuRWo-HA;5UZ(pCK$ zQgs=&a3ZZ_ zw?lU=#mccL$Nt%p0&Ar^C-ZNjGSc}y>nHXpWHA3b?exs#*E##9n9z!qk-Z8$hYw9kGFqp l3SGSN7<*vhxmEqC8>dSm%l9p~j?Yh=(EcQJZO!*L{{?A!tR4UW diff --git a/tools/html-api-commoncrawl/check-commoncrawl-access.sh b/tools/html-api-commoncrawl/check-commoncrawl-access.sh deleted file mode 100644 index f5fd0c7c0041b..0000000000000 --- a/tools/html-api-commoncrawl/check-commoncrawl-access.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -# -# Temporary: reports whether CommonCrawl serves this runner, to tell whether a 403 for WARC records comes from a block on -# this address after too many requests, or from a refusal of byte-range requests for records from this network. -# -# Requests one known WARC record by range, the same file without a range (stopped once its size is known), and a range of -# the URL index, with the same User-Agent as cc-analyzer. Prints each status, the CDN's headers, and the start of any -# response which is not a success. - -set -u - -data='https://data.commoncrawl.org' -record="$data/crawl-data/CC-MAIN-2026-43/segments/1791170054432.19/warc/CC-MAIN-20261010041422-20261010071422-00381.warc.gz" -index="$data/cc-index/collections/CC-MAIN-2026-43/indexes/cluster.idx" - -probe() { - local label=$1 - shift - local body - body=$( mktemp ) - - curl -sS -o "$body" -D "$body.headers" -A 'cc-analyzer/0.1 PHP-cURL' --max-time 5 --max-filesize 65536 \ - -w "${label}: HTTP %{http_code}, %{size_download} bytes\n" "$@" || true - grep -iE '^(server|x-cache|x-amz-cf-pop|retry-after|content-type):' "$body.headers" | sed 's/^/ /' - if ! grep -qE '^HTTP/[0-9.]+ 2' "$body.headers"; then - head -c 600 "$body" | sed 's/^/ | /' - echo - fi - - rm -f "$body" "$body.headers" -} - -probe 'WARC record by range' -r 5766330-5777984 "$record" -probe 'WARC file without a range' "$record" -probe 'URL index by range' -r 0-4095 "$index" From fed621dcc84c9b6f3778758e5aa36775fce878c9 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Mon, 2 Nov 2026 23:19:45 +0000 Subject: [PATCH 18/25] HTML API: Show example documents for each unsupported reason in the CommonCrawl report. The report counts the documents the HTML Processor stopped on for each reason, but did not say which ones, so a reason could not be looked into without rerunning the analysis. List up to three example documents for each reason, the smallest from different hosts, with their size and the call site where the HTML Processor stopped, as the report already does for its largest slowdowns. Examples come from the PR, or from the target branch for a reason only it stops for. See #66430. Co-Authored-By: Claude Opus 5.5 --- tools/html-api-commoncrawl/README.md | 2 +- tools/html-api-commoncrawl/report.php | 64 ++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/tools/html-api-commoncrawl/README.md b/tools/html-api-commoncrawl/README.md index c649a022637a2..385454431f14d 100644 --- a/tools/html-api-commoncrawl/README.md +++ b/tools/html-api-commoncrawl/README.md @@ -3,7 +3,7 @@ Compares how the HTML API parses real-world documents with and without a pull request. It fetches a random sample of HTML documents from the latest [CommonCrawl](https://commoncrawl.org/) crawl, then replays the same documents through both checkouts to measure: - how long the Tag Processor and the HTML Processor take to parse each document, and how long `wp_kses( $html, 'post' )` takes on it, for all documents and for small, medium, and large ones; -- which documents the HTML Processor does not support, and why it stopped on them. +- which documents the HTML Processor does not support, and why it stopped on them, with a few example documents for each reason. The [`HTML API CommonCrawl Analysis`](../../.github/workflows/html-api-commoncrawl.yml) workflow runs this for a pull request when: - the `html-api-commoncrawl` label is added to the pull request, if it changes the HTML API or `kses.php`. The label is removed straight away, so add it again to run another analysis. diff --git a/tools/html-api-commoncrawl/report.php b/tools/html-api-commoncrawl/report.php index d958f173c7ad4..5731fa5307a15 100644 --- a/tools/html-api-commoncrawl/report.php +++ b/tools/html-api-commoncrawl/report.php @@ -24,6 +24,9 @@ const HTML_API_BENCH_REPORT_MARKER = ''; const HTML_API_BENCH_EXAMPLES = 10; +// How many example documents to show for each reason the HTML Processor stopped. +const HTML_API_BENCH_REASON_EXAMPLES = 3; + // The smallest share of documents a size bin at either end of the chart needs to be drawn. const HTML_API_BENCH_CHART_MIN_BIN_SHARE = 0.02; @@ -394,6 +397,57 @@ function examples_table( $pairs ) { return table( array( 'URL', 'Size', 'Target', 'PR' ), $rows ); } +/** + * Renders a few example documents for each reason the HTML Processor stopped. + * + * Examples come from the PR, or from the target branch for a reason only it + * stops for. They are the smallest documents, which are the easiest to inspect, + * taking at most one from each host. + * + * @param array[] $reasons Reasons, in report order, each with `base` and `merge` counts, call sites, and documents. + * @param callable $source_url Returns the link to `class-wp-html-processor.php` at a commit, or null. + * @param string $base_sha Commit of the target branch. + * @param string $merge_sha Commit of the PR merged into the target branch. + * @return string Markdown table. + */ +function reason_examples_table( $reasons, $source_url, $base_sha, $merge_sha ) { + $rows = array(); + foreach ( $reasons as $reason => $sides ) { + $side = isset( $sides['merge']['documents'] ) ? 'merge' : 'base'; + $documents = $sides[ $side ]['documents']; + $url = $source_url( 'merge' === $side ? $merge_sha : $base_sha ); + usort( + $documents, + static function ( $a, $b ) { + return $a['bytes'] <=> $b['bytes']; + } + ); + + $hosts = array(); + foreach ( $documents as $document ) { + $host = strtolower( (string) parse_url( $document['url'], PHP_URL_HOST ) ); + if ( isset( $hosts[ $host ] ) ) { + continue; + } + + $hosts[ $host ] = true; + $line = $document['line']; + $rows[] = array( + 1 === count( $hosts ) ? format_code( $reason, 120 ) : '', + format_code( $document['url'] ), + format_bytes( $document['bytes'] ), + '' === $line ? '' : ( $url ? "[L{$line}]({$url}#L{$line})" : "L{$line}" ) . ( 'base' === $side ? ' (target)' : '' ), + ); + + if ( count( $hosts ) === HTML_API_BENCH_REASON_EXAMPLES ) { + break; + } + } + } + + return table( array( 'Reason', 'URL', 'Size', 'Call site' ), $rows ); +} + $base_results = load_results( $argv[1] ); $merge_results = load_results( $argv[2] ); @@ -509,7 +563,12 @@ static function ( $pair ) { continue; } - $reasons[ $result['reason'] ][ $side ]['count'] = ( $reasons[ $result['reason'] ][ $side ]['count'] ?? 0 ) + 1; + $reasons[ $result['reason'] ][ $side ]['count'] = ( $reasons[ $result['reason'] ][ $side ]['count'] ?? 0 ) + 1; + $reasons[ $result['reason'] ][ $side ]['documents'][] = array( + 'url' => $result['url'], + 'bytes' => $result['bytes'], + 'line' => $result['line'], + ); if ( '' !== $result['line'] ) { $reasons[ $result['reason'] ][ $side ]['lines'][ $result['line'] ] = ( $reasons[ $result['reason'] ][ $side ]['lines'][ $result['line'] ] ?? 0 ) + 1; } @@ -559,6 +618,9 @@ static function ( $a, $b ) { } $report .= "Unsupported documents by the reason the HTML Processor stopped. Call sites are lines in `class-wp-html-processor.php` of each checkout.\n\n"; $report .= table( array( 'Reason', 'Target', 'PR', 'Δ', 'Target call sites', 'PR call sites' ), $rows ) . "\n"; +if ( array() !== $reasons ) { + $report .= "

Example documents for each reason\n\n" . reason_examples_table( $reasons, $source_url, $base_sha, $merge_sha ) . "\n
\n\n"; +} $newly_unsupported = array_values( array_filter( From f35f3aa89e5b596aaab9337d9446047f9fd6dc43 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Mon, 2 Nov 2026 23:28:55 +0000 Subject: [PATCH 19/25] HTML API: Analyze every HTML document in the downloaded WARC files. The number of documents was only ever meant to bound the network load and the time spent downloading, but the fetch stopped reading a WARC file once it had that many documents, discarding the rest of a file it had already started on. Count WARC files instead: download whole files, one by default, and keep every HTML document in them, around 20,000 per file. The fetch duration still bounds the download. Rebuild cc-analyzer.phar from a version which accepts --limit all, to read each WARC file to its end. See #66430. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/html-api-commoncrawl.yml | 25 ++++++++++---------- tools/html-api-commoncrawl/README.md | 7 +++--- tools/html-api-commoncrawl/cc-analyzer.phar | Bin 530409 -> 531281 bytes 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/html-api-commoncrawl.yml b/.github/workflows/html-api-commoncrawl.yml index 4d8d26c5a5973..3ab4b51dd848b 100644 --- a/.github/workflows/html-api-commoncrawl.yml +++ b/.github/workflows/html-api-commoncrawl.yml @@ -27,11 +27,11 @@ on: description: 'Pull request number to analyze' required: true type: number - documents: - description: 'How many documents to fetch from CommonCrawl' + warcs: + description: 'How many WARC files to download from CommonCrawl, each with around 20,000 HTML documents' required: false type: number - default: 2000 + default: 1 fetch-duration: description: 'Longest time to spend fetching documents, e.g. 90s, 30m, 1h' required: false @@ -63,8 +63,8 @@ concurrency: permissions: {} env: - # The analysis stops fetching documents when it has this many or when the fetch duration has passed, whichever is first. - HTML_API_BENCH_DOCUMENTS: ${{ inputs.documents || 2000 }} + # The analysis keeps every HTML document in this many WARC files, unless the fetch duration passes first. + HTML_API_BENCH_WARCS: ${{ inputs.warcs || 1 }} HTML_API_BENCH_FETCH_DURATION: ${{ inputs.fetch-duration || '30m' }} HTML_API_BENCH_ROUNDS: ${{ inputs.rounds || 3 }} HTML_API_BENCH_SMALL_MAX_BYTES: ${{ inputs.small-max-bytes || 32768 }} @@ -119,18 +119,17 @@ jobs: ini-values: memory_limit=1G # Streams whole WARC files, a handful of requests in all: CommonCrawl blocks runners which send many requests, such as - # one per record. Each WARC file from a crawl holds around 20,000 HTML documents, so documents are taken from up to - # 10,000 per file. When CommonCrawl asks to slow down, with HTTP 429 or 503, no request is sent until its Retry-After - # or the backoff has passed. A download which still fails is listed, and the analysis runs on the documents already - # fetched. The cache limit is raised so that pruning never runs during the fetch. + # one per record. Every HTML document in them is kept; the number of files only bounds the download. When CommonCrawl + # asks to slow down, with HTTP 429 or 503, no request is sent until its Retry-After or the backoff has passed. A download + # which still fails is listed, and the analysis runs on the documents already fetched. The cache limit is raised so + # that pruning never runs during the fetch. - name: Fetch documents from CommonCrawl run: | - warcs=$(( ( HTML_API_BENCH_DOCUMENTS + 9999 ) / 10000 )) php "$CC_ANALYZER" --workspace "$RUNNER_TEMP/cc-analyzer" batch fetch corpus \ --resource-types html \ - --limit "$(( ( HTML_API_BENCH_DOCUMENTS + warcs - 1 ) / warcs ))" \ - --limit-warcs "$warcs" \ - --max-jobs "$warcs" \ + --limit all \ + --limit-warcs "$HTML_API_BENCH_WARCS" \ + --max-jobs "$HTML_API_BENCH_WARCS" \ --http-retries 6 \ --cache-max-bytes 17179869184 \ --duration "$HTML_API_BENCH_FETCH_DURATION" \ diff --git a/tools/html-api-commoncrawl/README.md b/tools/html-api-commoncrawl/README.md index 385454431f14d..159d045035362 100644 --- a/tools/html-api-commoncrawl/README.md +++ b/tools/html-api-commoncrawl/README.md @@ -24,7 +24,8 @@ The report is left as a comment on the pull request. Each new analysis replaces This needs PHP 8.5, as used by the workflow, with the `curl`, `pdo_sqlite`, and `zlib` extensions. Each analysis script runs in a separate process for each checkout, because PHP cannot load two versions of the HTML API into one process. ```sh -# Fetch 1,000 documents, or as many as can be fetched in 5 minutes. +# Fetch 1,000 documents, or as many as can be fetched in 5 minutes. The workflow uses --limit all instead, to keep every +# HTML document in the WARC files it downloads. php -d memory_limit=1G tools/html-api-commoncrawl/cc-analyzer.phar --workspace /tmp/cc batch fetch corpus \ --resource-types html --limit 1000 --limit-warcs 1 --max-jobs 1 --http-retries 6 --duration 5m --progress text @@ -52,7 +53,7 @@ The workflow reads these environment variables. When it is run manually, each on | Variable | Default | Meaning | | --- | --- | --- | -| `HTML_API_BENCH_DOCUMENTS` | `2000` | How many documents to fetch. | +| `HTML_API_BENCH_WARCS` | `1` | How many WARC files to download. Every HTML document in them is analyzed; each file holds around 20,000. | | `HTML_API_BENCH_FETCH_DURATION` | `30m` | Longest time to spend fetching. Fetching stops at whichever limit is reached first. | | `HTML_API_BENCH_ROUNDS` | `3` | How many times to time each document with each checkout. Each document's time is its median. | | `HTML_API_BENCH_SMALL_MAX_BYTES` | `32768` | Documents smaller than this are small. | @@ -60,7 +61,7 @@ The workflow reads these environment variables. When it is run manually, each on ## Rebuilding `cc-analyzer.phar` -`cc-analyzer.phar` was built from cc-analyzer commit `a17b1f6` (branch `index-sampled-fetch`) with `tools/build-phar.php`. Before building, the default HTTP identity was removed so that requests carry only a generic `User-Agent` and no `From` header: +`cc-analyzer.phar` was built from cc-analyzer commit `57bfbca` (branch `index-sampled-fetch`) with `tools/build-phar.php`. Before building, the default HTTP identity was removed so that requests carry only a generic `User-Agent` and no `From` header: - `src/Http/CurlStreamClient.php`: `DEFAULT_USER_AGENT` set to `'cc-analyzer/0.1 PHP-cURL'` and `DEFAULT_CONTACT_HEADER` set to `''`. - `src/Cli/Application.php`: the `--user-agent` and `--contact-email` defaults set to the same values, and the help text to match. diff --git a/tools/html-api-commoncrawl/cc-analyzer.phar b/tools/html-api-commoncrawl/cc-analyzer.phar index 6ea5952995bb63b253cf0a7248c9e79bb2eb9e15..c8b59c427ed38dbc728dd615dfb8d28e334f469f 100755 GIT binary patch delta 1682 zcmah|ZA@EL7`~sq(3Z9oTH2D~Sw<)oC}S{Gz6%VTZo!RNG8ZEiPS=jMx8B>1vBe0w zNR~_;yC!FgE^IpE9}_S$>z|QriTq(kK>wI%9Dg(+IvvhAQ^VptZ#PLyrZ>6w>3h!m zKJWWH=X7-jEnh~bzT>7#r@r^dg2GcYni7C|L4Z9$ z?4XN1ViQnL@eJc=Trg>af?NAYbke;!5z4b7)YTiHgJOV@W(`d=<>wq!O2GND1bgSD zopkRfsh2`SUKfS2JWzWr5B5y?-E?tJK8!n+59K%VJL#g;2$?}6`0|!<8(q9^gp|LG zAf?e%V>^C>WvD3>FFhN0Q&UZ~H1nbf3cfKxxz!9&ml>j?W?=cu44&OJL!*`gnEGe| z$h=el!hS6PwSEgkhb)bh`U49Xa??@|o{dTj6TDZ_rde%Ht-S;>aRt03XXDIw)11A~^b1oHv1#^V~eebj#)^^yTdfX0PU9*Fddv@@#vk1nV zC<5loMJ+JcyvQ&gXP3`veRhXdRP3RBYKuW$q!|2ozqrx8`9%f{q&Rte>Aj0A1CLjm zDfWCbbukJ9iZsi#B}!}v%wIv72SQA8d^ z@law64TX}aw1QQXl2I6^h9cy8mUWZcS9p;usl17pY6W}d_Z7aUoGkt!7#=y=#7={s z*wO}(%(8-=+*;wKOur}`@R6$)&a`_Vf>RN!puw6N5QlUDgD9kqs3@!ti4S9COczAe zA~}q3XehFwsX8YqAtR{ulh9VKj9m2#qH)6{vZtMQk#`sQQgY`m4-@2Z6&0jzm3KR! z&RdPVULR_0ZAD(ay_a0@3;q9X_;MBJBGZeUgN(#jGuV>J`h|U6teWXlgn8BtbX!q- zjuJJYN;RcK69;{nbyfH<_?nzfJ^Cyjnh{kuP9z@EPeCVm;0Bz`2*ybiR^<4=EFSBxtwk?jMMiNPN~q|ep-uneKV`~2(J-nk zSMV!otfuhrE_zML+N*3C8PBqIGSeeDNnJ{+A}@UFwOz`8Uhr BBTN7Q delta 1076 zcmX9+ZAcRV7{1%x?c8qeZl7)!BA5mn7 zoI+8C(XUb{TvSp)f-FA?vmencDiZrZB8-CQ-e-f`!~4F^`|&(?Q-guI{=k*Drq@#E zyA0!B6WNsnx$I_wHg+w+0K0+U8(ToI!i4C6DMV4Pso3^fnmepm80L3L&u6V#SgJ{! zlWOVQCW8IkMuHn$5kZ1mN8sl%CBm<#sFTO&IFA}H6jQQ7D8%Saj$u6cabpng*KP}5 zstpNf|0AF;&x{Sn%{XbmTu79!Nyrp2vtC5)rnr@A^I|1IFr|#3Ed^?yrJyER$|Vbr4nq?5Ja~3eWSGce zT9GCzUM(p5i4vAE`ihK2aT(_d3ZPiYBTB2X3d58tg6|5P2s)B3Ibgw%qr|9Pbt1Rf ziSBkMyn5z@l0_$cEOp_`b1qCCbcJx(u)r{P{!H~~VW&rPxmDVe?S{NYH~hHf&i8-W z%fLXwOZ={xK9<2RL+f{|`q2nyvt~6nHg_9ehS9&ogDWB)7;@DEeWM;6!+GIgi5JJT zdqH{P#RvA!3w`BjTj+|*X%I1<1iKF}pvDI+H+_)%$(KV+pC9{n`jPBTMz8!3VN>xs z%2ljAuYx(GlCld6YR7`g8jmTqpmMXrdOXTG^v8v~r0+NLV(j62u41_!h;pJHc*sdH zBU!&vfBMQGeOSY2ODN+Z(f1S6^E Date: Mon, 2 Nov 2026 23:40:45 +0000 Subject: [PATCH 20/25] HTML API: Show where the HTML Processor stopped in the CommonCrawl report examples. The example documents for each unsupported reason, and for documents whose support changed, gave a URL and a call site but not where in the document the HTML Processor stopped, so finding the markup meant reparsing the document. Record what WP_HTML_Unsupported_Exception reports: the byte offset and length of the token being processed, its raw text, and the stack of open elements. When the parser stopped without an exception, record the breadcrumbs of its last token. Show the token, its byte range, and the innermost breadcrumbs in the example tables. See #66430. Co-Authored-By: Claude Opus 5.5 --- tools/html-api-commoncrawl/README.md | 4 +- tools/html-api-commoncrawl/analysis.php | 45 +++++++++---- tools/html-api-commoncrawl/report.php | 88 ++++++++++++++++++++----- 3 files changed, 106 insertions(+), 31 deletions(-) diff --git a/tools/html-api-commoncrawl/README.md b/tools/html-api-commoncrawl/README.md index 159d045035362..9b162df83196f 100644 --- a/tools/html-api-commoncrawl/README.md +++ b/tools/html-api-commoncrawl/README.md @@ -3,7 +3,7 @@ Compares how the HTML API parses real-world documents with and without a pull request. It fetches a random sample of HTML documents from the latest [CommonCrawl](https://commoncrawl.org/) crawl, then replays the same documents through both checkouts to measure: - how long the Tag Processor and the HTML Processor take to parse each document, and how long `wp_kses( $html, 'post' )` takes on it, for all documents and for small, medium, and large ones; -- which documents the HTML Processor does not support, and why it stopped on them, with a few example documents for each reason. +- which documents the HTML Processor does not support, and why it stopped on them, with a few example documents for each reason. Each example shows the token the HTML Processor stopped on, its byte offsets in the document, and the breadcrumbs of open elements at that point. The [`HTML API CommonCrawl Analysis`](../../.github/workflows/html-api-commoncrawl.yml) workflow runs this for a pull request when: - the `html-api-commoncrawl` label is added to the pull request, if it changes the HTML API or `kses.php`. The label is removed straight away, so add it again to run another analysis. @@ -16,7 +16,7 @@ The report is left as a comment on the pull request. Each new analysis replaces - `cc-analyzer.phar`: [cc-analyzer](#rebuilding-cc-analyzerphar), which fetches documents from CommonCrawl into a local batch, and replays a batch through an analysis script. It streams whole WARC files and keeps their HTML responses. That takes only a handful of requests: CommonCrawl blocks runners which send many, such as one per record. It honors CommonCrawl's rate limiting, and if a download still fails, it lists the failure and the analysis runs on the documents it has. - `bootstrap.php`: loads the HTML API, and what `wp_kses()` needs, from the `src/` directory in `HTML_API_BENCH_WP_SRC`, without loading WordPress. It presents a UTF-8 site at example.com. When a change adds a file to the HTML API, add it to the list of files here. - `benchmark.php`: an analysis script which times the Tag Processor, the HTML Processor, and `wp_kses( $html, 'post' )` on each document and writes `benchmark.csv`. -- `analysis.php`: an analysis script which records whether the HTML Processor supports each document and writes `analysis.csv`. +- `analysis.php`: an analysis script which records whether the HTML Processor supports each document, and where it stopped on those it does not, and writes `analysis.csv`. - `report.php`: compares the results from two checkouts and prints the Markdown report. ## Running locally diff --git a/tools/html-api-commoncrawl/analysis.php b/tools/html-api-commoncrawl/analysis.php index 83737ed0912a9..5d9bd5503da20 100644 --- a/tools/html-api-commoncrawl/analysis.php +++ b/tools/html-api-commoncrawl/analysis.php @@ -6,8 +6,15 @@ * the message passed to `WP_HTML_Processor::bail()` and the line in * `class-wp-html-processor.php` it was called from. Running out of bookmarks * stops the parser without an exception, so it is reported as - * `exceeded-max-bookmarks`. The results are written to `analysis.csv` in the - * `CC_ANALYZER_OUTPUT_DIR` directory. + * `exceeded-max-bookmarks`. + * + * It also records where the HTML Processor stopped: the byte offset and length + * of the token it was processing, the token's raw text, and the breadcrumbs of + * open elements at that point. The exception reports all of these; when the + * parser stopped without one, only the breadcrumbs of its last token are known. + * + * The results are written to `analysis.csv` in the `CC_ANALYZER_OUTPUT_DIR` + * directory. * * Usage: * @@ -25,7 +32,7 @@ } $csv = fopen( "{$output_dir}/analysis.csv", 'wb' ); -fputcsv( $csv, array( 'key', 'url', 'bytes', 'last_error', 'reason', 'line', 'token_name', 'incomplete' ), ',', '"', '' ); +fputcsv( $csv, array( 'key', 'url', 'bytes', 'last_error', 'reason', 'line', 'token_name', 'incomplete', 'token_at', 'token_length', 'token_text', 'breadcrumbs' ), ',', '"', '' ); return static function ( $document ) use ( $csv ) { $processor = WP_HTML_Processor::create_full_parser( $document->body ); @@ -33,18 +40,28 @@ continue; } - $last_error = $processor->get_last_error(); - $reason = ''; - $line = ''; - $token_name = ''; + $last_error = $processor->get_last_error(); + $reason = ''; + $line = ''; + $token_name = ''; + $token_at = ''; + $token_length = ''; + $token_text = ''; + $breadcrumbs = ''; if ( WP_HTML_Processor::ERROR_UNSUPPORTED === $last_error ) { - $exception = $processor->get_unsupported_exception(); - $reason = $exception->getMessage(); - $line = $exception->getTrace()[0]['line']; - $token_name = $exception->token_name; + $exception = $processor->get_unsupported_exception(); + $reason = $exception->getMessage(); + $line = $exception->getTrace()[0]['line']; + $token_name = $exception->token_name; + $token_at = $exception->token_at; + $token_length = strlen( $exception->token ); + // The token comes from a crawled document; keep enough to recognize it, as valid UTF-8 for the report. + $token_text = mb_scrub( substr( $exception->token, 0, 300 ), 'UTF-8' ); + $breadcrumbs = implode( ' > ', $exception->stack_of_open_elements ); } elseif ( null !== $last_error ) { - $reason = $last_error; + $reason = $last_error; + $breadcrumbs = implode( ' > ', $processor->get_breadcrumbs() ?? array() ); } fputcsv( @@ -58,6 +75,10 @@ $line, $token_name, $processor->paused_at_incomplete_token() ? '1' : '0', + $token_at, + $token_length, + $token_text, + $breadcrumbs, ), ',', '"', diff --git a/tools/html-api-commoncrawl/report.php b/tools/html-api-commoncrawl/report.php index 5731fa5307a15..0ff61e1e3eb15 100644 --- a/tools/html-api-commoncrawl/report.php +++ b/tools/html-api-commoncrawl/report.php @@ -106,15 +106,19 @@ function load_results( $dir ) { } $documents[ $row['key'] ] = array( - 'url' => $row['url'], - 'bytes' => $timing['bytes'], - 'tag_ns' => median( $timing['tag_ns'] ), - 'html_ns' => median( $timing['html_ns'] ), - 'kses_ns' => median( $timing['kses_ns'] ), - 'supported' => '' === $row['last_error'], - 'reason' => $row['reason'], - 'line' => $row['line'], - 'incomplete' => '1' === $row['incomplete'], + 'url' => $row['url'], + 'bytes' => $timing['bytes'], + 'tag_ns' => median( $timing['tag_ns'] ), + 'html_ns' => median( $timing['html_ns'] ), + 'kses_ns' => median( $timing['kses_ns'] ), + 'supported' => '' === $row['last_error'], + 'reason' => $row['reason'], + 'line' => $row['line'], + 'incomplete' => '1' === $row['incomplete'], + 'token_at' => $row['token_at'], + 'token_length' => $row['token_length'], + 'token_text' => $row['token_text'], + 'breadcrumbs' => $row['breadcrumbs'], ); } @@ -212,6 +216,54 @@ function format_code( $text, $max_length = 100 ) { return '`' . str_replace( array( '`', '|', "\n", "\r" ), array( "'", '\|', ' ', ' ' ), $text ) . '`'; } +/** + * Formats the bytes of a document which the HTML Processor stopped on, from where they start to where they end. + * + * @param array $result Analysis result for one document. + * @return string Byte range, or an empty string if it is not known. + */ +function format_bytes_span( $result ) { + if ( '' === $result['token_at'] ) { + return ''; + } + + return number_format( (int) $result['token_at'] ) . '–' . number_format( (int) $result['token_at'] + (int) $result['token_length'] ); +} + +/** + * Formats breadcrumbs as inline code, keeping the innermost elements of a deep stack. + * + * @param string $breadcrumbs Element names from the outermost to the innermost, separated by ` > `. + * @param int $max_count Most elements to show. + * @return string Inline code, or an empty string if there are no breadcrumbs. + */ +function format_breadcrumbs( $breadcrumbs, $max_count = 6 ) { + if ( '' === $breadcrumbs ) { + return ''; + } + + $elements = explode( ' > ', $breadcrumbs ); + if ( count( $elements ) > $max_count ) { + $elements = array( '…', ...array_slice( $elements, -$max_count ) ); + } + + return format_code( implode( ' > ', $elements ), 120 ); +} + +/** + * Formats where the HTML Processor stopped on a document: the token, its bytes, and the breadcrumbs. + * + * @param array $result Analysis result for one document. + * @return string[] Table cells. + */ +function failure_cells( $result ) { + return array( + '' === $result['token_text'] ? '' : format_code( $result['token_text'], 60 ), + format_bytes_span( $result ), + format_breadcrumbs( $result['breadcrumbs'] ), + ); +} + /** * Renders a Markdown table. * @@ -380,6 +432,9 @@ static function ( $a, $b ) use ( $parser ) { /** * Renders example documents whose support changed. * + * Where the HTML Processor stopped is shown for the PR, or for the target + * branch when the PR supports the document. + * * @param array[] $pairs Documents, each with `base` and `merge` results. * @return string Markdown table. */ @@ -391,10 +446,11 @@ function examples_table( $pairs ) { format_bytes( $pair['base']['bytes'] ), $pair['base']['supported'] ? 'supported' : format_code( "L{$pair['base']['line']}: {$pair['base']['reason']}" ), $pair['merge']['supported'] ? 'supported' : format_code( "L{$pair['merge']['line']}: {$pair['merge']['reason']}" ), + ...failure_cells( $pair['merge']['supported'] ? $pair['base'] : $pair['merge'] ), ); } - return table( array( 'URL', 'Size', 'Target', 'PR' ), $rows ); + return table( array( 'URL', 'Size', 'Target', 'PR', 'Token', 'Bytes', 'Breadcrumbs' ), $rows ); } /** @@ -402,7 +458,8 @@ function examples_table( $pairs ) { * * Examples come from the PR, or from the target branch for a reason only it * stops for. They are the smallest documents, which are the easiest to inspect, - * taking at most one from each host. + * taking at most one from each host. Each shows the token the HTML Processor + * stopped on, its bytes in the document, and the breadcrumbs at that point. * * @param array[] $reasons Reasons, in report order, each with `base` and `merge` counts, call sites, and documents. * @param callable $source_url Returns the link to `class-wp-html-processor.php` at a commit, or null. @@ -437,6 +494,7 @@ static function ( $a, $b ) { format_code( $document['url'] ), format_bytes( $document['bytes'] ), '' === $line ? '' : ( $url ? "[L{$line}]({$url}#L{$line})" : "L{$line}" ) . ( 'base' === $side ? ' (target)' : '' ), + ...failure_cells( $document ), ); if ( count( $hosts ) === HTML_API_BENCH_REASON_EXAMPLES ) { @@ -445,7 +503,7 @@ static function ( $a, $b ) { } } - return table( array( 'Reason', 'URL', 'Size', 'Call site' ), $rows ); + return table( array( 'Reason', 'URL', 'Size', 'Call site', 'Token', 'Bytes', 'Breadcrumbs' ), $rows ); } $base_results = load_results( $argv[1] ); @@ -564,11 +622,7 @@ static function ( $pair ) { } $reasons[ $result['reason'] ][ $side ]['count'] = ( $reasons[ $result['reason'] ][ $side ]['count'] ?? 0 ) + 1; - $reasons[ $result['reason'] ][ $side ]['documents'][] = array( - 'url' => $result['url'], - 'bytes' => $result['bytes'], - 'line' => $result['line'], - ); + $reasons[ $result['reason'] ][ $side ]['documents'][] = $result; if ( '' !== $result['line'] ) { $reasons[ $result['reason'] ][ $side ]['lines'][ $result['line'] ] = ( $reasons[ $result['reason'] ][ $side ]['lines'][ $result['line'] ] ?? 0 ) + 1; } From d1d6ff62350dc68c2507607fce76ab876a47877a Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Mon, 2 Nov 2026 23:46:26 +0000 Subject: [PATCH 21/25] HTML API: Show the text around the token where the HTML Processor stopped. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token alone is often too little to recognize the markup which stopped the HTML Processor: a bare `` or ``. Record up to 30 extended grapheme clusters of the document on either side of the token, with `…` where the document continues beyond them, and show the token within that text in a new Context column of the report's example tables. A long token is shortened in the middle so that the text on either side stays in view. Tokens longer than 300 bytes are now recorded by their start and end rather than their start. See #66430. Co-Authored-By: Claude Opus 5.5 --- tools/html-api-commoncrawl/README.md | 2 +- tools/html-api-commoncrawl/analysis.php | 57 +++++++++++++++++++++++-- tools/html-api-commoncrawl/report.php | 33 ++++++++++++-- 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/tools/html-api-commoncrawl/README.md b/tools/html-api-commoncrawl/README.md index 9b162df83196f..02bae5d3f5b12 100644 --- a/tools/html-api-commoncrawl/README.md +++ b/tools/html-api-commoncrawl/README.md @@ -3,7 +3,7 @@ Compares how the HTML API parses real-world documents with and without a pull request. It fetches a random sample of HTML documents from the latest [CommonCrawl](https://commoncrawl.org/) crawl, then replays the same documents through both checkouts to measure: - how long the Tag Processor and the HTML Processor take to parse each document, and how long `wp_kses( $html, 'post' )` takes on it, for all documents and for small, medium, and large ones; -- which documents the HTML Processor does not support, and why it stopped on them, with a few example documents for each reason. Each example shows the token the HTML Processor stopped on, its byte offsets in the document, and the breadcrumbs of open elements at that point. +- which documents the HTML Processor does not support, and why it stopped on them, with a few example documents for each reason. Each example shows the token the HTML Processor stopped on, the token with up to 30 characters (extended grapheme clusters) of the document on either side, its byte offsets in the document, and the breadcrumbs of open elements at that point. The [`HTML API CommonCrawl Analysis`](../../.github/workflows/html-api-commoncrawl.yml) workflow runs this for a pull request when: - the `html-api-commoncrawl` label is added to the pull request, if it changes the HTML API or `kses.php`. The label is removed straight away, so add it again to run another analysis. diff --git a/tools/html-api-commoncrawl/analysis.php b/tools/html-api-commoncrawl/analysis.php index 5d9bd5503da20..6fc985244bea5 100644 --- a/tools/html-api-commoncrawl/analysis.php +++ b/tools/html-api-commoncrawl/analysis.php @@ -12,6 +12,8 @@ * of the token it was processing, the token's raw text, and the breadcrumbs of * open elements at that point. The exception reports all of these; when the * parser stopped without one, only the breadcrumbs of its last token are known. + * Up to 30 extended grapheme clusters on either side of the token are recorded + * too, starting or ending with `…` where the document continues beyond them. * * The results are written to `analysis.csv` in the `CC_ANALYZER_OUTPUT_DIR` * directory. @@ -31,8 +33,50 @@ mkdir( $output_dir, 0777, true ); } +// How many extended grapheme clusters of the document to show on either side of the token where parsing stopped. +const HTML_API_BENCH_CONTEXT_GRAPHEMES = 30; + +// Bytes read on either side of the token to find them in: enough for 30 clusters of even long emoji sequences. +const HTML_API_BENCH_CONTEXT_BYTES = 1024; + +/** + * Returns text from a crawled document, as valid UTF-8, keeping its start and end if it is long. + * + * @param string $text Text to keep. + * @param int $max_bytes Most bytes to keep from each end of a long text. + * @return string The text, or its start and end joined by `…`. + */ +function html_api_bench_excerpt( $text, $max_bytes = 150 ) { + if ( strlen( $text ) > 2 * $max_bytes ) { + $text = substr( $text, 0, $max_bytes ) . '…' . substr( $text, -$max_bytes ); + } + + return mb_scrub( $text, 'UTF-8' ); +} + +/** + * Returns the extended grapheme clusters of a document next to a byte offset. + * + * @param string $html Document. + * @param int $offset Byte offset of the boundary, at the start or end of a token. + * @param bool $before Whether to take the clusters before the offset, rather than after it. + * @return string Up to HTML_API_BENCH_CONTEXT_GRAPHEMES clusters, marked with `…` on the side where the document continues. + */ +function html_api_bench_context( $html, $offset, $before ) { + $start = $before ? max( 0, $offset - HTML_API_BENCH_CONTEXT_BYTES ) : $offset; + $window = mb_scrub( substr( $html, $start, $before ? $offset - $start : HTML_API_BENCH_CONTEXT_BYTES ), 'UTF-8' ); + $count = HTML_API_BENCH_CONTEXT_GRAPHEMES; + $text = 1 === preg_match( $before ? "/\X{0,{$count}}\z/u" : "/\A\X{0,{$count}}/u", $window, $matches ) ? $matches[0] : ''; + + if ( $before ) { + return ( $start > 0 || strlen( $text ) < strlen( $window ) ? '…' : '' ) . $text; + } + + return $text . ( $offset + HTML_API_BENCH_CONTEXT_BYTES < strlen( $html ) || strlen( $text ) < strlen( $window ) ? '…' : '' ); +} + $csv = fopen( "{$output_dir}/analysis.csv", 'wb' ); -fputcsv( $csv, array( 'key', 'url', 'bytes', 'last_error', 'reason', 'line', 'token_name', 'incomplete', 'token_at', 'token_length', 'token_text', 'breadcrumbs' ), ',', '"', '' ); +fputcsv( $csv, array( 'key', 'url', 'bytes', 'last_error', 'reason', 'line', 'token_name', 'incomplete', 'token_at', 'token_length', 'token_text', 'breadcrumbs', 'context_before', 'context_after' ), ',', '"', '' ); return static function ( $document ) use ( $csv ) { $processor = WP_HTML_Processor::create_full_parser( $document->body ); @@ -48,6 +92,8 @@ $token_length = ''; $token_text = ''; $breadcrumbs = ''; + $before = ''; + $after = ''; if ( WP_HTML_Processor::ERROR_UNSUPPORTED === $last_error ) { $exception = $processor->get_unsupported_exception(); @@ -56,9 +102,10 @@ $token_name = $exception->token_name; $token_at = $exception->token_at; $token_length = strlen( $exception->token ); - // The token comes from a crawled document; keep enough to recognize it, as valid UTF-8 for the report. - $token_text = mb_scrub( substr( $exception->token, 0, 300 ), 'UTF-8' ); - $breadcrumbs = implode( ' > ', $exception->stack_of_open_elements ); + $token_text = html_api_bench_excerpt( $exception->token ); + $breadcrumbs = implode( ' > ', $exception->stack_of_open_elements ); + $before = html_api_bench_context( $document->body, $token_at, true ); + $after = html_api_bench_context( $document->body, $token_at + $token_length, false ); } elseif ( null !== $last_error ) { $reason = $last_error; $breadcrumbs = implode( ' > ', $processor->get_breadcrumbs() ?? array() ); @@ -79,6 +126,8 @@ $token_length, $token_text, $breadcrumbs, + $before, + $after, ), ',', '"', diff --git a/tools/html-api-commoncrawl/report.php b/tools/html-api-commoncrawl/report.php index 0ff61e1e3eb15..743d11cb080f6 100644 --- a/tools/html-api-commoncrawl/report.php +++ b/tools/html-api-commoncrawl/report.php @@ -119,6 +119,8 @@ function load_results( $dir ) { 'token_length' => $row['token_length'], 'token_text' => $row['token_text'], 'breadcrumbs' => $row['breadcrumbs'], + 'before' => $row['context_before'], + 'after' => $row['context_after'], ); } @@ -251,7 +253,28 @@ function format_breadcrumbs( $breadcrumbs, $max_count = 6 ) { } /** - * Formats where the HTML Processor stopped on a document: the token, its bytes, and the breadcrumbs. + * Formats the token the HTML Processor stopped on with the text around it in the document. + * + * A long token is shortened in the middle, so that the text on either side of it stays in view. + * + * @param array $result Analysis result for one document. + * @return string Inline code, or an empty string if the token is not known. + */ +function format_context( $result ) { + if ( '' === $result['token_at'] ) { + return ''; + } + + $token = $result['token_text']; + if ( mb_strlen( $token ) > 40 ) { + $token = mb_substr( $token, 0, 20 ) . '…' . mb_substr( $token, -19 ); + } + + return format_code( $result['before'] . $token . $result['after'], 400 ); +} + +/** + * Formats where the HTML Processor stopped on a document: the token, the text around it, its bytes, and the breadcrumbs. * * @param array $result Analysis result for one document. * @return string[] Table cells. @@ -259,6 +282,7 @@ function format_breadcrumbs( $breadcrumbs, $max_count = 6 ) { function failure_cells( $result ) { return array( '' === $result['token_text'] ? '' : format_code( $result['token_text'], 60 ), + format_context( $result ), format_bytes_span( $result ), format_breadcrumbs( $result['breadcrumbs'] ), ); @@ -450,7 +474,7 @@ function examples_table( $pairs ) { ); } - return table( array( 'URL', 'Size', 'Target', 'PR', 'Token', 'Bytes', 'Breadcrumbs' ), $rows ); + return table( array( 'URL', 'Size', 'Target', 'PR', 'Token', 'Context', 'Bytes', 'Breadcrumbs' ), $rows ); } /** @@ -459,7 +483,8 @@ function examples_table( $pairs ) { * Examples come from the PR, or from the target branch for a reason only it * stops for. They are the smallest documents, which are the easiest to inspect, * taking at most one from each host. Each shows the token the HTML Processor - * stopped on, its bytes in the document, and the breadcrumbs at that point. + * stopped on, the text around it, its bytes in the document, and the + * breadcrumbs at that point. * * @param array[] $reasons Reasons, in report order, each with `base` and `merge` counts, call sites, and documents. * @param callable $source_url Returns the link to `class-wp-html-processor.php` at a commit, or null. @@ -503,7 +528,7 @@ static function ( $a, $b ) { } } - return table( array( 'Reason', 'URL', 'Size', 'Call site', 'Token', 'Bytes', 'Breadcrumbs' ), $rows ); + return table( array( 'Reason', 'URL', 'Size', 'Call site', 'Token', 'Context', 'Bytes', 'Breadcrumbs' ), $rows ); } $base_results = load_results( $argv[1] ); From 23c6b21afa4b087fee9776a3450a5048cc6a5adb Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 3 Nov 2026 00:39:43 +0000 Subject: [PATCH 22/25] HTML API: Time the HTML API implementation of wp_kses() in the CommonCrawl analysis. WordPress uses the HTML API implementation of wp_kses() by default: default-filters.php adds `__return_false` to `wp_kses_force_legacy_parser`. The analysis does not load that file, so it timed the legacy implementation instead, without the `pre_kses` filters which WordPress runs before it. Without them, the legacy implementation never returns on documents such as https://pdn.ps/p/20371, which stopped every analysis of the crawl's first WARC file. Add the same filter, and load what the HTML API implementation needs: the block processor and the block attribute filters in blocks.php. The other wp_kses() hooks in default-filters.php do nothing for this implementation with `'post'`. See #66430. Co-Authored-By: Claude Opus 5.5 --- tools/html-api-commoncrawl/bootstrap.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tools/html-api-commoncrawl/bootstrap.php b/tools/html-api-commoncrawl/bootstrap.php index cf86d20dc80f0..6f8166f2c68fb 100644 --- a/tools/html-api-commoncrawl/bootstrap.php +++ b/tools/html-api-commoncrawl/bootstrap.php @@ -9,6 +9,10 @@ * read are answered here, presenting a UTF-8 site at example.com. Reading any * other option fails, which points to whatever new dependency needs it. * + * `default-filters.php` is not loaded either. `wp_kses()` is set up to use its + * HTML API implementation, as that file sets it up for WordPress; its other + * hooks for `wp_kses()` do nothing for that implementation with `'post'`. + * * When a change adds a file to the HTML API, add it to the list below. * * @package WordPress @@ -63,6 +67,22 @@ require ABSPATH . WPINC . '/html-api/class-wp-html-processor-state.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php'; +/* + * The HTML API implementation of wp_kses() reads block delimiters in HTML comments + * and filters their attributes with filter_block_kses_value(). blocks.php only + * defines functions. Before 6.9 there is no block processor. + */ +require ABSPATH . WPINC . '/blocks.php'; +if ( is_file( ABSPATH . WPINC . '/class-wp-block-processor.php' ) ) { + require ABSPATH . WPINC . '/class-wp-block-processor.php'; +} + +/* + * Use the HTML API implementation of wp_kses(), as default-filters.php does. + * Without this filter wp_kses() falls back to its legacy implementation. + */ +add_filter( 'wp_kses_force_legacy_parser', '__return_false' ); + // l10n.php is not loaded, so messages are left untranslated. function __( $text ) { return $text; From d86e3a7f2f930364008a8690c9a615e33835aaa1 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 3 Nov 2026 01:11:37 +0000 Subject: [PATCH 23/25] HTML API: Report progress through the CommonCrawl analysis. With every HTML document of a WARC file, around 20,000, each pass of the timing step takes several minutes, and printed nothing until it ended, so a run looked stuck. Report the number of documents processed, the time taken, and the rate every 1,000 documents, from both the timing and the support scripts. See #66430. Co-Authored-By: Claude Opus 5.5 --- tools/html-api-commoncrawl/analysis.php | 2 ++ tools/html-api-commoncrawl/benchmark.php | 2 ++ tools/html-api-commoncrawl/bootstrap.php | 24 ++++++++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/tools/html-api-commoncrawl/analysis.php b/tools/html-api-commoncrawl/analysis.php index 6fc985244bea5..d554f9acfacb0 100644 --- a/tools/html-api-commoncrawl/analysis.php +++ b/tools/html-api-commoncrawl/analysis.php @@ -133,4 +133,6 @@ function html_api_bench_context( $html, $offset, $before ) { '"', '' ); + + html_api_bench_progress( 'support' ); }; diff --git a/tools/html-api-commoncrawl/benchmark.php b/tools/html-api-commoncrawl/benchmark.php index 33be1a2f05317..d1ec1eac7a88a 100644 --- a/tools/html-api-commoncrawl/benchmark.php +++ b/tools/html-api-commoncrawl/benchmark.php @@ -84,4 +84,6 @@ '"', '' ); + + html_api_bench_progress( 'timing' ); }; diff --git a/tools/html-api-commoncrawl/bootstrap.php b/tools/html-api-commoncrawl/bootstrap.php index 6f8166f2c68fb..2ac8b4ae84dd8 100644 --- a/tools/html-api-commoncrawl/bootstrap.php +++ b/tools/html-api-commoncrawl/bootstrap.php @@ -120,3 +120,27 @@ static function () { return ''; } ); + +/** + * Reports progress through a batch every 1,000 documents, so that a long run does not look stuck. + * + * Call it once for each document, after the document's measurements. It writes + * to STDERR, which shows in the workflow log between the analysis script's runs. + * + * @param string $label What is being done, such as `timing`. + */ +function html_api_bench_progress( $label ) { + static $count = 0; + static $started_at = null; + + $started_at ??= hrtime( true ); + if ( 0 !== ++$count % 1000 ) { + return; + } + + $seconds = max( 1, intdiv( hrtime( true ) - $started_at, 1000000000 ) ); + fwrite( + STDERR, + sprintf( " %s: %s documents after %dm %02ds (%.0f per second)\n", $label, number_format( $count ), intdiv( $seconds, 60 ), $seconds % 60, $count / $seconds ) + ); +} From 28471563e0ff9e2cfdab51fc7f9823d2088bcf06 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 3 Nov 2026 05:25:42 +0000 Subject: [PATCH 24/25] HTML API: Time the rounds of both checkouts at once in the CommonCrawl analysis. The analysis timed out at 90 minutes: six timing passes in sequence took 80 minutes, before support was recorded. Now each round runs one process for each checkout, with as many rounds at once as there are pairs of vCPUs: on the 4-vCPU runner, two rounds of both checkouts run together. Both checkouts are timed under the same load, as on a busy server, and timing takes about as long as one round. Support is recorded for both checkouts at once as well. The default number of rounds drops from three to two, which fill the runner. Progress lines name their results directory, such as `base/round-2`, since the processes share the log, and the report says when each document was timed only once. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/html-api-commoncrawl.yml | 50 ++++++++++++++++------ tools/html-api-commoncrawl/README.md | 4 +- tools/html-api-commoncrawl/bootstrap.php | 11 ++++- tools/html-api-commoncrawl/report.php | 4 +- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/.github/workflows/html-api-commoncrawl.yml b/.github/workflows/html-api-commoncrawl.yml index 3ab4b51dd848b..5ae3f06a75cd7 100644 --- a/.github/workflows/html-api-commoncrawl.yml +++ b/.github/workflows/html-api-commoncrawl.yml @@ -38,10 +38,10 @@ on: type: string default: '30m' rounds: - description: 'How many times to time each document with each checkout' + description: 'How many times to time each document with each checkout. Two processes per round run at once on each pair of vCPUs' required: false type: number - default: 3 + default: 2 small-max-bytes: description: 'Documents smaller than this many bytes are small' required: false @@ -66,7 +66,7 @@ env: # The analysis keeps every HTML document in this many WARC files, unless the fetch duration passes first. HTML_API_BENCH_WARCS: ${{ inputs.warcs || 1 }} HTML_API_BENCH_FETCH_DURATION: ${{ inputs.fetch-duration || '30m' }} - HTML_API_BENCH_ROUNDS: ${{ inputs.rounds || 3 }} + HTML_API_BENCH_ROUNDS: ${{ inputs.rounds || 2 }} HTML_API_BENCH_SMALL_MAX_BYTES: ${{ inputs.small-max-bytes || 32768 }} HTML_API_BENCH_LARGE_MIN_BYTES: ${{ inputs.large-min-bytes || 262144 }} @@ -77,9 +77,9 @@ jobs: # - Checks out the pull request merged into its target branch, and the target branch beside it. # - Sets up PHP. # - Fetches a random sample of HTML documents from the latest CommonCrawl crawl. - # - Times the Tag Processor, the HTML Processor, and wp_kses() on each document with both checkouts, alternating which - # goes first in each round. - # - Records which documents each checkout supports, and why the others are unsupported. + # - Times the Tag Processor, the HTML Processor, and wp_kses() on each document with both checkouts, running the rounds + # of both checkouts at the same time, one per vCPU, so that both share the same load. + # - Records which documents each checkout supports, and why the others are unsupported, with both checkouts at once. # - Writes the report. # - Uploads the report and the pull request number. analyze: @@ -135,24 +135,46 @@ jobs: --duration "$HTML_API_BENCH_FETCH_DURATION" \ --progress text + # Each round runs one process for each checkout, and as many rounds run at once as there are pairs of vCPUs: two on + # the standard runner. Both checkouts are timed under the same load, as on a busy server, and the step takes about + # as long as one round would on its own. Each process replays the batch separately, so the documents' times are + # independent. If one process fails, the others are stopped and the step fails. - name: Time parsing with both checkouts run: | - for round in $( seq 1 "$HTML_API_BENCH_ROUNDS" ); do - if (( round % 2 )); then order='base merge'; else order='merge base'; fi - for checkout in $order; do - echo "Round ${round}: ${checkout}" - HTML_API_BENCH_WP_SRC="$GITHUB_WORKSPACE/$checkout/src" \ - CC_ANALYZER_OUTPUT_DIR="$RUNNER_TEMP/results/$checkout/round-$round" \ - php "$CC_ANALYZER" --workspace "$RUNNER_TEMP/cc-analyzer" batch run corpus "$SCRIPTS/benchmark.php" + trap 'kill $( jobs -p ) 2> /dev/null || true' EXIT + rounds_at_once=$(( $( nproc ) / 2 )) + (( rounds_at_once > 0 )) || rounds_at_once=1 + for (( first = 1; first <= HTML_API_BENCH_ROUNDS; first += rounds_at_once )); do + last=$(( first + rounds_at_once - 1 )) + (( last <= HTML_API_BENCH_ROUNDS )) || last=$HTML_API_BENCH_ROUNDS + echo "Rounds ${first} to ${last}: base and merge, $(( 2 * ( last - first + 1 ) )) processes at once" + pids=() + for round in $( seq "$first" "$last" ); do + for checkout in base merge; do + HTML_API_BENCH_WP_SRC="$GITHUB_WORKSPACE/$checkout/src" \ + CC_ANALYZER_OUTPUT_DIR="$RUNNER_TEMP/results/$checkout/round-$round" \ + php "$CC_ANALYZER" --workspace "$RUNNER_TEMP/cc-analyzer" batch run corpus "$SCRIPTS/benchmark.php" & + pids+=( "$!" ) + done + done + for pid in "${pids[@]}"; do + wait "$pid" done done + # Nothing is timed here, so both checkouts run at once. - name: Record support with both checkouts run: | + trap 'kill $( jobs -p ) 2> /dev/null || true' EXIT + pids=() for checkout in base merge; do HTML_API_BENCH_WP_SRC="$GITHUB_WORKSPACE/$checkout/src" \ CC_ANALYZER_OUTPUT_DIR="$RUNNER_TEMP/results/$checkout/analysis" \ - php "$CC_ANALYZER" --workspace "$RUNNER_TEMP/cc-analyzer" batch run corpus "$SCRIPTS/analysis.php" + php "$CC_ANALYZER" --workspace "$RUNNER_TEMP/cc-analyzer" batch run corpus "$SCRIPTS/analysis.php" & + pids+=( "$!" ) + done + for pid in "${pids[@]}"; do + wait "$pid" done - name: Write the report diff --git a/tools/html-api-commoncrawl/README.md b/tools/html-api-commoncrawl/README.md index 02bae5d3f5b12..7d7a57415e73e 100644 --- a/tools/html-api-commoncrawl/README.md +++ b/tools/html-api-commoncrawl/README.md @@ -47,6 +47,8 @@ php tools/html-api-commoncrawl/report.php /tmp/results/base /tmp/results/merge > A batch can be replayed as many times as needed without fetching it again. +The workflow runs these replays at the same time, one process for each round of each checkout, as many rounds at once as there are pairs of vCPUs. On the standard 4-vCPU runner, both rounds of both checkouts run together, so timing takes about as long as one round. Both checkouts are timed under the same load, as on a busy server. A vCPU may be one of a core's two hardware threads, so each process can run slower than it would alone, and the times are best compared with each other rather than read on their own. Recording support for both checkouts also runs at once. + ## Settings The workflow reads these environment variables. When it is run manually, each one can be set from an input of the same name. @@ -55,7 +57,7 @@ The workflow reads these environment variables. When it is run manually, each on | --- | --- | --- | | `HTML_API_BENCH_WARCS` | `1` | How many WARC files to download. Every HTML document in them is analyzed; each file holds around 20,000. | | `HTML_API_BENCH_FETCH_DURATION` | `30m` | Longest time to spend fetching. Fetching stops at whichever limit is reached first. | -| `HTML_API_BENCH_ROUNDS` | `3` | How many times to time each document with each checkout. Each document's time is its median. | +| `HTML_API_BENCH_ROUNDS` | `2` | How many times to time each document with each checkout. Each document's time is its median. | | `HTML_API_BENCH_SMALL_MAX_BYTES` | `32768` | Documents smaller than this are small. | | `HTML_API_BENCH_LARGE_MIN_BYTES` | `262144` | Documents at least this large are large. Everything between is medium. | diff --git a/tools/html-api-commoncrawl/bootstrap.php b/tools/html-api-commoncrawl/bootstrap.php index 2ac8b4ae84dd8..1fa712770e6c3 100644 --- a/tools/html-api-commoncrawl/bootstrap.php +++ b/tools/html-api-commoncrawl/bootstrap.php @@ -125,15 +125,22 @@ static function () { * Reports progress through a batch every 1,000 documents, so that a long run does not look stuck. * * Call it once for each document, after the document's measurements. It writes - * to STDERR, which shows in the workflow log between the analysis script's runs. + * to STDERR, which shows in the workflow log. Several processes run at once in + * the workflow, so each line names its results directory, such as `base/round-2`. * * @param string $label What is being done, such as `timing`. */ function html_api_bench_progress( $label ) { static $count = 0; static $started_at = null; + static $name = null; $started_at ??= hrtime( true ); + if ( null === $name ) { + $output_dir = rtrim( (string) getenv( 'CC_ANALYZER_OUTPUT_DIR' ), '/' ); + $name = '' === $output_dir ? '' : ' ' . basename( dirname( $output_dir ) ) . '/' . basename( $output_dir ); + } + if ( 0 !== ++$count % 1000 ) { return; } @@ -141,6 +148,6 @@ function html_api_bench_progress( $label ) { $seconds = max( 1, intdiv( hrtime( true ) - $started_at, 1000000000 ) ); fwrite( STDERR, - sprintf( " %s: %s documents after %dm %02ds (%.0f per second)\n", $label, number_format( $count ), intdiv( $seconds, 60 ), $seconds % 60, $count / $seconds ) + sprintf( " %s%s: %s documents after %dm %02ds (%.0f per second)\n", $label, $name, number_format( $count ), intdiv( $seconds, 60 ), $seconds % 60, $count / $seconds ) ); } diff --git a/tools/html-api-commoncrawl/report.php b/tools/html-api-commoncrawl/report.php index 743d11cb080f6..29b9adbdbf77d 100644 --- a/tools/html-api-commoncrawl/report.php +++ b/tools/html-api-commoncrawl/report.php @@ -577,12 +577,12 @@ static function ( $pair ) { $report = HTML_API_BENCH_REPORT_MARKER . "\n"; $report .= "## HTML API CommonCrawl analysis\n\n"; $report .= sprintf( - "Parsed %s documents from CommonCrawl `%s` with the target branch%s and with this PR merged into it%s. Each time is the median of %d rounds. Negative deltas mean the PR is faster.%s\n\n", + "Parsed %s documents from CommonCrawl `%s` with the target branch%s and with this PR merged into it%s. %s. Negative deltas mean the PR is faster.%s\n\n", number_format( count( $pairs ) ), $crawl[1], $base_sha ? ' (`' . substr( $base_sha, 0, 10 ) . '`)' : '', $merge_sha ? ' (`' . substr( $merge_sha, 0, 10 ) . '`)' : '', - $rounds, + 1 === $rounds ? 'Each document was timed once' : "Each time is the median of {$rounds} rounds", $run_url ? " [Workflow run]({$run_url})." : '' ); From ca22c3fa240cfc76e7655ffb28606d42bf5b1dd4 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 3 Nov 2026 06:32:39 +0000 Subject: [PATCH 25/25] HTML API: Analyze WARC files picked at random from the latest crawl. The analysis always read the first WARC file listed for the crawl, because cc-analyzer took the listing in order and each run starts with an empty workspace. Every run analyzed the same 21,642 documents until CommonCrawl published its next crawl. The fetch now picks its WARC files at random from the crawl's whole list, around 100,000 files fetched over a couple of weeks. The seed which picked them is shown in the report with the files read, and the new `seed` and `crawl` inputs analyze the same documents again. cc-analyzer.phar is rebuilt from cc-analyzer commit 1bca572, which adds `--warc-order random` and `--seed`, and finds the latest crawl from the data server's listing when index.commoncrawl.org cannot be reached, instead of falling back to a months-old crawl. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/html-api-commoncrawl.yml | 25 ++++++++++++++++++-- tools/html-api-commoncrawl/README.md | 10 ++++---- tools/html-api-commoncrawl/cc-analyzer.phar | Bin 531281 -> 538298 bytes tools/html-api-commoncrawl/report.php | 19 +++++++++++++++ 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/.github/workflows/html-api-commoncrawl.yml b/.github/workflows/html-api-commoncrawl.yml index 5ae3f06a75cd7..3dff1d2be1bf5 100644 --- a/.github/workflows/html-api-commoncrawl.yml +++ b/.github/workflows/html-api-commoncrawl.yml @@ -32,6 +32,16 @@ on: required: false type: number default: 1 + seed: + description: 'Picks the WARC files: leave empty for a new random choice, or give the seed from an earlier report to analyze the same documents again' + required: false + type: string + default: '' + crawl: + description: 'CommonCrawl crawl to read, e.g. CC-MAIN-2026-43, or latest' + required: false + type: string + default: 'latest' fetch-duration: description: 'Longest time to spend fetching documents, e.g. 90s, 30m, 1h' required: false @@ -65,6 +75,8 @@ permissions: {} env: # The analysis keeps every HTML document in this many WARC files, unless the fetch duration passes first. HTML_API_BENCH_WARCS: ${{ inputs.warcs || 1 }} + HTML_API_BENCH_SEED: ${{ inputs.seed || '' }} + HTML_API_BENCH_CRAWL: ${{ inputs.crawl || 'latest' }} HTML_API_BENCH_FETCH_DURATION: ${{ inputs.fetch-duration || '30m' }} HTML_API_BENCH_ROUNDS: ${{ inputs.rounds || 2 }} HTML_API_BENCH_SMALL_MAX_BYTES: ${{ inputs.small-max-bytes || 32768 }} @@ -76,7 +88,7 @@ jobs: # Performs the following steps: # - Checks out the pull request merged into its target branch, and the target branch beside it. # - Sets up PHP. - # - Fetches a random sample of HTML documents from the latest CommonCrawl crawl. + # - Fetches every HTML document in WARC files picked at random from the latest CommonCrawl crawl. # - Times the Tag Processor, the HTML Processor, and wp_kses() on each document with both checkouts, running the rounds # of both checkouts at the same time, one per vCPU, so that both share the same load. # - Records which documents each checkout supports, and why the others are unsupported, with both checkouts at once. @@ -119,13 +131,21 @@ jobs: ini-values: memory_limit=1G # Streams whole WARC files, a handful of requests in all: CommonCrawl blocks runners which send many requests, such as - # one per record. Every HTML document in them is kept; the number of files only bounds the download. When CommonCrawl + # one per record. Every HTML document in them is kept; the number of files only bounds the download. The files are + # picked at random from the crawl's whole list, around 100,000 files fetched over a couple of weeks, so that each run + # sees a different part of the web. The seed is reported, and running the workflow again with it reads the same files. When CommonCrawl # asks to slow down, with HTTP 429 or 503, no request is sent until its Retry-After or the backoff has passed. A download # which still fails is listed, and the analysis runs on the documents already fetched. The cache limit is raised so # that pruning never runs during the fetch. - name: Fetch documents from CommonCrawl + id: fetch run: | + seed="${HTML_API_BENCH_SEED:-$(( (RANDOM << 15) | RANDOM ))}" + echo "seed=$seed" >> "$GITHUB_OUTPUT" php "$CC_ANALYZER" --workspace "$RUNNER_TEMP/cc-analyzer" batch fetch corpus \ + --crawl "$HTML_API_BENCH_CRAWL" \ + --warc-order random \ + --seed "$seed" \ --resource-types html \ --limit all \ --limit-warcs "$HTML_API_BENCH_WARCS" \ @@ -187,6 +207,7 @@ jobs: HTML_API_BENCH_BASE_SHA: ${{ steps.checkouts.outputs.base-sha }} HTML_API_BENCH_MERGE_SHA: ${{ steps.checkouts.outputs.merge-sha }} HTML_API_BENCH_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + HTML_API_BENCH_SEED: ${{ steps.fetch.outputs.seed }} - name: Upload the report uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/tools/html-api-commoncrawl/README.md b/tools/html-api-commoncrawl/README.md index 7d7a57415e73e..90be6ba1f1ed1 100644 --- a/tools/html-api-commoncrawl/README.md +++ b/tools/html-api-commoncrawl/README.md @@ -1,6 +1,6 @@ # HTML API CommonCrawl analysis -Compares how the HTML API parses real-world documents with and without a pull request. It fetches a random sample of HTML documents from the latest [CommonCrawl](https://commoncrawl.org/) crawl, then replays the same documents through both checkouts to measure: +Compares how the HTML API parses real-world documents with and without a pull request. It fetches every HTML document in a WARC file picked at random from the latest [CommonCrawl](https://commoncrawl.org/) crawl, then replays the same documents through both checkouts to measure: - how long the Tag Processor and the HTML Processor take to parse each document, and how long `wp_kses( $html, 'post' )` takes on it, for all documents and for small, medium, and large ones; - which documents the HTML Processor does not support, and why it stopped on them, with a few example documents for each reason. Each example shows the token the HTML Processor stopped on, the token with up to 30 characters (extended grapheme clusters) of the document on either side, its byte offsets in the document, and the breadcrumbs of open elements at that point. @@ -13,7 +13,7 @@ The report is left as a comment on the pull request. Each new analysis replaces ## Files -- `cc-analyzer.phar`: [cc-analyzer](#rebuilding-cc-analyzerphar), which fetches documents from CommonCrawl into a local batch, and replays a batch through an analysis script. It streams whole WARC files and keeps their HTML responses. That takes only a handful of requests: CommonCrawl blocks runners which send many, such as one per record. It honors CommonCrawl's rate limiting, and if a download still fails, it lists the failure and the analysis runs on the documents it has. +- `cc-analyzer.phar`: [cc-analyzer](#rebuilding-cc-analyzerphar), which fetches documents from CommonCrawl into a local batch, and replays a batch through an analysis script. It streams whole WARC files and keeps their HTML responses. That takes only a handful of requests: CommonCrawl blocks runners which send many, such as one per record. The files are picked at random from the crawl's whole list of around 100,000, fetched over a couple of weeks, so each analysis sees a different part of the web. One file already holds documents from nearly 20,000 hosts. It honors CommonCrawl's rate limiting, and if a download still fails, it lists the failure and the analysis runs on the documents it has. - `bootstrap.php`: loads the HTML API, and what `wp_kses()` needs, from the `src/` directory in `HTML_API_BENCH_WP_SRC`, without loading WordPress. It presents a UTF-8 site at example.com. When a change adds a file to the HTML API, add it to the list of files here. - `benchmark.php`: an analysis script which times the Tag Processor, the HTML Processor, and `wp_kses( $html, 'post' )` on each document and writes `benchmark.csv`. - `analysis.php`: an analysis script which records whether the HTML Processor supports each document, and where it stopped on those it does not, and writes `analysis.csv`. @@ -27,7 +27,7 @@ This needs PHP 8.5, as used by the workflow, with the `curl`, `pdo_sqlite`, and # Fetch 1,000 documents, or as many as can be fetched in 5 minutes. The workflow uses --limit all instead, to keep every # HTML document in the WARC files it downloads. php -d memory_limit=1G tools/html-api-commoncrawl/cc-analyzer.phar --workspace /tmp/cc batch fetch corpus \ - --resource-types html --limit 1000 --limit-warcs 1 --max-jobs 1 --http-retries 6 --duration 5m --progress text + --resource-types html --warc-order random --limit 1000 --limit-warcs 1 --max-jobs 1 --http-retries 6 --duration 5m --progress text # Time each document three times with each checkout, and record support once. for checkout in base merge; do @@ -56,6 +56,8 @@ The workflow reads these environment variables. When it is run manually, each on | Variable | Default | Meaning | | --- | --- | --- | | `HTML_API_BENCH_WARCS` | `1` | How many WARC files to download. Every HTML document in them is analyzed; each file holds around 20,000. | +| `HTML_API_BENCH_SEED` | random | Picks the WARC files. The report gives the seed it used; give it again, with the same crawl, to analyze the same documents. | +| `HTML_API_BENCH_CRAWL` | `latest` | The CommonCrawl crawl to read, such as `CC-MAIN-2026-43`. | | `HTML_API_BENCH_FETCH_DURATION` | `30m` | Longest time to spend fetching. Fetching stops at whichever limit is reached first. | | `HTML_API_BENCH_ROUNDS` | `2` | How many times to time each document with each checkout. Each document's time is its median. | | `HTML_API_BENCH_SMALL_MAX_BYTES` | `32768` | Documents smaller than this are small. | @@ -63,7 +65,7 @@ The workflow reads these environment variables. When it is run manually, each on ## Rebuilding `cc-analyzer.phar` -`cc-analyzer.phar` was built from cc-analyzer commit `57bfbca` (branch `index-sampled-fetch`) with `tools/build-phar.php`. Before building, the default HTTP identity was removed so that requests carry only a generic `User-Agent` and no `From` header: +`cc-analyzer.phar` was built from cc-analyzer commit `1bca572` (branch `index-sampled-fetch`) with `tools/build-phar.php`. Before building, the default HTTP identity was removed so that requests carry only a generic `User-Agent` and no `From` header: - `src/Http/CurlStreamClient.php`: `DEFAULT_USER_AGENT` set to `'cc-analyzer/0.1 PHP-cURL'` and `DEFAULT_CONTACT_HEADER` set to `''`. - `src/Cli/Application.php`: the `--user-agent` and `--contact-email` defaults set to the same values, and the help text to match. diff --git a/tools/html-api-commoncrawl/cc-analyzer.phar b/tools/html-api-commoncrawl/cc-analyzer.phar index c8b59c427ed38dbc728dd615dfb8d28e334f469f..32c396fc6eb5b03841aec9ec0894ca45a2ab9cba 100755 GIT binary patch delta 5307 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 N|1U7*k_D{`{tNHJFev~4 delta 1428 zcmYLJZA?>V6u$R;-`m^oURv7HLa^Bgh;sKvAMu-_ykXxc7O_dCxh| zbDrGXi!*`i7XsbCk>2c{se;s7SLH$htL3PGHu)I=L-Kk7GxAyi`2?evNx6v5kSc3$ z_N^ApBuR^*lYi*jm0aCIo#Jj8trW1EZV=E*D+El_X9f5ePN`?>MAXA@bb{d>Q>sLA zqY}i?Jyeq1WeJPEoB8xX#Ut)U6>NW?U|*>L8TK1c(vTr2N^YlNxr#GiQt_@&jcVWI zOOlW*aJ=r|X?;Omr-f4bMN;6^Q+K}7D~zbD&4^Bn8Kau+OqU2eoimrKyG=;6!-R|V zn2>0~6c+3bGxpV+(ZLVRt3~pX8B3PTIJ(U8wD#_VjI;uWw}cMst(H}ya=?NOlNM}u zSTTxNF>1Hsl=D_}cfpE80}wiP8Obv~Jp7itZjzC8_?N zw?cPl9(|3&CrY+CaCEl=i4qQ!t7r&STB#^`LwgE`X|)1owQ2!tG9m)nGSGt2j8G~% z=EPjoiQSz}^lHqBo7{8Oi2AS#Wgc?j><7XuoQb!Yyw2{v-wnN`tY7tE~(0)k0 zUayl?UiBvFATZKt2to1?al+zHvY&5#i|jSRFBysv?jI%*h+dPuyz+f=Rfb(>$PTU^ zB}Ow84-+r+en%Q1(ob^1b(!Sx?tYRlL+29)7505^a6s{Z!azGAGq_nH8zC@2oci{B zCJtx8{x!*gu{dMUb%hl1_;K>747WN+ZC7lN@Yo;?g!vbL(QyWqHB^PFB(2M7ZF+4_ zFt{@ws*5*l48>yMP-II(q%QP)tf2-PHyK>;?JTjv;w;UBOG)a7#2#XX%s\n\n%s\n%s\n\n", + 1 === count( $warcs ) ? 'The WARC file read' : sprintf( 'The %d WARC files read', count( $warcs ) ), + implode( '', array_map( static fn ( $path ) => "- `{$path}`\n", array_keys( $warcs ) ) ), + false !== $seed && '' !== $seed + ? sprintf( "%s picked at random with seed `%s`. Run the workflow manually with this seed and crawl `%s` to analyze the same documents again.\n\n", 1 === count( $warcs ) ? 'It was' : 'They were', $seed, $crawl[1] ) + : '' +); $report .= "### HTML Processor parse time\n\n"; $report .= "All documents, including those where parsing stopped early on unsupported markup:\n\n";