@maccesar/aiskills 1.17.0 → 1.18.0

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 (46) hide show
  1. package/README.md +42 -3
  2. package/commands/release.md +2 -4
  3. package/lib/commands/list.js +109 -22
  4. package/lib/commands/skills.js +1 -3
  5. package/lib/config.js +1 -0
  6. package/lib/prompts/checkboxCancel.js +0 -6
  7. package/lib/symlink.js +1 -1
  8. package/package.json +4 -2
  9. package/skills/refactoring-ui/SKILL.md +3 -7
  10. package/skills/seo-launch/SKILL.md +91 -0
  11. package/skills/seo-launch/assets/head.php +85 -0
  12. package/skills/seo-launch/assets/htaccess-static +86 -0
  13. package/skills/seo-launch/assets/robots.txt +17 -0
  14. package/skills/seo-launch/assets/social-meta.blade.php +90 -0
  15. package/skills/seo-launch/references/head-tags.md +109 -0
  16. package/skills/seo-launch/references/images.md +86 -0
  17. package/skills/seo-launch/references/search-engines.md +77 -0
  18. package/skills/seo-launch/references/server-files.md +168 -0
  19. package/skills/seo-launch/references/structured-data.md +139 -0
  20. package/skills/seo-launch/scripts/__pycache__/auditar_seo.cpython-312.pyc +0 -0
  21. package/skills/seo-launch/scripts/auditar_seo.py +539 -0
  22. package/skills/session-log/SKILL.md +83 -311
  23. package/skills/session-log/evals/README.md +16 -52
  24. package/skills/session-log/evals/ab-ronda-1.md +22 -67
  25. package/skills/session-log/evals/ab-ronda-2.md +28 -113
  26. package/skills/session-log/evals/defecto-experimento.md +3 -8
  27. package/skills/session-log/references/file-layout.md +44 -142
  28. package/skills/session-log/references/verification.md +22 -56
  29. package/skills/stitch-showcase/references/12-video-embedding.md +11 -28
  30. package/skills/stitch-showcase/references/13-language-detection.md +13 -38
  31. package/skills/stitch-showcase/references/14-troubleshooting-known-issues.md +18 -47
  32. package/skills/vscode-extension-dev/SKILL.md +3 -7
  33. package/skills/stitch-showcase/scripts/__pycache__/build_showcase.cpython-314.pyc +0 -0
  34. package/skills/stitch-showcase/scripts/__pycache__/component_utils.cpython-313.pyc +0 -0
  35. package/skills/stitch-showcase/scripts/__pycache__/component_utils.cpython-314.pyc +0 -0
  36. package/skills/stitch-showcase/scripts/__pycache__/detect_components.cpython-313.pyc +0 -0
  37. package/skills/stitch-showcase/scripts/__pycache__/detect_components.cpython-314.pyc +0 -0
  38. package/skills/stitch-showcase/scripts/__pycache__/extract_catalog.cpython-313.pyc +0 -0
  39. package/skills/stitch-showcase/scripts/__pycache__/extract_catalog.cpython-314.pyc +0 -0
  40. package/skills/stitch-showcase/scripts/__pycache__/extract_text.cpython-313.pyc +0 -0
  41. package/skills/stitch-showcase/scripts/__pycache__/extract_text.cpython-314.pyc +0 -0
  42. package/skills/stitch-showcase/scripts/__pycache__/extract_zips.cpython-313.pyc +0 -0
  43. package/skills/stitch-showcase/scripts/__pycache__/extract_zips.cpython-314.pyc +0 -0
  44. package/skills/stitch-showcase/scripts/__pycache__/parse_design_md.cpython-313.pyc +0 -0
  45. package/skills/stitch-showcase/scripts/__pycache__/parse_design_md.cpython-314.pyc +0 -0
  46. 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())