post_content; $plain = trim(wp_strip_all_tags(strip_shortcodes($content))); $tags = get_the_terms($post->ID, 'post_tag'); $cats = get_the_terms($post->ID, 'category'); return [ 'id' => (int) $post->ID, 'title' => (string) $post->post_title, 'type' => (string) $post->post_type, 'status' => (string) $post->post_status, 'date' => (string) $post->post_date, 'excerpt_set' => trim((string) $post->post_excerpt) !== '', 'tags_count' => is_array($tags) ? count($tags) : 0, 'categories_count' => self::real_categories($cats), 'word_count' => $plain === '' ? 0 : count(preg_split('/\s+/', $plain)), 'has_thumbnail' => has_post_thumbnail($post->ID), 'images_missing_alt' => self::images_missing_alt($content), 'duplicate_title' => isset($duplicate_title_ids[self::title_key((string) $post->post_title)]), 'supports' => [ 'tags' => is_object_in_taxonomy($post->post_type, 'post_tag'), 'categories' => is_object_in_taxonomy($post->post_type, 'category'), 'excerpt' => post_type_supports($post->post_type, 'excerpt'), 'thumbnail' => post_type_supports($post->post_type, 'thumbnail'), ], ]; } /** The default category ("Uncategorized") is where WordPress puts a post nobody filed. */ private static function real_categories($cats) { if (!is_array($cats)) { return 0; } $default = (int) get_option('default_category', 0); return count(array_filter($cats, static function ($c) use ($default) { return (int) $c->term_id !== $default; })); } /** Bounded, tolerant alt scan (see docs/TESTLOG.md: PREG_UNMATCHED_AS_NULL). */ public static function images_missing_alt($html, $limit = 200) { if ($html === '' || !preg_match_all('/]*>/i', $html, $m)) { return 0; } $missing = 0; foreach (array_slice($m[0], 0, $limit) as $tag) { if (preg_match('/\balt\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s>]+))/i', $tag, $a, PREG_UNMATCHED_AS_NULL)) { $value = $a[2] ?? $a[3] ?? $a[4] ?? ''; if (trim($value) === '') { $missing++; } } else { $missing++; } } return $missing; } /** One key for "the same title": case-insensitive, and any run of spaces, tabs or no-break spaces counts as one. */ public static function title_key(string $title): string { return mb_strtolower(trim((string) preg_replace('/[\s\x{00A0}]+/u', ' ', $title))); } /** Title-id map for the duplicate-title check (single pass, O(n)). */ public static function duplicate_title_map(array $posts) { $seen = []; foreach ($posts as $p) { $key = self::title_key((string) $p->post_title); if ($key === '') { continue; } $seen[$key] = ($seen[$key] ?? 0) + 1; } $dups = []; foreach ($seen as $key => $n) { if ($n > 1) { $dups[$key] = true; } } return $dups; } /** * Scan one batch (offset-based). Returns the number of posts scanned in this batch; * 0 means done. The report option is replaced on the first batch and appended after. */ public static function scan_batch(array $settings, $offset, $store = null) { $store = $store ?: new Postlint_Store(); $types = $settings['post_types']; $batch = max(1, (int) $settings['batch_size']); $posts = $store->published_posts($types, $batch, $offset); if (empty($posts)) { if ($offset > 0) { $report = self::get_report($store); $report['complete'] = true; $store->write_report($report); } return 0; } // Duplicates are judged against every published title in scope, not just this batch, // or two posts a batch apart would never meet. $dup_map = method_exists($store, 'published_titles') ? self::duplicate_title_map(array_map(static function ($t) { return (object) ['post_title' => (string) $t]; }, $store->published_titles($types))) : self::duplicate_title_map($posts); $report = $offset === 0 ? self::empty_report() : self::get_report($store); foreach ($posts as $post) { $snapshot = self::snapshot($post, $dup_map); $issues = Postlint_Checks::evaluate($snapshot, $settings); if ($issues) { $report['issues'][] = ['id' => $snapshot['id'], 'title' => $snapshot['title'], 'type' => $snapshot['type'], 'checks' => $issues]; } foreach ($issues as $code) { $report['totals'][$code] = ($report['totals'][$code] ?? 0) + 1; } $store->mark_scanned($post->ID, $store->timestamp()); } $report['scanned'] = ($report['scanned'] ?? 0) + count($posts); $report['complete'] = count($posts) < $batch; $report['scanned_at'] = $store->now(); $store->write_report($report); return count($posts); } public static function get_report($store = null) { $store = $store ?: new Postlint_Store(); return array_merge(self::empty_report(), $store->read_report()); } public static function empty_report() { $totals = array_fill_keys(array_keys(Postlint_Checks::all_checks()), 0); return ['scanned_at' => '', 'scanned' => 0, 'complete' => false, 'totals' => $totals, 'issues' => []]; } }