@gobing-ai/knowledge-kit 0.0.36 → 0.1.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/board/collect/index.css +654 -16
- package/dist/board/collect/index.js +191 -64
- package/dist/board/daily-voice/index.css +679 -12
- package/dist/board/daily-voice/index.js +185 -66
- package/dist/board/itc/index.css +679 -13
- package/dist/board/itc/index.js +188 -66
- package/dist/index.js +2878 -152
- package/package.json +3 -2
- package/plugins/generations/content-gen/dist/index.js +158 -12
- package/plugins/generations/core-facts-gen/dist/index.js +158 -12
- package/plugins/generations/daily-article-gen/dist/index.js +158 -12
- package/plugins/generations/dailynews-gen/dist/index.js +180 -12
- package/plugins/generations/dailynews-gen/src/script-plan.ts +32 -0
- package/plugins/generations/episode-plan-gen/dist/index.js +158 -12
- package/plugins/generations/gemini-tts/README.md +4 -3
- package/plugins/generations/gemini-tts/dist/index.js +158 -12
- package/plugins/generations/image-gen/dist/index.js +158 -12
- package/plugins/generations/news-report-gen/dist/index.js +158 -12
- package/plugins/generations/omni-voice-gen/README.md +15 -4
- package/plugins/generations/omni-voice-gen/profiles.json +1 -2
- package/plugins/generations/voice-gen/dist/index.js +158 -12
- package/plugins/generations/voxcpm-tts/README.md +21 -1
- package/plugins/generations/voxcpm-tts/dist/index.js +173 -13
- package/plugins/generations/voxcpm-tts/scripts/audit-speaker-consistency.py +109 -0
- package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +28 -1
- package/plugins/ingestions/aihot-ingest/dist/index.js +158 -12
- package/plugins/ingestions/horizon-ingest/dist/index.js +158 -12
- package/plugins/ingestions/last30days-ingest/dist/index.js +158 -12
- package/plugins/ingestions/web-search/dist/index.js +158 -12
- package/plugins/kk/commands/workflow-run.md +56 -18
- package/plugins/kk/decisions/kk-itc.yaml +46 -0
- package/plugins/kk/decisions/kk-seo-keyword.yaml +57 -0
- package/plugins/kk/decisions/kk-seo-page.yaml +230 -0
- package/plugins/kk/decisions/kk-seo-pair.yaml +21 -0
- package/plugins/kk/decisions/kk-seo-site.yaml +90 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/publish-presets/chinese-article.json +14 -0
- package/plugins/kk/publish-presets/technical-article.json +14 -0
- package/plugins/kk/references/smart-seo-attribution.md +85 -0
- package/plugins/kk/scripts/daily-stages.ts +42 -2
- package/plugins/kk/scripts/daily-voice-style.zh.md +1 -1
- package/plugins/kk/scripts/itc-stages.ts +25 -118
- package/plugins/kk/scripts/kk-workflow-stages.ts +38 -1
- package/plugins/kk/scripts/seo-decisions.ts +477 -0
- package/plugins/kk/scripts/seo-enrich.ts +728 -0
- package/plugins/kk/scripts/seo-facts.ts +610 -0
- package/plugins/kk/scripts/seo-fetch.ts +766 -0
- package/plugins/kk/scripts/seo-report.ts +460 -0
- package/plugins/kk/scripts/seo-rules.ts +1882 -0
- package/plugins/kk/scripts/seo-stages.ts +1044 -0
- package/plugins/kk/skills/article-adapt/SKILL.md +2 -2
- package/plugins/kk/skills/smart-seo/SKILL.md +183 -0
- package/plugins/kk/skills/smart-seo/references/method.md +126 -0
- package/plugins/kk/skills/smart-seo/references/narrative.md +125 -0
- package/plugins/kk/skills/smart-seo/references/operations.md +202 -0
- package/plugins/kk/skills/smart-seo/references/seo-geo-sources.md +90 -0
- package/plugins/kk/workflows/feature-verification.yaml +28 -24
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +86 -18
- package/plugins/kk/workflows/kk-itc-author.yaml +406 -0
- package/plugins/kk/workflows/kk-itc.yaml +208 -682
- package/plugins/kk/workflows/kk-smart-seo.yaml +251 -0
- package/plugins/publishings/bilibili-pub/dist/index.js +21782 -0
- package/plugins/publishings/bilibili-pub/dist/profile-check.js +20410 -0
- package/plugins/publishings/bilibili-pub/package.json +20 -0
- package/plugins/publishings/bilibili-pub/plugin.json +12 -0
- package/plugins/publishings/bilibili-pub/src/flow.ts +1296 -0
- package/plugins/publishings/bilibili-pub/src/index.ts +139 -0
- package/plugins/publishings/bilibili-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/bilibili-pub/src/selectors.ts +222 -0
- package/plugins/publishings/bilibili-pub/tsconfig.json +4 -0
- package/plugins/publishings/cnblogs-pub/dist/index.js +21383 -0
- package/plugins/publishings/cnblogs-pub/dist/profile-check.js +20412 -0
- package/plugins/publishings/cnblogs-pub/package.json +19 -0
- package/plugins/publishings/cnblogs-pub/plugin.json +12 -0
- package/plugins/publishings/cnblogs-pub/src/flow.ts +708 -0
- package/plugins/publishings/cnblogs-pub/src/index.ts +172 -0
- package/plugins/publishings/cnblogs-pub/src/profile-check.ts +24 -0
- package/plugins/publishings/cnblogs-pub/src/selectors.ts +104 -0
- package/plugins/publishings/cnblogs-pub/tsconfig.json +4 -0
- package/plugins/publishings/csdn-pub/dist/index.js +21552 -0
- package/plugins/publishings/csdn-pub/dist/profile-check.js +20963 -0
- package/plugins/publishings/csdn-pub/package.json +19 -0
- package/plugins/publishings/csdn-pub/plugin.json +12 -0
- package/plugins/publishings/csdn-pub/src/index.ts +1178 -0
- package/plugins/publishings/csdn-pub/src/profile-check.ts +23 -0
- package/plugins/publishings/csdn-pub/tsconfig.json +4 -0
- package/plugins/publishings/emdash-pub/dist/index.js +158 -12
- package/plugins/publishings/emdash-pub/plugin.json +22 -1
- package/plugins/publishings/infoq-pub/dist/index.js +160 -12
- package/plugins/publishings/infoq-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/infoq-pub/plugin.json +21 -0
- package/plugins/publishings/jike-pub/dist/index.js +21379 -0
- package/plugins/publishings/jike-pub/dist/profile-check.js +20951 -0
- package/plugins/publishings/jike-pub/package.json +19 -0
- package/plugins/publishings/jike-pub/plugin.json +12 -0
- package/plugins/publishings/jike-pub/src/index.ts +800 -0
- package/plugins/publishings/jike-pub/src/profile-check.ts +21 -0
- package/plugins/publishings/jike-pub/tsconfig.json +4 -0
- package/plugins/publishings/juejin-pub/dist/index.js +160 -12
- package/plugins/publishings/juejin-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/juejin-pub/plugin.json +21 -0
- package/plugins/publishings/medium-pub/dist/index.js +158 -12
- package/plugins/publishings/medium-pub/plugin.json +22 -1
- package/plugins/publishings/podcast-pub/dist/index.js +158 -12
- package/plugins/publishings/qiita-pub/dist/index.js +158 -12
- package/plugins/publishings/qiita-pub/plugin.json +24 -1
- package/plugins/publishings/shipinhao-pub/dist/index.js +21715 -0
- package/plugins/publishings/shipinhao-pub/dist/profile-check.js +20431 -0
- package/plugins/publishings/shipinhao-pub/package.json +19 -0
- package/plugins/publishings/shipinhao-pub/plugin.json +12 -0
- package/plugins/publishings/shipinhao-pub/src/flow.ts +1323 -0
- package/plugins/publishings/shipinhao-pub/src/index.ts +173 -0
- package/plugins/publishings/shipinhao-pub/src/profile-check.ts +25 -0
- package/plugins/publishings/shipinhao-pub/src/selectors.ts +241 -0
- package/plugins/publishings/shipinhao-pub/tsconfig.json +4 -0
- package/plugins/publishings/sspai-pub/dist/index.js +21371 -0
- package/plugins/publishings/sspai-pub/dist/profile-check.js +20414 -0
- package/plugins/publishings/sspai-pub/package.json +19 -0
- package/plugins/publishings/sspai-pub/plugin.json +12 -0
- package/plugins/publishings/sspai-pub/src/flow.ts +624 -0
- package/plugins/publishings/sspai-pub/src/index.ts +167 -0
- package/plugins/publishings/sspai-pub/src/profile-check.ts +25 -0
- package/plugins/publishings/sspai-pub/src/selectors.ts +122 -0
- package/plugins/publishings/sspai-pub/tsconfig.json +4 -0
- package/plugins/publishings/substack-pub/dist/index.js +160 -12
- package/plugins/publishings/substack-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/substack-pub/plugin.json +21 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +158 -12
- package/plugins/publishings/surfdash-pub/plugin.json +22 -1
- package/plugins/publishings/threads-pub/dist/index.js +158 -12
- package/plugins/publishings/wechatmp-pub/dist/index.js +1669 -502
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +204 -21
- package/plugins/publishings/wechatmp-pub/plugin.json +32 -0
- package/plugins/publishings/wechatmp-pub/src/cgi-draft.ts +540 -0
- package/plugins/publishings/wechatmp-pub/src/cgi-media.ts +380 -0
- package/plugins/publishings/wechatmp-pub/src/cgi.ts +485 -0
- package/plugins/publishings/wechatmp-pub/src/flow.ts +104 -0
- package/plugins/publishings/weibo-pub/dist/index.js +160 -12
- package/plugins/publishings/weibo-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/weibo-pub/plugin.json +35 -0
- package/plugins/publishings/x-pub/dist/index.js +160 -12
- package/plugins/publishings/x-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/x-pub/plugin.json +40 -0
- package/plugins/publishings/xhs-pub/dist/index.js +160 -12
- package/plugins/publishings/xhs-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/xhs-pub/plugin.json +37 -0
- package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +21491 -0
- package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +20417 -0
- package/plugins/publishings/xiaoyuzhou-pub/package.json +19 -0
- package/plugins/publishings/xiaoyuzhou-pub/plugin.json +12 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/flow.ts +826 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/index.ts +174 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/profile-check.ts +26 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/selectors.ts +125 -0
- package/plugins/publishings/xiaoyuzhou-pub/tsconfig.json +4 -0
- package/plugins/publishings/zenn-pub/dist/index.js +158 -12
- package/plugins/publishings/zenn-pub/plugin.json +22 -1
- package/plugins/generations/omni-voice-gen/voices/robin-news.wav +0 -0
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { dirname, join, resolve } from 'node:path';
|
|
13
|
+
import { type DecisionStubHandler, executeSeoJudgeStage } from './seo-decisions';
|
|
14
|
+
import { type EnrichDeps, runSeoEnrich } from './seo-enrich';
|
|
15
|
+
import {
|
|
16
|
+
type CrawlArtifact,
|
|
17
|
+
extractHtmlFacts,
|
|
18
|
+
getScopeHostnames,
|
|
19
|
+
type LimitsRecord,
|
|
20
|
+
type ManifestArtifact,
|
|
21
|
+
normalizeUrl,
|
|
22
|
+
type PageRecord,
|
|
23
|
+
type RequestRecord,
|
|
24
|
+
type SeoOptions,
|
|
25
|
+
sha256,
|
|
26
|
+
validateOptions,
|
|
27
|
+
} from './seo-facts';
|
|
28
|
+
import {
|
|
29
|
+
assertSeoNotCancelled,
|
|
30
|
+
fetchWithPinnedDns,
|
|
31
|
+
installSeoStageCancellation,
|
|
32
|
+
parseRobotsTxt,
|
|
33
|
+
parseSitemapXml,
|
|
34
|
+
SeoStageCancelledError,
|
|
35
|
+
seoSignalExitCode,
|
|
36
|
+
writeJsonAtomic,
|
|
37
|
+
} from './seo-fetch';
|
|
38
|
+
import {
|
|
39
|
+
type AuditArtifact,
|
|
40
|
+
type ReportJsonArtifact,
|
|
41
|
+
renderDeterministicSummary,
|
|
42
|
+
renderNarrativeSections,
|
|
43
|
+
renderReportMarkdown,
|
|
44
|
+
validateNarrative,
|
|
45
|
+
} from './seo-report';
|
|
46
|
+
import { type AssessmentRow, buildPrioritizedActions, calculateScoresAndCoverage, runSeoChecks } from './seo-rules';
|
|
47
|
+
|
|
48
|
+
function fail(message: string): never {
|
|
49
|
+
throw new Error(message);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Sibling temp file + rename, so a reader never observes a partially written value. */
|
|
53
|
+
function writeTextAtomic(destPath: string, text: string): void {
|
|
54
|
+
const parent = dirname(destPath);
|
|
55
|
+
if (!existsSync(parent)) {
|
|
56
|
+
mkdirSync(parent, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
const tmpPath = `${destPath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
59
|
+
try {
|
|
60
|
+
writeFileSync(tmpPath, text, 'utf-8');
|
|
61
|
+
renameSync(tmpPath, destPath);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
try {
|
|
64
|
+
rmSync(tmpPath, { force: true });
|
|
65
|
+
} catch {
|
|
66
|
+
// best-effort cleanup
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const allowAll = (): boolean => true;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* SEO decision catalogs the integrated workflow requires (design §7/§10).
|
|
76
|
+
* Direct collection-only stage use passes `require_decisions=false` and needs none of these.
|
|
77
|
+
*/
|
|
78
|
+
export const REQUIRED_SEO_CATALOGS = [
|
|
79
|
+
'kk-seo-site.yaml',
|
|
80
|
+
'kk-seo-page.yaml',
|
|
81
|
+
'kk-seo-pair.yaml',
|
|
82
|
+
'kk-seo-keyword.yaml',
|
|
83
|
+
] as const;
|
|
84
|
+
|
|
85
|
+
/** The 22 declared SEO decision ids (§7). A served catalog missing any of these is incomplete. */
|
|
86
|
+
export const REQUIRED_SEO_DECISION_IDS = [
|
|
87
|
+
'kk-seo-business-model',
|
|
88
|
+
'kk-seo-value-proposition',
|
|
89
|
+
'kk-seo-entity-clarity',
|
|
90
|
+
'kk-seo-topical-focus',
|
|
91
|
+
'kk-seo-local-intent',
|
|
92
|
+
'kk-seo-page-type',
|
|
93
|
+
'kk-seo-search-intent',
|
|
94
|
+
'kk-seo-page-action',
|
|
95
|
+
'kk-seo-importance',
|
|
96
|
+
'kk-seo-helpfulness',
|
|
97
|
+
'kk-seo-specificity',
|
|
98
|
+
'kk-seo-trust',
|
|
99
|
+
'kk-seo-citability',
|
|
100
|
+
'kk-seo-title-fit',
|
|
101
|
+
'kk-seo-meta-fit',
|
|
102
|
+
'kk-seo-answer-first',
|
|
103
|
+
'kk-seo-next-step',
|
|
104
|
+
'kk-seo-heading-fit',
|
|
105
|
+
'kk-seo-intent-overlap',
|
|
106
|
+
'kk-seo-keyword-relevance',
|
|
107
|
+
'kk-seo-keyword-other-brand',
|
|
108
|
+
'kk-seo-keyword-page',
|
|
109
|
+
] as const;
|
|
110
|
+
|
|
111
|
+
/** Configuration-pointing failure text for a missing SEO contract (§10). */
|
|
112
|
+
function missingDecisionsMessage(detail: string): string {
|
|
113
|
+
return [
|
|
114
|
+
`seo-preflight: SEO decision catalogs are not served — ${detail}`,
|
|
115
|
+
'Registration fix: append `<kk-package-root>/plugins/kk/decisions` to the project config `decisions.paths`',
|
|
116
|
+
'(`.spur/config.yaml`), preserving every existing entry and maker setting, then re-run preflight.',
|
|
117
|
+
'Repeat the setup if an upgrade changes the resolved package root.',
|
|
118
|
+
].join('\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Static readiness probe for the integrated workflow (`require_decisions=true`).
|
|
123
|
+
* Reads the served catalog inventory only — it is configuration evidence, never model health,
|
|
124
|
+
* and it dispatches no maker, so it is safe in offline fixture mode too.
|
|
125
|
+
*/
|
|
126
|
+
export function checkSeoDecisionContracts(
|
|
127
|
+
runJson: (args: string[]) => { status: number | null; stdout: string },
|
|
128
|
+
): void {
|
|
129
|
+
const res = runJson(['decision', 'list', '--json']);
|
|
130
|
+
if (res.status !== 0) {
|
|
131
|
+
fail(missingDecisionsMessage('`spur decision list --json` did not succeed'));
|
|
132
|
+
}
|
|
133
|
+
let entries: unknown;
|
|
134
|
+
try {
|
|
135
|
+
entries = JSON.parse(res.stdout);
|
|
136
|
+
} catch {
|
|
137
|
+
fail(missingDecisionsMessage('`spur decision list --json` did not return JSON'));
|
|
138
|
+
}
|
|
139
|
+
if (!Array.isArray(entries)) fail(missingDecisionsMessage('`spur decision list --json` did not return an array'));
|
|
140
|
+
|
|
141
|
+
const servedIds = new Set<string>();
|
|
142
|
+
const servedCatalogs = new Set<string>();
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (typeof entry !== 'object' || entry === null) continue;
|
|
145
|
+
const record = entry as { id?: unknown; source?: unknown };
|
|
146
|
+
if (typeof record.id === 'string') servedIds.add(record.id);
|
|
147
|
+
if (typeof record.source === 'string') servedCatalogs.add(record.source.split('/').pop() ?? '');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const missingCatalogs = REQUIRED_SEO_CATALOGS.filter((name) => !servedCatalogs.has(name));
|
|
151
|
+
const missingIds = REQUIRED_SEO_DECISION_IDS.filter((id) => !servedIds.has(id));
|
|
152
|
+
if (missingCatalogs.length === 0 && missingIds.length === 0) return;
|
|
153
|
+
const detail = [
|
|
154
|
+
missingCatalogs.length > 0 ? `missing catalog file(s): ${missingCatalogs.join(', ')}` : '',
|
|
155
|
+
missingIds.length > 0 ? `missing decision id(s): ${missingIds.join(', ')}` : '',
|
|
156
|
+
]
|
|
157
|
+
.filter((part) => part !== '')
|
|
158
|
+
.join('; ');
|
|
159
|
+
fail(missingDecisionsMessage(detail));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Format UTC date as YYYYMMDDTHHMMSSZ.
|
|
164
|
+
*/
|
|
165
|
+
function formatUtcStamp(date = new Date()): string {
|
|
166
|
+
return date.toISOString().replace(/[-:]/g, '').slice(0, 15).concat('Z');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* `seo-preflight <work_dir> <options_file> [run_id] [require_decisions]`
|
|
171
|
+
*/
|
|
172
|
+
export async function seoPreflight(args: string[]): Promise<void> {
|
|
173
|
+
const [workDirArg = '', optionsFileArg = '', runIdArg = '', requireDecisionsArg = 'false'] = args;
|
|
174
|
+
|
|
175
|
+
if (!optionsFileArg) {
|
|
176
|
+
fail('usage: kk stage run seo-preflight <work_dir> <options_file> [run_id] [require_decisions]');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const optionsPath = resolve(optionsFileArg);
|
|
180
|
+
if (!existsSync(optionsPath)) {
|
|
181
|
+
fail(`seo-preflight: options file not found at ${optionsPath}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let optionsRaw: string;
|
|
185
|
+
try {
|
|
186
|
+
optionsRaw = readFileSync(optionsPath, 'utf-8');
|
|
187
|
+
} catch (err) {
|
|
188
|
+
fail(`seo-preflight: cannot read options file at ${optionsPath}: ${String(err)}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (Buffer.byteLength(optionsRaw, 'utf-8') > 64 * 1024) {
|
|
192
|
+
fail(`seo-preflight: options file exceeds 64 KiB limit (${Buffer.byteLength(optionsRaw, 'utf-8')} bytes)`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let parsedJson: unknown;
|
|
196
|
+
try {
|
|
197
|
+
parsedJson = JSON.parse(optionsRaw);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
fail(`seo-preflight: options file is invalid JSON: ${String(err)}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let options: SeoOptions;
|
|
203
|
+
try {
|
|
204
|
+
options = validateOptions(parsedJson);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
fail(`seo-preflight: options validation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const runId = runIdArg.trim() || `seo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
210
|
+
const utcStamp = formatUtcStamp();
|
|
211
|
+
|
|
212
|
+
let workDir: string;
|
|
213
|
+
if (workDirArg.trim() === '') {
|
|
214
|
+
workDir = resolve(join('.works', 'kk-smart-seo', `${utcStamp}-${runId}`));
|
|
215
|
+
} else {
|
|
216
|
+
workDir = resolve(workDirArg.trim());
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Refuse overwrite if workDir exists and is non-empty
|
|
220
|
+
if (existsSync(workDir)) {
|
|
221
|
+
const files = readdirSync(workDir);
|
|
222
|
+
if (files.length > 0) {
|
|
223
|
+
fail(`seo-preflight: work_dir exists and is non-empty: ${workDir} (refusing overwrite)`);
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
mkdirSync(workDir, { recursive: true });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const inputDigest = sha256(optionsRaw);
|
|
230
|
+
|
|
231
|
+
const manifest: ManifestArtifact = {
|
|
232
|
+
schemaVersion: 1,
|
|
233
|
+
methodVersion: '1.0.0',
|
|
234
|
+
runId,
|
|
235
|
+
createdAt: new Date().toISOString(),
|
|
236
|
+
inputDigest,
|
|
237
|
+
stage: 'preflight',
|
|
238
|
+
options,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Write options.json and manifest.json atomically
|
|
242
|
+
writeJsonAtomic(join(workDir, 'options.json'), options);
|
|
243
|
+
writeJsonAtomic(join(workDir, 'manifest.json'), manifest);
|
|
244
|
+
|
|
245
|
+
// Atomically write the run-bound work-dir file (.spur/run/<run-id>-seo-work-dir.txt).
|
|
246
|
+
// Plain text on purpose: the workflow projects this exact file into `work_dir` with
|
|
247
|
+
// `file.read.into-var`, which sets the var to the raw file content (design §3).
|
|
248
|
+
const runDir = resolve('.spur', 'run');
|
|
249
|
+
if (!existsSync(runDir)) {
|
|
250
|
+
mkdirSync(runDir, { recursive: true });
|
|
251
|
+
}
|
|
252
|
+
const runWorkDirFile = join(runDir, `${runId}-seo-work-dir.txt`);
|
|
253
|
+
writeTextAtomic(runWorkDirFile, workDir);
|
|
254
|
+
|
|
255
|
+
// Check require_decisions: the integrated workflow requires the four SEO contracts to be
|
|
256
|
+
// served before any collection or judgment begins (design §7/§10).
|
|
257
|
+
const requireDecisions = requireDecisionsArg === 'true';
|
|
258
|
+
if (requireDecisions) {
|
|
259
|
+
checkSeoDecisionContracts((args) => {
|
|
260
|
+
const res = spawnSync(process.env.KK_SPUR_BIN || 'spur', args, {
|
|
261
|
+
encoding: 'utf-8',
|
|
262
|
+
env: process.env,
|
|
263
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
264
|
+
});
|
|
265
|
+
return { status: res.status, stdout: String(res.stdout ?? '') };
|
|
266
|
+
});
|
|
267
|
+
console.error('seo-preflight: decision contracts verified (require_decisions=true)');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.error(`seo-preflight: initialized work_dir=${workDir} runId=${runId}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* `seo-collect <work_dir>`
|
|
275
|
+
*/
|
|
276
|
+
export async function seoCollect(
|
|
277
|
+
args: string[],
|
|
278
|
+
transportOpts: { allowLoopback?: boolean; ca?: string | Buffer } = {},
|
|
279
|
+
): Promise<void> {
|
|
280
|
+
const [workDirArg = ''] = args;
|
|
281
|
+
if (!workDirArg) {
|
|
282
|
+
fail('usage: kk stage run seo-collect <work_dir>');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const workDir = resolve(workDirArg);
|
|
286
|
+
const optionsPath = join(workDir, 'options.json');
|
|
287
|
+
const manifestPath = join(workDir, 'manifest.json');
|
|
288
|
+
|
|
289
|
+
if (!existsSync(optionsPath) || !existsSync(manifestPath)) {
|
|
290
|
+
fail(`seo-collect: missing options.json or manifest.json in ${workDir}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const optionsRaw = readFileSync(optionsPath, 'utf-8');
|
|
294
|
+
const manifestRaw = readFileSync(manifestPath, 'utf-8');
|
|
295
|
+
|
|
296
|
+
const options: SeoOptions = validateOptions(JSON.parse(optionsRaw));
|
|
297
|
+
const manifest: ManifestArtifact = JSON.parse(manifestRaw);
|
|
298
|
+
|
|
299
|
+
const runId = manifest.runId;
|
|
300
|
+
const inputDigest = manifest.inputDigest;
|
|
301
|
+
const predecessorDigests: Record<string, string> = {
|
|
302
|
+
'options.json': sha256(optionsRaw),
|
|
303
|
+
'manifest.json': sha256(manifestRaw),
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const targetUrl = options.url;
|
|
307
|
+
const parsedTarget = new URL(targetUrl);
|
|
308
|
+
const scopeHostnames = getScopeHostnames(parsedTarget.hostname);
|
|
309
|
+
|
|
310
|
+
const pages: PageRecord[] = [];
|
|
311
|
+
const requests: RequestRecord[] = [];
|
|
312
|
+
const limits: LimitsRecord = {
|
|
313
|
+
pagesVisited: 0,
|
|
314
|
+
requestsMade: 0,
|
|
315
|
+
totalBytes: 0,
|
|
316
|
+
elapsedSeconds: 0,
|
|
317
|
+
hitLimit: null,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const startTime = Date.now();
|
|
321
|
+
const deadlineMs = startTime + options.deadline_seconds * 1000;
|
|
322
|
+
const maxRequests = options.max_requests;
|
|
323
|
+
const maxPages = options.max_pages;
|
|
324
|
+
const maxDepth = options.max_depth;
|
|
325
|
+
const maxTotalBytes = 40 * 1024 * 1024; // 40 MiB
|
|
326
|
+
|
|
327
|
+
// Prepare evidence directory
|
|
328
|
+
const evidencePagesDir = join(workDir, 'evidence', 'pages');
|
|
329
|
+
mkdirSync(evidencePagesDir, { recursive: true });
|
|
330
|
+
|
|
331
|
+
// Handle offline fixture mode
|
|
332
|
+
if (options.fixture && options.fixture.trim() !== '') {
|
|
333
|
+
const fixtureDir = resolve(options.fixture.trim());
|
|
334
|
+
if (!existsSync(fixtureDir)) {
|
|
335
|
+
fail(`seo-collect: fixture directory not found: ${fixtureDir}`);
|
|
336
|
+
}
|
|
337
|
+
console.error(`seo-collect: loading offline fixture from ${fixtureDir}`);
|
|
338
|
+
// Read fixture crawl.json if present
|
|
339
|
+
const candidates = ['crawl.json', 'smart-seo-minimal-crawl.json'];
|
|
340
|
+
for (const cand of candidates) {
|
|
341
|
+
const fixtureCrawl = join(fixtureDir, cand);
|
|
342
|
+
if (existsSync(fixtureCrawl)) {
|
|
343
|
+
const data: CrawlArtifact = JSON.parse(readFileSync(fixtureCrawl, 'utf-8'));
|
|
344
|
+
data.runId = runId;
|
|
345
|
+
data.createdAt = new Date().toISOString();
|
|
346
|
+
data.inputDigest = inputDigest;
|
|
347
|
+
data.predecessorDigests = predecessorDigests;
|
|
348
|
+
writeJsonAtomic(join(workDir, 'crawl.json'), data);
|
|
349
|
+
console.error(`seo-collect: copied fixture crawl.json with ${data.pages.length} pages`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const allowLoopback = options.allow_test_loopback ?? transportOpts.allowLoopback ?? false;
|
|
356
|
+
const effectiveTransportOpts = { ...transportOpts, allowLoopback };
|
|
357
|
+
|
|
358
|
+
// Step A: Fetch robots.txt
|
|
359
|
+
const origin = parsedTarget.origin;
|
|
360
|
+
const robotsUrl = `${origin}/robots.txt`;
|
|
361
|
+
let robotsPolicy = {
|
|
362
|
+
rawDigest: '',
|
|
363
|
+
isAvailable: true,
|
|
364
|
+
isBlocked: false,
|
|
365
|
+
crawlDelaySeconds: null as number | null,
|
|
366
|
+
sitemaps: [] as string[],
|
|
367
|
+
isPathAllowed: allowAll,
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
const reqStart = Date.now();
|
|
372
|
+
const robotsRes = await fetchWithPinnedDns(robotsUrl, scopeHostnames, {
|
|
373
|
+
...effectiveTransportOpts,
|
|
374
|
+
timeoutMs: Math.min(20000, Math.max(1000, deadlineMs - Date.now())),
|
|
375
|
+
});
|
|
376
|
+
const durationMs = Date.now() - reqStart;
|
|
377
|
+
limits.requestsMade += 1;
|
|
378
|
+
limits.totalBytes += robotsRes.bytesReceived;
|
|
379
|
+
|
|
380
|
+
requests.push({
|
|
381
|
+
url: robotsUrl,
|
|
382
|
+
method: 'GET',
|
|
383
|
+
status: robotsRes.status,
|
|
384
|
+
durationMs,
|
|
385
|
+
bytesReceived: robotsRes.bytesReceived,
|
|
386
|
+
error: null,
|
|
387
|
+
timestamp: new Date().toISOString(),
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
if (robotsRes.status === 404 || robotsRes.status === 410) {
|
|
391
|
+
// Absent policy: allows all
|
|
392
|
+
robotsPolicy = {
|
|
393
|
+
rawDigest: '',
|
|
394
|
+
isAvailable: true,
|
|
395
|
+
isBlocked: false,
|
|
396
|
+
crawlDelaySeconds: null,
|
|
397
|
+
sitemaps: [],
|
|
398
|
+
isPathAllowed: allowAll,
|
|
399
|
+
};
|
|
400
|
+
} else if (robotsRes.status === 401 || robotsRes.status === 403) {
|
|
401
|
+
// Blocked: disallow all
|
|
402
|
+
robotsPolicy = {
|
|
403
|
+
rawDigest: '',
|
|
404
|
+
isAvailable: true,
|
|
405
|
+
isBlocked: true,
|
|
406
|
+
crawlDelaySeconds: null,
|
|
407
|
+
sitemaps: [],
|
|
408
|
+
isPathAllowed: allowAll,
|
|
409
|
+
};
|
|
410
|
+
} else if (robotsRes.status >= 200 && robotsRes.status < 300) {
|
|
411
|
+
robotsPolicy = parseRobotsTxt(robotsRes.body);
|
|
412
|
+
} else {
|
|
413
|
+
// 429, 5xx, or other error: unavailable policy
|
|
414
|
+
robotsPolicy = {
|
|
415
|
+
rawDigest: '',
|
|
416
|
+
isAvailable: false,
|
|
417
|
+
isBlocked: false,
|
|
418
|
+
crawlDelaySeconds: null,
|
|
419
|
+
sitemaps: [],
|
|
420
|
+
isPathAllowed: allowAll,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
} catch (err: unknown) {
|
|
424
|
+
limits.requestsMade += 1;
|
|
425
|
+
requests.push({
|
|
426
|
+
url: robotsUrl,
|
|
427
|
+
method: 'GET',
|
|
428
|
+
status: null,
|
|
429
|
+
durationMs: 0,
|
|
430
|
+
bytesReceived: 0,
|
|
431
|
+
error: err instanceof Error ? err.message : String(err),
|
|
432
|
+
timestamp: new Date().toISOString(),
|
|
433
|
+
});
|
|
434
|
+
robotsPolicy = {
|
|
435
|
+
rawDigest: '',
|
|
436
|
+
isAvailable: false,
|
|
437
|
+
isBlocked: false,
|
|
438
|
+
crawlDelaySeconds: null,
|
|
439
|
+
sitemaps: [],
|
|
440
|
+
isPathAllowed: allowAll,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Step B: Sitemap discovery
|
|
445
|
+
const candidateUrls: Array<{ url: string; depth: number }> = [{ url: targetUrl, depth: 0 }];
|
|
446
|
+
const sitemapCandidates = [...robotsPolicy.sitemaps];
|
|
447
|
+
if (sitemapCandidates.length === 0) {
|
|
448
|
+
sitemapCandidates.push(`${origin}/sitemap.xml`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const processedSitemaps = new Set<string>();
|
|
452
|
+
while (sitemapCandidates.length > 0 && processedSitemaps.size < 25) {
|
|
453
|
+
if (Date.now() >= deadlineMs) {
|
|
454
|
+
limits.hitLimit = 'deadline';
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
if (limits.requestsMade >= maxRequests) {
|
|
458
|
+
limits.hitLimit = 'max_requests';
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const rawSitemapUrl = sitemapCandidates.shift();
|
|
463
|
+
if (!rawSitemapUrl || processedSitemaps.has(rawSitemapUrl)) continue;
|
|
464
|
+
processedSitemaps.add(rawSitemapUrl);
|
|
465
|
+
|
|
466
|
+
let sitemapUrl: string;
|
|
467
|
+
try {
|
|
468
|
+
sitemapUrl = new URL(rawSitemapUrl, origin).toString();
|
|
469
|
+
} catch {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
const smParsed = new URL(sitemapUrl);
|
|
475
|
+
if (!scopeHostnames.has(smParsed.hostname.toLowerCase())) continue;
|
|
476
|
+
|
|
477
|
+
const reqStart = Date.now();
|
|
478
|
+
const smRes = await fetchWithPinnedDns(sitemapUrl, scopeHostnames, {
|
|
479
|
+
...effectiveTransportOpts,
|
|
480
|
+
timeoutMs: Math.min(20000, Math.max(1000, deadlineMs - Date.now())),
|
|
481
|
+
});
|
|
482
|
+
limits.requestsMade += 1;
|
|
483
|
+
limits.totalBytes += smRes.bytesReceived;
|
|
484
|
+
|
|
485
|
+
requests.push({
|
|
486
|
+
url: sitemapUrl,
|
|
487
|
+
method: 'GET',
|
|
488
|
+
status: smRes.status,
|
|
489
|
+
durationMs: Date.now() - reqStart,
|
|
490
|
+
bytesReceived: smRes.bytesReceived,
|
|
491
|
+
error: null,
|
|
492
|
+
timestamp: new Date().toISOString(),
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
if (smRes.status === 200 && smRes.body) {
|
|
496
|
+
const parsedSm = parseSitemapXml(smRes.body);
|
|
497
|
+
for (const child of parsedSm.childSitemaps) {
|
|
498
|
+
if (!processedSitemaps.has(child) && sitemapCandidates.length < 50) {
|
|
499
|
+
sitemapCandidates.push(child);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
for (const discovered of parsedSm.urls) {
|
|
503
|
+
try {
|
|
504
|
+
const dUrl = normalizeUrl(discovered);
|
|
505
|
+
const dParsed = new URL(dUrl);
|
|
506
|
+
if (scopeHostnames.has(dParsed.hostname.toLowerCase())) {
|
|
507
|
+
candidateUrls.push({ url: dUrl, depth: 1 });
|
|
508
|
+
}
|
|
509
|
+
} catch {
|
|
510
|
+
// ignore malformed URL in sitemap
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
} catch (err: unknown) {
|
|
515
|
+
limits.requestsMade += 1;
|
|
516
|
+
requests.push({
|
|
517
|
+
url: sitemapUrl,
|
|
518
|
+
method: 'GET',
|
|
519
|
+
status: null,
|
|
520
|
+
durationMs: 0,
|
|
521
|
+
bytesReceived: 0,
|
|
522
|
+
error: err instanceof Error ? err.message : String(err),
|
|
523
|
+
timestamp: new Date().toISOString(),
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Step C: Crawl queue
|
|
529
|
+
const visitedUrls = new Set<string>();
|
|
530
|
+
|
|
531
|
+
while (candidateUrls.length > 0 && limits.pagesVisited < maxPages) {
|
|
532
|
+
// Cancellation stops new page dispatch even when no request is in flight (design §4).
|
|
533
|
+
assertSeoNotCancelled();
|
|
534
|
+
if (Date.now() >= deadlineMs) {
|
|
535
|
+
limits.hitLimit = 'deadline';
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
if (limits.requestsMade >= maxRequests) {
|
|
539
|
+
limits.hitLimit = 'max_requests';
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
if (limits.totalBytes >= maxTotalBytes) {
|
|
543
|
+
limits.hitLimit = 'total_bytes';
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const candidate = candidateUrls.shift();
|
|
548
|
+
if (!candidate) continue;
|
|
549
|
+
const currentUrl = normalizeUrl(candidate.url);
|
|
550
|
+
if (visitedUrls.has(currentUrl)) continue;
|
|
551
|
+
visitedUrls.add(currentUrl);
|
|
552
|
+
|
|
553
|
+
const currentParsed = new URL(currentUrl);
|
|
554
|
+
const path = `${currentParsed.pathname}${currentParsed.search}`;
|
|
555
|
+
|
|
556
|
+
// Robots check
|
|
557
|
+
if (!robotsPolicy.isAvailable) {
|
|
558
|
+
pages.push({
|
|
559
|
+
subjectId: sha256(currentUrl),
|
|
560
|
+
url: currentUrl,
|
|
561
|
+
status: null,
|
|
562
|
+
availability: 'unavailable',
|
|
563
|
+
redirectChain: [],
|
|
564
|
+
evidencePath: null,
|
|
565
|
+
evidenceHash: null,
|
|
566
|
+
facts: null,
|
|
567
|
+
error: 'robots.txt policy unavailable',
|
|
568
|
+
});
|
|
569
|
+
limits.pagesVisited += 1;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (robotsPolicy.isBlocked || !robotsPolicy.isPathAllowed(path)) {
|
|
574
|
+
pages.push({
|
|
575
|
+
subjectId: sha256(currentUrl),
|
|
576
|
+
url: currentUrl,
|
|
577
|
+
status: null,
|
|
578
|
+
availability: 'blocked',
|
|
579
|
+
redirectChain: [],
|
|
580
|
+
evidencePath: null,
|
|
581
|
+
evidenceHash: null,
|
|
582
|
+
facts: null,
|
|
583
|
+
error: 'blocked by robots.txt',
|
|
584
|
+
});
|
|
585
|
+
limits.pagesVisited += 1;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Crawl delay
|
|
590
|
+
if (robotsPolicy.crawlDelaySeconds && robotsPolicy.crawlDelaySeconds > 0) {
|
|
591
|
+
const delayMs = robotsPolicy.crawlDelaySeconds * 1000;
|
|
592
|
+
if (Date.now() + delayMs >= deadlineMs) {
|
|
593
|
+
limits.hitLimit = 'deadline';
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
await new Promise((r) => setTimeout(r, Math.min(delayMs, 5000)));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Fetch page
|
|
600
|
+
try {
|
|
601
|
+
const reqStart = Date.now();
|
|
602
|
+
const res = await fetchWithPinnedDns(currentUrl, scopeHostnames, {
|
|
603
|
+
...effectiveTransportOpts,
|
|
604
|
+
timeoutMs: Math.min(20000, Math.max(1000, deadlineMs - Date.now())),
|
|
605
|
+
});
|
|
606
|
+
const durationMs = Date.now() - reqStart;
|
|
607
|
+
limits.requestsMade += 1;
|
|
608
|
+
limits.totalBytes += res.bytesReceived;
|
|
609
|
+
|
|
610
|
+
requests.push({
|
|
611
|
+
url: currentUrl,
|
|
612
|
+
method: 'GET',
|
|
613
|
+
status: res.status,
|
|
614
|
+
durationMs,
|
|
615
|
+
bytesReceived: res.bytesReceived,
|
|
616
|
+
error: null,
|
|
617
|
+
timestamp: new Date().toISOString(),
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
const subjectId = sha256(res.finalUrl || currentUrl);
|
|
621
|
+
let facts = null;
|
|
622
|
+
|
|
623
|
+
if (res.body && res.status >= 200 && res.status < 400) {
|
|
624
|
+
facts = await extractHtmlFacts(res.body, {
|
|
625
|
+
url: res.finalUrl || currentUrl,
|
|
626
|
+
scopeHostnames,
|
|
627
|
+
ttfbMs: res.ttfbMs,
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// Enqueue discovered links if depth allows
|
|
631
|
+
if (candidate.depth < maxDepth) {
|
|
632
|
+
for (const link of facts.links) {
|
|
633
|
+
if (link.isInternal && !visitedUrls.has(normalizeUrl(link.href))) {
|
|
634
|
+
try {
|
|
635
|
+
const resolved = normalizeUrl(new URL(link.href, res.finalUrl).toString());
|
|
636
|
+
candidateUrls.push({ url: resolved, depth: candidate.depth + 1 });
|
|
637
|
+
} catch {
|
|
638
|
+
// ignore malformed href
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Write evidence
|
|
646
|
+
const evidenceRelPath = `evidence/pages/${subjectId}.json`;
|
|
647
|
+
const evidenceFile = join(workDir, evidenceRelPath);
|
|
648
|
+
const evidencePayload = {
|
|
649
|
+
subjectId,
|
|
650
|
+
url: res.finalUrl || currentUrl,
|
|
651
|
+
status: res.status,
|
|
652
|
+
headers: res.headers,
|
|
653
|
+
facts,
|
|
654
|
+
};
|
|
655
|
+
writeJsonAtomic(evidenceFile, evidencePayload);
|
|
656
|
+
const evidenceHash = sha256(JSON.stringify(evidencePayload));
|
|
657
|
+
|
|
658
|
+
pages.push({
|
|
659
|
+
subjectId,
|
|
660
|
+
url: currentUrl,
|
|
661
|
+
status: res.status,
|
|
662
|
+
availability: res.status >= 200 && res.status < 400 ? 'available' : 'unavailable',
|
|
663
|
+
redirectChain: res.redirectChain,
|
|
664
|
+
evidencePath: evidenceRelPath,
|
|
665
|
+
evidenceHash,
|
|
666
|
+
facts,
|
|
667
|
+
error: null,
|
|
668
|
+
});
|
|
669
|
+
limits.pagesVisited += 1;
|
|
670
|
+
} catch (err: unknown) {
|
|
671
|
+
limits.requestsMade += 1;
|
|
672
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
673
|
+
requests.push({
|
|
674
|
+
url: currentUrl,
|
|
675
|
+
method: 'GET',
|
|
676
|
+
status: null,
|
|
677
|
+
durationMs: 0,
|
|
678
|
+
bytesReceived: 0,
|
|
679
|
+
error: errMsg,
|
|
680
|
+
timestamp: new Date().toISOString(),
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
pages.push({
|
|
684
|
+
subjectId: sha256(currentUrl),
|
|
685
|
+
url: currentUrl,
|
|
686
|
+
status: null,
|
|
687
|
+
availability: 'unavailable',
|
|
688
|
+
redirectChain: [],
|
|
689
|
+
evidencePath: null,
|
|
690
|
+
evidenceHash: null,
|
|
691
|
+
facts: null,
|
|
692
|
+
error: errMsg,
|
|
693
|
+
});
|
|
694
|
+
limits.pagesVisited += 1;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
limits.elapsedSeconds = Math.round((Date.now() - startTime) / 1000);
|
|
699
|
+
|
|
700
|
+
const isPartial =
|
|
701
|
+
limits.hitLimit !== null || pages.length === 0 || pages.some((p) => p.availability === 'unavailable');
|
|
702
|
+
|
|
703
|
+
const crawlArtifact: CrawlArtifact = {
|
|
704
|
+
schemaVersion: 1,
|
|
705
|
+
methodVersion: '1.0.0',
|
|
706
|
+
runId,
|
|
707
|
+
createdAt: new Date().toISOString(),
|
|
708
|
+
inputDigest,
|
|
709
|
+
predecessorDigests,
|
|
710
|
+
status: isPartial ? 'partial' : 'complete',
|
|
711
|
+
pages,
|
|
712
|
+
requests,
|
|
713
|
+
limits,
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
writeJsonAtomic(join(workDir, 'crawl.json'), crawlArtifact);
|
|
717
|
+
console.error(
|
|
718
|
+
`seo-collect: completed crawl status=${crawlArtifact.status} visited=${limits.pagesVisited} requests=${limits.requestsMade} bytes=${limits.totalBytes}`,
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* `seo-check <work_dir>`
|
|
724
|
+
*/
|
|
725
|
+
export async function seoCheck(args: string[]): Promise<void> {
|
|
726
|
+
const [workDirArg = ''] = args;
|
|
727
|
+
if (!workDirArg) {
|
|
728
|
+
fail('usage: kk stage run seo-check <work_dir>');
|
|
729
|
+
}
|
|
730
|
+
const workDir = resolve(workDirArg);
|
|
731
|
+
const crawlPath = join(workDir, 'crawl.json');
|
|
732
|
+
if (!existsSync(crawlPath)) {
|
|
733
|
+
fail(`seo-check: missing crawl.json in ${workDir}`);
|
|
734
|
+
}
|
|
735
|
+
const crawlRaw = readFileSync(crawlPath, 'utf-8');
|
|
736
|
+
const crawl: CrawlArtifact = JSON.parse(crawlRaw);
|
|
737
|
+
const assessments = runSeoChecks(crawl);
|
|
738
|
+
|
|
739
|
+
const checksArtifact = {
|
|
740
|
+
schemaVersion: 1,
|
|
741
|
+
methodVersion: '1.0.0',
|
|
742
|
+
runId: crawl.runId,
|
|
743
|
+
createdAt: new Date().toISOString(),
|
|
744
|
+
inputDigest: crawl.inputDigest,
|
|
745
|
+
predecessorDigests: {
|
|
746
|
+
...crawl.predecessorDigests,
|
|
747
|
+
'crawl.json': sha256(crawlRaw),
|
|
748
|
+
},
|
|
749
|
+
assessments,
|
|
750
|
+
};
|
|
751
|
+
writeJsonAtomic(join(workDir, 'checks.json'), checksArtifact);
|
|
752
|
+
console.error(`seo-check: evaluated ${assessments.length} check rows across ${crawl.pages.length} pages`);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* `seo-score <work_dir> [new_revision_dir]`
|
|
757
|
+
*/
|
|
758
|
+
export async function seoScore(args: string[]): Promise<void> {
|
|
759
|
+
const [workDirArg = '', newRevisionDirArg = ''] = args;
|
|
760
|
+
if (!workDirArg) {
|
|
761
|
+
fail('usage: kk stage run seo-score <work_dir> [new_revision_dir]');
|
|
762
|
+
}
|
|
763
|
+
const sourceDir = resolve(workDirArg);
|
|
764
|
+
const checksPath = join(sourceDir, 'checks.json');
|
|
765
|
+
const crawlPath = join(sourceDir, 'crawl.json');
|
|
766
|
+
const optionsPath = join(sourceDir, 'options.json');
|
|
767
|
+
const manifestPath = join(sourceDir, 'manifest.json');
|
|
768
|
+
|
|
769
|
+
if (!existsSync(checksPath) || !existsSync(crawlPath) || !existsSync(optionsPath)) {
|
|
770
|
+
fail(`seo-score: missing required input artifacts in ${sourceDir}`);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const isRescore = Boolean(newRevisionDirArg.trim());
|
|
774
|
+
let targetDir = sourceDir;
|
|
775
|
+
let revisionId: string | undefined;
|
|
776
|
+
|
|
777
|
+
if (isRescore) {
|
|
778
|
+
targetDir = resolve(newRevisionDirArg.trim());
|
|
779
|
+
if (existsSync(targetDir)) {
|
|
780
|
+
const files = readdirSync(targetDir);
|
|
781
|
+
if (files.length > 0) {
|
|
782
|
+
fail(`seo-score: new_revision_dir exists and is non-empty: ${targetDir} (refusing overwrite)`);
|
|
783
|
+
}
|
|
784
|
+
} else {
|
|
785
|
+
mkdirSync(targetDir, { recursive: true });
|
|
786
|
+
}
|
|
787
|
+
revisionId = `rev-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
788
|
+
// Copy input artifacts
|
|
789
|
+
copyFileSync(optionsPath, join(targetDir, 'options.json'));
|
|
790
|
+
if (existsSync(manifestPath)) copyFileSync(manifestPath, join(targetDir, 'manifest.json'));
|
|
791
|
+
copyFileSync(crawlPath, join(targetDir, 'crawl.json'));
|
|
792
|
+
copyFileSync(checksPath, join(targetDir, 'checks.json'));
|
|
793
|
+
} else {
|
|
794
|
+
const auditPath = join(targetDir, 'audit.json');
|
|
795
|
+
if (existsSync(auditPath)) {
|
|
796
|
+
fail(`seo-score: audit.json already exists in ${targetDir} (refusing overwrite)`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const checksRaw = readFileSync(checksPath, 'utf-8');
|
|
801
|
+
const crawlRaw = readFileSync(crawlPath, 'utf-8');
|
|
802
|
+
const optionsRaw = readFileSync(optionsPath, 'utf-8');
|
|
803
|
+
|
|
804
|
+
const checks = JSON.parse(checksRaw);
|
|
805
|
+
const crawl: CrawlArtifact = JSON.parse(crawlRaw);
|
|
806
|
+
const options: SeoOptions = JSON.parse(optionsRaw);
|
|
807
|
+
|
|
808
|
+
const assessments: AssessmentRow[] = checks.assessments || [];
|
|
809
|
+
const { scores, coverage } = calculateScoresAndCoverage(assessments);
|
|
810
|
+
const actions = buildPrioritizedActions(assessments);
|
|
811
|
+
|
|
812
|
+
const facts: AuditArtifact['facts'] = {
|
|
813
|
+
pages_visited: { value: crawl.limits.pagesVisited, unit: 'count', source: 'crawl.json' },
|
|
814
|
+
requests_made: { value: crawl.limits.requestsMade, unit: 'count', source: 'crawl.json' },
|
|
815
|
+
total_bytes: { value: crawl.limits.totalBytes, unit: 'bytes', source: 'crawl.json' },
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
const auditArtifact: AuditArtifact = {
|
|
819
|
+
schemaVersion: 1,
|
|
820
|
+
methodVersion: '1.0.0',
|
|
821
|
+
runId: crawl.runId,
|
|
822
|
+
...(revisionId ? { revisionId } : {}),
|
|
823
|
+
createdAt: new Date().toISOString(),
|
|
824
|
+
inputDigest: crawl.inputDigest,
|
|
825
|
+
predecessorDigests: {
|
|
826
|
+
...checks.predecessorDigests,
|
|
827
|
+
'checks.json': sha256(checksRaw),
|
|
828
|
+
},
|
|
829
|
+
status: crawl.status,
|
|
830
|
+
targetUrl: options.url,
|
|
831
|
+
scores,
|
|
832
|
+
coverage,
|
|
833
|
+
facts,
|
|
834
|
+
assessments,
|
|
835
|
+
actions,
|
|
836
|
+
judgments: { status: 'not-assessed' },
|
|
837
|
+
performance: { status: 'not-assessed' },
|
|
838
|
+
visibility: { status: 'not-assessed' },
|
|
839
|
+
limits: crawl.limits,
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
writeJsonAtomic(join(targetDir, 'audit.json'), auditArtifact);
|
|
843
|
+
const digestMd = `# Smart SEO Audit Digest\n\n- Technical Score: ${scores.technical !== null ? scores.technical : 'null'}\n- Actions Count: ${actions.length}\n`;
|
|
844
|
+
writeJsonAtomic(join(targetDir, 'digest.json'), { digestMd });
|
|
845
|
+
writeTextAtomic(join(targetDir, 'digest.md'), digestMd);
|
|
846
|
+
|
|
847
|
+
console.error(`seo-score: generated audit.json in ${targetDir} with ${actions.length} prioritized actions`);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* `seo-report <work_dir>`
|
|
852
|
+
*/
|
|
853
|
+
export async function seoReport(args: string[]): Promise<void> {
|
|
854
|
+
const [workDirArg = ''] = args;
|
|
855
|
+
if (!workDirArg) {
|
|
856
|
+
fail('usage: kk stage run seo-report <work_dir>');
|
|
857
|
+
}
|
|
858
|
+
const workDir = resolve(workDirArg);
|
|
859
|
+
const auditPath = join(workDir, 'audit.json');
|
|
860
|
+
if (!existsSync(auditPath)) {
|
|
861
|
+
fail(`seo-report: missing audit.json in ${workDir}`);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const auditRaw = readFileSync(auditPath, 'utf-8');
|
|
865
|
+
const audit: AuditArtifact = JSON.parse(auditRaw);
|
|
866
|
+
const auditDigest = sha256(auditRaw);
|
|
867
|
+
|
|
868
|
+
// Optional narrative: strict template/reference validation (design §9). An absent,
|
|
869
|
+
// invalid or unreadable narrative is never an audit failure — it degrades to a
|
|
870
|
+
// labeled deterministic summary over the immutable audit records.
|
|
871
|
+
let narrativeStatus: 'omitted' | 'included' | 'rejected' = 'omitted';
|
|
872
|
+
let narrativeDiagnostics: string[] = [];
|
|
873
|
+
let narrativeText: string;
|
|
874
|
+
const narrativePath = join(workDir, 'narrative.json');
|
|
875
|
+
if (existsSync(narrativePath)) {
|
|
876
|
+
const validation = validateNarrative(readFileSync(narrativePath, 'utf-8'), audit, auditDigest);
|
|
877
|
+
narrativeStatus = validation.status;
|
|
878
|
+
narrativeDiagnostics = validation.diagnostics;
|
|
879
|
+
if (validation.status === 'included') {
|
|
880
|
+
narrativeText = renderNarrativeSections(audit, validation.sections);
|
|
881
|
+
} else {
|
|
882
|
+
// Diagnostics are persisted beside the report; the report itself stays deterministic.
|
|
883
|
+
writeJsonAtomic(join(workDir, 'narrative-diagnostics.json'), {
|
|
884
|
+
schemaVersion: 1,
|
|
885
|
+
runId: audit.runId,
|
|
886
|
+
status: 'rejected',
|
|
887
|
+
diagnostics: validation.diagnostics,
|
|
888
|
+
});
|
|
889
|
+
narrativeText = [
|
|
890
|
+
'**Narrative rejected** — the supplied narrative was discarded in full (see `narrative-diagnostics.json`).',
|
|
891
|
+
'',
|
|
892
|
+
renderDeterministicSummary(audit),
|
|
893
|
+
].join('\n');
|
|
894
|
+
}
|
|
895
|
+
} else {
|
|
896
|
+
narrativeText = ['**Narrative omitted** — deterministic summary.', '', renderDeterministicSummary(audit)].join(
|
|
897
|
+
'\n',
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const reportMd = renderReportMarkdown(audit, narrativeText);
|
|
902
|
+
writeTextAtomic(join(workDir, 'report.md'), reportMd);
|
|
903
|
+
|
|
904
|
+
const reportJson: ReportJsonArtifact = {
|
|
905
|
+
schemaVersion: 1,
|
|
906
|
+
methodVersion: '1.0.0',
|
|
907
|
+
runId: audit.runId,
|
|
908
|
+
...(audit.revisionId ? { revisionId: audit.revisionId } : {}),
|
|
909
|
+
createdAt: new Date().toISOString(),
|
|
910
|
+
targetUrl: audit.targetUrl,
|
|
911
|
+
status: audit.status === 'complete' ? 'done' : 'partial',
|
|
912
|
+
scores: audit.scores,
|
|
913
|
+
coverage: audit.coverage,
|
|
914
|
+
actionsCount: audit.actions.length,
|
|
915
|
+
actions: audit.actions,
|
|
916
|
+
narrativeStatus,
|
|
917
|
+
...(narrativeStatus === 'rejected' ? { narrativeDiagnostics } : {}),
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
writeJsonAtomic(join(workDir, 'report.json'), reportJson);
|
|
921
|
+
console.error(`seo-report: wrote report.json and report.md in ${workDir}`);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* `seo-enrich <work_dir>`
|
|
926
|
+
*/
|
|
927
|
+
export async function seoEnrich(args: string[], deps: EnrichDeps = {}): Promise<void> {
|
|
928
|
+
const [workDirArg = ''] = args;
|
|
929
|
+
if (!workDirArg) {
|
|
930
|
+
fail('usage: kk stage run seo-enrich <work_dir>');
|
|
931
|
+
}
|
|
932
|
+
const workDir = resolve(workDirArg);
|
|
933
|
+
if (!existsSync(join(workDir, 'crawl.json'))) {
|
|
934
|
+
fail(`seo-enrich: missing crawl.json in ${workDir}`);
|
|
935
|
+
}
|
|
936
|
+
await runSeoEnrich(workDir, deps);
|
|
937
|
+
console.error(`seo-enrich: wrote performance.json and visibility.json in ${workDir}`);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* `seo-judge <work_dir>`
|
|
942
|
+
*/
|
|
943
|
+
export async function seoJudge(
|
|
944
|
+
args: string[],
|
|
945
|
+
options: { spurBin?: string; stub?: DecisionStubHandler } = {},
|
|
946
|
+
): Promise<void> {
|
|
947
|
+
const [workDirArg = ''] = args;
|
|
948
|
+
if (!workDirArg) {
|
|
949
|
+
fail('usage: kk stage run seo-judge <work_dir>');
|
|
950
|
+
}
|
|
951
|
+
const workDir = resolve(workDirArg);
|
|
952
|
+
const crawlPath = join(workDir, 'crawl.json');
|
|
953
|
+
if (!existsSync(crawlPath)) {
|
|
954
|
+
fail(`seo-judge: missing crawl.json in ${workDir}`);
|
|
955
|
+
}
|
|
956
|
+
const result = await executeSeoJudgeStage(workDir, options);
|
|
957
|
+
console.error(
|
|
958
|
+
`seo-judge: completed ${result.summary.totalCalls} decision calls (${result.summary.qualifiedCalls} qualified)`,
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** Every SEO stage this module serves. */
|
|
963
|
+
export const SEO_STAGE_NAMES = [
|
|
964
|
+
'seo-preflight',
|
|
965
|
+
'seo-collect',
|
|
966
|
+
'seo-check',
|
|
967
|
+
'seo-enrich',
|
|
968
|
+
'seo-judge',
|
|
969
|
+
'seo-score',
|
|
970
|
+
'seo-report',
|
|
971
|
+
] as const;
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Dispatch one SEO stage with cancellation installed, the cancellation marker written for a
|
|
975
|
+
* cancelled run, and the conventional signal exit status.
|
|
976
|
+
*
|
|
977
|
+
* Shared by this module's own entry point and by the shipped `kk-workflow-stages.ts` runner, so a
|
|
978
|
+
* cancelled audit behaves identically however the stage was reached. `work_dir` is the first
|
|
979
|
+
* positional argument of every stage, which is where a cancellation marker is recorded.
|
|
980
|
+
*/
|
|
981
|
+
export async function runSeoStage(stage: string | undefined, rest: string[]): Promise<void> {
|
|
982
|
+
// Installed before any stage runs: a signal between bounded steps is recorded rather than
|
|
983
|
+
// killing the process by default action, so the cancellation marker can be written.
|
|
984
|
+
const disposeCancel = installSeoStageCancellation();
|
|
985
|
+
try {
|
|
986
|
+
switch (stage) {
|
|
987
|
+
case 'seo-preflight':
|
|
988
|
+
await seoPreflight(rest);
|
|
989
|
+
break;
|
|
990
|
+
case 'seo-collect':
|
|
991
|
+
await seoCollect(rest);
|
|
992
|
+
break;
|
|
993
|
+
case 'seo-check':
|
|
994
|
+
await seoCheck(rest);
|
|
995
|
+
break;
|
|
996
|
+
case 'seo-enrich':
|
|
997
|
+
await seoEnrich(rest);
|
|
998
|
+
break;
|
|
999
|
+
case 'seo-judge':
|
|
1000
|
+
await seoJudge(rest);
|
|
1001
|
+
break;
|
|
1002
|
+
case 'seo-score':
|
|
1003
|
+
await seoScore(rest);
|
|
1004
|
+
break;
|
|
1005
|
+
case 'seo-report':
|
|
1006
|
+
await seoReport(rest);
|
|
1007
|
+
break;
|
|
1008
|
+
default:
|
|
1009
|
+
fail(`unknown stage: ${stage ?? '(none)'} (expected ${SEO_STAGE_NAMES.join('|')})`);
|
|
1010
|
+
}
|
|
1011
|
+
} catch (err: unknown) {
|
|
1012
|
+
if (err instanceof SeoStageCancelledError) {
|
|
1013
|
+
// Preserve completed artifacts, mark the workspace, and exit with the conventional code.
|
|
1014
|
+
const workDir = rest[0] ? resolve(rest[0]) : '';
|
|
1015
|
+
if (workDir !== '' && existsSync(workDir)) {
|
|
1016
|
+
try {
|
|
1017
|
+
writeJsonAtomic(join(workDir, 'cancelled.json'), {
|
|
1018
|
+
schemaVersion: 1,
|
|
1019
|
+
stage: stage ?? '',
|
|
1020
|
+
signal: err.signal,
|
|
1021
|
+
cancelledAt: new Date().toISOString(),
|
|
1022
|
+
});
|
|
1023
|
+
} catch {
|
|
1024
|
+
// the marker is evidence, not a precondition for stopping
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
console.error(err.message);
|
|
1028
|
+
process.exit(seoSignalExitCode(err.signal));
|
|
1029
|
+
}
|
|
1030
|
+
throw err;
|
|
1031
|
+
} finally {
|
|
1032
|
+
disposeCancel();
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (import.meta.main) {
|
|
1037
|
+
const [stage, ...rest] = process.argv.slice(2);
|
|
1038
|
+
try {
|
|
1039
|
+
await runSeoStage(stage, rest);
|
|
1040
|
+
} catch (err: unknown) {
|
|
1041
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1042
|
+
process.exit(1);
|
|
1043
|
+
}
|
|
1044
|
+
}
|