smart-web-mcp 0.44.1 → 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 +342 -0
  2. package/README.md +33 -568
  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 +28 -11
  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 +30 -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 +33 -8
  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 +7 -1
  129. package/dist/smartfetch.js +353 -76
  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 +2 -0
  156. package/dist/smartsearch.js +506 -84
  157. package/dist/smartsearch.js.map +1 -1
  158. package/package.json +8 -5
  159. package/python/requirements-undetected-chromedriver.txt +2 -2
@@ -1,5 +1,5 @@
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
5
  import { configText, configValue } from "./settings.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;
@@ -584,28 +667,32 @@ function isDocumentLikeAsset(asset) {
584
667
  return asset?.kind === "document" || asset?.kind === "archive" || asset?.kind === "attachment";
585
668
  }
586
669
  export async function runSmartfetch(options, runtime = {}) {
587
- // Inflight coalescing: deduplicate calls only when the URL and contextual
588
- // commerce queries are identical. Distinct product hints must not share a result.
589
- const coalesceKey = !options.download && !options.profile
590
- ? `${options.url}\u0000${JSON.stringify(options.referenceQueries || [])}`
591
- : "";
592
- if (coalesceKey) {
593
- const inflight = inflightSmartfetch.get(coalesceKey);
594
- if (inflight)
595
- 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);
596
675
  }
597
- const promise = runSmartfetchInner(options, runtime);
598
- if (coalesceKey) {
599
- inflightSmartfetch.set(coalesceKey, promise);
600
- 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);
601
688
  }
602
- return promise;
603
689
  }
604
- 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.
605
693
  const requestedTarget = options.target || "auto";
