@dzhechkov/p-replicator 1.10.4 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.dz-manifest.json +312 -76
  2. package/CHANGELOG.md +232 -0
  3. package/MULTIPLATFORM_ROADMAP.md +1 -1
  4. package/README/eng/01_quickstart.md +3 -3
  5. package/README/eng/02_user_guide.md +1 -1
  6. package/README/eng/03_admin_guide.md +2 -2
  7. package/README/eng/04_api_reference.md +11 -5
  8. package/README/eng/05_architecture.md +1 -1
  9. package/README/eng/README.md +2 -1
  10. package/README/ru/01_quickstart.md +3 -3
  11. package/README/ru/02_user_guide.md +1 -1
  12. package/README/ru/03_admin_guide.md +2 -2
  13. package/README/ru/04_api_reference.md +11 -5
  14. package/README/ru/05_architecture.md +1 -1
  15. package/README/ru/README.md +2 -1
  16. package/README/ru/html/index.html +9 -9
  17. package/README.md +278 -39
  18. package/package.json +5 -4
  19. package/sbom.json +665 -75
  20. package/scripts/check-pipeline-gaps.sh +413 -0
  21. package/src/commands/doctor.js +94 -4
  22. package/src/commands/init.js +1 -1
  23. package/src/rule-components.json +15 -0
  24. package/src/utils.js +35 -11
  25. package/templates/.claude/agents/harvest-coordinator.md +10 -1
  26. package/templates/.claude/agents/product-discoverer.md +38 -0
  27. package/templates/.claude/agents/replicate-coordinator.md +11 -1
  28. package/templates/.claude/commands/feature.md +81 -9
  29. package/templates/.claude/commands/go.md +9 -0
  30. package/templates/.claude/commands/harvest.md +39 -3
  31. package/templates/.claude/commands/myinsights.md +21 -26
  32. package/templates/.claude/commands/replicate.md +171 -37
  33. package/templates/.claude/commands/start.md +29 -0
  34. package/templates/.claude/hooks/capture-source-path.cjs +795 -0
  35. package/templates/.claude/hooks/check-canon.cjs +493 -0
  36. package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
  37. package/templates/.claude/hooks/check-external-deps.cjs +288 -0
  38. package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
  39. package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
  40. package/templates/.claude/hooks/check-job-contract.cjs +501 -0
  41. package/templates/.claude/hooks/check-look-origin.cjs +240 -0
  42. package/templates/.claude/hooks/check-look-trace.cjs +385 -0
  43. package/templates/.claude/hooks/check-metric-source.cjs +296 -0
  44. package/templates/.claude/hooks/check-model-cost.cjs +470 -0
  45. package/templates/.claude/hooks/check-ports.cjs +434 -24
  46. package/templates/.claude/hooks/check-source-version.cjs +312 -0
  47. package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
  48. package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
  49. package/templates/.claude/hooks/session-insights.cjs +158 -25
  50. package/templates/.claude/hooks/statusline.cjs +2 -2
  51. package/templates/.claude/hooks/write-insight.cjs +253 -0
  52. package/templates/.claude/rules/cost-of-detection-ladder.md +96 -0
  53. package/templates/.claude/rules/docker-ports.md +41 -19
  54. package/templates/.claude/rules/embeddable-widget.md +73 -0
  55. package/templates/.claude/rules/feature-lifecycle.md +13 -3
  56. package/templates/.claude/rules/honest-configuration.md +54 -0
  57. package/templates/.claude/rules/incoming-webhooks.md +99 -0
  58. package/templates/.claude/rules/insights-capture.md +10 -5
  59. package/templates/.claude/rules/long-running-job.md +73 -0
  60. package/templates/.claude/rules/model-call-cost.md +85 -0
  61. package/templates/.claude/rules/replicate-pipeline.md +123 -52
  62. package/templates/.claude/rules/skill-interface-protocol.md +1 -0
  63. package/templates/.claude/rules/swarm-file-evidence.md +46 -0
  64. package/templates/.claude/settings.json +13 -1
  65. package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
  66. package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
  67. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
  68. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
  69. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
  70. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
  71. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
  72. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
  73. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
  74. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
  75. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
  76. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
  77. package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
  78. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
  79. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
  80. package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
  81. package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
  82. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
  83. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
  84. package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
  85. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
  86. package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
  87. package/templates/.claude/skills/knowledge-extractor/modules/01-agent-review.md +16 -5
  88. package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
  89. package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
  90. package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
  91. package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
  92. package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
  93. package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
  94. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +234 -716
  95. package/tests/e2e/lifecycle.test.js +55 -9
  96. package/tests/e2e/packed-insights-writer.test.js +308 -0
  97. package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/01_specification.md +29 -0
  98. package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/02_pseudocode.md +57 -0
  99. package/tests/snapshot/baseline.json +72 -46
  100. package/tests/snapshot/templates.test.js +47 -0
  101. package/tests/unit/absence-is-not-emptiness.test.js +15 -1
  102. package/tests/unit/capture-source-path.test.js +492 -0
  103. package/tests/unit/check-canon.test.js +403 -0
  104. package/tests/unit/check-embed-contract.test.js +422 -0
  105. package/tests/unit/check-external-deps.test.js +363 -0
  106. package/tests/unit/check-file-ownership.test.js +388 -0
  107. package/tests/unit/check-handoff-manifest.test.js +410 -0
  108. package/tests/unit/check-job-contract.test.js +514 -0
  109. package/tests/unit/check-look-origin.test.js +180 -0
  110. package/tests/unit/check-look-trace.test.js +420 -0
  111. package/tests/unit/check-metric-source.test.js +325 -0
  112. package/tests/unit/check-model-cost.test.js +425 -0
  113. package/tests/unit/check-pipeline-gaps.test.js +94 -0
  114. package/tests/unit/check-ports.test.js +773 -2
  115. package/tests/unit/check-source-version.test.js +344 -0
  116. package/tests/unit/check-swarm-receipts.test.js +231 -0
  117. package/tests/unit/check-webhook-contract.test.js +536 -0
  118. package/tests/unit/db-port-rule.test.js +43 -6
  119. package/tests/unit/detection-ladder-contract.test.js +302 -0
  120. package/tests/unit/detection-ladder-registry.test.js +52 -0
  121. package/tests/unit/doctor-insight-flow.test.js +315 -0
  122. package/tests/unit/external-dependency-check.test.js +19 -19
  123. package/tests/unit/generator-swarm-contract.test.js +287 -0
  124. package/tests/unit/guard-honest-input-meta.test.js +64 -0
  125. package/tests/unit/honest-failure-rules.test.js +574 -0
  126. package/tests/unit/hooks-project-anchored.test.js +67 -3
  127. package/tests/unit/insights-docs-tell-the-truth.test.js +52 -31
  128. package/tests/unit/insights-dz-delegation.test.js +197 -0
  129. package/tests/unit/insights-writer.test.js +285 -0
  130. package/tests/unit/look-phase-contract.test.js +231 -0
  131. package/tests/unit/negative-conclusion-gate.test.js +300 -0
  132. package/tests/unit/quote-provenance.test.js +122 -0
  133. package/tests/unit/shipped-suite-context.test.js +3 -1
  134. package/tests/unit/traceability-machine-ids.test.js +413 -0
  135. package/tests/unit/traceability-negative-fixture.test.js +322 -0
  136. package/tests/unit/utils.test.js +40 -2
