@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.
@@ -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
- ({ fetchResult, extraction } = await siteModule.fetchPage(normalizedUrl));
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
- }, 10000);
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
- function isTransientBrowserNavigationError(err) {
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 || !isTransientBrowserNavigationError(err))
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
- function cleanKumaScript(md) {
102
- // Never touch fenced code blocks.
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.replace(/\{\{(.+?)\}\}/g, replaceMacro)))
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
- return md
114
- .split(/(```[\s\S]*?```)/)
115
- .map((segment, i) => i % 2 === 1
116
- ? segment
117
- : // ponytail: non-greedy per-table match. A <table> nested in a cell would stop the match at
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
- let next = i + 1;
151
- while (next < lines.length && lines[next].trim() === '')
152
- next++;
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
- const tablesAsMarkdown = convertHtmlTables(sanitizeSourceMarkdown(deMacroed));
187
- const cleaned = sanitizePromptInjection(dropEmptySections(dropEmptyLinks(tablesAsMarkdown))).trim();
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
- tables: (markdown.match(/^\|(?:\s*---\s*\|)+\s*$/gm) || []).length,
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 { fetchResult, extraction } = await siteModule.fetchPage(meta.source_url);
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
- /** Fetches a developer.salesforce.com doc page (guides + API reference) via the shared LWR fetcher. */
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
- /** Fetches a developer.salesforce.com doc page (guides + API reference) via the shared LWR fetcher. */
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 `![](url)` 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) && !EMPTY_LINK_LINE.test(line.trim()))
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
  /**
@@ -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;
@@ -1 +1,14 @@
1
- export {};
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
+ }
@@ -843,5 +843,5 @@
843
843
  "stdoutIsPrimaryData": true
844
844
  }
845
845
  },
846
- "version": "3.0.3"
846
+ "version": "3.1.0"
847
847
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taurgis/bonsai",
3
- "version": "3.0.3",
3
+ "version": "3.1.0",
4
4
  "description": "Bonsai is a standalone local research cache CLI for AI agents",
5
5
  "repository": {
6
6
  "type": "git",