dsh-desktop-kit 0.0.0-stage → 2.0.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 (269) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -2
  3. package/client.js +4101 -0
  4. package/cordis.patch.yml +5 -0
  5. package/docs/ARCHITECTURE.md +114 -0
  6. package/docs/COMPATIBILITY.md +61 -0
  7. package/docs/INSTALL.md +84 -0
  8. package/docs/PRIVACY.md +50 -0
  9. package/docs/ROADMAP.md +57 -0
  10. package/docs/TOKENS.md +118 -0
  11. package/docs/TROUBLESHOOTING.md +68 -0
  12. package/docs/images/hero.png +0 -0
  13. package/index.js +84 -0
  14. package/package.json +70 -4
  15. package/plugins/dsh-gkb-ru-ui/cordis.patch.yml +6 -0
  16. package/plugins/dsh-gkb-ru-ui/lib/client.js +1331 -0
  17. package/plugins/dsh-gkb-ru-ui/lib/index.js +186 -0
  18. package/plugins/dsh-gkb-ru-ui/lib/skills.js +570 -0
  19. package/plugins/dsh-gkb-ru-ui/package.json +28 -0
  20. package/plugins/dsh-gkb-ru-ui/screenshots/01-home-tiles-1440-light.png +0 -0
  21. package/plugins/dsh-gkb-ru-ui/screenshots/02-skills-1440-light.png +0 -0
  22. package/plugins/dsh-gkb-ru-ui/screenshots/03-skills-editor-1440-light.png +0 -0
  23. package/plugins/dsh-gkb-ru-ui/screenshots/04-skills-1440-dark.png +0 -0
  24. package/plugins/dsh-gkb-ru-ui/screenshots/05-skills-390-light.png +0 -0
  25. package/plugins/dsh-gkb-ru-ui/screenshots/06-home-tiles-1440-dark.png +0 -0
  26. package/plugins/dsh-peak-guard/README.md +70 -0
  27. package/plugins/dsh-peak-guard/client.js +741 -0
  28. package/plugins/dsh-peak-guard/cordis.patch.yml +5 -0
  29. package/plugins/dsh-peak-guard/icon.svg +12 -0
  30. package/plugins/dsh-peak-guard/index.js +2 -0
  31. package/plugins/dsh-peak-guard/locale/en.json +6 -0
  32. package/plugins/dsh-peak-guard/locale/ru.json +6 -0
  33. package/plugins/dsh-peak-guard/locale/zh.json +6 -0
  34. package/plugins/dsh-peak-guard/package.json +33 -0
  35. package/plugins/dsh-peak-guard/screenshots/01-real-time-peak-start.png +0 -0
  36. package/plugins/dsh-peak-guard/screenshots/02-real-time-peak-end.png +0 -0
  37. package/plugins/dsh-peak-guard/screenshots/03-peak-card-1440-light.png +0 -0
  38. package/plugins/dsh-peak-guard/screenshots/04-peak-card-1440-dark.png +0 -0
  39. package/plugins/dsh-peak-guard/screenshots/05-peak-card-390-light.png +0 -0
  40. package/plugins/dsh-peak-guard/screenshots/05-peak-card-768-light.png +0 -0
  41. package/plugins/dsh-peak-guard/screenshots/06-peak-card-390-light.png +0 -0
  42. package/plugins/dsh-peak-guard/screenshots/07-live-preview-check.png +0 -0
  43. package/plugins/dsh-peak-guard/screenshots/07-pre-peak-1440-light.png +0 -0
  44. package/plugins/dsh-peak-guard/screenshots/08-peak-start-1440-light.png +0 -0
  45. package/plugins/dsh-peak-guard/screenshots/09-peak-end-1440-light.png +0 -0
  46. package/plugins/dsh-peak-guard/screenshots/10-pre-peak-card-1440-light.png +0 -0
  47. package/plugins/dsh-peak-guard/screenshots/11-pre-peak-card-1440-dark.png +0 -0
  48. package/plugins/dsh-peak-guard/screenshots/12-pre-peak-card-390-light.png +0 -0
  49. package/plugins/dsh-peak-guard/screenshots/qa-report.json +74 -0
  50. package/plugins/dsh-profile-transfer/README.md +62 -0
  51. package/plugins/dsh-profile-transfer/bootstrap/01-INSTALL.cmd +7 -0
  52. package/plugins/dsh-profile-transfer/bootstrap/README-ARCHIVE.md +54 -0
  53. package/plugins/dsh-profile-transfer/bootstrap/README-/320/243/320/241/320/242/320/220/320/235/320/236/320/222/320/232/320/220.md +42 -0
  54. package/plugins/dsh-profile-transfer/bootstrap/install-on-clean-machine.ps1 +119 -0
  55. package/plugins/dsh-profile-transfer/bootstrap//320/243/320/241/320/242/320/220/320/235/320/236/320/222/320/230/320/242/320/254.cmd +5 -0
  56. package/plugins/dsh-profile-transfer/client.js +806 -0
  57. package/plugins/dsh-profile-transfer/cordis.patch.yml +3 -0
  58. package/plugins/dsh-profile-transfer/icon.svg +13 -0
  59. package/plugins/dsh-profile-transfer/index.js +375 -0
  60. package/plugins/dsh-profile-transfer/locale/en.json +6 -0
  61. package/plugins/dsh-profile-transfer/locale/ru.json +6 -0
  62. package/plugins/dsh-profile-transfer/locale/zh.json +6 -0
  63. package/plugins/dsh-profile-transfer/package.json +36 -0
  64. package/plugins/dsh-profile-transfer/screenshots/01-settings-transfer-1440-light.png +0 -0
  65. package/plugins/dsh-profile-transfer/screenshots/02-settings-transfer-preview-1440-light.png +0 -0
  66. package/plugins/dsh-profile-transfer/screenshots/03-settings-transfer-1440-dark.png +0 -0
  67. package/plugins/dsh-profile-transfer/screenshots/04-settings-transfer-390-light.png +0 -0
  68. package/plugins/dsh-profile-transfer/screenshots/05-settings-transfer-768-light.png +0 -0
  69. package/plugins/dsh-profile-transfer/screenshots/06-settings-transfer-390-dark.png +0 -0
  70. package/plugins/dsh-profile-transfer/screenshots/07-settings-transfer-export-result.png +0 -0
  71. package/plugins/dsh-profile-transfer/screenshots/08-settings-transfer-workspaces-1440-light.png +0 -0
  72. package/plugins/dsh-profile-transfer/screenshots/09-settings-transfer-workspaces-1440-dark.png +0 -0
  73. package/plugins/dsh-profile-transfer/screenshots/10-settings-transfer-workspaces-390-light.png +0 -0
  74. package/plugins/dsh-profile-transfer/transfer.js +1004 -0
  75. package/plugins/dsh-project-memory/README.md +71 -0
  76. package/plugins/dsh-project-memory/cordis.patch.yml +4 -0
  77. package/plugins/dsh-project-memory/icon.svg +12 -0
  78. package/plugins/dsh-project-memory/index.js +1240 -0
  79. package/plugins/dsh-project-memory/locale/en.json +6 -0
  80. package/plugins/dsh-project-memory/locale/ru.json +6 -0
  81. package/plugins/dsh-project-memory/locale/zh.json +6 -0
  82. package/plugins/dsh-project-memory/package.json +26 -0
  83. package/plugins/dsh-session-move/README.md +67 -0
  84. package/plugins/dsh-session-move/client.js +719 -0
  85. package/plugins/dsh-session-move/cordis.patch.yml +5 -0
  86. package/plugins/dsh-session-move/icon.svg +14 -0
  87. package/plugins/dsh-session-move/index.js +497 -0
  88. package/plugins/dsh-session-move/locale/en.json +6 -0
  89. package/plugins/dsh-session-move/locale/ru.json +6 -0
  90. package/plugins/dsh-session-move/locale/zh.json +6 -0
  91. package/plugins/dsh-session-move/package.json +31 -0
  92. package/plugins/dsh-session-move/screenshots/01-row-menu-1440-light.png +0 -0
  93. package/plugins/dsh-session-move/screenshots/02-dialog-1440-light.png +0 -0
  94. package/plugins/dsh-session-move/screenshots/03-dialog-390-light.png +0 -0
  95. package/plugins/dsh-session-move/screenshots/04-dialog-1440-dark.png +0 -0
  96. package/plugins/dsh-session-move/screenshots/05-drag-highlight-1440-light.png +0 -0
  97. package/plugins/dsh-session-move/screenshots/06-drop-ungrouped-highlight.png +0 -0
  98. package/plugins/dsh-session-move/screenshots/07-after-drop-ungrouped.png +0 -0
  99. package/plugins/dsh-session-move/screenshots/08-move-dialog.png +0 -0
  100. package/plugins/dsh-session-move/screenshots/09-moved-to-second-workspace.png +0 -0
  101. package/plugins/dsh-session-move/screenshots/qa-report.json +62 -0
  102. package/plugins/dsh-skill-autopilot/cordis.patch.yml +3 -0
  103. package/plugins/dsh-skill-autopilot/index.js +444 -0
  104. package/plugins/dsh-skill-autopilot/package.json +19 -0
  105. package/plugins/dsh-token-lab/cordis.patch.yml +3 -0
  106. package/plugins/dsh-token-lab/lib/client.js +504 -0
  107. package/plugins/dsh-token-lab/lib/index.js +275 -0
  108. package/plugins/dsh-token-lab/package.json +26 -0
  109. package/plugins/dsh-token-lab/screenshots/01-token-lab-1440-light.png +0 -0
  110. package/plugins/dsh-token-lab/screenshots/02-token-lab-1440-dark.png +0 -0
  111. package/skills/code-review-checklist/SKILL.md +55 -0
  112. package/skills/content-writing/README.md +87 -0
  113. package/skills/content-writing/SKILL.md +85 -0
  114. package/skills/content-writing/assets/checklists/fact-check.md +53 -0
  115. package/skills/content-writing/assets/checklists/publish.md +65 -0
  116. package/skills/content-writing/assets/checklists/social.md +40 -0
  117. package/skills/content-writing/assets/templates/article.md +47 -0
  118. package/skills/content-writing/assets/templates/brief.md +44 -0
  119. package/skills/content-writing/assets/templates/doctor-card.md +41 -0
  120. package/skills/content-writing/assets/templates/image-prompts.md +128 -0
  121. package/skills/content-writing/assets/templates/post-telegram.md +36 -0
  122. package/skills/content-writing/assets/templates/post-vk.md +42 -0
  123. package/skills/content-writing/assets/templates/press-release.md +56 -0
  124. package/skills/content-writing/assets/templates/science-pop-note.md +55 -0
  125. package/skills/content-writing/assets/templates/service-card.md +46 -0
  126. package/skills/content-writing/references/anti-ai-style.md +248 -0
  127. package/skills/content-writing/references/editing.md +185 -0
  128. package/skills/content-writing/references/fact-checking.md +205 -0
  129. package/skills/content-writing/references/government-medical.md +246 -0
  130. package/skills/content-writing/references/images.md +212 -0
  131. package/skills/content-writing/references/journalism.md +252 -0
  132. package/skills/content-writing/references/process.md +123 -0
  133. package/skills/content-writing/references/quick-recipes.md +99 -0
  134. package/skills/content-writing/references/rules.md +85 -0
  135. package/skills/content-writing/references/science-popular.md +218 -0
  136. package/skills/content-writing/references/seo-content.md +172 -0
  137. package/skills/content-writing/references/social-media.md +253 -0
  138. package/skills/content-writing/references/sources.md +49 -0
  139. package/skills/content-writing/references/templates.md +291 -0
  140. package/skills/context-economy/SKILL.md +60 -0
  141. package/skills/context-economy/references/long-session.md +35 -0
  142. package/skills/context-economy/references/token-math.md +42 -0
  143. package/skills/context-economy/references/tool-recipes.md +34 -0
  144. package/skills/database-safety/SKILL.md +47 -0
  145. package/skills/debugging-playbook/SKILL.md +42 -0
  146. package/skills/design-critique/SKILL.md +65 -0
  147. package/skills/design-critique/references/a11y.md +25 -0
  148. package/skills/design-critique/references/diagnoses.md +33 -0
  149. package/skills/design-critique/references/quick-tests.md +30 -0
  150. package/skills/design-critique/references/report-template.md +63 -0
  151. package/skills/design-critique/references/rubric-fast.md +73 -0
  152. package/skills/dev-workflow/SKILL.md +48 -0
  153. package/skills/dev-workflow/references/definition-of-done.md +44 -0
  154. package/skills/dev-workflow/references/memory-and-boundaries.md +31 -0
  155. package/skills/dev-workflow/references/stages.md +67 -0
  156. package/skills/git-and-ci/SKILL.md +60 -0
  157. package/skills/pg-upgrade-verify/SKILL.md +67 -0
  158. package/skills/project-deep-dive/SKILL.md +45 -0
  159. package/skills/project-deep-dive/references/recon-commands.md +90 -0
  160. package/skills/project-deep-dive/references/section-templates.md +104 -0
  161. package/skills/project-deep-dive/references/tool-and-process.md +86 -0
  162. package/skills/project-memory-maintenance/SKILL.md +55 -0
  163. package/skills/project-memory-maintenance/references/api-and-checklist.md +99 -0
  164. package/skills/project-memory-maintenance/references/journal-template.md +50 -0
  165. package/skills/project-memory-maintenance/references/section-rules.md +59 -0
  166. package/skills/release-and-deploy/SKILL.md +53 -0
  167. package/skills/requirements-and-planning/SKILL.md +49 -0
  168. package/skills/requirements-and-planning/references/antipatterns-and-checklist.md +20 -0
  169. package/skills/requirements-and-planning/references/process-steps.md +52 -0
  170. package/skills/requirements-and-planning/references/templates-and-estimates.md +29 -0
  171. package/skills/security-review/SKILL.md +40 -0
  172. package/skills/seo/README.md +60 -0
  173. package/skills/seo/SKILL.md +77 -0
  174. package/skills/seo/assets/README.md +66 -0
  175. package/skills/seo/assets/scripts/LICENSE-playwright-core.txt +202 -0
  176. package/skills/seo/assets/scripts/README.md +179 -0
  177. package/skills/seo/assets/scripts/lib/args.mjs +105 -0
  178. package/skills/seo/assets/scripts/lib/browser.mjs +620 -0
  179. package/skills/seo/assets/scripts/lib/zip.mjs +191 -0
  180. package/skills/seo/assets/scripts/seo-audit.mjs +1675 -0
  181. package/skills/seo/assets/snippets/jsonld-breadcrumb.json +30 -0
  182. package/skills/seo/assets/snippets/jsonld-faq.json +39 -0
  183. package/skills/seo/assets/snippets/jsonld-medical-clinic.json +49 -0
  184. package/skills/seo/assets/snippets/jsonld-medical-webpage.json +33 -0
  185. package/skills/seo/assets/snippets/jsonld-organization.json +74 -0
  186. package/skills/seo/assets/snippets/jsonld-physician.json +46 -0
  187. package/skills/seo/assets/snippets/meta.blade.php +61 -0
  188. package/skills/seo/assets/snippets/robots.txt +61 -0
  189. package/skills/seo/assets/snippets/sitemap-urlset-example.xml +72 -0
  190. package/skills/seo/assets/snippets/sitemap.xml +36 -0
  191. package/skills/seo/references/antipatterns.md +66 -0
  192. package/skills/seo/references/audit-checklist.md +319 -0
  193. package/skills/seo/references/content-seo.md +204 -0
  194. package/skills/seo/references/eeat-medical.md +219 -0
  195. package/skills/seo/references/measurement.md +250 -0
  196. package/skills/seo/references/on-page.md +214 -0
  197. package/skills/seo/references/page-templates.md +56 -0
  198. package/skills/seo/references/stack-recipes.md +695 -0
  199. package/skills/seo/references/structured-data.md +487 -0
  200. package/skills/seo/references/technical-seo.md +296 -0
  201. package/skills/service-desk-conventions/SKILL.md +52 -0
  202. package/skills/service-desk-db-safety/SKILL.md +63 -0
  203. package/skills/service-desk-release/SKILL.md +61 -0
  204. package/skills/service-desk-testing/SKILL.md +61 -0
  205. package/skills/service-desk-ui/SKILL.md +52 -0
  206. package/skills/skills-router/SKILL.md +53 -0
  207. package/skills/skills-router/references/applying-skills.md +25 -0
  208. package/skills/skills-router/references/routing-matrix.md +52 -0
  209. package/skills/testing-strategy/SKILL.md +52 -0
  210. package/skills/ui-design-system/SKILL.md +50 -0
  211. package/skills/ui-design-system/references/accessibility-and-responsive.md +50 -0
  212. package/skills/ui-design-system/references/tokens-and-components.md +67 -0
  213. package/skills/ui-design-system/references/visual-qa.md +44 -0
  214. package/skills/web-research/SKILL.md +47 -0
  215. package/skills/web-research/references/answer-and-citations.md +60 -0
  216. package/skills/web-research/references/query-recipes.md +44 -0
  217. package/skills/wow-design/SKILL.md +89 -0
  218. package/skills/wow-design/assets/kit/README.md +195 -0
  219. package/skills/wow-design/assets/kit/assets/css/base.css +158 -0
  220. package/skills/wow-design/assets/kit/assets/css/components.css +1001 -0
  221. package/skills/wow-design/assets/kit/assets/css/fonts.css +73 -0
  222. package/skills/wow-design/assets/kit/assets/css/pages.css +737 -0
  223. package/skills/wow-design/assets/kit/assets/css/tokens.css +218 -0
  224. package/skills/wow-design/assets/kit/assets/fonts/LICENSE-golos-text.txt +93 -0
  225. package/skills/wow-design/assets/kit/assets/fonts/LICENSE-lora.txt +93 -0
  226. package/skills/wow-design/assets/kit/assets/fonts/LICENSE-unbounded.txt +93 -0
  227. package/skills/wow-design/assets/kit/assets/fonts/golos-text-cyrillic-400-normal.woff2 +0 -0
  228. package/skills/wow-design/assets/kit/assets/fonts/golos-text-cyrillic-500-normal.woff2 +0 -0
  229. package/skills/wow-design/assets/kit/assets/fonts/golos-text-cyrillic-600-normal.woff2 +0 -0
  230. package/skills/wow-design/assets/kit/assets/fonts/golos-text-cyrillic-700-normal.woff2 +0 -0
  231. package/skills/wow-design/assets/kit/assets/fonts/golos-text-latin-400-normal.woff2 +0 -0
  232. package/skills/wow-design/assets/kit/assets/fonts/golos-text-latin-500-normal.woff2 +0 -0
  233. package/skills/wow-design/assets/kit/assets/fonts/golos-text-latin-600-normal.woff2 +0 -0
  234. package/skills/wow-design/assets/kit/assets/fonts/golos-text-latin-700-normal.woff2 +0 -0
  235. package/skills/wow-design/assets/kit/assets/fonts/lora-cyrillic-400-italic.woff2 +0 -0
  236. package/skills/wow-design/assets/kit/assets/fonts/lora-cyrillic-400-normal.woff2 +0 -0
  237. package/skills/wow-design/assets/kit/assets/fonts/lora-cyrillic-500-normal.woff2 +0 -0
  238. package/skills/wow-design/assets/kit/assets/fonts/lora-cyrillic-600-normal.woff2 +0 -0
  239. package/skills/wow-design/assets/kit/assets/fonts/lora-latin-400-normal.woff2 +0 -0
  240. package/skills/wow-design/assets/kit/assets/fonts/lora-latin-500-normal.woff2 +0 -0
  241. package/skills/wow-design/assets/kit/assets/fonts/lora-latin-600-normal.woff2 +0 -0
  242. package/skills/wow-design/assets/kit/assets/fonts/unbounded-cyrillic-400-normal.woff2 +0 -0
  243. package/skills/wow-design/assets/kit/assets/fonts/unbounded-cyrillic-500-normal.woff2 +0 -0
  244. package/skills/wow-design/assets/kit/assets/fonts/unbounded-cyrillic-600-normal.woff2 +0 -0
  245. package/skills/wow-design/assets/kit/assets/fonts/unbounded-latin-400-normal.woff2 +0 -0
  246. package/skills/wow-design/assets/kit/assets/fonts/unbounded-latin-500-normal.woff2 +0 -0
  247. package/skills/wow-design/assets/kit/assets/fonts/unbounded-latin-600-normal.woff2 +0 -0
  248. package/skills/wow-design/assets/kit/assets/js/kit.js +550 -0
  249. package/skills/wow-design/assets/kit/pages/dashboard.html +539 -0
  250. package/skills/wow-design/assets/kit/pages/hospital.html +430 -0
  251. package/skills/wow-design/assets/kit/showcase.html +1164 -0
  252. package/skills/wow-design/assets/scripts/LICENSE-axe-core.txt +362 -0
  253. package/skills/wow-design/assets/scripts/LICENSE-playwright-core.txt +202 -0
  254. package/skills/wow-design/assets/scripts/README.md +312 -0
  255. package/skills/wow-design/assets/scripts/audit.mjs +1200 -0
  256. package/skills/wow-design/assets/scripts/capture.mjs +337 -0
  257. package/skills/wow-design/assets/scripts/package.json +30 -0
  258. package/skills/wow-design/references/anti-patterns.md +252 -0
  259. package/skills/wow-design/references/critique-rubric.md +262 -0
  260. package/skills/wow-design/references/design-brief-template.md +48 -0
  261. package/skills/wow-design/references/inspiration.md +203 -0
  262. package/skills/wow-design/references/presets.md +99 -0
  263. package/skills/wow-design/references/principles.md +371 -0
  264. package/skills/wow-design/references/quick-presets.md +50 -0
  265. package/skills/wow-design/references/report-template.md +21 -0
  266. package/skills/wow-design/references/stack-recipes.md +96 -0
  267. package/skills/wow-design/references/visual-qa.md +82 -0
  268. package/skills/wow-design/references/wow-levers.md +43 -0
  269. package/tools/skill-cost.mjs +76 -0
