diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 225522b..6f77fe6 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -22,6 +22,7 @@ {"_type":"issue","id":"hc-5ix.9","title":"Smoke test on testsite: full end-to-end run through the checklist","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":2,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:15Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:29Z","closed_at":"2026-06-11T14:49:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.9","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:14Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.8","title":"README + install instructions + screenshots placeholder","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":2,"issue_type":"chore","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:14Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:29Z","closed_at":"2026-06-11T14:49:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.8","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:14Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix","title":"Build WordPress Healthcheck plugin (stepper through steps.md)","description":"Plugin that walks a technician through the WordPress healthcheck steps documented in steps.md. Independent of the recovery plugin (which it depends on as step 0).","status":"open","priority":2,"issue_type":"epic","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T11:26:32Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T11:26:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hc-u5c","title":"Add Google Analytics and Search Console healthcheck steps","description":"Add two new healthcheck steps between performance (Step 7) and security (Step 8): a Google Analytics check that sniffs the homepage for GA4/GTM/UA snippets and detects known analytics plugins, and a Google Search Console check that looks for verification meta tags, sitemap reachability, robots.txt Disallow rules, and the WP 'Discourage search engines' setting. Updates steps.md to match.","status":"open","priority":3,"issue_type":"feature","owner":"steve@hanlon.co.uk","created_at":"2026-07-16T08:44:39Z","created_by":"Steve Hanlon","updated_at":"2026-07-16T08:44:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.27","title":"Self-hosted update channel: plugin checks a private URL for new releases and offers one-click update from wp-admin","description":"Lightweight wrapper around the WP plugin update transient: site_transient_update_plugins filter that adds an entry for ourselves if a newer release exists at a configured URL. URL hosts a JSON manifest + zip. Lets technicians keep clients up to date without manually re-uploading the zip every check.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:40:30Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:40:30Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.27","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:40:29Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.25","title":"Step 12 (Wrap-up): pre-fill report with all automated findings, attach diff vs. previous session, optionally push summary to ManageWP/WP Umbrella","notes":"wrap_up step's autocheck walks all other steps' stored findings, counts bad/warn separately, lists the top 6-8 examples, and identifies any blocked steps. Gives the technician a quick 'roll-up' before they finish. The report itself already embeds every finding verbatim.","status":"closed","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:26Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T15:13:15Z","started_at":"2026-06-11T15:05:01Z","closed_at":"2026-06-11T15:13:15Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.25","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:25Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.24","title":"Step 11 (Small fixes): broken internal link scan (sample first N pages), missing alt-text scan on homepage images, deactivated-but-installed plugin list","notes":"Deactivated-but-installed plugin list and homepage alt-text scan (fetch home_url, regex over \u003cimg\u003e tags for alt attribute presence). Broken-link scan deferred — too heavy for v1 (would need to crawl every internal link).","status":"closed","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:25Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T15:02:34Z","started_at":"2026-06-11T14:55:08Z","closed_at":"2026-06-11T15:02:34Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.24","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:25Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/includes/steps/72-analytics.php b/includes/steps/72-analytics.php new file mode 100644 index 0000000..ac36dcb --- /dev/null +++ b/includes/steps/72-analytics.php @@ -0,0 +1,108 @@ + 8, 'redirection' => 3]); + if (is_wp_error($resp)) { + $f[] = $this->finding('fetch', 'warn', 'Homepage fetch', 'failed', $resp->get_error_message()); + return $f; + } + $body = (string) wp_remote_retrieve_body($resp); + + // Measurement / container IDs anywhere in the HTML. + $found_ids = []; + if (preg_match_all('/\bG-[A-Z0-9]{6,}\b/', $body, $m)) $found_ids = array_merge($found_ids, $m[0]); + if (preg_match_all('/\bGTM-[A-Z0-9]{4,}\b/', $body, $m)) $found_ids = array_merge($found_ids, $m[0]); + if (preg_match_all('/\bUA-\d{4,}-\d+\b/', $body, $m)) $found_ids = array_merge($found_ids, $m[0]); + $found_ids = array_values(array_unique($found_ids)); + + // Snippet vendors (any script/URL referencing a known loader). + $signals = [ + 'gtag.js' => 'googletagmanager.com/gtag/js', + 'gtm.js' => 'googletagmanager.com/gtm.js', + 'analytics.js (UA)' => 'google-analytics.com/analytics.js', + 'ga.js (legacy UA)' => 'google-analytics.com/ga.js', + ]; + $vendors = []; + foreach ($signals as $label => $needle) { + if (stripos($body, $needle) !== false) $vendors[] = $label; + } + + if ($found_ids || $vendors) { + $has_ga4 = (bool) preg_grep('/^G-/', $found_ids); + $has_ua = (bool) preg_grep('/^UA-/', $found_ids); + $level = ($has_ua && !$has_ga4) ? 'warn' : 'ok'; + $detail = $vendors ? 'loader: ' . implode(', ', $vendors) : ''; + if ($has_ua && !$has_ga4) { + $detail = trim($detail . ' — Universal Analytics only; GA4 required since July 2023.'); + } + $f[] = $this->finding( + 'snippet', + $level, + 'Tracking snippet', + $found_ids ? implode(', ', array_slice($found_ids, 0, 4)) : 'present', + $detail + ); + } else { + $f[] = $this->finding( + 'snippet', + 'warn', + 'Tracking snippet', + 'not found', + 'No GA/GTM/UA measurement ID or loader was seen in the homepage HTML. A consent banner may be blocking the script, or tracking may have been removed.' + ); + } + + // GA/GTM plugin detection — informational only. + if (!function_exists('is_plugin_active')) require_once ABSPATH . 'wp-admin/includes/plugin.php'; + $plugins = [ + 'google-site-kit/google-site-kit.php' => 'Site Kit by Google', + 'google-analytics-for-wordpress/googleanalytics.php' => 'MonsterInsights', + 'google-analytics-premium/googleanalytics-premium.php' => 'MonsterInsights Pro', + 'google-analytics-dashboard-for-wp/gadwp.php' => 'ExactMetrics', + 'ga-google-analytics/ga-google-analytics.php' => 'GA Google Analytics', + 'analytify/wp-analytify.php' => 'Analytify', + 'duracelltomi-google-tag-manager/duracelltomi-google-tag-manager.php' => 'GTM4WP', + 'header-footer-code-manager/header-footer-code-manager.php' => 'Header Footer Code Manager', + 'insert-headers-and-footers/ihaf.php' => 'WPCode / Insert Headers and Footers', + ]; + $active = []; + foreach ($plugins as $file => $label) { + if (is_plugin_active($file)) $active[] = $label; + } + $f[] = $this->finding( + 'plugin', + $active ? 'ok' : 'info', + 'Analytics/tag plugin', + $active ? implode(', ', $active) : 'none detected', + $active ? '' : 'Tracking may be hard-coded in the theme or injected by a page builder — check view-source if the snippet check passed.' + ); + + return $f; + } +}; diff --git a/includes/steps/74-search-console.php b/includes/steps/74-search-console.php new file mode 100644 index 0000000..c1b6cdd --- /dev/null +++ b/includes/steps/74-search-console.php @@ -0,0 +1,166 @@ +25% vs. previous period)', + 'Check Manual Actions and Security Issues — flag anything that is not "No issues detected"', + 'Confirm the verified property matches the canonical URL (http vs https, www vs non-www) actually serving', + ]; + } + public function watch_outs(): array { + return [ + 'Verification meta tags can be added by SEO plugins (Yoast, Rank Math, AIOSEO) or by Site Kit — the presence of a tag does not tell you which Google account owns the property', + 'A missing tag does not mean the site is unverified — DNS TXT and file-based verification are equally valid and not visible from the front end', + ]; + } + + public function autocheck(array $session_state): array { + $f = []; + $home = home_url('/'); + + // 1. Homepage HTML — look for verification meta tag(s). + $resp = wp_remote_get($home, ['timeout' => 8, 'redirection' => 3]); + if (is_wp_error($resp)) { + $f[] = $this->finding('fetch', 'warn', 'Homepage fetch', 'failed', $resp->get_error_message()); + } else { + $body = (string) wp_remote_retrieve_body($resp); + $tokens = []; + if (preg_match_all('/]+name=["\']google-site-verification["\'][^>]*content=["\']([^"\']+)["\']/i', $body, $m)) { + $tokens = $m[1]; + } + $f[] = $this->finding( + 'verification_meta', + $tokens ? 'ok' : 'info', + 'GSC verification meta tag', + $tokens ? count($tokens) . ' present' : 'not found on homepage', + $tokens + ? 'Token(s): ' . implode(', ', array_map(fn($t) => substr($t, 0, 12) . '…', $tokens)) + : 'Absent tag is not a problem if verification is via DNS TXT or an uploaded HTML file — confirm in Search Console.' + ); + } + + // 2. Sitemap reachability. Try common locations, take the first that responds 200. + $candidates = [ + 'wp-sitemap.xml', // WP core (5.5+) + 'sitemap_index.xml', // Yoast/Rank Math default + 'sitemap.xml', + ]; + $found_sitemap = null; + foreach ($candidates as $rel) { + $url = trailingslashit($home) . $rel; + $head = wp_remote_head($url, ['timeout' => 5, 'redirection' => 2]); + if (is_wp_error($head)) continue; + $code = (int) wp_remote_retrieve_response_code($head); + if ($code === 200) { + $found_sitemap = $url; + break; + } + } + if ($found_sitemap) { + $f[] = $this->finding( + 'sitemap', + 'ok', + 'Sitemap', + $found_sitemap, + 'Reachable — confirm this URL is the one submitted in Search Console.' + ); + } else { + $f[] = $this->finding( + 'sitemap', + 'warn', + 'Sitemap', + 'none of the common URLs responded', + 'Tried: ' . implode(', ', $candidates) . '. A missing sitemap doesn\'t prevent indexing but Search Console will show a fetch error.' + ); + } + + // 3. robots.txt — surface if it blocks Googlebot from the site root. + $robots_url = trailingslashit($home) . 'robots.txt'; + $rob = wp_remote_get($robots_url, ['timeout' => 5, 'redirection' => 2]); + if (is_wp_error($rob)) { + $f[] = $this->finding('robots', 'info', 'robots.txt', 'unreachable', $rob->get_error_message()); + } else { + $code = (int) wp_remote_retrieve_response_code($rob); + if ($code !== 200) { + $f[] = $this->finding('robots', 'info', 'robots.txt', 'HTTP ' . $code, 'WordPress serves a virtual robots.txt by default; a non-200 response may indicate a redirect or a plugin intercepting it.'); + } else { + $rb = (string) wp_remote_retrieve_body($rob); + $blocks_root = $this->robots_blocks_root($rb); + $f[] = $this->finding( + 'robots', + $blocks_root ? 'bad' : 'ok', + 'robots.txt', + $blocks_root ? 'blocks Googlebot from /' : 'does not block /', + $blocks_root + ? 'A "Disallow: /" applying to * or Googlebot will prevent indexing. Check Settings → Reading for "Discourage search engines".' + : '' + ); + } + } + + // 4. "Discourage search engines" WP setting — hard block on indexing. + if ((int) get_option('blog_public') === 0) { + $f[] = $this->finding( + 'blog_public', + 'bad', + 'Search engine indexing', + 'discouraged (Settings → Reading)', + 'The "Discourage search engines from indexing this site" checkbox is on. This should be off on production.' + ); + } else { + $f[] = $this->finding('blog_public', 'ok', 'Search engine indexing', 'allowed', ''); + } + + // 5. Site Kit — the WP-side surface for Search Console data. Informational. + if (!function_exists('is_plugin_active')) require_once ABSPATH . 'wp-admin/includes/plugin.php'; + $sitekit = is_plugin_active('google-site-kit/google-site-kit.php'); + $f[] = $this->finding( + 'site_kit', + $sitekit ? 'ok' : 'info', + 'Site Kit by Google', + $sitekit ? 'active' : 'not active', + $sitekit ? 'GSC data may be visible on the WP dashboard.' : '' + ); + + return $f; + } + + /** Parse robots.txt and decide whether Googlebot (or *) is disallowed from /. */ + private function robots_blocks_root(string $robots): bool { + $lines = preg_split('/\r?\n/', $robots) ?: []; + $current_agents = []; + $groups = []; // agent => [disallow rules] + foreach ($lines as $line) { + $line = trim(preg_replace('/#.*$/', '', $line)); + if ($line === '') { $current_agents = []; continue; } + if (preg_match('/^user-agent:\s*(.+)$/i', $line, $m)) { + $current_agents[] = strtolower(trim($m[1])); + continue; + } + if (preg_match('/^disallow:\s*(.*)$/i', $line, $m)) { + $rule = trim($m[1]); + foreach ($current_agents as $ua) { + $groups[$ua][] = $rule; + } + } + } + foreach (['googlebot', '*'] as $ua) { + if (!empty($groups[$ua])) { + foreach ($groups[$ua] as $rule) { + if ($rule === '/') return true; + } + } + } + return false; + } +}; diff --git a/steps.md b/steps.md index 2079c51..4b6ee5a 100644 --- a/steps.md +++ b/steps.md @@ -101,6 +101,35 @@ Do a manual walkthrough of the site: --- +### Step — Google Analytics Check + +Confirm the site is still reporting into Google Analytics. + +- Confirm a tracking snippet is present on the homepage (view source, look for gtag/GTM/analytics.js) +- Log into Google Analytics and confirm real-time users are being recorded when you load the site +- Confirm the measurement ID on the site matches the client record (G-XXXX for GA4, GTM-XXXX for Tag Manager) +- Check that today has page-view events in the property — flag if traffic has dropped to zero +- Flag if only Universal Analytics (UA-XXXX) is still in use — GA4 has been required since July 2023 + +**Watch out for:** cookie/consent banners that block the tracker until accepted; caching/optimisation plugins that defer or strip inline scripts. + +--- + +### Step — Google Search Console Check + +Confirm the site is still verified and Google can crawl/index it. + +- Log into Google Search Console and confirm the property is still verified +- Check the Pages / Coverage report for new indexing errors since the last healthcheck +- Confirm the sitemap is submitted and its "Last read" date is recent +- Skim the Performance report — flag significant drops in impressions or clicks (>25% vs. previous period) +- Check Manual Actions and Security Issues — flag anything that is not "No issues detected" +- Confirm the verified property matches the canonical URL (http vs https, www vs non-www) actually serving + +**Watch out for:** a missing verification meta tag doesn't mean unverified — DNS TXT and file-based verification are equally valid. + +--- + ### Step 8 — Security Check - Confirm the SSL certificate is valid and not expiring within 30 days — flag if so