$ cat node-template.py
F
Fetch URL as Markdown
// Fetch a batch of public URLs (1-50) and return per-URL Markdown ready for LLM consumption. Scrapes HTML and converts to Markdown via crawl4ai + Playwright + Chromium. Wire directly from `Web Search` — the urls input accepts the ranked URL array as-is. Returns parallel arrays (markdown, title, error) plus success_count and failed_count. On success each markdown item is prefixed with a Source/title header. Failure is encoded by empty markdown. Pairs with: `Web Search`.
Process
Integration
#web#fetch#url#markdown#research#internet#search#scrape#crawl#html#page-content#crawl4ai#batch#array
template.py
1"""2Fetch URL as Markdown — array form (v2.0.0).34Accepts an array of URLs and returns three parallel arrays (markdown, title,5error) plus success_count and failed_count. Wire directly from Web Search's6urls output.78Success/failure is encoded by emptiness: markdown[i] is non-empty on success9(decorated with a Source/title header) and empty on failure. error[i] carries10the reason when failed and is empty when succeeded. success_count and11failed_count are pre-computed aggregates over markdown emptiness for fast12downstream branching without iterating the arrays.1314Per-item soft-fail invariant: any single fetch that raises an SDK exception15(BackendTimeout, BackendError, RemoteError) OR returns success:false from the16sidecar is surfaced as data in the per-item slot. The node exits 0 unless17input validation fails (programmer error, exit 1).1819Concurrency: up to 4 URLs in flight via ThreadPoolExecutor.map, which preserves20input order so results[i] always corresponds to urls[i]. gais-web caps at 421process-wide as well, so client-side concurrency above 4 would just queue.2223Markdown decoration: on a successful fetch the per-item markdown is prefixed24with a single header line carrying the source URL and the extracted title.25This makes each item self-describing so downstream LLM consumers see the26provenance inline without having to cross-reference the parallel arrays. The27prefix is only added when the body is non-empty (failed fetches stay empty so28`not markdown[i]` remains a valid quick-failure check).2930No truncation: the full markdown body from gais-web flows through. The31sidecar still applies its own 1 MB cap upstream, but the signal is not32surfaced here — the template's contract is "full text or empty (failed)".33Worst-case stdout payload at N=50 can therefore exceed Temporal's 2 MB gRPC34default; this is a deliberate trade-off for faithful content delivery.35"""3637from __future__ import annotations3839import json40import sys41import traceback42from concurrent.futures import ThreadPoolExecutor43from typing import Any4445from gais import Gais464748MAX_URLS = 5049MAX_WORKERS = 4505152def parse_urls(value: Any) -> list[str]:53 """Lenient parse: accept list, or JSON-encoded string of a list.5455 Mirrors the parse_json helper in odoo-rpc — the established convention56 for Json-typed inputs in this codebase. Rejects anything that is not a57 list or a JSON string that decodes to a list.58 """59 if value is None:60 raise ValueError("Required input 'urls' not provided")61 if isinstance(value, list):62 return value63 if isinstance(value, str):64 stripped = value.strip()65 if not stripped:66 raise ValueError("Required input 'urls' is empty")67 try:68 decoded = json.loads(stripped)69 except json.JSONDecodeError as exc:70 raise ValueError(f"urls is a string but not valid JSON: {exc}") from exc71 if not isinstance(decoded, list):72 raise ValueError(73 f"urls (string) decoded to {type(decoded).__name__}, expected list"74 )75 return decoded76 raise ValueError(77 f"urls must be a list or a JSON string of a list, got {type(value).__name__}"78 )798081def validate_urls(urls: list[Any]) -> list[str]:82 """Reject empty, over-cap, or non-string inputs with a clear error."""83 if not urls:84 raise ValueError("urls cannot be empty")85 if len(urls) > MAX_URLS:86 raise ValueError(87 f"urls exceeds max {MAX_URLS} (got {len(urls)}). "88 f"Cap web-search's num_results upstream or split the batch."89 )90 for idx, item in enumerate(urls):91 if not isinstance(item, str) or not item.strip():92 raise ValueError(93 f"urls[{idx}] must be a non-empty string, got {type(item).__name__}"94 )95 return urls969798_MD_TITLE_ESCAPE = str.maketrans(99 {100 "\\": "\\\\",101 "*": "\\*",102 "_": "\\_",103 "`": "\\`",104 "[": "\\[",105 "]": "\\]",106 "(": "\\(",107 ")": "\\)",108 "\n": " ",109 "\r": " ",110 }111)112113114def decorate_markdown(url: str, title: str, markdown: str) -> str:115 """Prepend a Source/title header to the markdown body.116117 The prefix is a single bold-title + link-to-source line followed by a blank118 line, so it composes cleanly with whatever heading the page itself emits119 below. Title can be empty (sidecar best-effort) — fall back to URL-only.120121 Title is escaped so a hostile <title> tag (containing `**`, `]`, newlines,122 etc.) cannot break the markdown parse or collide with the chat card's123 `[Source](url)` extractor regex.124125 Returns the original body unchanged when it is empty (a failed fetch126 should NOT gain a misleading provenance header).127 """128 if not markdown:129 return markdown130 safe_title = (title or "").strip().translate(_MD_TITLE_ESCAPE)131 # Collapse runs of whitespace introduced by newline-stripping.132 safe_title = " ".join(safe_title.split())133 if safe_title:134 header = f"**{safe_title}** — [Source]({url})"135 else:136 header = f"[Source]({url})"137 return f"{header}\n\n{markdown}"138139140def fetch_one(url: str, timeout_seconds: int, idx: int, total: int) -> dict[str, Any]:141 """Fetch one URL. NEVER raises — exceptions become soft-fail data.142143 Returns 3 fields per item: markdown (empty on failure), title, error.144 Success/failure is encoded by markdown emptiness; the explicit success145 bool was removed as redundant.146 """147 try:148 print(149 f"[fetch-url-markdown][{idx + 1}/{total}] start url={url!r} "150 f"timeout={timeout_seconds}s",151 file=sys.stderr,152 )153 result = Gais.web.fetch(url=url, timeout_seconds=timeout_seconds)154 md = result.metadata155 markdown = md.get("markdown", "") or ""156 title = md.get("title", "") or ""157 success = bool(md.get("success", False))158 error = md.get("error", "") or ""159160 # Soft-fail unification: a sidecar success=False with an empty body161 # MUST collapse markdown to "" so `not markdown[i]` is the single162 # source of truth for "this item failed". A sidecar success=False163 # with a non-empty body (rare) is treated as failed too.164 if not success:165 markdown = ""166167 # Decorate non-empty successful bodies with provenance.168 if markdown:169 markdown = decorate_markdown(url, title, markdown)170171 print(172 f"[fetch-url-markdown][{idx + 1}/{total}] done "173 f"markdown_chars={len(markdown)} error={error!r}",174 file=sys.stderr,175 )176 return {177 "markdown": markdown,178 "title": title,179 "error": error,180 }181 except Exception as exc: # noqa: BLE001 — soft-fail by contract182 err_label = f"{type(exc).__name__}: {exc}"183 print(184 f"[fetch-url-markdown][{idx + 1}/{total}] FAILED {err_label}",185 file=sys.stderr,186 )187 return {188 "markdown": "",189 "title": "",190 "error": err_label,191 }192193194def process(inputs: dict[str, Any]) -> dict[str, Any]:195 """Run the batch fetch and return the 5-field output dict.196197 Pure function over the input dict. Raises ValueError on input validation198 failures (programmer error); per-item SDK exceptions are caught inside199 fetch_one and surfaced as soft-fail data (empty markdown + populated200 error). Extracted from main() so tests can drive it without stdin/stdout201 monkey-patching.202 """203 urls = validate_urls(parse_urls(inputs.get("urls")))204 timeout_seconds = int(inputs.get("timeout_seconds") or 30)205 if not (5 <= timeout_seconds <= 120):206 raise ValueError(207 f"timeout_seconds must be between 5 and 120, got {timeout_seconds}"208 )209210 total = len(urls)211 print(212 f"[fetch-url-markdown] batch start n={total} "213 f"timeout={timeout_seconds}s workers={MAX_WORKERS}",214 file=sys.stderr,215 )216217 # ThreadPoolExecutor.map preserves order: results[i] <-> urls[i].218 # gais-web's process-wide semaphore caps concurrency at 4 server-side,219 # so MAX_WORKERS=4 client-side aligns perfectly.220 with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:221 results = list(222 pool.map(223 lambda iu: fetch_one(iu[1], timeout_seconds, iu[0], total),224 enumerate(urls),225 )226 )227228 # Pivot: list-of-dicts -> 3 parallel arrays. Order preserved by pool.map.229 markdown_arr = [r["markdown"] for r in results]230 title_arr = [r["title"] for r in results]231 error_arr = [r["error"] for r in results]232233 # Aggregates derived from markdown emptiness (the single failure signal234 # after dropping the redundant success[] and the no-longer-meaningful235 # truncated[] arrays).236 success_count = sum(1 for m in markdown_arr if m)237 failed_count = total - success_count238239 print(240 f"[fetch-url-markdown] batch done n={total} success={success_count} "241 f"failed={failed_count}",242 file=sys.stderr,243 )244245 return {246 "markdown": markdown_arr,247 "title": title_arr,248 "error": error_arr,249 "success_count": success_count,250 "failed_count": failed_count,251 }252253254def main() -> None:255 try:256 envelope = json.loads(sys.stdin.read() or "{}")257 inputs: dict[str, Any] = (258 envelope.get("inputs", {}) if isinstance(envelope, dict) else {}259 )260 output = process(inputs)261 json.dump(output, sys.stdout)262 except Exception as e:263 error_payload = {264 "error": str(e),265 "errorType": type(e).__name__,266 "traceback": traceback.format_exc(),267 }268 print(json.dumps(error_payload), file=sys.stderr)269 sys.exit(1)270271272if __name__ == "__main__":273 main()$ git log --oneline
v2.0.0
HEAD
2026-08-18v1.0.32026-05-25
v1.0.22026-05-22
v1.0.12026-05-22
v1.0.02026-05-21