@taurgis/bonsai 1.0.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/README.md +239 -0
- package/agents/agents/official-docs-researcher.agent.md +98 -0
- package/agents/instructions/web-research.instructions.md +29 -0
- package/agents/skills/web-research/SKILL.md +126 -0
- package/bin/cli.mjs +22 -0
- package/dist/base-command.d.ts +14 -0
- package/dist/base-command.js +56 -0
- package/dist/commands/config/base.d.ts +23 -0
- package/dist/commands/config/base.js +46 -0
- package/dist/commands/config/get.d.ts +20 -0
- package/dist/commands/config/get.js +48 -0
- package/dist/commands/config/list.d.ts +17 -0
- package/dist/commands/config/list.js +48 -0
- package/dist/commands/config/set.d.ts +31 -0
- package/dist/commands/config/set.js +94 -0
- package/dist/commands/config/unset.d.ts +21 -0
- package/dist/commands/config/unset.js +65 -0
- package/dist/commands/fetch.d.ts +38 -0
- package/dist/commands/fetch.js +311 -0
- package/dist/commands/import.d.ts +38 -0
- package/dist/commands/import.js +313 -0
- package/dist/commands/index.d.ts +35 -0
- package/dist/commands/index.js +306 -0
- package/dist/commands/inspect.d.ts +13 -0
- package/dist/commands/inspect.js +82 -0
- package/dist/commands/list.d.ts +25 -0
- package/dist/commands/list.js +143 -0
- package/dist/commands/prune.d.ts +24 -0
- package/dist/commands/prune.js +141 -0
- package/dist/commands/research/config/base.d.ts +23 -0
- package/dist/commands/research/config/base.js +46 -0
- package/dist/commands/research/config/get.d.ts +20 -0
- package/dist/commands/research/config/get.js +45 -0
- package/dist/commands/research/config/list.d.ts +17 -0
- package/dist/commands/research/config/list.js +42 -0
- package/dist/commands/research/config/set.d.ts +31 -0
- package/dist/commands/research/config/set.js +94 -0
- package/dist/commands/research/config/unset.d.ts +21 -0
- package/dist/commands/research/config/unset.js +59 -0
- package/dist/commands/research/import.d.ts +38 -0
- package/dist/commands/research/import.js +313 -0
- package/dist/commands/research/inspect.d.ts +13 -0
- package/dist/commands/research/inspect.js +86 -0
- package/dist/commands/research/list.d.ts +25 -0
- package/dist/commands/research/list.js +143 -0
- package/dist/commands/research/prune.d.ts +24 -0
- package/dist/commands/research/prune.js +141 -0
- package/dist/commands/research/search.d.ts +32 -0
- package/dist/commands/research/search.js +334 -0
- package/dist/commands/research/status.d.ts +17 -0
- package/dist/commands/research/status.js +108 -0
- package/dist/commands/research.d.ts +36 -0
- package/dist/commands/research.js +304 -0
- package/dist/commands/search.d.ts +32 -0
- package/dist/commands/search.js +334 -0
- package/dist/commands/status.d.ts +17 -0
- package/dist/commands/status.js +95 -0
- package/dist/commands.d.ts +24 -0
- package/dist/commands.js +24 -0
- package/dist/lib/config/index.d.ts +18 -0
- package/dist/lib/config/index.js +33 -0
- package/dist/lib/config/io.d.ts +8 -0
- package/dist/lib/config/io.js +75 -0
- package/dist/lib/config/resolve.d.ts +15 -0
- package/dist/lib/config/resolve.js +20 -0
- package/dist/lib/config/schema.d.ts +28 -0
- package/dist/lib/config/schema.js +32 -0
- package/dist/lib/research/artifact.d.ts +21 -0
- package/dist/lib/research/artifact.js +217 -0
- package/dist/lib/research/browser.d.ts +74 -0
- package/dist/lib/research/browser.js +359 -0
- package/dist/lib/research/cache-key.d.ts +5 -0
- package/dist/lib/research/cache-key.js +10 -0
- package/dist/lib/research/capture.d.ts +29 -0
- package/dist/lib/research/capture.js +181 -0
- package/dist/lib/research/compress.d.ts +5 -0
- package/dist/lib/research/compress.js +17 -0
- package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +1 -0
- package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +2 -0
- package/dist/lib/research/docs/capabilities.d.ts +43 -0
- package/dist/lib/research/docs/capabilities.js +9 -0
- package/dist/lib/research/docs/clean-dom.d.ts +4 -0
- package/dist/lib/research/docs/clean-dom.js +41 -0
- package/dist/lib/research/docs/code-blocks.d.ts +5 -0
- package/dist/lib/research/docs/code-blocks.js +69 -0
- package/dist/lib/research/docs/detect.d.ts +6 -0
- package/dist/lib/research/docs/detect.js +239 -0
- package/dist/lib/research/docs/machine-readable.d.ts +21 -0
- package/dist/lib/research/docs/machine-readable.js +73 -0
- package/dist/lib/research/docs/markdown-source.d.ts +17 -0
- package/dist/lib/research/docs/markdown-source.js +194 -0
- package/dist/lib/research/docs/quality-gates.d.ts +9 -0
- package/dist/lib/research/docs/quality-gates.js +65 -0
- package/dist/lib/research/docs/remote-search-runner.d.ts +21 -0
- package/dist/lib/research/docs/remote-search-runner.js +54 -0
- package/dist/lib/research/docs/remote-search.d.ts +14 -0
- package/dist/lib/research/docs/remote-search.js +101 -0
- package/dist/lib/research/docs/rsc.d.ts +10 -0
- package/dist/lib/research/docs/rsc.js +30 -0
- package/dist/lib/research/docs/search-index.d.ts +13 -0
- package/dist/lib/research/docs/search-index.js +176 -0
- package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
- package/dist/lib/research/docs/section-artifacts.js +75 -0
- package/dist/lib/research/docs/sections.d.ts +12 -0
- package/dist/lib/research/docs/sections.js +67 -0
- package/dist/lib/research/docs/source-map.d.ts +19 -0
- package/dist/lib/research/docs/source-map.js +87 -0
- package/dist/lib/research/docs/validate.d.ts +15 -0
- package/dist/lib/research/docs/validate.js +63 -0
- package/dist/lib/research/extract.d.ts +11 -0
- package/dist/lib/research/extract.js +83 -0
- package/dist/lib/research/fetcher.d.ts +29 -0
- package/dist/lib/research/fetcher.js +192 -0
- package/dist/lib/research/freshness.d.ts +21 -0
- package/dist/lib/research/freshness.js +82 -0
- package/dist/lib/research/markdown.d.ts +6 -0
- package/dist/lib/research/markdown.js +66 -0
- package/dist/lib/research/regression.d.ts +23 -0
- package/dist/lib/research/regression.js +93 -0
- package/dist/lib/research/resolve-target.d.ts +18 -0
- package/dist/lib/research/resolve-target.js +20 -0
- package/dist/lib/research/revalidate.d.ts +32 -0
- package/dist/lib/research/revalidate.js +152 -0
- package/dist/lib/research/schema.d.ts +47 -0
- package/dist/lib/research/schema.js +1 -0
- package/dist/lib/research/secret-scan.d.ts +5 -0
- package/dist/lib/research/secret-scan.js +37 -0
- package/dist/lib/research/secure-write.d.ts +16 -0
- package/dist/lib/research/secure-write.js +17 -0
- package/dist/lib/research/storage.d.ts +52 -0
- package/dist/lib/research/storage.js +182 -0
- package/dist/lib/research/store-roots.d.ts +29 -0
- package/dist/lib/research/store-roots.js +28 -0
- package/dist/lib/research/token-estimate.d.ts +5 -0
- package/dist/lib/research/token-estimate.js +7 -0
- package/dist/lib/research/url.d.ts +24 -0
- package/dist/lib/research/url.js +121 -0
- package/dist/sites/index.d.ts +4 -0
- package/dist/sites/index.js +28 -0
- package/dist/sites/salesforce/browser-search.d.ts +7 -0
- package/dist/sites/salesforce/browser-search.js +82 -0
- package/dist/sites/salesforce/coveo.d.ts +23 -0
- package/dist/sites/salesforce/coveo.js +161 -0
- package/dist/sites/salesforce/fetch-page.d.ts +6 -0
- package/dist/sites/salesforce/fetch-page.js +42 -0
- package/dist/sites/salesforce/index.d.ts +2 -0
- package/dist/sites/salesforce/index.js +16 -0
- package/dist/sites/salesforce/token.d.ts +26 -0
- package/dist/sites/salesforce/token.js +154 -0
- package/dist/sites/salesforce-developer/fetch-page.d.ts +3 -0
- package/dist/sites/salesforce-developer/fetch-page.js +18 -0
- package/dist/sites/salesforce-developer/index.d.ts +2 -0
- package/dist/sites/salesforce-developer/index.js +12 -0
- package/dist/sites/salesforce-doc-fetch.d.ts +14 -0
- package/dist/sites/salesforce-doc-fetch.js +321 -0
- package/dist/sites/types.d.ts +27 -0
- package/dist/sites/types.js +1 -0
- package/oclif.manifest.json +845 -0
- package/package.json +78 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
// Salesforce Help routes anonymous doc search through a public Coveo org. These are
|
|
5
|
+
// the published defaults for that org; they are not user credentials.
|
|
6
|
+
export const COVEO_DEFAULTS = {
|
|
7
|
+
organizationId: 'org62salesforce',
|
|
8
|
+
searchHub: 'HTCommunity',
|
|
9
|
+
endpointBase: 'https://help.salesforce.com/services/apexrest/coveo',
|
|
10
|
+
clientUri: 'https://platform.cloud.coveo.com',
|
|
11
|
+
};
|
|
12
|
+
const DEFAULT_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
const TOKEN_EXPIRY_SKEW_MS = 2 * 60 * 1000;
|
|
14
|
+
export function isTokenValid(expiresAtMs) {
|
|
15
|
+
if (!Number.isFinite(expiresAtMs))
|
|
16
|
+
return false;
|
|
17
|
+
return Date.now() + TOKEN_EXPIRY_SKEW_MS < expiresAtMs;
|
|
18
|
+
}
|
|
19
|
+
export function decodeJwtPayload(token) {
|
|
20
|
+
const parts = token.split('.');
|
|
21
|
+
if (parts.length < 2 || !parts[1])
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function getTokenExpiryMs(accessToken) {
|
|
31
|
+
const payload = decodeJwtPayload(accessToken);
|
|
32
|
+
const rawExp = payload?.exp ?? payload?.expires_at ?? payload?.expiresAt;
|
|
33
|
+
const expNumber = Number(rawExp);
|
|
34
|
+
if (rawExp === undefined || rawExp === null || !Number.isFinite(expNumber))
|
|
35
|
+
return null;
|
|
36
|
+
// JWT `exp` is seconds; values past year ~33658 are already milliseconds.
|
|
37
|
+
return expNumber > 1_000_000_000_000 ? expNumber : expNumber * 1000;
|
|
38
|
+
}
|
|
39
|
+
export function applyTokenExpiry(tokenInfo) {
|
|
40
|
+
if (!tokenInfo?.accessToken)
|
|
41
|
+
return tokenInfo;
|
|
42
|
+
const expiresAtMs = tokenInfo.expiresAtMs ||
|
|
43
|
+
getTokenExpiryMs(tokenInfo.accessToken) ||
|
|
44
|
+
Date.now() + DEFAULT_TOKEN_TTL_MS;
|
|
45
|
+
return { ...tokenInfo, expiresAtMs };
|
|
46
|
+
}
|
|
47
|
+
function stripAuraResponsePrefix(text) {
|
|
48
|
+
return text
|
|
49
|
+
.trim()
|
|
50
|
+
.replace(/^for\(;;\);/, '')
|
|
51
|
+
.replace(/^\)\]\}',?/, '')
|
|
52
|
+
.trim();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Parses the access token out of a Salesforce Aura `getToken` response body.
|
|
56
|
+
* Returns null when the payload is missing, errored, or lacks a token.
|
|
57
|
+
*/
|
|
58
|
+
export function parseAuraToken(rawResponseBody) {
|
|
59
|
+
let payload;
|
|
60
|
+
try {
|
|
61
|
+
payload = JSON.parse(stripAuraResponsePrefix(rawResponseBody));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const action = Array.isArray(payload?.actions) ? payload.actions[0] : null;
|
|
67
|
+
if (!action || (action.state && action.state !== 'SUCCESS'))
|
|
68
|
+
return null;
|
|
69
|
+
let returnValue = action.returnValue ?? null;
|
|
70
|
+
if (typeof returnValue?.returnValue === 'string')
|
|
71
|
+
returnValue = returnValue.returnValue;
|
|
72
|
+
const fields = readTokenFields(returnValue);
|
|
73
|
+
const accessToken = fields.accessToken;
|
|
74
|
+
if (!accessToken)
|
|
75
|
+
return null;
|
|
76
|
+
let filterer = fields.filterer;
|
|
77
|
+
const jwtFilterer = decodeJwtPayload(accessToken)?.filterer;
|
|
78
|
+
if (!filterer && typeof jwtFilterer === 'string')
|
|
79
|
+
filterer = jwtFilterer;
|
|
80
|
+
return applyTokenExpiry({
|
|
81
|
+
accessToken,
|
|
82
|
+
organizationId: fields.organizationId || COVEO_DEFAULTS.organizationId,
|
|
83
|
+
searchHub: fields.searchHub || COVEO_DEFAULTS.searchHub,
|
|
84
|
+
endpointBase: fields.endpointBase || COVEO_DEFAULTS.endpointBase,
|
|
85
|
+
clientUri: fields.clientUri || COVEO_DEFAULTS.clientUri,
|
|
86
|
+
filterer,
|
|
87
|
+
expiresAtMs: 0,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function readTokenFields(returnValue) {
|
|
91
|
+
if (typeof returnValue === 'string') {
|
|
92
|
+
const trimmed = returnValue.trim();
|
|
93
|
+
if (/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/.test(trimmed)) {
|
|
94
|
+
return { accessToken: trimmed, filterer: null };
|
|
95
|
+
}
|
|
96
|
+
if (trimmed.startsWith('{')) {
|
|
97
|
+
try {
|
|
98
|
+
return readTokenFields(JSON.parse(trimmed));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return { accessToken: null, filterer: null };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { accessToken: null, filterer: null };
|
|
105
|
+
}
|
|
106
|
+
if (returnValue && typeof returnValue === 'object') {
|
|
107
|
+
const v = returnValue;
|
|
108
|
+
return {
|
|
109
|
+
accessToken: v.accessToken || v.token || v.authToken || v.access_token || null,
|
|
110
|
+
organizationId: v.organizationId || v.organization || v.sfOrganizationId,
|
|
111
|
+
searchHub: v.searchHub,
|
|
112
|
+
endpointBase: v.platformUri || v.clientUri,
|
|
113
|
+
clientUri: v.clientUri,
|
|
114
|
+
filterer: v.filterer || v.filter || null,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { accessToken: null, filterer: null };
|
|
118
|
+
}
|
|
119
|
+
function tokenCachePath() {
|
|
120
|
+
const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
|
|
121
|
+
return join(base, 'bonsai', 'salesforce-coveo-token.json');
|
|
122
|
+
}
|
|
123
|
+
export function loadToken() {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(readFileSync(tokenCachePath(), 'utf8'));
|
|
126
|
+
if (!parsed.accessToken)
|
|
127
|
+
return null;
|
|
128
|
+
return applyTokenExpiry({
|
|
129
|
+
...COVEO_DEFAULTS,
|
|
130
|
+
filterer: null,
|
|
131
|
+
expiresAtMs: 0,
|
|
132
|
+
...parsed,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// ponytail: token persisted as plaintext on disk. It is a public anonymous Coveo
|
|
140
|
+
// search token (no user data, ~24h TTL), so the OS keychain isn't warranted. Move to
|
|
141
|
+
// keytar here if these modules ever hold a privileged token.
|
|
142
|
+
export function storeToken(tokenInfo) {
|
|
143
|
+
try {
|
|
144
|
+
const path = tokenCachePath();
|
|
145
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
146
|
+
writeFileSync(path, JSON.stringify(applyTokenExpiry(tokenInfo)), {
|
|
147
|
+
encoding: 'utf8',
|
|
148
|
+
mode: 0o600,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// A non-writable cache dir just means we re-mint via the browser next time.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { fetchSalesforceDoc } from '../salesforce-doc-fetch.js';
|
|
2
|
+
// Containers a developer doc renders into. doc-content-layout / doc-amf-reference are the
|
|
3
|
+
// web-component hosts for guide pages and API-reference (AMF) pages respectively.
|
|
4
|
+
const CONTENT_SELECTORS = [
|
|
5
|
+
'doc-content-layout',
|
|
6
|
+
'doc-amf-reference',
|
|
7
|
+
'.markdown-content',
|
|
8
|
+
'main article',
|
|
9
|
+
'article',
|
|
10
|
+
'main',
|
|
11
|
+
];
|
|
12
|
+
/** Fetches a developer.salesforce.com doc page (guides + API reference) via the shared LWR fetcher. */
|
|
13
|
+
export async function fetchDeveloperPage(url) {
|
|
14
|
+
return fetchSalesforceDoc(url, {
|
|
15
|
+
allowedHost: 'developer.salesforce.com',
|
|
16
|
+
contentSelectors: CONTENT_SELECTORS,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { fetchDeveloperPage } from './fetch-page.js';
|
|
2
|
+
// developer.salesforce.com docs (guides + API reference) render client-side inside web
|
|
3
|
+
// components, so content lives in shadow DOM — a fundamentally different extraction strategy
|
|
4
|
+
// than help.salesforce.com (see ../salesforce). No `search`: developer docs use a separate
|
|
5
|
+
// search backend, not Help's Coveo HTCommunity hub.
|
|
6
|
+
export const salesforceDeveloper = {
|
|
7
|
+
id: 'salesforce-developer',
|
|
8
|
+
name: 'Salesforce Developer',
|
|
9
|
+
domains: ['developer.salesforce.com'],
|
|
10
|
+
defaults: { rendered: true },
|
|
11
|
+
fetchPage: fetchDeveloperPage,
|
|
12
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SiteFetchResult } from './types.js';
|
|
2
|
+
/** True when the extracted text is an error/not-found/loading shell rather than real content. */
|
|
3
|
+
export declare function looksLikeSalesforceError(text: string): boolean;
|
|
4
|
+
export declare function stripBoilerplate(markdown: string): string;
|
|
5
|
+
export interface SalesforceDocOptions {
|
|
6
|
+
allowedHost: string;
|
|
7
|
+
contentSelectors: string[];
|
|
8
|
+
removeSelectors?: string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Renders a Salesforce LWR doc page, serializes its shadow-DOM content container, and converts
|
|
12
|
+
* it to Markdown. Validates the host before any network access.
|
|
13
|
+
*/
|
|
14
|
+
export declare function fetchSalesforceDoc(url: string, { allowedHost, contentSelectors, removeSelectors }: SalesforceDocOptions): Promise<SiteFetchResult>;
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { openCdpPage, waitForLoad, waitForContentReady } from '../lib/research/browser.js';
|
|
2
|
+
import { checkDnsSafety } from '../lib/research/fetcher.js';
|
|
3
|
+
import { htmlToMarkdown } from '../lib/research/markdown.js';
|
|
4
|
+
// Both help.salesforce.com and developer.salesforce.com are LWR sites that render doc content
|
|
5
|
+
// client-side inside web components (shadow DOM). document.documentElement.outerHTML captures
|
|
6
|
+
// only the "Loading…" shell, and Readability prunes the structured result to a single block —
|
|
7
|
+
// so this shared fetcher serializes the shadow DOM and converts the content container directly.
|
|
8
|
+
const TIMEOUT_MS = 45_000;
|
|
9
|
+
const SETTLE_MS = 1_500;
|
|
10
|
+
const MIN_CONTENT_CHARS = 100;
|
|
11
|
+
// Below this a capture is treated as a shell and retried (a fresh reload of the heavy page).
|
|
12
|
+
const SUBSTANTIAL_CHARS = 300;
|
|
13
|
+
const MAX_ATTEMPTS = 2;
|
|
14
|
+
const BODY_LIMIT_BYTES = 6 * 1024 * 1024;
|
|
15
|
+
const ACCEPT_CONSENT_EXPRESSION = "(() => { const b = document.querySelector('#onetrust-accept-btn-handler'); if (b) b.click(); })()";
|
|
16
|
+
const ERROR_PATTERNS = [
|
|
17
|
+
/page\s*(not\s*found|doesn'?t\s*exist|can'?t\s*be\s*found)/i,
|
|
18
|
+
/we\s*couldn'?t\s*find/i,
|
|
19
|
+
/we looked high and low/i,
|
|
20
|
+
/sorry to interrupt/i,
|
|
21
|
+
];
|
|
22
|
+
/** True when the extracted text is an error/not-found/loading shell rather than real content. */
|
|
23
|
+
export function looksLikeSalesforceError(text) {
|
|
24
|
+
return ERROR_PATTERNS.some((re) => re.test(text));
|
|
25
|
+
}
|
|
26
|
+
// Chrome present on every LWR doc page; stripped before serialization. Per-site noise (e.g.
|
|
27
|
+
// Help's article-feedback widget) is appended via SalesforceDocOptions.removeSelectors.
|
|
28
|
+
const BASE_REMOVE_SELECTORS = [
|
|
29
|
+
'nav',
|
|
30
|
+
'header',
|
|
31
|
+
'footer',
|
|
32
|
+
'aside',
|
|
33
|
+
'script',
|
|
34
|
+
'style',
|
|
35
|
+
'link',
|
|
36
|
+
'noscript',
|
|
37
|
+
'hgf-c360nav',
|
|
38
|
+
'hgf-c360contextnav',
|
|
39
|
+
'dx-scroll-manager',
|
|
40
|
+
'dx-skip-nav-link',
|
|
41
|
+
'dw-instrumentation',
|
|
42
|
+
'[role="navigation"]',
|
|
43
|
+
'[aria-label*="Table of Contents" i]',
|
|
44
|
+
'#onetrust-consent-sdk',
|
|
45
|
+
'#onetrust-banner-sdk',
|
|
46
|
+
];
|
|
47
|
+
function confidenceFor(length) {
|
|
48
|
+
if (length < 500)
|
|
49
|
+
return 'low';
|
|
50
|
+
if (length < 2000)
|
|
51
|
+
return 'medium';
|
|
52
|
+
return 'high';
|
|
53
|
+
}
|
|
54
|
+
// The "Did this article solve your issue?" feedback widget renders on every Salesforce doc
|
|
55
|
+
// page. Help wraps it in a removable component (see fetch-page.ts), but Developer renders it
|
|
56
|
+
// as bare divs, so we also strip its lines from the converted Markdown for both sites.
|
|
57
|
+
const BOILERPLATE_LINE = /(did this article solve your issue|let us know so we can improve|share your feedback)/i;
|
|
58
|
+
// A link with empty anchor text — e.g. an icon-only "home" logo link. It carries no readable
|
|
59
|
+
// content and renders as a bare `[](url)`. (Images `` are intentionally not matched.)
|
|
60
|
+
const EMPTY_LINK_LINE = /^\[\]\([^)]*\)$/;
|
|
61
|
+
export function stripBoilerplate(markdown) {
|
|
62
|
+
return markdown
|
|
63
|
+
.split('\n')
|
|
64
|
+
.filter((line) => !BOILERPLATE_LINE.test(line) && !EMPTY_LINK_LINE.test(line.trim()))
|
|
65
|
+
.join('\n')
|
|
66
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
67
|
+
.trim();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* In-page capture. Expands collapsed "Show" sections, inlines dx-code-block source (its code
|
|
71
|
+
* lives in a `code-block` attribute, not text), strips chrome, then picks the richest content
|
|
72
|
+
* container and serializes it INCLUDING open shadow roots. The expand/code steps are no-ops on
|
|
73
|
+
* Help pages (which lack those components) and load-bearing on Developer API-reference pages.
|
|
74
|
+
*/
|
|
75
|
+
function buildCaptureScript(contentSelectors, extraRemove) {
|
|
76
|
+
return `(async () => {
|
|
77
|
+
// Collects every descendant element across open shadow roots AND light DOM, including the
|
|
78
|
+
// root's own shadow root (so it works both document-wide and scoped to a single component).
|
|
79
|
+
const deepElements = (root) => {
|
|
80
|
+
const out = [];
|
|
81
|
+
const visit = (node) => {
|
|
82
|
+
if (node.shadowRoot) visit(node.shadowRoot);
|
|
83
|
+
for (const child of Array.from(node.children || [])) {
|
|
84
|
+
out.push(child);
|
|
85
|
+
visit(child);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
visit(root);
|
|
89
|
+
return out;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Collapse controls vary by page: <anypoint-button class="toggle-button">, <dx-button>, and
|
|
93
|
+
// older <* class="complex-toggle">. They all render as a button-like element whose entire
|
|
94
|
+
// label is just "Show"/"Hide", so match on that shape rather than a fixed tag list.
|
|
95
|
+
const isToggleControl = (el) =>
|
|
96
|
+
el instanceof HTMLElement &&
|
|
97
|
+
(/button$/.test(el.tagName.toLowerCase()) || el.classList.contains('complex-toggle'));
|
|
98
|
+
const toggleLabel = (el) => (el.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const toggles = deepElements(document).filter(
|
|
102
|
+
(el) => isToggleControl(el) && /^show$/i.test(toggleLabel(el))
|
|
103
|
+
);
|
|
104
|
+
for (const t of toggles) { t.click(); await new Promise((r) => setTimeout(r, 80)); }
|
|
105
|
+
// Once expanded the control flips its label to "Hide"; remove every Show/Hide toggle so the
|
|
106
|
+
// bare label doesn't leak into the captured Markdown. Generic across all article types.
|
|
107
|
+
for (const el of deepElements(document)) {
|
|
108
|
+
if (isToggleControl(el) && /^(show|hide)$/i.test(toggleLabel(el))) {
|
|
109
|
+
try { el.remove(); } catch (e) {}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
for (const node of deepElements(document)) {
|
|
116
|
+
if (node.tagName && node.tagName.toLowerCase() === 'dx-code-block') {
|
|
117
|
+
const code = (node.getAttribute('code-block') || node.textContent || '')
|
|
118
|
+
.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n').trim();
|
|
119
|
+
if (!code) continue;
|
|
120
|
+
const pre = document.createElement('pre');
|
|
121
|
+
const codeEl = document.createElement('code');
|
|
122
|
+
const lang = node.getAttribute('language');
|
|
123
|
+
if (lang) codeEl.className = 'language-' + lang;
|
|
124
|
+
codeEl.textContent = code;
|
|
125
|
+
pre.appendChild(codeEl);
|
|
126
|
+
node.replaceWith(pre);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (e) {}
|
|
130
|
+
|
|
131
|
+
// AMF API-reference pages render each object's fields as tall stacked blocks
|
|
132
|
+
// (property-shape-document: name / type / constraints / description). Collapse each
|
|
133
|
+
// api-type-document's fields into one compact Markdown table instead. Generic across all
|
|
134
|
+
// API-ref pages; a no-op where these components are absent (e.g. Help articles).
|
|
135
|
+
const firstIn = (host, sel) => deepElements(host).find((e) => { try { return e.matches && e.matches(sel); } catch (x) { return false; } });
|
|
136
|
+
const fieldText = (e) => e ? (e.textContent || '').replace(/\\s+/g, ' ').trim() : '';
|
|
137
|
+
const nearestTypeDoc = (el) => {
|
|
138
|
+
let n = el.parentElement || (el.getRootNode && el.getRootNode().host) || null;
|
|
139
|
+
while (n) {
|
|
140
|
+
if (n.tagName && n.tagName.toLowerCase() === 'api-type-document') return n;
|
|
141
|
+
n = n.parentElement || (n.getRootNode && n.getRootNode().host) || null;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
};
|
|
145
|
+
try {
|
|
146
|
+
const groups = new Map();
|
|
147
|
+
for (const shape of deepElements(document)) {
|
|
148
|
+
if (!shape.tagName || shape.tagName.toLowerCase() !== 'property-shape-document') continue;
|
|
149
|
+
const doc = nearestTypeDoc(shape);
|
|
150
|
+
if (!doc) continue;
|
|
151
|
+
if (!groups.has(doc)) groups.set(doc, []);
|
|
152
|
+
groups.get(doc).push(shape);
|
|
153
|
+
}
|
|
154
|
+
const buildRow = (cells, tag) => {
|
|
155
|
+
const tr = document.createElement('tr');
|
|
156
|
+
for (const value of cells) { const cell = document.createElement(tag); cell.textContent = value; tr.appendChild(cell); }
|
|
157
|
+
return tr;
|
|
158
|
+
};
|
|
159
|
+
for (const shapes of groups.values()) {
|
|
160
|
+
const rows = [];
|
|
161
|
+
for (const shape of shapes) {
|
|
162
|
+
const name = fieldText(firstIn(shape, '.property-title'));
|
|
163
|
+
if (!name) continue;
|
|
164
|
+
const type = fieldText(firstIn(shape, '.data-type'));
|
|
165
|
+
// The field description renders in an <arc-marked> markdown component (api-annotation-document
|
|
166
|
+
// is empty). The first one in document order is this field's own; constraints (Min/Max/Enum)
|
|
167
|
+
// come from .property-attribute. Combine them into the Description column.
|
|
168
|
+
const description = fieldText(firstIn(shape, 'arc-marked'));
|
|
169
|
+
const attrs = deepElements(shape)
|
|
170
|
+
.filter((e) => { try { return e.matches && e.matches('.property-attribute'); } catch (x) { return false; } })
|
|
171
|
+
.map(fieldText);
|
|
172
|
+
const desc = [description, attrs.join(', ')].filter(Boolean).join(' — ');
|
|
173
|
+
rows.push([name, type, desc]);
|
|
174
|
+
}
|
|
175
|
+
if (!rows.length) continue;
|
|
176
|
+
const table = document.createElement('table');
|
|
177
|
+
const thead = document.createElement('thead');
|
|
178
|
+
thead.appendChild(buildRow(['Field', 'Type', 'Description'], 'th'));
|
|
179
|
+
const tbody = document.createElement('tbody');
|
|
180
|
+
for (const row of rows) tbody.appendChild(buildRow(row, 'td'));
|
|
181
|
+
table.appendChild(thead);
|
|
182
|
+
table.appendChild(tbody);
|
|
183
|
+
const first = shapes[0];
|
|
184
|
+
if (first.parentNode) first.parentNode.insertBefore(table, first);
|
|
185
|
+
for (const shape of shapes) { try { shape.remove(); } catch (e) {} }
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {}
|
|
188
|
+
|
|
189
|
+
const REMOVE = ${JSON.stringify([...BASE_REMOVE_SELECTORS, ...extraRemove])};
|
|
190
|
+
for (const el of deepElements(document)) {
|
|
191
|
+
try { if (el.matches && REMOVE.some((s) => el.matches(s))) el.remove(); } catch (e) {}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Collapse api-console code-snippet language selectors to just the rendered example: the
|
|
195
|
+
// dropdown (cURL, HTTP, JavaScript-Fetch, …) is UI chrome, not content. Scoped to snippet
|
|
196
|
+
// widgets so unrelated toolbars elsewhere are untouched. Generic across all API-ref pages.
|
|
197
|
+
const inSnippetWidget = (el) => {
|
|
198
|
+
let n = el;
|
|
199
|
+
while (n) {
|
|
200
|
+
const t = n.tagName && n.tagName.toLowerCase();
|
|
201
|
+
if (t && /(code-snippet|http-snippet|example-render|example-document)/.test(t)) return true;
|
|
202
|
+
n = n.parentElement || (n.getRootNode && n.getRootNode().host) || null;
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
};
|
|
206
|
+
for (const el of deepElements(document)) {
|
|
207
|
+
const cls = (el.className || '').toString();
|
|
208
|
+
if (/(^|\\s)(toolbar|dropdown-item|dropdown-menu)(\\s|$)/.test(cls) && inSnippetWidget(el)) {
|
|
209
|
+
try { el.remove(); } catch (e) {}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const append = (src, target, depth) => {
|
|
214
|
+
if (depth > 30) return;
|
|
215
|
+
if (src.nodeType === 3) { target.appendChild(document.createTextNode(src.textContent || '')); return; }
|
|
216
|
+
if (src.nodeType !== 1) return;
|
|
217
|
+
const tag = src.tagName.toLowerCase();
|
|
218
|
+
if (tag === 'style' || tag === 'script' || tag === 'link' || tag === 'noscript' || tag === 'slot' || tag === 'title') return;
|
|
219
|
+
const clone = src.cloneNode(false);
|
|
220
|
+
target.appendChild(clone);
|
|
221
|
+
// Always descend into BOTH the shadow root and light children. API-reference components
|
|
222
|
+
// (property-shape-document, api-type-document, …) hold details in shadow DOM while also
|
|
223
|
+
// having light children; skipping either would cut content. <slot> is skipped above so
|
|
224
|
+
// slotted light content is not duplicated.
|
|
225
|
+
if (src.shadowRoot) {
|
|
226
|
+
for (const c of Array.from(src.shadowRoot.childNodes)) append(c, clone, depth + 1);
|
|
227
|
+
}
|
|
228
|
+
for (const c of Array.from(src.childNodes)) append(c, clone, depth + 1);
|
|
229
|
+
};
|
|
230
|
+
const serialize = (el) => { const root = document.createElement('div'); append(el, root, 0); return root.innerHTML; };
|
|
231
|
+
|
|
232
|
+
// Pick the content container by selector PRIORITY, not by max text: the selectors are ordered
|
|
233
|
+
// specific→general, so the first one with substantial content is the tight article/doc wrapper.
|
|
234
|
+
// Picking the largest match instead pulls in broader page chrome (nav tree, breadcrumb, chat
|
|
235
|
+
// widget) that surrounds the article. innerText is used because it pierces shadow roots
|
|
236
|
+
// (textContent does not), so a real container is measured rather than scoring 0.
|
|
237
|
+
const SELECTORS = ${JSON.stringify(contentSelectors)};
|
|
238
|
+
const MIN_PICK_CHARS = 200;
|
|
239
|
+
let container = null;
|
|
240
|
+
for (const sel of SELECTORS) {
|
|
241
|
+
let best = null, bestLen = 0;
|
|
242
|
+
for (const el of deepElements(document)) {
|
|
243
|
+
let matched; try { matched = el.matches && el.matches(sel); } catch (e) { matched = false; }
|
|
244
|
+
if (!matched) continue;
|
|
245
|
+
const len = (el.innerText || '').trim().length;
|
|
246
|
+
if (len > bestLen) { best = el; bestLen = len; }
|
|
247
|
+
}
|
|
248
|
+
if (best && bestLen >= MIN_PICK_CHARS) { container = best; break; }
|
|
249
|
+
}
|
|
250
|
+
container = container || document.body || document.documentElement;
|
|
251
|
+
const title = (document.querySelector('h1')?.textContent || document.title || '').trim();
|
|
252
|
+
return { html: serialize(container), title };
|
|
253
|
+
})()`;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Renders a Salesforce LWR doc page, serializes its shadow-DOM content container, and converts
|
|
257
|
+
* it to Markdown. Validates the host before any network access.
|
|
258
|
+
*/
|
|
259
|
+
export async function fetchSalesforceDoc(url, { allowedHost, contentSelectors, removeSelectors = [] }) {
|
|
260
|
+
let parsed;
|
|
261
|
+
try {
|
|
262
|
+
parsed = new URL(url);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
throw new Error(`Invalid Salesforce doc URL: ${url}`);
|
|
266
|
+
}
|
|
267
|
+
if (parsed.hostname.toLowerCase() !== allowedHost) {
|
|
268
|
+
throw new Error(`Refusing to fetch host "${parsed.hostname}" (expected ${allowedHost}).`);
|
|
269
|
+
}
|
|
270
|
+
await checkDnsSafety(parsed.hostname);
|
|
271
|
+
const captureScript = buildCaptureScript(contentSelectors, removeSelectors);
|
|
272
|
+
const page = await openCdpPage();
|
|
273
|
+
try {
|
|
274
|
+
let html = '';
|
|
275
|
+
let title = '';
|
|
276
|
+
let detailedMarkdown = '';
|
|
277
|
+
// The heavy api-console renders intermittently slowly; reload and retry if a capture comes
|
|
278
|
+
// back shell-thin rather than caching an empty page.
|
|
279
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
280
|
+
await page.client.send('Page.navigate', { url }, page.sessionId);
|
|
281
|
+
await waitForLoad(page.client, page.sessionId, TIMEOUT_MS, 0).catch(() => { });
|
|
282
|
+
await page.client
|
|
283
|
+
.send('Runtime.evaluate', { expression: ACCEPT_CONSENT_EXPRESSION }, page.sessionId)
|
|
284
|
+
.catch(() => { });
|
|
285
|
+
await waitForContentReady(page, contentSelectors, MIN_CONTENT_CHARS, TIMEOUT_MS);
|
|
286
|
+
await new Promise((r) => setTimeout(r, SETTLE_MS));
|
|
287
|
+
const result = await page.client.send('Runtime.evaluate', { expression: captureScript, awaitPromise: true, returnByValue: true }, page.sessionId);
|
|
288
|
+
const captured = result?.result?.value;
|
|
289
|
+
html = typeof captured?.html === 'string' ? captured.html : '';
|
|
290
|
+
title = captured?.title || '';
|
|
291
|
+
if (Buffer.byteLength(html) > BODY_LIMIT_BYTES) {
|
|
292
|
+
throw new Error(`Page exceeded body size limit (${Buffer.byteLength(html)} bytes).`);
|
|
293
|
+
}
|
|
294
|
+
detailedMarkdown = stripBoilerplate(htmlToMarkdown(html));
|
|
295
|
+
if (detailedMarkdown.length >= SUBSTANTIAL_CHARS)
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
if (detailedMarkdown.length < MIN_CONTENT_CHARS || looksLikeSalesforceError(detailedMarkdown)) {
|
|
299
|
+
throw new Error(`Salesforce returned no readable content for ${url} (page may be missing, gated, or still loading).`);
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
fetchResult: {
|
|
303
|
+
contentType: 'text/html',
|
|
304
|
+
etag: null,
|
|
305
|
+
lastModified: null,
|
|
306
|
+
finalUrl: url,
|
|
307
|
+
responseSize: Buffer.byteLength(html),
|
|
308
|
+
content: html,
|
|
309
|
+
},
|
|
310
|
+
extraction: {
|
|
311
|
+
title: title || url,
|
|
312
|
+
detailedMarkdown,
|
|
313
|
+
confidence: confidenceFor(detailedMarkdown.length),
|
|
314
|
+
qualityNotes: ['extracted from rendered shadow-DOM content container'],
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
await page.close();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ExtractionResult } from '../lib/research/extract.js';
|
|
2
|
+
export interface SiteFetchResult {
|
|
3
|
+
fetchResult: {
|
|
4
|
+
contentType: string | null;
|
|
5
|
+
etag: string | null;
|
|
6
|
+
lastModified: string | null;
|
|
7
|
+
finalUrl: string;
|
|
8
|
+
responseSize: number;
|
|
9
|
+
content: string;
|
|
10
|
+
};
|
|
11
|
+
extraction: ExtractionResult;
|
|
12
|
+
}
|
|
13
|
+
export interface SiteSearchResult {
|
|
14
|
+
url: string;
|
|
15
|
+
title: string;
|
|
16
|
+
snippet?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SiteModule {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
domains: string[];
|
|
22
|
+
defaults?: {
|
|
23
|
+
rendered?: boolean;
|
|
24
|
+
};
|
|
25
|
+
fetchPage?: (url: string) => Promise<SiteFetchResult>;
|
|
26
|
+
search?: (query: string) => Promise<SiteSearchResult[]>;
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|