@@ -0,0 +1,795 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * capture-source-path.cjs — инструмент для оси «путь» Фазы 0.5.
6
+ *
7
+ * NOT an event hook. Like `check-ports.cjs`, `check-growth-trace.cjs`, `check-look-trace.cjs` and
8
+ * `check-docs-complete.cjs`, it lives here because this directory already carries plain Node
9
+ * utilities; nothing registers it in settings.json. This package's hooks are NON-BLOCKING by
10
+ * contract, so a hook could never refuse anything — it could only print. Invoke it:
11
+ *
12
+ * node .claude/hooks/capture-source-path.cjs <url> [опции]
13
+ *
14
+ * ─── ЗАЧЕМ ─────────────────────────────────────────────────────────────────────────────────────
15
+ * Фаза 0.5 снимает ОБЛИК исходного продукта навыком `clone-website` — «clone exactly what is
16
+ * visible at that URL», ОДНА страница. Ось «путь» — в каком порядке человек проходит продукт:
17
+ * регистрация, онбординг, первое ценное действие, пейволл — не снималась ничем: колонка «Ось» в
18
+ * таблице-семени существовала, а инструмента под неё не было. Это он.
19
+ *
20
+ * ОДНО семейство идентификаторов: инструмент выпускает строки `FR-LOOK-nnn`, продолжая нумерацию
21
+ * существующего профиля, и проставляет им ось `путь`. Второго семейства и второго артефакта нет —
22
+ * ось это КОЛОНКА.
23
+ *
24
+ * ─── ТРИ ИСХОДА, И КАЖДОМУ СВОЙ КОД ВОЗВРАТА ───────────────────────────────────────────────────
25
+ * 0 СНЯТ путь прокликан; на stdout строки `FR-LOOK-nnn` с осью `путь`
26
+ * 1 ИСТОЧНИКА НЕТ источник ОТКРЫЛСЯ, но пути в нём нет: ни одного перехода со стартового
27
+ * экрана (одноэкранный продукт). Это ДОКАЗАННЫЙ отрицательный ответ, а не
28
+ * слепота — мы посмотрели. Печатается готовая строка объявления оси.
29
+ * 2 НЕ ИЗМЕРЕНО снять не удалось; причина из ЗАКРЫТОГО списка, каждая означает СВОЙ ремонт:
30
+ * no-browser Playwright/браузер не найден на машине
31
+ * robots-disallowed robots.txt цели запрещает обход (или не прочитан)
32
+ * bot-protected сайт ответил 403/429 — защита от ботов
33
+ * auth-required СТАРТОВЫЙ экран уже за входом
34
+ * timeout навигация не уложилась в бюджет
35
+ * unreachable DNS/сеть/HTTP-ошибка, не 403 и не 429
36
+ * out-of-scope не http(s), или аргументы вне области инструмента
37
+ *
38
+ * Эти же значения — легальные причины в `docs/source-product-profile.md`, и `check-look-trace.cjs`
39
+ * держит тот же закрытый список. Свободный текст причиной не является: список закрыт именно
40
+ * потому, что каждая запись называет РАЗНЫЙ ремонт.
41
+ *
42
+ * ─── ЮРИДИЧЕСКАЯ РАМКА (исполняемая, а не только описанная) ────────────────────────────────────
43
+ * Снимаются ЗАКОНОМЕРНОСТИ, а не ЗНАЧЕНИЯ: шаг сетки отступов, шкала кеглей, число уровней
44
+ * иерархии, число полей формы, длина последовательности экранов. «Отступ 8px встречается 137 раз»
45
+ * — измерение; «именно этот фиолетовый» — чужое оформление.
46
+ * • CSS-файлы и DOM чужого сайта — чужой код под авторским правом. По умолчанию НЕ сохраняются
47
+ * (`--keep-dom` сохраняет, печатая предупреждение). Основанием для измерения они быть могут,
48
+ * копированием в свой продукт — нет.
49
+ * • Логотип, название и фирменные цвета В СВЯЗКЕ — товарные знаки. Инструмент не выпускает строк
50
+ * оси «облик» и не предлагает палитру к переносу.
51
+ * • Каталог доказательств помечается как чужой материал и закрывается собственным `.gitignore`.
52
+ * • Обход более ОДНОЙ страницы читает `robots.txt` цели. Запрет — это исход `robots-disallowed`,
53
+ * а не препятствие: обходить запрет нечем и незачем.
54
+ * • Аутентификация и любой обход технических ограничений сайта ЗАПРЕЩЕНЫ. Экран входа —
55
+ * законная последняя точка пути: он записывается как шаг и обход останавливается.
56
+ * • User-Agent честный: к штатному дописывается токен инструмента. Маскировка под обычный
57
+ * браузер была бы обходом защиты.
58
+ *
59
+ * ─── ОПОРА НА УСТОЙЧИВОЕ ───────────────────────────────────────────────────────────────────────
60
+ * Всё измеряется по ВЫЧИСЛЕННЫМ стилям и СЕМАНТИЧЕСКИМ ролям (aria, теги форм, доступные имена).
61
+ * Имена классов вида `sx-ds2y8i` не читаются НИГДЕ: у сборщиков они меняются каждой сборкой чужого
62
+ * сайта, и опора на них — это тест, который краснеет от чужого релиза. По той же причине имена
63
+ * переменных дизайн-системы не выписываются: у сборщиков они обфусцированы, содержательно только их
64
+ * ЧИСЛО.
65
+ *
66
+ * ─── ВЕЖЛИВОСТЬ ────────────────────────────────────────────────────────────────────────────────
67
+ * Один поток, пауза между страницами (`--delay-ms`, по умолчанию 1500), жёсткий потолок числа
68
+ * страниц (`--max-pages`, по умолчанию 5, потолок 12). Учащение запросов повышает шанс блокировки
69
+ * и просто невежливо.
70
+ *
71
+ * ─── ВНЕШНЕЕ ПРЕДУСЛОВИЕ ───────────────────────────────────────────────────────────────────────
72
+ * Playwright — ВНЕШНЕЕ предусловие, ровно как браузерный MCP у `clone-website`. У пакета
73
+ * `p-replicator` НЕТ зависимостей, и эта утилита их не заводит: модуль ищется на машине во время
74
+ * запуска, а его отсутствие — это честный исход `no-browser`, а не падение.
75
+ * npm i -D playwright && npx playwright install chromium
76
+ * либо укажите путь явно: PLAYWRIGHT_MODULE=/путь/к/node_modules/playwright
77
+ */
78
+
79
+ const fs = require('node:fs');
80
+ const path = require('node:path');
81
+ const http = require('node:http');
82
+ const https = require('node:https');
83
+ const { execFileSync } = require('node:child_process');
84
+
85
+ /** Закрытый список причин. Совпадает с REASONS в check-look-trace.cjs — один список на два файла. */
86
+ const REASONS = ['no-browser', 'robots-disallowed', 'bot-protected', 'auth-required', 'timeout',
87
+ 'unreachable', 'out-of-scope'];
88
+
89
+ const UA_TOKEN = 'p-replicator-path-capture/1 (+recon; robots-respecting)';
90
+ const ROBOTS_AGENT = 'p-replicator-path-capture';
91
+ const PROFILE = path.join('docs', 'source-product-profile.md');
92
+ const DEFAULT_OUT = path.join('.p-replicator', 'source-path-capture');
93
+ const MAX_PAGES_CAP = 12;
94
+ const MIN_DELAY_MS = 250;
95
+
96
+ function say(s) { process.stdout.write(s + '\n'); }
97
+
98
+ /** НЕ ИЗМЕРЕНО. Единственная дверь к коду 2, и она всегда называет причину из закрытого списка. */
99
+ function notMeasured(reason, detail, hint) {
100
+ if (!REASONS.includes(reason)) {
101
+ // Обязанность закрытого списка держится кодом, а не дисциплиной вызывающего.
102
+ say('⚠️ внутренняя ошибка: причина «' + reason + '» вне закрытого списка ' + REASONS.join(' | '));
103
+ process.exit(2);
104
+ }
105
+ say('⚠️ НЕ ИЗМЕРЕНО: ' + reason);
106
+ if (detail) say(' ' + detail);
107
+ say('');
108
+ say(' В docs/source-product-profile.md запишите ось «путь» как НЕ ИЗМЕРЕНО:');
109
+ say(' **Статус съёмки (путь):** НЕ ИЗМЕРЕНО');
110
+ say(' **Причина (путь):** ' + reason);
111
+ say(' Закрытый список причин: ' + REASONS.join(' | '));
112
+ if (hint) { say(''); say(' ' + hint); }
113
+ process.exit(2);
114
+ }
115
+
116
+ // ───────────────────────────────────────────────────────────────────────────── аргументы
117
+
118
+ function parseArgs(argv) {
119
+ const opts = {
120
+ url: null, maxPages: 5, delayMs: 1500, timeoutMs: 20000, out: DEFAULT_OUT,
121
+ project: '.', follow: [], json: false, keepDom: false, fullPage: false,
122
+ viewport: { width: 1440, height: 900 },
123
+ };
124
+ const num = (v, name) => {
125
+ const n = Number(v);
126
+ if (!Number.isFinite(n)) notMeasured('out-of-scope', name + ' не число: ' + v);
127
+ return n;
128
+ };
129
+ for (let i = 0; i < argv.length; i++) {
130
+ const a = argv[i];
131
+ if (a === '--max-pages') opts.maxPages = num(argv[++i], '--max-pages');
132
+ else if (a === '--delay-ms') opts.delayMs = num(argv[++i], '--delay-ms');
133
+ else if (a === '--timeout-ms') opts.timeoutMs = num(argv[++i], '--timeout-ms');
134
+ else if (a === '--out') opts.out = argv[++i];
135
+ else if (a === '--project') opts.project = argv[++i];
136
+ else if (a === '--follow') opts.follow = String(argv[++i] || '').split('|').map((s) => s.trim()).filter(Boolean);
137
+ else if (a === '--width') opts.viewport.width = num(argv[++i], '--width');
138
+ else if (a === '--json') opts.json = true;
139
+ else if (a === '--keep-dom') opts.keepDom = true;
140
+ else if (a === '--full-page') opts.fullPage = true;
141
+ else if (a === '--help' || a === '-h') { usage(); process.exit(0); }
142
+ else if (a.startsWith('-')) notMeasured('out-of-scope', 'неизвестный флаг: ' + a);
143
+ else if (opts.url === null) opts.url = a;
144
+ else notMeasured('out-of-scope', 'лишний позиционный аргумент: ' + a);
145
+ }
146
+ if (!opts.url) { usage(); notMeasured('out-of-scope', 'не назван URL стартового экрана'); }
147
+
148
+ let u;
149
+ try { u = new URL(opts.url); } catch { notMeasured('out-of-scope', 'неразбираемый URL: ' + opts.url); }
150
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
151
+ notMeasured('out-of-scope', 'схема ' + u.protocol + ' вне области инструмента — только http(s)');
152
+ }
153
+ opts.start = u;
154
+
155
+ // Потолки — вежливость, а не совет. Превышение зажимается ВСЛУХ, никогда молча.
156
+ if (opts.maxPages > MAX_PAGES_CAP) {
157
+ say('ℹ️ --max-pages ' + opts.maxPages + ' зажат до потолка вежливости ' + MAX_PAGES_CAP);
158
+ opts.maxPages = MAX_PAGES_CAP;
159
+ }
160
+ if (opts.maxPages < 1) notMeasured('out-of-scope', '--max-pages меньше 1');
161
+ if (opts.delayMs < MIN_DELAY_MS) {
162
+ say('ℹ️ --delay-ms ' + opts.delayMs + ' поднят до минимума вежливости ' + MIN_DELAY_MS);
163
+ opts.delayMs = MIN_DELAY_MS;
164
+ }
165
+ return opts;
166
+ }
167
+
168
+ function usage() {
169
+ say('capture-source-path.cjs <url> [опции] — снять ось «путь» Фазы 0.5');
170
+ say(' --max-pages N сколько экранов пройти (по умолчанию 5, потолок ' + MAX_PAGES_CAP + ')');
171
+ say(' --delay-ms N пауза между экранами (по умолчанию 1500, минимум ' + MIN_DELAY_MS + ')');
172
+ say(' --timeout-ms N бюджет одной навигации (по умолчанию 20000)');
173
+ say(' --follow "A|B" доступные ИМЕНА призывов, по которым идти (иначе — по заметности)');
174
+ say(' --out DIR каталог доказательств (по умолчанию ' + DEFAULT_OUT + ')');
175
+ say(' --project DIR корень проекта, откуда читается профиль (по умолчанию .)');
176
+ say(' --width N ширина окна (по умолчанию 1440)');
177
+ say(' --json машинный вывод вместо таблицы');
178
+ say(' --full-page скриншот всей страницы, а не окна');
179
+ say(' --keep-dom сохранить DOM (чужой код — по умолчанию НЕ сохраняется)');
180
+ }
181
+
182
+ // ───────────────────────────────────────────────────────────────────────────── playwright
183
+
184
+ /**
185
+ * Playwright ищется на машине, а не в зависимостях пакета. Порядок кандидатов ЯВНЫЙ, и каждый
186
+ * промах называется — «не нашли» без списка того, где искали, нечинимо.
187
+ */
188
+ function resolvePlaywright() {
189
+ const tried = [];
190
+ const candidates = [];
191
+ if (process.env.PLAYWRIGHT_MODULE) candidates.push(process.env.PLAYWRIGHT_MODULE);
192
+ for (const name of ['playwright', 'playwright-core']) {
193
+ candidates.push(name);
194
+ candidates.push(path.resolve(process.cwd(), 'node_modules', name));
195
+ }
196
+ let globalRoot = null;
197
+ try { globalRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', timeout: 15000 }).trim(); }
198
+ catch { /* npm может отсутствовать — это не ошибка, просто минус один кандидат */ }
199
+ if (globalRoot) for (const name of ['playwright', 'playwright-core']) candidates.push(path.join(globalRoot, name));
200
+
201
+ for (const c of candidates) {
202
+ tried.push(c);
203
+ try {
204
+ const mod = require(c);
205
+ if (mod && mod.chromium) return { mod, from: c };
206
+ } catch { /* следующий кандидат */ }
207
+ }
208
+ notMeasured('no-browser',
209
+ 'модуль playwright не найден; искали: ' + tried.join(', '),
210
+ 'Playwright — ВНЕШНЕЕ предусловие (у пакета ноль зависимостей):\n'
211
+ + ' npm i -D playwright && npx playwright install chromium\n'
212
+ + ' либо PLAYWRIGHT_MODULE=/путь/к/node_modules/playwright');
213
+ return null;
214
+ }
215
+
216
+ // ───────────────────────────────────────────────────────────────────────────── robots.txt
217
+
218
+ /** Загрузить текст по URL без зависимостей. Возвращает {status, body} либо бросает. */
219
+ function fetchText(url, timeoutMs) {
220
+ return new Promise((resolve, reject) => {
221
+ const lib = url.protocol === 'https:' ? https : http;
222
+ const req = lib.get(url, {
223
+ timeout: timeoutMs,
224
+ headers: { 'user-agent': UA_TOKEN, accept: 'text/plain,*/*' },
225
+ }, (res) => {
226
+ const chunks = [];
227
+ res.on('data', (c) => { if (chunks.length < 512) chunks.push(c); });
228
+ res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
229
+ });
230
+ req.on('timeout', () => { req.destroy(new Error('robots.txt timeout')); });
231
+ req.on('error', reject);
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Разбор robots.txt под наш агент. Группа выбирается по нашему токену, иначе `*`.
237
+ * Совпадение — по длиннейшему префиксу; при равной длине Allow побеждает Disallow (RFC 9309).
238
+ */
239
+ function parseRobots(text) {
240
+ const groups = new Map();
241
+ let current = [];
242
+ let inGroup = false;
243
+ for (const rawLine of text.split(/\r?\n/)) {
244
+ const line = rawLine.replace(/#.*$/, '').trim();
245
+ if (!line) continue;
246
+ const m = /^([A-Za-z-]+)\s*:\s*(.*)$/.exec(line);
247
+ if (!m) continue;
248
+ const field = m[1].toLowerCase();
249
+ const value = m[2].trim();
250
+ if (field === 'user-agent') {
251
+ if (!inGroup) { current = []; inGroup = true; }
252
+ const key = value.toLowerCase();
253
+ if (!groups.has(key)) groups.set(key, current);
254
+ else current = groups.get(key);
255
+ groups.set(key, current);
256
+ } else if (field === 'allow' || field === 'disallow') {
257
+ inGroup = false;
258
+ current.push({ allow: field === 'allow', pattern: value });
259
+ }
260
+ }
261
+ const agent = ROBOTS_AGENT.toLowerCase();
262
+ let rules = null;
263
+ for (const [key, value] of groups) {
264
+ if (agent.startsWith(key) && key !== '*') { rules = value; break; }
265
+ }
266
+ if (!rules) rules = groups.get('*') || [];
267
+ return rules;
268
+ }
269
+
270
+ function robotsAllows(rules, pathname) {
271
+ let best = null;
272
+ for (const rule of rules) {
273
+ if (rule.pattern === '') continue; // пустой Disallow = разрешено всё
274
+ const re = patternToRegExp(rule.pattern);
275
+ if (!re || !re.test(pathname)) continue;
276
+ const len = rule.pattern.length;
277
+ if (!best || len > best.len || (len === best.len && rule.allow)) best = { len, allow: rule.allow };
278
+ }
279
+ return best ? best.allow : true;
280
+ }
281
+
282
+ function patternToRegExp(pattern) {
283
+ let out = '^';
284
+ for (let i = 0; i < pattern.length; i++) {
285
+ const ch = pattern[i];
286
+ if (ch === '*') out += '.*';
287
+ else if (ch === '$' && i === pattern.length - 1) out += '$';
288
+ else out += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
289
+ }
290
+ try { return new RegExp(out); } catch { return null; }
291
+ }
292
+
293
+ /**
294
+ * Обход более ОДНОЙ страницы обязан спросить robots.txt.
295
+ * 404/410/403 → правил не опубликовано → можно (стандартная трактовка);
296
+ * 5xx / сеть → правила НЕ ПРОЧИТАНЫ → нельзя. Нечитаемый robots.txt трактуется как запрет:
297
+ * «мы не смогли спросить» не то же самое, что «нам разрешили».
298
+ */
299
+ async function robotsGate(start, maxPages, timeoutMs) {
300
+ if (maxPages <= 1) {
301
+ return { checked: false, note: 'одна страница — robots.txt не запрашивался (обхода нет)' };
302
+ }
303
+ const url = new URL('/robots.txt', start.origin);
304
+ let res;
305
+ try { res = await fetchText(url, timeoutMs); } catch (e) {
306
+ notMeasured('robots-disallowed',
307
+ 'robots.txt не прочитан (' + ((e && e.message) || e) + ') — нечитаемый robots.txt трактуется как запрет',
308
+ 'снимите ОДНУ страницу (--max-pages 1): обход одной страницы robots.txt не регулирует');
309
+ }
310
+ if (res.status >= 500) {
311
+ notMeasured('robots-disallowed',
312
+ 'robots.txt отдал ' + res.status + ' — правила не прочитаны, трактуется как запрет',
313
+ 'повторите позже либо снимите одну страницу (--max-pages 1)');
314
+ }
315
+ if (res.status >= 400) {
316
+ return { checked: true, note: 'robots.txt отсутствует (HTTP ' + res.status + ') — правил не опубликовано', rules: [] };
317
+ }
318
+ const rules = parseRobots(res.body);
319
+ if (!robotsAllows(rules, start.pathname)) {
320
+ notMeasured('robots-disallowed',
321
+ 'robots.txt запрещает ' + start.pathname + ' для агента ' + ROBOTS_AGENT,
322
+ 'запрет — это исход, а не препятствие: обходить его нечем и незачем');
323
+ }
324
+ return { checked: true, note: 'robots.txt прочитан, стартовый путь разрешён', rules };
325
+ }
326
+
327
+ // ───────────────────────────────────────────────────────────────────────────── измерение экрана
328
+
329
+ /** Выполняется В СТРАНИЦЕ. Только вычисленные стили и семантические роли — никаких имён классов. */
330
+ function measureInPage() {
331
+ const px = (v) => { const n = parseFloat(v); return Number.isFinite(n) ? Math.round(n) : null; };
332
+ const bump = (m, k) => { if (k === null || k === undefined || k === '') return; m[k] = (m[k] || 0) + 1; };
333
+ const fontSizes = {}; const weights = {}; const spacing = {}; const radii = {}; const colors = {};
334
+ let counted = 0;
335
+ for (const el of document.querySelectorAll('body *')) {
336
+ if (counted >= 4000) break;
337
+ if (!el.getClientRects().length) continue; // невидимое к облику не относится
338
+ counted++;
339
+ const cs = getComputedStyle(el);
340
+ bump(fontSizes, px(cs.fontSize));
341
+ bump(weights, cs.fontWeight);
342
+ for (const p of ['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight',
343
+ 'marginTop', 'marginBottom', 'rowGap', 'columnGap']) {
344
+ const v = px(cs[p]); if (v) bump(spacing, v);
345
+ }
346
+ const rad = px(cs.borderTopLeftRadius); if (rad) bump(radii, rad);
347
+ const bg = cs.backgroundColor;
348
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') bump(colors, bg);
349
+ }
350
+ let rootVars = 0;
351
+ try {
352
+ const rs = getComputedStyle(document.documentElement);
353
+ for (let i = 0; i < rs.length; i++) if (String(rs[i]).startsWith('--')) rootVars++;
354
+ } catch (e) { rootVars = -1; }
355
+
356
+ const breakpoints = {}; let sheets = 0; let unreadable = 0;
357
+ const walkRules = (list) => {
358
+ for (const r of list) {
359
+ if (r.conditionText) {
360
+ const found = String(r.conditionText).match(/(\d+(?:\.\d+)?)px/g) || [];
361
+ for (const f of found) bump(breakpoints, Math.round(parseFloat(f)));
362
+ }
363
+ if (r.cssRules) { try { walkRules(r.cssRules); } catch (e) { /* вложенный cross-origin */ } }
364
+ }
365
+ };
366
+ for (const sheet of document.styleSheets) {
367
+ sheets++;
368
+ let rules = null;
369
+ try { rules = sheet.cssRules; } catch (e) { unreadable++; continue; }
370
+ if (!rules) { unreadable++; continue; }
371
+ try { walkRules(rules); } catch (e) { unreadable++; }
372
+ }
373
+
374
+ const headings = [];
375
+ for (const h of document.querySelectorAll('h1,h2,h3,h4')) {
376
+ if (headings.length >= 24) break;
377
+ const text = (h.textContent || '').trim().replace(/\s+/g, ' ');
378
+ if (text) headings.push({ level: Number(h.tagName.slice(1)), text: text.slice(0, 80) });
379
+ }
380
+
381
+ const ctas = [];
382
+ for (const el of document.querySelectorAll('a[href],button,[role="button"],input[type="submit"]')) {
383
+ if (ctas.length >= 60) break;
384
+ const r = el.getBoundingClientRect();
385
+ if (r.width < 8 || r.height < 8) continue;
386
+ const cs = getComputedStyle(el);
387
+ if (cs.visibility === 'hidden' || cs.display === 'none') continue;
388
+ const name = (el.getAttribute('aria-label') || el.value || el.textContent || '')
389
+ .trim().replace(/\s+/g, ' ').slice(0, 80);
390
+ if (!name) continue;
391
+ ctas.push({
392
+ name,
393
+ href: el.getAttribute('href') || null,
394
+ tag: el.tagName.toLowerCase(),
395
+ area: Math.round(r.width * r.height),
396
+ top: Math.round(r.top + window.scrollY),
397
+ weight: Number(cs.fontWeight) || 400,
398
+ filled: cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent',
399
+ aboveFold: r.top >= 0 && r.top < window.innerHeight,
400
+ });
401
+ }
402
+
403
+ const fields = [];
404
+ for (const el of document.querySelectorAll('input,select,textarea')) {
405
+ if (fields.length >= 30) break;
406
+ const type = String(el.type || el.tagName).toLowerCase();
407
+ if (type === 'hidden') continue;
408
+ let label = el.getAttribute('aria-label') || '';
409
+ if (!label && el.labels && el.labels[0]) label = el.labels[0].textContent || '';
410
+ if (!label) label = el.getAttribute('placeholder') || el.getAttribute('name') || '';
411
+ fields.push({ type, label: String(label).trim().replace(/\s+/g, ' ').slice(0, 60) });
412
+ }
413
+
414
+ return {
415
+ title: document.title || '',
416
+ fontSizes, weights, spacing, radii, colors, rootVars, breakpoints, sheets, unreadable,
417
+ headings, ctas, fields,
418
+ authWall: fields.some((f) => f.type === 'password'),
419
+ elements: counted,
420
+ viewport: { width: window.innerWidth, height: window.innerHeight },
421
+ };
422
+ }
423
+
424
+ // ───────────────────────────────────────────────────────────────────────────── закономерности
425
+
426
+ /**
427
+ * Шаг сетки отступов — НАИБОЛЬШИЙ делитель, на который делится не меньше SHARE массы отступов.
428
+ *
429
+ * НОД здесь НЕ годится, и это измерено: у браузера свои умолчания в `em` (у `h1` при кегле 56px
430
+ * margin выходит 38px), и ОДНО такое значение обнуляет НОД до 1 или 2 на сайте с честной сеткой в
431
+ * 4px. Доля устойчива к чужим умолчаниям, а порог 0.9 отделяет настоящий шаг от его кратного:
432
+ * на пробе с 8/12/16/20/24 доля для 4 = 0.91, для 8 = 0.73, и шагом называется 4.
433
+ *
434
+ * Возвращается и сама доля: «шаг 4px, доля 0.91» проверяемо, «шаг 4px» — нет.
435
+ */
436
+ const SPACING_SHARE = 0.9;
437
+
438
+ function spacingStep(hist) {
439
+ const entries = Object.entries(hist).map(([v, c]) => [Number(v), c]).filter(([v]) => v > 0);
440
+ const mass = entries.reduce((t, [, c]) => t + c, 0);
441
+ if (mass < 4) return null;
442
+ for (let d = 24; d >= 2; d--) {
443
+ const hit = entries.reduce((t, [v, c]) => t + (v % d === 0 ? c : 0), 0);
444
+ if (hit / mass >= SPACING_SHARE) return { step: d, share: Math.round((hit / mass) * 100) / 100 };
445
+ }
446
+ return null;
447
+ }
448
+
449
+ function topN(hist, n) {
450
+ return Object.entries(hist).sort((a, b) => b[1] - a[1]).slice(0, n)
451
+ .map(([k, v]) => ({ value: k, count: v }));
452
+ }
453
+
454
+ function regularities(m) {
455
+ const sizes = Object.keys(m.fontSizes).map(Number).sort((a, b) => a - b);
456
+ return {
457
+ spacingStep: spacingStep(m.spacing),
458
+ spacingTop: topN(m.spacing, 6),
459
+ typeScale: sizes,
460
+ typeLevels: sizes.length,
461
+ weights: Object.keys(m.weights).map(Number).sort((a, b) => a - b),
462
+ radii: Object.keys(m.radii).map(Number).sort((a, b) => a - b),
463
+ distinctColors: Object.keys(m.colors).length,
464
+ rootVars: m.rootVars,
465
+ breakpoints: Object.keys(m.breakpoints).map(Number).sort((a, b) => a - b),
466
+ stylesheets: { total: m.sheets, unreadable: m.unreadable },
467
+ headingLevels: [...new Set(m.headings.map((h) => h.level))].sort((a, b) => a - b).length,
468
+ ctaCount: m.ctas.length,
469
+ prominentCtas: m.ctas.filter((c) => c.filled && c.aboveFold).length,
470
+ fieldCount: m.fields.length,
471
+ };
472
+ }
473
+
474
+ // ───────────────────────────────────────────────────────────────────────────── выбор перехода
475
+
476
+ /**
477
+ * Следующий шаг выбирается по УСТОЙЧИВЫМ признакам: доступное имя, тот же источник, заметность
478
+ * (залитый фон + первый экран + площадь). Имена классов не читаются вовсе.
479
+ */
480
+ function pickNext(measure, currentUrl, visited, follow) {
481
+ const sameOrigin = [];
482
+ for (const c of measure.ctas) {
483
+ if (!c.href) continue;
484
+ let u;
485
+ try { u = new URL(c.href, currentUrl); } catch { continue; }
486
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') continue;
487
+ if (u.origin !== new URL(currentUrl).origin) continue;
488
+ u.hash = '';
489
+ const key = u.toString();
490
+ if (visited.has(key)) continue;
491
+ sameOrigin.push({ ...c, url: key });
492
+ }
493
+ if (!sameOrigin.length) return null;
494
+ if (follow.length) {
495
+ for (const want of follow) {
496
+ const hit = sameOrigin.find((c) => c.name.toLowerCase().includes(want.toLowerCase()));
497
+ if (hit) return hit;
498
+ }
499
+ }
500
+ const score = (c) => (c.filled ? 1000 : 0) + (c.aboveFold ? 500 : 0) + Math.min(c.area, 400)
501
+ + (c.weight >= 500 ? 100 : 0) + (c.tag === 'button' ? 50 : 0);
502
+ return sameOrigin.sort((a, b) => score(b) - score(a))[0];
503
+ }
504
+
505
+ // ───────────────────────────────────────────────────────────────────────────── строки FR-LOOK
506
+
507
+ /** Продолжить нумерацию СУЩЕСТВУЮЩЕГО профиля. Одно семейство — значит один счётчик. */
508
+ function nextId(projectRoot) {
509
+ const abs = path.join(projectRoot, PROFILE);
510
+ let max = 0;
511
+ try {
512
+ const text = fs.readFileSync(abs, 'utf8');
513
+ for (const m of text.matchAll(/\bFR-LOOK-(\d{3})\b/g)) max = Math.max(max, Number(m[1]));
514
+ return { next: max + 1, profileFound: true };
515
+ } catch { return { next: 1, profileFound: false }; }
516
+ }
517
+
518
+ const cell = (s) => String(s).replace(/\|/g, '/').replace(/\s+/g, ' ').trim();
519
+ const id = (n) => 'FR-LOOK-' + String(n).padStart(3, '0');
520
+
521
+ function stepRow(n, step, total, evidenceName) {
522
+ const r = step.regularities;
523
+ const parts = ['Шаг ' + step.index + ' из ' + total];
524
+ parts.push(step.index === 1 ? 'стартовый экран' : 'достижим одним действием с предыдущего');
525
+ parts.push('полей формы: ' + r.fieldCount);
526
+ parts.push('заметных призывов: ' + r.prominentCtas + ' из ' + r.ctaCount);
527
+ parts.push('уровней заголовков: ' + r.headingLevels);
528
+ if (step.authWall) parts.push('это экран входа — путь дальше за аутентификацией, не снимается');
529
+ return '| ' + id(n) + ' | ' + cell(parts.join('; ')) + ' | путь | ' + cell(step.path)
530
+ + ' | прокликано ' + step.at + ', ' + evidenceName + ' | ЧЕРНОВИК |';
531
+ }
532
+
533
+ function sequenceRow(n, steps) {
534
+ const chain = steps.map((s) => s.path).join(' → ');
535
+ const text = 'Последовательность экранов до конца снятого пути: ' + steps.length + ' экрана(ов), '
536
+ + chain + (steps[steps.length - 1].authWall ? '; путь упирается в экран входа' : '');
537
+ return '| ' + id(n) + ' | ' + cell(text) + ' | путь | ' + cell(steps[0].path)
538
+ + ' | прокликано ' + steps[0].at + ', capture.json | ЧЕРНОВИК |';
539
+ }
540
+
541
+ // ───────────────────────────────────────────────────────────────────────────── обход
542
+
543
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
544
+
545
+ function classifyNavError(err) {
546
+ const msg = String((err && err.message) || err);
547
+ if (/Timeout|timeout|exceeded/.test(msg)) return 'timeout';
548
+ if (/ERR_NAME_NOT_RESOLVED|ERR_CONNECTION|ERR_INTERNET|ENOTFOUND|ECONNREFUSED|net::/.test(msg)) return 'unreachable';
549
+ return 'unreachable';
550
+ }
551
+
552
+ async function run(opts) {
553
+ const { mod, from } = resolvePlaywright();
554
+ say('ℹ️ playwright: ' + from);
555
+
556
+ const robots = await robotsGate(opts.start, opts.maxPages, opts.timeoutMs);
557
+ say('ℹ️ ' + robots.note);
558
+
559
+ const outDir = path.resolve(opts.project, opts.out);
560
+ fs.mkdirSync(outDir, { recursive: true });
561
+ // Каталог держит ЧУЖОЙ материал. Он не попадает в git по собственному .gitignore, а не по чужой
562
+ // дисциплине: правило, которое надо помнить, — самый слабый слой лестницы обнаружения.
563
+ fs.writeFileSync(path.join(outDir, '.gitignore'), '*\n');
564
+ fs.writeFileSync(path.join(outDir, 'README.txt'),
565
+ 'Материал ЧУЖОГО сайта: скриншоты и семантические слепки, снятые для ИЗМЕРЕНИЯ.\n'
566
+ + 'Основание для измерения закономерностей — да. Копирование в свой продукт — нет.\n'
567
+ + 'Каталог закрыт собственным .gitignore и не коммитится.\n');
568
+
569
+ const launchArgs = [];
570
+ // Под суперпользователем chromium не стартует без --no-sandbox. Это условие окружения (VPS,
571
+ // контейнер), а не обход чужой защиты.
572
+ if (typeof process.getuid === 'function' && process.getuid() === 0) launchArgs.push('--no-sandbox');
573
+
574
+ let browser = null;
575
+ try {
576
+ browser = await mod.chromium.launch({ args: launchArgs });
577
+ } catch (e) {
578
+ notMeasured('no-browser', 'браузер не запустился: ' + ((e && e.message) || e),
579
+ 'бинарь браузера ставится отдельно: npx playwright install chromium');
580
+ }
581
+
582
+ const steps = [];
583
+ let outcome = null;
584
+ try {
585
+ let defaultUa = '';
586
+ try {
587
+ const probe = await browser.newPage();
588
+ defaultUa = await probe.evaluate(() => navigator.userAgent);
589
+ await probe.close();
590
+ } catch { /* UA останется без штатной части — не критично */ }
591
+ const context = await browser.newContext({
592
+ viewport: opts.viewport,
593
+ userAgent: (defaultUa ? defaultUa + ' ' : '') + UA_TOKEN,
594
+ });
595
+ const page = await context.newPage();
596
+
597
+ const visited = new Set();
598
+ let target = opts.start.toString();
599
+ let arrivedBy = null;
600
+
601
+ for (let i = 1; i <= opts.maxPages; i++) {
602
+ if (i > 1) await sleep(opts.delayMs);
603
+
604
+ const targetUrl = new URL(target);
605
+ if (robots.checked && robots.rules && !robotsAllows(robots.rules, targetUrl.pathname)) {
606
+ say('ℹ️ ' + targetUrl.pathname + ' закрыт robots.txt — обход остановлен здесь, это не ошибка');
607
+ break;
608
+ }
609
+
610
+ let response;
611
+ try {
612
+ response = await page.goto(target, { waitUntil: 'domcontentloaded', timeout: opts.timeoutMs });
613
+ } catch (e) {
614
+ const reason = classifyNavError(e);
615
+ if (i === 1) {
616
+ notMeasured(reason, 'стартовый экран не открылся: ' + ((e && e.message) || e),
617
+ reason === 'timeout' ? 'поднимите --timeout-ms либо возьмите более лёгкий экран' : null);
618
+ }
619
+ say('ℹ️ шаг ' + i + ' не открылся (' + reason + ') — путь снят до этого места');
620
+ break;
621
+ }
622
+
623
+ const status = response ? response.status() : 0;
624
+ if (status === 403 || status === 429) {
625
+ if (i === 1) {
626
+ notMeasured('bot-protected', 'сайт ответил HTTP ' + status + ' на стартовом экране',
627
+ 'обходить защиту от ботов ЗАПРЕЩЕНО; это исход, а не препятствие');
628
+ }
629
+ say('ℹ️ шаг ' + i + ': HTTP ' + status + ' — путь снят до этого места');
630
+ break;
631
+ }
632
+ if (status >= 400) {
633
+ if (i === 1) notMeasured('unreachable', 'стартовый экран ответил HTTP ' + status);
634
+ say('ℹ️ шаг ' + i + ': HTTP ' + status + ' — путь снят до этого места');
635
+ break;
636
+ }
637
+
638
+ // Тишина сети — попытка, а не обязанность: у тяжёлого лендинга её может не быть вовсе.
639
+ try { await page.waitForLoadState('networkidle', { timeout: Math.min(8000, opts.timeoutMs) }); }
640
+ catch { /* хватит domcontentloaded */ }
641
+
642
+ const finalUrl = new URL(page.url());
643
+ finalUrl.hash = '';
644
+ visited.add(finalUrl.toString());
645
+
646
+ const measure = await page.evaluate(measureInPage);
647
+
648
+ if (measure.authWall && i === 1) {
649
+ notMeasured('auth-required', 'стартовый экран уже за входом (поле пароля на первом экране)',
650
+ 'аутентификация и любой обход технических ограничений сайта ЗАПРЕЩЕНЫ');
651
+ }
652
+
653
+ const at = new Date().toISOString().slice(0, 19) + 'Z';
654
+ const base = 'step-' + String(i).padStart(2, '0');
655
+ try {
656
+ await page.screenshot({ path: path.join(outDir, base + '.png'), fullPage: opts.fullPage });
657
+ } catch { /* скриншот — доказательство, а не условие съёмки */ }
658
+ let aria = '';
659
+ try {
660
+ if (typeof page.locator('body').ariaSnapshot === 'function') {
661
+ aria = await page.locator('body').ariaSnapshot();
662
+ } else if (page.accessibility) {
663
+ aria = JSON.stringify(await page.accessibility.snapshot(), null, 1);
664
+ }
665
+ } catch { /* семантический слепок отсутствует — записываем пустым, не выдумываем */ }
666
+ fs.writeFileSync(path.join(outDir, base + '.aria.txt'), aria || '(семантический слепок недоступен)\n');
667
+ if (opts.keepDom) {
668
+ say('⚠️ --keep-dom: DOM чужого сайта сохраняется. Это ЧУЖОЙ код под авторским правом — '
669
+ + 'основание для измерения, не материал для копирования.');
670
+ fs.writeFileSync(path.join(outDir, base + '.dom.html'), await page.content());
671
+ }
672
+
673
+ steps.push({
674
+ index: i,
675
+ url: finalUrl.toString(),
676
+ path: finalUrl.pathname + (finalUrl.search || ''),
677
+ status,
678
+ title: measure.title,
679
+ arrivedBy,
680
+ authWall: measure.authWall,
681
+ at,
682
+ evidence: { screenshot: base + '.png', aria: base + '.aria.txt' },
683
+ regularities: regularities(measure),
684
+ headings: measure.headings.slice(0, 8),
685
+ fields: measure.fields,
686
+ });
687
+
688
+ if (measure.authWall) {
689
+ say('ℹ️ шаг ' + i + ' — экран входа. Останавливаемся: за вход не ходим, это законная '
690
+ + 'последняя точка пути.');
691
+ break;
692
+ }
693
+
694
+ const next = pickNext(measure, finalUrl.toString(), visited, opts.follow);
695
+ if (!next) break;
696
+ target = next.url;
697
+ arrivedBy = next.name;
698
+ }
699
+ } finally {
700
+ try { await browser.close(); } catch { /* закрыли как смогли */ }
701
+ }
702
+
703
+ if (!steps.length) {
704
+ // Сюда попадает только то, что не отсеклось причинами выше.
705
+ notMeasured('unreachable', 'ни одного экрана снять не удалось');
706
+ }
707
+
708
+ fs.writeFileSync(path.join(outDir, 'capture.json'), JSON.stringify({
709
+ tool: 'capture-source-path.cjs',
710
+ start: opts.start.toString(),
711
+ at: new Date().toISOString(),
712
+ robots: robots.note,
713
+ politeness: { maxPages: opts.maxPages, delayMs: opts.delayMs, threads: 1 },
714
+ steps,
715
+ }, null, 2));
716
+
717
+ if (steps.length === 1) {
718
+ outcome = 'no-source';
719
+ say('');
720
+ say('📄 ИСТОЧНИКА НЕТ (для оси «путь»): источник ОТКРЫЛСЯ, но перехода со стартового экрана нет.');
721
+ say(' Это доказанный отрицательный ответ — мы посмотрели, — а не слепота.');
722
+ say(' Запишите в docs/source-product-profile.md:');
723
+ say(' **Статус съёмки (путь):** ИСТОЧНИКА НЕТ');
724
+ if (opts.json) say(JSON.stringify({ outcome, steps }, null, 2));
725
+ return 1;
726
+ }
727
+
728
+ outcome = 'captured';
729
+ const { next, profileFound } = nextId(opts.project);
730
+ const rows = [];
731
+ let n = next;
732
+ for (const s of steps) rows.push(stepRow(n++, s, steps.length, s.evidence.aria));
733
+ rows.push(sequenceRow(n++, steps));
734
+
735
+ if (opts.json) {
736
+ say(JSON.stringify({ outcome, firstId: id(next), rows, steps }, null, 2));
737
+ return 0;
738
+ }
739
+
740
+ say('');
741
+ say('✅ СНЯТ: ось «путь», ' + steps.length + ' экрана(ов). Доказательства: ' + outDir);
742
+ if (!profileFound) {
743
+ say('⚠️ ' + PROFILE + ' не найден — нумерация начата с 001. Если профиль уже есть в другом '
744
+ + 'каталоге, укажите --project, иначе номера разойдутся.');
745
+ }
746
+ say('');
747
+ say('Вставьте в таблицу-семя `## 🎨 Look Requirements Seed` (строки ЧЕРНОВИК, проверьте их):');
748
+ say('');
749
+ for (const r of rows) say(r);
750
+ say('');
751
+ say('Формулировки — ЗАКОНОМЕРНОСТИ (сколько экранов, сколько полей, сколько уровней), а не');
752
+ say('ЗНАЧЕНИЯ чужого оформления. Уточняя строку руками, держитесь той же границы.');
753
+ // Сводка берётся со СТАРТОВОГО экрана, а не с последнего: дизайн-система объявляется на входе,
754
+ // а последним шагом часто оказывается экран входа — самый бедный на разметку из всех снятых.
755
+ const r0 = steps[0].regularities;
756
+ say('');
757
+ say('Замеренные закономерности СТАРТОВОГО экрана (материал для оси «облик», строк не выпускаем):');
758
+ say(' шаг сетки отступов: ' + (r0.spacingStep === null ? 'не выводится'
759
+ : r0.spacingStep.step + 'px (доля ' + r0.spacingStep.share + ')')
760
+ + ' · кеглей: ' + r0.typeLevels
761
+ + ' · начертаний: ' + r0.weights.length
762
+ + ' · радиусов: ' + r0.radii.length
763
+ + ' · переменных в :root: ' + r0.rootVars);
764
+ say(' брейкпоинты из настоящих @media: ' + (r0.breakpoints.join(', ') || 'не прочитаны')
765
+ + ' (таблиц стилей ' + r0.stylesheets.total + ', из них закрыты cross-origin: '
766
+ + r0.stylesheets.unreadable + ')');
767
+ say(' Имена классов и имена переменных НЕ читались: у сборщиков они меняются каждой сборкой.');
768
+ return 0;
769
+ }
770
+
771
+ async function main() {
772
+ const opts = parseArgs(process.argv.slice(2));
773
+ const code = await run(opts);
774
+ process.exit(code);
775
+ }
776
+
777
+ /**
778
+ * Чистые половины вынесены наружу НАМЕРЕННО: разбор robots.txt, вывод шага сетки, нумерация и
779
+ * формат строк проверяются детерминированно и БЕЗ браузера — слой 1 лестницы обнаружения. Оставь
780
+ * их внутри — и единственным способом их проверить стал бы живой прогон, то есть проверка,
781
+ * молчащая ровно там, где браузера нет.
782
+ */
783
+ if (require.main === module) {
784
+ main().catch((err) => {
785
+ // Даже неожиданный отказ не должен читаться как «снято».
786
+ notMeasured('out-of-scope', 'внутренняя ошибка съёмки: ' + String((err && err.stack) || err),
787
+ 'это дефект инструмента, а не ответ о продукте — почините вызов и повторите');
788
+ });
789
+ } else {
790
+ module.exports = {
791
+ REASONS, MAX_PAGES_CAP, MIN_DELAY_MS, ROBOTS_AGENT, UA_TOKEN,
792
+ parseRobots, robotsAllows, patternToRegExp, spacingStep, regularities,
793
+ pickNext, nextId, stepRow, sequenceRow, resolvePlaywright,
794
+ };
795
+ }