false, 'budget' => 0, 'skipped_links' => 0, 'unresolved_images' => 0, 'external_images' => 0, 'capped' => false, 'catalog_page' => 0, 'comment_offset' => -1, 'started' => 0, 'shop' => 'not applicable', ), (array) get_option( 'afsd_im_scan', array() ) ); } /** * Save the scan's state. * * @param array $s The state. */ public static function save( array $s ): void { Reviewed_Store::update_option( 'afsd_im_scan', $s, false ); } /** * Start a scan of the home and shop pages, with the product and review inventories when chosen. * * @param bool $catalog Whether to take the product inventory. * @param bool $comments Whether to take the review inventory. * @param string|null $owned_token The worker's lease, when the caller holds it. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function start( bool $catalog, bool $comments, ?string $owned_token = null ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { global $wpdb; $token = $owned_token ?: Repository::acquire(); if ( ! $token || ! Repository::owns( $token ) ) { throw new \RuntimeException( 'worker_busy' ); } try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom crawler queue uses atomic prepared writes and current due/retry states; persistent caching would replay stale work. Reviewed_Store::database_query( $wpdb->prepare( 'DELETE FROM %i', Repository::table( 'pages' ) ) ); $s = array( 'active' => true, 'budget' => 30, 'started' => time(), 'catalog_page' => $catalog && function_exists( 'wc_get_products' ) ? 1 : 0, 'comment_offset' => $comments ? 0 : -1, 'shop' => 'not applicable', 'skipped_links' => 0, 'external_images' => 0, 'unresolved_images' => 0, 'capped' => false, ); self::add_page( home_url( '/' ), 0, 1 ); if ( function_exists( 'wc_get_page_id' ) ) { $id = (int) wc_get_page_id( 'shop' ); if ( $id > 0 && Sources::public_post( $id ) ) { $shop = Urls::page( get_permalink( $id ) ); if ( ! is_wp_error( $shop ) ) { self::add_page( $shop, 0, 2 ); $s['shop'] = 'resolved by WooCommerce role'; } else { $s['shop'] = $shop->get_error_code(); } } } self::save( $s ); } finally { if ( ! $owned_token ) { Repository::release( $token ); } } Engine::ensure(); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Continue the page scan with a new budget of pages. */ public static function continue_scan(): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $s = self::state(); $s['active'] = true; $s['budget'] = 30; self::save( $s ); Engine::ensure(); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Queue a page to scan, when it is a public page Image Manager may read at most one step from the start. * * @param string $url The page's address. * @param int $depth Its distance from the start (0 or 1). * @param int $roots Which start pages led to it. * @return bool True when it was queued. */ public static function add_page( string $url, int $depth, int $roots ): bool { global $wpdb; $url = Urls::page( $url ); if ( is_wp_error( $url ) || $depth > 1 ) { return false; } $key = hash( 'sha256', $url ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom crawler queue uses atomic prepared writes and current due/retry states; persistent caching would replay stale work. Reviewed_Store::database_query( $wpdb->prepare( "INSERT IGNORE INTO %i (url_key,url,depth,roots,state,reason) VALUES (%s,%s,%d,%d,'waiting','')", Repository::table( 'pages' ), $key, $url, $depth, $roots ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom crawler queue uses atomic prepared writes and current due/retry states; persistent caching would replay stale work. Reviewed_Store::database_query( $wpdb->prepare( 'UPDATE %i SET depth=LEAST(depth,%d),roots=(roots | %d) WHERE url_key=%s', Repository::table( 'pages' ), $depth, $roots, $key ) ); return true; } /** * Whether the scan or an inventory has work left. * * @return bool True when it does. */ public static function pending(): bool { $s = self::state(); return ( $s['active'] && $s['budget'] > 0 ) || $s['catalog_page'] > 0 || $s['comment_offset'] >= 0; } /** * One bounded scan step: the next page, or the next product or review batch. * * @return bool True when a step was done. */ public static function step(): bool { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { global $wpdb; $s = self::state(); if ( $s['active'] && $s['budget'] > 0 ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom crawler queue uses atomic prepared writes and current due/retry states; persistent caching would replay stale work. $page = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE state='waiting' AND available_at<=%d ORDER BY depth ASC,id ASC LIMIT 1", Repository::table( 'pages' ), time() ), ARRAY_A ); if ( $page ) { --$s['budget']; $attempts = (int) ( $page['attempts'] ?? 0 ) + 1; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom crawler queue uses atomic prepared writes and current due/retry states; persistent caching would replay stale work. if ( $attempts > 3 ) { Reviewed_Store::database_update( Repository::table( 'pages' ), array( 'state' => 'blocked', 'reason' => 'retry_limit', ), array( 'id' => $page['id'] ) ); self::save( $s ); return true; } // Persist before the fetch: a killed request cannot retry this page forever. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom crawler queue uses atomic prepared writes and current due/retry states; persistent caching would replay stale work. Reviewed_Store::database_update( Repository::table( 'pages' ), array( 'attempts' => $attempts, 'available_at' => time() + 60 * ( 2 ** $attempts ), ), array( 'id' => $page['id'] ) ); $response = Urls::fetch( $page['url'] ); if ( is_wp_error( $response ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom crawler queue uses atomic prepared writes and current due/retry states; persistent caching would replay stale work. $page_saved = Reviewed_Store::database_update( Repository::table( 'pages' ), array( 'state' => in_array( $response->get_error_code(), array( 'http_request_blocked', 'dns_unavailable', 'http_status_408', 'http_status_429', 'http_status_500', 'http_status_502', 'http_status_503', 'http_status_504' ), true ) && $attempts < 3 ? 'waiting' : 'blocked', 'reason' => sanitize_key( $response->get_error_code() ), ), array( 'id' => $page['id'] ) ); } else { $result = self::parse( $response['html'], $response['url'], (int) $page['depth'], (int) $page['roots'], $s ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom crawler queue uses atomic prepared writes and current due/retry states; persistent caching would replay stale work. $page_saved = Reviewed_Store::database_update( Repository::table( 'pages' ), array( 'state' => 'scanned', 'images' => $result, 'reason' => 'Public HTML only; JavaScript, CSS visibility and browser requests not measured.', ), array( 'id' => $page['id'] ) ); } if ( $s['budget'] <= 0 ) { $s['active'] = false; } self::save( $s ); if ( false !== $page_saved ) { Progress::record( is_wp_error( $response ) ? ( $attempts < 3 && in_array( $response->get_error_code(), array( 'http_request_blocked', 'dns_unavailable', 'http_status_408', 'http_status_429', 'http_status_500', 'http_status_502', 'http_status_503', 'http_status_504' ), true ) ? 'page_retry' : 'page_blocked' ) : 'page_checked' ); } return true; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom crawler queue uses atomic prepared writes and current due/retry states; persistent caching would replay stale work. $waiting = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE state='waiting'", Repository::table( 'pages' ) ) ); if ( ! $waiting ) { $s['active'] = false; self::save( $s ); } } if ( $s['catalog_page'] > 0 && function_exists( 'wc_get_products' ) ) { $result = wc_get_products( array( 'status' => 'publish', 'visibility' => 'visible', 'limit' => 5, 'page' => (int) $s['catalog_page'], 'paginate' => true, 'orderby' => 'ID', 'order' => 'ASC', ) ); foreach ( $result->products ?? array() as $product ) { Sources::post( (int) $product->get_id() ); } $s['catalog_page'] = $s['catalog_page'] < ( $result->max_num_pages ?? 0 ) ? $s['catalog_page'] + 1 : 0; self::save( $s ); Progress::record( 'catalog_checked' ); return true; } if ( $s['comment_offset'] >= 0 ) { $ids = get_comments( array( 'status' => 'approve', 'number' => 10, 'offset' => $s['comment_offset'], 'orderby' => 'comment_ID', 'order' => 'ASC', 'fields' => 'ids', ) ); foreach ( $ids as $id ) { Sources::comment( (int) $id ); } $s['comment_offset'] = count( $ids ) === 10 ? $s['comment_offset'] + 10 : -1; self::save( $s ); Progress::record( 'comments_checked' ); return true; } return false; } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Record the pictures a page shows and queue the pages it links to. * * @param string $html The page. * @param string $url Its address. * @param int $depth Its distance from the start. * @param int $roots Which start pages led to it. * @param array $state The scan's state. * @param array|null $observed Pictures already seen on this page, when the caller keeps them. * @return int How many pictures it recorded (at most 256). */ public static function parse( string $html, string $url, int $depth, int $roots, array &$state, ?array &$observed = null ): int { $p = new \WP_HTML_Tag_Processor( $html ); $n = 0; $images = 0; $links = 0; $seen = array(); $owner = (int) url_to_postid( $url ); if ( $owner ) { Sources::post( $owner, false, 0 === $depth ? 1 : 2 ); } while ( $n++ < 8000 && $p->next_tag() ) { if ( 'BASE' === $p->get_tag() ) { // A nontrivial base URL changes relative-resource semantics. Do not guess or follow it. $base = $p->get_attribute( 'href' ); if ( is_string( $base ) && Urls::absolute( $base, $url ) !== $url ) { $state['capped'] = true; return $images; } } if ( 'A' === $p->get_tag() && 0 === $depth ) { if ( ++$links > 500 ) { $state['capped'] = true; continue; } $href = $p->get_attribute( 'href' ); if ( ! is_string( $href ) || '' === $href || '#' === $href[0] ) { continue; } $dest = Urls::page( $href, $url ); if ( is_wp_error( $dest ) || ! self::add_page( $dest, 1, $roots ) ) { ++$state['skipped_links']; } } if ( ! in_array( $p->get_tag(), array( 'IMG', 'SOURCE' ), true ) ) { continue; } if ( ++$images > 256 ) { $state['capped'] = true; break; } $urls = array(); foreach ( array( 'src', 'data-src', 'data-lazy-src', 'data-original', 'data-lazy-original' ) as $attr ) { $v = $p->get_attribute( $attr ); if ( is_string( $v ) && '' !== $v ) { $urls[] = Urls::absolute( $v, $url ); } } foreach ( array( 'srcset', 'data-srcset', 'data-lazy-srcset' ) as $attr ) { $v = $p->get_attribute( $attr ); if ( is_string( $v ) ) { foreach ( Urls::srcset( $v ) as $candidate ) { $urls[] = Urls::absolute( $candidate['url'], $url ); } } } $theme_urls = Theme::discover( $urls, $url, 0 === $depth ? 1 : 2 ); // Optional receipt for a targeted recheck; normal scans retain their existing behavior. if ( null !== $observed && $theme_urls ) { $theme_row = Theme::find( $theme_urls[0] ); if ( $theme_row ) { $observed[ (int) $theme_row['id'] ] = true; } } foreach ( array_unique( $urls ) as $image_url ) { if ( in_array( $image_url, $theme_urls, true ) ) { $seen[ $image_url ] = true; continue; } if ( isset( $seen[ $image_url ] ) ) { continue; } $seen[ $image_url ] = true; if ( Delivery::is_derivative( $image_url ) ) { continue; } $mapped = Sources::map( $image_url, Sources::hint( $p ) ); $width = $p->get_attribute( 'width' ); if ( $mapped ) { $r = Repository::discover( $mapped['id'], 0 === $depth ? 1 : 2, array( 'page' => $url, 'url' => $image_url, 'cdn' => $mapped['cdn'], 'mapping' => $mapped['mapping'], 'surface' => 'public_html', 'width' => is_string( $width ) && ctype_digit( $width ) ? (int) $width : 0, ) ); if ( null !== $observed ) { $observed[ (int) $r['id'] ] = true; } $attachment = get_post( $mapped['id'] ); if ( $attachment && $attachment->post_parent ) { $parent = get_post( $attachment->post_parent ); $parent_id = $parent && 'product_variation' === $parent->post_type ? (int) $parent->post_parent : (int) $attachment->post_parent; Sources::post( $parent_id, false, 0 === $depth ? 1 : 2 ); } if ( $owner && in_array( $mapped['id'], Sources::post_ids( $owner ), true ) ) { Repository::owner( (int) $r['id'], 'post', $owner ); } } elseif ( Urls::origin( $image_url ) === Urls::origin( home_url( '/' ) ) ) { $safe = strtok( $image_url, '?#' ); if ( ! Urls::safe_path( wp_parse_url( $safe, PHP_URL_PATH ) ?: '' ) ) { continue; } Repository::discover( 0, 0 === $depth ? 1 : 2, array( 'page' => $url, 'url' => $safe, 'surface' => 'direct_url_or_unmapped', 'mapping' => 'No owned native attachment mapping; unchanged.', ) ); ++$state['unresolved_images']; } else { ++$state['external_images']; } } } if ( $n >= 8000 ) { $state['capped'] = true; } return min( 256, $images ); } /** * The scanned pages, nearest first. * * @param int $limit The most pages. * @return array Page rows. */ public static function pages( int $limit = 15 ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom crawler queue uses atomic prepared writes and current due/retry states; persistent caching would replay stale work. return $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY depth ASC,id ASC LIMIT %d', Repository::table( 'pages' ), $limit ), ARRAY_A ); } }