seofleet-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seofleet/__init__.py +104 -0
- seofleet/checks/__init__.py +83 -0
- seofleet/checks/geo/__init__.py +0 -0
- seofleet/checks/geo/ai_crawler_directives.py +89 -0
- seofleet/checks/geo/content_extraction.py +72 -0
- seofleet/checks/geo/faq_schema.py +58 -0
- seofleet/checks/geo/link_header.py +116 -0
- seofleet/checks/geo/llms_txt.py +29 -0
- seofleet/checks/geo/markdown_negotiation.py +40 -0
- seofleet/checks/geo/organization_schema.py +89 -0
- seofleet/checks/geo/speakable_schema.py +66 -0
- seofleet/checks/geo/structured_data.py +53 -0
- seofleet/checks/technical/__init__.py +0 -0
- seofleet/checks/technical/canonical.py +48 -0
- seofleet/checks/technical/heading_structure.py +54 -0
- seofleet/checks/technical/image_alt.py +53 -0
- seofleet/checks/technical/image_weight.py +111 -0
- seofleet/checks/technical/meta_description.py +48 -0
- seofleet/checks/technical/open_graph.py +53 -0
- seofleet/checks/technical/redirect_chain.py +60 -0
- seofleet/checks/technical/robots_meta_directives.py +70 -0
- seofleet/checks/technical/robots_txt.py +39 -0
- seofleet/checks/technical/sitemap_xml.py +78 -0
- seofleet/checks/technical/title.py +52 -0
- seofleet/checks/technical/twitter_card.py +64 -0
- seofleet/cli.py +122 -0
- seofleet/cli_lib.py +106 -0
- seofleet/config.py +95 -0
- seofleet/errors.py +20 -0
- seofleet/fetch_utils.py +301 -0
- seofleet/fleet.py +152 -0
- seofleet/format.py +102 -0
- seofleet/html_util.py +162 -0
- seofleet/init.py +86 -0
- seofleet/py.typed +0 -0
- seofleet/report_file.py +49 -0
- seofleet/runner.py +35 -0
- seofleet/site_resources.py +119 -0
- seofleet/slugify.py +25 -0
- seofleet/types.py +84 -0
- seofleet_cli-0.2.0.dist-info/METADATA +181 -0
- seofleet_cli-0.2.0.dist-info/RECORD +45 -0
- seofleet_cli-0.2.0.dist-info/WHEEL +4 -0
- seofleet_cli-0.2.0.dist-info/entry_points.txt +2 -0
- seofleet_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
seofleet/__init__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Programmatic / agent-native entry point.
|
|
3
|
+
|
|
4
|
+
from seofleet import load_site, run_checks, select_checks, ALL_CHECKS
|
|
5
|
+
|
|
6
|
+
ctx = load_site("https://example.com")
|
|
7
|
+
results = run_checks(ALL_CHECKS, ctx)
|
|
8
|
+
for r in results:
|
|
9
|
+
print(r.status, r.name, r.message)
|
|
10
|
+
|
|
11
|
+
This is the Python port of the seofleet-cli npm package
|
|
12
|
+
(https://www.npmjs.com/package/seofleet-cli). Both distributions run the
|
|
13
|
+
same 12 technical-SEO/GEO checks; see
|
|
14
|
+
https://github.com/RudrenduPaul/SeoFleet for the canonical documentation,
|
|
15
|
+
comparison table, and the original TypeScript source.
|
|
16
|
+
"""
|
|
17
|
+
from .checks import (
|
|
18
|
+
ALL_CHECKS,
|
|
19
|
+
GEO_CHECKS,
|
|
20
|
+
TECHNICAL_CHECKS,
|
|
21
|
+
ai_crawler_directives_check,
|
|
22
|
+
canonical_check,
|
|
23
|
+
content_extraction_check,
|
|
24
|
+
faq_schema_check,
|
|
25
|
+
heading_structure_check,
|
|
26
|
+
image_alt_check,
|
|
27
|
+
llms_txt_check,
|
|
28
|
+
meta_description_check,
|
|
29
|
+
robots_txt_check,
|
|
30
|
+
sitemap_xml_check,
|
|
31
|
+
structured_data_check,
|
|
32
|
+
title_check,
|
|
33
|
+
)
|
|
34
|
+
from .config import CONFIG_FILENAME, SeoFleetConfig, default_config, load_config, select_checks
|
|
35
|
+
from .errors import SeoFleetError
|
|
36
|
+
from .fetch_utils import FetchedResource, assert_http_url, safe_fetch
|
|
37
|
+
from .fleet import FleetManifest, FleetManifestEntry, FleetSiteResult, load_fleet_manifest, run_fleet
|
|
38
|
+
from .format import (
|
|
39
|
+
format_check_results_json,
|
|
40
|
+
format_check_results_text,
|
|
41
|
+
format_fleet_results_json,
|
|
42
|
+
format_fleet_results_text,
|
|
43
|
+
format_init_result_json,
|
|
44
|
+
format_init_result_text,
|
|
45
|
+
)
|
|
46
|
+
from .init import InitResult, init_project
|
|
47
|
+
from .runner import has_failure, run_checks
|
|
48
|
+
from .site_resources import build_check_context, fetch_site_resources, load_site
|
|
49
|
+
from .types import Check, CheckContext, CheckResult, SiteResources
|
|
50
|
+
|
|
51
|
+
__version__ = "0.2.0"
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"__version__",
|
|
55
|
+
# Types
|
|
56
|
+
"Check",
|
|
57
|
+
"CheckContext",
|
|
58
|
+
"CheckResult",
|
|
59
|
+
"SiteResources",
|
|
60
|
+
"FetchedResource",
|
|
61
|
+
"SeoFleetError",
|
|
62
|
+
"SeoFleetConfig",
|
|
63
|
+
"InitResult",
|
|
64
|
+
"FleetManifest",
|
|
65
|
+
"FleetManifestEntry",
|
|
66
|
+
"FleetSiteResult",
|
|
67
|
+
# Checks
|
|
68
|
+
"ALL_CHECKS",
|
|
69
|
+
"TECHNICAL_CHECKS",
|
|
70
|
+
"GEO_CHECKS",
|
|
71
|
+
"title_check",
|
|
72
|
+
"meta_description_check",
|
|
73
|
+
"canonical_check",
|
|
74
|
+
"robots_txt_check",
|
|
75
|
+
"sitemap_xml_check",
|
|
76
|
+
"heading_structure_check",
|
|
77
|
+
"image_alt_check",
|
|
78
|
+
"structured_data_check",
|
|
79
|
+
"llms_txt_check",
|
|
80
|
+
"ai_crawler_directives_check",
|
|
81
|
+
"faq_schema_check",
|
|
82
|
+
"content_extraction_check",
|
|
83
|
+
# Core functions
|
|
84
|
+
"run_checks",
|
|
85
|
+
"has_failure",
|
|
86
|
+
"fetch_site_resources",
|
|
87
|
+
"build_check_context",
|
|
88
|
+
"load_site",
|
|
89
|
+
"safe_fetch",
|
|
90
|
+
"assert_http_url",
|
|
91
|
+
"load_config",
|
|
92
|
+
"default_config",
|
|
93
|
+
"select_checks",
|
|
94
|
+
"CONFIG_FILENAME",
|
|
95
|
+
"init_project",
|
|
96
|
+
"load_fleet_manifest",
|
|
97
|
+
"run_fleet",
|
|
98
|
+
"format_check_results_text",
|
|
99
|
+
"format_check_results_json",
|
|
100
|
+
"format_fleet_results_text",
|
|
101
|
+
"format_fleet_results_json",
|
|
102
|
+
"format_init_result_text",
|
|
103
|
+
"format_init_result_json",
|
|
104
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Ported from src/checks/index.ts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from ..types import Check
|
|
7
|
+
from .geo.ai_crawler_directives import ai_crawler_directives_check
|
|
8
|
+
from .geo.content_extraction import content_extraction_check
|
|
9
|
+
from .geo.faq_schema import faq_schema_check
|
|
10
|
+
from .geo.link_header import link_header_check
|
|
11
|
+
from .geo.llms_txt import llms_txt_check
|
|
12
|
+
from .geo.markdown_negotiation import markdown_negotiation_check
|
|
13
|
+
from .geo.organization_schema import organization_schema_check
|
|
14
|
+
from .geo.speakable_schema import speakable_schema_check
|
|
15
|
+
from .geo.structured_data import structured_data_check
|
|
16
|
+
from .technical.canonical import canonical_check
|
|
17
|
+
from .technical.heading_structure import heading_structure_check
|
|
18
|
+
from .technical.image_alt import image_alt_check
|
|
19
|
+
from .technical.image_weight import image_weight_check
|
|
20
|
+
from .technical.meta_description import meta_description_check
|
|
21
|
+
from .technical.open_graph import open_graph_check
|
|
22
|
+
from .technical.robots_meta_directives import robots_meta_directives_check
|
|
23
|
+
from .technical.redirect_chain import redirect_chain_check
|
|
24
|
+
from .technical.robots_txt import robots_txt_check
|
|
25
|
+
from .technical.sitemap_xml import sitemap_xml_check
|
|
26
|
+
from .technical.title import title_check
|
|
27
|
+
from .technical.twitter_card import twitter_card_check
|
|
28
|
+
|
|
29
|
+
TECHNICAL_CHECKS: List[Check] = [
|
|
30
|
+
title_check,
|
|
31
|
+
meta_description_check,
|
|
32
|
+
canonical_check,
|
|
33
|
+
robots_txt_check,
|
|
34
|
+
sitemap_xml_check,
|
|
35
|
+
heading_structure_check,
|
|
36
|
+
image_alt_check,
|
|
37
|
+
open_graph_check,
|
|
38
|
+
twitter_card_check,
|
|
39
|
+
robots_meta_directives_check,
|
|
40
|
+
image_weight_check,
|
|
41
|
+
redirect_chain_check,
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
GEO_CHECKS: List[Check] = [
|
|
45
|
+
structured_data_check,
|
|
46
|
+
llms_txt_check,
|
|
47
|
+
ai_crawler_directives_check,
|
|
48
|
+
faq_schema_check,
|
|
49
|
+
content_extraction_check,
|
|
50
|
+
speakable_schema_check,
|
|
51
|
+
organization_schema_check,
|
|
52
|
+
markdown_negotiation_check,
|
|
53
|
+
link_header_check,
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
ALL_CHECKS: List[Check] = [*TECHNICAL_CHECKS, *GEO_CHECKS]
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"TECHNICAL_CHECKS",
|
|
60
|
+
"GEO_CHECKS",
|
|
61
|
+
"ALL_CHECKS",
|
|
62
|
+
"title_check",
|
|
63
|
+
"meta_description_check",
|
|
64
|
+
"canonical_check",
|
|
65
|
+
"robots_txt_check",
|
|
66
|
+
"sitemap_xml_check",
|
|
67
|
+
"heading_structure_check",
|
|
68
|
+
"image_alt_check",
|
|
69
|
+
"open_graph_check",
|
|
70
|
+
"twitter_card_check",
|
|
71
|
+
"robots_meta_directives_check",
|
|
72
|
+
"image_weight_check",
|
|
73
|
+
"redirect_chain_check",
|
|
74
|
+
"structured_data_check",
|
|
75
|
+
"llms_txt_check",
|
|
76
|
+
"ai_crawler_directives_check",
|
|
77
|
+
"faq_schema_check",
|
|
78
|
+
"content_extraction_check",
|
|
79
|
+
"speakable_schema_check",
|
|
80
|
+
"organization_schema_check",
|
|
81
|
+
"markdown_negotiation_check",
|
|
82
|
+
"link_header_check",
|
|
83
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Ported from src/checks/geo/ai-crawler-directives.ts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Dict, List
|
|
5
|
+
|
|
6
|
+
from ...types import Check, CheckContext, CheckResult
|
|
7
|
+
|
|
8
|
+
_ID = "ai-crawler-directives"
|
|
9
|
+
_NAME = "AI crawler directives"
|
|
10
|
+
_CATEGORY = "geo"
|
|
11
|
+
|
|
12
|
+
# The AI crawlers checked for are reported on, never prescribed -- whether
|
|
13
|
+
# to allow or disallow any of them is a genuine site-owner choice this tool
|
|
14
|
+
# does not take a position on. Training crawlers (GPTBot, ClaudeBot,
|
|
15
|
+
# Google-Extended, Applebot-Extended) and search/retrieval crawlers
|
|
16
|
+
# (OAI-SearchBot, Claude-SearchBot, PerplexityBot) are separate,
|
|
17
|
+
# independently blockable user agents at OpenAI and Anthropic -- a real
|
|
18
|
+
# robots.txt distinction, not a cosmetic one: blocking a training bot has
|
|
19
|
+
# no effect on whether that same company's assistant can still retrieve
|
|
20
|
+
# and cite the page live via its search bot, and vice versa.
|
|
21
|
+
TRACKED_BOTS = [
|
|
22
|
+
"GPTBot",
|
|
23
|
+
"OAI-SearchBot",
|
|
24
|
+
"ClaudeBot",
|
|
25
|
+
"Claude-SearchBot",
|
|
26
|
+
"PerplexityBot",
|
|
27
|
+
"Google-Extended",
|
|
28
|
+
"Applebot-Extended",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_ai_crawler_directives(robots_txt: str, bots: List[str] = TRACKED_BOTS) -> Dict[str, str]:
|
|
33
|
+
"""
|
|
34
|
+
A deliberately simple robots.txt parser: it tracks the single most
|
|
35
|
+
recent User-agent line and attributes the next Allow/Disallow lines to
|
|
36
|
+
it. It does not handle grouped multi-agent blocks (several consecutive
|
|
37
|
+
User-agent lines sharing one set of rules) -- a documented limitation,
|
|
38
|
+
not a bug, for v0.1.
|
|
39
|
+
"""
|
|
40
|
+
result: Dict[str, str] = {bot: "unspecified" for bot in bots}
|
|
41
|
+
|
|
42
|
+
current_agent = None
|
|
43
|
+
for raw_line in robots_txt.splitlines():
|
|
44
|
+
line = raw_line.split("#", 1)[0].strip()
|
|
45
|
+
if not line:
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
sep_index = line.find(":")
|
|
49
|
+
if sep_index == -1:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
key = line[:sep_index].strip().lower()
|
|
53
|
+
value = line[sep_index + 1 :].strip()
|
|
54
|
+
|
|
55
|
+
if key == "user-agent":
|
|
56
|
+
current_agent = value
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
if key in ("disallow", "allow") and current_agent:
|
|
60
|
+
matched_bot = next((bot for bot in bots if bot.lower() == current_agent.lower()), None)
|
|
61
|
+
if matched_bot is None:
|
|
62
|
+
continue
|
|
63
|
+
if key == "disallow" and value == "":
|
|
64
|
+
continue # an empty Disallow means "allow everything"; leave unspecified rather than assert allow
|
|
65
|
+
result[matched_bot] = "disallow" if key == "disallow" else "allow"
|
|
66
|
+
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _run(ctx: CheckContext) -> CheckResult:
|
|
71
|
+
robots = ctx.resources.robots_txt
|
|
72
|
+
|
|
73
|
+
if not robots.ok or robots.body is None:
|
|
74
|
+
return CheckResult(
|
|
75
|
+
_ID, _NAME, _CATEGORY, "WARN",
|
|
76
|
+
"robots.txt is unreachable, so AI-crawler directives could not be determined.",
|
|
77
|
+
"Add a reachable robots.txt if you want to state an explicit policy for AI crawlers (GPTBot, OAI-SearchBot, ClaudeBot, Claude-SearchBot, PerplexityBot, Google-Extended, Applebot-Extended).",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
directives = parse_ai_crawler_directives(robots.body)
|
|
81
|
+
summary = ", ".join(f"{bot}: {directives[bot]}" for bot in TRACKED_BOTS)
|
|
82
|
+
|
|
83
|
+
return CheckResult(
|
|
84
|
+
_ID, _NAME, _CATEGORY, "PASS",
|
|
85
|
+
f"AI crawler directives found in robots.txt -- {summary}. This is a report of what's configured, not a recommendation.",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
ai_crawler_directives_check = Check(id=_ID, name=_NAME, category=_CATEGORY, run=_run)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Ported from src/checks/geo/content-extraction.ts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ...types import Check, CheckContext, CheckResult
|
|
5
|
+
|
|
6
|
+
_ID = "content-extraction"
|
|
7
|
+
_NAME = "Content extraction friendliness"
|
|
8
|
+
_CATEGORY = "geo"
|
|
9
|
+
_UNSTRUCTURED_BLOCK_THRESHOLD = 800
|
|
10
|
+
_PARAGRAPH_MIN_LENGTH = 30
|
|
11
|
+
_STRUCTURE_TAGS = ("h1", "h2", "h3", "h4", "h5", "h6", "p", "li")
|
|
12
|
+
_HEADING_TAGS = ("h1", "h2", "h3", "h4", "h5", "h6")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _run(ctx: CheckContext) -> CheckResult:
|
|
16
|
+
"""
|
|
17
|
+
A heuristic, not a precise measurement: it checks whether the page has
|
|
18
|
+
heading/paragraph structure a generative engine can chunk, versus a
|
|
19
|
+
large amount of text crammed into a single element with no internal
|
|
20
|
+
structure. It cannot judge semantic quality, and it cannot see content
|
|
21
|
+
that only appears after client-side JavaScript runs -- this tool does
|
|
22
|
+
not execute JS or use a headless browser by design, so a page that is
|
|
23
|
+
empty until hydrated will read as unstructured here even if it renders
|
|
24
|
+
well structured in a real browser. Treat WARN as "worth a manual look",
|
|
25
|
+
not as a definitive verdict.
|
|
26
|
+
"""
|
|
27
|
+
if ctx.root is None:
|
|
28
|
+
return CheckResult(
|
|
29
|
+
_ID, _NAME, _CATEGORY, "FAIL",
|
|
30
|
+
"Homepage could not be fetched, so content extraction friendliness could not be checked.",
|
|
31
|
+
"Confirm siteUrl in seofleet.json is correct and reachable.",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
root = ctx.root
|
|
35
|
+
main = root.find_first(("main",))
|
|
36
|
+
scope = main if main is not None else (root.find_first(("body",)) or root)
|
|
37
|
+
|
|
38
|
+
heading_count = len(scope.find_all(_HEADING_TAGS))
|
|
39
|
+
paragraph_count = sum(
|
|
40
|
+
1 for el in scope.find_all(("p", "li")) if len(el.text().strip()) > _PARAGRAPH_MIN_LENGTH
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
max_unstructured_length = 0
|
|
44
|
+
for div in scope.find_all(("div",)):
|
|
45
|
+
has_structure = len(div.find_all(_STRUCTURE_TAGS)) > 0
|
|
46
|
+
if not has_structure:
|
|
47
|
+
length = len(div.text().strip())
|
|
48
|
+
if length > max_unstructured_length:
|
|
49
|
+
max_unstructured_length = length
|
|
50
|
+
|
|
51
|
+
if heading_count == 0 and paragraph_count == 0:
|
|
52
|
+
return CheckResult(
|
|
53
|
+
_ID, _NAME, _CATEGORY, "WARN",
|
|
54
|
+
"No headings or paragraph-level structure detected; a generative engine may struggle to extract distinct sections from this page.",
|
|
55
|
+
"Break content into headings (<h2>, <h3>, ...) and paragraphs (<p>) rather than unstructured text.",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if max_unstructured_length > _UNSTRUCTURED_BLOCK_THRESHOLD:
|
|
59
|
+
return CheckResult(
|
|
60
|
+
_ID, _NAME, _CATEGORY, "WARN",
|
|
61
|
+
f"Found a {max_unstructured_length}-character block of text with no internal heading or paragraph structure.",
|
|
62
|
+
"Break large unstructured blocks into headed sections and paragraphs.",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return CheckResult(
|
|
66
|
+
_ID, _NAME, _CATEGORY, "PASS",
|
|
67
|
+
f"Found {heading_count} heading(s) and {paragraph_count} structured text block(s); "
|
|
68
|
+
"content appears reasonably extractable. (Heuristic: cannot assess semantic quality or JS-rendered content.)",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
content_extraction_check = Check(id=_ID, name=_NAME, category=_CATEGORY, run=_run)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Ported from src/checks/geo/faq-schema.ts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ...html_util import get_scripts_by_type
|
|
8
|
+
from ...types import Check, CheckContext, CheckResult
|
|
9
|
+
|
|
10
|
+
_ID = "faq-schema"
|
|
11
|
+
_NAME = "FAQ schema"
|
|
12
|
+
_CATEGORY = "geo"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _contains_faq_type(value: Any) -> bool:
|
|
16
|
+
if isinstance(value, list):
|
|
17
|
+
return any(_contains_faq_type(item) for item in value)
|
|
18
|
+
if isinstance(value, dict):
|
|
19
|
+
type_value = value.get("@type")
|
|
20
|
+
if type_value == "FAQPage" or (isinstance(type_value, list) and "FAQPage" in type_value):
|
|
21
|
+
return True
|
|
22
|
+
if "@graph" in value:
|
|
23
|
+
return _contains_faq_type(value["@graph"])
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _run(ctx: CheckContext) -> CheckResult:
|
|
28
|
+
if ctx.root is None:
|
|
29
|
+
return CheckResult(
|
|
30
|
+
_ID, _NAME, _CATEGORY, "FAIL",
|
|
31
|
+
"Homepage could not be fetched, so FAQ schema could not be checked.",
|
|
32
|
+
"Confirm siteUrl in seofleet.json is correct and reachable.",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
found = False
|
|
36
|
+
for script in get_scripts_by_type(ctx.root, "application/ld+json"):
|
|
37
|
+
if found:
|
|
38
|
+
break
|
|
39
|
+
try:
|
|
40
|
+
parsed = json.loads(script.text())
|
|
41
|
+
if _contains_faq_type(parsed):
|
|
42
|
+
found = True
|
|
43
|
+
except (json.JSONDecodeError, ValueError):
|
|
44
|
+
pass # invalid JSON-LD is reported by the structured-data check; ignore here
|
|
45
|
+
|
|
46
|
+
# FAQ schema only applies to pages that actually have FAQ content, so
|
|
47
|
+
# its absence is informational, never a failure.
|
|
48
|
+
if not found:
|
|
49
|
+
return CheckResult(
|
|
50
|
+
_ID, _NAME, _CATEGORY, "WARN",
|
|
51
|
+
"No FAQPage structured data found.",
|
|
52
|
+
"If this page has an FAQ section, mark it up with FAQPage JSON-LD so generative engines can surface individual answers.",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return CheckResult(_ID, _NAME, _CATEGORY, "PASS", "FAQPage structured data found.")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
faq_schema_check = Check(id=_ID, name=_NAME, category=_CATEGORY, run=_run)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Ported from src/checks/geo/link-header.ts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from ...types import Check, CheckContext, CheckResult
|
|
8
|
+
|
|
9
|
+
_ID = "link-header"
|
|
10
|
+
_NAME = "Link header (RFC 8288)"
|
|
11
|
+
_CATEGORY = "geo"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ParsedLink:
|
|
16
|
+
url: str
|
|
17
|
+
rel: Optional[str] = None
|
|
18
|
+
params: Dict[str, str] = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _split_top_level(header: str) -> List[str]:
|
|
22
|
+
"""Splits a raw header value on top-level commas -- i.e. commas that
|
|
23
|
+
are neither inside a `<...>` URI-reference nor inside a `"..."` quoted
|
|
24
|
+
param value. RFC 8288 link-values themselves can contain commas in a
|
|
25
|
+
quoted title param (`title="a, b"`), so a naive `split(",")` would
|
|
26
|
+
misparse a multi-link header in that case."""
|
|
27
|
+
parts: List[str] = []
|
|
28
|
+
depth = 0
|
|
29
|
+
in_quotes = False
|
|
30
|
+
current = ""
|
|
31
|
+
for ch in header:
|
|
32
|
+
if ch == '"':
|
|
33
|
+
in_quotes = not in_quotes
|
|
34
|
+
if not in_quotes:
|
|
35
|
+
if ch == "<":
|
|
36
|
+
depth += 1
|
|
37
|
+
if ch == ">":
|
|
38
|
+
depth = max(0, depth - 1)
|
|
39
|
+
if ch == "," and depth == 0 and not in_quotes:
|
|
40
|
+
parts.append(current)
|
|
41
|
+
current = ""
|
|
42
|
+
else:
|
|
43
|
+
current += ch
|
|
44
|
+
if current.strip():
|
|
45
|
+
parts.append(current)
|
|
46
|
+
return parts
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_link_header(header: str) -> List[ParsedLink]:
|
|
50
|
+
"""Parses a raw RFC 8288 Link header value into one entry per
|
|
51
|
+
link-value, e.g. `<https://example.com/feed>; rel="alternate"` becomes
|
|
52
|
+
`ParsedLink(url="https://example.com/feed", rel="alternate", params={"rel": "alternate"})`.
|
|
53
|
+
Entries that don't match the `<url>; params` shape are dropped rather
|
|
54
|
+
than raising -- a malformed header is data for the check to report on,
|
|
55
|
+
not a reason to crash the run."""
|
|
56
|
+
links: List[ParsedLink] = []
|
|
57
|
+
for raw_part in _split_top_level(header):
|
|
58
|
+
part = raw_part.strip()
|
|
59
|
+
if not part:
|
|
60
|
+
continue
|
|
61
|
+
if not (part.startswith("<") and ">" in part):
|
|
62
|
+
continue
|
|
63
|
+
end = part.index(">")
|
|
64
|
+
url = part[1:end]
|
|
65
|
+
params_raw = part[end + 1 :]
|
|
66
|
+
|
|
67
|
+
params: Dict[str, str] = {}
|
|
68
|
+
for raw_param in params_raw.split(";"):
|
|
69
|
+
param_part = raw_param.strip()
|
|
70
|
+
if not param_part:
|
|
71
|
+
continue
|
|
72
|
+
eq = param_part.find("=")
|
|
73
|
+
if eq == -1:
|
|
74
|
+
continue
|
|
75
|
+
key = param_part[:eq].strip().lower()
|
|
76
|
+
value = param_part[eq + 1 :].strip()
|
|
77
|
+
if value.startswith('"') and value.endswith('"'):
|
|
78
|
+
value = value[1:-1]
|
|
79
|
+
params[key] = value
|
|
80
|
+
|
|
81
|
+
links.append(ParsedLink(url=url, rel=params.get("rel"), params=params))
|
|
82
|
+
return links
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _run(ctx: CheckContext) -> CheckResult:
|
|
86
|
+
header = ctx.resources.homepage.link_header
|
|
87
|
+
|
|
88
|
+
# An RFC 8288 Link header is machine-readable service discovery
|
|
89
|
+
# (alternate feeds, API entry points, rel="describedby" schemas, etc.)
|
|
90
|
+
# that almost no site sends today -- its absence is informational only
|
|
91
|
+
# and never fails the run, same spirit as llms_txt.py.
|
|
92
|
+
if not header or not header.strip():
|
|
93
|
+
return CheckResult(
|
|
94
|
+
_ID, _NAME, _CATEGORY, "WARN",
|
|
95
|
+
"The homepage does not send a Link response header.",
|
|
96
|
+
'Optional: add an RFC 8288 Link response header (e.g. <https://example.com/feed>; rel="alternate") '
|
|
97
|
+
"to advertise machine-readable service-discovery endpoints to crawlers and AI agents.",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
links = parse_link_header(header)
|
|
101
|
+
if not links:
|
|
102
|
+
return CheckResult(
|
|
103
|
+
_ID, _NAME, _CATEGORY, "WARN",
|
|
104
|
+
f'The homepage sends a Link header that could not be parsed as RFC 8288: "{header}".',
|
|
105
|
+
'Optional: format the Link header per RFC 8288, e.g. <https://example.com/feed>; rel="alternate".',
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
summary = ", ".join(f'{link.url} (rel="{link.rel}")' if link.rel else link.url for link in links)
|
|
109
|
+
plural = "y" if len(links) == 1 else "ies"
|
|
110
|
+
return CheckResult(
|
|
111
|
+
_ID, _NAME, _CATEGORY, "PASS",
|
|
112
|
+
f"The homepage sends {len(links)} Link header entr{plural}: {summary}.",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
link_header_check = Check(id=_ID, name=_NAME, category=_CATEGORY, run=_run)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Ported from src/checks/geo/llms-txt.ts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ...types import Check, CheckContext, CheckResult
|
|
5
|
+
|
|
6
|
+
_ID = "llms-txt"
|
|
7
|
+
_NAME = "llms.txt"
|
|
8
|
+
_CATEGORY = "geo"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _run(ctx: CheckContext) -> CheckResult:
|
|
12
|
+
llms_txt = ctx.resources.llms_txt
|
|
13
|
+
|
|
14
|
+
# llms.txt is an emerging, not-yet-universal convention -- its absence
|
|
15
|
+
# is informational only and never fails the run.
|
|
16
|
+
if not llms_txt.ok or not (llms_txt.body or "").strip():
|
|
17
|
+
return CheckResult(
|
|
18
|
+
_ID, _NAME, _CATEGORY, "WARN",
|
|
19
|
+
f"No llms.txt found at {llms_txt.url}.",
|
|
20
|
+
"Optional: add an llms.txt at your site root summarizing the site for LLM-based agents (see llmstxt.org).",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return CheckResult(
|
|
24
|
+
_ID, _NAME, _CATEGORY, "PASS",
|
|
25
|
+
f"llms.txt is present at {llms_txt.url}.",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
llms_txt_check = Check(id=_ID, name=_NAME, category=_CATEGORY, run=_run)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Ported from src/checks/geo/markdown-negotiation.ts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ...types import Check, CheckContext, CheckResult
|
|
5
|
+
|
|
6
|
+
_ID = "markdown-negotiation"
|
|
7
|
+
_NAME = "Markdown content negotiation"
|
|
8
|
+
_CATEGORY = "geo"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _run(ctx: CheckContext) -> CheckResult:
|
|
12
|
+
url = ctx.resources.site_url
|
|
13
|
+
res = ctx.fetch_fn(url, headers={"Accept": "text/markdown"})
|
|
14
|
+
content_type = (res.content_type or "").lower()
|
|
15
|
+
|
|
16
|
+
# Markdown content negotiation (responding to "Accept: text/markdown"
|
|
17
|
+
# with an actual text/markdown body) is an emerging, forward-looking
|
|
18
|
+
# convention that almost no site supports yet -- its absence is
|
|
19
|
+
# informational only and never fails the run, same spirit as
|
|
20
|
+
# llms_txt.py.
|
|
21
|
+
if not res.ok or "text/markdown" not in content_type:
|
|
22
|
+
message = (
|
|
23
|
+
f'Requesting {url} with "Accept: text/markdown" returned Content-Type '
|
|
24
|
+
f'"{res.content_type or "unknown"}" instead of text/markdown.'
|
|
25
|
+
if res.ok
|
|
26
|
+
else f'Could not verify Markdown content negotiation at {url} (the request failed).'
|
|
27
|
+
)
|
|
28
|
+
return CheckResult(
|
|
29
|
+
_ID, _NAME, _CATEGORY, "WARN", message,
|
|
30
|
+
'Optional: serve a text/markdown representation of pages when the client sends '
|
|
31
|
+
'"Accept: text/markdown" so LLM-based agents can fetch clean Markdown directly instead of parsing HTML.',
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return CheckResult(
|
|
35
|
+
_ID, _NAME, _CATEGORY, "PASS",
|
|
36
|
+
f'{url} returns Content-Type "{res.content_type}" when requested with "Accept: text/markdown".',
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
markdown_negotiation_check = Check(id=_ID, name=_NAME, category=_CATEGORY, run=_run)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Ported from src/checks/geo/organization-schema.ts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from ...html_util import get_scripts_by_type
|
|
8
|
+
from ...types import Check, CheckContext, CheckResult
|
|
9
|
+
|
|
10
|
+
_ID = "organization-schema"
|
|
11
|
+
_NAME = "Organization schema"
|
|
12
|
+
_CATEGORY = "geo"
|
|
13
|
+
|
|
14
|
+
ENTITY_TYPES = ["Organization", "Corporation", "LocalBusiness", "Person"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _has_entity_type(type_value: Any) -> bool:
|
|
18
|
+
if isinstance(type_value, str):
|
|
19
|
+
return type_value in ENTITY_TYPES
|
|
20
|
+
if isinstance(type_value, list):
|
|
21
|
+
return any(isinstance(t, str) and t in ENTITY_TYPES for t in type_value)
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _find_entity_node(value: Any) -> Optional[Dict[str, Any]]:
|
|
26
|
+
if isinstance(value, list):
|
|
27
|
+
for item in value:
|
|
28
|
+
found = _find_entity_node(item)
|
|
29
|
+
if found is not None:
|
|
30
|
+
return found
|
|
31
|
+
return None
|
|
32
|
+
if isinstance(value, dict):
|
|
33
|
+
if _has_entity_type(value.get("@type")):
|
|
34
|
+
return value
|
|
35
|
+
if "@graph" in value:
|
|
36
|
+
return _find_entity_node(value["@graph"])
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _has_non_empty_same_as(node: Dict[str, Any]) -> bool:
|
|
41
|
+
same_as = node.get("sameAs")
|
|
42
|
+
if isinstance(same_as, list):
|
|
43
|
+
return len(same_as) > 0
|
|
44
|
+
return isinstance(same_as, str) and same_as.strip() != ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _run(ctx: CheckContext) -> CheckResult:
|
|
48
|
+
if ctx.root is None:
|
|
49
|
+
return CheckResult(
|
|
50
|
+
_ID, _NAME, _CATEGORY, "FAIL",
|
|
51
|
+
"Homepage could not be fetched, so Organization schema could not be checked.",
|
|
52
|
+
"Confirm siteUrl in seofleet.json is correct and reachable.",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
entity_node: Optional[Dict[str, Any]] = None
|
|
56
|
+
for script in get_scripts_by_type(ctx.root, "application/ld+json"):
|
|
57
|
+
if entity_node is not None:
|
|
58
|
+
break
|
|
59
|
+
try:
|
|
60
|
+
parsed = json.loads(script.text())
|
|
61
|
+
entity_node = _find_entity_node(parsed)
|
|
62
|
+
except (json.JSONDecodeError, ValueError):
|
|
63
|
+
pass # invalid JSON-LD is reported by the structured-data check; ignore here
|
|
64
|
+
|
|
65
|
+
# Organization/Person schema only applies to entities that actually
|
|
66
|
+
# want a Knowledge Panel presence, so its absence is informational,
|
|
67
|
+
# never a failure.
|
|
68
|
+
if entity_node is None:
|
|
69
|
+
return CheckResult(
|
|
70
|
+
_ID, _NAME, _CATEGORY, "WARN",
|
|
71
|
+
f"No {'/'.join(ENTITY_TYPES)} structured data found.",
|
|
72
|
+
"Add Organization (or Person) JSON-LD with a sameAs array of your official social/profile URLs "
|
|
73
|
+
"to strengthen Knowledge Panel signals.",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if not _has_non_empty_same_as(entity_node):
|
|
77
|
+
return CheckResult(
|
|
78
|
+
_ID, _NAME, _CATEGORY, "WARN",
|
|
79
|
+
"Organization/Person schema found, but it has no sameAs property.",
|
|
80
|
+
"Add a sameAs array listing this entity's official social profiles and other authoritative URLs.",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return CheckResult(
|
|
84
|
+
_ID, _NAME, _CATEGORY, "PASS",
|
|
85
|
+
"Organization/Person schema found with a sameAs property linking authoritative profiles.",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
organization_schema_check = Check(id=_ID, name=_NAME, category=_CATEGORY, run=_run)
|