smart-web-mcp 0.44.0 → 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.
Files changed (159) hide show
  1. package/CHANGELOG.md +352 -1
  2. package/README.md +33 -566
  3. package/dist/assessment.d.ts +10 -3
  4. package/dist/assessment.js +21 -6
  5. package/dist/assessment.js.map +1 -1
  6. package/dist/body-export.d.ts +10 -0
  7. package/dist/body-export.js +28 -0
  8. package/dist/body-export.js.map +1 -0
  9. package/dist/browser-session.d.ts +32 -1
  10. package/dist/browser-session.js +373 -3
  11. package/dist/browser-session.js.map +1 -1
  12. package/dist/cli-fetch-options.d.ts +7 -0
  13. package/dist/cli-fetch-options.js +41 -0
  14. package/dist/cli-fetch-options.js.map +1 -0
  15. package/dist/cli.js +31 -12
  16. package/dist/cli.js.map +1 -1
  17. package/dist/comment-export.d.ts +10 -0
  18. package/dist/comment-export.js +39 -0
  19. package/dist/comment-export.js.map +1 -0
  20. package/dist/composition.d.ts +1 -22
  21. package/dist/composition.js +0 -16
  22. package/dist/composition.js.map +1 -1
  23. package/dist/extraction/budget.d.ts +3 -0
  24. package/dist/extraction/budget.js +88 -7
  25. package/dist/extraction/budget.js.map +1 -1
  26. package/dist/extraction/document.js +134 -9
  27. package/dist/extraction/document.js.map +1 -1
  28. package/dist/korea/coupang-filters.d.ts +5 -0
  29. package/dist/korea/coupang-filters.js +87 -0
  30. package/dist/korea/coupang-filters.js.map +1 -0
  31. package/dist/korea/coupang.d.ts +27 -6
  32. package/dist/korea/coupang.js +202 -82
  33. package/dist/korea/coupang.js.map +1 -1
  34. package/dist/korea/proxy-client.d.ts +1 -0
  35. package/dist/korea/proxy-client.js +6 -2
  36. package/dist/korea/proxy-client.js.map +1 -1
  37. package/dist/korea/smartvertical.d.ts +1 -0
  38. package/dist/korea/smartvertical.js +39 -25
  39. package/dist/korea/smartvertical.js.map +1 -1
  40. package/dist/lib.d.ts +4 -2
  41. package/dist/lib.js +2 -1
  42. package/dist/lib.js.map +1 -1
  43. package/dist/mcp-server.js +39 -15
  44. package/dist/mcp-server.js.map +1 -1
  45. package/dist/operator-browser-fixture.d.ts +7 -0
  46. package/dist/operator-browser-fixture.js +19 -0
  47. package/dist/operator-browser-fixture.js.map +1 -0
  48. package/dist/operator-canary-cli.d.ts +6 -0
  49. package/dist/operator-canary-cli.js +190 -0
  50. package/dist/operator-canary-cli.js.map +1 -0
  51. package/dist/operator-canary.d.ts +74 -0
  52. package/dist/operator-canary.js +135 -0
  53. package/dist/operator-canary.js.map +1 -0
  54. package/dist/operator-cdp.d.ts +15 -0
  55. package/dist/operator-cdp.js +195 -0
  56. package/dist/operator-cdp.js.map +1 -0
  57. package/dist/operator-frame.d.ts +3 -0
  58. package/dist/operator-frame.js +55 -0
  59. package/dist/operator-frame.js.map +1 -0
  60. package/dist/settings.d.ts +3 -0
  61. package/dist/settings.js +1 -0
  62. package/dist/settings.js.map +1 -1
  63. package/dist/shared.d.ts +47 -10
  64. package/dist/shared.js +15 -9
  65. package/dist/shared.js.map +1 -1
  66. package/dist/smartbrowser.d.ts +169 -0
  67. package/dist/smartbrowser.js +288 -0
  68. package/dist/smartbrowser.js.map +1 -0
  69. package/dist/smartcrawl.js +16 -21
  70. package/dist/smartcrawl.js.map +1 -1
  71. package/dist/smartfetch/academic-fallback.js +3 -5
  72. package/dist/smartfetch/academic-fallback.js.map +1 -1
  73. package/dist/smartfetch/archive-fallback.js +2 -5
  74. package/dist/smartfetch/archive-fallback.js.map +1 -1
  75. package/dist/smartfetch/assets.d.ts +1 -1
  76. package/dist/smartfetch/assets.js +28 -1
  77. package/dist/smartfetch/assets.js.map +1 -1
  78. package/dist/smartfetch/jina-reader.d.ts +1 -0
  79. package/dist/smartfetch/jina-reader.js +17 -3
  80. package/dist/smartfetch/jina-reader.js.map +1 -1
  81. package/dist/smartfetch/pipeline.js +15 -25
  82. package/dist/smartfetch/pipeline.js.map +1 -1
  83. package/dist/smartfetch/provider-policy.d.ts +3 -0
  84. package/dist/smartfetch/provider-policy.js +45 -2
  85. package/dist/smartfetch/provider-policy.js.map +1 -1
  86. package/dist/smartfetch/provider-types.d.ts +8 -0
  87. package/dist/smartfetch/providers/article.js +76 -10
  88. package/dist/smartfetch/providers/article.js.map +1 -1
  89. package/dist/smartfetch/providers/blind-post.d.ts +2 -0
  90. package/dist/smartfetch/providers/blind-post.js +167 -0
  91. package/dist/smartfetch/providers/blind-post.js.map +1 -0
  92. package/dist/smartfetch/providers/clien.d.ts +2 -0
  93. package/dist/smartfetch/providers/clien.js +157 -0
  94. package/dist/smartfetch/providers/clien.js.map +1 -0
  95. package/dist/smartfetch/providers/commerce.d.ts +15 -0
  96. package/dist/smartfetch/providers/commerce.js +123 -7
  97. package/dist/smartfetch/providers/commerce.js.map +1 -1
  98. package/dist/smartfetch/providers/dcinside.js +313 -20
  99. package/dist/smartfetch/providers/dcinside.js.map +1 -1
  100. package/dist/smartfetch/providers/hackernews-html.d.ts +21 -0
  101. package/dist/smartfetch/providers/hackernews-html.js +148 -0
  102. package/dist/smartfetch/providers/hackernews-html.js.map +1 -0
  103. package/dist/smartfetch/providers/hackernews.js +76 -14
  104. package/dist/smartfetch/providers/hackernews.js.map +1 -1
  105. package/dist/smartfetch/providers/index.js +7 -1
  106. package/dist/smartfetch/providers/index.js.map +1 -1
  107. package/dist/smartfetch/providers/linkedin.js +2 -1
  108. package/dist/smartfetch/providers/linkedin.js.map +1 -1
  109. package/dist/smartfetch/providers/naver-blog.d.ts +2 -0
  110. package/dist/smartfetch/providers/naver-blog.js +87 -45
  111. package/dist/smartfetch/providers/naver-blog.js.map +1 -1
  112. package/dist/smartfetch/providers/naver-cafe-native.d.ts +7 -0
  113. package/dist/smartfetch/providers/naver-cafe-native.js +188 -0
  114. package/dist/smartfetch/providers/naver-cafe-native.js.map +1 -0
  115. package/dist/smartfetch/providers/naver-cafe.js +165 -4
  116. package/dist/smartfetch/providers/naver-cafe.js.map +1 -1
  117. package/dist/smartfetch/providers/naver-map-search.js +1 -1
  118. package/dist/smartfetch/providers/naver-map-search.js.map +1 -1
  119. package/dist/smartfetch/providers/ppomppu.d.ts +2 -0
  120. package/dist/smartfetch/providers/ppomppu.js +289 -0
  121. package/dist/smartfetch/providers/ppomppu.js.map +1 -0
  122. package/dist/smartfetch/providers/reddit.js +24 -3
  123. package/dist/smartfetch/providers/reddit.js.map +1 -1
  124. package/dist/smartfetch/providers/threads.js +1 -1
  125. package/dist/smartfetch/providers/threads.js.map +1 -1
  126. package/dist/smartfetch/providers/wikipedia.js +138 -0
  127. package/dist/smartfetch/providers/wikipedia.js.map +1 -1
  128. package/dist/smartfetch.d.ts +11 -2
  129. package/dist/smartfetch.js +369 -78
  130. package/dist/smartfetch.js.map +1 -1
  131. package/dist/smartsearch-brave.d.ts +2 -0
  132. package/dist/smartsearch-brave.js +43 -0
  133. package/dist/smartsearch-brave.js.map +1 -0
  134. package/dist/smartsearch-browser.d.ts +5 -0
  135. package/dist/smartsearch-browser.js +116 -0
  136. package/dist/smartsearch-browser.js.map +1 -0
  137. package/dist/smartsearch-ddg.d.ts +2 -0
  138. package/dist/smartsearch-ddg.js +46 -0
  139. package/dist/smartsearch-ddg.js.map +1 -0
  140. package/dist/smartsearch-node.d.ts +6 -0
  141. package/dist/smartsearch-node.js +21 -0
  142. package/dist/smartsearch-node.js.map +1 -0
  143. package/dist/smartsearch-postgresql.d.ts +7 -0
  144. package/dist/smartsearch-postgresql.js +44 -0
  145. package/dist/smartsearch-postgresql.js.map +1 -0
  146. package/dist/smartsearch-python.d.ts +8 -0
  147. package/dist/smartsearch-python.js +87 -0
  148. package/dist/smartsearch-python.js.map +1 -0
  149. package/dist/smartsearch-quality.d.ts +6 -0
  150. package/dist/smartsearch-quality.js +47 -0
  151. package/dist/smartsearch-quality.js.map +1 -0
  152. package/dist/smartsearch-youtube.d.ts +7 -0
  153. package/dist/smartsearch-youtube.js +76 -0
  154. package/dist/smartsearch-youtube.js.map +1 -0
  155. package/dist/smartsearch.d.ts +3 -0
  156. package/dist/smartsearch.js +617 -132
  157. package/dist/smartsearch.js.map +1 -1
  158. package/package.json +8 -5
  159. package/python/requirements-undetected-chromedriver.txt +2 -2
