Group
Extension

SEO-Inspector/lib/SEO/Inspector.pm

package SEO::Inspector;

use strict;
use warnings;

use Carp;
use Mojo::UserAgent;
use Mojo::URL;
use Module::Pluggable require => 1, search_path => 'SEO::Inspector::Plugin';
use Object::Configure 0.14;
use Params::Get 0.13;

=head1 NAME

SEO::Inspector - Run SEO checks on HTML or URLs

=head1 VERSION

Version 0.02

=cut

our $VERSION = '0.02';

=head1 SYNOPSIS

  use SEO::Inspector;

  my $inspector = SEO::Inspector->new(url => 'https://example.com');

  # Run plugins
  my $html = '<html><body>......</body></html>';
  my $plugin_results = $inspector->check_html($html);

  # Run built-in checks
  my $builtin_results = $inspector->run_all($html);

  # Check a single URL and get all results
  my $all_results = $inspector->check_url('https://example.com');

=head1 DESCRIPTION

SEO::Inspector provides:

=over 4

=item * 14 built-in SEO checks

=item * Plugin system: dynamically load modules under SEO::Inspector::Plugin namespace

=item * Methods to check HTML strings or fetch and analyze a URL

=back

=head1 PLUGIN SYSTEM

In addition to the built-in SEO checks, C<SEO::Inspector> supports a flexible
plugin system.
Plugins allow you to extend the checker with new rules or
specialized analysis without modifying the core module.

=head2 How Plugins Are Found

Plugins are loaded dynamically from the C<SEO::Inspector::Plugin> namespace.
For example, a module called:

  package SEO::Inspector::Plugin::MyCheck;

will be detected and loaded automatically if it is available in C<@INC>.

You can also tell the constructor to search additional directories by passing
the C<plugin_dirs> argument:

  my $inspector = SEO::Inspector->new(
    plugin_dirs => ['t/lib', '/path/to/custom/plugins'],
  );

Each directory must contain files under a subpath corresponding to the
namespace, for example:

  /path/to/custom/plugins/SEO/Inspector/Plugin/Foo.pm

=head2 Plugin Interface

A plugin must provide at least two methods:

=over 4

=item * C<new>

Constructor, called with no arguments.

=item * C<run($html)>

Given a string of raw HTML, return a hashref describing the result of the check.
The hashref should have at least these keys:

  {
    name   => 'My Check',
    status => 'ok' | 'warn' | 'error',
    notes  => 'human-readable message',
    resolution => 'how to resolve'
  }

=back

=head2 Running Plugins

You can run all loaded plugins against a piece of HTML with:

  my $results = $inspector->check_html($html);

This returns a hashref keyed by plugin name (lowercased), each value being the
hashref returned by the plugin's C<run> method.

Plugins are also run automatically when you call C<check_url>:

  my $results = $inspector->check_url('https://example.com');

That result will include both built-in checks and plugin checks.

=head2 Example Plugin

Here is a minimal example plugin that checks whether the page contains
the string "Hello":

	package SEO::Inspector::Plugin::HelloCheck;
	use strict;
	use warnings;

	sub new { bless {}, shift }

	sub run {
		my ($self, $html) = @_;
		if($html =~ /Hello/) {
			return { name => 'Hello Check', status => 'ok', notes => 'found Hello' };
		} else {
			return { name => 'Hello Check', status => 'warn', notes => 'no Hello', resolution => 'add a hello field' };
		}
	}

	1;

Place this file under C<lib/SEO/Inspector/Plugin/HelloCheck.pm> (or another
directory listed in C<plugin_dirs>), and it will be discovered automatically.

=head2 Naming Conventions

The plugin key stored in C<< $inspector->{plugins} >> is derived from the final
part of the package name, lowercased. For example:

	SEO::Inspector::Plugin::HelloCheck -> "hellocheck"

This is the key you will see in the hashref returned by C<check_html> or
C<check_url>.

