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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { thresholds } from '../config.mjs';
|
|
2
|
+
import { anchorLinks, visibleText } from '../html.mjs';
|
|
3
|
+
import { canonicalUrl, sameOriginLinks } from '../link-graph.mjs';
|
|
4
|
+
import { makeObservation, problem, unitKey } from './observation.mjs';
|
|
5
|
+
|
|
6
|
+
const FAMILY = 'render-content';
|
|
7
|
+
const NO_BODY_TEXT = 'render-content.no-body-text';
|
|
8
|
+
const CHROME_MISSING = 'chrome_missing';
|
|
9
|
+
const RENDER_BLOCKED = 'render_blocked';
|
|
10
|
+
const RENDER_FAILED = 'render_failed';
|
|
11
|
+
/** How the reason of a page that passes only after rendering begins; the report keys its JavaScript note on it. */
|
|
12
|
+
export const CONTENT_AFTER_RENDER = 'Content shows after rendering';
|
|
13
|
+
|
|
14
|
+
const squash = value => String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
15
|
+
/** Visible characters of the body, whitespace not counted. */
|
|
16
|
+
const bodyLength = html => visibleText(/<body\b[^>]*>([\s\S]*)/i.exec(String(html ?? ''))?.[1] ?? html).replace(/\s/g, '').length;
|
|
17
|
+
|
|
18
|
+
const assetOf = (value, base) => { try { const url = new URL(value, base); return url.origin + url.pathname; } catch { return String(value); } };
|
|
19
|
+
/**
|
|
20
|
+
* What a page's template loads: the external scripts and the head assets (stylesheets, preloads), each without query or fragment.
|
|
21
|
+
* Two pages made by one template load the same set; null when the page loads nothing external, which is no signal at all.
|
|
22
|
+
*/
|
|
23
|
+
function templateAssets(html, base) {
|
|
24
|
+
const live = String(html ?? '').replace(/<!--[\s\S]*?(?:-->|$)/g, '');
|
|
25
|
+
const scripts = [...live.matchAll(/<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi)].map(match => assetOf(match[1], base));
|
|
26
|
+
const head = [...live.matchAll(/<link\b[^>]*>/gi)].map(match => match[0]).filter(tag => /\brel\s*=\s*["'][^"']*\b(?:stylesheet|preload|modulepreload)\b/i.test(tag))
|
|
27
|
+
.map(tag => /\bhref\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1]).filter(Boolean).map(href => assetOf(href, base));
|
|
28
|
+
const sorted = list => [...new Set(list)].sort();
|
|
29
|
+
return scripts.length || head.length ? { scripts: sorted(scripts), head: sorted(head) } : null;
|
|
30
|
+
}
|
|
31
|
+
const sameTemplate = (a, b) => a !== null && b !== null && JSON.stringify(a) === JSON.stringify(b);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The raw HTML against the stored render of the same page. A page whose raw body, title and link count already match the render
|
|
35
|
+
* does not depend on rendering. One that depends on it passes when the render shows content and fails when neither the raw HTML
|
|
36
|
+
* nor the render has any. A challenge page (a challenge status, or a marker in the title or in a short body) and a render that did
|
|
37
|
+
* not finish are unknown, and so is a page of the render sample with no render when the run did not record a Chrome.
|
|
38
|
+
*
|
|
39
|
+
* A page outside the render sample inherits the verdict of the rendered page of its own page type when every rendered page of the
|
|
40
|
+
* type gives the same pass or not-applicable, and that page loads the same external scripts and head assets as this one (a page type
|
|
41
|
+
* is only the first path segment, so it can hold leftovers and list pages beside detail pages). The page must have body text in its
|
|
42
|
+
* own raw HTML, and the reason names the rendered page; the evidence is that page's render record. Otherwise the page gets no
|
|
43
|
+
* verdict here.
|
|
44
|
+
*/
|
|
45
|
+
export const renderContent = {
|
|
46
|
+
family_id: FAMILY,
|
|
47
|
+
message_keys: [NO_BODY_TEXT],
|
|
48
|
+
derive({ context, pageRows, renders, renderTextOf, chrome, renderSample }) {
|
|
49
|
+
const { min_body_ratio, challenge_status, challenge_markers, challenge_max_body_chars } = thresholds(FAMILY);
|
|
50
|
+
const judgeOwn = ({ url, artifact, html, info }) => {
|
|
51
|
+
const own = [canonicalUrl(url), canonicalUrl(artifact.final_url)];
|
|
52
|
+
const stored = renders.find(render => [render.url, render.final_url].some(value => value && own.includes(canonicalUrl(value))));
|
|
53
|
+
if (!stored) return chrome?.found !== true && renderSample.has(canonicalUrl(url)) ? { state: 'unknown', reason: CHROME_MISSING } : null;
|
|
54
|
+
if (challenge_status.includes(stored.status)) return { state: 'unknown', reason: RENDER_BLOCKED, stored };
|
|
55
|
+
if (!stored.dom_ref || stored.blocked_reason) return { state: 'unknown', reason: RENDER_FAILED, stored };
|
|
56
|
+
const dom = renderTextOf(stored);
|
|
57
|
+
const rawLength = bodyLength(html), renderedLength = bodyLength(dom);
|
|
58
|
+
// A challenge page is short; a long page that happens to quote a marker is judged on its content.
|
|
59
|
+
const markerText = `${stored.title ?? ''} ${renderedLength <= challenge_max_body_chars ? visibleText(dom) : ''}`.toLowerCase();
|
|
60
|
+
if (challenge_markers.some(marker => markerText.includes(marker))) return { state: 'unknown', reason: RENDER_BLOCKED, stored };
|
|
61
|
+
// The counts belong to the page, so they travel in the measurement and the reason stays the same for every page.
|
|
62
|
+
const measurement = { raw_body_characters: rawLength, rendered_body_characters: renderedLength };
|
|
63
|
+
const ratio = renderedLength === 0 ? (rawLength > 0 ? Infinity : 0) : rawLength / renderedLength;
|
|
64
|
+
if (ratio >= min_body_ratio && squash(info.title) === squash(stored.title)
|
|
65
|
+
&& sameOriginLinks(anchorLinks(html, artifact.final_url), artifact.final_url).length === stored.links.length) {
|
|
66
|
+
const reason = 'Raw HTML holds the rendered body text, with the same title and link count';
|
|
67
|
+
return { state: 'not_applicable', reason, stored, extra: { measurement, applicability_decision: { condition: 'not_render_dependent', reason, evidence_ref: artifact.evidence_ref } } };
|
|
68
|
+
}
|
|
69
|
+
if (renderedLength > 0) return { state: 'pass', reason: CONTENT_AFTER_RENDER, stored, extra: { measurement } };
|
|
70
|
+
if (rawLength > 0) return { state: 'pass', reason: 'The raw HTML holds body text and the render is empty', stored, extra: { measurement } };
|
|
71
|
+
return { state: 'fail', stored, problems: [problem('Neither the raw HTML nor the rendered page has any body text', NO_BODY_TEXT)] };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const judged = new Map(pageRows.map(row => [row.url, judgeOwn(row)]));
|
|
75
|
+
// Every page of the audit with the verdict its own render gave, by canonical url, and the pages by canonical url.
|
|
76
|
+
const rendered = new Map(pageRows.filter(row => judged.get(row.url)?.stored).map(row => [canonicalUrl(row.url), { row, ...judged.get(row.url) }]));
|
|
77
|
+
const templates = new Map(pageRows.map(row => [row.url, templateAssets(row.html, row.artifact.final_url)]));
|
|
78
|
+
const typeOf = new Map(context.protocol.page_types.flatMap(type => type.urls.map(item => [canonicalUrl(item.normalized), type])));
|
|
79
|
+
const inherit = ({ url, artifact, html }) => {
|
|
80
|
+
const type = typeOf.get(canonicalUrl(url));
|
|
81
|
+
// Only when every rendered page of the type gives one usable verdict, and from the rendered page that loads the same scripts and
|
|
82
|
+
// stylesheets as this page (the first path segment groups leftovers and list and detail pages as well as templates).
|
|
83
|
+
const peers = (type?.urls ?? []).map(item => rendered.get(canonicalUrl(item.normalized))).filter(Boolean);
|
|
84
|
+
const usable = peers.length > 0 && ['pass', 'not_applicable'].includes(peers[0].state) && peers.every(peer => peer.state === peers[0].state);
|
|
85
|
+
const from = usable ? peers.find(peer => sameTemplate(templates.get(peer.row.url), templates.get(url))) : null;
|
|
86
|
+
const rawLength = bodyLength(html);
|
|
87
|
+
if (!from || !from.stored.evidence_ref || renderSample.has(canonicalUrl(url)) || rawLength === 0) return null;
|
|
88
|
+
const evidence_ref = from.stored.evidence_ref;
|
|
89
|
+
const measurement = { raw_body_characters: rawLength, template: { scripts: templates.get(url).scripts.length, head_assets: templates.get(url).head.length },
|
|
90
|
+
inherited_from: { url: from.row.url, state: from.state, page_type: type.id, render_ref: evidence_ref } };
|
|
91
|
+
const basis = `inferred from ${from.row.url}, the rendered page of the same type that loads the same scripts and stylesheets; this page's raw HTML holds body text too`;
|
|
92
|
+
if (from.state === 'pass') return { state: 'pass', evidence_ref, reason: `${from.reason} (${basis})`, extra: { measurement } };
|
|
93
|
+
const reason = `Inferred from ${from.row.url}, the rendered page of the same type that loads the same scripts and stylesheets, which does not depend on rendering; this page's raw HTML holds body text too`;
|
|
94
|
+
return { state: 'not_applicable', evidence_ref, reason, extra: { measurement, applicability_decision: { condition: 'not_render_dependent', reason, evidence_ref } } };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return pageRows.flatMap(row => {
|
|
98
|
+
const { url, artifact } = row;
|
|
99
|
+
const own = judged.get(url);
|
|
100
|
+
const verdict = own ? (({ stored, ...rest }) => rest)(own) : inherit(row);
|
|
101
|
+
return verdict ? [makeObservation({ family_id: FAMILY, unit_key: unitKey(FAMILY, url), evidence_ref: artifact.evidence_ref, ...verdict })] : [];
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { parseRobots, robotsVerdict } from '../../../modules/naver-searchadvisor/scripts/robots.mjs';
|
|
2
|
+
import { agentFor } from '../crawlers.mjs';
|
|
3
|
+
import { makeObservation, problem, unitKey } from './observation.mjs';
|
|
4
|
+
|
|
5
|
+
const FAMILY = 'search-crawl-policy';
|
|
6
|
+
const DISALLOWED = 'search-crawl-policy.disallowed';
|
|
7
|
+
|
|
8
|
+
/** The collected robots.txt against each target engine's crawler, for pages meant to be indexed. */
|
|
9
|
+
export const searchCrawlPolicy = {
|
|
10
|
+
family_id: FAMILY,
|
|
11
|
+
message_keys: [DISALLOWED],
|
|
12
|
+
derive({ context, pageRows, textOf }) {
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const row of pageRows) {
|
|
15
|
+
if (!row.publicIndexing || row.robots?.status !== 200) continue;
|
|
16
|
+
const parsed = parseRobots(textOf(row.robots));
|
|
17
|
+
const target = new URL(row.artifact.final_url);
|
|
18
|
+
for (const engine of context.protocol.engines) {
|
|
19
|
+
const agent = agentFor(engine);
|
|
20
|
+
if (!agent) continue;
|
|
21
|
+
const verdict = robotsVerdict(parsed, agent, target.pathname + target.search);
|
|
22
|
+
out.push(makeObservation({ family_id: FAMILY, unit_key: unitKey(FAMILY, row.url, engine), state: verdict.allowed ? 'pass' : 'fail',
|
|
23
|
+
evidence_ref: row.robots.evidence_ref, ...(verdict.allowed ? { reason: `Collected robots.txt allows ${agent} path` }
|
|
24
|
+
: { problems: [problem(`Collected robots.txt disallows ${agent} path`, DISALLOWED, { agent })] }) }));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsonLdBlocks, stripComments } from '../html.mjs';
|
|
2
|
+
import { jsonLdTruth } from '../facts.mjs';
|
|
3
|
+
import { makeObservation, problem, unitKey } from './observation.mjs';
|
|
4
|
+
|
|
5
|
+
const FAMILY = 'structured-data-truth';
|
|
6
|
+
const PARSE_ERROR = 'structured-data-truth.parse-error';
|
|
7
|
+
const FACT_DIFFERS = 'structured-data-truth.fact-differs';
|
|
8
|
+
// Microdata and RDFa mark a page up without a JSON-LD block; this check does not read them.
|
|
9
|
+
const OTHER_MARKUP = /<[a-z][^>]*\s(?:itemscope|vocab\s*=|typeof\s*=)/i;
|
|
10
|
+
const NO_MARKUP = 'The page has no JSON-LD, microdata or RDFa markup';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* JSON-LD that does not parse fails. A page with no markup at all is not applicable. A page whose own JSON-LD (an organization's name,
|
|
14
|
+
* phone number and street address; an article's headline and publication date) states facts passes when the page shows every one and
|
|
15
|
+
* contradicts none, and fails when the page shows a different value in the field that holds it. A fact the page neither shows nor
|
|
16
|
+
* contradicts leaves the page unknown, and so does JSON-LD of a type that states nothing this compares. A publisher or author name
|
|
17
|
+
* alone never passes.
|
|
18
|
+
*/
|
|
19
|
+
export const structuredDataTruth = {
|
|
20
|
+
family_id: FAMILY,
|
|
21
|
+
message_keys: [PARSE_ERROR, FACT_DIFFERS],
|
|
22
|
+
derive({ pageRows, facts }) {
|
|
23
|
+
const factsOf = new Map(facts.map(item => [item.url, item.facts]));
|
|
24
|
+
return pageRows.map(({ url, artifact, html, info }) => {
|
|
25
|
+
const observed = { family_id: FAMILY, unit_key: unitKey(FAMILY, url), evidence_ref: artifact.evidence_ref };
|
|
26
|
+
if (info.jsonld_parse_errors > 0) return makeObservation({ ...observed, state: 'fail', problems: [problem('JSON-LD block(s) fail to parse', PARSE_ERROR)],
|
|
27
|
+
extra: { measurement: { parse_errors: info.jsonld_parse_errors } } });
|
|
28
|
+
const blocks = jsonLdBlocks(html).length;
|
|
29
|
+
if (!blocks) {
|
|
30
|
+
if (OTHER_MARKUP.test(stripComments(html))) return makeObservation({ ...observed, state: 'unknown', reason: 'The page uses microdata or RDFa, which this check does not read' });
|
|
31
|
+
return makeObservation({ ...observed, state: 'not_applicable', reason: NO_MARKUP, extra: { measurement: { jsonld_blocks: 0 },
|
|
32
|
+
applicability_decision: { condition: 'markup_not_used_or_selected', reason: NO_MARKUP, evidence_ref: artifact.evidence_ref } } });
|
|
33
|
+
}
|
|
34
|
+
const { matched, mismatched, unevaluated } = jsonLdTruth(html, factsOf.get(url));
|
|
35
|
+
const measurement = { jsonld_blocks: blocks, matched: matched.map(({ type, jsonld, shown }) => ({ type, jsonld, ...(shown ? { shown } : {}) })), mismatched,
|
|
36
|
+
unevaluated: unevaluated.map(({ type, jsonld }) => ({ type, jsonld })) };
|
|
37
|
+
if (mismatched.length) return makeObservation({ ...observed, state: 'fail', extra: { measurement },
|
|
38
|
+
problems: mismatched.map(({ type, jsonld, visible }) => problem(`JSON-LD ${type} "${jsonld}" differs from visible "${visible}"`, FACT_DIFFERS, { type, linked: jsonld, visible })) });
|
|
39
|
+
if (matched.length && !unevaluated.length) return makeObservation({ ...observed, state: 'pass', extra: { measurement },
|
|
40
|
+
reason: 'Every name, phone number, address, headline and date the JSON-LD states is shown on the page, and none is contradicted' });
|
|
41
|
+
return makeObservation({ ...observed, state: 'unknown', extra: { measurement }, reason: unevaluated.length
|
|
42
|
+
? 'The JSON-LD states a fact that the page text neither shows nor contradicts, so not every fact is confirmed'
|
|
43
|
+
: 'The JSON-LD states no organization or article fact that the page text could confirm' });
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { readdir, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { limits } from './config.mjs';
|
|
5
|
+
|
|
6
|
+
/** Chrome's temp folders: the environment it runs in, and the sweep of profile folders a killed launcher left (T22 M-7). */
|
|
7
|
+
|
|
8
|
+
const LIST_LINES_WINDOWS = '$ErrorActionPreference = "Stop"; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($env:OPTBREW_MARKER) } | ForEach-Object { $_.CommandLine }';
|
|
9
|
+
|
|
10
|
+
/** Command lines (whole lines) that name `marker`; null when the listing failed or timed out, so a caller never mistakes "could not look" for "nothing there". */
|
|
11
|
+
function commandLinesNaming(marker, timeoutMs) {
|
|
12
|
+
const [command, args] = process.platform === 'win32' ?
|
|
13
|
+
['powershell', ['-NoProfile', '-NonInteractive', '-Command', LIST_LINES_WINDOWS]] : ['ps', ['-ww', '-eo', 'args=']];
|
|
14
|
+
return new Promise(resolve => {
|
|
15
|
+
execFile(command, args, { env: { ...process.env, OPTBREW_MARKER: marker }, maxBuffer: 1 << 24, windowsHide: true, timeout: timeoutMs }, (error, stdout) => {
|
|
16
|
+
resolve(error ? null : stdout.split(/\r?\n/).filter(line => line.includes(marker)));
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const PROFILE_PREFIX = 'optbrew-chrome-';
|
|
22
|
+
const TEMP_VARIABLES = /^(?:temp|tmp|tmpdir)$/i;
|
|
23
|
+
/**
|
|
24
|
+
* A profile folder younger than this may belong to a run still going (one whose command line this process cannot read, e.g. an elevated
|
|
25
|
+
* Chrome), so the sweep leaves it. An audit takes minutes; a folder left for half a day is from a launcher that was killed.
|
|
26
|
+
*/
|
|
27
|
+
const YOUNG_PROFILE_MS = 12 * 60 * 60_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The environment Chrome runs in: `tempDir`, a folder inside this run's profile folder, in place of the user's temporary folder.
|
|
31
|
+
* What Chrome writes there (its `scoped_dir*` folders and component updates) is removed with the profile, even after a forced kill
|
|
32
|
+
* skipped Chrome's own cleanup. Existing TEMP, TMP and TMPDIR entries are dropped in any letter case, so the child sees one value each.
|
|
33
|
+
*
|
|
34
|
+
* Off Windows, Chrome binds its single-instance socket at `<temp folder>/.org.chromium.Chromium.XXXXXX/SingletonSocket`, and a Unix
|
|
35
|
+
* socket path is limited to 107 bytes on Linux and 104 on macOS; Chrome aborts at start when it is longer (Chromium
|
|
36
|
+
* `process_singleton_posix.cc`: "Socket path too long"). A profile under a long folder (a macOS `/var/folders/...` temp folder, a
|
|
37
|
+
* folder with Hangul, which takes three bytes a character) would put `tempDir` past that, so then Chrome keeps the user's own temp
|
|
38
|
+
* folder, as it did before this redirect. `platform` is injectable for tests.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_REDIRECTED_TEMP_BYTES = 48;
|
|
41
|
+
export function chromeEnv(env, tempDir, platform = process.platform) {
|
|
42
|
+
if (platform !== 'win32' && Buffer.byteLength(tempDir) > MAX_REDIRECTED_TEMP_BYTES) return { ...env };
|
|
43
|
+
const kept = Object.fromEntries(Object.entries(env).filter(([name]) => !TEMP_VARIABLES.test(name)));
|
|
44
|
+
return { ...kept, TEMP: tempDir, TMP: tempDir, TMPDIR: tempDir };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Remove the profile folders of earlier launches whose launcher was killed before it could clean up. A folder goes only when it is not
|
|
49
|
+
* young and no running process names it; when the process list cannot be read nothing is removed. `listLines(marker)` returns the
|
|
50
|
+
* command lines that name the marker, or null (injectable for tests). Returns the names removed.
|
|
51
|
+
*/
|
|
52
|
+
export async function sweepStaleProfiles(tmpRoot, { now = Date.now(), listLines = marker => commandLinesNaming(marker, limits().chrome_close_grace_ms) } = {}) {
|
|
53
|
+
let names;
|
|
54
|
+
try { names = (await readdir(tmpRoot)).filter(name => name.startsWith(PROFILE_PREFIX)); } catch { return []; }
|
|
55
|
+
const old = [];
|
|
56
|
+
for (const name of names) {
|
|
57
|
+
try { if (now - (await stat(path.join(tmpRoot, name))).mtimeMs > YOUNG_PROFILE_MS) old.push(name); } catch { /* gone already */ }
|
|
58
|
+
}
|
|
59
|
+
if (!old.length) return [];
|
|
60
|
+
const lines = await listLines(PROFILE_PREFIX);
|
|
61
|
+
if (lines === null) return [];
|
|
62
|
+
const removed = [];
|
|
63
|
+
for (const name of old) {
|
|
64
|
+
if (lines.some(line => line.includes(name))) continue;
|
|
65
|
+
try { await rm(path.join(tmpRoot, name), { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); removed.push(name); } catch { /* in use or locked: the next start tries again */ }
|
|
66
|
+
}
|
|
67
|
+
return removed;
|
|
68
|
+
}
|