606
- const target = inferTarget(options.url, requestedTarget);
607
- const timeoutMs = Math.max(3000, Math.floor(Number(options.timeoutMs || 25000)));
608
- const output = baseOutput(options.url, target);
694
+ const target = (requestedTarget === "auto" ? nativeNaverTarget(options.url) : undefined) || inferTarget(options.url, requestedTarget);
695
+ const remaining = () => remainingFetchTime(deadlineAt);
609
696
  output.pipeline.resolve = buildResolveStage({
610
697
  url: options.url,
611
698
  requestedTarget,
@@ -616,6 +703,7 @@ async function runSmartfetchInner(options, runtime = {}) {
616
703
  const profileName = options.profile || configText("fetch.browserProfile");
617
704
  const profileDir = profileName ? resolveProfileDir(profileName) : undefined;
618
705
  const validation = await validateOutboundUrl(options.url, { allowPrivateHosts: allowPrivateHosts() });
706
+ remaining();
619
707
  if (!validation.ok) {
620
708
  return attachResearchHandoff({
621
709
  ...output,
@@ -730,16 +818,27 @@ async function runSmartfetchInner(options, runtime = {}) {
730
818
  }
731
819
  }
732
820
  let active;
821
+ let expectedPostUrl = nativeNaverTarget(options.url) === target ? options.url : undefined;
733
822
  if (acquirePlan.lane === "browser") {
734
- 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);
735
833
  browserAttempt.attempted = true;
736
834
  browserAttempt.ok = active.ok;
737
835
  browserAttempt.method = active.method;
738
836
  output.evidence.playwright_attempted = true;
739
- output.evidence.playwright_success = active.ok;
740
837
  output.retrieval_method.push(active.method);
741
838
  if (active.error)
742
839
  output.errors.push(active.error);
840
+ remaining();
841
+ output.evidence.playwright_success = active.ok;
743
842
  }
744
843
  else if (acquirePlan.lane === "seed") {
745
844
  active = {
@@ -761,65 +860,156 @@ async function runSmartfetchInner(options, runtime = {}) {
761
860
  output.retrieval_method.push(active.method);
762
861
  }
763
862
  else {
764
- 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
+ }
765
876
  directAttempt.attempted = true;
766
877
  directAttempt.ok = active.ok;
767
878
  directAttempt.method = active.method;
768
879
  output.evidence.impit_attempted = true;
769
- output.evidence.impit_success = active.ok;
770
880
  output.retrieval_method.push(active.method);
771
881
  if (active.error)
772
882
  output.errors.push(active.error);
883
+ remaining();
884
+ output.evidence.impit_success = active.ok;
773
885
  const redirectUrl = active.final_url && active.final_url !== options.url ? active.final_url : "";
774
886
  const redirectTarget = redirectUrl
775
- ? inferTarget(redirectUrl, "auto")
887
+ ? nativeNaverTarget(redirectUrl) || inferTarget(redirectUrl, "auto")
776
888
  : target;
777
889
  const redirectProvider = redirectUrl
778
890
  ? resolveSmartfetchProvider(redirectUrl, redirectTarget)
779
891
  : plannedProvider;
780
892
  const needsRedirectBrowserUpgrade = Boolean(redirectUrl)
781
893
  && (redirectProvider.capabilities.includes("requires_browser") || redirectProvider.capabilities.includes("interactive_surface"));
782
- if (needsRedirectBrowserUpgrade) {
783
- 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);
784
905
  browserAttempt.attempted = true;
785
906
  browserAttempt.ok = browserResult.ok;
786
907
  browserAttempt.method = browserResult.method;
787
908
  browserAttempt.reason = `redirected final provider:${redirectProvider.id} requires browser retrieval`;
788
909
  output.evidence.playwright_attempted = true;
789
- output.evidence.playwright_success = browserResult.ok;
790
910
  output.retrieval_method.push(browserResult.method);
791
911
  if (browserResult.error)
792
912
  output.errors.push(browserResult.error);
913
+ remaining();
914
+ output.evidence.playwright_success = browserResult.ok;
793
915
  effectiveLane = "browser";
794
916
  effectiveReason = `redirected final provider:${redirectProvider.id} requires browser retrieval`;
795
917
  redirectUpgrade = true;
796
- if (browserResult.ok)
918
+ if (browserResult.ok || expectedPostUrl || hasTerminalSafetyBlock([browserResult.error]))
797
919
  active = browserResult;
798
920
  }
799
- else if (needsBotChallengeBrowserUpgrade(active)) {
800
- // impit returned a Cloudflare/bot-challenge interstitial — try the browser lane.
801
- 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);
802
924
  browserAttempt.attempted = true;
803
925
  browserAttempt.ok = browserResult.ok;
804
926
  browserAttempt.method = browserResult.method;
805
- browserAttempt.reason = "impit returned bot-challenge interstitial — upgraded to browser";
927
+ browserAttempt.reason = operatorRecoveryReason(active) || "impit returned bot-challenge interstitial — upgraded to browser";
806
928
  output.evidence.playwright_attempted = true;
807
- output.evidence.playwright_success = browserResult.ok;
808
929
  output.retrieval_method.push(browserResult.method);
809
930
  if (browserResult.error)
810
931
  output.errors.push(browserResult.error);
932
+ remaining();
933
+ output.evidence.playwright_success = browserResult.ok;
811
934
  effectiveLane = "browser";
812
- effectiveReason = "impit returned bot-challenge interstitial — upgraded to browser";
935
+ effectiveReason = browserAttempt.reason;
813
936
  if (browserResult.ok)
814
937
  active = browserResult;
815
938
  }
816
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
+ }
817
964
  const archiveTarget = inferTarget(active.final_url || options.url, "auto");
818
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
+ }
819
1005
  const allowArchiveFallback = acquirePlan.allowArchiveFallback
1006
+ && !suppressRetries
1007
+ && !lockedBlindPreview
820
1008
  && effectiveLane === "direct"
821
1009
  && Boolean(archiveProvider.policy.allowArchiveFallback);
822
1010
  const allowJinaReader = acquirePlan.allowJinaReader
1011
+ && !suppressRetries
1012
+ && !lockedBlindPreview
823
1013
  && effectiveLane === "direct"
824
1014
  && Boolean(archiveProvider.policy.allowJinaReader);
825
1015
  if (!allowJinaReader) {
@@ -832,9 +1022,10 @@ async function runSmartfetchInner(options, runtime = {}) {
832
1022
  ? "archive fallback was not eligible for the effective provider/lane"
833
1023
  : "archive fallback was not allowed by plan, privacy profile, or settings";
834
1024
  }
835
- if (allowJinaReader) {
1025
+ if (allowJinaReader && effectiveLane === "direct") {
836
1026
  const beforeMethod = active.method;
837
- 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();
838
1029
  active = relayed.active;
839
1030
  output.errors.push(...relayed.errors);
840
1031
  const jinaErrors = relayed.errors.filter((error) => error.code.startsWith("jina_reader"));
@@ -853,9 +1044,10 @@ async function runSmartfetchInner(options, runtime = {}) {
853
1044
  if (jinaSucceeded)
854
1045
  output.retrieval_method.push(active.method);
855
1046
  }
856
- if (allowArchiveFallback) {
1047
+ if (allowArchiveFallback && effectiveLane === "direct") {
857
1048
  const beforeMethod = active.method;
858
- 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();
859
1051
  active = archived.active;
860
1052
  output.errors.push(...archived.errors);
861
1053
  const archiveErrors = archived.errors.filter((error) => /archive|wayback/i.test(error.code));
@@ -877,7 +1069,8 @@ async function runSmartfetchInner(options, runtime = {}) {
877
1069
  }
878
1070
  }
879
1071
  if (effectiveLane === "direct" && (needsDynamicCrawl(active) || needsSpaBrowserFallback(active))) {
880
- 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();
881
1074
  output.errors.push(...scraped.errors);
882
1075
  adaptiveScraperAttempt.attempted = scraped.attempted;
883
1076
  adaptiveScraperAttempt.ok = scraped.succeeded;
@@ -900,24 +1093,26 @@ async function runSmartfetchInner(options, runtime = {}) {
900
1093
  // SPA auto-fallback: after cheap alternatives (Jina, archive, adaptive scraper) didn't help,
901
1094
  // if the direct fetch result looks like an SPA shell, upgrade to Playwright.
902
1095
  // This must come AFTER Jina/archive so those cheap paths get a chance first.
903
- if (effectiveLane === "direct" && needsSpaBrowserFallback(active)) {
904
- 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);
905
1098
  browserAttempt.attempted = true;
906
1099
  browserAttempt.ok = browserResult.ok;
907
1100
  browserAttempt.method = browserResult.method;
908
1101
  browserAttempt.reason = "direct fetch returned SPA shell — auto-fallback to browser for JS rendering";
909
1102
  output.evidence.playwright_attempted = true;
910
- output.evidence.playwright_success = browserResult.ok;
911
1103
  output.retrieval_method.push(browserResult.method);
912
1104
  if (browserResult.error)
913
1105
  output.errors.push(browserResult.error);
1106
+ remaining();
1107
+ output.evidence.playwright_success = browserResult.ok;
914
1108
  effectiveLane = "browser";
915
1109
  effectiveReason = "direct fetch returned SPA shell — auto-fallback to browser for JS rendering";
916
1110
  if (browserResult.ok)
917
1111
  active = browserResult;
918
1112
  }
919
- let effectiveTarget = target;
920
- 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) {
921
1116
  const inferred = inferTarget(active.final_url || options.url, "auto");
922
1117
  if (inferred !== effectiveTarget) {
923
1118
  effectiveTarget = inferred;
@@ -925,6 +1120,8 @@ async function runSmartfetchInner(options, runtime = {}) {
925
1120
  output.risk_level = riskLevelForTarget(inferred);
926
1121
  }
927
1122
  }
1123
+ output.target = effectiveTarget;
1124
+ output.risk_level = riskLevelForTarget(effectiveTarget);
928
1125
  output.pipeline.resolve = buildResolveStage({
929
1126
  url: options.url,
930
1127
  requestedTarget,
@@ -953,17 +1150,39 @@ async function runSmartfetchInner(options, runtime = {}) {
953
1150
  ],
954
1151
  };
955
1152
  const providerUrl = active.final_url || options.url;
956
- 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);
957
1155
  if (provider.id === "generic" && providerUrl !== options.url) {
958
1156
  const originalProvider = resolveSmartfetchProvider(options.url, effectiveTarget);
959
1157
  if (originalProvider.id !== "generic")
960
1158
  provider = originalProvider;
961
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
+ }
962
1172
  const normalized = await provider.normalize({
963
1173
  url: options.url,
964
1174
  resolvedUrl: providerUrl,
1175
+ ...(expectedPostUrl ? { expectedPostUrl } : {}),
965
1176
  target: effectiveTarget,
966
- 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
+ },
967
1186
  active,
968
1187
  errors: output.errors,
969
1188
  runBrowserSession: runtime.runBrowserSession || runBrowserSession,
@@ -972,15 +1191,17 @@ async function runSmartfetchInner(options, runtime = {}) {
972
1191
  runCoupangSearch: runtime.runCoupangSearch || runCoupang,
973
1192
  ...(options.includeMarkdown ? { includeMarkdown: true } : {}),
974
1193
  });
1194
+ remaining();
975
1195
  const academic = await maybeUseAcademicFallback({
976
1196
  url: options.url,
977
1197
  resolvedUrl: providerUrl,
978
1198
  target: effectiveTarget,
979
- timeoutMs,
1199
+ timeoutMs: remaining(),
980
1200
  active,
981
1201
  errors: output.errors,
982
1202
  ...(runtime.runSmartsearch ? { runSmartsearch: runtime.runSmartsearch } : {}),
983
1203
  }, normalized);
1204
+ remaining();
984
1205
  const enriched = academic.normalized;
985
1206
  output.retrieval_method.push(normalized.method);
986
1207
  output.retrieval_method.push(...academic.retrievalMethods);
@@ -990,7 +1211,7 @@ async function runSmartfetchInner(options, runtime = {}) {
990
1211
  output.outbound_links = Array.isArray(enriched.outbound_links) ? enriched.outbound_links : [];
991
1212
  output.partial = enriched.partial;
992
1213
  output.errors = enriched.errors;
993
- 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");
994
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)) {
995
1216
  output.assets = [];
996
1217
  }
@@ -1004,6 +1225,24 @@ async function runSmartfetchInner(options, runtime = {}) {
1004
1225
  output.assets = [];
1005
1226
  output.download = null;
1006
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
+ }
1007
1246
  output.pipeline.normalize = buildNormalizeStage({ provider, partial: output.partial });
1008
1247
  output.assessment = assessSmartfetchOutput({
1009
1248
  url: options.url,
@@ -1063,13 +1302,35 @@ async function runSmartfetchInner(options, runtime = {}) {
1063
1302
  }
1064
1303
  return projectedOutput;
1065
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
+ }
1066
1311
  export function renderSmartfetch(output, format) {
1067
1312
  const projected = output.budget ? output : projectSmartfetchOutput(output);
1068
1313
  const assessment = projected.assessment || createAssessment();
1069
1314
  const post = projected.post || {};
1070
1315
  const title = String(post?.title || "").trim();
1071
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}` : "";
1072
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;
1073
1334
  const assessmentLines = [
1074
1335
  `confidence=${assessment.confidence}`,
1075
1336
  assessment.blocked_reason ? `blocked=${assessment.blocked_reason}` : "",
@@ -1086,12 +1347,13 @@ export function renderSmartfetch(output, format) {
1086
1347
  post?.availability ? `availability=${post.availability}` : "",
1087
1348
  assessmentLines,
1088
1349
  ].filter(Boolean).join(" ");
1089
- const resultItems = Array.isArray(projected.thread) ? limitedItems(projected.thread, TEXT_RENDER_LIMITS.results) : { visible: [], omitted: 0 };
1090
- const commentItems = Array.isArray(projected.comments) ? limitedItems(projected.comments, TEXT_RENDER_LIMITS.comments) : { visible: [], omitted: 0 };
1091
- const hasHandoff = Boolean(assessment.recommended_handoff || assessment.auth_required_likely || assessment.interactive_flow_likely);
1092
- const linkItems = limitedItems(cleanLinks(projected.outbound_links || []), hasHandoff ? TEXT_RENDER_LIMITS.handoffLinks : TEXT_RENDER_LIMITS.links);
1093
- const assetItems = Array.isArray(projected.assets) ? limitedItems(projected.assets, TEXT_RENDER_LIMITS.assets) : { visible: [], omitted: 0 };
1094
- 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);
1095
1357
  const renderBudget = [
1096
1358
  textBody.truncated ? `text_chars_omitted=${textBody.omittedChars}` : "",
1097
1359
  resultItems.omitted ? `results_omitted=${resultItems.omitted}` : "",
@@ -1103,21 +1365,22 @@ export function renderSmartfetch(output, format) {
1103
1365
  const title = String(item?.title || "").trim();
1104
1366
  const price = String(item?.price || "").trim();
1105
1367
  const url = String(item?.url || "").trim();
1106
- 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;
1107
1369
  }).filter(Boolean);
1108
1370
  const comments = commentItems.visible.map((comment) => {
1109
- const body = String(comment?.body || "").trim();
1371
+ const body = renderCommentBody(comment);
1110
1372
  const author = String(comment?.author || "unknown");
1111
- return body ? compactText(`${author}: ${body}`, TEXT_RENDER_LIMITS.itemChars).text : "";
1373
+ return body ? compactText(`${author}: ${body}`, textLimits.itemChars).text : "";
1112
1374
  }).filter(Boolean);
1113
1375
  const assets = assetItems.visible.map((asset) => `${asset.kind}: ${asset.download_url}`);
1114
1376
  const download = projected.download ? `download: ${projected.download.download_url}` : "";
1115
1377
  const handoff = assessment.recommended_handoff
1116
- ? `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}`
1117
1379
  : "";
1380
+ const continuation = assessment.continuation ? `continuation: ${assessment.continuation.status} — ${assessment.continuation.reason}` : "";
1118
1381
  const budget = projected.budget?.truncated ? `budget: truncated fields=${Object.keys(projected.budget.fields).join(",")}` : "";
1119
- const compactBudget = renderBudget ? `text_view: compact renderer omitted ${renderBudget}` : "";
1120
- 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();
1121
1384
  }
1122
1385
  const parts = [];
1123
1386
  const url = String(post?.url || projected.url || "").trim();
@@ -1140,6 +1403,10 @@ export function renderSmartfetch(output, format) {
1140
1403
  parts.push(`# ${title}`);
1141
1404
  if (url)
1142
1405
  parts.push(`Source: ${url}`);
1406
+ if (exportReceipt)
1407
+ parts.push(exportReceipt);
1408
+ if (bodyExportReceipt)
1409
+ parts.push(bodyExportReceipt);
1143
1410
  parts.push(`Confidence: ${assessment.confidence}`);
1144
1411
  if (assessment.blocked_reason)
1145
1412
  parts.push(`Blocked reason: ${assessment.blocked_reason}`);
@@ -1154,7 +1421,7 @@ export function renderSmartfetch(output, format) {
1154
1421
  if (Array.isArray(projected.comments) && projected.comments.length > 0) {
1155
1422
  const comments = projected.comments.map((comment) => {
1156
1423
  const author = String(comment?.author || "unknown");
1157
- const body = String(comment?.body || "").trim();
1424
+ const body = renderCommentBody(comment);
1158
1425
  return body ? `- ${author}: ${body}` : "";
1159
1426
  }).filter(Boolean);
1160
1427
  if (comments.length > 0)
@@ -1189,8 +1456,11 @@ export function renderSmartfetch(output, format) {
1189
1456
  parts.push(`## Assets\n\n${assets.join("\n")}`);
1190
1457
  if (assessment.recommended_handoff) {
1191
1458
  const handoff = assessment.recommended_handoff;
1192
- const captchaLine = handoff.captcha_type ? `\n- captcha_type: ${handoff.captcha_type}` : "";
1193
- 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}`);
1194
1464
  }
1195
1465
  if (projected.budget?.truncated) {
1196
1466
  const fields = Object.entries(projected.budget.fields).map(([field, item]) => {
@@ -1215,12 +1485,19 @@ export async function renderSmartfetchUrl(url, format, timeoutMs) {
1215
1485
  return renderSmartfetch(output, format);
1216
1486
  }
1217
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
+ }
1218
1494
  if (!runtime.runBrowserSession)
1219
1495
  return runPlaywrightFetch(url, timeoutMs, target, profileDir, waitForSelector);
1220
1496
  const extraHeaders = browserFetchHeaders(url);
1221
1497
  const result = await runtime.runBrowserSession({
1222
1498
  url,
1223
1499
  timeoutMs,
1500
+ ...(!profileDir && configText("fetch.browserCdpUrl") ? { cdpUrl: configText("fetch.browserCdpUrl") } : {}),
1224
1501
  allowPrivateHosts: allowPrivateHosts(),
1225
1502
  waitUntil: choosePlaywrightWaitUntil(target, url),
1226
1503
  extraHeaders,