@@ -1,8 +1,8 @@
1
1
  import { absolutizeUrl, assetFromUrl, asString, cleanLinks, dedupeUrls, configFlag, extractAnchorHrefs, extractUrls, inferTarget, needsBotChallengeBrowserUpgrade, needsDynamicCrawl, needsSpaBrowserFallback, resolveValidatedUrl, validateOutboundUrl, } from "./shared.js";
2
- import { createAssessment } from "./assessment.js";
2
+ import { createAssessment, hasTerminalSafetyBlock } from "./assessment.js";
3
3
  import { runBrowserSession, resolveProfileDir } from "./browser-session.js";
4
4
  import { projectSmartfetchOutput, resolveSmartfetchBudgetPolicy } from "./extraction/budget.js";
5
- import { configText } from "./settings.js";
5
+ import { configText, configValue } from "./settings.js";
6
6
  import { browserFetchHeaders, directFetchHeaders, impitFetchHeaders, shouldUseFastWait } from "./site-hints.js";
7
7
  import { getImpitCache, getImpitLoader, setImpitCache } from "./smartfetch-internals.js";
8
8
  import { planSmartfetchAcquire } from "./smartfetch/acquisition-plan.js";
@@ -14,6 +14,10 @@ import { maybeUseJinaReader } from "./smartfetch/jina-reader.js";
14
14
  import { runCoupang } from "./korea/coupang.js";
15
15
  import { assessSmartfetchOutput, buildAcquireStage, buildNormalizeStage, buildResolveStage } from "./smartfetch/pipeline.js";
16
16
  import { resolveSmartfetchProvider } from "./smartfetch/providers/index.js";
17
+ import { naverBlogMobilePostUrl } from "./smartfetch/providers/naver-blog.js";
18
+ import { NAVER_CAFE_READY_SELECTOR, naverCafeMobilePostUrl } from "./smartfetch/providers/naver-cafe-native.js";
19
+ import { exportComments } from "./comment-export.js";
20
+ import { exportBody } from "./body-export.js";
17
21
  const FAILURE_CACHE_TTL_MS = 30_000;
18
22
  const IMPIT_MAX_REDIRECTS = 5;
