draftgo-cli 1.0.14 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,6 +49,14 @@ draftgo verify --url http://localhost:3000/path --screenshot always --viewport b
49
49
 
50
50
  ## 安装与连接
51
51
 
52
+ 公开页面发布后,可单独执行匿名 SEO 验收,无需浏览器或项目连接:
53
+
54
+ ```bash
55
+ draftgo verify --seo --url https://example.com/product --output json
56
+ ```
57
+
58
+ 检查初始 HTML、状态码、重定向、标题、描述、canonical、索引指令、robots.txt 和有界 sitemap/链接采样;结果位于 `seo_validation`。目标 URL 应为希望被索引的规范页面,noindex 会失败。警告在 `--strict` 时失败。该检查不能保证正文完整性、私有数据安全、实际收录或排名,需结合人工及站长平台验收。配置通过实时 MCP 契约发现和修改。
59
+
52
60
  ```bash
53
61
  npm install -g draftgo-cli
54
62
  cd /path/to/project
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "draftgo-cli",
3
- "version": "1.0.14",
3
+ "version": "1.0.15",
4
4
  "description": "Install and manage the DraftGo skill across AI coding agents (Claude Code, Codex, Cursor, Windsurf, Antigravity, Copilot, Gemini, Kiro, Pi, ZCode).",
5
5
  "bin": {
6
6
  "draftgo": "bin/draftgo.js"
@@ -49,7 +49,7 @@
49
49
  "build:release": "node scripts/build-release.js",
50
50
  "verify:package": "node scripts/verify-package.js",
51
51
  "test": "npm run lint && npm run validate:skill && npm run verify:package && npm run test:unit && npm run test:api-key && npm run test:components && npm run test:capabilities && npm run test:worklog && npm run test:mcp && npm run test:worktree && npm run test:integration && npm run test:local && npm run test:e2e",
52
- "test:unit": "node tests/unit.js",
52
+ "test:unit": "node tests/unit.js && node --test tests/seo.test.js",
53
53
  "test:api-key": "node --test tests/api-key.test.js",
54
54
  "test:components": "node --test tests/components.test.js",
55
55
  "test:capabilities": "node --test tests/capabilities.test.js",
@@ -2,7 +2,7 @@
2
2
  "schema_version": "1.0",
3
3
  "id": "draftgo",
4
4
  "name": "DraftGo 开发助手",
5
- "version": "1.0.14",
5
+ "version": "1.0.15",
6
6
  "entry": "SKILL.md",
7
7
  "description": "以 Skill/reference 任务路由、MCP 实时发现、长正文 checkout/commit、统一验证和完成日志为边界的 DraftGo 工作流。",
8
8
  "license": "MIT",
@@ -23,6 +23,8 @@ draftgo check --remote --output json
23
23
 
24
24
  ## 统一收尾
25
25
 
26
+ 公开页面上线且需要收录时,追加 `draftgo verify --seo --url https://example.com/path --output json`。它匿名检查初始 HTTP HTML、索引指令、canonical、robots.txt、sitemap 及有界链接采样,不启动浏览器,也不携带项目凭据。查看 `seo_validation` 的错误、警告、覆盖范围;noindex 对希望收录的页面属于失败。正文完整性、私有数据暴露、内容质量与实际收录需另行核对,不能将静态检查通过写成“已收录”。SEO 配置操作始终通过实时 MCP search/describe 发现。
27
+
26
28
  ```bash
27
29
  draftgo verify
28
30
  draftgo work complete <ref> --note "<结果与证据>"
@@ -155,7 +155,8 @@ Important flags:
155
155
  --frame <mode> (verify) auto | top | all | <iframe-selector>.
156
156
  --token <mode> (verify) auto | never; auto appends the configured
157
157
  API Key to same-origin URLs as the token query parameter.
158
- --screenshot <mode> (verify) always | never; always alone captures a screenshot.
158
+ --seo (verify) Anonymous initial-HTML SEO checks; requires --url.
159
+ --screenshot <mode> (verify) always | never; always alone captures a screenshot.
159
160
  --browser <name> (verify) chromium | chrome | msedge.
160
161
  --browser-path <file> (verify) Explicit browser executable; environment
161
162
  fallback: DRAFTGO_BROWSER_PATH.
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ const parse5 = require('parse5');
4
+
5
+ function httpURL(value, base) {
6
+ const url = new URL(value, base);
7
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('SEO URLs must use HTTP(S) without credentials.');
8
+ url.hash = '';
9
+ return url;
10
+ }
11
+
12
+ async function request(value, method = 'GET') {
13
+ let url = httpURL(value);
14
+ const redirects = [];
15
+ const signal = AbortSignal.timeout(10000);
16
+ for (let i = 0; i <= 5; i += 1) {
17
+ const response = await fetch(url, { method, redirect: 'manual', signal, headers: { 'User-Agent': 'DraftGo-SEO-Verify/1.0' } });
18
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
19
+ await response.body?.cancel();
20
+ if (i === 5) throw new Error('More than 5 redirects.');
21
+ const location = response.headers.get('location');
22
+ if (!location) throw new Error('Redirect has no Location.');
23
+ redirects.push({ url: url.href, status: response.status });
24
+ url = httpURL(location, url);
25
+ continue;
26
+ }
27
+ const chunks = [];
28
+ let size = 0;
29
+ if (response.body) {
30
+ for await (const chunk of response.body) {
31
+ size += chunk.length;
32
+ if (size > 2 * 1024 * 1024) throw new Error('Response exceeds 2 MiB.');
33
+ chunks.push(Buffer.from(chunk));
34
+ }
35
+ }
36
+ return { url: url.href, status: response.status, headers: response.headers, text: Buffer.concat(chunks).toString('utf8'), redirects };
37
+ }
38
+ }
39
+
40
+ function inspect(html) {
41
+ const result = { titles: [], descriptions: [], canonicals: [], robots: [], links: [], images: [], body: '' };
42
+ const text = node => node.nodeName === '#text' ? node.value : (node.childNodes || []).map(text).join(' ');
43
+ function walk(node, hidden = false, body = false, head = false) {
44
+ const attrs = Object.fromEntries((node.attrs || []).map(a => [a.name, a.value]));
45
+ hidden ||= ['script', 'style', 'template', 'iframe', 'noscript', 'nav', 'footer'].includes(node.tagName) || 'hidden' in attrs || attrs['aria-hidden'] === 'true';
46
+ body ||= node.tagName === 'body';
47
+ head ||= node.tagName === 'head';
48
+ if (head && node.tagName === 'title') result.titles.push(text(node).trim());
49
+ if (head && node.tagName === 'meta') {
50
+ const name = (attrs.name || attrs.property || '').toLowerCase();
51
+ if (name === 'description') result.descriptions.push(attrs.content || '');
52
+ if (['robots', 'googlebot', 'bingbot'].includes(name)) result.robots.push(attrs.content || '');
53
+ if (['og:image', 'twitter:image'].includes(name) && attrs.content) result.images.push(attrs.content);
54
+ }
55
+ if (head && node.tagName === 'link' && (attrs.rel || '').toLowerCase().split(/\s+/).includes('canonical')) result.canonicals.push(attrs.href || '');
56
+ if (body && !hidden && node.tagName === 'a' && attrs.href) result.links.push(attrs.href);
57
+ if (body && !hidden && node.nodeName === '#text') result.body += ` ${node.value}`;
58
+ for (const child of node.childNodes || []) walk(child, hidden, body, head);
59
+ }
60
+ walk(parse5.parse(html));
61
+ result.body = result.body.replace(/\s+/g, ' ').trim();
62
+ return result;
63
+ }
64
+
65
+ function robotsRules(source, url) {
66
+ const groups = [];
67
+ let group = null;
68
+ const sitemaps = [];
69
+ for (const line of source.split(/\r?\n/)) {
70
+ const match = line.replace(/#.*$/, '').match(/^\s*([^:]+):\s*(.*?)\s*$/);
71
+ if (!match) continue;
72
+ const key = match[1].toLowerCase(); const value = match[2];
73
+ if (key === 'sitemap') { sitemaps.push(value); continue; }
74
+ if (key === 'user-agent') {
75
+ if (!group || group.hasDirectives) { group = { agents: [], rules: [], hasDirectives: false }; groups.push(group); }
76
+ group.agents.push(value.toLowerCase());
77
+ } else if (group) {
78
+ group.hasDirectives = true;
79
+ if (['allow', 'disallow'].includes(key) && value) group.rules.push({ allow: key === 'allow', value });
80
+ }
81
+ }
82
+ const path = new URL(url).pathname + new URL(url).search;
83
+ const rules = groups.filter(g => g.agents.includes('*')).flatMap(g => g.rules).filter(rule => {
84
+ const end = rule.value.endsWith('$');
85
+ const pattern = (end ? rule.value.slice(0, -1) : rule.value).split('*').map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*');
86
+ return new RegExp(`^${pattern}${end ? '$' : ''}`).test(path);
87
+ }).sort((a, b) => b.value.replace(/\*/g, '').length - a.value.replace(/\*/g, '').length || Number(b.allow) - Number(a.allow));
88
+ return { blocked: rules.length > 0 && !rules[0].allow, sitemaps, specific_agents: groups.some(g => g.agents.some(a => a !== '*')) };
89
+ }
90
+
91
+ function xmlLocations(source) {
92
+ return [...source.matchAll(/<loc(?:\s[^>]*)?>([\s\S]*?)<\/loc>/gi)].map(m => m[1].trim().replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'"));
93
+ }
94
+
95
+ async function seoVerify(input) {
96
+ const report = { status: 'failed', url: input, errors: [], warnings: [], checks: {}, limitations: ['Static anonymous HTTP inspection only; body completeness, private data exposure, CSS visibility, content quality, actual indexing/ranking and Core Web Vitals require separate verification.'] };
97
+ try {
98
+ const page = await request(input);
99
+ report.final_url = page.url; report.redirects = page.redirects;
100
+ report.checks.http_status = page.status;
101
+ if (page.status !== 200) report.errors.push(`Page returned HTTP ${page.status}; expected 200.`);
102
+ if (!/text\/html|application\/xhtml\+xml/i.test(page.headers.get('content-type') || '')) report.errors.push('Response is not HTML.');
103
+ const doc = inspect(page.text);
104
+ for (const [label, values] of [['title', doc.titles], ['description', doc.descriptions], ['canonical', doc.canonicals]]) {
105
+ report.checks[label] = values;
106
+ if (values.length !== 1 || !values[0].trim()) report.errors.push(`Expected one non-empty ${label}.`);
107
+ }
108
+ let canonical = page.url;
109
+ if (doc.canonicals.length === 1) {
110
+ try {
111
+ canonical = httpURL(doc.canonicals[0]).href;
112
+ if (canonical !== page.url) report.errors.push('Canonical does not match the final URL; this URL is not the canonical indexing target.');
113
+ } catch { report.errors.push('Canonical must be an absolute HTTP(S) URL without credentials.'); }
114
+ }
115
+ const robots = [...doc.robots, page.headers.get('x-robots-tag') || ''];
116
+ report.checks.robots = robots.filter(Boolean);
117
+ if (robots.some(value => /(?:^|[\s,:])(?:noindex|none)(?:$|[\s,;])/i.test(value))) report.errors.push('Indexing is disabled by robots directives.');
118
+ report.checks.body_text_characters = doc.body.length;
119
+ if (!doc.body) report.errors.push('Initial HTML has no readable body text.');
120
+ else if (doc.body.length < 80) report.warnings.push('Initial body text is short; manually confirm the main content is present.');
121
+ const origin = new URL(page.url).origin;
122
+ let sitemapURLs = [`${origin}/sitemap.xml`];
123
+ try {
124
+ const response = await request(`${origin}/robots.txt`);
125
+ report.checks.robots_txt_status = response.status;
126
+ if (response.status === 200) {
127
+ const rules = robotsRules(response.text, page.url);
128
+ if (rules.blocked) report.errors.push('robots.txt blocks this URL for the wildcard user agent.');
129
+ if (rules.specific_agents) report.warnings.push('robots.txt has crawler-specific rules; inspect target search-engine rules separately.');
130
+ if (rules.sitemaps.length) sitemapURLs = rules.sitemaps;
131
+ } else if (response.status !== 404) report.warnings.push(`robots.txt returned HTTP ${response.status}.`);
132
+ } catch (error) { report.warnings.push(`robots.txt: ${error.message}`); }
133
+ const seen = new Set(); let found = false; let incomplete = false;
134
+ while (sitemapURLs.length && seen.size < 5 && !found) {
135
+ const raw = sitemapURLs.shift();
136
+ try {
137
+ const url = httpURL(raw);
138
+ if (url.origin !== origin) { incomplete = true; continue; }
139
+ if (seen.has(url.href)) continue;
140
+ seen.add(url.href);
141
+ const response = await request(url.href);
142
+ if (response.status !== 200) { incomplete = true; report.warnings.push(`Sitemap returned HTTP ${response.status}: ${url.href}`); continue; }
143
+ const locations = xmlLocations(response.text);
144
+ if (/<sitemapindex[\s>]/i.test(response.text)) sitemapURLs.push(...locations);
145
+ else if (/<urlset[\s>]/i.test(response.text)) found = locations.includes(canonical);
146
+ else { incomplete = true; report.warnings.push(`Not a supported XML sitemap: ${url.href}`); }
147
+ } catch (error) { incomplete = true; report.warnings.push(`Sitemap: ${error.message}`); }
148
+ }
149
+ report.checks.sitemap = { found, checked: [...seen], complete: !incomplete && sitemapURLs.length === 0 };
150
+ if (!found) report.warnings.push(incomplete || sitemapURLs.length ? 'Canonical URL not found in bounded sitemap coverage; membership remains unverified.' : 'Canonical URL is absent from the checked sitemap(s).');
151
+ report.checks.samples = [];
152
+ const targets = [];
153
+ for (const [kind, values, limit] of [['link', doc.links, 5], ['share_image', doc.images, 2]]) {
154
+ const urls = new Set();
155
+ for (const raw of values) {
156
+ try { const url = httpURL(raw, page.url); if (kind === 'link' && url.origin !== origin) continue; urls.add(url.href); } catch { /* Non-HTTP links are not fetched. */ }
157
+ }
158
+ for (const url of [...urls].slice(0, limit)) targets.push({ kind, url });
159
+ }
160
+ for (const target of targets) {
161
+ try {
162
+ const response = await request(target.url, 'HEAD');
163
+ report.checks.samples.push({ ...target, status: response.status });
164
+ if (response.status < 200 || response.status >= 400) report.warnings.push(`${target.kind} HEAD returned ${response.status}: ${target.url}`);
165
+ else if (target.kind === 'share_image' && !/^image\//i.test(response.headers.get('content-type') || '')) report.warnings.push(`Share image has no image content type: ${target.url}`);
166
+ } catch (error) { report.warnings.push(`${target.kind}: ${error.message}`); }
167
+ }
168
+ report.limitations.push('Samples cover at most 5 same-origin links and 2 share images using HEAD; unsupported HEAD needs manual verification. Sitemap coverage is limited to 5 same-origin XML documents.');
169
+ } catch (error) { report.errors.push(error.message); }
170
+ report.status = report.errors.length ? 'failed' : report.warnings.length ? 'warning' : 'passed';
171
+ return report;
172
+ }
173
+
174
+ module.exports = { seoVerify, inspect, robotsRules, httpURL };
@@ -3,6 +3,7 @@
3
3
  const log = require('../logger');
4
4
  const check = require('./check');
5
5
  const visualVerify = require('./visualVerify');
6
+ const { seoVerify, httpURL } = require('./seoVerify');
6
7
  const { canonicalResourceType } = require('../worktree/types');
7
8
  const fs = require('fs');
8
9
  const path = require('path');
@@ -151,6 +152,10 @@ async function verify(projectDir, positional = [], flags = {}) {
151
152
  selected = resources(positional);
152
153
  remoteMode = flags.remote ? 'always' : 'never';
153
154
  visual = visualRequest(flags, url);
155
+ if (flags.seo) {
156
+ if (!url) throw new Error('SEO verification requires --url <http(s) URL>.');
157
+ httpURL(url);
158
+ }
154
159
  if (flags.output === 'json' && visual.run) throw new Error('Visual verification does not support --output json; run the explicit visual check separately.');
155
160
  } catch (error) {
156
161
  log.err(error.message);
@@ -158,6 +163,8 @@ async function verify(projectDir, positional = [], flags = {}) {
158
163
  }
159
164
 
160
165
  if (flags.output !== 'json') log.title('draftgo verify');
166
+ const seo = flags.seo ? await seoVerify(url) : null;
167
+ const seoCode = seo && (seo.errors.length || (flags.strict && seo.warnings.length)) ? 1 : 0;
161
168
  const checkOptions = {
162
169
  output: flags.output,
163
170
  strict: flags.strict,
@@ -182,21 +189,28 @@ async function verify(projectDir, positional = [], flags = {}) {
182
189
  }
183
190
  }
184
191
  result.remote_validation.component_contracts = componentContracts;
185
- const code = (result.code || componentContracts.status === 'failed') ? 1 : 0;
192
+ if (seo) result.seo_validation = seo;
193
+ const code = (result.code || componentContracts.status === 'failed' || seoCode) ? 1 : 0;
186
194
  delete result.code;
187
195
  console.log(JSON.stringify(result, null, 2));
188
196
  if (code !== 0) return code;
189
197
  } else {
198
+ if (seo) {
199
+ for (const error of seo.errors) log.err(`SEO: ${error}`);
200
+ for (const warning of seo.warnings) log.warn(`SEO: ${warning}`);
201
+ log.info(`SEO ${seo.status}: ${seo.final_url || url}; static HTTP checks only, indexing and content completeness are not guaranteed.`);
202
+ }
190
203
  if (flags.remote) {
191
204
  try { await verifyPageComponents(projectDir, selected); }
192
205
  catch (error) { log.err(error.message); return 1; }
193
206
  }
194
207
  const checkCode = await check(projectDir, [], checkOptions);
195
208
  if (checkCode !== 0) return checkCode;
209
+ if (seoCode !== 0) return seoCode;
196
210
  }
197
211
 
198
212
  if (!visual.run) {
199
- if (url) log.warn('--url was provided without --screenshot always or --ui always; visual verification was skipped.');
213
+ if (url && !flags.seo) log.warn('--url was provided without --screenshot always or --ui always; visual verification was skipped.');
200
214
  if (flags.output !== 'json') log.ok('Local verification passed; browser and business interactions were not tested.');
201
215
  return 0;
202
216
  }