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.
Files changed (45) hide show
  1. seofleet/__init__.py +104 -0
  2. seofleet/checks/__init__.py +83 -0
  3. seofleet/checks/geo/__init__.py +0 -0
  4. seofleet/checks/geo/ai_crawler_directives.py +89 -0
  5. seofleet/checks/geo/content_extraction.py +72 -0
  6. seofleet/checks/geo/faq_schema.py +58 -0
  7. seofleet/checks/geo/link_header.py +116 -0
  8. seofleet/checks/geo/llms_txt.py +29 -0
  9. seofleet/checks/geo/markdown_negotiation.py +40 -0
  10. seofleet/checks/geo/organization_schema.py +89 -0
  11. seofleet/checks/geo/speakable_schema.py +66 -0
  12. seofleet/checks/geo/structured_data.py +53 -0
  13. seofleet/checks/technical/__init__.py +0 -0
  14. seofleet/checks/technical/canonical.py +48 -0
  15. seofleet/checks/technical/heading_structure.py +54 -0
  16. seofleet/checks/technical/image_alt.py +53 -0
  17. seofleet/checks/technical/image_weight.py +111 -0
  18. seofleet/checks/technical/meta_description.py +48 -0
  19. seofleet/checks/technical/open_graph.py +53 -0
  20. seofleet/checks/technical/redirect_chain.py +60 -0
  21. seofleet/checks/technical/robots_meta_directives.py +70 -0
  22. seofleet/checks/technical/robots_txt.py +39 -0
  23. seofleet/checks/technical/sitemap_xml.py +78 -0
  24. seofleet/checks/technical/title.py +52 -0
  25. seofleet/checks/technical/twitter_card.py +64 -0
  26. seofleet/cli.py +122 -0
  27. seofleet/cli_lib.py +106 -0
  28. seofleet/config.py +95 -0
  29. seofleet/errors.py +20 -0
  30. seofleet/fetch_utils.py +301 -0
  31. seofleet/fleet.py +152 -0
  32. seofleet/format.py +102 -0
  33. seofleet/html_util.py +162 -0
  34. seofleet/init.py +86 -0
  35. seofleet/py.typed +0 -0
  36. seofleet/report_file.py +49 -0
  37. seofleet/runner.py +35 -0
  38. seofleet/site_resources.py +119 -0
  39. seofleet/slugify.py +25 -0
  40. seofleet/types.py +84 -0
  41. seofleet_cli-0.2.0.dist-info/METADATA +181 -0
  42. seofleet_cli-0.2.0.dist-info/RECORD +45 -0
  43. seofleet_cli-0.2.0.dist-info/WHEEL +4 -0
  44. seofleet_cli-0.2.0.dist-info/entry_points.txt +2 -0
  45. 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)