optbrew 0.1.3 → 0.2.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/AGENT_INSTALL.md +13 -11
- package/CHANGELOG.md +52 -0
- package/README.ko.md +40 -15
- package/README.md +40 -15
- package/THIRD_PARTY_NOTICES.md +88 -0
- package/docs/platforms.md +10 -10
- package/docs/release.md +37 -5
- package/lib/cli.mjs +5 -2
- package/lib/install.mjs +2 -1
- package/lib/records.mjs +2 -1
- package/lib/transaction.mjs +12 -5
- package/lib/update.mjs +3 -2
- package/package.json +6 -4
- package/plugin.json +2 -2
- package/skills/.claude-plugin/icon.png +0 -0
- package/skills/.claude-plugin/plugin.json +23 -0
- package/skills/README.md +27 -0
- package/skills/optbrew/SKILL.md +223 -8
- package/skills/optbrew/THIRD_PARTY_NOTICES.md +88 -0
- package/skills/optbrew/bundle-manifest.json +407 -47
- package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +4 -4
- package/skills/optbrew/references/api.md +5 -3
- package/skills/optbrew/references/check-registry.json +16 -16
- package/skills/optbrew/references/chrome-paths.json +31 -0
- package/skills/optbrew/references/endpoints.json +10 -0
- package/skills/optbrew/references/harness-signals.json +5 -0
- package/skills/optbrew/references/intake.md +76 -5
- package/skills/optbrew/references/judging.md +233 -0
- package/skills/optbrew/references/limits.json +26 -0
- package/skills/optbrew/references/measurement.md +1 -1
- package/skills/optbrew/references/messages.ko.json +170 -0
- package/skills/optbrew/references/report.md +18 -6
- package/skills/optbrew/references/routing.md +2 -2
- package/skills/optbrew/references/runbooks/anthropic-api-key.md +34 -0
- package/skills/optbrew/references/runbooks/chrome-install.md +56 -0
- package/skills/optbrew/references/runbooks/cwv-site-collection.md +38 -0
- package/skills/optbrew/references/runbooks/google-api-key.md +51 -0
- package/skills/optbrew/references/runbooks/gsc-export.md +32 -0
- package/skills/optbrew/references/runbooks/index.json +18 -0
- package/skills/optbrew/references/runbooks/key-set-claude-cloud.md +32 -0
- package/skills/optbrew/references/runbooks/key-set-codex.md +33 -0
- package/skills/optbrew/references/runbooks/key-set-local.md +40 -0
- package/skills/optbrew/references/runbooks/naver-searchadvisor-export.md +32 -0
- package/skills/optbrew/references/runbooks/network-claude-cloud-custom.md +33 -0
- package/skills/optbrew/references/runbooks/network-codex-approval.md +26 -0
- package/skills/optbrew/references/runbooks/network-codex-config.md +36 -0
- package/skills/optbrew/references/runbooks/seranking.md +38 -0
- package/skills/optbrew/references/schemas/contract.schema.json +42 -15
- package/skills/optbrew/references/scoring.md +40 -5
- package/skills/optbrew/references/source-analysis.md +40 -0
- package/skills/optbrew/references/versions.json +9 -0
- package/skills/optbrew/scripts/adapters/anthropic.mjs +5 -5
- package/skills/optbrew/scripts/adapters/imports.mjs +138 -1
- package/skills/optbrew/scripts/audit.mjs +10 -112
- package/skills/optbrew/scripts/cli.mjs +5 -2
- package/skills/optbrew/scripts/core/bundle.mjs +30 -13
- package/skills/optbrew/scripts/core/capabilities.mjs +8 -4
- package/skills/optbrew/scripts/core/cdp.mjs +105 -0
- package/skills/optbrew/scripts/core/checks/canonical-consistency.mjs +56 -0
- package/skills/optbrew/scripts/core/checks/cwv-field.mjs +77 -0
- package/skills/optbrew/scripts/core/checks/discovery.mjs +43 -0
- package/skills/optbrew/scripts/core/checks/document-title.mjs +62 -0
- package/skills/optbrew/scripts/core/checks/entity-identity.mjs +31 -0
- package/skills/optbrew/scripts/core/checks/fact-consistency.mjs +31 -0
- package/skills/optbrew/scripts/core/checks/fetch-response.mjs +18 -0
- package/skills/optbrew/scripts/core/checks/index-policy.mjs +16 -0
- package/skills/optbrew/scripts/core/checks/index.mjs +18 -0
- package/skills/optbrew/scripts/core/checks/internal-navigation.mjs +67 -0
- package/skills/optbrew/scripts/core/checks/locale-links.mjs +55 -0
- package/skills/optbrew/scripts/core/checks/naver-markup.mjs +21 -0
- package/skills/optbrew/scripts/core/checks/observation.mjs +37 -0
- package/skills/optbrew/scripts/core/checks/prepare.mjs +103 -0
- package/skills/optbrew/scripts/core/checks/render-content.mjs +104 -0
- package/skills/optbrew/scripts/core/checks/search-crawl-policy.mjs +29 -0
- package/skills/optbrew/scripts/core/checks/structured-data-truth.mjs +46 -0
- package/skills/optbrew/scripts/core/chrome-temp.mjs +68 -0
- package/skills/optbrew/scripts/core/chrome.mjs +546 -0
- package/skills/optbrew/scripts/core/collector.mjs +45 -49
- package/skills/optbrew/scripts/core/compare.mjs +18 -1
- package/skills/optbrew/scripts/core/config.mjs +31 -0
- package/skills/optbrew/scripts/core/contracts.mjs +31 -2
- package/skills/optbrew/scripts/core/cwv.mjs +100 -0
- package/skills/optbrew/scripts/core/derive.mjs +10 -186
- package/skills/optbrew/scripts/core/evidence-store.mjs +112 -0
- package/skills/optbrew/scripts/core/facts.mjs +175 -17
- package/skills/optbrew/scripts/core/findings.mjs +19 -1
- package/skills/optbrew/scripts/core/html.mjs +41 -3
- package/skills/optbrew/scripts/core/index-targets.mjs +118 -0
- package/skills/optbrew/scripts/core/judgments.mjs +675 -0
- package/skills/optbrew/scripts/core/keys.mjs +86 -0
- package/skills/optbrew/scripts/core/link-graph.mjs +93 -0
- package/skills/optbrew/scripts/core/messages.mjs +23 -0
- package/skills/optbrew/scripts/core/metrics.mjs +4 -2
- package/skills/optbrew/scripts/core/migration.mjs +37 -0
- package/skills/optbrew/scripts/core/page-select.mjs +136 -0
- package/skills/optbrew/scripts/core/probe.mjs +83 -0
- package/skills/optbrew/scripts/core/protocol.mjs +38 -14
- package/skills/optbrew/scripts/core/render.mjs +160 -0
- package/skills/optbrew/scripts/core/report-compose.mjs +377 -0
- package/skills/optbrew/scripts/core/report-html.mjs +198 -0
- package/skills/optbrew/scripts/core/report-page.mjs +14 -0
- package/skills/optbrew/scripts/core/report.mjs +327 -122
- package/skills/optbrew/scripts/core/runbooks.mjs +63 -0
- package/skills/optbrew/scripts/core/scope.mjs +23 -5
- package/skills/optbrew/scripts/core/score.mjs +12 -4
- package/skills/optbrew/scripts/core/secrets.mjs +2 -0
- package/skills/optbrew/scripts/core/stages.mjs +694 -0
- package/skills/optbrew/scripts/core/transport.mjs +157 -0
- package/skills/optbrew/scripts/core/weights.mjs +2 -1
- package/skills/optbrew/scripts/key.mjs +90 -0
- package/skills/optbrew/scripts/preflight.mjs +176 -0
- package/skills/optbrew/scripts/run.mjs +81 -0
- package/skills/optbrew/source-lock.json +1 -1
- package/source-lock.json +1 -1
|
@@ -9,9 +9,11 @@ export function stripInert(html) {
|
|
|
9
9
|
export const stripComments = html => String(html ?? '').replace(/<!--[\s\S]*?(?:-->|$)/g, '');
|
|
10
10
|
|
|
11
11
|
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
12
|
+
// A numeric reference names a character only up to U+10FFFF and outside the surrogates; any other one stays as written (site text is untrusted).
|
|
13
|
+
const codePoint = n => Number.isSafeInteger(n) && n <= 0x10ffff && !(n >= 0xd800 && n <= 0xdfff) ? String.fromCodePoint(n) : null;
|
|
12
14
|
/** Decode the character references that appear in attribute values. */
|
|
13
15
|
export const decodeEntities = value => String(value).replace(/&(?:#(\d+)|#x([0-9a-f]+)|(amp|lt|gt|quot|apos|nbsp));/gi,
|
|
14
|
-
(match, dec, hex, name) => dec ?
|
|
16
|
+
(match, dec, hex, name) => dec ? codePoint(Number(dec)) ?? match : hex ? codePoint(parseInt(hex, 16)) ?? match : named[name.toLowerCase()]);
|
|
15
17
|
|
|
16
18
|
/** Absolute hrefs of anchors, resolved against the response URL. */
|
|
17
19
|
export function anchorLinks(html, base) {
|
|
@@ -22,11 +24,47 @@ export function anchorLinks(html, base) {
|
|
|
22
24
|
return out;
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/** The attributes of one tag's attribute text, names lowercased, values as written. */
|
|
28
|
+
function attributes(text) {
|
|
29
|
+
const attrs = {};
|
|
30
|
+
for (const a of text.matchAll(/([a-zA-Z:_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) attrs[a[1].toLowerCase()] = a[2] ?? a[3] ?? a[4] ?? '';
|
|
31
|
+
return attrs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const resolved = (value, base) => { try { return new URL(decodeEntities(value), base).href; } catch { return null; } };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Every live anchor with an href: its absolute target and its label. The label is the link text; a link without text is
|
|
38
|
+
* labelled by its aria-label, then by the alt text of an image inside it, and is empty when it has none of them.
|
|
39
|
+
*/
|
|
40
|
+
export function anchorTexts(html, base) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const match of stripInert(html).matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a\s*>/gi)) {
|
|
43
|
+
const attrs = attributes(match[1]);
|
|
44
|
+
const href = attrs.href === undefined ? null : resolved(attrs.href, base);
|
|
45
|
+
if (!href) continue;
|
|
46
|
+
const text = decodeEntities(match[2].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
|
|
47
|
+
const alt = /<img\b[^>]*\balt\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(match[2]);
|
|
48
|
+
out.push({ href, label: text || decodeEntities(attrs['aria-label'] ?? '').trim() || decodeEntities(alt?.[1] ?? alt?.[2] ?? '').trim() });
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `rel=alternate` links with an hreflang: the language code (lowercase) and the absolute target. */
|
|
54
|
+
export function alternateLinks(html, base) {
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const match of stripInert(html).matchAll(/<link\b([^>]*)>/gi)) {
|
|
57
|
+
const attrs = attributes(match[1]);
|
|
58
|
+
const href = attrs.href ? resolved(attrs.href, base) : null;
|
|
59
|
+
if (/(^|\s)alternate(\s|$)/i.test(attrs.rel ?? '') && attrs.hreflang?.trim() && href) out.push({ hreflang: attrs.hreflang.trim().toLowerCase(), href });
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
25
64
|
/** The raw href of the first rel=canonical link in live markup, or null. */
|
|
26
65
|
export function canonicalLink(html) {
|
|
27
66
|
for (const match of stripInert(html).matchAll(/<link\b([^>]*)>/gi)) {
|
|
28
|
-
const attrs =
|
|
29
|
-
for (const a of match[1].matchAll(/([a-zA-Z:_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) attrs[a[1].toLowerCase()] = a[2] ?? a[3] ?? a[4] ?? '';
|
|
67
|
+
const attrs = attributes(match[1]);
|
|
30
68
|
if (/(^|\s)canonical(\s|$)/i.test(attrs.rel ?? '') && attrs.href) return attrs.href;
|
|
31
69
|
}
|
|
32
70
|
return null;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { indexingDirectives } from '../../modules/naver-searchadvisor/scripts/audit.mjs';
|
|
2
|
+
import { decodeEntities } from './html.mjs';
|
|
3
|
+
import { canonicalUrl } from './link-graph.mjs';
|
|
4
|
+
import { sameSite } from './page-select.mjs';
|
|
5
|
+
import { assertUrlInScope } from './scope.mjs';
|
|
6
|
+
|
|
7
|
+
/** The decision a page gets when the site itself shows it is not meant to be in the index (R89). */
|
|
8
|
+
const NOT_INDEX_CONDITION = 'not_public_index_target';
|
|
9
|
+
/** The reason text is fixed: replay tells a decision the code derived from one an owner approved by this text. */
|
|
10
|
+
const NOT_INDEX_REASON = 'noindex and not listed in the sitemap';
|
|
11
|
+
const SITEMAP_FILE = /\.xml(?:\.gz)?$/i;
|
|
12
|
+
const LOC = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
|
|
13
|
+
/** A declared sitemap that answers with one of these is read, and it lists nothing. Any other answer that is not a parsed 2xx is missing evidence. */
|
|
14
|
+
const ABSENT = [404, 410];
|
|
15
|
+
|
|
16
|
+
const xRobotsOf = artifact => Array.isArray(artifact.header_lines) ?
|
|
17
|
+
artifact.header_lines.filter(line => line.name === 'x-robots-tag').map(line => line.value) : [];
|
|
18
|
+
|
|
19
|
+
/** Whether a decision is the one this module derives (and replay derives again), not an approved one that an intake record backs. */
|
|
20
|
+
export const isDerivedIndexDecision = decision => decision?.condition === NOT_INDEX_CONDITION && decision.reason === NOT_INDEX_REASON;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* An address as the run's own page selection writes it, folded to one spelling of the site: `www.` and http/https of the run's host
|
|
24
|
+
* become the run's origin, a trailing slash goes, and the rest is the product's normalizer (scope check, fragment, `index.html`).
|
|
25
|
+
* Null for an address of another site.
|
|
26
|
+
*/
|
|
27
|
+
function foldedUrl(value, base, scope) {
|
|
28
|
+
let url;
|
|
29
|
+
try { url = new URL(value); } catch { return null; }
|
|
30
|
+
if (!['http:', 'https:'].includes(url.protocol) || !sameSite(url, base)) return null;
|
|
31
|
+
url.protocol = base.protocol;
|
|
32
|
+
url.host = base.host;
|
|
33
|
+
let href = url.href;
|
|
34
|
+
try { href = assertUrlInScope(href, scope).href; } catch { /* an address outside the scope is still compared by its folded form */ }
|
|
35
|
+
const key = canonicalUrl(href);
|
|
36
|
+
if (key === null) return null;
|
|
37
|
+
const folded = new URL(key);
|
|
38
|
+
if (folded.pathname.length > 1) folded.pathname = folded.pathname.replace(/\/+$/, '') || '/';
|
|
39
|
+
return folded.href;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What the declared sitemaps say, or that the evidence is incomplete (R96, R97). `complete` is false unless every declared sitemap is
|
|
44
|
+
* read: a 2xx whose body parses as a sitemap, or a 404/410 (absent). A 5xx, a timeout, a connection error, a body that is no sitemap,
|
|
45
|
+
* or a sitemap that was never fetched is missing evidence. A sitemap index counts only when every child it names is itself a declared
|
|
46
|
+
* sitemap that was read, and the entries of an index are never pages. `pages` are the folded same-site addresses the sitemaps list;
|
|
47
|
+
* an entry that is a sitemap file or belongs to another site lists no page.
|
|
48
|
+
*/
|
|
49
|
+
function sitemapEvidence({ context, artifacts, textOf }) {
|
|
50
|
+
const base = new URL(context.target);
|
|
51
|
+
const { scope } = context;
|
|
52
|
+
const declared = (context.protocol.auxiliary_resources ?? []).filter(resource => resource.kind === 'sitemap').map(resource => resource.normalized);
|
|
53
|
+
const byUrl = new Map(artifacts.map(artifact => [artifact.normalized_url, artifact]));
|
|
54
|
+
const readable = new Set();
|
|
55
|
+
const pages = new Set();
|
|
56
|
+
const children = [];
|
|
57
|
+
let complete = true;
|
|
58
|
+
for (const url of declared) {
|
|
59
|
+
const artifact = byUrl.get(url);
|
|
60
|
+
if (!artifact) { complete = false; continue; }
|
|
61
|
+
if (ABSENT.includes(artifact.status)) { readable.add(foldedUrl(url, base, scope)); continue; }
|
|
62
|
+
const body = artifact.status >= 200 && artifact.status < 300 ? String(textOf(artifact) ?? '') : '';
|
|
63
|
+
const index = /<sitemapindex\b/i.test(body);
|
|
64
|
+
if (!index && !/<urlset\b/i.test(body)) { complete = false; continue; }
|
|
65
|
+
readable.add(foldedUrl(url, base, scope));
|
|
66
|
+
for (const match of body.matchAll(LOC)) {
|
|
67
|
+
const loc = decodeEntities(match[1]);
|
|
68
|
+
if (index) children.push(loc);
|
|
69
|
+
else if (!SITEMAP_FILE.test(loc.split(/[?#]/)[0])) {
|
|
70
|
+
const key = foldedUrl(loc, base, scope);
|
|
71
|
+
if (key !== null) pages.add(key);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Every child an index names must be a sitemap the run declared and read; one beyond the declared list could list the page.
|
|
76
|
+
for (const child of children) {
|
|
77
|
+
const key = foldedUrl(child, base, scope);
|
|
78
|
+
if (key === null || !readable.has(key)) complete = false;
|
|
79
|
+
}
|
|
80
|
+
return { complete, pages };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The selected pages the site deliberately keeps out of the index: a page that (a) carries noindex in its raw response for the target
|
|
85
|
+
* engines, (b) is not the home page, and (c) is listed in no sitemap the run read, while (d) the sitemaps list other pages of the same
|
|
86
|
+
* site and every declared sitemap was read in full (R96, R97). Such a page is a contact form or a thank-you page, not a page that lost
|
|
87
|
+
* its place by mistake. Where the evidence is incomplete or ambiguous, a noindex stays a failure and a blocker.
|
|
88
|
+
* `artifacts` carry `evidence_ref`, `status`, `capability`, `final_url` and `header_lines`; `textOf(artifact)` gives the body. The audit
|
|
89
|
+
* calls this once after collection and report replay calls it again over the stored bytes. Returns `[{ url, evidence_ref }]` by address.
|
|
90
|
+
*/
|
|
91
|
+
export function notIndexTargets({ context, artifacts, textOf }) {
|
|
92
|
+
const { protocol, scope } = context;
|
|
93
|
+
const sitemaps = sitemapEvidence({ context, artifacts, textOf });
|
|
94
|
+
if (!sitemaps.complete || !sitemaps.pages.size) return [];
|
|
95
|
+
const base = new URL(context.target);
|
|
96
|
+
const home = foldedUrl(context.target, base, scope);
|
|
97
|
+
const selected = new Set(protocol.page_types.flatMap(type => type.urls.map(url => url.normalized)));
|
|
98
|
+
const found = [];
|
|
99
|
+
for (const artifact of artifacts) {
|
|
100
|
+
if (!selected.has(artifact.normalized_url) || artifact.capability !== 'raw_html' || artifact.status !== 200) continue;
|
|
101
|
+
const keys = [artifact.normalized_url, artifact.final_url ?? artifact.normalized_url].map(url => foldedUrl(url, base, scope));
|
|
102
|
+
if (keys.includes(home) || keys.some(key => key !== null && sitemaps.pages.has(key))) continue;
|
|
103
|
+
if (!indexingDirectives({ html: textOf(artifact) ?? '', xRobotsTag: xRobotsOf(artifact), engines: protocol.engines }).blocked) continue;
|
|
104
|
+
found.push({ url: artifact.normalized_url, evidence_ref: artifact.evidence_ref });
|
|
105
|
+
}
|
|
106
|
+
return found.sort((a, b) => a.url < b.url ? -1 : a.url > b.url ? 1 : 0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The context with the pages of `targets` marked as not public index targets (in the page types and in the render pages), each with its decision. */
|
|
110
|
+
export function withIndexTargets(context, targets) {
|
|
111
|
+
if (!targets.length) return context;
|
|
112
|
+
const decision = new Map(targets.map(({ url, evidence_ref }) => [url, { condition: NOT_INDEX_CONDITION, reason: NOT_INDEX_REASON, evidence_ref }]));
|
|
113
|
+
const mark = url => decision.has(url.normalized) ? { ...url, public_indexing: false, indexing_decision: decision.get(url.normalized) } : url;
|
|
114
|
+
const { protocol } = context;
|
|
115
|
+
return { ...context, protocol: { ...protocol,
|
|
116
|
+
page_types: protocol.page_types.map(type => ({ ...type, urls: type.urls.map(mark) })),
|
|
117
|
+
...(protocol.render ? { render: { ...protocol.render, pages: protocol.render.pages.map(mark) } } : {}) } };
|
|
118
|
+
}
|