optbrew 0.0.0-stage → 0.1.2
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 +6 -0
- package/.claude-plugin/plugin.json +6 -0
- package/.codex-plugin/plugin.json +13 -0
- package/CHANGELOG.md +50 -0
- package/LICENSE +21 -0
- package/README.ko.md +149 -0
- package/README.md +148 -2
- package/THIRD_PARTY_NOTICES.md +10 -0
- package/bin/optbrew.mjs +4 -0
- package/docs/platforms.md +107 -0
- package/docs/release.md +14 -0
- package/lib/cli.mjs +116 -0
- package/lib/doctor.mjs +45 -0
- package/lib/install.mjs +158 -0
- package/lib/paths.mjs +30 -0
- package/lib/records.mjs +44 -0
- package/lib/reinstall.mjs +92 -0
- package/lib/transaction.mjs +205 -0
- package/lib/update.mjs +189 -0
- package/package.json +39 -4
- package/plugin.json +13 -0
- package/skills/optbrew/LICENSE +21 -0
- package/skills/optbrew/SKILL.md +17 -0
- package/skills/optbrew/THIRD_PARTY_NOTICES.md +10 -0
- package/skills/optbrew/agents/openai.yaml +4 -0
- package/skills/optbrew/bundle-manifest.json +687 -0
- package/skills/optbrew/licenses/firecrawl-seo-audit.txt +15 -0
- package/skills/optbrew/licenses/magnus-seo.txt +21 -0
- package/skills/optbrew/licenses/marketing-search.txt +21 -0
- package/skills/optbrew/licenses/mblode-seo.txt +21 -0
- package/skills/optbrew/licenses/naver-searchadvisor.txt +29 -0
- package/skills/optbrew/licenses/seranking-seo.txt +21 -0
- package/skills/optbrew/modules/firecrawl-seo-audit/GUIDE.md +64 -0
- package/skills/optbrew/modules/magnus-seo/GUIDE.md +79 -0
- package/skills/optbrew/modules/magnus-seo/README.md +45 -0
- package/skills/optbrew/modules/magnus-seo/assets/audit-report-template.md +76 -0
- package/skills/optbrew/modules/magnus-seo/references/aeo-methodology.md +105 -0
- package/skills/optbrew/modules/magnus-seo/references/agent-readable-content.md +28 -0
- package/skills/optbrew/modules/magnus-seo/references/content-and-entity-architecture.md +100 -0
- package/skills/optbrew/modules/magnus-seo/references/content-strategy-seo.md +142 -0
- package/skills/optbrew/modules/magnus-seo/references/discovery-and-freshness.md +47 -0
- package/skills/optbrew/modules/magnus-seo/references/evidence-boundaries.md +75 -0
- package/skills/optbrew/modules/magnus-seo/references/ghost-metadata.md +204 -0
- package/skills/optbrew/modules/magnus-seo/references/implementation-playbook.md +114 -0
- package/skills/optbrew/modules/magnus-seo/references/measurement-and-experimentation.md +47 -0
- package/skills/optbrew/modules/magnus-seo/references/onpage-seo.md +154 -0
- package/skills/optbrew/modules/magnus-seo/references/platform-guidance.md +30 -0
- package/skills/optbrew/modules/magnus-seo/references/schema-markup.md +244 -0
- package/skills/optbrew/modules/magnus-seo/references/source-index.md +36 -0
- package/skills/optbrew/modules/magnus-seo/references/structured-data.md +56 -0
- package/skills/optbrew/modules/magnus-seo/references/technical-seo.md +99 -0
- package/skills/optbrew/modules/magnus-seo/scripts/aeo_audit.py +158 -0
- package/skills/optbrew/modules/magnus-seo/scripts/build_prompt_matrix.py +52 -0
- package/skills/optbrew/modules/magnus-seo/templates/aeo-implementation-plan.md +53 -0
- package/skills/optbrew/modules/magnus-seo/templates/citation-observation-log.md +16 -0
- package/skills/optbrew/modules/magnus-seo/templates/llms.txt.template +15 -0
- package/skills/optbrew/modules/magnus-seo/templates/question-cluster.md +27 -0
- package/skills/optbrew/modules/magnus-seo/templates/robots-ai-crawlers.txt +23 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/GUIDE.md +483 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/agent-readiness.md +57 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/citations-vs-recommendations.md +85 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/content-patterns.md +287 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/content-types.md +71 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/format-volatility.md +98 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/okf.md +104 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/platform-ranking-factors.md +154 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/youtube-ai-citations.md +60 -0
- package/skills/optbrew/modules/marketing-search/skills/analytics/GUIDE.md +303 -0
- package/skills/optbrew/modules/marketing-search/skills/analytics/references/event-library.md +260 -0
- package/skills/optbrew/modules/marketing-search/skills/analytics/references/ga4-implementation.md +300 -0
- package/skills/optbrew/modules/marketing-search/skills/analytics/references/gtm-implementation.md +390 -0
- package/skills/optbrew/modules/marketing-search/skills/content-strategy/GUIDE.md +432 -0
- package/skills/optbrew/modules/marketing-search/skills/content-strategy/references/content-distribution.md +83 -0
- package/skills/optbrew/modules/marketing-search/skills/content-strategy/references/headless-cms.md +194 -0
- package/skills/optbrew/modules/marketing-search/skills/programmatic-seo/GUIDE.md +231 -0
- package/skills/optbrew/modules/marketing-search/skills/programmatic-seo/references/playbooks.md +308 -0
- package/skills/optbrew/modules/marketing-search/skills/schema/GUIDE.md +172 -0
- package/skills/optbrew/modules/marketing-search/skills/schema/references/schema-examples.md +398 -0
- package/skills/optbrew/modules/marketing-search/skills/seo-audit/GUIDE.md +492 -0
- package/skills/optbrew/modules/marketing-search/skills/seo-audit/references/ai-writing-detection.md +200 -0
- package/skills/optbrew/modules/marketing-search/skills/seo-audit/references/international-seo.md +230 -0
- package/skills/optbrew/modules/marketing-search/skills/site-architecture/GUIDE.md +350 -0
- package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/mermaid-templates.md +216 -0
- package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/navigation-patterns.md +305 -0
- package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/site-type-templates.md +293 -0
- package/skills/optbrew/modules/mblode-seo/GUIDE.md +74 -0
- package/skills/optbrew/modules/mblode-seo/references/answer-engines.md +58 -0
- package/skills/optbrew/modules/mblode-seo/references/audit.md +78 -0
- package/skills/optbrew/modules/mblode-seo/references/brief-template.md +47 -0
- package/skills/optbrew/modules/mblode-seo/references/monitoring.md +56 -0
- package/skills/optbrew/modules/mblode-seo/references/nextjs-implementation.md +65 -0
- package/skills/optbrew/modules/mblode-seo/references/research-protocol.md +37 -0
- package/skills/optbrew/modules/mblode-seo/references/sources.md +47 -0
- package/skills/optbrew/modules/mblode-seo/references/validation-evidence.md +21 -0
- package/skills/optbrew/modules/naver-searchadvisor/GUIDE.md +75 -0
- package/skills/optbrew/modules/naver-searchadvisor/NOTICE.md +59 -0
- package/skills/optbrew/modules/naver-searchadvisor/references/INDEX.md +97 -0
- package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +742 -0
- package/skills/optbrew/modules/naver-searchadvisor/scripts/robots.mjs +77 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-ai-search-share-of-voice/GUIDE.md +94 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-api/GUIDE.md +186 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/api-surface-map.md +207 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/auth-and-keys.md +182 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/integration-patterns.md +283 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/rate-limits-and-credits.md +235 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-competitor-gap-analysis/GUIDE.md +117 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/GUIDE.md +155 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/references/cite.md +84 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/references/core-eeat.md +147 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/templates/verdict.md +63 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-firecrawl/GUIDE.md +141 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-firecrawl/references/preflight.md +73 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-geo/GUIDE.md +159 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/GUIDE.md +393 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/cwv-audit-report.md +48 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/gsc-performance-report.md +44 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/indexation-status-report.md +43 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/auth-setup.md +160 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/cross-skill-integration.md +187 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/ga4-data-api.md +184 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/indexing-api.md +109 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/keyword-planner-api.md +66 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/nlp-api.md +55 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/pagespeed-crux-api.md +204 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/rate-limits-quotas.md +75 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/search-console-api.md +156 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/supplementary-apis.md +99 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/youtube-api.md +49 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-keyword-niche/GUIDE.md +174 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-page/GUIDE.md +174 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-plan/GUIDE.md +220 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/GUIDE.md +135 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/references/google-rich-results.md +89 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/article.json +23 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/breadcrumb-list.json +18 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/faq-page.json +22 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/local-business.json +32 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/product.json +25 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-sitemap/GUIDE.md +128 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-technical-audit/GUIDE.md +235 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-technical-audit/references/severity-mapping.md +133 -0
- package/skills/optbrew/references/ai-crawlers.json +21 -0
- package/skills/optbrew/references/ai-crawlers.md +24 -0
- package/skills/optbrew/references/api.md +45 -0
- package/skills/optbrew/references/check-registry.json +28 -0
- package/skills/optbrew/references/evidence.md +9 -0
- package/skills/optbrew/references/intake.md +9 -0
- package/skills/optbrew/references/measurement.md +29 -0
- package/skills/optbrew/references/policies.md +7 -0
- package/skills/optbrew/references/profiles.json +13 -0
- package/skills/optbrew/references/remediations.json +12 -0
- package/skills/optbrew/references/report.md +29 -0
- package/skills/optbrew/references/routing.md +15 -0
- package/skills/optbrew/references/schemas/contract.schema.json +51 -0
- package/skills/optbrew/references/scoring.md +62 -0
- package/skills/optbrew/scripts/adapters/anthropic.mjs +116 -0
- package/skills/optbrew/scripts/adapters/imports.mjs +81 -0
- package/skills/optbrew/scripts/audit.mjs +124 -0
- package/skills/optbrew/scripts/cli.mjs +41 -0
- package/skills/optbrew/scripts/core/bootstrap.mjs +31 -0
- package/skills/optbrew/scripts/core/bundle.mjs +73 -0
- package/skills/optbrew/scripts/core/capabilities.mjs +19 -0
- package/skills/optbrew/scripts/core/collector.mjs +250 -0
- package/skills/optbrew/scripts/core/compare.mjs +47 -0
- package/skills/optbrew/scripts/core/contracts.mjs +144 -0
- package/skills/optbrew/scripts/core/cost-ledger.mjs +95 -0
- package/skills/optbrew/scripts/core/crawlers.mjs +11 -0
- package/skills/optbrew/scripts/core/derive.mjs +168 -0
- package/skills/optbrew/scripts/core/facts.mjs +189 -0
- package/skills/optbrew/scripts/core/findings.mjs +54 -0
- package/skills/optbrew/scripts/core/html.mjs +53 -0
- package/skills/optbrew/scripts/core/metrics.mjs +97 -0
- package/skills/optbrew/scripts/core/protocol.mjs +125 -0
- package/skills/optbrew/scripts/core/report.mjs +742 -0
- package/skills/optbrew/scripts/core/scope.mjs +52 -0
- package/skills/optbrew/scripts/core/score.mjs +71 -0
- package/skills/optbrew/scripts/core/weights.mjs +139 -0
- package/skills/optbrew/source-lock.json +2686 -0
- package/source-lock.json +2686 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { parseRobots, robotsVerdict } from './robots.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* naver-searchadvisor-expert / audit.mjs
|
|
8
|
+
*
|
|
9
|
+
* URL 하나를 네이버 서치어드바이저 웹마스터 가이드 기준으로 점검한다.
|
|
10
|
+
* 의존성 없음. Node 18+ (전역 fetch).
|
|
11
|
+
*
|
|
12
|
+
* node audit.mjs https://example.com
|
|
13
|
+
* node audit.mjs https://example.com --json
|
|
14
|
+
*
|
|
15
|
+
* 판정 근거는 references/INDEX.md (official URLs) 의 원문이며, 각 항목에 guid를 붙인다.
|
|
16
|
+
* 기계적으로 확인 가능한 것만 본다. 콘텐츠 품질·스팸 소지는 사람이 판단해야 한다.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const YETI_UA =
|
|
20
|
+
'Mozilla/5.0 (compatible; Yeti/1.1; +https://naver.me/spd)';
|
|
21
|
+
const BROWSER_UA =
|
|
22
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36';
|
|
23
|
+
const TIMEOUT = 15000;
|
|
24
|
+
|
|
25
|
+
// ── 결과 수집 ────────────────────────────────────────────────────────────
|
|
26
|
+
// BLOCK: 수집/색인 자체를 막음 INDEX: 색인 품질 저해
|
|
27
|
+
// SERP : 검색결과 표현 저하 INFO : 참고 / 정상
|
|
28
|
+
const findings = [];
|
|
29
|
+
const add = (level, code, message, guid, fix) =>
|
|
30
|
+
findings.push({ level, code, message, guid, fix });
|
|
31
|
+
|
|
32
|
+
const LEVEL_ORDER = { BLOCK: 0, INDEX: 1, SERP: 2, INFO: 3 };
|
|
33
|
+
|
|
34
|
+
// ── 유틸 ─────────────────────────────────────────────────────────────────
|
|
35
|
+
async function get(url, { ua = YETI_UA, method = 'GET', redirect = 'manual' } = {}) {
|
|
36
|
+
throw new Error('direct remote fetch disabled; use optbrew audit with a RunContext');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 리다이렉트 체인을 직접 따라가며 기록한다 (최대 10회). */
|
|
40
|
+
async function follow(url, ua) {
|
|
41
|
+
const chain = [];
|
|
42
|
+
let current = url;
|
|
43
|
+
for (let i = 0; i < 10; i++) {
|
|
44
|
+
let res;
|
|
45
|
+
try {
|
|
46
|
+
res = await get(current, { ua });
|
|
47
|
+
} catch (e) {
|
|
48
|
+
chain.push({ url: current, error: String(e.message || e) });
|
|
49
|
+
return { chain, final: null };
|
|
50
|
+
}
|
|
51
|
+
const loc = res.headers.get('location');
|
|
52
|
+
chain.push({ url: current, status: res.status, location: loc });
|
|
53
|
+
if (res.status >= 300 && res.status < 400 && loc) {
|
|
54
|
+
current = new URL(loc, current).href;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
return { chain, final: res, finalUrl: current };
|
|
58
|
+
}
|
|
59
|
+
return { chain, final: null, tooMany: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** <head> 안의 태그를 속성 맵으로 뽑는다. 의존성 없이 쓸 만한 수준. */
|
|
63
|
+
function tags(html, name) {
|
|
64
|
+
const out = [];
|
|
65
|
+
const re = new RegExp(`<${name}\\b([^>]*)>`, 'gi');
|
|
66
|
+
let m;
|
|
67
|
+
while ((m = re.exec(html))) {
|
|
68
|
+
const attrs = {};
|
|
69
|
+
const are = /([a-zA-Z:_-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
|
|
70
|
+
let a;
|
|
71
|
+
while ((a = are.exec(m[1]))) {
|
|
72
|
+
attrs[a[1].toLowerCase()] = a[3] ?? a[4] ?? a[5] ?? '';
|
|
73
|
+
}
|
|
74
|
+
out.push(attrs);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function textOf(html, tag) {
|
|
80
|
+
const m = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i').exec(html);
|
|
81
|
+
return m ? m[1].replace(/\s+/g, ' ').trim() : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const metaBy = (metas, key, val) =>
|
|
85
|
+
metas.filter((t) => (t[key] || '').toLowerCase() === val).map((t) => t.content ?? '');
|
|
86
|
+
|
|
87
|
+
/** Crawler tokens that select an engine-specific robots meta tag or X-Robots-Tag scope. */
|
|
88
|
+
const ENGINE_AGENTS = { google: ['googlebot'], naver: ['yeti'] };
|
|
89
|
+
const HEADER_VALUE_FIELDS = new Set(['unavailable_after', 'max-snippet', 'max-image-preview', 'max-video-preview']);
|
|
90
|
+
|
|
91
|
+
/** Drop comments and script/style/textarea/title text, which are not markup; keep the title element on request. */
|
|
92
|
+
function stripInert(html, keepTitle = false) {
|
|
93
|
+
return String(html ?? '').replace(/<!--[\s\S]*?(?:-->|$)|<(script|style|textarea|title)\b[^>]*>[\s\S]*?(?:<\/\1\s*>|$)/gi,
|
|
94
|
+
(match, tag) => (keepTitle && tag?.toLowerCase() === 'title' ? match : ''));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const blocksIndex = (text) => String(text ?? '').split(/[,;]/).some((token) => {
|
|
98
|
+
const value = token.trim().toLowerCase();
|
|
99
|
+
return !value.includes(':') && value.split(/\s+/).some((word) => word === 'noindex' || word === 'none');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Select the meta and response-header indexing directives that apply to the target engines
|
|
104
|
+
* together with the generic robots directives. Optbrew audit and report replay share this extractor.
|
|
105
|
+
*/
|
|
106
|
+
export function indexingDirectives({ html = '', xRobotsTag = null, engines = [] }) {
|
|
107
|
+
// xRobotsTag is one string or the separate header lines; a crawler prefix scopes only the line that carries it.
|
|
108
|
+
const lines = Array.isArray(xRobotsTag) ? xRobotsTag : xRobotsTag == null ? [] : [xRobotsTag];
|
|
109
|
+
const agents = new Set(['robots', ...engines.flatMap((engine) => ENGINE_AGENTS[engine] ?? [])]);
|
|
110
|
+
const markup = stripInert(html);
|
|
111
|
+
const head = /<head[^>]*>([\s\S]*?)<\/head>/i.exec(markup)?.[1] ?? markup;
|
|
112
|
+
const meta = tags(head, 'meta').filter((t) => agents.has((t.name || '').toLowerCase())).map((t) => t.content ?? '');
|
|
113
|
+
const header = [];
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
let scope = null;
|
|
116
|
+
for (const raw of String(line).split(',')) {
|
|
117
|
+
const token = raw.trim();
|
|
118
|
+
const prefixed = /^([a-z0-9_-]+)\s*:\s*(.*)$/i.exec(token);
|
|
119
|
+
let directive = token;
|
|
120
|
+
if (prefixed && !HEADER_VALUE_FIELDS.has(prefixed[1].toLowerCase())) {
|
|
121
|
+
scope = prefixed[1].toLowerCase();
|
|
122
|
+
directive = prefixed[2];
|
|
123
|
+
}
|
|
124
|
+
if (directive && (scope === null || agents.has(scope))) header.push(directive);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { robots_meta: meta.join(', ') || null, x_robots_applicable: header.join(', ') || null,
|
|
128
|
+
blocked: blocksIndex(meta.join(', ')) || blocksIndex(header.join(', ')) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 이미지 앞부분만 Range로 받는다. 전체 바이트 수는 Content-Range/Content-Length에서 얻는다.
|
|
133
|
+
* Range를 무시하는 서버면 전체 응답을 그대로 쓴다.
|
|
134
|
+
*/
|
|
135
|
+
async function fetchRange(url, first = 65536) {
|
|
136
|
+
throw new Error('direct remote fetch disabled; use collected evidence');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── 이미지 크기 (og:image 검증용) ────────────────────────────────────────
|
|
140
|
+
function imageSize(buf) {
|
|
141
|
+
const b = Buffer.from(buf);
|
|
142
|
+
if (b.length < 24) return null;
|
|
143
|
+
// PNG
|
|
144
|
+
if (b[0] === 0x89 && b.toString('ascii', 1, 4) === 'PNG')
|
|
145
|
+
return { w: b.readUInt32BE(16), h: b.readUInt32BE(20), type: 'png' };
|
|
146
|
+
// GIF
|
|
147
|
+
if (b.toString('ascii', 0, 3) === 'GIF')
|
|
148
|
+
return { w: b.readUInt16LE(6), h: b.readUInt16LE(8), type: 'gif' };
|
|
149
|
+
// WebP
|
|
150
|
+
if (b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP') {
|
|
151
|
+
const fmt = b.toString('ascii', 12, 16);
|
|
152
|
+
if (fmt === 'VP8X') return { w: (b.readUIntLE(24, 3) & 0xffffff) + 1, h: (b.readUIntLE(27, 3) & 0xffffff) + 1, type: 'webp' };
|
|
153
|
+
if (fmt === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff, type: 'webp' };
|
|
154
|
+
if (fmt === 'VP8L') {
|
|
155
|
+
const n = b.readUInt32LE(21);
|
|
156
|
+
return { w: (n & 0x3fff) + 1, h: ((n >> 14) & 0x3fff) + 1, type: 'webp' };
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
// JPEG — SOFn 마커 탐색
|
|
161
|
+
if (b[0] === 0xff && b[1] === 0xd8) {
|
|
162
|
+
let i = 2;
|
|
163
|
+
while (i < b.length - 9) {
|
|
164
|
+
if (b[i] !== 0xff) { i++; continue; }
|
|
165
|
+
const marker = b[i + 1];
|
|
166
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker))
|
|
167
|
+
return { h: b.readUInt16BE(i + 5), w: b.readUInt16BE(i + 7), type: 'jpeg' };
|
|
168
|
+
i += 2 + b.readUInt16BE(i + 2);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── 본 점검 ──────────────────────────────────────────────────────────────
|
|
175
|
+
async function audit(target) {
|
|
176
|
+
const url = new URL(target);
|
|
177
|
+
const origin = url.origin;
|
|
178
|
+
|
|
179
|
+
// 1) 리다이렉트 체인 + 최종 응답 (Yeti UA)
|
|
180
|
+
const { chain, final, finalUrl, tooMany } = await follow(url.href, YETI_UA);
|
|
181
|
+
|
|
182
|
+
if (tooMany) {
|
|
183
|
+
add('BLOCK', 'redirect-loop', '리다이렉트가 10회를 넘습니다. 루프일 가능성이 높습니다.', 'seo-basic-redirect',
|
|
184
|
+
'리다이렉트 체인을 1회로 줄이세요.');
|
|
185
|
+
return { url: url.href, chain, findings };
|
|
186
|
+
}
|
|
187
|
+
if (!final) {
|
|
188
|
+
add('BLOCK', 'unreachable',
|
|
189
|
+
`Yeti User-Agent로 접속하지 못했습니다: ${chain.at(-1)?.error ?? '알 수 없는 오류'}`,
|
|
190
|
+
'seo-basic-firewall',
|
|
191
|
+
'방화벽/WAF가 Yeti UA 또는 네이버 IP 대역을 차단하는지 확인하세요.');
|
|
192
|
+
return { url: url.href, chain, findings };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const hops = chain.filter((c) => c.status >= 300 && c.status < 400).length;
|
|
196
|
+
if (hops === 0) {
|
|
197
|
+
add('INFO', 'redirect-none', '리다이렉트 없이 바로 응답합니다.', 'seo-basic-redirect');
|
|
198
|
+
} else if (hops === 1) {
|
|
199
|
+
add('INFO', 'redirect-1', `리다이렉트 1회: ${chain[0].status} → ${finalUrl}`, 'seo-basic-redirect');
|
|
200
|
+
} else {
|
|
201
|
+
add('INDEX', 'redirect-chain',
|
|
202
|
+
`리다이렉트가 ${hops}회 연결되어 있습니다: ${chain.map((c) => c.status ?? 'END').join(' → ')}`,
|
|
203
|
+
'seo-basic-redirect',
|
|
204
|
+
'체인을 최종 목적지로 직접 연결해 1회로 줄이세요.');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (final.status >= 400) {
|
|
208
|
+
add('BLOCK', 'http-error',
|
|
209
|
+
`최종 응답이 ${final.status} 입니다. 색인 대상이 되지 않습니다.`, 'seo-basic-http',
|
|
210
|
+
'2xx를 반환하도록 수정하세요.');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 2) 로봇 수집 차단 HTTP 헤더
|
|
214
|
+
const xr = final.headers.get('x-robots-tag');
|
|
215
|
+
if (xr) {
|
|
216
|
+
const bad = /noindex|none/i.test(xr);
|
|
217
|
+
add(bad ? 'BLOCK' : 'INFO', 'x-robots-tag',
|
|
218
|
+
`X-Robots-Tag 헤더: "${xr}"${bad ? ' — 색인이 차단됩니다.' : ''}`, 'faq-serpmissing',
|
|
219
|
+
bad ? '색인이 필요한 페이지라면 헤더를 제거하세요. HTML만 봐서는 놓치기 쉬운 원인입니다.' : undefined);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 3) robots.txt
|
|
223
|
+
const robotsUrl = origin + '/robots.txt';
|
|
224
|
+
let robots = null;
|
|
225
|
+
let robotsRaw = '';
|
|
226
|
+
try {
|
|
227
|
+
const r = await get(robotsUrl, { redirect: 'follow' });
|
|
228
|
+
const ct = (r.headers.get('content-type') || '').toLowerCase();
|
|
229
|
+
const body = await r.text();
|
|
230
|
+
|
|
231
|
+
if (r.status >= 500) {
|
|
232
|
+
add('BLOCK', 'robots-5xx',
|
|
233
|
+
`robots.txt가 ${r.status}를 반환합니다. 네이버는 5xx를 "모두 허용하지 않음"으로 해석합니다.`,
|
|
234
|
+
'seo-basic-robots', '서버 오류를 해결하세요. 이 상태로는 사이트 전체가 수집 차단됩니다.');
|
|
235
|
+
} else if (r.status >= 400) {
|
|
236
|
+
add('INFO', 'robots-4xx',
|
|
237
|
+
`robots.txt가 ${r.status} 입니다. 4xx는 "모두 허용"으로 해석되므로 수집 자체는 막히지 않습니다.`,
|
|
238
|
+
'seo-basic-robots', 'sitemap 지정 등을 위해 robots.txt를 두는 편이 좋습니다.');
|
|
239
|
+
} else {
|
|
240
|
+
if (!ct.includes('text/plain')) {
|
|
241
|
+
add('INDEX', 'robots-content-type',
|
|
242
|
+
`robots.txt의 Content-Type이 "${ct || '없음'}" 입니다. text/plain이 아니면 규칙이 있어도 "없음(모두 허용)"으로 해석될 수 있습니다.`,
|
|
243
|
+
'seo-basic-robots', 'text/plain으로 서빙하세요.');
|
|
244
|
+
}
|
|
245
|
+
if (/^\s*</.test(body)) {
|
|
246
|
+
add('INDEX', 'robots-html',
|
|
247
|
+
'robots.txt 응답이 HTML로 보입니다. 규칙이 무시될 수 있습니다.',
|
|
248
|
+
'seo-basic-robots', '일반 텍스트 파일로 교체하세요.');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
robots = parseRobots(body);
|
|
252
|
+
robotsRaw = body;
|
|
253
|
+
|
|
254
|
+
// 지시자 이름과 콜론 사이의 공백은 RFC 9309 문법상 허용되지만,
|
|
255
|
+
// 엄격한 파서는 그 줄을 통째로 버린다. Yeti 전용 그룹이 이런 형태면
|
|
256
|
+
// 사이트 전체 수집 정책이 파서 관용도에 걸린 셈이라 반드시 알려야 한다.
|
|
257
|
+
const loose = body.split(/\r?\n/).filter((l) => /^\s*user-agent\s+:/i.test(l));
|
|
258
|
+
if (loose.length)
|
|
259
|
+
add('INDEX', 'robots-loose-syntax',
|
|
260
|
+
`robots.txt에 지시자와 콜론 사이가 벌어진 줄이 ${loose.length}개 있습니다: ${loose.map((l) => `"${l.trim()}"`).join(', ')}`,
|
|
261
|
+
'seo-basic-robots',
|
|
262
|
+
'표준 문법상 허용되지만 엄격한 파서는 이 줄을 무시합니다. 공백을 제거해 "User-agent: Yeti" 형태로 맞추세요.');
|
|
263
|
+
|
|
264
|
+
const yeti = robotsVerdict(robots, 'yeti', url.pathname + url.search);
|
|
265
|
+
if (!yeti.allowed) {
|
|
266
|
+
add('BLOCK', 'robots-disallow',
|
|
267
|
+
`robots.txt가 Yeti의 이 경로 수집을 차단합니다 (${yeti.reason}).`,
|
|
268
|
+
'seo-basic-robots', '색인이 필요한 경로라면 Allow로 바꾸세요.');
|
|
269
|
+
} else {
|
|
270
|
+
add('INFO', 'robots-allow', `robots.txt 기준 Yeti 수집 허용 (${yeti.reason}).`, 'seo-basic-robots');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const hasYetiGroup = robots.groups.some((g) => g.agents.includes('yeti'));
|
|
274
|
+
const starBlocked = robotsVerdict(robots, 'some-other-bot', url.pathname + url.search);
|
|
275
|
+
if (!starBlocked.allowed && !hasYetiGroup) {
|
|
276
|
+
add('BLOCK', 'robots-star-block',
|
|
277
|
+
'와일드카드(*) 그룹이 수집을 차단하는데 Yeti 전용 그룹이 없습니다. 네이버도 함께 차단됩니다.',
|
|
278
|
+
'seo-basic-robots', 'User-agent: Yeti / Allow: / 그룹을 추가하세요.');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!robots.sitemaps.length) {
|
|
282
|
+
add('SERP', 'robots-no-sitemap',
|
|
283
|
+
'robots.txt에 Sitemap 지정이 없습니다.', 'seo-basic-robots',
|
|
284
|
+
'Sitemap: https://example.com/sitemap.xml 한 줄을 추가하세요.');
|
|
285
|
+
} else {
|
|
286
|
+
add('INFO', 'robots-sitemap', `Sitemap 지정됨: ${robots.sitemaps.join(', ')}`, 'request-feed');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} catch (e) {
|
|
290
|
+
add('INFO', 'robots-fetch-fail', `robots.txt를 가져오지 못했습니다: ${e.message}`, 'seo-basic-robots');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 4) HTML 분석
|
|
294
|
+
const ctype = (final.headers.get('content-type') || '').toLowerCase();
|
|
295
|
+
if (!ctype.includes('html')) {
|
|
296
|
+
add('INFO', 'not-html', `Content-Type이 "${ctype}" 이라 HTML 점검을 건너뜁니다.`, 'seo-basic-http');
|
|
297
|
+
return { url: finalUrl, chain, findings };
|
|
298
|
+
}
|
|
299
|
+
const html = await final.text();
|
|
300
|
+
const head = /<head[^>]*>([\s\S]*?)<\/head>/i.exec(html)?.[1] ?? html;
|
|
301
|
+
const metas = tags(head, 'meta');
|
|
302
|
+
const links = tags(head, 'link');
|
|
303
|
+
|
|
304
|
+
// title
|
|
305
|
+
const title = textOf(head, 'title');
|
|
306
|
+
if (!title) {
|
|
307
|
+
add('SERP', 'no-title', '<title> 태그가 없습니다.', 'markup-content', '페이지 주제를 설명하는 고유한 제목을 넣으세요.');
|
|
308
|
+
} else {
|
|
309
|
+
add('INFO', 'title', `제목: "${title}" (${title.length}자)`, 'markup-content');
|
|
310
|
+
const words = title.toLowerCase().match(/[가-힣a-z0-9]+/g) || [];
|
|
311
|
+
const dup = [...new Set(words.filter((w) => w.length > 1 && words.filter((x) => x === w).length >= 2))];
|
|
312
|
+
if (dup.length)
|
|
313
|
+
add('INDEX', 'title-keyword-repeat',
|
|
314
|
+
`제목에 반복되는 키워드가 있습니다: ${dup.join(', ')}. 2회 이상 반복은 어뷰징 의심 대상입니다.`,
|
|
315
|
+
'markup-content', '자연스러운 한 문장으로 다듬으세요.');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// description
|
|
319
|
+
const desc = metaBy(metas, 'name', 'description')[0];
|
|
320
|
+
if (!desc) {
|
|
321
|
+
add('SERP', 'no-description', '<meta name="description">이 없습니다.', 'markup-content',
|
|
322
|
+
'1~2문장으로 페이지 내용을 요약하세요.');
|
|
323
|
+
} else {
|
|
324
|
+
if (desc.trim() === (title || '').trim())
|
|
325
|
+
add('INDEX', 'description-eq-title', 'description이 title과 동일합니다.', 'markup-content',
|
|
326
|
+
'제목과 다른, 내용을 요약하는 문장을 쓰세요.');
|
|
327
|
+
else add('INFO', 'description', `설명문: ${desc.length}자`, 'markup-content');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// canonical
|
|
331
|
+
const canonical = links.find((l) => (l.rel || '').toLowerCase() === 'canonical')?.href;
|
|
332
|
+
if (!canonical) {
|
|
333
|
+
add('INDEX', 'no-canonical', 'canonical(선호 URL)이 지정되지 않았습니다.', 'markup-structure',
|
|
334
|
+
'동일 콘텐츠가 여러 URL로 접근 가능하면 중복으로 처리될 수 있습니다. 절대 경로로 지정하세요.');
|
|
335
|
+
} else if (!/^https?:\/\//i.test(canonical)) {
|
|
336
|
+
add('INDEX', 'canonical-relative',
|
|
337
|
+
`canonical이 상대 경로입니다: "${canonical}". 가이드는 절대 경로를 요구합니다.`,
|
|
338
|
+
'markup-structure', `<link rel="canonical" href="${new URL(canonical, finalUrl).href}">`);
|
|
339
|
+
} else {
|
|
340
|
+
add('INFO', 'canonical', `canonical: ${canonical}`, 'markup-structure');
|
|
341
|
+
if (new URL(canonical).href !== new URL(finalUrl).href)
|
|
342
|
+
add('INFO', 'canonical-differs',
|
|
343
|
+
`canonical이 현재 URL과 다릅니다. 이 페이지는 ${canonical} 의 사본으로 처리됩니다.`, 'markup-structure');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// robots meta
|
|
347
|
+
const robotsMeta = metaBy(metas, 'name', 'robots').join(', ');
|
|
348
|
+
if (robotsMeta) {
|
|
349
|
+
if (/noindex/i.test(robotsMeta))
|
|
350
|
+
add('BLOCK', 'meta-noindex', `로봇 메타 태그에 noindex가 있습니다: "${robotsMeta}"`, 'markup-structure',
|
|
351
|
+
'검색 노출이 필요한 페이지라면 index,follow로 바꾸세요.');
|
|
352
|
+
else add('INFO', 'meta-robots', `로봇 메타 태그: "${robotsMeta}"`, 'markup-structure');
|
|
353
|
+
if (/nosourceinfo/i.test(robotsMeta))
|
|
354
|
+
add('INFO', 'nosourceinfo', 'nosourceinfo 설정됨 — AI 자동생성 출처설명이 제공되지 않습니다.', 'markup-structure');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 오픈그래프
|
|
358
|
+
const og = {};
|
|
359
|
+
for (const t of metas)
|
|
360
|
+
if ((t.property || '').startsWith('og:')) og[t.property.slice(3)] = t.content ?? '';
|
|
361
|
+
|
|
362
|
+
for (const k of ['title', 'description', 'image', 'url', 'type']) {
|
|
363
|
+
if (!og[k])
|
|
364
|
+
add('SERP', `no-og-${k}`, `og:${k} 가 없습니다.`, 'markup-content',
|
|
365
|
+
'네이버 검색로봇도 오픈그래프를 페이지 분석·노출에 활용합니다.');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (og.image) {
|
|
369
|
+
try {
|
|
370
|
+
const imgUrl = new URL(og.image, finalUrl).href;
|
|
371
|
+
// 헤더 판독에는 앞부분이면 충분하다. Range로 받아 대역폭을 아낀다.
|
|
372
|
+
const ir = await fetchRange(imgUrl);
|
|
373
|
+
if (!ir.ok) {
|
|
374
|
+
add('SERP', 'og-image-unreachable', `og:image를 가져올 수 없습니다 (${ir.status}): ${imgUrl}`,
|
|
375
|
+
'markup-content', '접근 가능한 절대 URL로 지정하세요.');
|
|
376
|
+
} else {
|
|
377
|
+
const { buf, bytes } = ir;
|
|
378
|
+
const size = imageSize(buf);
|
|
379
|
+
if (bytes < 5000)
|
|
380
|
+
add('SERP', 'og-image-small-bytes',
|
|
381
|
+
`og:image 파일 크기가 ${bytes} byte 입니다. 가이드 기준은 5,000 byte 이상입니다.`,
|
|
382
|
+
'markup-content', '더 큰 대표 이미지를 사용하세요.');
|
|
383
|
+
if (size) {
|
|
384
|
+
const { w, h } = size;
|
|
385
|
+
const ratio = Math.max(w, h) / Math.min(w, h);
|
|
386
|
+
const parts = [`${w}×${h}`, `${bytes.toLocaleString()} byte`, `비율 ${ratio.toFixed(2)}:1`];
|
|
387
|
+
if (w <= 150 || h <= 150)
|
|
388
|
+
add('SERP', 'og-image-small-dim',
|
|
389
|
+
`og:image가 ${w}×${h} 입니다. 가이드 기준은 150×150 초과입니다.`, 'markup-content',
|
|
390
|
+
'더 큰 이미지를 사용하세요.');
|
|
391
|
+
if (ratio > 3)
|
|
392
|
+
add('SERP', 'og-image-ratio',
|
|
393
|
+
`og:image 비율이 ${ratio.toFixed(2)}:1 입니다. 가이드 기준은 3:1 이내입니다.`, 'markup-content',
|
|
394
|
+
'가로세로 비율을 3:1 안으로 맞추세요.');
|
|
395
|
+
if (w > 150 && h > 150 && ratio <= 3 && bytes >= 5000)
|
|
396
|
+
add('INFO', 'og-image-ok', `og:image 규격 통과 — ${parts.join(', ')}`, 'markup-content');
|
|
397
|
+
} else {
|
|
398
|
+
add('INFO', 'og-image-unknown-format',
|
|
399
|
+
`og:image 크기를 판독하지 못했습니다 (${bytes.toLocaleString()} byte). 150×150 초과·3:1 이내인지 직접 확인하세요.`,
|
|
400
|
+
'markup-content');
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} catch (e) {
|
|
404
|
+
add('SERP', 'og-image-error', `og:image 확인 실패: ${e.message}`, 'markup-content');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// viewport
|
|
409
|
+
const viewport = metaBy(metas, 'name', 'viewport')[0];
|
|
410
|
+
if (!viewport)
|
|
411
|
+
add('SERP', 'no-viewport', '<meta name="viewport">가 없습니다. 모바일 사용성에 불리합니다.', 'markup-mobile',
|
|
412
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">');
|
|
413
|
+
else add('INFO', 'viewport', `viewport: ${viewport}`, 'markup-mobile');
|
|
414
|
+
|
|
415
|
+
// 파비콘
|
|
416
|
+
const favicon = links.find((l) => /(^|\s)(icon|shortcut icon|apple-touch-icon)(\s|$)/i.test(l.rel || ''));
|
|
417
|
+
if (!favicon)
|
|
418
|
+
add('SERP', 'no-favicon', 'HTML에 파비콘 link 태그가 없습니다.', 'markup-favicon',
|
|
419
|
+
'/favicon.ico 가 있으면 인식될 수 있지만, link 태그로 명시하는 편이 확실합니다. 파비콘 경로가 robots.txt로 차단되지 않았는지도 확인하세요.');
|
|
420
|
+
else add('INFO', 'favicon', `파비콘: ${favicon.href}`, 'markup-favicon');
|
|
421
|
+
|
|
422
|
+
// lang
|
|
423
|
+
const lang = /<html[^>]*\blang\s*=\s*["']?([\w-]+)/i.exec(html)?.[1];
|
|
424
|
+
if (!lang) add('SERP', 'no-lang', '<html> 태그에 lang 속성이 없습니다.', 'markup-structure');
|
|
425
|
+
|
|
426
|
+
// 구조화된 데이터
|
|
427
|
+
const ld = [...html.matchAll(/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)];
|
|
428
|
+
const micro = /itemscope/i.test(html);
|
|
429
|
+
if (!ld.length && !micro) {
|
|
430
|
+
add('INFO', 'no-structured-data',
|
|
431
|
+
'구조화된 데이터(JSON-LD / Microdata)가 없습니다.', 'structured-data-intro',
|
|
432
|
+
'콘텐츠 성격에 맞는 타입이 있다면 추가를 검토하세요. 단, 마크업해도 노출이 보장되지는 않습니다.');
|
|
433
|
+
} else {
|
|
434
|
+
const types = [];
|
|
435
|
+
for (const [, body] of ld) {
|
|
436
|
+
try {
|
|
437
|
+
const j = JSON.parse(body.trim());
|
|
438
|
+
for (const n of Array.isArray(j) ? j : [j]) {
|
|
439
|
+
const t = n['@type'];
|
|
440
|
+
if (t) types.push(...(Array.isArray(t) ? t : [t]));
|
|
441
|
+
}
|
|
442
|
+
} catch {
|
|
443
|
+
add('INDEX', 'ld-json-invalid', 'JSON-LD 블록의 JSON 파싱에 실패했습니다.', 'structured-data-intro',
|
|
444
|
+
'https://validator.schema.org/ 로 검증하세요.');
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
add('INFO', 'structured-data',
|
|
448
|
+
`구조화된 데이터 발견 — ${ld.length}개 JSON-LD 블록${micro ? ' + Microdata' : ''}${types.length ? `, 타입: ${[...new Set(types)].join(', ')}` : ''}`,
|
|
449
|
+
'structured-data-intro');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// JS 렌더링 의존 여부
|
|
453
|
+
const bodyHtml = /<body[^>]*>([\s\S]*)<\/body>/i.exec(html)?.[1] ?? '';
|
|
454
|
+
const visible = bodyHtml
|
|
455
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
456
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
457
|
+
.replace(/<[^>]+>/g, ' ')
|
|
458
|
+
.replace(/\s+/g, ' ')
|
|
459
|
+
.trim();
|
|
460
|
+
if (visible.length < 200) {
|
|
461
|
+
add('INDEX', 'js-rendered',
|
|
462
|
+
`HTML 원문의 본문 텍스트가 ${visible.length}자뿐입니다. 클라이언트 렌더링(SPA)일 가능성이 높습니다.`,
|
|
463
|
+
'seo-advanced-javascript',
|
|
464
|
+
'SSR/프리렌더링을 검토하세요. 서치어드바이저 [URL 검사]로 로봇이 실제 수집하는 HTML을 확인할 수 있습니다.');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// 이미지 alt — 속성 누락과 alt="" 는 다른 이야기다.
|
|
468
|
+
// alt="" 는 장식용 이미지를 표시하는 올바른 방법이므로 결함으로 세지 않는다.
|
|
469
|
+
const imgs = tags(html, 'img');
|
|
470
|
+
const missingAlt = imgs.filter((i) => i.alt === undefined).length;
|
|
471
|
+
const emptyAlt = imgs.filter((i) => i.alt !== undefined && i.alt.trim() === '').length;
|
|
472
|
+
if (missingAlt)
|
|
473
|
+
add('SERP', 'img-no-alt',
|
|
474
|
+
`이미지 ${imgs.length}개 중 ${missingAlt}개에 alt 속성이 아예 없습니다.`, 'content-basic',
|
|
475
|
+
'검색로봇은 이미지 속 텍스트를 인식하기 어렵습니다. 내용이 있는 이미지에는 alt를 넣고, 장식용이면 alt=""로 명시하세요.');
|
|
476
|
+
if (emptyAlt)
|
|
477
|
+
add('INFO', 'img-empty-alt',
|
|
478
|
+
`이미지 ${imgs.length}개 중 ${emptyAlt}개가 alt=""(장식용 표시)입니다. 이 중 실제로 정보를 담은 이미지가 있는지 확인하세요.`,
|
|
479
|
+
'content-basic');
|
|
480
|
+
|
|
481
|
+
// ── 페이지 구성 리소스가 robots.txt로 차단되는지 ────────────────────
|
|
482
|
+
// 페이지 자체는 허용인데 CSS/JS가 막히면 로봇이 문서를 제대로 해석하지 못한다.
|
|
483
|
+
// 가이드가 명시적으로 경고하는 항목이고, 페이지 경로만 봐서는 절대 안 잡힌다.
|
|
484
|
+
const resources = { CSS: [], JS: [], 이미지: [], 기타: [] };
|
|
485
|
+
for (const l of links)
|
|
486
|
+
if (/(^|\s)stylesheet(\s|$)/i.test(l.rel || '') && l.href) resources.CSS.push(l.href);
|
|
487
|
+
for (const s of tags(html, 'script')) if (s.src) resources.JS.push(s.src);
|
|
488
|
+
for (const i of imgs) if (i.src) resources['이미지'].push(i.src);
|
|
489
|
+
if (og.image) resources['기타'].push(og.image);
|
|
490
|
+
if (favicon?.href) resources['기타'].push(favicon.href);
|
|
491
|
+
|
|
492
|
+
const resolve = (r) => { try { return new URL(r, finalUrl); } catch { return null; } };
|
|
493
|
+
|
|
494
|
+
if (robots) {
|
|
495
|
+
/** agent 기준으로 리소스 차단 현황을 센다. */
|
|
496
|
+
const scan = (agent) => {
|
|
497
|
+
const out = {};
|
|
498
|
+
let same = 0, blocked = 0;
|
|
499
|
+
for (const [kind, list] of Object.entries(resources)) {
|
|
500
|
+
const urls = [...new Set(list)].map(resolve).filter((u) => u && u.origin === origin);
|
|
501
|
+
const bad = urls.filter((u) => !robotsVerdict(robots, agent, u.pathname + u.search).allowed);
|
|
502
|
+
same += urls.length;
|
|
503
|
+
blocked += bad.length;
|
|
504
|
+
if (bad.length)
|
|
505
|
+
out[kind] = { n: bad.length, of: urls.length,
|
|
506
|
+
rules: [...new Set(bad.map((u) => robotsVerdict(robots, agent, u.pathname + u.search).reason))] };
|
|
507
|
+
}
|
|
508
|
+
return { out, same, blocked };
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const describe = (r) =>
|
|
512
|
+
Object.entries(r.out).map(([k, v]) => `${k} ${v.n}/${v.of}`).join(', ');
|
|
513
|
+
const rulesOf = (r) => [...new Set(Object.values(r.out).flatMap((v) => v.rules))].join(' / ');
|
|
514
|
+
|
|
515
|
+
const asYeti = scan('yeti');
|
|
516
|
+
const asStar = scan('*');
|
|
517
|
+
const hasYetiGroupNow = robots.groups.some((g) => g.agents.includes('yeti'));
|
|
518
|
+
|
|
519
|
+
if (asYeti.blocked) {
|
|
520
|
+
const breaks = asYeti.out.CSS || asYeti.out.JS;
|
|
521
|
+
add(breaks ? 'BLOCK' : 'INDEX', 'resource-blocked',
|
|
522
|
+
`페이지는 수집 허용이지만 구성 리소스가 robots.txt로 차단됩니다 — ${describe(asYeti)}. 적용된 규칙: ${rulesOf(asYeti)}`,
|
|
523
|
+
'resource-and-link',
|
|
524
|
+
breaks
|
|
525
|
+
? 'CSS/JS가 막히면 로봇이 문서를 온전히 해석하지 못해 미노출·오노출로 이어질 수 있습니다. 해당 경로를 Allow로 바꾸거나 Yeti 전용 예외를 추가하세요.'
|
|
526
|
+
: '문서와 동일한 규칙을 적용하거나 기본 허용으로 바꾸세요.');
|
|
527
|
+
} else if (hasYetiGroupNow && asStar.blocked) {
|
|
528
|
+
// Yeti 전용 그룹 덕분에만 리소스가 열려 있는 상태.
|
|
529
|
+
// 그 그룹이 한 줄이라도 어긋나면 곧바로 아래 수치만큼 막힌다.
|
|
530
|
+
add('INDEX', 'resource-depends-on-yeti-group',
|
|
531
|
+
`리소스 수집이 Yeti 전용 그룹 하나에만 의존합니다. 그 그룹이 무시되면 와일드카드(*) 규칙이 적용되어 ${describe(asStar)}가 차단됩니다 (${rulesOf(asStar)}).`,
|
|
532
|
+
'resource-and-link',
|
|
533
|
+
'Yeti 그룹 문법을 정확히 유지하거나, 애초에 * 그룹에서 리소스 경로를 Disallow하지 않는 편이 안전합니다.');
|
|
534
|
+
} else if (asYeti.same) {
|
|
535
|
+
add('INFO', 'resource-ok', `동일 호스트 리소스 ${asYeti.same}개 모두 수집 허용.`, 'resource-and-link');
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ── apex ↔ www 반대편 호스트의 robots.txt ───────────────────────────
|
|
540
|
+
// robots 규칙은 호스트별로만 유효하다. 한쪽이 전면 차단이면 그쪽 URL은 통째로 죽는다.
|
|
541
|
+
const otherHost = url.hostname.startsWith('www.')
|
|
542
|
+
? url.hostname.slice(4)
|
|
543
|
+
: 'www.' + url.hostname;
|
|
544
|
+
try {
|
|
545
|
+
const or = await get(`${url.protocol}//${otherHost}/robots.txt`, { redirect: 'follow' });
|
|
546
|
+
if (or.ok) {
|
|
547
|
+
const op = parseRobots(await or.text());
|
|
548
|
+
const ov = robotsVerdict(op, 'yeti', '/');
|
|
549
|
+
if (!ov.allowed)
|
|
550
|
+
add('INDEX', 'other-host-blocked',
|
|
551
|
+
`반대편 호스트 ${otherHost} 의 robots.txt가 Yeti를 전면 차단합니다 (${ov.reason}).`,
|
|
552
|
+
'seo-basic-robots',
|
|
553
|
+
`robots.txt 규칙은 호스트별로만 유효하므로 ${url.hostname} 의 설정이 이쪽을 구제하지 못합니다. ${otherHost} 를 ${url.hostname} 로 301 리다이렉트하거나 robots.txt를 맞추세요.`);
|
|
554
|
+
}
|
|
555
|
+
} catch { /* 반대편 호스트가 없을 수 있다 — 정상 */ }
|
|
556
|
+
|
|
557
|
+
// ── 크롤 불가능한 링크 / 프로토콜 혼용 ──────────────────────────────
|
|
558
|
+
const anchors = tags(html, 'a');
|
|
559
|
+
const badHref = anchors.filter((a) => {
|
|
560
|
+
const h = (a.href || '').trim();
|
|
561
|
+
if (!h) return false;
|
|
562
|
+
return /^javascript:/i.test(h) || /^[a-zA-Z_$][\w$]*\s*\(/.test(h);
|
|
563
|
+
});
|
|
564
|
+
if (badHref.length)
|
|
565
|
+
add('INDEX', 'uncrawlable-links',
|
|
566
|
+
`<a href>가 URL이 아닌 자바스크립트 호출인 링크가 ${badHref.length}개 있습니다 (예: ${badHref[0].href.slice(0, 50)}).`,
|
|
567
|
+
'resource-and-link',
|
|
568
|
+
'로봇은 href의 URL로 다른 페이지를 발견합니다. 실제 URL을 넣으면 사이트 수집량이 늘어납니다.');
|
|
569
|
+
|
|
570
|
+
if (url.protocol === 'https:') {
|
|
571
|
+
const mixed = Object.values(resources).flat()
|
|
572
|
+
.map(resolve)
|
|
573
|
+
.filter((u) => u && u.protocol === 'http:' && u.hostname === url.hostname);
|
|
574
|
+
if (mixed.length)
|
|
575
|
+
add('INDEX', 'mixed-protocol',
|
|
576
|
+
`HTTPS 페이지인데 동일 호스트를 http:// 로 참조하는 리소스가 ${mixed.length}개 있습니다.`,
|
|
577
|
+
'resource-and-link', '리소스 URL을 페이지와 동일한 https:// 로 맞추세요.');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// 5) 클로킹 점검 — 브라우저 UA와 응답 비교
|
|
581
|
+
try {
|
|
582
|
+
const b = await follow(url.href, BROWSER_UA);
|
|
583
|
+
if (b.final) {
|
|
584
|
+
if (b.final.status !== final.status) {
|
|
585
|
+
add('BLOCK', 'ua-status-differs',
|
|
586
|
+
`Yeti UA는 ${final.status}, 일반 브라우저 UA는 ${b.final.status}를 받습니다. 방화벽이 검색로봇을 차단하고 있을 수 있습니다.`,
|
|
587
|
+
'seo-basic-firewall', '방화벽/WAF에서 Yeti UA와 네이버 IP 대역을 허용하세요.');
|
|
588
|
+
} else if (ctype.includes('html')) {
|
|
589
|
+
const bt = (await b.final.text()).length;
|
|
590
|
+
const diff = Math.abs(bt - html.length) / Math.max(bt, html.length, 1);
|
|
591
|
+
if (diff > 0.3)
|
|
592
|
+
add('INDEX', 'ua-content-differs',
|
|
593
|
+
`Yeti UA와 브라우저 UA의 응답 크기가 ${(diff * 100).toFixed(0)}% 다릅니다 (${html.length} vs ${bt} byte).`,
|
|
594
|
+
'content-abusing',
|
|
595
|
+
'의도한 것이 아니라면 확인하세요. 검색엔진과 사용자에게 다른 내용을 주는 것은 클로킹으로 분류될 수 있습니다.');
|
|
596
|
+
}
|
|
597
|
+
} else {
|
|
598
|
+
add('INFO', 'ua-browser-fail', '브라우저 UA 비교 요청이 실패해 클로킹 점검을 건너뜁니다.', 'seo-basic-firewall');
|
|
599
|
+
}
|
|
600
|
+
} catch {
|
|
601
|
+
/* 비교 실패는 치명적이지 않다 */
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return { url: finalUrl, chain, findings };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ── 출력 ─────────────────────────────────────────────────────────────────
|
|
608
|
+
const ICON = { BLOCK: '🔴', INDEX: '🟠', SERP: '🟡', INFO: '🔵' };
|
|
609
|
+
const LABEL = {
|
|
610
|
+
BLOCK: '수집·색인 차단',
|
|
611
|
+
INDEX: '색인 품질',
|
|
612
|
+
SERP: '검색결과 표현',
|
|
613
|
+
INFO: '참고',
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
function report(result) {
|
|
617
|
+
const { url, chain, findings } = result;
|
|
618
|
+
const out = [];
|
|
619
|
+
out.push(`\n네이버 SEO 점검 — ${url}`);
|
|
620
|
+
out.push('근거: 네이버 서치어드바이저 웹마스터 가이드 (references/INDEX.md (official URLs))');
|
|
621
|
+
|
|
622
|
+
if (chain.length > 1)
|
|
623
|
+
out.push(`\n리다이렉트 체인: ${chain.map((c) => `${c.status ?? 'ERR'}`).join(' → ')}`);
|
|
624
|
+
|
|
625
|
+
const sorted = [...findings].sort((a, b) => LEVEL_ORDER[a.level] - LEVEL_ORDER[b.level]);
|
|
626
|
+
let last = null;
|
|
627
|
+
for (const f of sorted) {
|
|
628
|
+
if (f.level !== last) {
|
|
629
|
+
out.push(`\n${ICON[f.level]} ${LABEL[f.level]}`);
|
|
630
|
+
last = f.level;
|
|
631
|
+
}
|
|
632
|
+
out.push(` · ${f.message} [${f.guid}]`);
|
|
633
|
+
if (f.fix) out.push(` → ${f.fix}`);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const n = (lv) => findings.filter((f) => f.level === lv).length;
|
|
637
|
+
out.push(
|
|
638
|
+
`\n요약: 차단 ${n('BLOCK')} / 색인 ${n('INDEX')} / 표현 ${n('SERP')} / 참고 ${n('INFO')}`
|
|
639
|
+
);
|
|
640
|
+
out.push(
|
|
641
|
+
'\n이 스크립트로 판정할 수 없는 것: 콘텐츠 품질, 주제 일관성, 스팸 소지, 실제 순위.'
|
|
642
|
+
);
|
|
643
|
+
out.push(' → official Naver guide URLs (see references/INDEX.md) 기준으로 검토하세요.');
|
|
644
|
+
out.push(' → 서치어드바이저 [사이트 간단 체크]·[URL 검사]로 교차검증하세요.\n');
|
|
645
|
+
return out.join('\n');
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Optbrew path: inspect only responses already collected and preserved by the bounded collector. */
|
|
649
|
+
export function auditCollected({ artifact, html, robotsArtifact = null, engines = [] }) {
|
|
650
|
+
const titleSource = stripInert(html, true);
|
|
651
|
+
const head = /<head[^>]*>([\s\S]*?)<\/head>/i.exec(titleSource)?.[1] ?? titleSource;
|
|
652
|
+
const title = textOf(head, 'title');
|
|
653
|
+
const xRobotsLines = Array.isArray(artifact.header_lines) ?
|
|
654
|
+
artifact.header_lines.filter((line) => line.name === 'x-robots-tag').map((line) => line.value) :
|
|
655
|
+
artifact.headers['x-robots-tag'] != null ? [artifact.headers['x-robots-tag']] : [];
|
|
656
|
+
const directives = indexingDirectives({ html, xRobotsTag: xRobotsLines, engines });
|
|
657
|
+
// JSON-LD inside HTML comments is not live markup; an empty placeholder block is not a parse error.
|
|
658
|
+
const jsonld = [...String(html).replace(/<!--[\s\S]*?(?:-->|$)/g, '').matchAll(/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)].filter(([, body]) => body.trim());
|
|
659
|
+
// Naver markup checks on the collected response (moved from the unreachable direct-fetch path).
|
|
660
|
+
const liveMarkup = stripInert(html);
|
|
661
|
+
const markupHead = /<head[^>]*>([\s\S]*?)<\/head>/i.exec(liveMarkup)?.[1] ?? liveMarkup;
|
|
662
|
+
const markupMetas = tags(markupHead, 'meta');
|
|
663
|
+
const markupLinks = tags(markupHead, 'link');
|
|
664
|
+
const markupFindings = [];
|
|
665
|
+
const openGraph = {};
|
|
666
|
+
for (const t of markupMetas) if ((t.property || '').startsWith('og:')) openGraph[t.property.slice(3)] = t.content ?? '';
|
|
667
|
+
for (const k of ['title', 'description', 'image', 'url', 'type'])
|
|
668
|
+
if (!openGraph[k]) markupFindings.push({ code: `no-og-${k}`, message: `og:${k} is missing` });
|
|
669
|
+
if (!metaBy(markupMetas, 'name', 'description')[0]) markupFindings.push({ code: 'no-description', message: 'meta description is missing' });
|
|
670
|
+
const canonical = markupLinks.find((l) => (l.rel || '').toLowerCase() === 'canonical')?.href ?? null;
|
|
671
|
+
if (!canonical) markupFindings.push({ code: 'no-canonical', message: 'canonical link is missing' });
|
|
672
|
+
else if (!/^https?:\/\//i.test(canonical)) markupFindings.push({ code: 'canonical-relative', message: `canonical is relative: ${canonical}` });
|
|
673
|
+
// Three or more occurrences of one word in a title; two are common in legitimate "Page | Site" titles.
|
|
674
|
+
const titleWords = (title || '').toLowerCase().match(/[가-힣a-z0-9]+/g) || [];
|
|
675
|
+
const repeated = [...new Set(titleWords.filter((w) => w.length > 1 && titleWords.filter((x) => x === w).length >= 3))];
|
|
676
|
+
if (repeated.length) markupFindings.push({ code: 'title-keyword-repeat', message: `title repeats: ${repeated.join(', ')}` });
|
|
677
|
+
if (!metaBy(markupMetas, 'name', 'viewport')[0]) markupFindings.push({ code: 'no-viewport', message: 'meta viewport is missing' });
|
|
678
|
+
let parsedRobots = null;
|
|
679
|
+
let yetiVerdict = null;
|
|
680
|
+
const findings = [];
|
|
681
|
+
const limitations = ['Yeti User-Agent supplied to this collector is not proof of a Naver bot visit.',
|
|
682
|
+
'Metadata observations do not prove indexing, ranking, or structured-data truth.'];
|
|
683
|
+
if (robotsArtifact?.status === 200 && typeof robotsArtifact.body === 'string') {
|
|
684
|
+
parsedRobots = parseRobots(robotsArtifact.body);
|
|
685
|
+
const url = new URL(artifact.final_url);
|
|
686
|
+
yetiVerdict = robotsVerdict(parsedRobots, 'yeti', url.pathname + url.search);
|
|
687
|
+
if (!yetiVerdict.allowed) findings.push({ code: 'robots-disallow', message: `Yeti path blocked: ${url.pathname}${url.search}` });
|
|
688
|
+
const resources = [...tags(html, 'link').filter(x => /stylesheet/i.test(x.rel ?? '')).map(x => x.href),
|
|
689
|
+
...tags(html, 'script').map(x => x.src), ...tags(html, 'img').map(x => x.src)].filter(Boolean);
|
|
690
|
+
for (const resource of resources) {
|
|
691
|
+
let target;
|
|
692
|
+
try { target = new URL(resource, artifact.final_url); }
|
|
693
|
+
catch { limitations.push('Invalid resource URL skipped during robots check.'); continue; }
|
|
694
|
+
if (target.origin !== url.origin) continue;
|
|
695
|
+
if (!robotsVerdict(parsedRobots, 'yeti', target.pathname + target.search).allowed)
|
|
696
|
+
findings.push({ code: 'resource-blocked', message: `Yeti resource blocked: ${target.pathname}${target.search}` });
|
|
697
|
+
}
|
|
698
|
+
// A blocked stylesheet or script stops the crawler from rendering the page the way a visitor sees it.
|
|
699
|
+
for (const resource of [...tags(html, 'link').filter(x => /stylesheet/i.test(x.rel ?? '')).map(x => x.href), ...tags(html, 'script').map(x => x.src)].filter(Boolean)) {
|
|
700
|
+
let target;
|
|
701
|
+
try { target = new URL(resource, artifact.final_url); } catch { continue; }
|
|
702
|
+
if (target.origin === url.origin && !robotsVerdict(parsedRobots, 'yeti', target.pathname + target.search).allowed)
|
|
703
|
+
markupFindings.push({ code: 'resource-blocked', message: `Yeti cannot fetch ${target.pathname}${target.search}` });
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return {
|
|
707
|
+
url: artifact.final_url, requested_url: artifact.normalized_url ?? null,
|
|
708
|
+
redirect_chain: artifact.redirect_chain ?? [], status: artifact.status, title,
|
|
709
|
+
x_robots_tag: xRobotsLines.length ? xRobotsLines.join(', ') : null, robots_meta: directives.robots_meta,
|
|
710
|
+
index_blocked: directives.blocked,
|
|
711
|
+
jsonld_blocks: jsonld.length, jsonld_parse_errors: jsonld.filter(([, body]) => { try { JSON.parse(body); return false; } catch { return true; } }).length,
|
|
712
|
+
robots: parsedRobots ? { yeti_allowed: yetiVerdict.allowed, reason: yetiVerdict.reason, sitemaps: parsedRobots.sitemaps } : null,
|
|
713
|
+
canonical, markup_findings: markupFindings,
|
|
714
|
+
findings,
|
|
715
|
+
limitations,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ── 엔트리 ───────────────────────────────────────────────────────────────
|
|
720
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
721
|
+
const args = process.argv.slice(2);
|
|
722
|
+
const target = args.find((a) => !a.startsWith('--'));
|
|
723
|
+
const asJson = args.includes('--json');
|
|
724
|
+
|
|
725
|
+
if (!target) {
|
|
726
|
+
console.error('사용법: node skills/optbrew/scripts/audit.mjs --context <run-context.json> --modules naver-searchadvisor --output-dir <run-dir>');
|
|
727
|
+
process.exit(2);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
console.error('직접 원격 감사가 비활성화되었습니다. optbrew audit의 scope context와 수집 증거를 사용하세요.');
|
|
731
|
+
process.exit(2);
|
|
732
|
+
|
|
733
|
+
try {
|
|
734
|
+
const result = await audit(target.includes('://') ? target : `https://${target}`);
|
|
735
|
+
if (asJson) console.log(JSON.stringify(result, null, 2));
|
|
736
|
+
else console.log(report(result));
|
|
737
|
+
process.exit(result.findings.some((f) => f.level === 'BLOCK') ? 1 : 0);
|
|
738
|
+
} catch (e) {
|
|
739
|
+
console.error(`점검 실패: ${e.message}`);
|
|
740
|
+
process.exit(2);
|
|
741
|
+
}
|
|
742
|
+
}
|