optbrew 0.1.1 → 0.1.3
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 +4 -4
- package/.claude-plugin/plugin.json +2 -1
- package/.codex-plugin/plugin.json +2 -2
- package/AGENT_INSTALL.md +92 -0
- package/CHANGELOG.md +51 -0
- package/README.ko.md +111 -36
- package/README.md +111 -36
- package/docs/platforms.md +118 -5
- package/docs/release.md +3 -2
- package/lib/cli.mjs +15 -2
- package/lib/doctor.mjs +1 -0
- package/lib/reinstall.mjs +92 -0
- package/lib/update.mjs +10 -2
- package/package.json +26 -8
- package/plugin.json +2 -2
- package/skills/optbrew/SKILL.md +2 -2
- package/skills/optbrew/bundle-manifest.json +39 -15
- package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +29 -1
- package/skills/optbrew/references/ai-crawlers.json +21 -0
- package/skills/optbrew/references/ai-crawlers.md +24 -0
- package/skills/optbrew/references/api.md +2 -2
- package/skills/optbrew/references/report.md +4 -0
- package/skills/optbrew/references/scoring.md +1 -1
- package/skills/optbrew/scripts/audit.mjs +25 -30
- package/skills/optbrew/scripts/core/bundle.mjs +1 -1
- package/skills/optbrew/scripts/core/collector.mjs +40 -16
- package/skills/optbrew/scripts/core/crawlers.mjs +11 -0
- package/skills/optbrew/scripts/core/derive.mjs +193 -0
- package/skills/optbrew/scripts/core/facts.mjs +215 -0
- package/skills/optbrew/scripts/core/html.mjs +53 -0
- package/skills/optbrew/scripts/core/report.mjs +154 -43
- package/skills/optbrew/source-lock.json +1 -1
- package/source-lock.json +1 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { validateEntity } from './contracts.mjs';
|
|
3
|
+
import { auditCollected } from '../../modules/naver-searchadvisor/scripts/audit.mjs';
|
|
4
|
+
import { parseRobots, robotsVerdict } from '../../modules/naver-searchadvisor/scripts/robots.mjs';
|
|
5
|
+
import { anchorLinks, decodeEntities } from './html.mjs';
|
|
6
|
+
import { agentFor } from './crawlers.mjs';
|
|
7
|
+
import { declaredRobotsIndex } from './collector.mjs';
|
|
8
|
+
import { compareFacts, pageFacts } from './facts.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every collector verdict, derived from collected responses alone. The audit writes these observations and the
|
|
12
|
+
* report replays this same function over hashed raw evidence, so a producer and a verifier cannot disagree.
|
|
13
|
+
* `artifacts` carry the collection record fields plus evidence_ref; `textOf(artifact)` returns the raw body text.
|
|
14
|
+
*/
|
|
15
|
+
/** Link and sitemap target comparison key: no fragment, and a directory index file equals its directory. */
|
|
16
|
+
function canonicalUrl(value) {
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(value); url.hash = '';
|
|
19
|
+
url.pathname = url.pathname.replace(/\/index\.(?:html?|php)$/i, '/');
|
|
20
|
+
return url.href;
|
|
21
|
+
} catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function deriveObservations({ context, artifacts, collection = {}, textOf }) {
|
|
25
|
+
const protocol = context.protocol;
|
|
26
|
+
const planned = new Set(protocol.families.filter(x => x.applicable).map(x => x.family_id));
|
|
27
|
+
const selected = new Map(protocol.page_types.flatMap(type => type.urls).map(url => [url.normalized, url]));
|
|
28
|
+
const declared = kind => new Set((protocol.auxiliary_resources ?? []).filter(x => x.kind === kind).map(x => x.normalized));
|
|
29
|
+
const declaredRobots = declared('robots'), declaredSitemaps = declared('sitemap');
|
|
30
|
+
const out = [];
|
|
31
|
+
const add = (family, engine, url, state, reason, evidence_ref) => {
|
|
32
|
+
const unit_key = `${family}|${engine ? `${engine}|` : ''}${url}`;
|
|
33
|
+
out.push(validateEntity('CheckObservation', { schema_version: '1.0.0',
|
|
34
|
+
observation_id: createHash('sha256').update(unit_key).digest('hex'), check_id: family, family_id: family, unit_key,
|
|
35
|
+
state, q: state === 'pass' ? 1 : state === 'fail' ? 0 : null, partial_basis: null, reason, evaluator_type: 'code',
|
|
36
|
+
source_ref: 'optbrew:collector', evidence_ref }));
|
|
37
|
+
};
|
|
38
|
+
const pageRows = [];
|
|
39
|
+
for (const artifact of artifacts) {
|
|
40
|
+
const url = artifact.normalized_url;
|
|
41
|
+
if (!selected.has(url)) continue;
|
|
42
|
+
if (planned.has('fetch-response')) add('fetch-response', null, url,
|
|
43
|
+
artifact.error || artifact.status < 200 || artifact.status >= 300 && artifact.status < 400 ? 'unknown' : artifact.status >= 400 ? 'fail' : 'pass',
|
|
44
|
+
artifact.error ? 'Request did not finish' : `HTTP ${artifact.status}`, artifact.evidence_ref);
|
|
45
|
+
if (artifact.capability !== 'raw_html' || artifact.status !== 200) continue;
|
|
46
|
+
const html = textOf(artifact);
|
|
47
|
+
const robotsIndex = declaredRobotsIndex(artifacts, declaredRobots, artifact.final_url);
|
|
48
|
+
const robots = robotsIndex < 0 ? null : artifacts[robotsIndex];
|
|
49
|
+
const info = auditCollected({ artifact, html, engines: protocol.engines,
|
|
50
|
+
robotsArtifact: robots?.status === 200 ? { ...robots, body: textOf(robots) } : null });
|
|
51
|
+
pageRows.push({ url, artifact, html, info, robots, publicIndexing: selected.get(url).public_indexing !== false });
|
|
52
|
+
}
|
|
53
|
+
const normalizeTitle = title => title.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
54
|
+
const titleCounts = new Map();
|
|
55
|
+
for (const row of pageRows) if (row.info.title) {
|
|
56
|
+
const key = normalizeTitle(row.info.title);
|
|
57
|
+
titleCounts.set(key, new Set([...(titleCounts.get(key) ?? []), row.artifact.final_url]));
|
|
58
|
+
}
|
|
59
|
+
const facts = pageRows.map(row => ({ url: row.url, row, facts: pageFacts(row.html) }));
|
|
60
|
+
const comparison = compareFacts(facts);
|
|
61
|
+
for (const row of pageRows) {
|
|
62
|
+
const { url, artifact, info } = row, ref = artifact.evidence_ref;
|
|
63
|
+
if (planned.has('document-title')) {
|
|
64
|
+
const dup = info.title ? pageRows.filter(x => x !== row && x.info.title && normalizeTitle(x.info.title) === normalizeTitle(info.title) &&
|
|
65
|
+
x.artifact.final_url !== artifact.final_url) : [];
|
|
66
|
+
add('document-title', null, url, !info.title || dup.length ? 'fail' : 'unknown',
|
|
67
|
+
!info.title ? 'Raw HTML has no title' : dup.length ? `Title duplicates ${dup.map(x => x.url).join(', ')}` :
|
|
68
|
+
'Raw HTML has a title; uniqueness and descriptiveness are not established', ref);
|
|
69
|
+
}
|
|
70
|
+
if (planned.has('index-policy') && row.publicIndexing)
|
|
71
|
+
add('index-policy', null, url, info.index_blocked ? 'fail' : 'pass',
|
|
72
|
+
info.index_blocked ? 'Raw response blocks indexing for the target engine' : 'Raw response has no applicable noindex directive', ref);
|
|
73
|
+
if (planned.has('search-crawl-policy') && row.publicIndexing && row.robots?.status === 200) {
|
|
74
|
+
const parsed = parseRobots(textOf(row.robots));
|
|
75
|
+
const target = new URL(artifact.final_url);
|
|
76
|
+
for (const engine of protocol.engines) {
|
|
77
|
+
const agent = agentFor(engine);
|
|
78
|
+
if (!agent) continue;
|
|
79
|
+
const verdict = robotsVerdict(parsed, agent, target.pathname + target.search);
|
|
80
|
+
add('search-crawl-policy', engine, url, verdict.allowed ? 'pass' : 'fail',
|
|
81
|
+
verdict.allowed ? `Collected robots.txt allows ${agent} path` : `Collected robots.txt disallows ${agent} path`, row.robots.evidence_ref);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (planned.has('naver-markup') && protocol.engines.includes('naver'))
|
|
85
|
+
add('naver-markup', 'naver', url, info.markup_findings.length ? 'fail' : 'pass',
|
|
86
|
+
info.markup_findings.length ? info.markup_findings.map(x => x.message).join('; ') : 'Open Graph, description, canonical, viewport and title checks pass', ref);
|
|
87
|
+
if (planned.has('structured-data-truth') && info.jsonld_parse_errors > 0)
|
|
88
|
+
add('structured-data-truth', null, url, 'fail', `${info.jsonld_parse_errors} JSON-LD block(s) fail to parse`, ref);
|
|
89
|
+
if (planned.has('canonical-consistency')) {
|
|
90
|
+
const hops = artifact.redirect_chain?.length ?? 0;
|
|
91
|
+
let canonicalState = null, canonicalNote = '';
|
|
92
|
+
if (info.canonical) {
|
|
93
|
+
try {
|
|
94
|
+
const target = new URL(info.canonical, artifact.final_url); target.hash = '';
|
|
95
|
+
const other = artifacts.find(x => x.normalized_url === target.href || x.final_url === target.href);
|
|
96
|
+
if (target.href === artifact.final_url || target.href === artifact.normalized_url) canonicalState = 'pass';
|
|
97
|
+
else if (other && other.status >= 200 && other.status < 300) canonicalState = 'pass';
|
|
98
|
+
else if (other && Number.isInteger(other.status)) { canonicalState = 'fail'; canonicalNote = `canonical target ${target.href} returns HTTP ${other.status}`; }
|
|
99
|
+
} catch { canonicalState = 'fail'; canonicalNote = 'canonical is not a valid URL'; }
|
|
100
|
+
}
|
|
101
|
+
if (hops >= 2) add('canonical-consistency', null, url, 'fail', `Redirect chain of ${hops} hops${canonicalNote ? `; ${canonicalNote}` : ''}`, ref);
|
|
102
|
+
else if (canonicalState === 'fail') add('canonical-consistency', null, url, 'fail', canonicalNote, ref);
|
|
103
|
+
else if (canonicalState === 'pass') add('canonical-consistency', null, url, 'pass', 'Canonical target is this page or a reachable page and redirects stay within one hop', ref);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** True when any other collected page's raw bytes name this page's path (whole path segment, not a longer path). */
|
|
107
|
+
const pathMentioned = ({ url, artifact }, bodies) => {
|
|
108
|
+
const own = canonicalUrl(url);
|
|
109
|
+
for (const target of new Set([url, artifact.final_url])) {
|
|
110
|
+
let path;
|
|
111
|
+
try { path = decodeURIComponent(new URL(target).pathname); } catch { continue; }
|
|
112
|
+
if (path === '/') continue;
|
|
113
|
+
const pattern = new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![A-Za-z0-9_-])');
|
|
114
|
+
for (const [page, body] of bodies) if (page !== own && pattern.test(body)) return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
};
|
|
118
|
+
if (planned.has('discovery')) {
|
|
119
|
+
const sitemapUrls = new Set();
|
|
120
|
+
const brokenSitemaps = [];
|
|
121
|
+
let sitemapIndex = false;
|
|
122
|
+
for (const artifact of artifacts) {
|
|
123
|
+
if (!declaredSitemaps.has(artifact.normalized_url)) continue;
|
|
124
|
+
if (artifact.status >= 200 && artifact.status < 300) {
|
|
125
|
+
const body = String(textOf(artifact) ?? '');
|
|
126
|
+
// Child sitemaps of an index are not fetched, so pages missing from the index prove nothing.
|
|
127
|
+
if (/<sitemapindex\b/i.test(body)) sitemapIndex = true;
|
|
128
|
+
for (const match of body.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)) sitemapUrls.add(canonicalUrl(decodeEntities(match[1])));
|
|
129
|
+
} else brokenSitemaps.push(`${artifact.normalized_url} (${artifact.status ?? artifact.error ?? 'no response'})`);
|
|
130
|
+
}
|
|
131
|
+
const htmlPages = artifacts.filter(x => x.capability === 'raw_html' && x.status === 200);
|
|
132
|
+
const linked = new Map();
|
|
133
|
+
// A page with scripts and no anchors may build its navigation in the browser. That only hides an orphan when the
|
|
134
|
+
// script can be read: an inline script is searched for the path below, and a same-origin script file that was not
|
|
135
|
+
// collected cannot be read at all.
|
|
136
|
+
const collected = new Set(artifacts.flatMap(x => [x.normalized_url, x.final_url].filter(Boolean).map(canonicalUrl)));
|
|
137
|
+
let uncollectedScript = false;
|
|
138
|
+
const bodies = new Map();
|
|
139
|
+
for (const page of htmlPages) {
|
|
140
|
+
const body = textOf(page);
|
|
141
|
+
bodies.set(canonicalUrl(page.normalized_url), body);
|
|
142
|
+
const hrefs = anchorLinks(body, page.final_url);
|
|
143
|
+
if (!hrefs.length && /<script\b/i.test(body)) {
|
|
144
|
+
for (const match of body.matchAll(/<script\b[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
|
|
145
|
+
try {
|
|
146
|
+
const source = new URL(decodeEntities(match[1] ?? match[2]), page.final_url);
|
|
147
|
+
if (source.origin === new URL(page.final_url).origin && !collected.has(canonicalUrl(source.href))) uncollectedScript = true;
|
|
148
|
+
} catch { /* malformed script URL */ }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for (const href of hrefs) {
|
|
152
|
+
const target = canonicalUrl(href);
|
|
153
|
+
if (target && target !== canonicalUrl(page.final_url) && target !== canonicalUrl(page.normalized_url)) linked.set(target, (linked.get(target) ?? 0) + 1);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const complete = !collection.limit_reached && !(collection.skipped ?? []).some(x => ['depth_limit', 'url_limit', 'request_limit'].includes(x.reason));
|
|
157
|
+
for (const row of pageRows) {
|
|
158
|
+
if (!row.publicIndexing) continue;
|
|
159
|
+
const { url, artifact } = row;
|
|
160
|
+
const keys = [canonicalUrl(url), canonicalUrl(artifact.final_url)];
|
|
161
|
+
const inSitemap = keys.some(key => sitemapUrls.has(key));
|
|
162
|
+
if (inSitemap || keys.some(key => linked.has(key)))
|
|
163
|
+
add('discovery', null, url, 'pass', inSitemap ? 'Listed in a declared sitemap' : 'Linked from another collected page', artifact.evidence_ref);
|
|
164
|
+
// An unlinked page is unknown when a collected page's own bytes (inline scripts included) name its path, or when a
|
|
165
|
+
// script file that could build the link was not collected, unless a fetched sitemap that lists other pages leaves
|
|
166
|
+
// it out. Anything else is an orphan.
|
|
167
|
+
else if (htmlPages.length >= 2 && complete && !sitemapIndex && (sitemapUrls.size || !(uncollectedScript || pathMentioned(row, bodies))))
|
|
168
|
+
add('discovery', null, url, 'fail', `Not linked from any collected page and not in a sitemap${brokenSitemaps.length ? `; declared sitemap unusable: ${brokenSitemaps.join(', ')}` : ''}`, artifact.evidence_ref);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (planned.has('fact-consistency')) {
|
|
172
|
+
for (const page of facts) {
|
|
173
|
+
if (comparison.separate.has(page.url)) continue;
|
|
174
|
+
const found = comparison.deviations.get(page.url) ?? [];
|
|
175
|
+
const comparable = Object.keys(comparison.references).some(type => page.facts.visible[type].size || page.facts.linked[type].size || (type === 'phone' && page.facts.tel.phone.size));
|
|
176
|
+
if (found.length) add('fact-consistency', null, page.url, 'fail', `Cross-page fact comparison (no approved fact list): ${found.join('; ')}`, page.row.artifact.evidence_ref);
|
|
177
|
+
else if (comparable) add('fact-consistency', null, page.url, 'pass', 'Facts on this page match the cross-page reference values (no approved fact list; consistency is not accuracy)', page.row.artifact.evidence_ref);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (planned.has('entity-identity') && facts.length) {
|
|
181
|
+
const reasons = [];
|
|
182
|
+
const home = facts.find(x => new URL(x.row.artifact.final_url).pathname === '/') ?? facts[0];
|
|
183
|
+
const orgPages = facts.filter(x => x.facts.orgs.length && !comparison.separate.has(x.url));
|
|
184
|
+
const nameReference = comparison.references.name;
|
|
185
|
+
const nameDeviants = facts.filter(x => (comparison.deviations.get(x.url) ?? []).some(text => text.startsWith('name ')));
|
|
186
|
+
if (nameDeviants.length) reasons.push(`name differs from "${nameReference.label}" on ${nameDeviants.map(x => x.url).join(', ')}`);
|
|
187
|
+
for (const page of orgPages) if (page.facts.orgs.some(org => !org.sameAs)) reasons.push(`Organization JSON-LD without sameAs on ${page.url}`);
|
|
188
|
+
if (orgPages.length && !home.facts.orgs.length) reasons.push(`no Organization or LocalBusiness JSON-LD on ${home.url}`);
|
|
189
|
+
if (reasons.length) add('entity-identity', null, 'site', 'fail', `Entity identity: ${reasons.join('; ')}`, (nameDeviants[0] ?? home).row.artifact.evidence_ref);
|
|
190
|
+
else if (orgPages.length) add('entity-identity', null, 'site', 'pass', 'Organization JSON-LD with name and sameAs is present on the home page and names agree', home.row.artifact.evidence_ref);
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { decodeEntities, jsonLdBlocks, stripComments, stripInert, visibleText } from './html.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cross-page entity fact extraction and comparison, pure functions of the raw HTML of the selected pages.
|
|
5
|
+
* Facts come only from Organization or LocalBusiness JSON-LD, tel: links and explicitly labelled contact fields.
|
|
6
|
+
* Free prose, dates and numbers elsewhere on a page are never read as facts; when a comparison is not clear the
|
|
7
|
+
* result is unknown, not a failure.
|
|
8
|
+
*/
|
|
9
|
+
const FACT_TYPES = ['name', 'phone', 'year', 'address'];
|
|
10
|
+
const ORG_TYPES = /^(?:Organization|Corporation|LocalBusiness|Store|Restaurant|CafeOrCoffeeShop|OnlineStore|NGO|[A-Za-z]*Business)$/;
|
|
11
|
+
const squash = value => String(value).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
12
|
+
const BRANCH = /\b(?:branch)\b|지점|지사/i;
|
|
13
|
+
// URL path segments that list branches: /branch/, /branches/, /locations/, /stores/, /지점/.
|
|
14
|
+
const BRANCH_PATH = /^(?:branch|branches|locations|stores|지점)$/i;
|
|
15
|
+
const branchPath = url => {
|
|
16
|
+
try { return new URL(url, 'http://page.invalid').pathname.split('/').some(part => BRANCH_PATH.test(decodeURIComponent(part))); }
|
|
17
|
+
catch { return false; }
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ABBREVIATIONS = [[/\bstreet\b/g, 'st'], [/\broad\b/g, 'rd'], [/\bavenue\b/g, 'ave'], [/\bboulevard\b/g, 'blvd'],
|
|
21
|
+
[/\bdrive\b/g, 'dr'], [/\blane\b/g, 'ln'], [/\bsuite\b/g, 'ste'], [/\bfloor\b/g, 'fl'], [/\bbuilding\b/g, 'bldg']];
|
|
22
|
+
/**
|
|
23
|
+
* Address key: normalized tokens joined by one space. Lower case, abbreviations unified, trailing country dropped, Korean
|
|
24
|
+
* 시/도 forms unified. A postal code is dropped only in its own position (in parentheses or brackets, after a label, or as
|
|
25
|
+
* the last token after the city); a five-digit street number such as "12345 Ocean Avenue" stays part of the address.
|
|
26
|
+
*/
|
|
27
|
+
export function addressKey(value) {
|
|
28
|
+
let text = String(value).toLowerCase().replace(/[,;]+/g, ' ');
|
|
29
|
+
text = text.replace(/\b(?:south korea|republic of korea|korea|kr|usa|us|united states|대한민국|한국)\b\s*$/u, '');
|
|
30
|
+
text = text.replace(/[(\[]\s*\d{3}-?\d{2,3}(?:-\d{4})?\s*[)\]]/g, ' ');
|
|
31
|
+
text = text.replace(/(?:우편번호|postal code|post code|zip code|zip)\s*[::]?\s*\d[\d-]*/giu, ' ');
|
|
32
|
+
text = text.replace(/(?<=\p{L}\s+(?:[a-z]{2}\s+)?)(?:\d{5}(?:-\d{4})?|\d{3}-\d{3}|\d{6})\s*$/u, ' ');
|
|
33
|
+
for (const [pattern, replacement] of ABBREVIATIONS) text = text.replace(pattern, replacement);
|
|
34
|
+
text = text.replace(/(특별자치시|특별자치도|특별시|광역시)/g, '').replace(/^\s*(\p{Script=Hangul}+?)(시|도)(?=\s|$)/u, '$1');
|
|
35
|
+
return (text.match(/\p{L}+|\p{N}+/gu) ?? []).join(' ');
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Two address keys agree when they are equal or one is a run of whole tokens of the other (a street address with or
|
|
39
|
+
* without city detail). Tokens are compared exactly, so "10 main st" never matches "110 main st".
|
|
40
|
+
*/
|
|
41
|
+
function sameAddress(a, b) {
|
|
42
|
+
if (a === b) return true;
|
|
43
|
+
// The same words in another order (an English and a Korean address order) are not a difference.
|
|
44
|
+
if (a.split(' ').sort().join(' ') === b.split(' ').sort().join(' ')) return true;
|
|
45
|
+
const [short, long] = a.length <= b.length ? [a, b] : [b, a];
|
|
46
|
+
const shortTokens = short.split(' '), longTokens = long.split(' ');
|
|
47
|
+
if (shortTokens.length < 2 || !shortTokens.some(token => /^\p{L}/u.test(token))) return false;
|
|
48
|
+
for (let i = 0; i + shortTokens.length <= longTokens.length; i++)
|
|
49
|
+
if (shortTokens.every((token, k) => token === longTokens[i + k])) return true;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function nodesOf(value, out = []) {
|
|
54
|
+
if (Array.isArray(value)) value.forEach(item => nodesOf(item, out));
|
|
55
|
+
else if (value && typeof value === 'object') {
|
|
56
|
+
out.push(value);
|
|
57
|
+
if (value['@graph']) nodesOf(value['@graph'], out);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
const typesOf = node => [].concat(node['@type'] ?? []).map(String);
|
|
62
|
+
const addressText = address => typeof address === 'string' ? address : address && typeof address === 'object' ?
|
|
63
|
+
[address.streetAddress, address.addressLocality, address.addressRegion]
|
|
64
|
+
.filter(x => typeof x === 'string').join(', ') : '';
|
|
65
|
+
|
|
66
|
+
function phoneKey(text) {
|
|
67
|
+
const clean = String(text).replace(/\(0\)/g, '');
|
|
68
|
+
const digits = clean.replace(/\D/g, '');
|
|
69
|
+
// Korean nationwide representative numbers (15xx, 16xx, 18xx plus four digits) have eight digits.
|
|
70
|
+
if (/^1[568]\d{6}$/.test(digits)) return digits;
|
|
71
|
+
if (digits.length < 9 || digits.length > 15) return null;
|
|
72
|
+
return /^\s*\+?\s*82/.test(clean) && clean.trim().startsWith('+') && digits.startsWith('82') ? `0${digits.slice(2)}` : digits;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Text for phone extraction: every tag, inline or not, ends the text before it, so a figure in the next element
|
|
77
|
+
* (a table cell, a span, a list item) is never read as the rest of a number.
|
|
78
|
+
*/
|
|
79
|
+
const hardText = html => decodeEntities(stripInert(html).replace(/<[^>]+>/g, '\n')).replace(/[ \t\f\v\r ]+/g, ' ');
|
|
80
|
+
// An international or parenthesised number may use spaces between its groups; a plain national number only dashes and dots.
|
|
81
|
+
const LOOSE_PHONE = /^[+(]\s?[\d()]{1,6}(?:[-. ]?\(?\d{1,6}\)?){1,5}/;
|
|
82
|
+
const STRICT_PHONE = /^\+?\d{1,4}(?:[-.]\d{1,6}){1,4}/;
|
|
83
|
+
// In a label, a national number may use spaces only (02 555 0142, 010 1234 5678), and a nationwide representative number
|
|
84
|
+
// is 15xx, 16xx or 18xx plus four digits. Neither is read in prose.
|
|
85
|
+
const SPACED_NATIONAL_PHONE = /^0\d{1,2}(?: \d{3,4}){2}(?!\d)/;
|
|
86
|
+
const REPRESENTATIVE_PHONE = /^1[568]\d{2}[- ]\d{4}(?!\d)/;
|
|
87
|
+
/**
|
|
88
|
+
* The number at the start of `rest`, ending at its own last digit group. A spaced number that is followed by more
|
|
89
|
+
* digits or a bracketed digit is ambiguous, so only its dashed or dotted part is read, and a number longer than twelve
|
|
90
|
+
* digits is not read at all.
|
|
91
|
+
*/
|
|
92
|
+
function readPhone(rest) {
|
|
93
|
+
const usable = text => text && (phoneKey(text)?.length ?? 99) <= 13 ? text.trim() : null;
|
|
94
|
+
const loose = LOOSE_PHONE.exec(rest)?.[0].trimEnd();
|
|
95
|
+
if (loose && !/^[ \t]*[(\[]?\d/.test(rest.slice(loose.length)) && usable(loose)) return loose.trim();
|
|
96
|
+
const strict = usable(STRICT_PHONE.exec(rest)?.[0]);
|
|
97
|
+
if (strict) return strict;
|
|
98
|
+
// Digits straight after a spaced number are ambiguous (02 555 0142 42 seats); a bracket after it is not (hours).
|
|
99
|
+
for (const pattern of [SPACED_NATIONAL_PHONE, REPRESENTATIVE_PHONE]) {
|
|
100
|
+
const match = pattern.exec(rest)?.[0];
|
|
101
|
+
if (match && !/^[ \t]*\d/.test(rest.slice(match.length)) && usable(match)) return match.trim();
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Facts on one page: visible labelled values and JSON-LD / tel: values, keyed by fact type. */
|
|
107
|
+
export function pageFacts(html) {
|
|
108
|
+
const live = stripComments(html);
|
|
109
|
+
const visible = Object.fromEntries(FACT_TYPES.map(type => [type, new Map()]));
|
|
110
|
+
const linked = Object.fromEntries(FACT_TYPES.map(type => [type, new Map()]));
|
|
111
|
+
// tel: links are their own evidence class: supporting evidence that never contradicts a page's labelled or JSON-LD phone.
|
|
112
|
+
const tel = { phone: new Map() };
|
|
113
|
+
const put = (bucket, type, raw, key) => { if (key) bucket[type].set(key, String(raw).trim()); };
|
|
114
|
+
const text = visibleText(live);
|
|
115
|
+
// The text after a label is read in a lookahead, so a label alone (a heading that says "phone") never consumes the next line.
|
|
116
|
+
for (const match of hardText(live).matchAll(/\b(?:Phone|Tel(?:ephone)?|Mobile)\b(?=\s*[::]?\s*([^\n]*))|(?:전화(?:번호)?|연락처|대표번호)(?=\s*[::]?\s*([^\n]*))/gi)) {
|
|
117
|
+
const number = readPhone(match[1] ?? match[2]);
|
|
118
|
+
if (number) put(visible, 'phone', number, phoneKey(number));
|
|
119
|
+
}
|
|
120
|
+
for (const match of live.matchAll(/href\s*=\s*["']tel:([^"']+)["']/gi)) {
|
|
121
|
+
const value = decodeURIComponent(match[1]);
|
|
122
|
+
put(tel, 'phone', value, phoneKey(value));
|
|
123
|
+
}
|
|
124
|
+
for (const match of text.matchAll(/\b(?:Founded|Established)\b\s*[::]\s*((?:19|20)\d{2})\b|(?:설립(?:연도)?|창립(?:연도)?)\s*[::]?\s*((?:19|20)\d{2})/gi))
|
|
125
|
+
put(visible, 'year', match[1] ?? match[2], match[1] ?? match[2]);
|
|
126
|
+
for (const match of text.matchAll(/\bAddress\b\s*[::]\s*([^\n]+?)(?=\.\s|\.?\s*$|\n)/gim)) put(visible, 'address', match[1], addressKey(match[1]));
|
|
127
|
+
for (const match of text.matchAll(/주소\s*[::]\s*([^\n.]+)/g)) put(visible, 'address', match[1], addressKey(match[1]));
|
|
128
|
+
const orgs = [];
|
|
129
|
+
for (const block of jsonLdBlocks(live)) {
|
|
130
|
+
if (!block.ok) continue;
|
|
131
|
+
for (const node of nodesOf(block.value)) {
|
|
132
|
+
if (!typesOf(node).some(type => ORG_TYPES.test(type))) continue;
|
|
133
|
+
const name = typeof node.name === 'string' ? node.name : '';
|
|
134
|
+
const org = { name, id: typeof node['@id'] === 'string' ? node['@id'] : null,
|
|
135
|
+
sameAs: [].concat(node.sameAs ?? []).some(x => typeof x === 'string' && x.trim()),
|
|
136
|
+
branch: Boolean(node.branchOf || node.parentOrganization || BRANCH.test(name)) };
|
|
137
|
+
if (name) put(linked, 'name', name, squash(name));
|
|
138
|
+
if (typeof node.telephone === 'string') put(linked, 'phone', node.telephone, phoneKey(node.telephone));
|
|
139
|
+
const address = addressText(node.address);
|
|
140
|
+
if (address) put(linked, 'address', address, addressKey(address));
|
|
141
|
+
const year = /\b((?:19|20)\d{2})\b/.exec(String(node.foundingDate ?? ''))?.[1];
|
|
142
|
+
if (year) put(linked, 'year', year, year);
|
|
143
|
+
orgs.push(org);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// A branch word in the title or first heading is only a hint (titleBranch). A page is a separate entity when its
|
|
147
|
+
// JSON-LD says so (branch), when its URL is a branch list, or when the hint comes with an address of its own.
|
|
148
|
+
const heading = /<(?:title|h1)\b[^>]*>([\s\S]*?)<\/(?:title|h1)>/gi;
|
|
149
|
+
const headings = [...live.matchAll(heading)].map(match => match[1]).join(' ');
|
|
150
|
+
return { visible, linked, tel, orgs, branch: orgs.some(org => org.branch), titleBranch: BRANCH.test(headings) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Values compared across pages for one fact type: the labelled and JSON-LD values. A tel: link joins only when the page has
|
|
155
|
+
* no labelled or JSON-LD phone, so a mobile inquiry link beside the page's landline never counts against it.
|
|
156
|
+
*/
|
|
157
|
+
function evidenceOf(page, type) {
|
|
158
|
+
const found = new Map([...page.facts.visible[type], ...page.facts.linked[type]]);
|
|
159
|
+
if (type === 'phone' && !found.size) for (const [key, raw] of page.facts.tel.phone) found.set(key, raw);
|
|
160
|
+
return found;
|
|
161
|
+
}
|
|
162
|
+
const keysOf = (page, type) => new Set(evidenceOf(page, type).keys());
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Compare facts across the selected pages. A reference value needs at least two pages and a unique top count;
|
|
166
|
+
* otherwise the type is insufficient and stays unknown. Branch pages and entities with a different JSON-LD @id are skipped.
|
|
167
|
+
*/
|
|
168
|
+
export function compareFacts(allPages) {
|
|
169
|
+
const ids = new Map();
|
|
170
|
+
for (const page of allPages) for (const id of new Set(page.facts.orgs.map(org => org.id).filter(Boolean))) ids.set(id, (ids.get(id) ?? 0) + 1);
|
|
171
|
+
const ranked = [...ids.entries()].sort((a, b) => b[1] - a[1]);
|
|
172
|
+
const mainId = ranked.length && ranked[0][1] >= 2 && (ranked.length === 1 || ranked[0][1] > ranked[1][1]) ? ranked[0][0] : null;
|
|
173
|
+
const structural = page => page.facts.branch || branchPath(page.url) ||
|
|
174
|
+
(mainId && page.facts.orgs.some(org => org.id) && !page.facts.orgs.some(org => org.id === mainId));
|
|
175
|
+
// The main entity's addresses come from the pages with neither structure nor a branch word in the title.
|
|
176
|
+
const mainAddresses = [...new Set(allPages.filter(page => !structural(page) && !page.facts.titleBranch).flatMap(page => [...keysOf(page, 'address')]))];
|
|
177
|
+
// A branch word in the title counts only with a labelled address that is not the main entity's.
|
|
178
|
+
const ownAddress = page => { const own = keysOf(page, 'address'); return own.size > 0 && mainAddresses.length > 0 && ![...own].some(key => mainAddresses.some(main => sameAddress(key, main))); };
|
|
179
|
+
const separate = page => structural(page) || (page.facts.titleBranch && ownAddress(page));
|
|
180
|
+
const pages = allPages.filter(page => !separate(page));
|
|
181
|
+
const references = {};
|
|
182
|
+
const deviations = new Map(allPages.map(page => [page.url, []]));
|
|
183
|
+
for (const type of FACT_TYPES) {
|
|
184
|
+
const counts = new Map();
|
|
185
|
+
const carrying = pages.filter(page => keysOf(page, type).size);
|
|
186
|
+
for (const page of carrying) for (const key of keysOf(page, type)) counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
187
|
+
if (type === 'address') {
|
|
188
|
+
// Merge keys that agree under address matching so abbreviations and city detail do not split the count.
|
|
189
|
+
const keys = [...counts.keys()];
|
|
190
|
+
for (const key of keys) for (const other of keys) if (key !== other && counts.has(key) && counts.has(other) && sameAddress(key, other) && key.length >= other.length) {
|
|
191
|
+
counts.set(other, counts.get(other) + counts.get(key)); counts.delete(key);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const top = [...counts.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
|
|
195
|
+
if (top.length && top[0][1] >= 2 && (top.length === 1 || top[0][1] > top[1][1])) {
|
|
196
|
+
const [modal, count] = top[0];
|
|
197
|
+
const agrees = key => type === 'address' ? sameAddress(key, modal) || [...counts.keys()].some(k => sameAddress(k, modal) && sameAddress(k, key)) : key === modal;
|
|
198
|
+
const label = pages.flatMap(page => [...evidenceOf(page, type).entries()]).find(([key]) => agrees(key))?.[1] ?? modal;
|
|
199
|
+
references[type] = { key: modal, label, pages: count };
|
|
200
|
+
for (const page of carrying) {
|
|
201
|
+
if (![...keysOf(page, type)].some(agrees)) {
|
|
202
|
+
const found = [...evidenceOf(page, type).values()].join(' / ');
|
|
203
|
+
deviations.get(page.url).push(`${type} "${found}" differs from "${label}" on ${count} pages`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const page of pages) {
|
|
208
|
+
const v = [...page.facts.visible[type].keys()], l = [...page.facts.linked[type].keys()];
|
|
209
|
+
const same = type === 'address' ? v.some(a => l.some(b => sameAddress(a, b))) : v.some(key => l.includes(key));
|
|
210
|
+
if (v.length && l.length && !same && !deviations.get(page.url).some(x => x.startsWith(`${type} `)))
|
|
211
|
+
deviations.get(page.url).push(`${type} in JSON-LD "${[...page.facts.linked[type].values()].join(' / ')}" differs from visible "${[...page.facts.visible[type].values()].join(' / ')}"`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { references, deviations, separate: new Set(allPages.filter(separate).map(page => page.url)) };
|
|
215
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Small HTML helpers shared by the collector and the verdict derivation. Pure functions of raw bytes. */
|
|
2
|
+
|
|
3
|
+
/** Drop comments and raw text elements whose content is not markup. */
|
|
4
|
+
export function stripInert(html) {
|
|
5
|
+
return String(html ?? '').replace(/<!--[\s\S]*?(?:-->|$)|<(script|style|textarea)\b[^>]*>[\s\S]*?(?:<\/\1\s*>|$)/gi, '');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Markup without HTML comments. */
|
|
9
|
+
export const stripComments = html => String(html ?? '').replace(/<!--[\s\S]*?(?:-->|$)/g, '');
|
|
10
|
+
|
|
11
|
+
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
12
|
+
/** Decode the character references that appear in attribute values. */
|
|
13
|
+
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 ? String.fromCodePoint(Number(dec)) : hex ? String.fromCodePoint(parseInt(hex, 16)) : named[name.toLowerCase()]);
|
|
15
|
+
|
|
16
|
+
/** Absolute hrefs of anchors, resolved against the response URL. */
|
|
17
|
+
export function anchorLinks(html, base) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const match of String(html).matchAll(/<a\b[^>]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
|
|
20
|
+
try { out.push(new URL(decodeEntities(match[1] ?? match[2]), base).href); } catch { /* malformed link */ }
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The raw href of the first rel=canonical link in live markup, or null. */
|
|
26
|
+
export function canonicalLink(html) {
|
|
27
|
+
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] ?? '';
|
|
30
|
+
if (/(^|\s)canonical(\s|$)/i.test(attrs.rel ?? '') && attrs.href) return attrs.href;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** JSON-LD blocks with their parse result. */
|
|
36
|
+
export function jsonLdBlocks(html) {
|
|
37
|
+
// Blocks inside HTML comments are not live markup, and an empty placeholder block is not an error.
|
|
38
|
+
return [...stripComments(html).matchAll(/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)]
|
|
39
|
+
.filter(match => match[1].trim()).map(match => {
|
|
40
|
+
try { return { ok: true, value: JSON.parse(match[1]) }; } catch { return { ok: false, value: null }; }
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const entities = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ' ': ' ' };
|
|
45
|
+
|
|
46
|
+
/** Visible text with block boundaries kept as line breaks. */
|
|
47
|
+
export function visibleText(html) {
|
|
48
|
+
return stripInert(html)
|
|
49
|
+
.replace(/<\/?(?:h[1-6]|p|div|li|ul|ol|br|tr|section|article|header|footer|main|nav|title)\b[^>]*>/gi, '\n')
|
|
50
|
+
.replace(/<[^>]+>/g, ' ')
|
|
51
|
+
.replace(/&(?:amp|lt|gt|quot|#39|nbsp);/g, token => entities[token])
|
|
52
|
+
.replace(/[ \t\f\v\r]+/g, ' ').replace(/ ?\n ?/g, '\n').replace(/\n{2,}/g, '\n').trim();
|
|
53
|
+
}
|