19
23
  const TEXT_RENDER_LIMITS = {
@@ -26,14 +30,42 @@ const TEXT_RENDER_LIMITS = {
26
30
  handoffLinks: 8,
27
31
  assets: 10,
28
32
  };
29
- /** Inflight coalescing: deduplicate concurrent smartfetch calls to the same URL. */
30
- const inflightSmartfetch = new Map();
31
33
  function isRedirect(status) {
32
34
  return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
33
35
  }
34
36
  function allowPrivateHosts() {
35
37
  return configFlag("network.allowPrivateHosts");
36
38
  }
39
+ function nativeNaverTarget(url) {
40
+ if (naverBlogMobilePostUrl(url))
41
+ return "naver_blog";
42
+ if (naverCafeMobilePostUrl(url))
43
+ return "naver_cafe_post";
44
+ return undefined;
45
+ }
46
+ function operatorRecoveryReason(result) {
47
+ if (!configText("fetch.browserCdpUrl") || result.ok || hasTerminalSafetyBlock([result.error]))
48
+ return "";
49
+ // An explicit cooldown is not an invitation to retry in another client.
50
+ if (result.http_status === 429 || result.retry_after)
51
+ return "";
52
+ if (result.http_status === 403 || (result.http_status && result.http_status >= 500)) {
53
+ return `direct HTTP ${result.http_status} — continued in approved operator browser`;
54
+ }
55
+ if (["redirect_resolution_failed", "plain_fetch_exception", "impit_fetch_exception", "direct_acquisition_timeout"].includes(result.error?.code || "")) {
56
+ return "direct transport failed — continued in approved operator browser";
57
+ }
58
+ return "";
59
+ }
60
+ function preflightCooldown(result, method) {
61
+ if (result.http_status !== 429 && !result.retry_after)
62
+ return undefined;
63
+ return {
64
+ ok: false, method, content: "", links: [], final_url: result.url,
65
+ http_status: result.http_status, retry_after: result.retry_after,
66
+ error: { category: "block", code: "direct_retry_after", message: `status=${result.http_status}${result.retry_after ? `; Retry-After=${result.retry_after}` : ""}` },
67
+ };
68
+ }
37
69
  function compactText(value, maxChars) {
38
70
  const text = String(value || "").trim();
39
71
  if (text.length <= maxChars)
@@ -108,11 +140,36 @@ function redirectUrl(url, headers, body) {
108
140
  }
109
141
  return "";
110
142
  }
111
- async function runNativeFetch(url, timeoutMs) {
143
+ class FetchDeadlineError extends Error {
144
+ constructor() { super("The original smartfetch deadline expired"); }
145
+ }
146
+ function remainingFetchTime(deadlineAt) {
147
+ const remaining = deadlineAt - Date.now();
148
+ if (remaining <= 0)
149
+ throw new FetchDeadlineError();
150
+ return remaining;
151
+ }
152
+ async function withinFetchDeadline(deadlineAt, work) {
153
+ const controller = new AbortController();
154
+ const remaining = remainingFetchTime(deadlineAt);
155
+ let timer;
156
+ const expiry = new Promise((_resolve, reject) => {
157
+ timer = setTimeout(() => { controller.abort(); reject(new FetchDeadlineError()); }, remaining);
158
+ });
159
+ try {
160
+ return await Promise.race([work(controller.signal), expiry]);
161
+ }
162
+ finally {
163
+ clearTimeout(timer);
164
+ controller.abort();
165
+ }
166
+ }
167
+ async function runNativeFetch(url, deadlineAt, signal) {
112
168
  const headers = directFetchHeaders(url);
113
169
  const resolved = await resolveValidatedUrl(url, {
114
170
  allowPrivateHosts: allowPrivateHosts(),
115
- timeoutMs,
171
+ timeoutMs: remainingFetchTime(deadlineAt),
172
+ signal,
116
173
  headers,
117
174
  });
118
175
  if (!resolved.ok) {
@@ -128,18 +185,26 @@ async function runNativeFetch(url, timeoutMs) {
128
185
  },
129
186
  };
130
187
  }
188
+ const cooldown = preflightCooldown(resolved, "plain_fetch");
189
+ if (cooldown)
190
+ return cooldown;
131
191
  const controller = new AbortController();
132
- const timer = setTimeout(() => controller.abort(), timeoutMs);
192
+ const timer = setTimeout(() => controller.abort(), remainingFetchTime(deadlineAt));
133
193
  try {
134
- const response = await fetch(resolved.url, { signal: controller.signal, headers, redirect: "error" });
135
- const body = await response.text();
194
+ const requestSignal = AbortSignal.any([signal, controller.signal]);
195
+ requestSignal.throwIfAborted();
196
+ const response = await fetch(resolved.url, { signal: requestSignal, headers, redirect: "error" });
136
197
  if (!response.ok) {
198
+ // Cleanup must not delay publishing an observed terminal status.
199
+ void response.body?.cancel().catch(() => { });
137
200
  return {
138
201
  ok: false,
139
202
  method: "plain_fetch",
140
203
  content: "",
141
204
  links: [],
142
205
  final_url: resolved.url,
206
+ http_status: response.status,
207
+ retry_after: header(response.headers, "retry-after"),
143
208
  error: {
144
209
  category: response.status === 403 || response.status === 429 ? "block" : "unavailable",
145
210
  code: "plain_fetch_failed",
@@ -147,6 +212,8 @@ async function runNativeFetch(url, timeoutMs) {
147
212
  },
148
213
  };
149
214
  }
215
+ const body = await response.text();
216
+ remainingFetchTime(deadlineAt);
150
217
  const links = [...extractAnchorHrefs(body), ...extractUrls(body)].map((item) => absolutizeUrl(item, resolved.url) || item).filter(Boolean);
151
218
  return { ok: true, method: "plain_fetch", content: body, links: dedupeUrls(links), final_url: resolved.url };
152
219
  }
@@ -169,14 +236,17 @@ async function runNativeFetch(url, timeoutMs) {
169
236
  clearTimeout(timer);
170
237
  }
171
238
  }
172
- async function runImpitFetch(url, timeoutMs) {
239
+ async function runImpitFetch(url, deadlineAt, signal) {
173
240
  const Impit = await getImpit();
241
+ signal.throwIfAborted();
242
+ remainingFetchTime(deadlineAt);
174
243
  if (!Impit)
175
- return runNativeFetch(url, timeoutMs);
244
+ return runNativeFetch(url, deadlineAt, signal);
176
245
  const headers = impitFetchHeaders(url);
177
246
  const resolved = await resolveValidatedUrl(url, {
178
247
  allowPrivateHosts: allowPrivateHosts(),
179
- timeoutMs,
248
+ timeoutMs: remainingFetchTime(deadlineAt),
249
+ signal,
180
250
  headers,
181
251
  });
182
252
  if (!resolved.ok) {
@@ -192,12 +262,19 @@ async function runImpitFetch(url, timeoutMs) {
192
262
  },
193
263
  };
194
264
  }
265
+ const cooldown = preflightCooldown(resolved, "impit_fetch");
266
+ if (cooldown)
267
+ return cooldown;
195
268
  let currentUrl = resolved.url;
196
269
  try {
197
- const client = new Impit({ browser: "chrome", timeout: timeoutMs });
270
+ const client = new Impit({ browser: "chrome", timeout: remainingFetchTime(deadlineAt) });
198
271
  for (let hop = 0; hop <= IMPIT_MAX_REDIRECTS; hop++) {
199
- const response = await client.fetch(currentUrl, { headers, redirect: "error" });
200
- const body = await response.text();
272
+ signal.throwIfAborted();
273
+ const response = await client.fetch(currentUrl, { headers, redirect: "error", signal, timeout: remainingFetchTime(deadlineAt) });
274
+ // Preserve status/cooldown even if an error response body is interrupted.
275
+ const body = response.ok || isRedirect(response.status) ? await response.text() : "";
276
+ if (response.ok || isRedirect(response.status))
277
+ remainingFetchTime(deadlineAt);
201
278
  if (response.ok) {
202
279
  const finalUrl = asString(response.url).trim() || currentUrl;
203
280
  const base = finalUrl || currentUrl;
@@ -211,12 +288,15 @@ async function runImpitFetch(url, timeoutMs) {
211
288
  };
212
289
  }
213
290
  if (!isRedirect(response.status)) {
291
+ void response.body?.cancel().catch(() => { });
214
292
  return {
215
293
  ok: false,
216
294
  method: "impit_fetch",
217
295
  content: "",
218
296
  links: [],
219
297
  final_url: asString(response.url).trim() || currentUrl,
298
+ http_status: response.status,
299
+ retry_after: header(response.headers, "retry-after"),
220
300
  error: {
221
301
  category: response.status === 403 || response.status === 429 ? "block" : "unavailable",
222
302
  code: "impit_fetch_failed",
@@ -316,6 +396,7 @@ async function runPlaywrightFetch(url, timeoutMs, target, profileDir, waitForSel
316
396
  const result = await runBrowserSession({
317
397
  url,
318
398
  timeoutMs,
399
+ ...(!profileDir && configText("fetch.browserCdpUrl") ? { cdpUrl: configText("fetch.browserCdpUrl") } : {}),
319
400
  allowPrivateHosts: allowPrivateHosts(),
320
401
  waitUntil: choosePlaywrightWaitUntil(target, url),
321
402
  extraHeaders,
@@ -462,6 +543,8 @@ const HEADROOM_BALANCED_MIN = 60_000;
462
543
  const PREFERRED_BALANCED_MIN = 30_000;
463
544
  const PREFERRED_FULL_MIN = 120_000;
464
545
  function asNonNegativeInteger(value) {
546
+ if (value === null || value === undefined)
547
+ return null;
465
548
  const parsed = Number(value);
466
549
  if (!Number.isFinite(parsed) || parsed < 0)
467
550
  return null;
@@ -510,6 +593,15 @@ export function resolveSmartfetchOutputBudget(input = {}) {
510
593
  reason: "context_mode used as backward-compatible alias (no numeric hint supplied)",
511
594
  };
512
595
  }
596
+ if (input.configuredOutputBudget !== undefined) {
597
+ const policy = resolveSmartfetchBudgetPolicy(input.configuredOutputBudget);
598
+ const preset = policy.name.replace(/^smartfetch\./, "");
599
+ return {
600
+ preset,
601
+ source: "settings",
602
+ reason: `fetch.outputBudget settings selected ${preset}`,
603
+ };
604
+ }
513
605
  return {
514
606
  preset: "compact",
515
607
  source: "default",
@@ -575,28 +667,32 @@ function isDocumentLikeAsset(asset) {
575
667
  return asset?.kind === "document" || asset?.kind === "archive" || asset?.kind === "attachment";
576
668
  }
577
669
  export async function runSmartfetch(options, runtime = {}) {
578
- // Inflight coalescing: deduplicate calls only when the URL and contextual
579
- // commerce queries are identical. Distinct product hints must not share a result.
580
- const coalesceKey = !options.download && !options.profile
581
- ? `${options.url}\u0000${JSON.stringify(options.referenceQueries || [])}`
582
- : "";
583
- if (coalesceKey) {
584
- const inflight = inflightSmartfetch.get(coalesceKey);
585
- if (inflight)
586
- return inflight;
670
+ const timeoutMs = Math.max(3000, Math.floor(Number(options.timeoutMs || 25000)));
671
+ const deadlineAt = Date.now() + timeoutMs;
672
+ const output = baseOutput(options.url, inferTarget(options.url, options.target || "auto"));
673
+ try {
674
+ return await runSmartfetchTask(options, runtime, deadlineAt, output);
587
675
  }
588
- const promise = runSmartfetchInner(options, runtime);
589
- if (coalesceKey) {
590
- inflightSmartfetch.set(coalesceKey, promise);
591
- promise.finally(() => { inflightSmartfetch.delete(coalesceKey); });
676
+ catch (error) {
677
+ if (!(error instanceof FetchDeadlineError))
678
+ throw error;
679
+ output.partial = true;
680
+ output.errors.push({ category: "timeout", code: "smartfetch_deadline_exceeded", message: error.message });
681
+ output.assessment = createAssessment({ confidence: "low", blocked_reason: error.message, signals: ["retrieval:deadline_exceeded"] });
682
+ if (!output.pipeline.acquire.attempt_map) {
683
+ output.pipeline.acquire = buildAcquireStage({ attempts: output.retrieval_method, active: { ok: false, method: output.retrieval_method.at(-1) || "none", content: "", links: [] }, usedBrowser: output.evidence.playwright_attempted, usedArchive: false });
684
+ }
685
+ output.pipeline.normalize.status = "partial";
686
+ output.pipeline.assess = output.assessment;
687
+ return attachResearchHandoff(output);
592
688
  }
593
- return promise;
594
689
  }
595
- async function runSmartfetchInner(options, runtime = {}) {
690
+ async function runSmartfetchTask(options, runtime, deadlineAt, output) {
691
+ // A URL is not a task identity: callers can have different acquisition,
692
+ // session, runtime and projection requirements. Keep each task independent.
596
693
  const requestedTarget = options.target || "auto";
597
- const target = inferTarget(options.url, requestedTarget);
598
- const timeoutMs = Math.max(3000, Math.floor(Number(options.timeoutMs || 25000)));
599
- const output = baseOutput(options.url, target);
694
+ const target = (requestedTarget === "auto" ? nativeNaverTarget(options.url) : undefined) || inferTarget(options.url, requestedTarget);
695
+ const remaining = () => remainingFetchTime(deadlineAt);
600
696
  output.pipeline.resolve = buildResolveStage({
601
697
  url: options.url,
602
698
  requestedTarget,
@@ -607,6 +703,7 @@ async function runSmartfetchInner(options, runtime = {}) {
607
703
  const profileName = options.profile || configText("fetch.browserProfile");
608
704
  const profileDir = profileName ? resolveProfileDir(profileName) : undefined;
609
705
  const validation = await validateOutboundUrl(options.url, { allowPrivateHosts: allowPrivateHosts() });
706
+ remaining();
610
707
  if (!validation.ok) {
611
708
  return attachResearchHandoff({
612
709
  ...output,
@@ -721,16 +818,27 @@ async function runSmartfetchInner(options, runtime = {}) {
721
818
  }
722
819
  }
723
820
  let active;
821
+ let expectedPostUrl = nativeNaverTarget(options.url) === target ? options.url : undefined;
724
822
  if (acquirePlan.lane === "browser") {
725
- active = await runPlaywrightFetchWithRuntime(options.url, timeoutMs, target, runtime, profileDir, options.wait_for);
823
+ const nativeUrl = target === "naver_blog" ? naverBlogMobilePostUrl(options.url)
824
+ : target === "naver_cafe_post" ? naverCafeMobilePostUrl(options.url) : null;
825
+ if (nativeUrl && nativeUrl !== options.url) {
826
+ effectiveReason += `; exact ${target === "naver_blog" ? "Naver Blog" : "Naver Cafe"} native mobile document`;
827
+ browserAttempt.reason = effectiveReason;
828
+ }
829
+ const nativeWait = nativeUrl && target === "naver_cafe_post"
830
+ ? NAVER_CAFE_READY_SELECTOR
831
+ : undefined;
832
+ active = await runPlaywrightFetchWithRuntime(nativeUrl || options.url, remaining(), target, runtime, profileDir, options.wait_for || nativeWait);
726
833
  browserAttempt.attempted = true;
727
834
  browserAttempt.ok = active.ok;
728
835
  browserAttempt.method = active.method;
729
836
  output.evidence.playwright_attempted = true;
730
- output.evidence.playwright_success = active.ok;
731
837
  output.retrieval_method.push(active.method);
732
838
  if (active.error)
733
839
  output.errors.push(active.error);
840
+ remaining();
841
+ output.evidence.playwright_success = active.ok;
734
842
  }
735
843
  else if (acquirePlan.lane === "seed") {
736
844
  active = {
@@ -752,65 +860,156 @@ async function runSmartfetchInner(options, runtime = {}) {
752
860
  output.retrieval_method.push(active.method);
753
861
  }
754
862
  else {
755
- active = await runImpitFetch(options.url, timeoutMs);
863
+ // Leave time for the approved browser when direct transport stalls. Both
864
+ // phases still share the caller's original deadline.
865
+ const directDeadline = configText("fetch.browserCdpUrl") && !profileDir
866
+ ? Math.min(deadlineAt, Date.now() + Math.max(1, Math.floor(remaining() * 0.4)))
867
+ : deadlineAt;
868
+ try {
869
+ active = await withinFetchDeadline(directDeadline, signal => (runtime.runImpitFetch || runImpitFetch)(options.url, directDeadline, signal));
870
+ }
871
+ catch (error) {
872
+ if (!(error instanceof FetchDeadlineError))
873
+ throw error;
874
+ active = { ok: false, method: "direct_fetch", content: "", links: [], error: { category: "timeout", code: "direct_acquisition_timeout", message: "Direct acquisition exhausted its share of the original fetch budget" } };
875
+ }
756
876
  directAttempt.attempted = true;
757
877
  directAttempt.ok = active.ok;
758
878
  directAttempt.method = active.method;
759
879
  output.evidence.impit_attempted = true;
760
- output.evidence.impit_success = active.ok;
761
880
  output.retrieval_method.push(active.method);
762
881
  if (active.error)
763
882
  output.errors.push(active.error);
883
+ remaining();
884
+ output.evidence.impit_success = active.ok;
764
885
  const redirectUrl = active.final_url && active.final_url !== options.url ? active.final_url : "";
765
886
  const redirectTarget = redirectUrl
766
- ? inferTarget(redirectUrl, "auto")
887
+ ? nativeNaverTarget(redirectUrl) || inferTarget(redirectUrl, "auto")
767
888
  : target;
768
889
  const redirectProvider = redirectUrl
769
890
  ? resolveSmartfetchProvider(redirectUrl, redirectTarget)
770
891
  : plannedProvider;
771
892
  const needsRedirectBrowserUpgrade = Boolean(redirectUrl)
772
893
  && (redirectProvider.capabilities.includes("requires_browser") || redirectProvider.capabilities.includes("interactive_surface"));
773
- if (needsRedirectBrowserUpgrade) {
774
- const browserResult = await runPlaywrightFetchWithRuntime(redirectUrl, timeoutMs, redirectTarget, runtime, profileDir, options.wait_for);
894
+ const mustNotRetry = hasTerminalSafetyBlock([active.error]) || active.http_status === 429 || Boolean(active.retry_after);
895
+ if (mustNotRetry) {
896
+ browserAttempt.reason = hasTerminalSafetyBlock([active.error])
897
+ ? "terminal URL safety rejection — no browser continuation"
898
+ : `direct HTTP ${active.http_status}: cooldown${active.retry_after ? `; Retry-After=${active.retry_after}` : ""} — no browser continuation`;
899
+ }
900
+ else if (needsRedirectBrowserUpgrade) {
901
+ if (!expectedPostUrl && (requestedTarget === "auto" || target === redirectTarget)
902
+ && (naverBlogMobilePostUrl(redirectUrl) || naverCafeMobilePostUrl(redirectUrl)))
903
+ expectedPostUrl = redirectUrl;
904
+ const browserResult = await runPlaywrightFetchWithRuntime(redirectUrl, remaining(), redirectTarget, runtime, profileDir, options.wait_for);
775
905
  browserAttempt.attempted = true;
776
906
  browserAttempt.ok = browserResult.ok;
777
907
  browserAttempt.method = browserResult.method;
778
908
  browserAttempt.reason = `redirected final provider:${redirectProvider.id} requires browser retrieval`;
779
909
  output.evidence.playwright_attempted = true;
780
- output.evidence.playwright_success = browserResult.ok;
781
910
  output.retrieval_method.push(browserResult.method);
782
911
  if (browserResult.error)
783
912
  output.errors.push(browserResult.error);
913
+ remaining();
914
+ output.evidence.playwright_success = browserResult.ok;
784
915
  effectiveLane = "browser";
785
916
  effectiveReason = `redirected final provider:${redirectProvider.id} requires browser retrieval`;
786
917
  redirectUpgrade = true;
787
- if (browserResult.ok)
918
+ if (browserResult.ok || expectedPostUrl || hasTerminalSafetyBlock([browserResult.error]))
788
919
  active = browserResult;
789
920
  }
790
- else if (needsBotChallengeBrowserUpgrade(active)) {
791
- // impit returned a Cloudflare/bot-challenge interstitial — try the browser lane.
792
- const browserResult = await runPlaywrightFetchWithRuntime(options.url, timeoutMs, target, runtime, profileDir, options.wait_for);
921
+ else if (operatorRecoveryReason(active) || needsBotChallengeBrowserUpgrade(active)) {
922
+ // Direct-client failure does not prove the approved browser cannot read it.
923
+ const browserResult = await runPlaywrightFetchWithRuntime(options.url, remaining(), target, runtime, profileDir, options.wait_for);
793
924
  browserAttempt.attempted = true;
794
925
  browserAttempt.ok = browserResult.ok;
795
926
  browserAttempt.method = browserResult.method;
796
- browserAttempt.reason = "impit returned bot-challenge interstitial — upgraded to browser";
927
+ browserAttempt.reason = operatorRecoveryReason(active) || "impit returned bot-challenge interstitial — upgraded to browser";
797
928
  output.evidence.playwright_attempted = true;
798
- output.evidence.playwright_success = browserResult.ok;
799
929
  output.retrieval_method.push(browserResult.method);
800
930
  if (browserResult.error)
801
931
  output.errors.push(browserResult.error);
932
+ remaining();
933
+ output.evidence.playwright_success = browserResult.ok;
802
934
  effectiveLane = "browser";
803
- effectiveReason = "impit returned bot-challenge interstitial — upgraded to browser";
935
+ effectiveReason = browserAttempt.reason;
804
936
  if (browserResult.ok)
805
937
  active = browserResult;
806
938
  }
807
939
  }
940
+ // Browser-resolved aliases must bind the observed article before continuing.
941
+ if (!expectedPostUrl && active.ok && active.final_url && !hasTerminalSafetyBlock([active.error]) && active.http_status !== 429 && !active.retry_after) {
942
+ const landedTarget = nativeNaverTarget(active.final_url);
943
+ if (landedTarget && (requestedTarget === "auto" || target === landedTarget)) {
944
+ expectedPostUrl = active.final_url;
945
+ if (!["m.blog.naver.com", "m.cafe.naver.com"].includes(new URL(active.final_url).hostname)) {
946
+ const browserResult = await runPlaywrightFetchWithRuntime(expectedPostUrl, remaining(), landedTarget, runtime, profileDir, options.wait_for);
947
+ browserAttempt.attempted = true;
948
+ browserAttempt.ok = browserResult.ok;
949
+ browserAttempt.method = browserResult.method;
950
+ browserAttempt.reason = "validated alias resolved to an exact Naver native article";
951
+ output.evidence.playwright_attempted = true;
952
+ output.retrieval_method.push(browserResult.method);
953
+ if (browserResult.error)
954
+ output.errors.push(browserResult.error);
955
+ remaining();
956
+ output.evidence.playwright_success = browserResult.ok;
957
+ effectiveLane = "browser";
958
+ effectiveReason = browserAttempt.reason;
959
+ redirectUpgrade = true;
960
+ active = browserResult;
961
+ }
962
+ }
963
+ }
808
964
  const archiveTarget = inferTarget(active.final_url || options.url, "auto");
809
965
  const archiveProvider = resolveSmartfetchProvider(active.final_url || options.url, archiveTarget);
966
+ const suppressRetries = hasTerminalSafetyBlock([active.error]) || active.http_status === 429 || Boolean(active.retry_after);
967
+ // A visible review-to-unlock prompt is a participation boundary, not a
968
+ // weak article to be sent through third-party relay/archive retries.
969
+ const lockedBlindPreview = ["teamblind.com", "www.teamblind.com"].includes(new URL(options.url).hostname)
970
+ && /^\/kr\/company\/[^/]+\/review\/[^/]+\/?$/.test(new URL(options.url).pathname)
971
+ && /class=["'][^"']*\breview_unlock\b/.test(active.content);
972
+ // NamuWiki's weak anonymous shell can consume the entire deadline in the
973
+ // reader relay even when the approved local browser can read the original.
974
+ // Keep the direct result and existing relay fallback if browser recovery fails.
975
+ if (effectiveLane === "direct" && target === "namuwiki_article" && configText("fetch.browserCdpUrl")
976
+ && !profileDir && !suppressRetries && needsDynamicCrawl(active) && !browserAttempt.attempted) {
977
+ const browserResult = await runPlaywrightFetchWithRuntime(options.url, remaining(), target, runtime, profileDir, options.wait_for);
978
+ const original = new URL(options.url);
979
+ let landed = null;
980
+ try {
981
+ if (browserResult.final_url)
982
+ landed = new URL(browserResult.final_url);
983
+ }
984
+ catch { /* Unusable browser identity. */ }
985
+ // A full browser DOM may contain unrelated CAPTCHA/help terms in chrome;
986
+ // use the isolated primary content when available, not a page-wide token.
987
+ const usable = browserResult.ok && ((browserResult.primary_content?.text?.length || 0) >= 200 || !needsDynamicCrawl(browserResult))
988
+ && landed?.origin === original.origin && landed.pathname === original.pathname && landed.search === original.search;
989
+ browserAttempt.attempted = true;
990
+ browserAttempt.ok = Boolean(usable);
991
+ browserAttempt.method = browserResult.method;
992
+ browserAttempt.reason = "weak NamuWiki direct document — approved browser before optional relays";
993
+ output.evidence.playwright_attempted = true;
994
+ output.retrieval_method.push(browserResult.method);
995
+ if (browserResult.error)
996
+ output.errors.push(browserResult.error);
997
+ remaining();
998
+ output.evidence.playwright_success = Boolean(usable);
999
+ if (usable) {
1000
+ active = browserResult;
1001
+ effectiveLane = "browser";
1002
+ effectiveReason = browserAttempt.reason;
1003
+ }
1004
+ }
810
1005
  const allowArchiveFallback = acquirePlan.allowArchiveFallback
1006
+ && !suppressRetries
1007
+ && !lockedBlindPreview
811
1008
  && effectiveLane === "direct"
812
1009
  && Boolean(archiveProvider.policy.allowArchiveFallback);
813
1010
  const allowJinaReader = acquirePlan.allowJinaReader
1011
+ && !suppressRetries
1012
+ && !lockedBlindPreview
814
1013
  && effectiveLane === "direct"
815
1014
  && Boolean(archiveProvider.policy.allowJinaReader);
816
1015
  if (!allowJinaReader) {
@@ -823,9 +1022,10 @@ async function runSmartfetchInner(options, runtime = {}) {
823
1022
  ? "archive fallback was not eligible for the effective provider/lane"
824
1023
  : "archive fallback was not allowed by plan, privacy profile, or settings";
825
1024
  }
826
- if (allowJinaReader) {
1025
+ if (allowJinaReader && effectiveLane === "direct") {
827
1026
  const beforeMethod = active.method;
828
- const relayed = await maybeUseJinaReader(active.final_url || options.url, active, timeoutMs);
1027
+ const relayed = await maybeUseJinaReader(active.final_url || options.url, active, remaining());
1028
+ remaining();
829
1029
  active = relayed.active;
830
1030
  output.errors.push(...relayed.errors);
831
1031
  const jinaErrors = relayed.errors.filter((error) => error.code.startsWith("jina_reader"));
@@ -844,9 +1044,10 @@ async function runSmartfetchInner(options, runtime = {}) {
844
1044
  if (jinaSucceeded)
845
1045
  output.retrieval_method.push(active.method);
846
1046
  }
847
- if (allowArchiveFallback) {
1047
+ if (allowArchiveFallback && effectiveLane === "direct") {
848
1048
  const beforeMethod = active.method;
849
- const archived = await maybeUseArchiveFallback(active.final_url || options.url, active, timeoutMs);
1049
+ const archived = await maybeUseArchiveFallback(active.final_url || options.url, active, remaining());
1050
+ remaining();
850
1051
  active = archived.active;
851
1052
  output.errors.push(...archived.errors);
852
1053
  const archiveErrors = archived.errors.filter((error) => /archive|wayback/i.test(error.code));
@@ -868,7 +1069,8 @@ async function runSmartfetchInner(options, runtime = {}) {
868
1069
  }
869
1070
  }
870
1071
  if (effectiveLane === "direct" && (needsDynamicCrawl(active) || needsSpaBrowserFallback(active))) {
871
- const scraped = await maybeUseAdaptiveScraper(active.final_url || options.url, active, timeoutMs);
1072
+ const scraped = await maybeUseAdaptiveScraper(active.final_url || options.url, active, remaining());
1073
+ remaining();
872
1074
  output.errors.push(...scraped.errors);
873
1075
  adaptiveScraperAttempt.attempted = scraped.attempted;
874
1076
  adaptiveScraperAttempt.ok = scraped.succeeded;
@@ -891,24 +1093,26 @@ async function runSmartfetchInner(options, runtime = {}) {
891
1093
  // SPA auto-fallback: after cheap alternatives (Jina, archive, adaptive scraper) didn't help,
892
1094
  // if the direct fetch result looks like an SPA shell, upgrade to Playwright.
893
1095
  // This must come AFTER Jina/archive so those cheap paths get a chance first.
894
- if (effectiveLane === "direct" && needsSpaBrowserFallback(active)) {
895
- const browserResult = await runPlaywrightFetchWithRuntime(options.url, timeoutMs, target, runtime, profileDir, options.wait_for);
1096
+ if (effectiveLane === "direct" && !browserAttempt.attempted && needsSpaBrowserFallback(active)) {
1097
+ const browserResult = await runPlaywrightFetchWithRuntime(options.url, remaining(), target, runtime, profileDir, options.wait_for);
896
1098
  browserAttempt.attempted = true;
897
1099
  browserAttempt.ok = browserResult.ok;
898
1100
  browserAttempt.method = browserResult.method;
899
1101
  browserAttempt.reason = "direct fetch returned SPA shell — auto-fallback to browser for JS rendering";
900
1102
  output.evidence.playwright_attempted = true;
901
- output.evidence.playwright_success = browserResult.ok;
902
1103
  output.retrieval_method.push(browserResult.method);
903
1104
  if (browserResult.error)
904
1105
  output.errors.push(browserResult.error);
1106
+ remaining();
1107
+ output.evidence.playwright_success = browserResult.ok;
905
1108
  effectiveLane = "browser";
906
1109
  effectiveReason = "direct fetch returned SPA shell — auto-fallback to browser for JS rendering";
907
1110
  if (browserResult.ok)
908
1111
  active = browserResult;
909
1112
  }
910
- let effectiveTarget = target;
911
- if ((options.target || "auto") === "auto") {
1113
+ let effectiveTarget = (expectedPostUrl ? nativeNaverTarget(expectedPostUrl) : undefined) || target;
1114
+ // The identity parser and selected normalizer must agree on accepted URLs.
1115
+ if ((options.target || "auto") === "auto" && !expectedPostUrl) {
912
1116
  const inferred = inferTarget(active.final_url || options.url, "auto");
913
1117
  if (inferred !== effectiveTarget) {
914
1118
  effectiveTarget = inferred;
@@ -916,6 +1120,8 @@ async function runSmartfetchInner(options, runtime = {}) {
916
1120
  output.risk_level = riskLevelForTarget(inferred);
917
1121
  }
918
1122
  }
1123
+ output.target = effectiveTarget;
1124
+ output.risk_level = riskLevelForTarget(effectiveTarget);
919
1125
  output.pipeline.resolve = buildResolveStage({
920
1126
  url: options.url,
921
1127
  requestedTarget,
@@ -944,17 +1150,39 @@ async function runSmartfetchInner(options, runtime = {}) {
944
1150
  ],
945
1151
  };
946
1152
  const providerUrl = active.final_url || options.url;
947
- let provider = resolveSmartfetchProvider(providerUrl, effectiveTarget);
1153
+ const exactNaverProviderUrl = expectedPostUrl || (target === "naver_blog" || target === "naver_cafe_post" ? options.url : undefined);
1154
+ let provider = resolveSmartfetchProvider(exactNaverProviderUrl || providerUrl, effectiveTarget);
948
1155
  if (provider.id === "generic" && providerUrl !== options.url) {
949
1156
  const originalProvider = resolveSmartfetchProvider(options.url, effectiveTarget);
950
1157
  if (originalProvider.id !== "generic")
951
1158
  provider = originalProvider;
952
1159
  }
1160
+ if (suppressRetries) {
1161
+ // Normalizers can perform their own feed/API/search I/O. Do not let them
1162
+ // silently repeat a cooldown request or reopen a terminal safety rejection.
1163
+ output.partial = true;
1164
+ output.assessment = createAssessment({
1165
+ confidence: "low", blocked_reason: active.error?.message || "Retrieval stopped",
1166
+ signals: [hasTerminalSafetyBlock([active.error]) ? "resolve:blocked" : "retrieval:cooldown"],
1167
+ });
1168
+ output.pipeline.normalize = buildNormalizeStage({ provider, partial: true });
1169
+ output.pipeline.assess = output.assessment;
1170
+ return attachResearchHandoff(output);
1171
+ }
953
1172
  const normalized = await provider.normalize({
954
1173
  url: options.url,
955
1174
  resolvedUrl: providerUrl,
1175
+ ...(expectedPostUrl ? { expectedPostUrl } : {}),
956
1176
  target: effectiveTarget,
957
- timeoutMs,
1177
+ timeoutMs: remaining(),
1178
+ deadlineAt,
1179
+ browserSessionOptions: {
1180
+ ...(!profileDir && configText("fetch.browserCdpUrl") ? { cdpUrl: configText("fetch.browserCdpUrl") } : {}),
1181
+ ...(profileDir ? { profileDir } : {}),
1182
+ allowPrivateHosts: allowPrivateHosts(),
1183
+ waitUntil: choosePlaywrightWaitUntil(effectiveTarget, options.url),
1184
+ extraHeaders: browserFetchHeaders(options.url),
1185
+ },
958
1186
  active,
959
1187
  errors: output.errors,
960
1188
  runBrowserSession: runtime.runBrowserSession || runBrowserSession,
@@ -963,15 +1191,17 @@ async function runSmartfetchInner(options, runtime = {}) {
963
1191
  runCoupangSearch: runtime.runCoupangSearch || runCoupang,
964
1192
  ...(options.includeMarkdown ? { includeMarkdown: true } : {}),
965
1193
  });
1194
+ remaining();
966
1195
  const academic = await maybeUseAcademicFallback({
967
1196
  url: options.url,
968
1197
  resolvedUrl: providerUrl,
969
1198
  target: effectiveTarget,
970
- timeoutMs,
1199
+ timeoutMs: remaining(),
971
1200
  active,
972
1201
  errors: output.errors,
973
1202
  ...(runtime.runSmartsearch ? { runSmartsearch: runtime.runSmartsearch } : {}),
974
1203
  }, normalized);
1204
+ remaining();
975
1205
  const enriched = academic.normalized;
976
1206
  output.retrieval_method.push(normalized.method);
977
1207
  output.retrieval_method.push(...academic.retrievalMethods);
@@ -981,7 +1211,7 @@ async function runSmartfetchInner(options, runtime = {}) {
981
1211
  output.outbound_links = Array.isArray(enriched.outbound_links) ? enriched.outbound_links : [];
982
1212
  output.partial = enriched.partial;
983
1213
  output.errors = enriched.errors;
984
- output.assets = discoverAssets(options.url, active.content, output.post, output.outbound_links);
1214
+ output.assets = discoverAssets(options.url, normalized.assetHtml ?? active.content, output.post, output.outbound_links, active.method === "wayback_archive");
985
1215
  if (isNaverCafeUnavailableFallback(provider.id, output) || isNaverMapSearchUnavailable(provider.id, output) || isThreadsJoinShellUnavailable(provider.id, output) || isInstagramUnavailable(provider.id, output) || isDcinsideUnavailable(provider.id, output) || isTelegramUnavailable(provider.id, output) || isXUnavailable(provider.id, output)) {
986
1216
  output.assets = [];
987
1217
  }
@@ -995,6 +1225,24 @@ async function runSmartfetchInner(options, runtime = {}) {
995
1225
  output.assets = [];
996
1226
  output.download = null;
997
1227
  }
1228
+ if (options.exportComments) {
1229
+ try {
1230
+ output.comment_export = exportComments(output);
1231
+ }
1232
+ catch {
1233
+ output.partial = true;
1234
+ output.errors.push({ category: "unavailable", code: "comment_export_failed", message: "Private comment export failed or exceeded its 5000-row/8 MiB bound; no export artifact is claimed" });
1235
+ }
1236
+ }
1237
+ if (options.exportBody) {
1238
+ try {
1239
+ output.body_export = exportBody(output);
1240
+ }
1241
+ catch {
1242
+ output.partial = true;
1243
+ output.errors.push({ category: "unavailable", code: "body_export_failed", message: "Private body export failed, had no readable body, or exceeded its 1 MiB bound; no export artifact is claimed" });
1244
+ }
1245
+ }
998
1246
  output.pipeline.normalize = buildNormalizeStage({ provider, partial: output.partial });
999
1247
  output.assessment = assessSmartfetchOutput({
1000
1248
  url: options.url,
@@ -1023,13 +1271,18 @@ async function runSmartfetchInner(options, runtime = {}) {
1023
1271
  output.outbound_links = dedupeUrls(cleanLinks(dedupeUrls(Array.isArray(output.outbound_links) ? output.outbound_links : [])));
1024
1272
  output.assets = output.assets.filter((asset, index, list) => asset.url && list.findIndex((item) => item.url === asset.url) === index);
1025
1273
  attachResearchHandoff(output);
1274
+ const configuredOutputBudget = options.outputBudgetConfig ?? configValue("fetch.outputBudget");
1026
1275
  const budgetDecision = resolveSmartfetchOutputBudget({
1027
1276
  outputBudget: options.outputBudgetPreset,
1028
1277
  preferredBudgetChars: options.preferredBudgetChars,
1029
1278
  headroomTokens: options.headroomTokens,
1030
1279
  contextMode: options.contextMode,
1280
+ configuredOutputBudget,
1031
1281
  });
1032
- const projectedOutput = projectSmartfetchOutput(output, resolveSmartfetchBudgetPolicy(budgetDecision.preset));
1282
+ const budgetPolicy = budgetDecision.source === "settings"
1283
+ ? resolveSmartfetchBudgetPolicy(configuredOutputBudget)
1284
+ : resolveSmartfetchBudgetPolicy(budgetDecision.preset);
1285
+ const projectedOutput = projectSmartfetchOutput(output, budgetPolicy);
1033
1286
  if (options.strictSchema !== false) {
1034
1287
  const error = validateOutput(projectedOutput);
1035
1288
  if (error) {
@@ -1049,13 +1302,35 @@ async function runSmartfetchInner(options, runtime = {}) {
1049
1302
  }
1050
1303
  return projectedOutput;
1051
1304
  }
1305
+ function renderCommentBody(comment) {
1306
+ if (!comment)
1307
+ return "";
1308
+ const images = Array.isArray(comment.image_urls) ? comment.image_urls.filter((url) => typeof url === "string") : [];
1309
+ return [asString(comment.body) || asString(comment.text), ...images].filter(Boolean).join("\n").trim();
1310
+ }
1052
1311
  export function renderSmartfetch(output, format) {
1053
1312
  const projected = output.budget ? output : projectSmartfetchOutput(output);
1054
1313
  const assessment = projected.assessment || createAssessment();
1055
1314
  const post = projected.post || {};
1056
1315
  const title = String(post?.title || "").trim();
1057
1316
  const text = String(post?.text || post?.selftext || "").trim();
1317
+ const exported = projected.comment_export;
1318
+ const exportReceipt = exported ? `Comment export: ${exported.path}\ncomments=${exported.comments_count} complete=${exported.complete} bytes=${exported.bytes} sha256=${exported.sha256}` : "";
1319
+ const bodyExported = projected.body_export;
1320
+ const bodyExportReceipt = bodyExported ? `Body export: ${bodyExported.path}\nchars=${bodyExported.chars} complete=${bodyExported.complete} bytes=${bodyExported.bytes} sha256=${bodyExported.sha256}` : "";
1058
1321
  if (format === "text") {
1322
+ // Explicit full output must not hide projected comments behind the compact text view.
1323
+ // The projection policy still bounds each field and the total response.
1324
+ const fullPolicy = projected.budget?.policy === "smartfetch.full" ? resolveSmartfetchBudgetPolicy("full") : null;
1325
+ const textLimits = fullPolicy ? {
1326
+ ...TEXT_RENDER_LIMITS,
1327
+ bodyChars: fullPolicy.maxPostTextChars,
1328
+ itemChars: fullPolicy.maxItemTextChars,
1329
+ results: fullPolicy.maxThreadItems,
1330
+ comments: fullPolicy.maxCommentItems,
1331
+ links: fullPolicy.maxOutboundLinks,
1332
+ assets: fullPolicy.maxAssets,
1333
+ } : TEXT_RENDER_LIMITS;
1059
1334
  const assessmentLines = [
1060
1335
  `confidence=${assessment.confidence}`,
1061
1336
  assessment.blocked_reason ? `blocked=${assessment.blocked_reason}` : "",
@@ -1072,12 +1347,13 @@ export function renderSmartfetch(output, format) {
1072
1347
  post?.availability ? `availability=${post.availability}` : "",
1073
1348
  assessmentLines,
1074
1349
  ].filter(Boolean).join(" ");
1075
- const resultItems = Array.isArray(projected.thread) ? limitedItems(projected.thread, TEXT_RENDER_LIMITS.results) : { visible: [], omitted: 0 };
1076
- const commentItems = Array.isArray(projected.comments) ? limitedItems(projected.comments, TEXT_RENDER_LIMITS.comments) : { visible: [], omitted: 0 };
1077
- const hasHandoff = Boolean(assessment.recommended_handoff || assessment.auth_required_likely || assessment.interactive_flow_likely);
1078
- const linkItems = limitedItems(cleanLinks(projected.outbound_links || []), hasHandoff ? TEXT_RENDER_LIMITS.handoffLinks : TEXT_RENDER_LIMITS.links);
1079
- const assetItems = Array.isArray(projected.assets) ? limitedItems(projected.assets, TEXT_RENDER_LIMITS.assets) : { visible: [], omitted: 0 };
1080
- const textBody = compactText(text, hasHandoff ? TEXT_RENDER_LIMITS.handoffBodyChars : TEXT_RENDER_LIMITS.bodyChars);
1350
+ const resultItems = Array.isArray(projected.thread) ? limitedItems(projected.thread, textLimits.results) : { visible: [], omitted: 0 };
1351
+ const commentItems = Array.isArray(projected.comments) ? limitedItems(projected.comments, textLimits.comments) : { visible: [], omitted: 0 };
1352
+ const hasHandoff = Boolean(assessment.recommended_handoff || assessment.auth_required_likely || assessment.interactive_flow_likely
1353
+ || (assessment.continuation && (assessment.continuation.status !== "unavailable" || assessment.confidence === "low")));
1354
+ const linkItems = limitedItems(cleanLinks(projected.outbound_links || []), hasHandoff ? TEXT_RENDER_LIMITS.handoffLinks : textLimits.links);
1355
+ const assetItems = Array.isArray(projected.assets) ? limitedItems(projected.assets, textLimits.assets) : { visible: [], omitted: 0 };
1356
+ const textBody = compactText(text, hasHandoff ? TEXT_RENDER_LIMITS.handoffBodyChars : textLimits.bodyChars);
1081
1357
  const renderBudget = [
1082
1358
  textBody.truncated ? `text_chars_omitted=${textBody.omittedChars}` : "",
1083
1359
  resultItems.omitted ? `results_omitted=${resultItems.omitted}` : "",
@@ -1089,21 +1365,22 @@ export function renderSmartfetch(output, format) {
1089
1365
  const title = String(item?.title || "").trim();
1090
1366
  const price = String(item?.price || "").trim();
1091
1367
  const url = String(item?.url || "").trim();
1092
- return compactText([title, price, url].filter(Boolean).join(" — "), TEXT_RENDER_LIMITS.itemChars).text;
1368
+ return compactText([title, price, url].filter(Boolean).join(" — "), textLimits.itemChars).text;
1093
1369
  }).filter(Boolean);
1094
1370
  const comments = commentItems.visible.map((comment) => {
1095
- const body = String(comment?.body || "").trim();
1371
+ const body = renderCommentBody(comment);
1096
1372
  const author = String(comment?.author || "unknown");
1097
- return body ? compactText(`${author}: ${body}`, TEXT_RENDER_LIMITS.itemChars).text : "";
1373
+ return body ? compactText(`${author}: ${body}`, textLimits.itemChars).text : "";
1098
1374
  }).filter(Boolean);
1099
1375
  const assets = assetItems.visible.map((asset) => `${asset.kind}: ${asset.download_url}`);
1100
1376
  const download = projected.download ? `download: ${projected.download.download_url}` : "";
1101
1377
  const handoff = assessment.recommended_handoff
1102
- ? `handoff: ${assessment.recommended_handoff.tool} — ${assessment.recommended_handoff.reason}${assessment.recommended_handoff.captcha_type ? ` [${assessment.recommended_handoff.captcha_type}]` : ""}`
1378
+ ? `handoff: ${assessment.recommended_handoff.tool} — ${assessment.recommended_handoff.reason}`
1103
1379
  : "";
1380
+ const continuation = assessment.continuation ? `continuation: ${assessment.continuation.status} — ${assessment.continuation.reason}` : "";
1104
1381
  const budget = projected.budget?.truncated ? `budget: truncated fields=${Object.keys(projected.budget.fields).join(",")}` : "";
1105
- const compactBudget = renderBudget ? `text_view: compact renderer omitted ${renderBudget}` : "";
1106
- return [title, meta, textBody.text, results.join("\n"), comments.join("\n"), linkItems.visible.join("\n"), download, assets.join("\n"), handoff, budget, compactBudget].filter(Boolean).join("\n\n").trim();
1382
+ const compactBudget = renderBudget ? `text_view: ${fullPolicy ? "full" : "compact"} renderer omitted ${renderBudget}` : "";
1383
+ return [title, meta, exportReceipt, bodyExportReceipt, textBody.text, results.join("\n"), comments.join("\n"), linkItems.visible.join("\n"), download, assets.join("\n"), handoff, continuation, budget, compactBudget].filter(Boolean).join("\n\n").trim();
1107
1384
  }
1108
1385
  const parts = [];
1109
1386
  const url = String(post?.url || projected.url || "").trim();
@@ -1126,6 +1403,10 @@ export function renderSmartfetch(output, format) {
1126
1403
  parts.push(`# ${title}`);
1127
1404
  if (url)
1128
1405
  parts.push(`Source: ${url}`);
1406
+ if (exportReceipt)
1407
+ parts.push(exportReceipt);
1408
+ if (bodyExportReceipt)
1409
+ parts.push(bodyExportReceipt);
1129
1410
  parts.push(`Confidence: ${assessment.confidence}`);
1130
1411
  if (assessment.blocked_reason)
1131
1412
  parts.push(`Blocked reason: ${assessment.blocked_reason}`);
@@ -1140,7 +1421,7 @@ export function renderSmartfetch(output, format) {
1140
1421
  if (Array.isArray(projected.comments) && projected.comments.length > 0) {
1141
1422
  const comments = projected.comments.map((comment) => {
1142
1423
  const author = String(comment?.author || "unknown");
1143
- const body = String(comment?.body || "").trim();
1424
+ const body = renderCommentBody(comment);
1144
1425
  return body ? `- ${author}: ${body}` : "";
1145
1426
  }).filter(Boolean);
1146
1427
  if (comments.length > 0)
@@ -1175,8 +1456,11 @@ export function renderSmartfetch(output, format) {
1175
1456
  parts.push(`## Assets\n\n${assets.join("\n")}`);
1176
1457
  if (assessment.recommended_handoff) {
1177
1458
  const handoff = assessment.recommended_handoff;
1178
- const captchaLine = handoff.captcha_type ? `\n- captcha_type: ${handoff.captcha_type}` : "";
1179
- parts.push(`## Recommended handoff\n\n- tool: ${handoff.tool}\n- mode: ${handoff.mode}\n- url: ${handoff.url}\n- reason: ${handoff.reason}\n- goal: ${handoff.goal}${captchaLine}`);
1459
+ parts.push(`## Recommended handoff\n\n- tool: ${handoff.tool}\n- mode: ${handoff.mode}\n- url: ${handoff.url}\n- reason: ${handoff.reason}\n- goal: ${handoff.goal}`);
1460
+ }
1461
+ if (assessment.continuation) {
1462
+ const continuation = assessment.continuation;
1463
+ parts.push(`## Continuation\n\n- status: ${continuation.status}\n- reason: ${continuation.reason}\n- url: ${continuation.url}`);
1180
1464
  }
1181
1465
  if (projected.budget?.truncated) {
1182
1466
  const fields = Object.entries(projected.budget.fields).map(([field, item]) => {
@@ -1201,12 +1485,19 @@ export async function renderSmartfetchUrl(url, format, timeoutMs) {
1201
1485
  return renderSmartfetch(output, format);
1202
1486
  }
1203
1487
  async function runPlaywrightFetchWithRuntime(url, timeoutMs, target, runtime, profileDir, waitForSelector) {
1488
+ const nativeUrl = target === "naver_blog" ? naverBlogMobilePostUrl(url) : target === "naver_cafe_post" ? naverCafeMobilePostUrl(url) : null;
1489
+ if (nativeUrl) {
1490
+ url = nativeUrl;
1491
+ if (!waitForSelector && target === "naver_cafe_post")
1492
+ waitForSelector = NAVER_CAFE_READY_SELECTOR;
1493
+ }
1204
1494
  if (!runtime.runBrowserSession)
1205
1495
  return runPlaywrightFetch(url, timeoutMs, target, profileDir, waitForSelector);
1206
1496
  const extraHeaders = browserFetchHeaders(url);
1207
1497
  const result = await runtime.runBrowserSession({
1208
1498
  url,
1209
1499
  timeoutMs,
1500
+ ...(!profileDir && configText("fetch.browserCdpUrl") ? { cdpUrl: configText("fetch.browserCdpUrl") } : {}),
1210
1501
  allowPrivateHosts: allowPrivateHosts(),
1211
1502
  waitUntil: choosePlaywrightWaitUntil(target, url),
1212
1503
  extraHeaders,