@@ -0,0 +1,1675 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * seo-audit.mjs — технический SEO-аудит страницы: meta, canonical/robots, OG/Twitter,
4
+ * JSON-LD (schema.org), заголовки, изображения, ссылки (битые same-origin), hreflang,
5
+ * mixed content, вес/ресурсы, Core Web Vitals (LCP/CLS через PerformanceObserver).
6
+ *
7
+ * Работает через уже запущенный headless Chrome (CDP, по умолчанию http://127.0.0.1:9333)
8
+ * и общую инфраструктуру tools/design-qa/lib (fallback-запуск браузера там же).
9
+ *
10
+ * Usage:
11
+ * node seo-audit.mjs <file|url> [--out dir] [--name] [--json] [--max-links 200]
12
+ * [--viewports desktop,mobile] [--serve] [--no-links] [--no-robots] [--cdp url]
13
+ *
14
+ * Коды выхода: 0 — нет critical/serious; 2 — есть critical/serious; 1 — сбой инструмента.
15
+ */
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
+ import { parseArgs, safeFileName, UsageError, printHelpAndExit } from './lib/args.mjs';
21
+ import {
22
+ connectChrome,
23
+ disposeChrome,
24
+ resolveTarget,
25
+ startStaticServer,
26
+ openPage,
27
+ closePage,
28
+ waitForFonts,
29
+ parseViewportList,
30
+ VIEWPORTS,
31
+ } from './lib/browser.mjs';
32
+
33
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
34
+ const WORKSPACE = path.resolve(HERE, '..', '..');
35
+ const DEFAULT_OUT = path.join(WORKSPACE, 'tmp', 'qa-artifacts');
36
+
37
+ const SEVERITY = ['critical', 'serious', 'moderate', 'minor'];
38
+ const SEVERITY_RANK = { critical: 4, serious: 3, moderate: 2, minor: 1 };
39
+
40
+ const T = {
41
+ titleMin: 30,
42
+ titleMax: 65,
43
+ descMin: 70,
44
+ descMax: 160,
45
+ htmlMinor: 150 * 1024,
46
+ htmlModerate: 600 * 1024,
47
+ weightMinor: 3 * 1024 * 1024,
48
+ weightModerate: 6 * 1024 * 1024,
49
+ lcpSerious: 2500,
50
+ lcpCritical: 4000,
51
+ clsModerate: 0.1,
52
+ clsSerious: 0.25,
53
+ ttfbModerate: 800,
54
+ resourcesMinor: 100,
55
+ };
56
+
57
+ const FIX = {
58
+ 'title-missing': 'Добавить <title> 30–65 символов с ключевым запросом и брендом.',
59
+ 'title-length': 'Сократить/расширить title до 30–65 символов (важное — в начале).',
60
+ 'title-duplicate': 'Оставить ровно один тег <title> в <head>.',
61
+ 'description-missing': 'Добавить <meta name="description"> 70–160 символов: оффер + призыв.',
62
+ 'description-length': 'Привести description к 70–160 символам (без «воды» и повторов).',
63
+ 'description-duplicate': 'Оставить ровно одну meta description.',
64
+ 'h1-missing': 'Добавить один <h1> с главным запросом страницы.',
65
+ 'h1-multiple': 'Оставить ровно один <h1>, остальные понизить до h2.',
66
+ 'h1-empty': 'Заполнить <h1> видимым осмысленным текстом.',
67
+ 'heading-skip': 'Не пропускать уровни заголовков: h1 → h2 → h3.',
68
+ 'lang-missing': 'Указать lang у <html> (для русской страницы lang="ru").',
69
+ 'lang-invalid': 'Использовать корректный код языка: ru, en-US и т.п.',
70
+ 'charset-missing': 'Добавить <meta charset="utf-8"> в первый килобайт <head>.',
71
+ 'viewport-missing': 'Добавить <meta name="viewport" content="width=device-width, initial-scale=1">.',
72
+ 'viewport-width': 'В viewport должно быть width=device-width, initial-scale=1.',
73
+ 'viewport-zoom': 'Не блокировать зум (убрать user-scalable=no / maximum-scale<5).',
74
+ 'canonical-missing': 'Добавить <link rel="canonical" href="абсолютный self-URL">.',
75
+ 'canonical-multiple': 'Оставить ровно один canonical.',
76
+ 'canonical-relative': 'Canonical должен быть абсолютным URL с протоколом и хостом.',
77
+ 'canonical-not-self': 'Canonical должен указывать на саму страницу (без параметров/другого URL).',
78
+ 'robots-noindex': 'Убрать noindex из meta robots / X-Robots-Tag, иначе страница не индексируется.',
79
+ 'robots-nofollow': 'Проверить nofollow: ссылки не будут передавать вес.',
80
+ 'og-missing': 'Добавить Open Graph: og:title, og:description, og:image, og:url, og:type.',
81
+ 'og-image-relative': 'og:image должен быть абсолютным URL (желательно 1200×630).',
82
+ 'og-image-format': 'og:image — JPG/PNG/WebP 1200×630; SVG соцсети не показывают.',
83
+ 'twitter-missing': 'Добавить twitter:card (summary_large_image) и twitter:title/description/image.',
84
+ 'schema-missing': 'Добавить JSON-LD schema.org (MedicalClinic/Organization/BreadcrumbList/FAQPage и т.п.).',
85
+ 'schema-json-invalid': 'Исправить JSON в <script type="application/ld+json">.',
86
+ 'schema-context': 'Указать "@context": "https://schema.org".',
87
+ 'schema-type': 'Указать "@type" (Organization, MedicalClinic, Article, FAQPage…).',
88
+ 'schema-required': 'Заполнить обязательные поля типа schema.org.',
89
+ 'schema-faq': 'В FAQPage: mainEntity[] с Question (name + acceptedAnswer).',
90
+ 'schema-breadcrumb': 'В BreadcrumbList: непустой itemListElement[] с position + name/item.',
91
+ 'img-alt-missing': 'Добавить alt: описательный для смысловых, alt="" для декоративных.',
92
+ 'img-dimensions-missing': 'Указать width/height (или aspect-ratio) — иначе скачет CLS.',
93
+ 'img-lazy-missing': 'Добавить loading="lazy" изображениям ниже первого экрана.',
94
+ 'img-nextgen': 'Крупные PNG/JPEG перевести в WebP/AVIF (или отдавать <picture>).',
95
+ 'link-empty-anchor': 'Дать ссылке видимый текст или aria-label.',
96
+ 'link-hash-placeholder': 'Заменить href="#" на реальный URL или кнопку.',
97
+ 'link-empty-href': 'Убрать ссылки с пустым href.',
98
+ 'link-javascript': 'Заменить javascript: на <button> с обработчиком.',
99
+ 'link-broken': 'Исправить битые ссылки (404/сетевые ошибки).',
100
+ 'link-broken-anchor': 'Исправить якоря: цели с таким id нет на странице.',
101
+ 'link-target-blank': 'Для target="_blank" добавить rel="noopener".',
102
+ 'http-links': 'Заменить http:// на https:// (или относительные ссылки).',
103
+ 'mixed-content': 'Убрать http://-ресурсы на https-странице (mixed content блокируется браузером).',
104
+ 'hreflang-invalid': 'Исправить формат hreflang (ru, en-US, x-default).',
105
+ 'hreflang-relative': 'hreflang-ссылки должны быть абсолютными.',
106
+ 'hreflang-duplicate': 'Убрать дубли hreflang.',
107
+ 'hreflang-self': 'Добавить self-referencing hreflang (включая x-default).',
108
+ 'html-too-big': 'Уменьшить HTML (вынести инлайн-данные, сократить разметку, пагинация).',
109
+ 'render-blocking-css': 'Сократить render-blocking CSS в <head> (critical CSS, defer остальных).',
110
+ 'render-blocking-js': 'Скрипты в <head> — с defer/async (или вынести в конец body).',
111
+ 'resources-many': 'Сократить число запросов: объединить/инлайнить, лениво грузить.',
112
+ 'weight-heavy': 'Снизить вес страницы: сжатие (gzip/brotli), WebP/AVIF, ленивая загрузка.',
113
+ 'no-compression': 'Включить gzip/brotli для HTML.',
114
+ 'lcp-slow': 'Ускорить LCP: preload hero-изображения/шрифта, убрать render-blocking, CDN.',
115
+ 'cls-bad': 'Уменьшить CLS: width/height у медиа, резервировать место под баннеры/шрифты.',
116
+ 'ttfb-slow': 'Ускорить TTFB: кеш страницы, CDN, оптимизация бэкенда.',
117
+ 'long-tasks': 'Разбить длинные JS-задачи (code splitting, defer, web worker).',
118
+ 'redirect-chain': 'Сократить цепочку редиректов до 1.',
119
+ 'http-status': 'Исправить HTTP-статус страницы.',
120
+ 'robots-missing': 'Опубликовать robots.txt со ссылкой на sitemap.',
121
+ 'robots-disallow': 'robots.txt закрывает страницу от индексации — проверить правила.',
122
+ 'sitemap-missing': 'Опубликовать sitemap.xml и указать его в robots.txt.',
123
+ 'sitemap-not-listed': 'Добавить URL страницы в sitemap.xml.',
124
+ 'dom-huge': 'Сократить DOM (>800–1500 элементов) — влияет на интерактивность.',
125
+ 'perf-unavailable': 'Метрики CWV не собрались (проверить в реальном браузере).',
126
+ };
127
+
128
+ const HELP = `
129
+ seo-audit.mjs — технический SEO-аудит страницы (meta, schema.org, ссылки, CWV).
130
+
131
+ Использование:
132
+ node tools/seo-audit/seo-audit.mjs <путь-к-html|url> [опции]
133
+
134
+ Опции:
135
+ --out <dir> Каталог отчётов (по умолчанию tmp/qa-artifacts)
136
+ --name <name> Имена файлов <name>-seo-audit.json / <name>-seo-audit.md
137
+ --viewports <list> desktop,mobile или 1440x900,390x844 (по умолчанию desktop)
138
+ --max-links <n> Сколько same-origin ссылок проверить на битость (по умолчанию 80)
139
+ --no-links Не проверять битые ссылки (быстрее)
140
+ --no-robots Не запрашивать robots.txt/sitemap.xml
141
+ --serve Отдавать локальную страницу по http://127.0.0.1 (для ES-модулей/fetch)
142
+ --cdp <url> CDP-эндпоинт (по умолчанию env CDP_URL или http://127.0.0.1:9333)
143
+ --timeout <ms> Таймаут загрузки страницы (по умолчанию 30000)
144
+ --json Полный JSON в stdout (логи — в stderr)
145
+ -h, --help Эта справка
146
+
147
+ Проверки: title (есть/длина/дубли), description, H1 и порядок заголовков, lang, charset,
148
+ viewport, canonical, robots meta, OG/Twitter, JSON-LD schema.org (валидность и обязательные
149
+ поля для MedicalClinic/MedicalWebPage/Physician/FAQPage/Article/Organization/BreadcrumbList),
150
+ alt и размеры изображений, ленивая загрузка, внутренние/внешние ссылки (пустые анкоры,
151
+ битые same-origin), hreflang, mixed content, http-ссылки, вес HTML/ресурсов, render-blocking,
152
+ LCP/CLS/TTFB через PerformanceObserver, robots.txt и sitemap.xml (для http/https).
153
+
154
+ Коды выхода: 0 — нет critical/serious; 2 — есть critical/serious; 1 — сбой инструмента.
155
+ `;
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // In-page: PerformanceObserver для LCP/CLS (ставится до загрузки страницы)
159
+ // ---------------------------------------------------------------------------
160
+ function PERF_OBSERVER() {
161
+ const store = (window.__seoPerf = { lcp: 0, lcpTime: 0, lcpElement: null, cls: 0, shifts: [], longTasks: 0 });
162
+ try {
163
+ new PerformanceObserver((list) => {
164
+ for (const e of list.getEntries()) {
165
+ const t = e.renderTime || e.loadTime || 0;
166
+ if (t > store.lcp) {
167
+ store.lcp = Math.round(t);
168
+ store.lcpTime = Math.round(t);
169
+ store.lcpElement = e.element
170
+ ? {
171
+ tag: e.element.tagName || '',
172
+ id: e.element.id || '',
173
+ cls:
174
+ e.element.className && typeof e.element.className === 'string'
175
+ ? e.element.className.slice(0, 120)
176
+ : '',
177
+ url: e.url ? String(e.url).slice(0, 200) : '',
178
+ }
179
+ : null;
180
+ }
181
+ }
182
+ }).observe({ type: 'largest-contentful-paint', buffered: true });
183
+ } catch {
184
+ /* not supported */
185
+ }
186
+ try {
187
+ new PerformanceObserver((list) => {
188
+ for (const e of list.getEntries()) {
189
+ if (e.hadRecentInput) continue;
190
+ store.cls = Math.round((store.cls + e.value) * 10000) / 10000;
191
+ if (store.shifts.length < 20) store.shifts.push(Math.round(e.value * 10000) / 10000);
192
+ }
193
+ }).observe({ type: 'layout-shift', buffered: true });
194
+ } catch {
195
+ /* not supported */
196
+ }
197
+ try {
198
+ new PerformanceObserver((list) => {
199
+ store.longTasks += list.getEntries().length;
200
+ }).observe({ type: 'longtask', buffered: true });
201
+ } catch {
202
+ /* not supported */
203
+ }
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // In-page: сбор SEO-данных
208
+ // ---------------------------------------------------------------------------
209
+ function ANALYZE_DOM() {
210
+ const doc = document;
211
+ const q = (sel) => Array.from(doc.querySelectorAll(sel));
212
+ const attr = (el, n) => (el ? el.getAttribute(n) : null);
213
+ const norm = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
214
+ const clean = (s, n = 200) => {
215
+ const t = norm(s);
216
+ return t.length > n ? t.slice(0, n) + '…' : t;
217
+ };
218
+ const abs = (u) => {
219
+ try {
220
+ return new URL(u, location.href).href;
221
+ } catch {
222
+ return u;
223
+ }
224
+ };
225
+
226
+ const metas = q('meta').map((m) => ({
227
+ name: (attr(m, 'name') || '').toLowerCase(),
228
+ property: (attr(m, 'property') || '').toLowerCase(),
229
+ httpEquiv: (attr(m, 'http-equiv') || '').toLowerCase(),
230
+ content: attr(m, 'content') || '',
231
+ }));
232
+ const titleNodes = q('title');
233
+ const descriptions = metas.filter((m) => m.name === 'description');
234
+ const canonicals = q('link[rel~="canonical" i]').map((l) => ({ href: attr(l, 'href'), resolved: l.href }));
235
+ const robotsMetas = metas
236
+ .filter((m) => ['robots', 'googlebot', 'yandex', 'msnbot'].includes(m.name))
237
+ .map((m) => ({ name: m.name, content: m.content }));
238
+ const viewportMetas = metas.filter((m) => m.name === 'viewport');
239
+ const charsetMeta =
240
+ metas.find((m) => m.httpEquiv === 'content-type') || doc.querySelector('meta[charset]')
241
+ ? true
242
+ : false;
243
+
244
+ const og = {};
245
+ for (const m of metas) if (m.property.startsWith('og:') && m.content) og[m.property.slice(3)] = m.content;
246
+ const twitter = {};
247
+ for (const m of metas) if (m.name.startsWith('twitter:') && m.content) twitter[m.name.slice(8)] = m.content;
248
+
249
+ const headings = q('h1,h2,h3,h4,h5,h6').map((h) => ({
250
+ level: Number(h.tagName[1]),
251
+ text: clean(h.textContent, 160),
252
+ id: h.id || null,
253
+ }));
254
+ const h1s = headings.filter((h) => h.level === 1);
255
+
256
+ const imgRect = (img) => img.getBoundingClientRect();
257
+ const imgs = q('img')
258
+ .slice(0, 300)
259
+ .map((img) => {
260
+ const r = imgRect(img);
261
+ const src = (img.currentSrc || img.src || '').slice(0, 400);
262
+ return {
263
+ src,
264
+ alt: attr(img, 'alt'),
265
+ hasAlt: img.hasAttribute('alt'),
266
+ width: attr(img, 'width'),
267
+ height: attr(img, 'height'),
268
+ loading: (attr(img, 'loading') || '').toLowerCase(),
269
+ srcset: !!attr(img, 'srcset'),
270
+ format: src.toLowerCase().split('?')[0].split('.').pop().slice(0, 5),
271
+ top: Math.round(r.top + window.scrollY),
272
+ visible: r.width > 0 && r.height > 0,
273
+ };
274
+ });
275
+
276
+ const anchors = q('a[href]')
277
+ .slice(0, 500)
278
+ .map((a) => {
279
+ const aria = attr(a, 'aria-label');
280
+ const txt = norm(a.textContent || '');
281
+ const imgAlt = (a.querySelector('img[alt]')?.getAttribute('alt') || '').trim();
282
+ const title = (attr(a, 'title') || '').trim();
283
+ return {
284
+ href: attr(a, 'href') || '',
285
+ text: clean(aria || txt || imgAlt || title, 120),
286
+ rel: (attr(a, 'rel') || '').toLowerCase(),
287
+ target: (attr(a, 'target') || '').toLowerCase(),
288
+ hreflang: (attr(a, 'hreflang') || '').toLowerCase(),
289
+ hasText: !!(aria || txt || imgAlt || title),
290
+ };
291
+ });
292
+ const ids = q('[id]')
293
+ .map((el) => el.id)
294
+ .filter(Boolean);
295
+
296
+ const scripts = q('script').map((s) => ({
297
+ src: attr(s, 'src') || '',
298
+ async: s.async === true,
299
+ defer: s.defer === true,
300
+ type: (attr(s, 'type') || '').toLowerCase(),
301
+ inHead: !!s.closest('head'),
302
+ inlineLength: s.src ? 0 : (s.textContent || '').length,
303
+ }));
304
+ const cssLinks = q('link[rel~="stylesheet" i]').map((l) => ({
305
+ href: l.href,
306
+ media: attr(l, 'media') || '',
307
+ inHead: !!l.closest('head'),
308
+ }));
309
+ const preloads = q('link[rel="preload"]').map((l) => ({ as: attr(l, 'as') || '', href: l.href }));
310
+ const jsonLd = q('script[type="application/ld+json"]')
311
+ .slice(0, 20)
312
+ .map((s) => ({ raw: (s.textContent || '').slice(0, 40000) }));
313
+ const hreflangs = q('link[rel~="alternate" i][hreflang]').map((l) => ({
314
+ hreflang: (attr(l, 'hreflang') || '').toLowerCase(),
315
+ href: attr(l, 'href') || '',
316
+ resolved: l.href,
317
+ }));
318
+ const iframes = q('iframe')
319
+ .slice(0, 20)
320
+ .map((f) => ({ src: attr(f, 'src') || '', title: attr(f, 'title') || '' }));
321
+
322
+ let nav = null;
323
+ try {
324
+ const e = performance.getEntriesByType('navigation')[0];
325
+ if (e) {
326
+ nav = {
327
+ ttfb: Math.round(e.responseStart || 0),
328
+ domContentLoaded: Math.round(e.domContentLoadedEventEnd || 0),
329
+ load: Math.round(e.loadEventEnd || 0),
330
+ transferSize: e.transferSize || 0,
331
+ encodedBodySize: e.encodedBodySize || 0,
332
+ redirects: e.redirectCount || 0,
333
+ protocol: e.nextHopProtocol || '',
334
+ };
335
+ }
336
+ } catch {
337
+ /* ignore */
338
+ }
339
+
340
+ let resources = [];
341
+ try {
342
+ resources = performance
343
+ .getEntriesByType('resource')
344
+ .slice(0, 500)
345
+ .map((r) => ({
346
+ name: String(r.name).slice(0, 500),
347
+ type: r.initiatorType || 'other',
348
+ transferSize: r.transferSize || 0,
349
+ encodedBodySize: r.encodedBodySize || 0,
350
+ duration: Math.round(r.duration || 0),
351
+ }));
352
+ } catch {
353
+ /* ignore */
354
+ }
355
+
356
+ const mixed = {
357
+ resources: resources.filter((r) => /^http:\/\//i.test(r.name)).map((r) => r.name.slice(0, 200)),
358
+ images: imgs.filter((i) => /^http:\/\//i.test(i.src)).map((i) => i.src),
359
+ scripts: scripts.filter((s) => s.src && /^http:\/\//i.test(s.src)).map((s) => s.src.slice(0, 200)),
360
+ css: cssLinks.filter((l) => /^http:\/\//i.test(l.href)).map((l) => l.href.slice(0, 200)),
361
+ };
362
+ const httpAnchors = anchors.filter((a) => /^http:\/\//i.test(a.href)).slice(0, 20).map((a) => a.href.slice(0, 200));
363
+
364
+ return {
365
+ url: location.href,
366
+ title: titleNodes.length ? norm(titleNodes[0].textContent) : '',
367
+ titleCount: titleNodes.length,
368
+ description: descriptions.length ? norm(descriptions[0].content) : '',
369
+ descriptionCount: descriptions.length,
370
+ canonical: canonicals.length ? canonicals[0].resolved || abs(canonicals[0].href || '') : '',
371
+ canonicalRaw: canonicals.length ? canonicals[0].href : null,
372
+ canonicalCount: canonicals.length,
373
+ robotsMetas,
374
+ viewportMeta: viewportMetas.length ? viewportMetas[0].content : null,
375
+ viewportCount: viewportMetas.length,
376
+ charsetMeta,
377
+ charset: (doc.characterSet || '').toLowerCase(),
378
+ lang: doc.documentElement.getAttribute('lang'),
379
+ og,
380
+ twitter,
381
+ headings,
382
+ h1Count: h1s.length,
383
+ h1Texts: h1s.map((h) => h.text),
384
+ images: imgs,
385
+ links: anchors,
386
+ ids,
387
+ scripts,
388
+ cssLinks,
389
+ preloads,
390
+ jsonLd,
391
+ hreflangs,
392
+ iframes,
393
+ nav,
394
+ resources,
395
+ perf: window.__seoPerf || { lcp: 0, cls: 0, longTasks: 0, shifts: [], lcpElement: null },
396
+ domElements: doc.getElementsByTagName('*').length,
397
+ mixed,
398
+ httpAnchors,
399
+ metaRobotsFromHttpEquiv: metas.filter((m) => m.httpEquiv === 'x-robots-tag').map((m) => m.content),
400
+ };
401
+ }
402
+
403
+ // ===========================================================================
404
+ // main
405
+ // ===========================================================================
406
+
407
+ async function main() {
408
+ const startedAt = new Date();
409
+ const t0 = Date.now();
410
+ const args = parseArgs(process.argv.slice(2), {
411
+ booleans: ['help', 'json', 'serve', 'links', 'robots', 'quiet'],
412
+ values: ['out', 'name', 'max-links', 'cdp', 'timeout', 'viewports'],
413
+ aliases: { h: 'help' },
414
+ defaults: {
415
+ help: false,
416
+ json: false,
417
+ serve: false,
418
+ links: true,
419
+ robots: true,
420
+ quiet: false,
421
+ out: undefined,
422
+ name: undefined,
423
+ maxLinks: '80',
424
+ timeout: '30000',
425
+ viewports: 'desktop',
426
+ cdp: undefined,
427
+ },
428
+ });
429
+ if (args.help) printHelpAndExit(HELP);
430
+ const log = args.json ? (...a) => console.error(...a) : (...a) => console.log(...a);
431
+
432
+ const target = args._[0];
433
+ if (!target) throw new UsageError('Не указан путь к HTML-файлу или URL (см. --help)');
434
+
435
+ const viewports = parseViewportList(args.viewports);
436
+ const maxLinks = Math.max(0, Math.min(400, Number(args.maxLinks) || 80));
437
+ const timeout = Math.max(1000, Number(args.timeout) || 30000);
438
+ const resolved = resolveTarget(String(target), { serve: args.serve });
439
+ const name = safeFileName(args.name || defaultName(resolved), 'page');
440
+ const outDir = path.resolve(process.cwd(), args.out || DEFAULT_OUT);
441
+ fs.mkdirSync(outDir, { recursive: true });
442
+ const files = args.name
443
+ ? { json: path.join(outDir, `${name}-seo-audit.json`), md: path.join(outDir, `${name}-seo-audit.md`) }
444
+ : { json: path.join(outDir, 'seo-audit.json'), md: path.join(outDir, 'seo-audit.md') };
445
+
446
+ const report = {
447
+ tool: 'design-qa/seo-audit',
448
+ schemaVersion: 1,
449
+ name,
450
+ source: resolved.source,
451
+ url: resolved.url,
452
+ kind: resolved.kind,
453
+ startedAt: startedAt.toISOString(),
454
+ finishedAt: null,
455
+ durationMs: null,
456
+ browser: null,
457
+ options: {
458
+ viewports: viewports.map((v) => ({ id: v.id, width: v.width, height: v.height })),
459
+ maxLinks,
460
+ checkLinks: Boolean(args.links),
461
+ checkRobots: Boolean(args.robots),
462
+ serve: Boolean(args.serve),
463
+ },
464
+ pages: [],
465
+ meta: null,
466
+ headings: null,
467
+ links: null,
468
+ images: null,
469
+ schema: null,
470
+ hreflang: null,
471
+ performance: null,
472
+ robots: null,
473
+ sitemap: null,
474
+ redirects: null,
475
+ http: null,
476
+ issues: [],
477
+ summary: null,
478
+ fatalError: null,
479
+ exitCode: 0,
480
+ };
481
+
482
+ let conn = null;
483
+ let server = null;
484
+ let fatal = null;
485
+
486
+ log(`[seo] ${resolved.source}`);
487
+ log(`[seo] viewports: ${viewports.map((v) => `${v.id} ${v.width}x${v.height}`).join(', ')}; max-links: ${maxLinks}`);
488
+
489
+ try {
490
+ conn = await connectChrome({ cdpUrl: args.cdp || undefined, log });
491
+ report.browser = {
492
+ endpoint: conn.endpoint,
493
+ launched: conn.launched,
494
+ browser: conn.version?.Browser || null,
495
+ };
496
+ log(`[seo] браузер: ${report.browser.browser || '?'} (${conn.launched ? 'fallback' : 'CDP ' + conn.endpoint})`);
497
+
498
+ if (args.serve) {
499
+ if (!resolved.abs) throw new UsageError('--serve поддерживается только для локальных html-файлов');
500
+ server = await startStaticServer(process.cwd());
501
+ const rel = path.relative(process.cwd(), resolved.abs).split(path.sep).join('/');
502
+ resolved.url = `${server.origin}/${rel}`;
503
+ report.url = resolved.url;
504
+ log(`[seo] локальный сервер: ${server.origin}`);
505
+ }
506
+
507
+ // ---- 1. Загрузка и сбор данных по viewport ----
508
+ for (const vp of viewports) {
509
+ log(`[seo] ${vp.id}: загрузка...`);
510
+ const handle = await openPage(conn.browser, {
511
+ viewport: vp,
512
+ dpr: 1,
513
+ theme: 'light',
514
+ reducedMotion: 'no-preference',
515
+ });
516
+ const page = handle.page;
517
+ let pageData = null;
518
+ try {
519
+ await page.addInitScript(PERF_OBSERVER);
520
+ let response = null;
521
+ try {
522
+ response = await page.goto(resolved.url, { waitUntil: 'load', timeout });
523
+ } catch (e) {
524
+ throw new Error(`Не удалось загрузить страницу: ${e.message}`);
525
+ }
526
+ await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
527
+ await waitForFonts(page);
528
+ await new Promise((r) => setTimeout(r, 1500)); // LCP финализируется после load
529
+ const data = await page.evaluate(ANALYZE_DOM);
530
+ let htmlBytes = null;
531
+ let headers = {};
532
+ if (response) {
533
+ headers = response.headers();
534
+ try {
535
+ htmlBytes = (await response.body()).length;
536
+ } catch {
537
+ /* ignore */
538
+ }
539
+ }
540
+ if (htmlBytes == null && resolved.abs) {
541
+ try {
542
+ htmlBytes = fs.statSync(resolved.abs).size;
543
+ } catch {
544
+ /* ignore */
545
+ }
546
+ }
547
+ const status = response ? response.status() : null;
548
+ pageData = {
549
+ viewport: vp.id,
550
+ width: vp.width,
551
+ height: vp.height,
552
+ finalUrl: page.url(),
553
+ status,
554
+ headers,
555
+ htmlBytes,
556
+ ...data,
557
+ };
558
+ report.pages.push(pageData);
559
+ log(
560
+ `[seo] ${vp.id}: status=${status ?? 'file'}, title=${JSON.stringify(pageData.title.slice(0, 60))}, ` +
561
+ `h1=${pageData.h1Count}, LCP=${pageData.perf?.lcp || '?'}ms, CLS=${pageData.perf?.cls ?? '?'}`,
562
+ );
563
+ } finally {
564
+ await closePage(handle);
565
+ }
566
+ }
567
+
568
+ // ---- 2. robots.txt / sitemap.xml (только http/https) ----
569
+ const first = report.pages[0];
570
+ const isHttp = /^https?:/i.test(first.finalUrl);
571
+ if (isHttp && args.robots) {
572
+ log('[seo] robots.txt / sitemap.xml...');
573
+ const context = null; // запросы делаем через отдельный контекст Playwright ниже
574
+ report.robots = await fetchRobots(conn, first.finalUrl, log);
575
+ report.sitemap = await fetchSitemap(conn, first.finalUrl, report.robots, log);
576
+ }
577
+
578
+ // ---- 3. Битые same-origin ссылки ----
579
+ if (args.links) {
580
+ log(`[seo] проверка ссылок (max ${maxLinks})...`);
581
+ report.links = await checkBrokenLinks(conn, first, { maxLinks, log });
582
+ log(`[seo] ссылки: проверено ${report.links.checked}, битых ${report.links.broken.length}`);
583
+ } else {
584
+ report.links = { checked: 0, broken: [], internal: countLinks(first).internal, external: countLinks(first).external, skipped: true };
585
+ }
586
+
587
+ // ---- 4. Агрегированные сущности и проверки ----
588
+ report.meta = buildMeta(first, report.pages);
589
+ report.headings = buildHeadings(first);
590
+ report.images = buildImages(first);
591
+ report.schema = validateSchema(first);
592
+ report.hreflang = buildHreflang(first);
593
+ report.performance = buildPerformance(report.pages);
594
+ report.redirects = buildRedirects(report.pages);
595
+ report.http = buildHttp(first);
596
+
597
+ const issues = [];
598
+ const add = (id, severity, category, help, extra = {}) =>
599
+ issues.push({ id, severity, category, help: help || FIX[id] || '', fix: FIX[id] || '', ...extra });
600
+
601
+ checkMetaTags(first, add);
602
+ checkHeadings(first, add);
603
+ checkCanonical(first, report.pages, add);
604
+ checkRobotsMeta(first, report.pages, add);
605
+ checkViewport(first, add);
606
+ checkOpenGraph(first, add);
607
+ checkSchema(report.schema, first, add);
608
+ checkImages(report.images, first, add);
609
+ checkLinks(report.links, first, add);
610
+ checkHreflang(report.hreflang, first, add);
611
+ checkHttp(report.http, first, add);
612
+ checkMixedContent(first, add);
613
+ checkPerformance(report.performance, add);
614
+ checkResources(first, report.performance, add);
615
+ checkRobots(conn, report.robots, report.sitemap, report.pages, add);
616
+ checkStatusAndRedirects(first, report.redirects, add);
617
+
618
+ report.issues = dedupeIssues(issues).sort(compareIssues);
619
+ const counts = countSeverities(report.issues);
620
+ report.summary = {
621
+ counts,
622
+ verdict: counts.critical + counts.serious > 0 ? 'FAIL' : 'PASS',
623
+ exitCode: counts.critical + counts.serious > 0 ? 2 : 0,
624
+ issuesTotal: report.issues.length,
625
+ metrics: {
626
+ htmlBytes: first.htmlBytes,
627
+ requestCount: first.resources.length,
628
+ transferBytes: report.performance.transferBytes,
629
+ lcp: report.performance.lcp,
630
+ cls: report.performance.cls,
631
+ ttfb: report.performance.ttfb,
632
+ domElements: first.domElements,
633
+ },
634
+ };
635
+ report.exitCode = report.summary.exitCode;
636
+ } catch (err) {
637
+ fatal = err;
638
+ report.fatalError = `${err?.name || 'Error'}: ${err?.message || err}`;
639
+ report.exitCode = 1;
640
+ log(`[seo] ОШИБКА: ${report.fatalError}`);
641
+ } finally {
642
+ if (server) await server.close().catch(() => {});
643
+ if (conn) await disposeChrome(conn);
644
+ }
645
+
646
+ if (!report.pages.length && fatal) {
647
+ // нечего агрегировать — пишем отчёт с ошибкой
648
+ report.summary = { counts: { critical: 0, serious: 0, moderate: 0, minor: 0 }, verdict: 'ERROR', exitCode: 1, issuesTotal: 0 };
649
+ }
650
+
651
+ report.finishedAt = new Date().toISOString();
652
+ report.durationMs = Date.now() - t0;
653
+
654
+ const json = JSON.stringify(report, null, 2);
655
+ fs.writeFileSync(files.json, json, 'utf8');
656
+ fs.writeFileSync(files.md, renderMarkdown(report), 'utf8');
657
+
658
+ log('');
659
+ if (report.fatalError) {
660
+ log(`[seo] вердикт: ОШИБКА ИНСТРУМЕНТА — ${report.fatalError}`);
661
+ } else {
662
+ const c = report.summary.counts;
663
+ log(
664
+ `[seo] вердикт: ${report.summary.verdict}; critical=${c.critical} serious=${c.serious} ` +
665
+ `moderate=${c.moderate} minor=${c.minor}; LCP=${report.performance?.lcp ?? '?'}ms, CLS=${report.performance?.cls ?? '?'}`,
666
+ );
667
+ const top = report.issues.slice(0, 10);
668
+ if (top.length) {
669
+ log('[seo] что чинить в первую очередь:');
670
+ top.forEach((i, n) => log(` ${n + 1}. [${i.severity}] ${i.id} — ${shorten(i.help, 130)}`));
671
+ }
672
+ }
673
+ log(`[seo] отчёт: ${files.json}`);
674
+ log(`[seo] сводка: ${files.md}`);
675
+
676
+ if (args.json) process.stdout.write(json + '\n');
677
+ process.exitCode = report.exitCode;
678
+ }
679
+
680
+ // ===========================================================================
681
+ // Сбор сущностей
682
+ // ===========================================================================
683
+
684
+ function defaultName(resolved) {
685
+ if (resolved.abs) return path.basename(resolved.abs, path.extname(resolved.abs));
686
+ try {
687
+ const u = new URL(resolved.url);
688
+ const last = u.pathname.split('/').filter(Boolean).pop() || 'page';
689
+ return path.basename(last, path.extname(last));
690
+ } catch {
691
+ return 'page';
692
+ }
693
+ }
694
+
695
+ function countLinks(pageData) {
696
+ const base = new URL(pageData.finalUrl);
697
+ let internal = 0;
698
+ let external = 0;
699
+ for (const a of pageData.links) {
700
+ if (!a.href || /^(#|mailto:|tel:|javascript:|data:)/i.test(a.href)) continue;
701
+ try {
702
+ const u = new URL(a.href, pageData.finalUrl);
703
+ if (u.origin === base.origin || u.protocol === 'file:') internal++;
704
+ else external++;
705
+ } catch {
706
+ /* ignore */
707
+ }
708
+ }
709
+ return { internal, external };
710
+ }
711
+
712
+ function buildMeta(pageData, pages) {
713
+ return {
714
+ title: pageData.title,
715
+ titleLength: pageData.title.length,
716
+ titleCount: pageData.titleCount,
717
+ description: pageData.description,
718
+ descriptionLength: pageData.description.length,
719
+ descriptionCount: pageData.descriptionCount,
720
+ canonical: pageData.canonical,
721
+ canonicalRaw: pageData.canonicalRaw,
722
+ canonicalCount: pageData.canonicalCount,
723
+ lang: pageData.lang,
724
+ charset: pageData.charset,
725
+ charsetMeta: pageData.charsetMeta,
726
+ viewport: pageData.viewportMeta,
727
+ robotsMetas: pageData.robotsMetas,
728
+ og: pageData.og,
729
+ twitter: pageData.twitter,
730
+ finalUrl: pageData.finalUrl,
731
+ status: pageData.status,
732
+ htmlBytes: pageData.htmlBytes,
733
+ contentType: pageData.headers?.['content-type'] || null,
734
+ xRobotsTag: pageData.headers?.['x-robots-tag'] || null,
735
+ contentEncoding: pageData.headers?.['content-encoding'] || null,
736
+ viewportsChecked: pages.map((p) => p.viewport),
737
+ };
738
+ }
739
+
740
+ function buildHeadings(pageData) {
741
+ const headings = pageData.headings;
742
+ const skips = [];
743
+ for (let i = 1; i < headings.length; i++) {
744
+ if (headings[i].level - headings[i - 1].level > 1) {
745
+ skips.push({ from: headings[i - 1], to: headings[i] });
746
+ }
747
+ }
748
+ return {
749
+ count: headings.length,
750
+ h1Count: pageData.h1Count,
751
+ h1Texts: pageData.h1Texts,
752
+ order: headings.map((h) => `h${h.level}`),
753
+ skips,
754
+ headings: headings.slice(0, 100),
755
+ };
756
+ }
757
+
758
+ function buildImages(pageData) {
759
+ const images = pageData.images || [];
760
+ const missingAlt = images.filter((i) => !i.hasAlt && i.visible);
761
+ const missingDimensions = images.filter((i) => i.visible && (!i.width || !i.height) && !i.srcset);
762
+ const belowFold = images.filter((i) => i.top > 1200);
763
+ const lazyMissing = belowFold.filter((i) => i.loading !== 'lazy');
764
+ const largeImages = images.filter((i) => i.visible && ['png', 'jpg', 'jpeg', 'gif'].includes(i.format));
765
+ return {
766
+ total: images.length,
767
+ visible: images.filter((i) => i.visible).length,
768
+ missingAlt: missingAlt.length,
769
+ missingAltList: missingAlt.slice(0, 20).map((i) => ({ src: i.src, top: i.top })),
770
+ missingDimensions: missingDimensions.length,
771
+ missingDimensionsList: missingDimensions.slice(0, 20).map((i) => ({ src: i.src })),
772
+ belowFold: belowFold.length,
773
+ lazyMissing: lazyMissing.length,
774
+ lazyMissingList: lazyMissing.slice(0, 20).map((i) => ({ src: i.src })),
775
+ legacyFormats: largeImages.length,
776
+ formats: [...new Set(images.map((i) => i.format).filter(Boolean))].slice(0, 10),
777
+ };
778
+ }
779
+
780
+ function validateSchema(pageData) {
781
+ const scripts = pageData.jsonLd || [];
782
+ const result = {
783
+ found: scripts.length > 0,
784
+ scripts: scripts.length,
785
+ nodes: [],
786
+ parseErrors: [],
787
+ contextErrors: [],
788
+ typeErrors: [],
789
+ missingFields: [],
790
+ faqIssues: [],
791
+ breadcrumbIssues: [],
792
+ types: [],
793
+ };
794
+ const rules = {
795
+ Organization: { required: ['name', 'url'], recommended: ['logo', 'sameAs'] },
796
+ MedicalOrganization: { required: ['name', 'address'], recommended: ['telephone', 'url'] },
797
+ MedicalClinic: { required: ['name', 'address'], recommended: ['telephone', 'url', 'openingHours'] },
798
+ Hospital: { required: ['name', 'address'], recommended: ['telephone', 'url'] },
799
+ LocalBusiness: { required: ['name', 'address'], recommended: ['telephone', 'url'] },
800
+ MedicalWebPage: { required: ['name'], recommended: ['url', 'description', 'lastReviewed'] },
801
+ WebPage: { required: ['name'], recommended: ['url'] },
802
+ WebSite: { required: ['name', 'url'], recommended: ['potentialAction'] },
803
+ Physician: { required: ['name'], recommended: ['medicalSpecialty', 'jobTitle', 'url'] },
804
+ Person: { required: ['name'], recommended: [] },
805
+ FAQPage: { required: ['mainEntity'], recommended: [] },
806
+ Article: { required: ['headline', 'author', 'datePublished'], recommended: ['image', 'publisher'] },
807
+ NewsArticle: { required: ['headline', 'author', 'datePublished'], recommended: ['image', 'publisher'] },
808
+ BlogPosting: { required: ['headline', 'author', 'datePublished'], recommended: ['image'] },
809
+ BreadcrumbList: { required: ['itemListElement'], recommended: [] },
810
+ ItemList: { required: ['itemListElement'], recommended: [] },
811
+ Product: { required: ['name'], recommended: ['offers', 'image'] },
812
+ };
813
+ const flatten = (obj) => {
814
+ if (!obj || typeof obj !== 'object') return [];
815
+ if (Array.isArray(obj)) return obj.flatMap(flatten);
816
+ if (Array.isArray(obj['@graph'])) return obj['@graph'].flatMap(flatten);
817
+ return [obj];
818
+ };
819
+ const hasValue = (v) => (Array.isArray(v) ? v.length > 0 : v !== undefined && v !== null && String(v).trim() !== '');
820
+
821
+ for (let si = 0; si < scripts.length; si++) {
822
+ let parsed;
823
+ try {
824
+ parsed = JSON.parse(scripts[si].raw);
825
+ } catch (e) {
826
+ result.parseErrors.push({ script: si + 1, message: e.message, raw: scripts[si].raw.slice(0, 200) });
827
+ continue;
828
+ }
829
+ for (const node of flatten(parsed)) {
830
+ if (!node || typeof node !== 'object') continue;
831
+ const ctx = node['@context'];
832
+ if (!ctx || !String(Array.isArray(ctx) ? ctx.join(' ') : ctx).includes('schema.org')) {
833
+ result.contextErrors.push({ script: si + 1, type: node['@type'] || null });
834
+ }
835
+ const types = Array.isArray(node['@type']) ? node['@type'] : node['@type'] ? [node['@type']] : [];
836
+ if (!types.length) {
837
+ result.typeErrors.push({ script: si + 1 });
838
+ continue;
839
+ }
840
+ for (const type of types) result.types.push(type);
841
+ for (const type of types) {
842
+ const rule = rules[type];
843
+ if (!rule) continue;
844
+ for (const field of rule.required) {
845
+ if (!hasValue(node[field])) {
846
+ result.missingFields.push({ script: si + 1, type, field, required: true, help: FIX['schema-required'] });
847
+ }
848
+ }
849
+ if (type === 'FAQPage') {
850
+ const items = Array.isArray(node.mainEntity) ? node.mainEntity : node.mainEntity ? [node.mainEntity] : [];
851
+ if (!items.length) result.faqIssues.push({ script: si + 1, message: 'mainEntity пустой' });
852
+ for (const q of items.slice(0, 20)) {
853
+ const qType = Array.isArray(q['@type']) ? q['@type'][0] : q['@type'];
854
+ if (!hasValue(q.name)) result.faqIssues.push({ script: si + 1, message: `вопрос без name (${qType || '?'})` });
855
+ const ans = q.acceptedAnswer;
856
+ if (!ans || !hasValue(ans.text)) {
857
+ result.faqIssues.push({ script: si + 1, message: `вопрос «${String(q.name || '?').slice(0, 60)}» без acceptedAnswer.text` });
858
+ }
859
+ }
860
+ }
861
+ if (type === 'BreadcrumbList') {
862
+ const items = Array.isArray(node.itemListElement)
863
+ ? node.itemListElement
864
+ : node.itemListElement
865
+ ? [node.itemListElement]
866
+ : [];
867
+ if (!items.length) result.breadcrumbIssues.push({ script: si + 1, message: 'itemListElement пустой' });
868
+ items.slice(0, 20).forEach((it, idx) => {
869
+ if (!hasValue(it.position)) result.breadcrumbIssues.push({ script: si + 1, message: `ListItem #${idx + 1} без position` });
870
+ if (!hasValue(it.name) && !hasValue(it.item)) result.breadcrumbIssues.push({ script: si + 1, message: `ListItem #${idx + 1} без name/item` });
871
+ });
872
+ }
873
+ }
874
+ result.nodes.push({
875
+ script: si + 1,
876
+ types,
877
+ requiredFields: types.flatMap((t) => rules[t]?.required || []),
878
+ recommendedFields: types.flatMap((t) => rules[t]?.recommended || []),
879
+ name: node.name || node.headline || null,
880
+ url: node.url || node['@id'] || null,
881
+ });
882
+ if (result.nodes.length >= 50) break;
883
+ }
884
+ }
885
+ return result;
886
+ }
887
+
888
+ function buildHreflang(pageData) {
889
+ const entries = pageData.hreflangs || [];
890
+ const problems = [];
891
+ const validRe = /^(x-default|[a-z]{2,3}(-[A-Za-z]{2,4})?)$/;
892
+ const seen = new Map();
893
+ for (const e of entries) {
894
+ if (!validRe.test(e.hreflang)) problems.push({ hreflang: e.hreflang, issue: 'invalid-format' });
895
+ if (!/^https?:/i.test(e.href) && !/^\/|^[^:/]+$/.test(e.href)) problems.push({ hreflang: e.hreflang, issue: 'relative' });
896
+ if (seen.has(e.hreflang)) problems.push({ hreflang: e.hreflang, issue: 'duplicate' });
897
+ seen.set(e.hreflang, e);
898
+ }
899
+ return {
900
+ count: entries.length,
901
+ entries: entries.slice(0, 50),
902
+ problems,
903
+ hasXDefault: entries.some((e) => e.hreflang === 'x-default'),
904
+ htmlLang: pageData.lang,
905
+ };
906
+ }
907
+
908
+ function buildPerformance(pages) {
909
+ let transferBytes = 0;
910
+ let requestCount = 0;
911
+ const resourceTypes = {};
912
+ const topResources = [];
913
+ for (const p of pages) {
914
+ for (const r of p.resources || []) {
915
+ requestCount++;
916
+ resourceTypes[r.type] = (resourceTypes[r.type] || 0) + 1;
917
+ const size = r.transferSize || r.encodedBodySize || 0;
918
+ transferBytes += size;
919
+ topResources.push({ name: r.name, size, type: r.type });
920
+ }
921
+ }
922
+ // file:// — transferSize часто 0; считаем размеры файлов напрямую
923
+ for (const p of pages) {
924
+ if (!/^file:/i.test(p.finalUrl)) continue;
925
+ for (const r of p.resources || []) {
926
+ if ((r.transferSize || r.encodedBodySize || 0) !== 0) continue;
927
+ try {
928
+ const fsPath = fileURLToPath(new URL(r.name));
929
+ const size = fs.statSync(fsPath).size;
930
+ transferBytes += size;
931
+ const existing = topResources.find((x) => x.name === r.name);
932
+ if (existing) existing.size = size;
933
+ } catch {
934
+ /* ignore */
935
+ }
936
+ }
937
+ }
938
+ topResources.sort((a, b) => b.size - a.size);
939
+ const lcp = Math.max(...pages.map((p) => p.perf?.lcp || 0));
940
+ const cls = Math.max(...pages.map((p) => p.perf?.cls || 0));
941
+ const lcpPage = pages.find((p) => (p.perf?.lcp || 0) === lcp) || pages[0];
942
+ const ttfb = Math.max(...pages.map((p) => p.nav?.ttfb || 0));
943
+ const longTasks = pages.reduce((s, p) => s + (p.perf?.longTasks || 0), 0);
944
+ const domElements = Math.max(...pages.map((p) => p.domElements || 0));
945
+ return {
946
+ lcp,
947
+ lcpViewport: lcpPage.viewport,
948
+ lcpElement: lcpPage.perf?.lcpElement || null,
949
+ cls,
950
+ clsViewport: pages.find((p) => (p.perf?.cls || 0) === cls)?.viewport || pages[0]?.viewport,
951
+ shifts: pages.map((p) => ({ viewport: p.viewport, shifts: p.perf?.shifts || [] })),
952
+ ttfb,
953
+ longTasks,
954
+ domElements,
955
+ requestCount,
956
+ transferBytes,
957
+ resourceTypes,
958
+ topResources: topResources.slice(0, 10),
959
+ nav: pages.map((p) => ({ viewport: p.viewport, ...(p.nav || {}) })),
960
+ htmlBytes: pages[0]?.htmlBytes || null,
961
+ };
962
+ }
963
+
964
+ function buildRedirects(pages) {
965
+ const nav = pages[0]?.nav;
966
+ return {
967
+ count: nav?.redirects || 0,
968
+ finalUrl: pages[0]?.finalUrl,
969
+ status: pages[0]?.status,
970
+ };
971
+ }
972
+
973
+ function buildHttp(pageData) {
974
+ return {
975
+ status: pageData.status,
976
+ finalUrl: pageData.finalUrl,
977
+ contentType: pageData.headers?.['content-type'] || null,
978
+ contentEncoding: pageData.headers?.['content-encoding'] || null,
979
+ xRobotsTag: pageData.headers?.['x-robots-tag'] || null,
980
+ cacheControl: pageData.headers?.['cache-control'] || null,
981
+ server: pageData.headers?.['server'] || null,
982
+ httpAnchors: pageData.httpAnchors || [],
983
+ };
984
+ }
985
+
986
+ // ===========================================================================
987
+ // Проверки → issues
988
+ // ===========================================================================
989
+
990
+ function checkMetaTags(p, add) {
991
+ if (!p.title) add('title-missing', 'critical', 'meta', 'Нет <title> или он пустой', { nodes: [] });
992
+ else if (p.title.length < T.titleMin || p.title.length > T.titleMax) {
993
+ add('title-length', 'moderate', 'meta', `Длина title ${p.title.length} симв. (рекомендуется ${T.titleMin}–${T.titleMax})`, {
994
+ value: p.title,
995
+ });
996
+ }
997
+ if (p.titleCount > 1) add('title-duplicate', 'serious', 'meta', `Тегов <title> на странице: ${p.titleCount}`);
998
+
999
+ if (!p.description) add('description-missing', 'serious', 'meta', 'Нет meta description');
1000
+ else if (p.description.length < T.descMin || p.description.length > T.descMax) {
1001
+ add('description-length', 'moderate', 'meta', `Длина description ${p.description.length} симв. (рекомендуется ${T.descMin}–${T.descMax})`, {
1002
+ value: p.description,
1003
+ });
1004
+ }
1005
+ if (p.descriptionCount > 1) add('description-duplicate', 'serious', 'meta', `Meta description на странице: ${p.descriptionCount}`);
1006
+
1007
+ if (!p.lang) add('lang-missing', 'serious', 'meta', 'У <html> нет атрибута lang');
1008
+ else if (!/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(p.lang)) add('lang-invalid', 'minor', 'meta', `Некорректный lang="${p.lang}"`);
1009
+
1010
+ if (!p.charsetMeta && !String(p.charset).includes('utf-8')) {
1011
+ add('charset-missing', 'serious', 'meta', 'Нет meta charset и кодировка не UTF-8');
1012
+ }
1013
+ }
1014
+
1015
+ function checkHeadings(p, add) {
1016
+ if (p.h1Count === 0) add('h1-missing', 'serious', 'headings', 'На странице нет <h1>');
1017
+ else if (p.h1Count > 1) add('h1-multiple', 'serious', 'headings', `H1 на странице: ${p.h1Count}`, { nodes: p.h1Texts });
1018
+ if (p.h1Count === 1 && !String(p.h1Texts[0] || '').trim()) add('h1-empty', 'serious', 'headings', 'Пустой <h1>');
1019
+ const skips = [];
1020
+ for (let i = 1; i < p.headings.length; i++) {
1021
+ if (p.headings[i].level - p.headings[i - 1].level > 1) skips.push(`h${p.headings[i - 1].level} → h${p.headings[i].level}`);
1022
+ }
1023
+ if (skips.length) {
1024
+ add('heading-skip', 'moderate', 'headings', `Пропуск уровней заголовков: ${skips.slice(0, 6).join(', ')}`, { nodes: skips.slice(0, 10) });
1025
+ }
1026
+ if (p.headings.length === 0) add('h1-missing', 'serious', 'headings', 'Заголовков H1–H6 нет вообще');
1027
+ }
1028
+
1029
+ function checkCanonical(p, pages, add) {
1030
+ if (p.canonicalCount === 0) {
1031
+ add('canonical-missing', 'serious', 'indexing', 'Нет <link rel="canonical">');
1032
+ return;
1033
+ }
1034
+ if (p.canonicalCount > 1) add('canonical-multiple', 'critical', 'indexing', `Canonical-тегов: ${p.canonicalCount}`);
1035
+ if (!p.canonicalRaw) return;
1036
+ const raw = String(p.canonicalRaw);
1037
+ if (!/^(https?:)?\/\//i.test(raw) && !/^https?:/i.test(p.canonical)) {
1038
+ add('canonical-relative', 'serious', 'indexing', `Canonical относительный: ${raw}`);
1039
+ return;
1040
+ }
1041
+ const final = pages[0]?.finalUrl || p.finalUrl;
1042
+ try {
1043
+ const c = new URL(p.canonical);
1044
+ const f = new URL(final);
1045
+ const norm = (u) => `${u.origin}${u.pathname}`.replace(/\/+$/, '');
1046
+ if (c.origin !== f.origin || norm(c) !== norm(f)) {
1047
+ add('canonical-not-self', 'serious', 'indexing', `Canonical указывает не на себя: ${c.href} ≠ ${f.href}`, {
1048
+ canonical: c.href,
1049
+ page: f.href,
1050
+ });
1051
+ }
1052
+ } catch {
1053
+ add('canonical-relative', 'serious', 'indexing', `Canonical не является абсолютным URL: ${p.canonical}`);
1054
+ }
1055
+ }
1056
+
1057
+ function checkRobotsMeta(p, pages, add) {
1058
+ const all = [
1059
+ ...(p.robotsMetas || []).map((r) => ({ name: r.name, content: String(r.content || '').toLowerCase() })),
1060
+ ];
1061
+ const x = String(p.headers?.['x-robots-tag'] || '').toLowerCase();
1062
+ if (x) all.push({ name: 'x-robots-tag', content: x });
1063
+ const noindex = all.find((r) => r.content.includes('noindex'));
1064
+ if (noindex) {
1065
+ add('robots-noindex', 'critical', 'indexing', `Страница закрыта от индексации (${noindex.name}: noindex)`);
1066
+ }
1067
+ const nofollow = all.find((r) => r.content.includes('nofollow'));
1068
+ if (nofollow) add('robots-nofollow', 'moderate', 'indexing', `nofollow в ${nofollow.name}`);
1069
+ if (!p.robotsMetas?.length) {
1070
+ // отсутствие meta robots — норма; отметим только как info в отчёте
1071
+ }
1072
+ }
1073
+
1074
+ function checkViewport(p, add) {
1075
+ if (!p.viewportMeta) {
1076
+ add('viewport-missing', 'serious', 'meta', 'Нет meta viewport');
1077
+ return;
1078
+ }
1079
+ const v = String(p.viewportMeta).toLowerCase().replace(/\s+/g, '');
1080
+ if (!v.includes('width=device-width')) add('viewport-width', 'serious', 'meta', `Viewport без width=device-width: ${p.viewportMeta}`);
1081
+ if (v.includes('user-scalable=no') || /maximum-scale=(\d(\.\d+)?)/.test(v) && parseFloat(/maximum-scale=([\d.]+)/.exec(v)?.[1] || '5') < 5) {
1082
+ add('viewport-zoom', 'moderate', 'meta', 'Viewport блокирует масштабирование');
1083
+ }
1084
+ }
1085
+
1086
+ function checkOpenGraph(p, add) {
1087
+ const og = p.og || {};
1088
+ const required = ['title', 'description', 'image', 'url'];
1089
+ const missing = required.filter((k) => !og[k]);
1090
+ if (!Object.keys(og).length) {
1091
+ add('og-missing', 'moderate', 'social', 'Open Graph тегов нет (og:title/description/image/url)');
1092
+ } else if (missing.length) {
1093
+ add('og-missing', 'moderate', 'social', `Не хватает Open Graph: ${missing.map((k) => 'og:' + k).join(', ')}`, { missing });
1094
+ }
1095
+ if (og.image && !/^https?:\/\//i.test(og.image)) add('og-image-relative', 'moderate', 'social', `og:image не абсолютный: ${og.image}`);
1096
+ if (og.image && /\.svg($|\?)/i.test(og.image)) add('og-image-format', 'moderate', 'social', 'og:image в SVG — соцсети (VK/Telegram/OK) не отображают SVG, нужен JPG/PNG 1200×630');
1097
+ const tw = p.twitter || {};
1098
+ if (!tw.card) add('twitter-missing', 'minor', 'social', 'Нет twitter:card (Twitter/X карточка)');
1099
+ }
1100
+
1101
+ function checkSchema(schema, pageData, add) {
1102
+ if (!schema.found) {
1103
+ add('schema-missing', 'moderate', 'schema', 'Нет JSON-LD schema.org');
1104
+ return;
1105
+ }
1106
+ for (const e of schema.parseErrors) {
1107
+ add('schema-json-invalid', 'critical', 'schema', `Битый JSON-LD (script #${e.script}): ${e.message}`, { nodes: [e.raw] });
1108
+ }
1109
+ if (schema.contextErrors.length) {
1110
+ add('schema-context', 'serious', 'schema', `Нет/неверный @context schema.org (${schema.contextErrors.length} узлов)`, {
1111
+ nodes: schema.contextErrors.slice(0, 5),
1112
+ });
1113
+ }
1114
+ if (schema.typeErrors.length) {
1115
+ add('schema-type', 'serious', 'schema', `Нет @type у ${schema.typeErrors.length} JSON-LD-узлов`);
1116
+ }
1117
+ const missing = schema.missingFields || [];
1118
+ const criticalTypes = new Set(['MedicalClinic', 'MedicalOrganization', 'Hospital', 'LocalBusiness', 'Article', 'NewsArticle', 'Organization', 'WebSite', 'BreadcrumbList']);
1119
+ const seriousMissing = missing.filter((m) => criticalTypes.has(m.type) && m.required);
1120
+ if (seriousMissing.length) {
1121
+ add('schema-required', 'serious', 'schema', `Не хватает обязательных полей schema.org: ${seriousMissing.slice(0, 8).map((m) => `${m.type}.${m.field}`).join(', ')}`, {
1122
+ nodes: seriousMissing.slice(0, 10),
1123
+ });
1124
+ } else if (missing.length) {
1125
+ add('schema-required', 'moderate', 'schema', `Не хватает полей schema.org: ${missing.slice(0, 8).map((m) => `${m.type}.${m.field}`).join(', ')}`);
1126
+ }
1127
+ if (schema.faqIssues.length) add('schema-faq', 'serious', 'schema', `Проблемы FAQPage: ${schema.faqIssues.slice(0, 4).map((x) => x.message).join('; ')}`);
1128
+ if (schema.breadcrumbIssues.length) add('schema-breadcrumb', 'moderate', 'schema', `Проблемы BreadcrumbList: ${schema.breadcrumbIssues.slice(0, 4).map((x) => x.message).join('; ')}`);
1129
+ }
1130
+
1131
+ function checkImages(images, p, add) {
1132
+ if (images.missingAlt > 0) {
1133
+ add('img-alt-missing', 'serious', 'images', `Изображений без alt: ${images.missingAlt}`, { nodes: images.missingAltList });
1134
+ }
1135
+ if (images.missingDimensions > 0) {
1136
+ add('img-dimensions-missing', 'moderate', 'images', `Изображений без width/height: ${images.missingDimensions} (риск CLS)`, { nodes: images.missingDimensionsList });
1137
+ }
1138
+ if (images.lazyMissing > 0) {
1139
+ add('img-lazy-missing', 'minor', 'images', `Изображений ниже первого экрана без loading="lazy": ${images.lazyMissing}`, { nodes: images.lazyMissingList.slice(0, 10) });
1140
+ }
1141
+ if (images.legacyFormats > 20) {
1142
+ add('img-nextgen', 'minor', 'images', `Много изображений в png/jpg/gif: ${images.legacyFormats} — рассмотреть WebP/AVIF`);
1143
+ }
1144
+ }
1145
+
1146
+ function checkLinks(links, p, add) {
1147
+ const data = links || { broken: [], checked: 0 };
1148
+ if (data.broken?.length) {
1149
+ add('link-broken', 'serious', 'links', `Битых same-origin ссылок: ${data.broken.length}`, { nodes: data.broken.slice(0, 20) });
1150
+ }
1151
+ const empty = p.links.filter((a) => !a.hasText);
1152
+ if (empty.length) {
1153
+ add('link-empty-anchor', 'moderate', 'links', `Ссылок без текста/aria-label: ${empty.length}`, {
1154
+ nodes: empty.slice(0, 15).map((a) => ({ href: a.href || '(пусто)', text: a.text || '' })),
1155
+ });
1156
+ }
1157
+ const hashOnly = p.links.filter((a) => a.href === '#');
1158
+ if (hashOnly.length) add('link-hash-placeholder', 'minor', 'links', `Ссылок href="#": ${hashOnly.length}`);
1159
+ const emptyHref = p.links.filter((a) => a.href === '');
1160
+ if (emptyHref.length) add('link-empty-href', 'moderate', 'links', `Ссылок с пустым href: ${emptyHref.length}`);
1161
+ const jsLinks = p.links.filter((a) => /^javascript:/i.test(a.href));
1162
+ if (jsLinks.length) add('link-javascript', 'moderate', 'links', `Ссылок javascript:: ${jsLinks.length}`);
1163
+ const blankNoNoopener = p.links.filter((a) => a.target === '_blank' && !a.rel.includes('noopener'));
1164
+ if (blankNoNoopener.length) add('link-target-blank', 'minor', 'links', `target="_blank" без rel="noopener": ${blankNoNoopener.length}`);
1165
+ // существование целей якорей
1166
+ const ids = new Set(p.ids || []);
1167
+ const brokenAnchors = p.links
1168
+ .filter((a) => a.href.startsWith('#') && a.href.length > 1)
1169
+ .filter((a) => !ids.has(decodeURIComponent(a.href.slice(1))))
1170
+ .map((a) => a.href);
1171
+ if (brokenAnchors.length) add('link-broken-anchor', 'moderate', 'links', `Якорей без цели: ${brokenAnchors.length}`, { nodes: [...new Set(brokenAnchors)].slice(0, 15) });
1172
+ const httpLinks = (p.httpAnchors || []).length;
1173
+ if (httpLinks) add('http-links', 'minor', 'links', `Ссылок на http://: ${httpLinks}`, { nodes: (p.httpAnchors || []).slice(0, 15) });
1174
+ }
1175
+
1176
+ function checkHreflang(hreflang, p, add) {
1177
+ if (!hreflang.count) return;
1178
+ const invalid = hreflang.problems.filter((x) => x.issue === 'invalid-format').map((x) => x.hreflang);
1179
+ if (invalid.length) add('hreflang-invalid', 'moderate', 'i18n', `Некорректный hreflang: ${invalid.join(', ')}`);
1180
+ const relative = hreflang.problems.filter((x) => x.issue === 'relative');
1181
+ if (relative.length) add('hreflang-relative', 'moderate', 'i18n', `Относительные hreflang-ссылки: ${relative.map((x) => x.hreflang).join(', ')}`);
1182
+ const dup = hreflang.problems.filter((x) => x.issue === 'duplicate');
1183
+ if (dup.length) add('hreflang-duplicate', 'moderate', 'i18n', `Дубли hreflang: ${[...new Set(dup.map((x) => x.hreflang))].join(', ')}`);
1184
+ if (!hreflang.hasXDefault) add('hreflang-self', 'minor', 'i18n', 'Нет hreflang x-default');
1185
+ }
1186
+
1187
+ function checkHttp(http, p, add) {
1188
+ if (http.status && http.status >= 400) {
1189
+ add('http-status', 'critical', 'http', `HTTP-статус страницы: ${http.status}`);
1190
+ }
1191
+ const isHttpPage = /^https?:/i.test(p.finalUrl || '');
1192
+ const htmlBytes = isHttpPage ? p.htmlBytes || 0 : 0;
1193
+ if (isHttpPage && htmlBytes > 50 * 1024 && !http.contentEncoding) {
1194
+ add('no-compression', 'minor', 'http', `Нет сжатия HTML (content-encoding отсутствует, ${Math.round(htmlBytes / 1024)} КБ)`);
1195
+ }
1196
+ }
1197
+
1198
+ function checkMixedContent(p, add) {
1199
+ const isHttps = /^https:/i.test(p.finalUrl);
1200
+ if (!isHttps) return;
1201
+ const m = p.mixed || {};
1202
+ const total =
1203
+ (m.resources?.length || 0) +
1204
+ (m.images?.length || 0) +
1205
+ (m.scripts?.length || 0) +
1206
+ (m.css?.length || 0);
1207
+ if (total) {
1208
+ add('mixed-content', 'serious', 'security', `Mixed content (http://-ресурсы на https): ${total}`, { nodes: [...(m.resources || []), ...(m.scripts || []), ...(m.css || [])].slice(0, 20) });
1209
+ }
1210
+ }
1211
+
1212
+ function checkPerformance(perf, add) {
1213
+ if (!perf) return;
1214
+ if (perf.lcp > 0) {
1215
+ if (perf.lcp > T.lcpCritical) add('lcp-slow', 'critical', 'performance', `LCP ${(perf.lcp / 1000).toFixed(1)} с > 4 c (${perf.lcpViewport}) — плохо`, { metric: 'lcp', value: perf.lcp });
1216
+ else if (perf.lcp > T.lcpSerious) add('lcp-slow', 'serious', 'performance', `LCP ${(perf.lcp / 1000).toFixed(1)} с > 2.5 c (${perf.lcpViewport}) — нужно улучшить`, { metric: 'lcp', value: perf.lcp });
1217
+ } else {
1218
+ add('perf-unavailable', 'minor', 'performance', 'LCP не измерен (PerformanceObserver не дал данных)');
1219
+ }
1220
+ if (perf.cls > 0) {
1221
+ if (perf.cls > T.clsSerious) add('cls-bad', 'serious', 'performance', `CLS ${perf.cls} > 0.25 — плохо`, { metric: 'cls', value: perf.cls });
1222
+ else if (perf.cls > T.clsModerate) add('cls-bad', 'moderate', 'performance', `CLS ${perf.cls} > 0.1 — нужно улучшить`, { metric: 'cls', value: perf.cls });
1223
+ }
1224
+ if (perf.ttfb > 0 && perf.ttfb > T.ttfbModerate) {
1225
+ add('ttfb-slow', perf.ttfb > 1800 ? 'serious' : 'moderate', 'performance', `TTFB ${perf.ttfb} мс > ${T.ttfbModerate} мс`, { metric: 'ttfb', value: perf.ttfb });
1226
+ }
1227
+ if (perf.longTasks > 20) add('long-tasks', 'minor', 'performance', `Длинных JS-задач: ${perf.longTasks}`);
1228
+ }
1229
+
1230
+ function checkResources(p, perf, add) {
1231
+ const htmlBytes = perf.htmlBytes || 0;
1232
+ if (htmlBytes > T.htmlModerate) add('html-too-big', 'moderate', 'performance', `HTML ${Math.round(htmlBytes / 1024)} КБ > 600 КБ`);
1233
+ else if (htmlBytes > T.htmlMinor) add('html-too-big', 'minor', 'performance', `HTML ${Math.round(htmlBytes / 1024)} КБ > 150 КБ`);
1234
+
1235
+ const headCss = (p.cssLinks || []).filter((l) => l.inHead && (!l.media || l.media === 'all' || l.media === 'screen'));
1236
+ if (headCss.length > 4) add('render-blocking-css', 'minor', 'performance', `Render-blocking CSS в <head>: ${headCss.length}`, { nodes: headCss.map((l) => l.href) });
1237
+ const headBlockingJs = (p.scripts || []).filter((s) => s.inHead && s.src && !s.async && !s.defer);
1238
+ if (headBlockingJs.length) {
1239
+ add('render-blocking-js', 'moderate', 'performance', `Блокирующих скриптов в <head>: ${headBlockingJs.length}`, { nodes: headBlockingJs.map((s) => s.src) });
1240
+ }
1241
+ if (perf.requestCount > T.resourcesMinor) add('resources-many', 'minor', 'performance', `Запросов ресурсов: ${perf.requestCount} > 100`);
1242
+ if (perf.transferBytes > T.weightModerate) add('weight-heavy', 'moderate', 'performance', `Вес страницы ${(perf.transferBytes / 1024 / 1024).toFixed(1)} МБ > 6 МБ`);
1243
+ else if (perf.transferBytes > T.weightMinor) add('weight-heavy', 'minor', 'performance', `Вес страницы ${(perf.transferBytes / 1024 / 1024).toFixed(1)} МБ > 3 МБ`);
1244
+ if (perf.domElements > 1500) add('dom-huge', 'moderate', 'performance', `DOM: ${perf.domElements} элементов > 1500`);
1245
+ else if (perf.domElements > 800) add('dom-huge', 'minor', 'performance', `DOM: ${perf.domElements} элементов > 800`);
1246
+ }
1247
+
1248
+ function checkRobots(conn, robots, sitemap, pages, add) {
1249
+ if (!robots) return;
1250
+ if (robots.error) {
1251
+ add('robots-missing', 'moderate', 'indexing', `robots.txt недоступен: ${robots.error}`);
1252
+ } else if (robots.status === 404) {
1253
+ add('robots-missing', 'moderate', 'indexing', 'robots.txt не найден (404)');
1254
+ } else if (robots.status && robots.status >= 400) {
1255
+ add('robots-missing', 'moderate', 'indexing', `robots.txt: HTTP ${robots.status}`);
1256
+ }
1257
+ if (robots.disallowAll) {
1258
+ add('robots-disallow', 'critical', 'indexing', 'robots.txt содержит Disallow: / для User-agent: * — сайт закрыт от индексации');
1259
+ }
1260
+ if (sitemap) {
1261
+ if (sitemap.error) add('sitemap-missing', 'moderate', 'indexing', `sitemap.xml недоступен: ${sitemap.error}`);
1262
+ else if (sitemap.status === 404) add('sitemap-missing', 'moderate', 'indexing', 'sitemap.xml не найден (404)');
1263
+ else if (sitemap.status && sitemap.status >= 400) add('sitemap-missing', 'moderate', 'indexing', `sitemap.xml: HTTP ${sitemap.status}`);
1264
+ else if (sitemap.found && sitemap.pageListed === false) {
1265
+ const where = sitemap.isIndex ? ' (в sitemap-index + первом дочернем sitemap)' : '';
1266
+ add('sitemap-not-listed', 'minor', 'indexing', `URL страницы не найден в sitemap${where}; URL в индексе: ${sitemap.urlsCount}`);
1267
+ }
1268
+ }
1269
+ }
1270
+
1271
+ function checkStatusAndRedirects(p, redirects, add) {
1272
+ if (redirects?.count > 2) add('redirect-chain', 'moderate', 'http', `Цепочка редиректов: ${redirects.count}`);
1273
+ }
1274
+
1275
+ // ===========================================================================
1276
+ // Ссылки: проверка битых same-origin
1277
+ // ===========================================================================
1278
+
1279
+ async function checkBrokenLinks(conn, pageData, { maxLinks, log }) {
1280
+ const base = new URL(pageData.finalUrl);
1281
+ const { api, close } = await getApi(conn);
1282
+ const seen = new Set();
1283
+ const broken = [];
1284
+ let checked = 0;
1285
+ let skippedExternal = 0;
1286
+ let internal = 0;
1287
+ let external = 0;
1288
+ const isLocal = /^file:/i.test(base.href);
1289
+
1290
+ for (const a of pageData.links || []) {
1291
+ const href = String(a.href || '').trim();
1292
+ if (!href) continue;
1293
+ if (/^(mailto:|tel:|javascript:|data:|blob:|sms:|whatsapp:)/i.test(href)) continue;
1294
+ let abs;
1295
+ try {
1296
+ abs = new URL(href, pageData.finalUrl);
1297
+ } catch {
1298
+ continue;
1299
+ }
1300
+ if (abs.hash && abs.pathname === base.pathname && abs.search === base.search && abs.origin === base.origin) continue;
1301
+ abs.hash = '';
1302
+ const key = abs.href;
1303
+ if (seen.has(key)) continue;
1304
+ seen.add(key);
1305
+
1306
+ if (abs.protocol === 'file:') {
1307
+ internal++;
1308
+ checked++;
1309
+ try {
1310
+ const fsPath = fileURLToPath(abs);
1311
+ if (!fs.existsSync(fsPath)) broken.push({ url: key, anchor: shorten(a.text, 80), error: 'файл не найден' });
1312
+ } catch (e) {
1313
+ broken.push({ url: key, anchor: shorten(a.text, 80), error: e.message });
1314
+ }
1315
+ continue;
1316
+ }
1317
+ if (abs.protocol !== 'http:' && abs.protocol !== 'https:') continue;
1318
+ if (abs.origin === base.origin) internal++;
1319
+ else {
1320
+ external++;
1321
+ skippedExternal++;
1322
+ continue; // внешние не хаммерим
1323
+ }
1324
+ if (checked >= maxLinks) break;
1325
+ checked++;
1326
+ try {
1327
+ let resp = await api.head(abs.href, { timeout: 10000, maxRedirects: 5, ignoreHTTPSErrors: true });
1328
+ let status = resp.status();
1329
+ if (status === 405 || status === 501 || status === 403) {
1330
+ resp = await api.get(abs.href, { timeout: 12000, maxRedirects: 5, ignoreHTTPSErrors: true });
1331
+ status = resp.status();
1332
+ }
1333
+ if (status >= 400) broken.push({ url: key, status, anchor: shorten(a.text, 80) });
1334
+ } catch (e) {
1335
+ broken.push({ url: key, error: String(e.message || e).split('\n')[0].slice(0, 200), anchor: shorten(a.text, 80) });
1336
+ }
1337
+ }
1338
+ if (close) await close.close().catch(() => {});
1339
+ return { checked, broken, internal, external, skippedExternal, maxLinks, localMode: isLocal };
1340
+ }
1341
+
1342
+ /** APIRequestContext для HTTP-запросов (HEAD/GET robots, sitemap, битые ссылки). */
1343
+ async function getApi(conn) {
1344
+ const existing = conn.browser.contexts()[0];
1345
+ if (existing?.request) return { api: existing.request, close: null };
1346
+ const tempCtx = await conn.browser.newContext();
1347
+ return { api: tempCtx.request, close: tempCtx };
1348
+ }
1349
+
1350
+ // ===========================================================================
1351
+ // robots.txt / sitemap.xml
1352
+ // ===========================================================================
1353
+
1354
+ async function fetchRobots(conn, finalUrl, log) {
1355
+ const origin = new URL(finalUrl).origin;
1356
+ const { api, close } = await getApi(conn);
1357
+ const out = { url: `${origin}/robots.txt`, status: null, found: false, sitemaps: [], disallowAll: false, text: null, error: null };
1358
+ if (!api) return { ...out, error: 'нет APIRequestContext' };
1359
+ try {
1360
+ const resp = await api.get(out.url, { timeout: 10000, maxRedirects: 3, ignoreHTTPSErrors: true });
1361
+ out.status = resp.status();
1362
+ if (resp.status() === 200) {
1363
+ out.found = true;
1364
+ const text = (await resp.text()).slice(0, 50000);
1365
+ out.text = text.slice(0, 20000);
1366
+ out.sitemaps = [...text.matchAll(/^\s*sitemap:\s*(\S+)/gim)].map((m) => m[1]).slice(0, 5);
1367
+ // Disallow: / для User-agent: *
1368
+ let inStar = false;
1369
+ for (const line of text.split(/\r?\n/)) {
1370
+ const l = line.trim();
1371
+ const ua = /^user-agent:\s*(.*)$/i.exec(l);
1372
+ if (ua) inStar = ua[1].trim() === '*';
1373
+ const dis = /^disallow:\s*(.*)$/i.exec(l);
1374
+ if (dis && inStar && dis[1].trim() === '/') out.disallowAll = true;
1375
+ }
1376
+ }
1377
+ } catch (e) {
1378
+ out.error = String(e.message || e).split('\n')[0].slice(0, 200);
1379
+ } finally {
1380
+ if (close) await close.close().catch(() => {});
1381
+ }
1382
+ return out;
1383
+ }
1384
+
1385
+ async function fetchSitemap(conn, finalUrl, robots, log) {
1386
+ const origin = new URL(finalUrl).origin;
1387
+ const { api, close } = await getApi(conn);
1388
+ const candidates = [...(robots?.sitemaps || []), `${origin}/sitemap.xml`];
1389
+ const out = {
1390
+ url: null,
1391
+ status: null,
1392
+ found: false,
1393
+ urlsCount: 0,
1394
+ pageListed: null,
1395
+ isIndex: false,
1396
+ childUrl: null,
1397
+ error: null,
1398
+ };
1399
+ if (!api) return { ...out, error: 'нет APIRequestContext' };
1400
+ const parseLocs = (text) => [...text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map((m) => m[1]);
1401
+ const pagePathOf = (href) => {
1402
+ try {
1403
+ const u = new URL(href);
1404
+ return { origin: u.origin, path: decodeURIComponent(u.pathname).replace(/\/+$/, '') };
1405
+ } catch {
1406
+ return null;
1407
+ }
1408
+ };
1409
+ try {
1410
+ for (const candidate of candidates.slice(0, 2)) {
1411
+ try {
1412
+ const resp = await api.get(candidate, { timeout: 10000, maxRedirects: 3, ignoreHTTPSErrors: true });
1413
+ out.url = candidate;
1414
+ out.status = resp.status();
1415
+ if (resp.status() !== 200) continue;
1416
+ let text = (await resp.text()).slice(0, 2_000_000);
1417
+ const locs = parseLocs(text);
1418
+ out.found = true;
1419
+ const target = pagePathOf(finalUrl);
1420
+ // sitemap-index: внутри лежат дочерние sitemap; забираем первый, чтобы проверить URL
1421
+ if (/<sitemapindex[\s>]/i.test(text) || (locs.length && locs.every((l) => /\.xml(\?|$)/i.test(l)))) {
1422
+ out.isIndex = true;
1423
+ out.urlsCount = locs.length;
1424
+ out.childUrl = locs[0] || null;
1425
+ if (out.childUrl) {
1426
+ try {
1427
+ const child = await api.get(out.childUrl, { timeout: 10000, maxRedirects: 3, ignoreHTTPSErrors: true });
1428
+ if (child.status() === 200) {
1429
+ text = (await child.text()).slice(0, 2_000_000);
1430
+ const childLocs = parseLocs(text);
1431
+ out.urlsCount += childLocs.length;
1432
+ out.pageListed = childLocs.some((loc) => {
1433
+ const p = pagePathOf(loc);
1434
+ return p && target && p.origin === target.origin && p.path === target.path;
1435
+ });
1436
+ }
1437
+ } catch {
1438
+ /* дочерний sitemap не проверился — pageListed остаётся null */
1439
+ }
1440
+ }
1441
+ } else {
1442
+ out.pageListed = locs.some((loc) => {
1443
+ const p = pagePathOf(loc);
1444
+ return p && target && p.origin === target.origin && p.path === target.path;
1445
+ });
1446
+ out.urlsCount = locs.length;
1447
+ }
1448
+ break;
1449
+ } catch (e) {
1450
+ out.error = String(e.message || e).split('\n')[0].slice(0, 200);
1451
+ }
1452
+ }
1453
+ } finally {
1454
+ if (close) await close.close().catch(() => {});
1455
+ }
1456
+ return out;
1457
+ }
1458
+
1459
+ // ===========================================================================
1460
+ // Агрегация и отчёт
1461
+ // ===========================================================================
1462
+
1463
+ function dedupeIssues(issues) {
1464
+ const map = new Map();
1465
+ for (const issue of issues) {
1466
+ const key = `${issue.id}|${issue.help}|${issue.category}`;
1467
+ const prev = map.get(key);
1468
+ if (prev) {
1469
+ prev.nodes = [...(prev.nodes || []), ...(issue.nodes || [])].slice(0, 40);
1470
+ prev.count = (prev.count || 1) + 1;
1471
+ continue;
1472
+ }
1473
+ map.set(key, { ...issue, count: 1 });
1474
+ }
1475
+ return [...map.values()];
1476
+ }
1477
+
1478
+ function compareIssues(a, b) {
1479
+ const s = (SEVERITY_RANK[b.severity] || 0) - (SEVERITY_RANK[a.severity] || 0);
1480
+ if (s) return s;
1481
+ return String(a.id).localeCompare(String(b.id));
1482
+ }
1483
+
1484
+ function countSeverities(issues) {
1485
+ const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
1486
+ for (const i of issues) counts[SEVERITY.includes(i.severity) ? i.severity : 'moderate']++;
1487
+ return counts;
1488
+ }
1489
+
1490
+ function shorten(text, n) {
1491
+ const s = String(text ?? '').replace(/\s+/g, ' ').trim();
1492
+ return s.length > n ? s.slice(0, n) + '…' : s;
1493
+ }
1494
+
1495
+ // ---------------------------------------------------------------------------
1496
+ // Markdown
1497
+ // ---------------------------------------------------------------------------
1498
+
1499
+ function renderMarkdown(report) {
1500
+ const lines = [];
1501
+ const push = (...l) => lines.push(...l);
1502
+ const s = report.summary;
1503
+ push(`# SEO-аудит: ${report.name}`, '');
1504
+ push(`- **Источник:** \`${report.source}\``);
1505
+ push(`- **URL:** ${report.pages[0]?.finalUrl || report.url}`);
1506
+ push(`- **Дата:** ${report.finishedAt} (${((report.durationMs || 0) / 1000).toFixed(1)} c)`);
1507
+ push(`- **Браузер:** ${report.browser?.browser || '—'}${report.browser?.launched ? ' (fallback)' : ' (CDP)'}`);
1508
+ push(`- **Viewports:** ${report.options.viewports.map((v) => `${v.id} ${v.width}×${v.height}`).join(', ')}`);
1509
+ push('');
1510
+
1511
+ if (report.fatalError) {
1512
+ push('## Вердикт: ОШИБКА ИНСТРУМЕНТА', '', '```', report.fatalError, '```', '');
1513
+ return lines.join('\n');
1514
+ }
1515
+
1516
+ const m = s.metrics || {};
1517
+ push(`## Вердикт: ${s.verdict === 'PASS' ? 'PASS ✅' : 'FAIL ❌'}`, '');
1518
+ push(
1519
+ `- critical: **${s.counts.critical}**, serious: **${s.counts.serious}**, moderate: ${s.counts.moderate}, minor: ${s.counts.minor}`,
1520
+ `- LCP: **${m.lcp ? (m.lcp / 1000).toFixed(2) + ' c' : '?'}**; CLS: **${m.cls ?? '?'}**; TTFB: ${m.ttfb ? m.ttfb + ' мс' : '?'}`,
1521
+ `- HTML: ${m.htmlBytes ? Math.round(m.htmlBytes / 1024) + ' КБ' : '?'}; запросов: ${m.requestCount}; вес: ${m.transferBytes ? (m.transferBytes / 1024 / 1024).toFixed(2) + ' МБ' : '?'}; DOM: ${m.domElements} элементов`,
1522
+ `- HTTP-статус: ${report.http?.status ?? 'file'}; код выхода: **${s.exitCode}**`,
1523
+ '',
1524
+ );
1525
+
1526
+ push('## Что чинить в первую очередь', '');
1527
+ const top = report.issues.slice(0, 10);
1528
+ if (!top.length) push('Критичных проблем не найдено.', '');
1529
+ else {
1530
+ top.forEach((i, idx) => {
1531
+ push(`${idx + 1}. **[${i.severity}] ${i.id}** — ${shorten(i.help, 240)}`);
1532
+ if (i.fix) push(` - *Как исправить:* ${shorten(i.fix, 300)}`);
1533
+ const nodes = (i.nodes || []).slice(0, 3);
1534
+ for (const n of nodes) push(` - ${typeof n === 'string' ? n : JSON.stringify(n).slice(0, 200)}`);
1535
+ });
1536
+ push('');
1537
+ }
1538
+
1539
+ push('## Сводка по категориям', '', '| категория | critical | serious | moderate | minor |', '|---|---:|---:|---:|---:|');
1540
+ const cats = [...new Set(report.issues.map((i) => i.category))];
1541
+ for (const cat of cats) {
1542
+ const by = (sev) => report.issues.filter((i) => i.category === cat && i.severity === sev).length;
1543
+ push(`| ${cat} | ${by('critical')} | ${by('serious')} | ${by('moderate')} | ${by('minor')} |`);
1544
+ }
1545
+ push('');
1546
+
1547
+ // Meta
1548
+ push('## Meta и индексируемость', '');
1549
+ push('| параметр | значение |', '|---|---|');
1550
+ push(`| title | ${report.meta?.title ? `«${escapeMd(report.meta.title)}» (${report.meta.titleLength} симв.)` : '—'} |`);
1551
+ push(`| description | ${report.meta?.description ? `«${escapeMd(shorten(report.meta.description, 120))}» (${report.meta.descriptionLength} симв.)` : '—'} |`);
1552
+ push(`| canonical | ${report.meta?.canonical ? escapeMd(report.meta.canonical) : '—'} |`);
1553
+ push(`| lang | ${report.meta?.lang || '—'} |`);
1554
+ push(`| viewport | ${report.meta?.viewport ? escapeMd(report.meta.viewport) : '—'} |`);
1555
+ push(`| robots meta | ${report.meta?.robotsMetas?.length ? report.meta.robotsMetas.map((r) => `${r.name}: ${r.content}`).join('; ') : '—'} |`);
1556
+ push(`| X-Robots-Tag | ${report.meta?.xRobotsTag || '—'} |`);
1557
+ push(`| charset | ${report.meta?.charset || '—'} |`);
1558
+ push('');
1559
+
1560
+ // Заголовки
1561
+ push('## Заголовки', '');
1562
+ push(`- H1: **${report.headings?.h1Count}**${report.headings?.h1Texts?.[0] ? ` — «${escapeMd(shorten(report.headings.h1Texts[0], 120))}»` : ''}`);
1563
+ push(`- Всего заголовков: ${report.headings?.count}; порядок: ${(report.headings?.order || []).slice(0, 30).join(' ')}`);
1564
+ if (report.headings?.skips?.length) push(`- Пропуски уровней: ${report.headings.skips.map((x) => `h${x.from.level} → h${x.to.level}`).join(', ')}`);
1565
+ push('');
1566
+
1567
+ // Schema
1568
+ push('## JSON-LD schema.org', '');
1569
+ if (!report.schema?.found) push('JSON-LD не найден.');
1570
+ else {
1571
+ push(`- Скриптов JSON-LD: ${report.schema.scripts}; узлов: ${report.schema.nodes.length}`);
1572
+ push(`- Типы: ${[...new Set(report.schema.types)].join(', ') || '—'}`);
1573
+ for (const e of report.schema.parseErrors || []) push(`- ❌ битый JSON (script #${e.script}): ${escapeMd(e.message)}`);
1574
+ for (const mf of (report.schema.missingFields || []).slice(0, 15)) push(`- не хватает поля ${mf.type}.${mf.field}`);
1575
+ for (const f of (report.schema.faqIssues || []).slice(0, 10)) push(`- FAQPage: ${escapeMd(f.message)}`);
1576
+ for (const b of (report.schema.breadcrumbIssues || []).slice(0, 10)) push(`- BreadcrumbList: ${escapeMd(b.message)}`);
1577
+ }
1578
+ push('');
1579
+
1580
+ // OG
1581
+ push('## Open Graph / Twitter', '');
1582
+ push(`- og: ${Object.keys(report.meta?.og || {}).length ? Object.entries(report.meta.og).map(([k, v]) => `og:${k}=${escapeMd(shorten(v, 80))}`).join('; ') : '—'}`);
1583
+ push(`- twitter: ${Object.keys(report.meta?.twitter || {}).length ? Object.entries(report.meta.twitter).map(([k, v]) => `twitter:${k}=${escapeMd(shorten(v, 60))}`).join('; ') : '—'}`);
1584
+ push('');
1585
+
1586
+ // Изображения
1587
+ push('## Изображения', '');
1588
+ push(`- всего: ${report.images?.total}, видимых: ${report.images?.visible}`);
1589
+ push(`- без alt: ${report.images?.missingAlt}; без width/height: ${report.images?.missingDimensions}; без lazy ниже экрана: ${report.images?.lazyMissing}`);
1590
+ push(`- форматов: ${(report.images?.formats || []).join(', ') || '—'}`);
1591
+ push('');
1592
+
1593
+ // Ссылки
1594
+ push('## Ссылки', '');
1595
+ push(`- проверено same-origin: ${report.links?.checked ?? 0}; внутренних: ${report.links?.internal ?? '?'}, внешних: ${report.links?.external ?? '?'}`);
1596
+ if (report.links?.broken?.length) {
1597
+ push(`- **битых: ${report.links.broken.length}**`);
1598
+ for (const b of report.links.broken.slice(0, 20)) push(` - ${b.status ? `HTTP ${b.status}` : b.error || 'ошибка'} — ${b.url}${b.anchor ? ` (анкор: «${escapeMd(b.anchor)}»)` : ''}`);
1599
+ } else push('- битых same-origin ссылок не найдено ✅');
1600
+ push('');
1601
+
1602
+ // hreflang
1603
+ push('## hreflang', '');
1604
+ if (!report.hreflang?.count) push('hreflang не задан (для одноязычной страницы — норма).');
1605
+ else {
1606
+ push(`- записей: ${report.hreflang.count}; x-default: ${report.hreflang.hasXDefault ? 'есть' : 'нет'}`);
1607
+ for (const e of report.hreflang.entries.slice(0, 10)) push(` - ${e.hreflang} → ${e.href}`);
1608
+ }
1609
+ push('');
1610
+
1611
+ // Производительность
1612
+ push('## Производительность и Core Web Vitals', '');
1613
+ push('| метрика | значение |', '|---|---|');
1614
+ push(`| LCP | ${report.performance?.lcp ? report.performance.lcp + ' мс (' + report.performance.lcpViewport + ')' : '—'} |`);
1615
+ push(`| CLS | ${report.performance?.cls ?? '—'} |`);
1616
+ push(`| TTFB | ${report.performance?.ttfb || 0} мс |`);
1617
+ push(`| запросов | ${report.performance?.requestCount} |`);
1618
+ push(`| вес страницы | ${((report.performance?.transferBytes || 0) / 1024 / 1024).toFixed(2)} МБ |`);
1619
+ push(`| DOM | ${report.performance?.domElements} элементов |`);
1620
+ push(`| долгих JS-задач | ${report.performance?.longTasks} |`);
1621
+ push(`| INP | не измеряется в headless (нет пользовательских взаимодействий) — проверять в PageSpeed/поле |`);
1622
+ if (report.performance?.lcpElement) push(`- LCP-элемент: \`${escapeMd(JSON.stringify(report.performance.lcpElement))}\``);
1623
+ if (report.performance?.topResources?.length) {
1624
+ push('', 'Крупнейшие ресурсы:', '');
1625
+ for (const r of report.performance.topResources.slice(0, 8)) push(`- ${(r.size / 1024).toFixed(1)} КБ — ${r.type} \`${escapeMd(shorten(r.name, 150))}\``);
1626
+ }
1627
+ push('');
1628
+
1629
+ // mixed / http
1630
+ push('## Mixed content и http-ссылки', '');
1631
+ push(`- mixed content (http-ресурсы на https): ${(report.issues.find((i) => i.id === 'mixed-content') ? 'есть' : (report.http?.status && /^https/.test(report.pages[0]?.finalUrl || '') ? 'нет' : 'n/a'))}`);
1632
+ push(`- http-ссылок: ${report.issues.find((i) => i.id === 'http-links')?.help || 'нет'}`);
1633
+ push('');
1634
+
1635
+ // robots/sitemap
1636
+ push('## robots.txt и sitemap.xml', '');
1637
+ if (report.robots) {
1638
+ push(`- robots.txt: ${report.robots.error ? 'ошибка: ' + report.robots.error : `HTTP ${report.robots.status}${report.robots.found ? ', найден' : ''}`}${report.robots.disallowAll ? ' (**Disallow: /**)' : ''}`);
1639
+ if (report.robots.sitemaps?.length) push(`- sitemap из robots: ${report.robots.sitemaps.join(', ')}`);
1640
+ } else push('- robots.txt не проверялся (локальный файл или --no-robots)');
1641
+ if (report.sitemap) {
1642
+ const sm = report.sitemap;
1643
+ const smStatus = sm.error ? 'ошибка: ' + sm.error : `HTTP ${sm.status}${sm.found ? `, URL: ${sm.urlsCount}${sm.isIndex ? ' (sitemap-index' + (sm.childUrl ? `, дочерний: ${sm.childUrl}` : '') + ')' : ''}` : ''}`;
1644
+ push(`- sitemap.xml: ${smStatus}`);
1645
+ if (sm.pageListed === false) push('- URL страницы в sitemap не найден');
1646
+ if (sm.pageListed === true) push('- URL страницы есть в sitemap ✅');
1647
+ }
1648
+ push('');
1649
+
1650
+ push('## Все замечания', '');
1651
+ if (!report.issues.length) push('Замечаний нет. ✅');
1652
+ else {
1653
+ push('| severity | id | что не так |', '|---|---|---|');
1654
+ for (const i of report.issues) push(`| ${i.severity} | ${i.id} | ${escapeMd(shorten(i.help, 200))} |`);
1655
+ }
1656
+ push('');
1657
+ push('---', '');
1658
+ push('_Отчёт сгенерирован tools/seo-audit/seo-audit.mjs. Технический SEO, не заменяет аудит контента/семантики._');
1659
+ return lines.join('\n');
1660
+ }
1661
+
1662
+ function escapeMd(s) {
1663
+ return String(s ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
1664
+ }
1665
+
1666
+ main().catch((err) => {
1667
+ if (err?.isUsageError) {
1668
+ console.error(`[seo] ${err.message}`);
1669
+ console.error('[seo] подсказка: запустите node tools/seo-audit/seo-audit.mjs --help');
1670
+ process.exitCode = 1;
1671
+ return;
1672
+ }
1673
+ console.error('[seo] НЕОЖИДАННАЯ ОШИБКА:', err);
1674
+ process.exitCode = 1;
1675
+ });