=head1 METHODS

=head2 new(%args)

Create a new inspector object. Accepts optional C<url> and C<plugin_dirs> arguments.
If C<plugin_dirs> isn't given, it tries hard to find the right place.

=cut

# -------------------------------
# Constructor
# -------------------------------
sub new {
	my $class = shift;
	my $params = Object::Configure::configure($class, Params::Get::get_params(undef, \@_));

	$params->{'ua'} ||= Mojo::UserAgent->new();
	$params->{'plugins'} ||= {};

	my $self = bless { %{$params} }, $class;

	$self->load_plugins();

	return $self;
}

=head2 load_plugins

Loads plugins from the C<SEO::Inspector::Plugin> namespace.

=cut

# -------------------------------
# Load plugins from SEO::Inspector::Plugin namespace
# -------------------------------
sub load_plugins {
	my $self = $_[0];

	for my $plugin ($self->plugins()) {
		my $key = lc($plugin =~ s/.*:://r);
		$self->{plugins}{$key} = $plugin->new();
	}
	if($self->{plugin_dirs}) {
		for my $dir (@{$self->{plugin_dirs}}) {
			local @INC = ($dir, @INC);

			my $finder = Module::Pluggable::Object->new(
				search_path => ['SEO::Inspector::Plugin'],
				require => 1,
				instantiate => 'new',
			);

			for my $plugin ($finder->plugins) {
				my $key = lc(ref($plugin) =~ s/.*:://r);
				$self->{plugins}{$key} = $plugin;
			}
		}
	}
}

# -------------------------------
# Fetch HTML from URL or object default
# -------------------------------
sub _fetch_html {
	my ($self, $url) = @_;
	$url //= $self->{url};
	croak 'URL missing' unless $url;

	my $res = $self->{ua}->get($url)->result;
	if ($res->is_error) {
		croak 'Fetch failed: ', $res->message();
	}
	return $res->body;
}

=head2 check($check_name, $html)

Run a single built-in check or plugin on provided HTML (or fetch from object URL if HTML not provided).

=cut

# -------------------------------
# Run a single plugin or built-in check
# -------------------------------
sub check {
	my ($self, $check_name, $html) = @_;
	$html //= $self->_fetch_html();

	my %dispatch = (
		title => \&_check_title,
		meta_description => \&_check_meta_description,
		canonical => \&_check_canonical,
		robots_meta => \&_check_robots_meta,
		viewport => \&_check_viewport,
		h1_presence => \&_check_h1_presence,
		word_count => \&_check_word_count,
		links_alt_text => \&_check_links_alt_text,
		check_structured_data => \&_check_structured_data,
		check_headings => \&_check_headings,
		check_links => \&_check_links,
		open_graph => \&_check_open_graph,
		twitter_cards => \&_check_twitter_cards,
		page_size => \&_check_page_size,
		readability => \&_check_readability,
	);

	# built-in checks
	if (exists $dispatch{$check_name}) {
		return $dispatch{$check_name}->($self, $html);
	} else {
		croak "Unknown check $check_name";
	}

	# plugin checks
	if (exists $self->{plugins}{$check_name}) {
		my $plugin = $self->{plugins}{$check_name};
		return $plugin->run($html);
	}

	return { name => $check_name, status => 'unknown', notes => '' };
}

=head2 run_all($html)

Run all built-in checks on HTML (or object URL).

=cut

# -------------------------------
# Run all built-in checks
# -------------------------------
sub run_all
{
	my ($self, $html) = @_;
	$html //= $self->_fetch_html();

	my %results;
	for my $check (qw(
		title meta_description canonical robots_meta viewport h1_presence word_count links_alt_text
		check_structured_data check_headings check_links
		open_graph twitter_cards page_size readability
	)) {
		$results{$check} = $self->check($check, $html);
	}

	return \%results;
}

=head2 check_html($html)

Run all loaded plugins on HTML.

=cut

# -------------------------------
# Run all plugins on HTML
# -------------------------------
sub check_html {
	my ($self, $html) = @_;
	$html //= $self->_fetch_html();
	my %results;

	for my $key (keys %{ $self->{plugins} }) {
		my $plugin = $self->{plugins}{$key};
		$results{$key} = $plugin->run($html);
	}

	return \%results;
}

=head2 check_url($url)

Fetch the URL and run all plugins and built-in checks.

=cut

# -------------------------------
# Run URL: fetch and check
# -------------------------------
sub check_url {
	my ($self, $url) = @_;

	$url //= $self->{url};

	croak('URL missing') unless $url;

	my $html = $self->_fetch_html($url);

	my $plugin_results = $self->check_html($html);
	my $builtin_results = $self->run_all($html);

	# merge all results
	my %results = (%$plugin_results, %$builtin_results, _html => $html);
	return \%results;
}

# -------------------------------
# Built-in check implementations
# -------------------------------
sub _check_title {
	my ($self, $html) = @_;

	if ($html =~ /<title>(.*?)<\/title>/is) {
		my $title = $1;
		$title =~ s/^\s+|\s+$//g;		 # trim
		$title =~ s/\s{2,}/ /g;		# collapse spaces

		my $len = length($title);
		my $status = 'ok';
		my $notes = "title present ($len chars)";

		if ($len == 0) {
			$status = 'error';
			$notes = 'empty title';
		} elsif ($len < 10) {
			$status = 'warn';
			$notes = "title too short ($len chars)";
		} elsif ($len > 65) {
			$status = 'warn';
			$notes = "title too long ($len chars)";
		}

		# Flag really weak titles
		if ($title =~ /^(home|untitled|index)$/i) {
			$status = 'warn';
			$notes = "generic title: $title";
		}

		return { name => 'Title', status => $status, notes => $notes };
	}

	return { name => 'Title', status => 'error', notes => 'missing title' };
}


sub _check_meta_description {
	my ($self, $html) = @_;
	if ($html =~ /<meta\s+name=["']description["']\s+content=["'](.*?)["']/is) {
		my $desc = $1;
		return { name => 'Meta Description', status => 'ok', notes => 'meta description present' };
	}
	return { name => 'Meta Description', status => 'warn', notes => 'missing meta description' };
}

sub _check_canonical
{
	my ($self, $html) = @_;
	if ($html =~ /<link\s+rel=["']canonical["']\s+href=["'](.*?)["']/is) {
		return { name => 'Canonical', status => 'ok', notes => 'canonical link present' };
	}
	return {
		name => 'Canonical',
		status => 'warn',
		notes => 'missing canonical link',
		resolution => 'Add canonical link to <head>: <link rel="canonical" href="https://your-domain.com/this-page-url"> - use the preferred URL for this page to prevent duplicate content issues'
	};
}

sub _check_robots_meta {
	my ($self, $html) = @_;
	if ($html =~ /<meta\s+name=["']robots["']\s+content=["'](.*?)["']/is) {
		return { name => 'Robots Meta', status => 'ok', notes => 'robots meta present' };
	}
	return {
		name => 'Robots Meta',
		status => 'warn',
		notes => 'missing robots meta',
		resolution => 'Add robots meta tag to <head>: <meta name="robots" content="index, follow"> for normal indexing, or <meta name="robots" content="noindex, nofollow"> to prevent indexing - controls how search engines crawl and index this page'
	};
}

sub _check_viewport {
	my ($self, $html) = @_;
	if ($html =~ /<meta\s+name=["']viewport["']\s+content=["'](.*?)["']/is) {
		return { name => 'Viewport', status => 'ok', notes => 'viewport meta present' };
	}
	return {
		name => 'Viewport',
		status => 'warn',
		notes => 'missing viewport meta',
		resolution => 'Add viewport meta tag to <head>: <meta name="viewport" content="width=device-width, initial-scale=1.0"> - essential for mobile responsiveness and Google mobile-first indexing'
	};
}

sub _check_h1_presence {
	my ($self, $html) = @_;
	if ($html =~ /<h1\b[^>]*>(.*?)<\/h1>/is) {
		return { name => 'H1 Presence', status => 'ok', notes => 'h1 tag present' };
	}
	return { name => 'H1 Presence', status => 'warn', notes => 'missing h1' };
}

sub _check_word_count {
	my ($self, $html) = @_;
	my $text = $html;
	$text =~ s/<[^>]+>//g;
	my $words = scalar split /\s+/, $text;
	return { name => 'Word Count', status => $words > 0 ? 'ok' : 'warn', notes => "$words words" };
}

sub _check_links_alt_text {
	my ($self, $html) = @_;
	my @missing;
	while ($html =~ /<img\b(.*?)>/gis) {
		my $attr = $1;
		push @missing, $1 unless $attr =~ /alt=/i;
	}
	if(scalar(@missing)) {
		return {
			name => 'Links Alt Text',
			status => 'warn',
			notes => scalar(@missing) . ' images missing alt',
			resolution => 'Add alt attributes to all images: <img src="image.jpg" alt="Descriptive text"> - describe the image content for screen readers and SEO. Use alt="" for decorative images that don\'t add meaning'
		};
	}

	return {
		name => 'Links Alt Text',
		status => 'ok',
		notes => 'all images have alt'
	};
}

sub _check_structured_data {
	my ($self, $html) = @_;

	my @jsonld = ($html =~ /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>(.*?)<\/script>/gis);

	if(scalar(@jsonld)) {
		return {
			name => 'Structured Data',
			status => 'ok',
			notes => scalar(@jsonld) . ' JSON-LD block(s) found'
		};
	}

	return {
		name => 'Structured Data',
		status => 'warn',
		notes => 'no structured data found',
		resolution => 'Add JSON-LD structured data to <head>: <script type="application/ld+json">{"@context": "https://schema.org", "@type": "WebPage", "name": "Page Title", "description": "Page description"}</script> - helps search engines understand your content better and enables rich snippets'
	}
}

# _check_headings
# ----------------
# Analyzes the HTML document for heading structure and returns a structured
# SEO/a11y report.
#
# Checks performed:
#   - Presence of headings (<h1>–<h6>), with counts of each level.
#   - Ensures exactly one <h1> exists (warns if missing or multiple).
#   - Validates heading hierarchy (no skipped levels, e.g. <h3> should not appear before an <h2>).
#   - Flags suspicious heading text lengths (too short < 2 chars, or too long > 120 chars).
#
# Returns:
#   {
#	 name   => 'Headings',
#	 status => 'ok' | 'warn',
#	 notes  => 'summary of counts and issues'
#   }
#
# Notes:
#   - Status is 'warn' if issues are found, otherwise 'ok'.
#   - 'error' status is reserved for future use (currently unused).

sub _check_headings {
	my ($self, $html) = @_;

	my %counts;
	my @headings;

	# Capture all headings and their order
	while ($html =~ /<(h[1-6])\b[^>]*>(.*?)<\/\1>/gi) {
		my $tag = lc $1;
		my $text = $2 // '';
		$text =~ s/\s+/ /g;	# normalize whitespace
		$text =~ s/^\s+|\s+$//g;

		$counts{$tag}++;
		push @headings, { level => substr($tag, 1), text => $text };
	}

	my @issues;
	my $status = 'ok';

	# Check for no headings
	if (!%counts) {
		return {
			name => 'Headings',
			status => 'warn',
			notes => 'no headings found',
		};
	}

	# Check H1 presence/uniqueness
	if (!$counts{h1}) {
		push @issues, 'missing <h1>';
		$status = 'warn';
	}
	elsif ($counts{h1} > 1) {
		push @issues, 'multiple <h1> tags';
		$status = 'warn';
	}

	# Check heading hierarchy (no skipped levels)
	my $last_level = 0;
	for my $h (@headings) {
		my $level = $h->{level};
		if ($last_level && $level > $last_level + 1) {
			push @issues, "skipped heading level before <h$level>";
			$status = 'warn';
		}
		$last_level = $level;
	}

	# Check heading text length (too short or too long)
	for my $h (@headings) {
		my $len = length($h->{text});
		if ($len < 2) {
			push @issues, "<h$h->{level}> too short";
			$status = 'warn';
		}
		elsif ($len > 120) {
			push @issues, "<h$h->{level}> too long";
			$status = 'warn';
		}
	}

	# Summarize counts and issues
	my $summary = join ', ', map { "$_: $counts{$_}" } sort keys %counts;
	$summary .= @issues ? " | Issues: " . join('; ', @issues) : '';

	return {
		name => 'Headings',
		status => $status,
		notes => $summary,
	};
}

sub _check_links {
	my ($self, $html) = @_;

	my $base_host;
	if ($self->{url} && $self->{url} =~ m{^https?://}i) {
		$base_host = Mojo::URL->new($self->{url})->host;
	}

	my ($total, $internal, $external, $badtext) = (0,0,0,0);

	# common "bad" link text patterns (exact match or just punctuation around)
	my $bad_rx = qr/^(?:click\s*here|read\s*more|more|link|here|details)$/i;

	while ($html =~ m{<a\b([^>]*)>(.*?)</a>}gis) {
		my $attrs = $1;
		my $text = $2 // '';

		$total++;

		# get href (prefer quoted values)
		my ($href) = $attrs =~ /\bhref\s*=\s*"(.*?)"/i;
		$href //= ($attrs =~ /\bhref\s*=\s*'(.*?)'/i ? $1 : undef);
		$href //= ($attrs =~ /\bhref\s*=\s*([^\s>]+)/i ? $1 : undef);

		# classify internal vs external
		if (defined $href && $href =~ m{^\s*https?://}i) {
			# attempt to compare host
			my ($host) = $href =~ m{^\s*https?://([^/:\s]+)}i;
			if (defined $base_host && defined $host) {
				if (lc $host eq lc $base_host) {
					$internal++;
				} else {
					$external++;
				}
			} else {
				# no base host to compare; treat as external if absolute URL
				$external++;
			}
		} else {
			# relative URL or fragment or mailto/etc -> treat as internal
			$internal++;
		}

		# normalize visible text: strip tags, trim whitespace, collapse spaces
		$text =~ s/<[^>]+>//g;
		$text =~ s/^\s+|\s+$//g;
		$text =~ s/\s+/ /g;

		# check for bad link text (exact-ish)
		if ($text =~ $bad_rx) {
			$badtext++;
		}
	}

	my $status = ($external || $badtext) ? 'warn' : ($total ? 'ok' : 'warn');

	my $notes;
	if ($total) {
		$notes = sprintf("%d total (%d internal, %d external). %d link(s) with poor anchor text",
						 $total, $internal, $external, $badtext);
	} else {
		$notes = 'no links found';
	}

	return {
		name => 'Links',
		status => $status,
		notes => $notes,
	};
}

# Checks for essential Open Graph tags that improve social media sharing
sub _check_open_graph {
	my ($self, $html) = @_;

	my %og_tags;
	my @required = qw(title description image url);

	# Extract all Open Graph meta tags
	while ($html =~ /<meta\s+(?:property|name)=["']og:([^"']+)["']\s+content=["']([^"']*)["']/gis) {
		$og_tags{$1} = $2;
	}

	my @missing = grep { !exists $og_tags{$_} || !$og_tags{$_} } @required;
	my $found = keys %og_tags;

	my $status = @missing ? 'warn' : 'ok';
	my $notes;

	if ($found == 0) {
		$notes = 'no Open Graph tags found';
		$status = 'warn';
	} elsif (@missing) {
		$notes = sprintf('%d OG tags found, missing: %s', $found, join(', ', @missing));
	} else {
		$notes = sprintf('all essential OG tags present (%d total)', $found);
	}

	return {
		name => 'Open Graph',
		status => $status,
		notes => $notes,
		resolution => 'Add missing tags to <head>: <meta property="og:title" content="Your Page Title">, <meta property="og:description" content="Brief page description">'
	};
}

# Checks for Twitter Card meta tags for better Twitter sharing
sub _check_twitter_cards {
	my ($self, $html) = @_;

	my %twitter_tags;
	my @recommended = qw(card title description);

	# Extract Twitter Card meta tags
	while ($html =~ /<meta\s+(?:property|name)=["']twitter:([^"']+)["']\s+content=["']([^"']*)["']/gis) {
		$twitter_tags{$1} = $2;
	}

	my @missing = grep { !exists $twitter_tags{$_} || !$twitter_tags{$_} } @recommended;
	my $found = keys %twitter_tags;

	my $status = @missing ? 'warn' : 'ok';
	my $notes;

	if ($found == 0) {
		$notes = 'no Twitter Card tags found';
		$status = 'warn';
	} elsif (@missing) {
		$notes = sprintf('%d Twitter tags found, missing: %s', $found, join(', ', @missing));
	} else {
		$notes = sprintf('essential Twitter Card tags present (%d total)', $found);
	}

	return {
		name => 'Twitter Cards',
		status => $status,
		notes => $notes,
		resolution => 'Add missing tags to <head>: <meta name="twitter:card" content="summary">, <meta name="twitter:title" content="Your Page Title">'
	};
}

# Checks HTML size and warns if too large (impacts loading speed)
sub _check_page_size {
	my ($self, $html) = @_;

	my $size_bytes = length($html);
	my $size_kb = int($size_bytes / 1024);

	my $status = 'ok';
	my $notes = "${size_kb}KB HTML size";
	my $resolution = '';

	if ($size_bytes > 1_048_576) {	# > 1MB
		$status = 'error';
		$notes .= ' (too large, over 1MB)';
		$resolution = 'Consider optimizing: minify CSS/JS, compress images, remove unused elements, enable server compression';
	} elsif ($size_bytes > 102_400) {	# > 100KB
		$status = 'warn';
		$notes .= ' (large, consider optimization)';
	} elsif ($size_bytes < 1024) {	# < 1KB
		$status = 'warn';
		$notes .= ' (suspiciously small)';
	} else {
		$notes .= ' (good size)';
	}

	return {
		name => 'Page Size',
		status => $status,
		notes => $notes,
		resolution => $resolution
	};
}

# Calculates approximate Flesch Reading Ease score for content readability
sub _check_readability {
	my ($self, $html) = @_;

	# Extract text content (remove scripts, styles, and HTML tags)
	my $text = $html;
	$text =~ s/<script\b[^>]*>.*?<\/script>//gis;
	$text =~ s/<style\b[^>]*>.*?<\/style>//gis;
	$text =~ s/<[^>]+>//g;
	$text =~ s/\s+/ /g;
	$text =~ s/^\s+|\s+$//g;

	return {
		name => 'Readability',
		status => 'warn',
		notes => 'insufficient text for analysis',
		resolution => 'Add more content to the page - aim for at least 300 words of meaningful text',
	} if length($text) < 100;

	# Count sentences (approximate)
	my $sentences = () = $text =~ /[.!?]+/g;
	$sentences = 1 if $sentences == 0;	# avoid division by zero

	# Count words
	my @words = split /\s+/, $text;
	my $word_count = @words;

	return {
		name => 'Readability',
		status => 'warn',
		notes => 'insufficient content for analysis',
		resolution => 'Add more substantial content - aim for at least 300 words for proper SEO value',
	} if $word_count < 50;

	# Count syllables (very basic approximation)
	my $syllables = 0;
	for my $word (@words) {
		$word = lc($word);
		$word =~ s/[^a-z]//g;	# remove punctuation
		next if length($word) == 0;

		# Simple syllable counting heuristic
		my $vowels = () = $word =~ /[aeiouy]/g;
		$syllables += $vowels > 0 ? $vowels : 1;
		$syllables-- if $word =~ /e$/;	# silent e
	}
	$syllables = $word_count if $syllables < $word_count;	# minimum 1 syllable per word

	# Flesch Reading Ease formula
	my $avg_sentence_length = $word_count / $sentences;
	my $avg_syllables_per_word = $syllables / $word_count;
	my $flesch_score = 206.835 - (1.015 * $avg_sentence_length) - (84.6 * $avg_syllables_per_word);

	my $status = 'ok';
	my $level;
	my $notes;
	my $resolution = '';

	if ($flesch_score >= 90) {
		$level = 'very easy';
	} elsif ($flesch_score >= 80) {
		$level = 'easy';
	} elsif ($flesch_score >= 70) {
		$level = 'fairly easy';
	} elsif ($flesch_score >= 60) {
		$level = 'standard';
	} elsif ($flesch_score >= 50) {
		$level = 'fairly difficult';
		$status = 'warn';
		$resolution = 'Consider simplifying: use shorter sentences (aim for 15-20 words), choose simpler words, break up long paragraphs, add bullet points or lists';
	} elsif ($flesch_score >= 30) {
		$level = 'difficult';
		$status = 'warn';
		$resolution = 'Improve readability: use much shorter sentences (10-15 words), replace complex words with simpler alternatives, add more paragraph breaks, use active voice';
	} else {
		$level = 'very difficult';
		$status = 'warn';
		$resolution = 'Significantly simplify content: break long sentences into multiple short ones, replace jargon with plain language, add explanations for technical terms, use more white space and formatting';
	}

	$notes = sprintf('Flesch score: %.1f (%s) - %d words, %d sentences',
		$flesch_score, $level, $word_count, $sentences);

	return {
		name => 'Readability',
		status => $status,
		notes => $notes,
		resolution => $resolution,
	};
}

=head1 AUTHOR

Nigel Horne, C<< <njh at nigelhorne.com> >>

=head1 SEE ALSO

=over 4

=item * Test coverage report: L<https://nigelhorne.github.io/SEO-Inspector/coverage/>

=item * L<https://github.com/nigelhorne/SEO-Checker>

=item * L<https://github.com/sethblack/python-seo-analyzer>

=back

=head1 REPOSITORY

L<https://github.com/nigelhorne/SEO-Inspector>

=head1 SUPPORT

This module is provided as-is without any warranty.

Please report any bugs or feature requests to C<bug-seo-inspector at rt.cpan.org>,
or through the web interface at
L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=SEO-Inspector>.
I will be notified, and then you'll
automatically be notified of progress on your bug as I make changes.

You can find documentation for this module with the perldoc command.

    perldoc SEO::Inspector

You can also look for information at:

=over 4

=item * MetaCPAN

L<https://metacpan.org/dist/SEO-Inspector>

=item * RT: CPAN's request tracker

L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=SEO-Inspector>

=item * CPAN Testers' Matrix

L<http://matrix.cpantesters.org/?dist=SEO-Inspector>

=item * CPAN Testers Dependencies

L<http://deps.cpantesters.org/?module=SEO::Inspector>

=back

=head1 LICENCE AND COPYRIGHT

Copyright 2025 Nigel Horne.

Usage is subject to licence terms.

The licence terms of this software are as follows:

=over 4

=item * Personal single user, single computer use: GPL2

=item * All other users (including Commercial, Charity, Educational, Government)
  must apply in writing for a licence for use from Nigel Horne at the
  above e-mail.

=back

=cut

1;

__END__


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.