@taurgis/bonsai 3.0.3 → 3.1.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/dist/commands/fetch.js +7 -1
- package/dist/lib/research/browser.js +15 -4
- package/dist/lib/research/docs/markdown-source.d.ts +7 -0
- package/dist/lib/research/docs/markdown-source.js +46 -20
- package/dist/lib/research/regression.js +4 -1
- package/dist/lib/research/revalidate.js +8 -3
- package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
- package/dist/sites/salesforce-developer/fetch-page.js +10 -1
- package/dist/sites/salesforce-developer/route-markdown.d.ts +23 -0
- package/dist/sites/salesforce-developer/route-markdown.js +142 -0
- package/dist/sites/salesforce-doc-fetch.js +10 -1
- package/dist/sites/types.d.ts +13 -0
- package/dist/sites/types.js +14 -1
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
package/dist/commands/fetch.js
CHANGED
|
@@ -15,6 +15,7 @@ import { capturePage } from '../lib/research/capture.js';
|
|
|
15
15
|
import { persistSectionArtifacts } from '../lib/research/docs/section-artifacts.js';
|
|
16
16
|
import { applyAutoTags } from '../lib/research/keywords.js';
|
|
17
17
|
import { detectSite } from '../sites/index.js';
|
|
18
|
+
import { applySiteFetchProvenance } from '../sites/types.js';
|
|
18
19
|
const CAPTURE_DEPS = {
|
|
19
20
|
fetchStatic: (url) => fetchStaticHtml(url),
|
|
20
21
|
fetchRendered: (url) => fetchRenderedHtml(url),
|
|
@@ -124,9 +125,11 @@ export default class FetchCommand extends BaseCommand {
|
|
|
124
125
|
let fetchResult;
|
|
125
126
|
let extraction;
|
|
126
127
|
let capture = null;
|
|
128
|
+
let siteFetch = null;
|
|
127
129
|
if (siteModule?.fetchPage) {
|
|
128
130
|
// Custom site modules own their fetch/extract strategy; the generic capture path is skipped.
|
|
129
|
-
|
|
131
|
+
siteFetch = await siteModule.fetchPage(normalizedUrl);
|
|
132
|
+
({ fetchResult, extraction } = siteFetch);
|
|
130
133
|
}
|
|
131
134
|
else {
|
|
132
135
|
capture = await capturePage(normalizedUrl, { forceRendered: useRendered }, CAPTURE_DEPS);
|
|
@@ -140,6 +143,9 @@ export default class FetchCommand extends BaseCommand {
|
|
|
140
143
|
if (capture) {
|
|
141
144
|
applyCaptureMetadata(artifact, capture);
|
|
142
145
|
}
|
|
146
|
+
else if (siteFetch?.captureMethod) {
|
|
147
|
+
applySiteFetchProvenance(artifact.metadata, siteFetch);
|
|
148
|
+
}
|
|
143
149
|
else if (useRendered) {
|
|
144
150
|
artifact.metadata.capture_method = 'browser_fallback';
|
|
145
151
|
}
|
|
@@ -127,6 +127,9 @@ export function buildChromeArgs() {
|
|
|
127
127
|
...getChromeTlsCompatibilityArgs(),
|
|
128
128
|
];
|
|
129
129
|
}
|
|
130
|
+
// CI runners occasionally starve headless Chrome's startup (disk/CPU contention from concurrent
|
|
131
|
+
// jobs), so this is generous enough to absorb that rather than a "typical" local startup time.
|
|
132
|
+
const CHROME_STARTUP_TIMEOUT_MS = 20_000;
|
|
130
133
|
async function spawnChrome(chromePath) {
|
|
131
134
|
const chromeProcess = spawn(chromePath, buildChromeArgs());
|
|
132
135
|
try {
|
|
@@ -135,7 +138,7 @@ async function spawnChrome(chromePath) {
|
|
|
135
138
|
const timeoutId = setTimeout(() => {
|
|
136
139
|
chromeProcess.kill('SIGKILL');
|
|
137
140
|
reject(new Error('Timed out waiting for Chrome to start.'));
|
|
138
|
-
},
|
|
141
|
+
}, CHROME_STARTUP_TIMEOUT_MS);
|
|
139
142
|
const onData = (data) => {
|
|
140
143
|
output += data.toString();
|
|
141
144
|
const match = output.match(/ws:\/\/127\.0\.0\.1:\d+\/devtools\/browser\/[a-zA-Z0-9-]+/);
|
|
@@ -363,9 +366,17 @@ export function describeNavigationFailure(errorText, url) {
|
|
|
363
366
|
}
|
|
364
367
|
// Chrome can emit these on first navigation in headless CI when the cert verifier reloads mid-run.
|
|
365
368
|
const TRANSIENT_NAV_ERROR_CODES = ['ERR_CERT_VERIFIER_CHANGED'];
|
|
366
|
-
|
|
369
|
+
// Resource contention in CI (concurrent jobs competing for CPU/disk) can make Chrome itself fail
|
|
370
|
+
// to come up in time, or die before its devtools websocket URL is printed. Neither reflects a
|
|
371
|
+
// problem with the target page, so it's worth a fresh attempt rather than failing the whole fetch.
|
|
372
|
+
const TRANSIENT_CHROME_STARTUP_MESSAGES = [
|
|
373
|
+
'Timed out waiting for Chrome to start.',
|
|
374
|
+
'Chrome exited prematurely',
|
|
375
|
+
];
|
|
376
|
+
function isTransientBrowserError(err) {
|
|
367
377
|
const message = err instanceof Error ? err.message : String(err);
|
|
368
|
-
return TRANSIENT_NAV_ERROR_CODES.some((code) => message.includes(code))
|
|
378
|
+
return (TRANSIENT_NAV_ERROR_CODES.some((code) => message.includes(code)) ||
|
|
379
|
+
TRANSIENT_CHROME_STARTUP_MESSAGES.some((text) => message.includes(text)));
|
|
369
380
|
}
|
|
370
381
|
async function fetchRenderedHtmlOnce(url, options = {}) {
|
|
371
382
|
const timeout = options.timeoutMs ?? 15_000;
|
|
@@ -432,7 +443,7 @@ export async function fetchRenderedHtml(url, options = {}) {
|
|
|
432
443
|
return await fetchRenderedHtmlOnce(url, options);
|
|
433
444
|
}
|
|
434
445
|
catch (err) {
|
|
435
|
-
if (attempt === maxAttempts || !
|
|
446
|
+
if (attempt === maxAttempts || !isTransientBrowserError(err))
|
|
436
447
|
throw err;
|
|
437
448
|
}
|
|
438
449
|
}
|
|
@@ -9,6 +9,13 @@ export declare function parseFrontmatter(md: string): ParsedFrontmatter;
|
|
|
9
9
|
* handler attributes, and javascript: URLs. Leaves ordinary Markdown and benign inline HTML intact.
|
|
10
10
|
*/
|
|
11
11
|
export declare function sanitizeSourceMarkdown(md: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Applies a cleanup transform to the parts of a document OUTSIDE fenced code blocks, passing the
|
|
14
|
+
* fences through verbatim. Every text-level cleanup in this pipeline must go through this guard:
|
|
15
|
+
* fenced code is quoted material, and "cleaning" it destroys the samples readers came for
|
|
16
|
+
* (a `<script>` line in an example, a bash `# comment`, a literal `javascript:` URL).
|
|
17
|
+
*/
|
|
18
|
+
export declare function outsideFences(md: string, transform: (segment: string) => string): string;
|
|
12
19
|
/**
|
|
13
20
|
* Builds an ExtractionResult from public Markdown/MDX source. Frontmatter is parsed for the title
|
|
14
21
|
* and the body is sanitized. The detailed Markdown is the source itself, preserving code fences,
|
|
@@ -98,28 +98,31 @@ function replaceMacro(_match, inner) {
|
|
|
98
98
|
function stripMdnHiddenSamples(md) {
|
|
99
99
|
return md.replace(/<pre\b[^>]*\bhidden\b[^>]*>[\s\S]*?<\/pre>/gi, '');
|
|
100
100
|
}
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Applies a cleanup transform to the parts of a document OUTSIDE fenced code blocks, passing the
|
|
103
|
+
* fences through verbatim. Every text-level cleanup in this pipeline must go through this guard:
|
|
104
|
+
* fenced code is quoted material, and "cleaning" it destroys the samples readers came for
|
|
105
|
+
* (a `<script>` line in an example, a bash `# comment`, a literal `javascript:` URL).
|
|
106
|
+
*/
|
|
107
|
+
export function outsideFences(md, transform) {
|
|
103
108
|
return md
|
|
104
109
|
.split(/(```[\s\S]*?```)/)
|
|
105
|
-
.map((segment, i) => (i % 2 === 1 ? segment : segment
|
|
110
|
+
.map((segment, i) => (i % 2 === 1 ? segment : transform(segment)))
|
|
106
111
|
.join('');
|
|
107
112
|
}
|
|
113
|
+
function cleanKumaScript(md) {
|
|
114
|
+
return outsideFences(md, (segment) => segment.replace(/\{\{(.+?)\}\}/g, replaceMacro));
|
|
115
|
+
}
|
|
108
116
|
// MDN (and occasionally other docs) author tables as raw HTML inside their Markdown source, which
|
|
109
117
|
// otherwise leaks verbatim into the artifact and wastes agent tokens. Turndown already emits clean
|
|
110
118
|
// GFM tables, so convert any literal <table> region to Markdown. Code fences are skipped so an HTML
|
|
111
119
|
// table shown as a *code example* (```html …<table>…) is preserved, not rewritten.
|
|
112
120
|
function convertHtmlTables(md) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// the inner </table>, leaving the outer wrapper's closing tags as inert residue (no attrs,
|
|
119
|
-
// so it can't trip the leak gate). Doc sources use flat tables; upgrade to a DOM walk over
|
|
120
|
-
// the whole segment via htmlToMarkdown if a nested-table source ever appears.
|
|
121
|
-
segment.replace(/<table\b[\s\S]*?<\/table>/gi, (table) => `\n\n${htmlToMarkdown(table).trim()}\n\n`))
|
|
122
|
-
.join('');
|
|
121
|
+
// ponytail: non-greedy per-table match. A <table> nested in a cell would stop the match at
|
|
122
|
+
// the inner </table>, leaving the outer wrapper's closing tags as inert residue (no attrs,
|
|
123
|
+
// so it can't trip the leak gate). Doc sources use flat tables; upgrade to a DOM walk over
|
|
124
|
+
// the whole segment via htmlToMarkdown if a nested-table source ever appears.
|
|
125
|
+
return outsideFences(md, (segment) => segment.replace(/<table\b[\s\S]*?<\/table>/gi, (table) => `\n\n${htmlToMarkdown(table).trim()}\n\n`));
|
|
123
126
|
}
|
|
124
127
|
const HEADING = /^(#{1,6})\s/;
|
|
125
128
|
function headingLevel(line) {
|
|
@@ -140,17 +143,37 @@ function dropEmptySections(md) {
|
|
|
140
143
|
} while (current !== prev);
|
|
141
144
|
return current;
|
|
142
145
|
}
|
|
146
|
+
const FENCE_DELIMITER = /^\s*```/;
|
|
147
|
+
// Marks each line that belongs to fenced code, delimiters included. Fenced lines are content,
|
|
148
|
+
// never headings: a bash `# comment` in an example must not be classified — and deleted — as an
|
|
149
|
+
// empty section.
|
|
150
|
+
function fencedLineMask(lines) {
|
|
151
|
+
let fenced = false;
|
|
152
|
+
return lines.map((line) => {
|
|
153
|
+
if (!FENCE_DELIMITER.test(line))
|
|
154
|
+
return fenced;
|
|
155
|
+
fenced = !fenced;
|
|
156
|
+
return true;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** Index of the first non-blank line after `start`, or lines.length. */
|
|
160
|
+
function nextContentLine(lines, start) {
|
|
161
|
+
let next = start + 1;
|
|
162
|
+
while (next < lines.length && lines[next].trim() === '')
|
|
163
|
+
next++;
|
|
164
|
+
return next;
|
|
165
|
+
}
|
|
143
166
|
function dropEmptySectionsOnce(md) {
|
|
144
167
|
const lines = md.split('\n');
|
|
168
|
+
const inFence = fencedLineMask(lines);
|
|
145
169
|
const keep = lines.map(() => true);
|
|
146
170
|
for (let i = 0; i < lines.length; i++) {
|
|
147
|
-
const level = headingLevel(lines[i]);
|
|
171
|
+
const level = inFence[i] ? 0 : headingLevel(lines[i]);
|
|
148
172
|
if (!level)
|
|
149
173
|
continue;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const nextLevel = headingLevel(lines[next]);
|
|
174
|
+
const next = nextContentLine(lines, i);
|
|
175
|
+
// A fenced line after the heading means the section has content (nextLevel 0 → not empty).
|
|
176
|
+
const nextLevel = next < lines.length && !inFence[next] ? headingLevel(lines[next]) : 0;
|
|
154
177
|
const sectionIsEmpty = next >= lines.length || (nextLevel > 0 && nextLevel <= level);
|
|
155
178
|
if (sectionIsEmpty)
|
|
156
179
|
for (let k = i; k < next; k++)
|
|
@@ -183,8 +206,11 @@ export function extractFromSource(md, sourceUrl) {
|
|
|
183
206
|
const deMacroed = /mdn\/content/.test(sourceUrl)
|
|
184
207
|
? cleanKumaScript(stripMdnHiddenSamples(body))
|
|
185
208
|
: body;
|
|
186
|
-
|
|
187
|
-
|
|
209
|
+
// Sanitization and link/section cleanup are fence-guarded: code samples legitimately contain
|
|
210
|
+
// <script>/<button>/on*=/javascript: and bare [](…) lines. sanitizePromptInjection deliberately
|
|
211
|
+
// is NOT guarded — injection text is just as live when the agent reads it inside a code block.
|
|
212
|
+
const tablesAsMarkdown = convertHtmlTables(outsideFences(deMacroed, sanitizeSourceMarkdown));
|
|
213
|
+
const cleaned = sanitizePromptInjection(dropEmptySections(outsideFences(tablesAsMarkdown, dropEmptyLinks))).trim();
|
|
188
214
|
const notes = [`captured from public Markdown/MDX source: ${sourceUrl}`];
|
|
189
215
|
return {
|
|
190
216
|
title: titleFrom(frontmatter, cleaned),
|
|
@@ -93,7 +93,10 @@ export function contentMetrics(markdown) {
|
|
|
93
93
|
return {
|
|
94
94
|
chars: markdown.length,
|
|
95
95
|
codeBlocks: Math.floor((markdown.match(/```/g) || []).length / 2),
|
|
96
|
-
|
|
96
|
+
// Separator rows come in every authored width and alignment (| --- |, | ------ |, | :---: |).
|
|
97
|
+
// Converted HTML always emits exactly ---, but route/source Markdown keeps the author's
|
|
98
|
+
// padding, so a width-sensitive count would misreport source captures as losing tables.
|
|
99
|
+
tables: (markdown.match(/^\|(?:\s*:?-{3,}:?\s*\|)+\s*$/gm) || []).length,
|
|
97
100
|
images: (markdown.match(/!\[[^\]]*\]\(/g) || []).length,
|
|
98
101
|
orderedSteps: (markdown.match(/^\d+\.\s+/gm) || []).length,
|
|
99
102
|
bullets: (markdown.match(/^[-*]\s+/gm) || []).length,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { getSiteModuleById } from '../../sites/index.js';
|
|
3
|
+
import { applySiteFetchProvenance } from '../../sites/types.js';
|
|
3
4
|
import { fetchStaticHtml } from './fetcher.js';
|
|
4
5
|
import { extractHtmlContent } from './extract.js';
|
|
5
6
|
import { writeArtifact } from './storage.js';
|
|
@@ -111,9 +112,13 @@ function preserveUserMetadata(source, target, rendered) {
|
|
|
111
112
|
// Builds a refreshed artifact from a fresh fetch, carries over user metadata, persists it, and
|
|
112
113
|
// returns the "refreshed" result. Shared by both refresh paths (conditional-request fallthrough
|
|
113
114
|
// and site-module re-fetch), which differ only in how fetchResult/extraction are obtained.
|
|
114
|
-
function persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options) {
|
|
115
|
+
function persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options, siteFetch) {
|
|
115
116
|
const refreshed = createArtifactFromFetch(meta.source_url, meta.normalized_url, meta.cache_key, fetchResult, extraction, meta.tier, options.ttlOverride || meta.ttl, currentTime, options.summaryLevel);
|
|
116
117
|
preserveUserMetadata(meta, refreshed, options.rendered);
|
|
118
|
+
// A site-module refresh reports how it actually captured this time; that overrides the
|
|
119
|
+
// carried-over provenance, so a withdrawn .md twin can't leave a stale source_doc_url behind.
|
|
120
|
+
if (siteFetch)
|
|
121
|
+
applySiteFetchProvenance(refreshed.metadata, siteFetch);
|
|
117
122
|
// Back-fill keyword tags when the carried-over set is empty (e.g. an artifact first cached before
|
|
118
123
|
// auto-tagging, or one originally stored without tags), so refreshing keeps it searchable.
|
|
119
124
|
applyAutoTags(refreshed);
|
|
@@ -162,8 +167,8 @@ export async function revalidateCache(dataDir, existing, currentTime, options) {
|
|
|
162
167
|
if (siteModule?.fetchPage) {
|
|
163
168
|
// Site modules use custom fetch strategies that don't speak HTTP conditional requests
|
|
164
169
|
// (no ETag/If-Modified-Since), so a full re-fetch is the only correct revalidation here.
|
|
165
|
-
const
|
|
166
|
-
return persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options);
|
|
170
|
+
const siteFetch = await siteModule.fetchPage(meta.source_url);
|
|
171
|
+
return persistRefreshedArtifact(dataDir, meta, siteFetch.fetchResult, siteFetch.extraction, currentTime, options, siteFetch);
|
|
167
172
|
}
|
|
168
173
|
const fetchResult = options.rendered
|
|
169
174
|
? await fetchRenderedHtml(meta.source_url)
|
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
import type { SiteFetchResult } from '../types.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Fetches a developer.salesforce.com doc page. Supported articles publish a Markdown twin at the
|
|
4
|
+
* derived `.md` route ("View as Markdown"), which is preferred: one static request instead of a
|
|
5
|
+
* browser render, and it returns the article's source. Pages without a validated twin (API
|
|
6
|
+
* reference, atlas.* books, landing pages) fall back to the shared LWR shadow-DOM fetcher.
|
|
7
|
+
*/
|
|
3
8
|
export declare function fetchDeveloperPage(url: string): Promise<SiteFetchResult>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fetchSalesforceDoc } from '../salesforce-doc-fetch.js';
|
|
2
|
+
import { fetchDeveloperRouteMarkdown } from './route-markdown.js';
|
|
2
3
|
// Containers a developer doc renders into. doc-content-layout / doc-amf-reference are the
|
|
3
4
|
// web-component hosts for guide pages and API-reference (AMF) pages respectively.
|
|
4
5
|
const CONTENT_SELECTORS = [
|
|
@@ -11,8 +12,16 @@ const CONTENT_SELECTORS = [
|
|
|
11
12
|
'article',
|
|
12
13
|
'main',
|
|
13
14
|
];
|
|
14
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Fetches a developer.salesforce.com doc page. Supported articles publish a Markdown twin at the
|
|
17
|
+
* derived `.md` route ("View as Markdown"), which is preferred: one static request instead of a
|
|
18
|
+
* browser render, and it returns the article's source. Pages without a validated twin (API
|
|
19
|
+
* reference, atlas.* books, landing pages) fall back to the shared LWR shadow-DOM fetcher.
|
|
20
|
+
*/
|
|
15
21
|
export async function fetchDeveloperPage(url) {
|
|
22
|
+
const fromMarkdownRoute = await fetchDeveloperRouteMarkdown(url);
|
|
23
|
+
if (fromMarkdownRoute)
|
|
24
|
+
return fromMarkdownRoute;
|
|
16
25
|
return fetchSalesforceDoc(url, {
|
|
17
26
|
allowedHost: 'developer.salesforce.com',
|
|
18
27
|
contentSelectors: CONTENT_SELECTORS,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type FetchResult } from '../../lib/research/fetcher.js';
|
|
2
|
+
import type { SiteFetchResult } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Derives the candidate `.md` route for a developer.salesforce.com doc URL, or null when the URL
|
|
5
|
+
* can't serve one (wrong host, not under /docs/, or an atlas.* book — verified to answer `.md`
|
|
6
|
+
* with an HTML shell, never Markdown). Query/hash are view state and are dropped.
|
|
7
|
+
*/
|
|
8
|
+
export declare function deriveMarkdownUrl(url: string): string | null;
|
|
9
|
+
/** Removes unresolvable `::include{…}` directive lines, reporting how many were dropped. */
|
|
10
|
+
export declare function stripIncludeDirectives(md: string): {
|
|
11
|
+
body: string;
|
|
12
|
+
dropped: number;
|
|
13
|
+
};
|
|
14
|
+
export type RouteMarkdownFetcher = (url: string, options?: {
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
}) => Promise<FetchResult>;
|
|
18
|
+
/**
|
|
19
|
+
* Probes the derived `.md` route for a developer doc URL. Returns a complete SiteFetchResult
|
|
20
|
+
* (with route_markdown provenance) when the twin validates, or null so the caller falls back to
|
|
21
|
+
* the browser capture. Never throws: any network/validation failure is just "no twin".
|
|
22
|
+
*/
|
|
23
|
+
export declare function fetchDeveloperRouteMarkdown(url: string, fetcher?: RouteMarkdownFetcher): Promise<SiteFetchResult | null>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { fetchText } from '../../lib/research/fetcher.js';
|
|
2
|
+
import { validateTextArtifact } from '../../lib/research/docs/validate.js';
|
|
3
|
+
import { extractFromSource } from '../../lib/research/docs/markdown-source.js';
|
|
4
|
+
// developer.salesforce.com now publishes a Markdown twin for supported doc articles (the
|
|
5
|
+
// "View as Markdown" link): /docs/<cloud>/<book>/guide/<article>[.html] also serves
|
|
6
|
+
// <article>.md with Content-Type text/markdown. The URL mapping is deterministic, so we derive
|
|
7
|
+
// the .md URL and probe it BEFORE the browser path — one static request replaces a ~45s
|
|
8
|
+
// shadow-DOM render and returns the article's actual source (code fences, admonitions intact).
|
|
9
|
+
//
|
|
10
|
+
// Coverage is rolling out per doc set, so the probe result is only a candidate (T-19/T-24):
|
|
11
|
+
// unsupported articles 404, and atlas.* books answer the .md route with an HTTP 200 HTML SPA
|
|
12
|
+
// shell — which makes the strict content-type + body validation below load-bearing, not
|
|
13
|
+
// defensive garnish.
|
|
14
|
+
const DEVELOPER_HOST = 'developer.salesforce.com';
|
|
15
|
+
const MARKDOWN_CONTENT_TYPE = 'text/markdown';
|
|
16
|
+
// Salesforce's WAF answers 403 unless the User-Agent LEADS with a recognized HTTP-tool token
|
|
17
|
+
// (curl/wget/python-requests pass; bare product names and browser UAs on a non-browser TLS stack
|
|
18
|
+
// are blocked). Lead with the compatibility token — the same convention as browsers' Mozilla/5.0
|
|
19
|
+
// prefix — and carry Bonsai's honest identity after it.
|
|
20
|
+
const PROBE_HEADERS = {
|
|
21
|
+
'user-agent': 'curl/8.7.1 bonsai-research-cache (+https://bonsai.rhino-inquisitor.com)',
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Derives the candidate `.md` route for a developer.salesforce.com doc URL, or null when the URL
|
|
25
|
+
* can't serve one (wrong host, not under /docs/, or an atlas.* book — verified to answer `.md`
|
|
26
|
+
* with an HTML shell, never Markdown). Query/hash are view state and are dropped.
|
|
27
|
+
*/
|
|
28
|
+
export function deriveMarkdownUrl(url) {
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = new URL(url);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (parsed.hostname.toLowerCase() !== DEVELOPER_HOST)
|
|
37
|
+
return null;
|
|
38
|
+
let path = parsed.pathname.replace(/\/+$/, '');
|
|
39
|
+
if (!path.startsWith('/docs/') || path.startsWith('/docs/atlas.'))
|
|
40
|
+
return null;
|
|
41
|
+
if (path.endsWith('.md'))
|
|
42
|
+
return `https://${DEVELOPER_HOST}${path}`;
|
|
43
|
+
path = path.replace(/\.html?$/i, '');
|
|
44
|
+
return `https://${DEVELOPER_HOST}${path}.md`;
|
|
45
|
+
}
|
|
46
|
+
// `::include{src="../../shared/….md"}` directives reference build-time snippet files that are
|
|
47
|
+
// not published on the .md route (they 404), so they'd survive as inert noise in the artifact.
|
|
48
|
+
const INCLUDE_DIRECTIVE = /^\s*::include\{[^}]*\}\s*$/;
|
|
49
|
+
/** Removes unresolvable `::include{…}` directive lines, reporting how many were dropped. */
|
|
50
|
+
export function stripIncludeDirectives(md) {
|
|
51
|
+
let dropped = 0;
|
|
52
|
+
const body = md
|
|
53
|
+
.split(/(```[\s\S]*?```)/)
|
|
54
|
+
.map((segment, i) => {
|
|
55
|
+
// Fenced code is untouched: an ::include shown as a syntax example is content.
|
|
56
|
+
if (i % 2 === 1)
|
|
57
|
+
return segment;
|
|
58
|
+
return segment
|
|
59
|
+
.split('\n')
|
|
60
|
+
.filter((line) => {
|
|
61
|
+
if (!INCLUDE_DIRECTIVE.test(line))
|
|
62
|
+
return true;
|
|
63
|
+
dropped++;
|
|
64
|
+
return false;
|
|
65
|
+
})
|
|
66
|
+
.join('\n');
|
|
67
|
+
})
|
|
68
|
+
.join('');
|
|
69
|
+
return { body, dropped };
|
|
70
|
+
}
|
|
71
|
+
// A probe response counts as the Markdown twin only when the server labels it text/markdown AND
|
|
72
|
+
// the redirect chain ended on https at the developer host AND the body validates as non-HTML,
|
|
73
|
+
// non-error text. Atlas shells fail the content-type check; non-2xx statuses already threw in
|
|
74
|
+
// the fetcher (the probe sends no conditional headers, so a 304 can't occur either).
|
|
75
|
+
function isMarkdownResponse(res) {
|
|
76
|
+
if (!res.content)
|
|
77
|
+
return false;
|
|
78
|
+
const mediaType = (res.contentType?.split(';')[0] ?? '').trim().toLowerCase();
|
|
79
|
+
if (mediaType !== MARKDOWN_CONTENT_TYPE)
|
|
80
|
+
return false;
|
|
81
|
+
let finalUrl;
|
|
82
|
+
try {
|
|
83
|
+
finalUrl = new URL(res.finalUrl);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
// Scheme matters, not just host: a redirect hop downgrading to plain http would let an on-path
|
|
89
|
+
// response be cached as the trusted twin.
|
|
90
|
+
if (finalUrl.protocol !== 'https:' || finalUrl.hostname.toLowerCase() !== DEVELOPER_HOST) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return validateTextArtifact(res.content).ok;
|
|
94
|
+
}
|
|
95
|
+
// A real twin is a small static file that answers fast; anything slower should mean "no twin,
|
|
96
|
+
// fall back now" instead of stacking the fetcher's default 10s on top of the browser render.
|
|
97
|
+
const PROBE_TIMEOUT_MS = 4_000;
|
|
98
|
+
// The browser path refuses to cache captures under this floor (MIN_CONTAINER_CHARS); a twin that
|
|
99
|
+
// thin is a rollout stub, and falling back gives the rendered page a chance to do better.
|
|
100
|
+
const MIN_TWIN_CHARS = 100;
|
|
101
|
+
/**
|
|
102
|
+
* Probes the derived `.md` route for a developer doc URL. Returns a complete SiteFetchResult
|
|
103
|
+
* (with route_markdown provenance) when the twin validates, or null so the caller falls back to
|
|
104
|
+
* the browser capture. Never throws: any network/validation failure is just "no twin".
|
|
105
|
+
*/
|
|
106
|
+
export async function fetchDeveloperRouteMarkdown(url, fetcher = fetchText) {
|
|
107
|
+
const candidate = deriveMarkdownUrl(url);
|
|
108
|
+
if (!candidate)
|
|
109
|
+
return null;
|
|
110
|
+
let res;
|
|
111
|
+
try {
|
|
112
|
+
res = await fetcher(candidate, { headers: PROBE_HEADERS, timeoutMs: PROBE_TIMEOUT_MS });
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
if (!isMarkdownResponse(res))
|
|
118
|
+
return null;
|
|
119
|
+
const { body, dropped } = stripIncludeDirectives(res.content);
|
|
120
|
+
const extraction = extractFromSource(body, res.finalUrl);
|
|
121
|
+
if (extraction.detailedMarkdown.length < MIN_TWIN_CHARS)
|
|
122
|
+
return null;
|
|
123
|
+
if (dropped > 0) {
|
|
124
|
+
extraction.qualityNotes = [
|
|
125
|
+
...(extraction.qualityNotes ?? []),
|
|
126
|
+
`${dropped} ::include directive(s) removed (shared snippets are not published on the .md route)`,
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
fetchResult: {
|
|
131
|
+
contentType: res.contentType,
|
|
132
|
+
etag: res.etag,
|
|
133
|
+
lastModified: res.lastModified,
|
|
134
|
+
finalUrl: res.finalUrl,
|
|
135
|
+
responseSize: res.responseSize,
|
|
136
|
+
content: res.content,
|
|
137
|
+
},
|
|
138
|
+
extraction,
|
|
139
|
+
captureMethod: 'route_markdown',
|
|
140
|
+
sourceDocUrl: res.finalUrl,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -64,13 +64,19 @@ function confidenceFor(length) {
|
|
|
64
64
|
// page. Help wraps it in a removable component (see fetch-page.ts), but Developer renders it
|
|
65
65
|
// as bare divs, so we also strip its lines from the converted Markdown for both sites.
|
|
66
66
|
const BOILERPLATE_LINE = /(did this article solve your issue|let us know so we can improve|share your feedback)/i;
|
|
67
|
+
// Toolbar of the "View as Markdown" feature on Developer articles. When the .md route probe
|
|
68
|
+
// succeeds the browser never runs, but rollout/network asymmetries can still land such a page
|
|
69
|
+
// here, and the toolbar labels serialize as bare paragraph lines.
|
|
70
|
+
const MARKDOWN_TOOLBAR_LINE = /^(copy as markdown|view as markdown|copy url to markdown)$/i;
|
|
67
71
|
// A link with empty anchor text — e.g. an icon-only "home" logo link. It carries no readable
|
|
68
72
|
// content and renders as a bare `[](url)`. (Images `` are intentionally not matched.)
|
|
69
73
|
const EMPTY_LINK_LINE = /^\[\]\([^)]*\)$/;
|
|
70
74
|
export function stripBoilerplate(markdown) {
|
|
71
75
|
return markdown
|
|
72
76
|
.split('\n')
|
|
73
|
-
.filter((line) => !BOILERPLATE_LINE.test(line) &&
|
|
77
|
+
.filter((line) => !BOILERPLATE_LINE.test(line) &&
|
|
78
|
+
!MARKDOWN_TOOLBAR_LINE.test(line.trim()) &&
|
|
79
|
+
!EMPTY_LINK_LINE.test(line.trim()))
|
|
74
80
|
.join('\n')
|
|
75
81
|
.replace(/\n{3,}/g, '\n\n')
|
|
76
82
|
.trim();
|
|
@@ -336,6 +342,9 @@ function buildSalesforceFetchResult(url, html, title, detailedMarkdown, qualityN
|
|
|
336
342
|
confidence: confidenceFor(detailedMarkdown.length),
|
|
337
343
|
qualityNotes,
|
|
338
344
|
},
|
|
345
|
+
// Rendered capture is this fetcher's only strategy; reporting it lets a refresh overwrite
|
|
346
|
+
// route_markdown provenance when an article's .md twin has since vanished.
|
|
347
|
+
captureMethod: 'browser_fallback',
|
|
339
348
|
};
|
|
340
349
|
}
|
|
341
350
|
/**
|
package/dist/sites/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtractionResult } from '../lib/research/extract.js';
|
|
2
|
+
import type { CaptureMethod } from '../lib/research/schema.js';
|
|
2
3
|
export interface SiteFetchResult {
|
|
3
4
|
fetchResult: {
|
|
4
5
|
contentType: string | null;
|
|
@@ -9,7 +10,19 @@ export interface SiteFetchResult {
|
|
|
9
10
|
content: string;
|
|
10
11
|
};
|
|
11
12
|
extraction: ExtractionResult;
|
|
13
|
+
captureMethod?: CaptureMethod;
|
|
14
|
+
sourceDocUrl?: string;
|
|
12
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Stamps a site module's reported provenance onto artifact metadata. Shared by the cache-miss
|
|
18
|
+
* fetch and revalidation so the two write paths can't drift. A result without captureMethod (a
|
|
19
|
+
* module predating provenance reporting) leaves the metadata untouched.
|
|
20
|
+
*/
|
|
21
|
+
export declare function applySiteFetchProvenance(metadata: {
|
|
22
|
+
capture_method: CaptureMethod | null;
|
|
23
|
+
source_doc_url: string | null;
|
|
24
|
+
source_urls: string[];
|
|
25
|
+
}, siteFetch: SiteFetchResult): void;
|
|
13
26
|
export interface SiteModule {
|
|
14
27
|
id: string;
|
|
15
28
|
name: string;
|
package/dist/sites/types.js
CHANGED
|
@@ -1 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Stamps a site module's reported provenance onto artifact metadata. Shared by the cache-miss
|
|
3
|
+
* fetch and revalidation so the two write paths can't drift. A result without captureMethod (a
|
|
4
|
+
* module predating provenance reporting) leaves the metadata untouched.
|
|
5
|
+
*/
|
|
6
|
+
export function applySiteFetchProvenance(metadata, siteFetch) {
|
|
7
|
+
if (!siteFetch.captureMethod)
|
|
8
|
+
return;
|
|
9
|
+
metadata.capture_method = siteFetch.captureMethod;
|
|
10
|
+
metadata.source_doc_url = siteFetch.sourceDocUrl ?? null;
|
|
11
|
+
if (siteFetch.sourceDocUrl && !metadata.source_urls.includes(siteFetch.sourceDocUrl)) {
|
|
12
|
+
metadata.source_urls.push(siteFetch.sourceDocUrl);
|
|
13
|
+
}
|
|
14
|
+
}
|
package/oclif.manifest.json
CHANGED