@maccesar/aiskills 1.17.1 → 1.18.1
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.
- package/README.md +42 -1
- package/lib/commands/list.js +109 -22
- package/lib/commands/skills.js +1 -3
- package/lib/config.js +1 -0
- package/lib/prompts/checkboxCancel.js +0 -6
- package/lib/symlink.js +1 -1
- package/package.json +4 -2
- package/skills/seo-launch/SKILL.md +91 -0
- package/skills/seo-launch/assets/head.php +85 -0
- package/skills/seo-launch/assets/htaccess-static +86 -0
- package/skills/seo-launch/assets/robots.txt +17 -0
- package/skills/seo-launch/assets/social-meta.blade.php +90 -0
- package/skills/seo-launch/references/head-tags.md +109 -0
- package/skills/seo-launch/references/images.md +86 -0
- package/skills/seo-launch/references/search-engines.md +77 -0
- package/skills/seo-launch/references/server-files.md +168 -0
- package/skills/seo-launch/references/structured-data.md +139 -0
- package/skills/seo-launch/scripts/__pycache__/auditar_seo.cpython-312.pyc +0 -0
- package/skills/seo-launch/scripts/auditar_seo.py +539 -0
- package/skills/stitch-showcase/references/12-video-embedding.md +11 -28
- package/skills/stitch-showcase/references/13-language-detection.md +13 -38
- package/skills/stitch-showcase/scripts/__pycache__/build_showcase.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/component_utils.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/component_utils.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/detect_components.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/detect_components.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_catalog.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_catalog.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_text.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_text.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_zips.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_zips.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/parse_design_md.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/parse_design_md.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/slug_demangle.cpython-314.pyc +0 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Structured data (JSON-LD)
|
|
2
|
+
|
|
3
|
+
A block that describes the thing on the page in a vocabulary search engines parse. It is what can produce the side panel with phone, location and services, or the breadcrumb trail above a result.
|
|
4
|
+
|
|
5
|
+
It goes in the `<head>` as `<script type="application/ld+json">`. JSON-LD is the format Google recommends; microdata and RDFa still work but mean editing the markup itself.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The rule that outranks the schemas
|
|
10
|
+
|
|
11
|
+
**Confirmed data only.** Google cross-checks structured data against other sources — the business listing, the site's own text, directories. An address, opening hours or a rating that does not match makes it distrust the whole block, and nothing tells you that happened.
|
|
12
|
+
|
|
13
|
+
Leave a field out rather than approximate it. A `LocalBusiness` with name, URL, phone and city is worth more than one with an invented street and guessed hours.
|
|
14
|
+
|
|
15
|
+
Do not describe what is not on the page. Marking up a product that is not there, or reviews that do not exist, is what Google's spam policies call structured data abuse, and the penalty applies to the site, not the tag.
|
|
16
|
+
|
|
17
|
+
## `LocalBusiness` — a business with a physical presence
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"@context": "https://schema.org",
|
|
22
|
+
"@type": "LocalBusiness",
|
|
23
|
+
"name": "Acme Logistics",
|
|
24
|
+
"description": "Freight, storage and crossdock in the northeast.",
|
|
25
|
+
"url": "https://example.com/",
|
|
26
|
+
"logo": "https://example.com/images/logo.svg",
|
|
27
|
+
"image": "https://example.com/images/og-image.jpg",
|
|
28
|
+
"telephone": "+52-899-186-6350",
|
|
29
|
+
"address": {
|
|
30
|
+
"@type": "PostalAddress",
|
|
31
|
+
"addressLocality": "Reynosa",
|
|
32
|
+
"addressRegion": "Tamaulipas",
|
|
33
|
+
"addressCountry": "MX"
|
|
34
|
+
},
|
|
35
|
+
"areaServed": ["Reynosa", "Matamoros", "Monterrey"],
|
|
36
|
+
"sameAs": ["https://www.facebook.com/acmelogistics"],
|
|
37
|
+
"hasOfferCatalog": {
|
|
38
|
+
"@type": "OfferCatalog",
|
|
39
|
+
"name": "Services",
|
|
40
|
+
"itemListElement": [
|
|
41
|
+
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "Full truckload freight" } },
|
|
42
|
+
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "Warehousing" } }
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- **Phone in international format** (`+52-899-186-6350`) — that is what schema.org expects and what a phone link can dial from anywhere.
|
|
49
|
+
- **`address` can be partial.** `addressLocality` + `addressRegion` + `addressCountry` is valid and honest for a business without a public street address; `streetAddress` is optional.
|
|
50
|
+
- **`sameAs`** holds the real, verified profiles — the actual Facebook page, not the one that ought to exist.
|
|
51
|
+
- **`openingHours`** only if they are accurate and maintained. Wrong hours are worse than no hours.
|
|
52
|
+
- Use a more specific subtype when one fits: `Restaurant`, `AutoRepair`, `MedicalClinic`, `Store`. The full list is at <https://schema.org/LocalBusiness>.
|
|
53
|
+
|
|
54
|
+
**Omit the email if the page deliberately obfuscates it.** Publishing it in plain text inside the JSON-LD undoes whatever the visible markup was protecting it from.
|
|
55
|
+
|
|
56
|
+
## `Organization` — a company without a storefront
|
|
57
|
+
|
|
58
|
+
For a business whose location is not the point: an agency, a SaaS, a publisher.
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"@context": "https://schema.org",
|
|
63
|
+
"@type": "Organization",
|
|
64
|
+
"name": "Acme Software",
|
|
65
|
+
"url": "https://example.com/",
|
|
66
|
+
"logo": "https://example.com/images/logo.svg",
|
|
67
|
+
"sameAs": ["https://github.com/acme", "https://www.linkedin.com/company/acme"],
|
|
68
|
+
"contactPoint": {
|
|
69
|
+
"@type": "ContactPoint",
|
|
70
|
+
"telephone": "+52-899-186-6350",
|
|
71
|
+
"contactType": "customer service",
|
|
72
|
+
"availableLanguage": ["Spanish", "English"]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The `logo` should be a square-ish, reasonably large image on a plain background — it is a candidate for the knowledge panel.
|
|
78
|
+
|
|
79
|
+
## `Article` / `NewsArticle` — a post or a news item
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"@context": "https://schema.org",
|
|
84
|
+
"@type": "NewsArticle",
|
|
85
|
+
"headline": "Headline, under 110 characters",
|
|
86
|
+
"description": "The same summary as the meta description.",
|
|
87
|
+
"image": ["https://example.com/images/note-1200x630.jpg"],
|
|
88
|
+
"datePublished": "2026-08-13T08:30:00-06:00",
|
|
89
|
+
"dateModified": "2026-08-13T11:00:00-06:00",
|
|
90
|
+
"author": { "@type": "Person", "name": "Author Name" },
|
|
91
|
+
"publisher": {
|
|
92
|
+
"@type": "Organization",
|
|
93
|
+
"name": "Site Name",
|
|
94
|
+
"logo": { "@type": "ImageObject", "url": "https://example.com/images/logo.png" }
|
|
95
|
+
},
|
|
96
|
+
"mainEntityOfPage": { "@type": "WebPage", "@id": "https://example.com/note-slug" }
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- **`headline` under 110 characters** — Google truncates it beyond that.
|
|
101
|
+
- **Dates in ISO 8601 with a timezone offset.** A date without an offset is read as UTC, which shifts an evening post to the next day.
|
|
102
|
+
- **`dateModified` only when the content really changed.** Bumping it on every deploy is the same lie as a sitemap that stamps today on everything.
|
|
103
|
+
- On a database-driven site this block is generated per page from the record — never hand-written per post.
|
|
104
|
+
|
|
105
|
+
## `BreadcrumbList` — the trail above a result
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"@context": "https://schema.org",
|
|
110
|
+
"@type": "BreadcrumbList",
|
|
111
|
+
"itemListElement": [
|
|
112
|
+
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://example.com/" },
|
|
113
|
+
{ "@type": "ListItem", "position": 2, "name": "Services", "item": "https://example.com/services" },
|
|
114
|
+
{ "@type": "ListItem", "position": 3, "name": "Warehousing" }
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Positions start at 1 and are contiguous. The last item is the current page and **carries no `item` URL**. It must reflect a trail the user can actually see or follow on the page.
|
|
120
|
+
|
|
121
|
+
## Several blocks on one page
|
|
122
|
+
|
|
123
|
+
Two options, both valid: multiple `<script type="application/ld+json">` tags, or one array. Multiple tags are easier to generate from separate templates.
|
|
124
|
+
|
|
125
|
+
```html
|
|
126
|
+
<script type="application/ld+json">{ "@context": "…", "@type": "Organization", … }</script>
|
|
127
|
+
<script type="application/ld+json">{ "@context": "…", "@type": "BreadcrumbList", … }</script>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A typical article page carries three: `NewsArticle`, `BreadcrumbList`, and the site-wide `Organization`.
|
|
131
|
+
|
|
132
|
+
## Validating
|
|
133
|
+
|
|
134
|
+
- **<https://validator.schema.org/>** — checks the markup is well-formed and the properties exist. This is the one that must come back with zero errors.
|
|
135
|
+
- **<https://search.google.com/test/rich-results>** — a *different* tool. It checks whether the page qualifies for a rich result: the cards with stars, prices or steps.
|
|
136
|
+
|
|
137
|
+
`LocalBusiness` on its own does **not** generate a rich result, so the Rich Results Test will likely say no eligible items were detected. **That is not an error.** The JSON-LD still does its job of explaining the business to Google, and it is what feeds the knowledge panel.
|
|
138
|
+
|
|
139
|
+
Escaping is the other thing to check: a quote or an apostrophe inside a value that is not escaped breaks the JSON, and a broken block is ignored in full. Templating a description straight into JSON without escaping is how that happens — the audit script parses every block it finds and reports the ones that do not parse.
|
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Audit the SEO tags and configuration files of a URL.
|
|
3
|
+
|
|
4
|
+
Downloads the page once, extracts what lives in the <head> and contrasts it
|
|
5
|
+
with what the platforms actually need. Also checks robots.txt, sitemap.xml,
|
|
6
|
+
the canonical redirects, the response headers and the og:image.
|
|
7
|
+
|
|
8
|
+
python3 auditar_seo.py https://example.com
|
|
9
|
+
python3 auditar_seo.py https://example.com --no-network # markup only
|
|
10
|
+
python3 auditar_seo.py https://mysite.test --local # self-signed cert
|
|
11
|
+
|
|
12
|
+
Standard library only: runs on any macOS or Linux with Python 3.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import gzip
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import ssl
|
|
22
|
+
import sys
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.parse
|
|
25
|
+
import urllib.request
|
|
26
|
+
from html.parser import HTMLParser
|
|
27
|
+
|
|
28
|
+
# A browser User-Agent keeps some WAFs (Sucuri, Cloudflare) from answering
|
|
29
|
+
# with a challenge instead of the page.
|
|
30
|
+
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36"
|
|
31
|
+
|
|
32
|
+
OK, FALTA, REVISAR, INFO = "ok", "falta", "revisar", "info"
|
|
33
|
+
|
|
34
|
+
SIMBOLO = {OK: " ok ", FALTA: " MISS ", REVISAR: "CHECK ", INFO: " info "}
|
|
35
|
+
|
|
36
|
+
# Set from --local. Certificate verification is on by default: a tool that
|
|
37
|
+
# never verifies would happily audit a man-in-the-middled response and report
|
|
38
|
+
# it as healthy. The flag exists for Herd's .test domains, which are signed by
|
|
39
|
+
# a local authority the interpreter does not trust.
|
|
40
|
+
VERIFICAR_TLS = True
|
|
41
|
+
|
|
42
|
+
# Last transport error, so main() can explain a failure instead of printing a
|
|
43
|
+
# bare "could not read".
|
|
44
|
+
ULTIMO_ERROR = ""
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------- utilities
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def contexto_ssl() -> ssl.SSLContext:
|
|
50
|
+
"""Default verification, unless --local relaxed it for a self-signed cert."""
|
|
51
|
+
ctx = ssl.create_default_context()
|
|
52
|
+
if not VERIFICAR_TLS:
|
|
53
|
+
ctx.check_hostname = False
|
|
54
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
55
|
+
return ctx
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def pedir(url: str, metodo: str = "GET", seguir: bool = True, tiempo: int = 15):
|
|
59
|
+
"""Return (status, headers, body, final_url), or (None, {}, '', url) on failure."""
|
|
60
|
+
global ULTIMO_ERROR
|
|
61
|
+
|
|
62
|
+
class SinRedirect(urllib.request.HTTPRedirectHandler):
|
|
63
|
+
def redirect_request(self, *_args, **_kwargs):
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
handlers = [] if seguir else [SinRedirect()]
|
|
67
|
+
opener = urllib.request.build_opener(
|
|
68
|
+
urllib.request.HTTPSHandler(context=contexto_ssl()), *handlers
|
|
69
|
+
)
|
|
70
|
+
peticion = urllib.request.Request(url, method=metodo, headers={"User-Agent": UA})
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
with opener.open(peticion, timeout=tiempo) as r:
|
|
74
|
+
crudo = r.read() if metodo == "GET" else b""
|
|
75
|
+
cabeceras = {k.lower(): v for k, v in r.headers.items()}
|
|
76
|
+
if cabeceras.get("content-encoding") == "gzip":
|
|
77
|
+
crudo = gzip.decompress(crudo)
|
|
78
|
+
cuerpo = crudo.decode("utf-8", errors="replace")
|
|
79
|
+
return r.status, cabeceras, cuerpo, r.url
|
|
80
|
+
except urllib.error.HTTPError as e:
|
|
81
|
+
cabeceras = {k.lower(): v for k, v in e.headers.items()} if e.headers else {}
|
|
82
|
+
return e.code, cabeceras, "", url
|
|
83
|
+
except Exception as e:
|
|
84
|
+
ULTIMO_ERROR = f"{type(e).__name__}: {e}"
|
|
85
|
+
return None, {}, "", url
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def dimensiones(datos: bytes) -> tuple[int, int] | None:
|
|
89
|
+
"""Width and height of a PNG, JPEG or WebP, reading only the file header.
|
|
90
|
+
|
|
91
|
+
This catches the silent failure of declaring og:image:width 1200 when the
|
|
92
|
+
image measures something else: the card renders cropped and nobody notices.
|
|
93
|
+
"""
|
|
94
|
+
if datos[:8] == b"\x89PNG\r\n\x1a\n" and len(datos) >= 24:
|
|
95
|
+
return int.from_bytes(datos[16:20], "big"), int.from_bytes(datos[20:24], "big")
|
|
96
|
+
|
|
97
|
+
if datos[:2] == b"\xff\xd8": # JPEG: walk the segments up to the SOF
|
|
98
|
+
i = 2
|
|
99
|
+
while i + 9 < len(datos):
|
|
100
|
+
if datos[i] != 0xFF:
|
|
101
|
+
i += 1
|
|
102
|
+
continue
|
|
103
|
+
marcador = datos[i + 1]
|
|
104
|
+
if marcador in (0xD8, 0xD9) or 0xD0 <= marcador <= 0xD7:
|
|
105
|
+
i += 2
|
|
106
|
+
continue
|
|
107
|
+
largo = int.from_bytes(datos[i + 2 : i + 4], "big")
|
|
108
|
+
if 0xC0 <= marcador <= 0xCF and marcador not in (0xC4, 0xC8, 0xCC):
|
|
109
|
+
alto = int.from_bytes(datos[i + 5 : i + 7], "big")
|
|
110
|
+
ancho = int.from_bytes(datos[i + 7 : i + 9], "big")
|
|
111
|
+
return ancho, alto
|
|
112
|
+
i += 2 + largo
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
if datos[:4] == b"RIFF" and datos[8:12] == b"WEBP":
|
|
116
|
+
formato = datos[12:16]
|
|
117
|
+
if formato == b"VP8 " and len(datos) >= 30:
|
|
118
|
+
return (
|
|
119
|
+
int.from_bytes(datos[26:28], "little") & 0x3FFF,
|
|
120
|
+
int.from_bytes(datos[28:30], "little") & 0x3FFF,
|
|
121
|
+
)
|
|
122
|
+
if formato == b"VP8L" and len(datos) >= 25:
|
|
123
|
+
bits = int.from_bytes(datos[21:25], "little")
|
|
124
|
+
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
|
125
|
+
if formato == b"VP8X" and len(datos) >= 30:
|
|
126
|
+
return (
|
|
127
|
+
int.from_bytes(datos[24:27], "little") + 1,
|
|
128
|
+
int.from_bytes(datos[27:30], "little") + 1,
|
|
129
|
+
)
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class LectorHead(HTMLParser):
|
|
134
|
+
"""Collects from the <head> what matters for SEO and for social cards."""
|
|
135
|
+
|
|
136
|
+
def __init__(self) -> None:
|
|
137
|
+
super().__init__(convert_charrefs=True)
|
|
138
|
+
self.title = ""
|
|
139
|
+
self.metas: dict[str, str] = {}
|
|
140
|
+
self.links: list[dict[str, str]] = []
|
|
141
|
+
self.jsonld: list[str] = []
|
|
142
|
+
self.lang = ""
|
|
143
|
+
self._en_title = False
|
|
144
|
+
self._en_jsonld = False
|
|
145
|
+
|
|
146
|
+
def handle_starttag(self, tag, attrs):
|
|
147
|
+
a = {k.lower(): (v or "") for k, v in attrs}
|
|
148
|
+
if tag == "html":
|
|
149
|
+
self.lang = a.get("lang", "")
|
|
150
|
+
elif tag == "title":
|
|
151
|
+
self._en_title = True
|
|
152
|
+
elif tag == "meta":
|
|
153
|
+
clave = a.get("name") or a.get("property") or a.get("http-equiv")
|
|
154
|
+
if clave:
|
|
155
|
+
self.metas.setdefault(clave.lower(), a.get("content", ""))
|
|
156
|
+
elif "charset" in a:
|
|
157
|
+
self.metas.setdefault("charset", a["charset"])
|
|
158
|
+
elif tag == "link":
|
|
159
|
+
self.links.append(a)
|
|
160
|
+
elif tag == "script" and a.get("type", "").lower() == "application/ld+json":
|
|
161
|
+
self._en_jsonld = True
|
|
162
|
+
self.jsonld.append("")
|
|
163
|
+
|
|
164
|
+
def handle_endtag(self, tag):
|
|
165
|
+
if tag == "title":
|
|
166
|
+
self._en_title = False
|
|
167
|
+
elif tag == "script":
|
|
168
|
+
self._en_jsonld = False
|
|
169
|
+
|
|
170
|
+
def handle_data(self, data):
|
|
171
|
+
if self._en_title:
|
|
172
|
+
self.title += data
|
|
173
|
+
elif self._en_jsonld and self.jsonld:
|
|
174
|
+
self.jsonld[-1] += data
|
|
175
|
+
|
|
176
|
+
def link(self, rel: str) -> dict[str, str] | None:
|
|
177
|
+
for enlace in self.links:
|
|
178
|
+
if rel in enlace.get("rel", "").lower().split():
|
|
179
|
+
return enlace
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class Reporte:
|
|
184
|
+
def __init__(self) -> None:
|
|
185
|
+
self.filas: list[tuple[str, str, str, str]] = []
|
|
186
|
+
self.seccion = ""
|
|
187
|
+
|
|
188
|
+
def abrir(self, titulo: str) -> None:
|
|
189
|
+
self.seccion = titulo
|
|
190
|
+
|
|
191
|
+
def add(self, estado: str, etiqueta: str, detalle: str = "") -> None:
|
|
192
|
+
self.filas.append((self.seccion, estado, etiqueta, detalle))
|
|
193
|
+
|
|
194
|
+
def imprimir(self) -> int:
|
|
195
|
+
actual = None
|
|
196
|
+
for seccion, estado, etiqueta, detalle in self.filas:
|
|
197
|
+
if seccion != actual:
|
|
198
|
+
print(f"\n{seccion}\n{'─' * len(seccion)}")
|
|
199
|
+
actual = seccion
|
|
200
|
+
linea = f"[{SIMBOLO[estado]}] {etiqueta}"
|
|
201
|
+
if detalle:
|
|
202
|
+
linea += f" — {detalle}"
|
|
203
|
+
print(linea)
|
|
204
|
+
|
|
205
|
+
faltan = sum(1 for f in self.filas if f[1] == FALTA)
|
|
206
|
+
revisar = sum(1 for f in self.filas if f[1] == REVISAR)
|
|
207
|
+
bien = sum(1 for f in self.filas if f[1] == OK)
|
|
208
|
+
|
|
209
|
+
print(f"\n{'═' * 60}")
|
|
210
|
+
print(f"SUMMARY {bien} ok · {revisar} to check · {faltan} missing")
|
|
211
|
+
print("═" * 60)
|
|
212
|
+
return faltan
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ------------------------------------------------------------------- checks
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def revisar_head(h: LectorHead, r: Reporte) -> None:
|
|
219
|
+
r.abrir("Basics")
|
|
220
|
+
|
|
221
|
+
r.add(OK if h.metas.get("charset") else FALTA, "charset", h.metas.get("charset", ""))
|
|
222
|
+
r.add(OK if h.lang else FALTA, "lang on <html>", h.lang or "no lang attribute")
|
|
223
|
+
|
|
224
|
+
vp = h.metas.get("viewport", "")
|
|
225
|
+
r.add(OK if vp else FALTA, "viewport", vp)
|
|
226
|
+
|
|
227
|
+
t = h.title.strip()
|
|
228
|
+
if not t:
|
|
229
|
+
r.add(FALTA, "<title>", "no title")
|
|
230
|
+
elif len(t) > 60:
|
|
231
|
+
r.add(REVISAR, "<title>", f"{len(t)} characters; Google truncates near 60")
|
|
232
|
+
else:
|
|
233
|
+
r.add(OK, "<title>", f"{len(t)} characters")
|
|
234
|
+
|
|
235
|
+
d = h.metas.get("description", "").strip()
|
|
236
|
+
if not d:
|
|
237
|
+
r.add(FALTA, "description", "this is the text people read in the results")
|
|
238
|
+
elif len(d) < 70:
|
|
239
|
+
r.add(REVISAR, "description", f"{len(d)} characters; too short (120-160 works well)")
|
|
240
|
+
elif len(d) > 165:
|
|
241
|
+
r.add(REVISAR, "description", f"{len(d)} characters; gets cut past 160")
|
|
242
|
+
else:
|
|
243
|
+
r.add(OK, "description", f"{len(d)} characters")
|
|
244
|
+
|
|
245
|
+
canonical = h.link("canonical")
|
|
246
|
+
if not canonical:
|
|
247
|
+
r.add(FALTA, "canonical", "without it, every URL variant competes with itself")
|
|
248
|
+
else:
|
|
249
|
+
href = canonical.get("href", "")
|
|
250
|
+
estado = OK if href.startswith("http") else REVISAR
|
|
251
|
+
nota = href if estado == OK else f"{href} — must be absolute"
|
|
252
|
+
r.add(estado, "canonical", nota)
|
|
253
|
+
|
|
254
|
+
robots = h.metas.get("robots", "")
|
|
255
|
+
if "noindex" in robots.lower():
|
|
256
|
+
r.add(REVISAR, "meta robots", f"{robots} — this page is NOT indexed")
|
|
257
|
+
else:
|
|
258
|
+
r.add(OK if robots else INFO, "meta robots", robots or "absent (indexed by default)")
|
|
259
|
+
|
|
260
|
+
tc = h.metas.get("theme-color", "")
|
|
261
|
+
r.add(OK if tc else INFO, "theme-color", tc or "paints the mobile browser bar")
|
|
262
|
+
|
|
263
|
+
# --- Open Graph ---------------------------------------------------------
|
|
264
|
+
r.abrir("Open Graph (WhatsApp, Facebook, LinkedIn)")
|
|
265
|
+
|
|
266
|
+
for etiqueta, obligatoria in (
|
|
267
|
+
("og:type", True),
|
|
268
|
+
("og:site_name", False),
|
|
269
|
+
("og:locale", False),
|
|
270
|
+
("og:url", True),
|
|
271
|
+
("og:title", True),
|
|
272
|
+
("og:description", True),
|
|
273
|
+
):
|
|
274
|
+
v = h.metas.get(etiqueta, "").strip()
|
|
275
|
+
if v:
|
|
276
|
+
recorte = v if len(v) <= 70 else v[:67] + "…"
|
|
277
|
+
r.add(OK, etiqueta, recorte)
|
|
278
|
+
else:
|
|
279
|
+
r.add(FALTA if obligatoria else INFO, etiqueta, "")
|
|
280
|
+
|
|
281
|
+
og_url = h.metas.get("og:url", "")
|
|
282
|
+
if og_url and not og_url.startswith("http"):
|
|
283
|
+
r.add(REVISAR, "og:url is relative", f"{og_url} — must be absolute")
|
|
284
|
+
|
|
285
|
+
og_img = h.metas.get("og:image", "").strip()
|
|
286
|
+
if not og_img:
|
|
287
|
+
r.add(FALTA, "og:image", "without it the link shows up as a grey rectangle")
|
|
288
|
+
elif not og_img.startswith("http"):
|
|
289
|
+
r.add(REVISAR, "og:image", f"{og_img} — relative; WhatsApp and Facebook discard it")
|
|
290
|
+
else:
|
|
291
|
+
r.add(OK, "og:image", og_img)
|
|
292
|
+
|
|
293
|
+
for etiqueta in ("og:image:width", "og:image:height", "og:image:alt", "og:image:type"):
|
|
294
|
+
v = h.metas.get(etiqueta, "")
|
|
295
|
+
r.add(OK if v else INFO, etiqueta, v)
|
|
296
|
+
|
|
297
|
+
# --- Twitter/X ----------------------------------------------------------
|
|
298
|
+
r.abrir("Twitter / X")
|
|
299
|
+
|
|
300
|
+
card = h.metas.get("twitter:card", "")
|
|
301
|
+
if not card:
|
|
302
|
+
r.add(FALTA, "twitter:card", "no card without this")
|
|
303
|
+
elif card != "summary_large_image":
|
|
304
|
+
r.add(REVISAR, "twitter:card", f"{card} — 'summary_large_image' shows the photo full width")
|
|
305
|
+
else:
|
|
306
|
+
r.add(OK, "twitter:card", card)
|
|
307
|
+
|
|
308
|
+
for etiqueta in ("twitter:title", "twitter:description", "twitter:image", "twitter:site"):
|
|
309
|
+
v = h.metas.get(etiqueta, "")
|
|
310
|
+
r.add(OK if v else INFO, etiqueta, v[:70] if v else "")
|
|
311
|
+
|
|
312
|
+
# --- Icons --------------------------------------------------------------
|
|
313
|
+
r.abrir("Icons")
|
|
314
|
+
|
|
315
|
+
icono = h.link("icon")
|
|
316
|
+
if not icono:
|
|
317
|
+
r.add(FALTA, "favicon", "no <link rel=icon>")
|
|
318
|
+
else:
|
|
319
|
+
href = icono.get("href", "")
|
|
320
|
+
if icono.get("type", "") == "image/svg+xml" or href.endswith(".svg"):
|
|
321
|
+
r.add(OK, "favicon SVG", href)
|
|
322
|
+
else:
|
|
323
|
+
r.add(REVISAR, "favicon", f"{href} — an SVG draws sharp at any size")
|
|
324
|
+
|
|
325
|
+
touch = h.link("apple-touch-icon")
|
|
326
|
+
if touch:
|
|
327
|
+
r.add(OK, "apple-touch-icon", f"{touch.get('href', '')} ({touch.get('sizes', 'no sizes')})")
|
|
328
|
+
else:
|
|
329
|
+
r.add(FALTA, "apple-touch-icon", "iOS ignores the SVG and screenshots the page instead")
|
|
330
|
+
|
|
331
|
+
titulo_ios = h.metas.get("apple-mobile-web-app-title", "")
|
|
332
|
+
r.add(
|
|
333
|
+
OK if titulo_ios else INFO,
|
|
334
|
+
"apple-mobile-web-app-title",
|
|
335
|
+
titulo_ios or "without it, iOS truncates the <title> under the icon",
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# --- Structured data ----------------------------------------------------
|
|
339
|
+
r.abrir("Structured data")
|
|
340
|
+
|
|
341
|
+
if not h.jsonld:
|
|
342
|
+
r.add(FALTA, "JSON-LD", "this is what describes the business or article to Google")
|
|
343
|
+
for bloque in h.jsonld:
|
|
344
|
+
try:
|
|
345
|
+
datos = json.loads(bloque)
|
|
346
|
+
except json.JSONDecodeError as e:
|
|
347
|
+
r.add(REVISAR, "JSON-LD", f"not valid JSON: {e}")
|
|
348
|
+
continue
|
|
349
|
+
for item in datos if isinstance(datos, list) else [datos]:
|
|
350
|
+
tipo = item.get("@type", "no @type") if isinstance(item, dict) else "?"
|
|
351
|
+
r.add(OK, "JSON-LD", f"@type {tipo}")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def revisar_red(base: str, h: LectorHead, cabeceras: dict, r: Reporte) -> None:
|
|
355
|
+
partes = urllib.parse.urlsplit(base)
|
|
356
|
+
raiz = f"{partes.scheme}://{partes.netloc}"
|
|
357
|
+
|
|
358
|
+
# --- robots.txt and sitemap --------------------------------------------
|
|
359
|
+
r.abrir("robots.txt and sitemap.xml")
|
|
360
|
+
|
|
361
|
+
status, _, cuerpo, _ = pedir(f"{raiz}/robots.txt")
|
|
362
|
+
if status != 200:
|
|
363
|
+
r.add(FALTA, "robots.txt", f"answers {status}; it is the first thing Google looks for")
|
|
364
|
+
sitemaps: list[str] = []
|
|
365
|
+
else:
|
|
366
|
+
sitemaps = re.findall(r"(?im)^\s*sitemap:\s*(\S+)", cuerpo)
|
|
367
|
+
r.add(OK, "robots.txt", f"{len(cuerpo)} bytes")
|
|
368
|
+
if sitemaps:
|
|
369
|
+
r.add(OK, "declares the sitemap", sitemaps[0])
|
|
370
|
+
else:
|
|
371
|
+
r.add(
|
|
372
|
+
FALTA,
|
|
373
|
+
"Sitemap: in robots.txt",
|
|
374
|
+
"the only standard place to point at it without submitting it by hand",
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
url_sitemap = sitemaps[0] if sitemaps else f"{raiz}/sitemap.xml"
|
|
378
|
+
status, cab_sm, cuerpo, _ = pedir(url_sitemap)
|
|
379
|
+
if status != 200:
|
|
380
|
+
r.add(FALTA, "sitemap.xml", f"{url_sitemap} answers {status}")
|
|
381
|
+
else:
|
|
382
|
+
urls = len(re.findall(r"<loc>", cuerpo))
|
|
383
|
+
indices = len(re.findall(r"<sitemap>", cuerpo))
|
|
384
|
+
que = f"{indices} indexed sitemaps" if indices else f"{urls} URLs"
|
|
385
|
+
r.add(OK, "sitemap.xml", que)
|
|
386
|
+
tipo = cab_sm.get("content-type", "")
|
|
387
|
+
if "xml" not in tipo:
|
|
388
|
+
r.add(REVISAR, "sitemap MIME type", f"{tipo} — should be application/xml")
|
|
389
|
+
|
|
390
|
+
# --- One canonical domain ----------------------------------------------
|
|
391
|
+
r.abrir("Canonical domain")
|
|
392
|
+
|
|
393
|
+
status, cab, _, _ = pedir(f"http://{partes.netloc}{partes.path or '/'}", seguir=False)
|
|
394
|
+
if status is None:
|
|
395
|
+
r.add(INFO, "http:// → https://", "did not answer")
|
|
396
|
+
elif status in (301, 308):
|
|
397
|
+
r.add(OK, "http:// → https://", f"{status} to {cab.get('location', '')}")
|
|
398
|
+
elif status == 302:
|
|
399
|
+
r.add(REVISAR, "http:// → https://", "302 temporary; this calls for a 301")
|
|
400
|
+
else:
|
|
401
|
+
r.add(FALTA, "http:// → https://", f"answers {status} without redirecting")
|
|
402
|
+
|
|
403
|
+
host = partes.netloc
|
|
404
|
+
otro = host[4:] if host.startswith("www.") else f"www.{host}"
|
|
405
|
+
status, cab, _, _ = pedir(f"{partes.scheme}://{otro}{partes.path or '/'}", seguir=False)
|
|
406
|
+
if status is None:
|
|
407
|
+
r.add(INFO, f"{otro}", "does not resolve (fine: a single form of the domain)")
|
|
408
|
+
elif status in (301, 308):
|
|
409
|
+
r.add(OK, f"{otro} → canonical", cab.get("location", ""))
|
|
410
|
+
elif status == 200:
|
|
411
|
+
r.add(REVISAR, f"{otro}", "serves 200: two URLs for the same content")
|
|
412
|
+
else:
|
|
413
|
+
r.add(INFO, f"{otro}", f"answers {status}")
|
|
414
|
+
|
|
415
|
+
# --- Headers ------------------------------------------------------------
|
|
416
|
+
r.abrir("Response headers")
|
|
417
|
+
|
|
418
|
+
for nombre, recomendada in (
|
|
419
|
+
("x-content-type-options", "nosniff"),
|
|
420
|
+
("referrer-policy", "strict-origin-when-cross-origin"),
|
|
421
|
+
("x-frame-options", "SAMEORIGIN"),
|
|
422
|
+
):
|
|
423
|
+
v = cabeceras.get(nombre, "")
|
|
424
|
+
r.add(OK if v else FALTA, nombre, v or f"suggested: {recomendada}")
|
|
425
|
+
|
|
426
|
+
cache = cabeceras.get("cache-control", "")
|
|
427
|
+
if not cache:
|
|
428
|
+
r.add(REVISAR, "cache-control on the HTML", "no policy; must-revalidate is the safe one")
|
|
429
|
+
elif "max-age=0" in cache or "no-cache" in cache or "must-revalidate" in cache:
|
|
430
|
+
r.add(OK, "cache-control on the HTML", cache)
|
|
431
|
+
else:
|
|
432
|
+
r.add(REVISAR, "cache-control on the HTML", f"{cache} — cached HTML serves stale content")
|
|
433
|
+
|
|
434
|
+
if cabeceras.get("set-cookie"):
|
|
435
|
+
r.add(
|
|
436
|
+
REVISAR,
|
|
437
|
+
"Set-Cookie on the HTML",
|
|
438
|
+
"with this header many CDNs and WAFs stop caching the response",
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# --- The og:image really exists ----------------------------------------
|
|
442
|
+
og_img = h.metas.get("og:image", "")
|
|
443
|
+
if og_img.startswith("http"):
|
|
444
|
+
r.abrir("The og:image")
|
|
445
|
+
status, cab_img, _, _ = pedir(og_img, metodo="HEAD")
|
|
446
|
+
if status != 200:
|
|
447
|
+
r.add(FALTA, "og:image reachable", f"answers {status}")
|
|
448
|
+
else:
|
|
449
|
+
tipo = cab_img.get("content-type", "")
|
|
450
|
+
if "webp" in tipo:
|
|
451
|
+
r.add(REVISAR, "format", "WebP; several preview clients cannot read it. Use JPEG")
|
|
452
|
+
else:
|
|
453
|
+
r.add(OK, "format", tipo)
|
|
454
|
+
|
|
455
|
+
peso = int(cab_img.get("content-length", 0) or 0)
|
|
456
|
+
if peso:
|
|
457
|
+
estado = OK if peso < 1_500_000 else REVISAR
|
|
458
|
+
r.add(estado, "weight", f"{peso / 1024:.0f} KB")
|
|
459
|
+
|
|
460
|
+
trozo, _ = descargar_inicio(og_img)
|
|
461
|
+
medidas = dimensiones(trozo) if trozo else None
|
|
462
|
+
if medidas:
|
|
463
|
+
ancho, alto = medidas
|
|
464
|
+
declarado_w = h.metas.get("og:image:width", "")
|
|
465
|
+
declarado_h = h.metas.get("og:image:height", "")
|
|
466
|
+
texto = f"{ancho}×{alto}"
|
|
467
|
+
if (ancho, alto) == (1200, 630):
|
|
468
|
+
r.add(OK, "dimensions", texto)
|
|
469
|
+
else:
|
|
470
|
+
r.add(REVISAR, "dimensions", f"{texto} — 1200×630 is what platforms expect")
|
|
471
|
+
if declarado_w and (declarado_w, declarado_h) != (str(ancho), str(alto)):
|
|
472
|
+
r.add(
|
|
473
|
+
REVISAR,
|
|
474
|
+
"declared dimensions",
|
|
475
|
+
f"og:image:width/height say {declarado_w}×{declarado_h}, "
|
|
476
|
+
f"the file measures {texto}",
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def descargar_inicio(url: str, bytes_max: int = 4096) -> tuple[bytes, int | None]:
|
|
481
|
+
"""Only the file header: enough to read the dimensions."""
|
|
482
|
+
peticion = urllib.request.Request(url, headers={"User-Agent": UA, "Range": "bytes=0-4095"})
|
|
483
|
+
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=contexto_ssl()))
|
|
484
|
+
try:
|
|
485
|
+
with opener.open(peticion, timeout=15) as r:
|
|
486
|
+
return r.read(bytes_max), r.status
|
|
487
|
+
except Exception:
|
|
488
|
+
return b"", None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def main() -> int:
|
|
492
|
+
global VERIFICAR_TLS
|
|
493
|
+
|
|
494
|
+
p = argparse.ArgumentParser(description="Audit the SEO tags of a URL.")
|
|
495
|
+
p.add_argument("url")
|
|
496
|
+
p.add_argument(
|
|
497
|
+
"--no-network",
|
|
498
|
+
action="store_true",
|
|
499
|
+
help="markup only: skips robots.txt, sitemap, redirects and og:image",
|
|
500
|
+
)
|
|
501
|
+
p.add_argument(
|
|
502
|
+
"--local",
|
|
503
|
+
action="store_true",
|
|
504
|
+
help="accept a self-signed certificate (Herd .test domains). Never use it against a site on the public internet",
|
|
505
|
+
)
|
|
506
|
+
args = p.parse_args()
|
|
507
|
+
|
|
508
|
+
VERIFICAR_TLS = not args.local
|
|
509
|
+
|
|
510
|
+
url = args.url if "://" in args.url else f"https://{args.url}"
|
|
511
|
+
|
|
512
|
+
status, cabeceras, cuerpo, final = pedir(url)
|
|
513
|
+
if status != 200 or not cuerpo:
|
|
514
|
+
print(f"Could not read {url} (status {status}).")
|
|
515
|
+
if ULTIMO_ERROR:
|
|
516
|
+
print(f" {ULTIMO_ERROR}")
|
|
517
|
+
if "CERTIFICATE_VERIFY_FAILED" in ULTIMO_ERROR:
|
|
518
|
+
print(" A local site with a self-signed certificate needs --local.")
|
|
519
|
+
return 2
|
|
520
|
+
|
|
521
|
+
if final.rstrip("/") != url.rstrip("/"):
|
|
522
|
+
print(f"Note: {url} redirects to {final}")
|
|
523
|
+
|
|
524
|
+
lector = LectorHead()
|
|
525
|
+
lector.feed(cuerpo)
|
|
526
|
+
|
|
527
|
+
print(f"\nSEO audit of {final}")
|
|
528
|
+
|
|
529
|
+
reporte = Reporte()
|
|
530
|
+
revisar_head(lector, reporte)
|
|
531
|
+
if not args.no_network:
|
|
532
|
+
revisar_red(final, lector, cabeceras, reporte)
|
|
533
|
+
|
|
534
|
+
faltan = reporte.imprimir()
|
|
535
|
+
return 1 if faltan else 0
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
if __name__ == "__main__":
|
|
539
|
+
sys.exit(main())
|