CoolFace
Apppublic

localailb/assistant

sourceHugging Facemitupdated 1mo agoView on Hugging Face
0likes
playwright_search_tool.py1190 linesDownload Raw Back to agents
1"""playwright_search_tool.py
2
3Playwright-driven web search tools for Open Deep Research / rdtii, used ALONGSIDE the
4existing paid SerpAPI/Serper `GoogleSearchTool` and `DuckDuckGoSearchTool` (not a
5replacement for either).
6
7Two search tools are provided:
8  - PlaywrightDuckDuckGoSearchTool (RECOMMENDED as primary): scrapes DuckDuckGo's
9    server-rendered HTML results page. No JavaScript wait, no CAPTCHA/consent wall in
10    normal use, and stable markup -- the most scrape-friendly of the two.
11  - PlaywrightGoogleSearchTool (fallback): scrapes Google's JS-rendered results page.
12    Broader index (useful for some non-English/regional government sites DuckDuckGo
13    misses), but far more bot-detection friction -- occasional CAPTCHA/consent walls
14    and markup that shifts to resist scraping.
15
16Why either exists at all: SerpAPI/Serper cost money per query and DuckDuckGo (via the
17`ddgs` library) gets rate-limited under heavy/rapid use. These instead launch a real
18(headless) Chromium browser via Playwright and scrape results directly -- no paid API,
19no third-party search library, no per-query cost or account quota.
20
21They're drop-in-shaped tools: single `query` input, and a similar "numbered list of
22title/link/snippet" output shape as smolagents' GoogleSearchTool. Once wired in
23alongside the other search tools, the search_agent keeps using its existing tools
24(VisitTool, PageUpTool, PageDownTool, FinderTool, FindNextTool, ArchiveSearchTool,
25TextInspectorTool) to open, crawl, and read whatever pages these tools find -- only the
26initial "search" step differs.
27
28Requirements (not in requirements.txt by default -- add both):
29    pip install playwright
30    playwright install chromium
31
32Usage:
33    from playwright_search_tool import PlaywrightDuckDuckGoSearchTool, PlaywrightGoogleSearchTool
34    WEB_TOOLS = [GoogleSearchTool(...), DuckDuckGoSearchTool(), PlaywrightDuckDuckGoSearchTool(), PlaywrightGoogleSearchTool(), ...]
35
36---------------------------------------------------------------------------
37Why PlaywrightGoogleSearchTool's extraction logic is NOT based on Google's CSS class
38names (div.g, div.MjjYud, etc.):
39
40Google's result-page markup (a) changes its class names frequently and without
41notice, and (b) is frequently served *differently* to traffic it suspects is
42automated -- a plain, unauthenticated, headless-looking browser can get a
43stripped-down or reflowed results page where those old class names simply don't
44exist, even though a person opening the same query in a normal browser sees full
45results. Relying on class names is why an earlier version of this tool could report
46"No Google results found" for a query that clearly has results.
47
48Instead, extraction here is structural: Google's results almost always still put each
49organic result's title inside an <h3>, wrapped (directly or via an ancestor) in an
50<a href="..."> to the real destination. Walking up from every <h3> to its nearest
51linked ancestor is far more resistant to markup/class churn than any fixed selector
52list. A handful of anti-bot-detection tweaks (a realistic user agent, a normal
53viewport, hiding the `navigator.webdriver` flag, and waiting for actual content
54instead of a fixed sleep) also reduce how often Google serves the stripped-down page
55in the first place.
56
57DuckDuckGo's HTML endpoint below needs none of this -- it's server-rendered with no
58client-side JS to fight, which is exactly why it's the recommended primary tool.
59---------------------------------------------------------------------------
60"""
61from __future__ import annotations
62
63import json
64import os
65import tempfile
66import urllib.parse
67
68from smolagents import Tool
69
70_USER_AGENT = (
71    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
72    "(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
73)
74
75_CONSENT_BUTTON_LABELS = ("Accept all", "I agree", "Accept", "Reject all", "Alle akzeptieren")
76
77# Hosts that are Google's own chrome (nav bars, "Sign in", "Settings", cached-page
78# links, translate links, etc.) rather than an actual search result -- filtered out
79# of both the h3-based pass and the broad fallback pass below.
80_IGNORED_HOST_FRAGMENTS = (
81    "google.com/search", "google.com/preferences", "google.com/advanced_search",
82    "google.com/intl", "accounts.google.", "support.google.", "policies.google.",
83    "maps.google.", "webcache.googleusercontent.", "translate.google.",
84    "/url?q=/search", "consent.google.",
85)
86
87_ANTI_DETECTION_INIT_SCRIPT = """
88Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
89Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
90Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
91window.chrome = window.chrome || { runtime: {} };
92"""
93
94
95class PlaywrightDuckDuckGoSearchTool(Tool):
96    """Searches DuckDuckGo's plain HTML endpoint (html.duckduckgo.com/html/) with
97    Playwright. This is the PREFERRED Playwright search tool for automated use, ahead
98    of PlaywrightGoogleSearchTool below:
99
100    - The page is fully server-rendered HTML with no JavaScript required, so there's
101      no client-side rendering to wait for and no anti-bot-detection script needed.
102    - No CAPTCHA/consent wall in normal use (unlike Google, which frequently serves
103      one to automated-looking traffic).
104    - Stable, simple markup (a.result__a / .result__snippet) that changes far less
105      often than Google's, which is deliberately obfuscated/rotated against scrapers.
106
107    Trade-off: DuckDuckGo's index is thinner than Google's for some non-English or
108    regional government sites, which is why PlaywrightGoogleSearchTool is kept as an
109    explicit fallback rather than removed.
110    """
111
112    name = "playwright_duckduckgo_search"
113    description = (
114        "Performs a DuckDuckGo web search for your query and returns the top results (title, "
115        "link, short snippet) as a numbered list. Uses a real browser (Playwright) against "
116        "DuckDuckGo's plain HTML results page -- no JavaScript rendering wait, no CAPTCHA/consent "
117        "wall in normal use, and more stable markup than Google's. Use this as your PRIMARY search "
118        "tool for almost every search -- call it FIRST, before any other search tool. Only fall "
119        "back to the 'playwright_google_search' tool if this one comes back with no results, or a "
120        "topic needs Google's broader index (e.g. some non-English regional government sites). "
121        "Keep queries short and simple -- the plain topic name plus a country/keyword or two -- "
122        "rather than long boolean or quoted-phrase chains."
123    )
124    inputs = {
125        "query": {"type": "string", "description": "The search query to perform. Keep it short and simple."},
126    }
127    output_type = "string"
128
129    def __init__(self, max_results: int = 8, headless: bool = False, timeout_ms: int = 20000):
130        super().__init__()
131        self.max_results = max_results
132        self.headless = headless
133        self.timeout_ms = timeout_ms
134
135    def forward(self, query: str) -> str:
136        try:
137            from playwright.sync_api import sync_playwright
138        except ImportError as e:
139            raise ImportError(
140                "PlaywrightDuckDuckGoSearchTool needs the `playwright` package and its "
141                "browser binaries. Install with:\n"
142                "    pip install playwright\n"
143                "    playwright install chromium"
144            ) from e
145
146        query = (query or "").strip()
147        if not query:
148            return "Error: empty search query."
149
150        url = "https://html.duckduckgo.com/html/?" + urllib.parse.urlencode({"q": query})
151
152        with sync_playwright() as p:
153            browser = p.chromium.launch(
154                headless=self.headless,
155                args=["--disable-blink-features=AutomationControlled"],
156            )
157            try:
158                context = browser.new_context(
159                    user_agent=_USER_AGENT,
160                    locale="en-US",
161                    viewport={"width": 1366, "height": 900},
162                    extra_http_headers={"Accept-Language": "en-US,en;q=0.9"},
163                )
164                page = context.new_page()
165                # Server-rendered HTML -- domcontentloaded is enough, no networkidle wait
166                # needed the way the JS-heavy Google results page requires.
167                page.goto(url, timeout=self.timeout_ms, wait_until="domcontentloaded")
168
169                extracted = page.evaluate(
170                    """
171                    () => {
172                      const results = [];
173                      document.querySelectorAll('.result, .web-result').forEach(block => {
174                        const a = block.querySelector('a.result__a');
175                        if (!a || !a.href) return;
176                        const snippetEl = block.querySelector('.result__snippet');
177                        results.push({
178                          title: (a.innerText || '').trim(),
179                          link: a.href,
180                          snippet: snippetEl ? (snippetEl.innerText || '').trim() : '',
181                        });
182                      });
183                      return { results, pageTitle: document.title };
184                    }
185                    """
186                )
187                results = extracted.get("results", [])
188            finally:
189                browser.close()
190
191        if not results:
192            return (
193                f"No DuckDuckGo results found for '{query}'. Try a shorter/simpler query with "
194                f"playwright_duckduckgo_search again, OR call the fallback Google tool like this:\n"
195                f"```python\n"
196                f'result = playwright_google_search(query="{query}")\n'
197                f"print(result)\n"
198                f"```"
199            )
200
201        lines = [f"## DuckDuckGo Search Results for '{query}'\n"]
202        n = 0
203        for r in results:
204            link = (r.get("link") or "").strip()
205            if not link:
206                continue
207            n += 1
208            title = (r.get("title") or link).strip()
209            snippet = (r.get("snippet") or "").strip()
210            lines.append(f"{n}. [{title}]({link})\n{snippet}\n")
211            if n >= self.max_results:
212                break
213
214        return "\n".join(lines)
215
216
217class PlaywrightGoogleSearchTool(Tool):
218    """Searches Google by driving a real headless browser with Playwright, as an
219    additional, free alternative alongside the existing paid/rate-limited search
220    tools -- not a replacement for them.
221
222    A fresh, isolated browser is launched per call (rather than one shared browser
223    kept alive across calls) -- slightly slower per search, but avoids any
224    thread-safety concerns from Gradio potentially handling requests on different
225    threads, and keeps this tool's lifecycle dead simple (nothing to explicitly shut
226    down at app exit).
227    """
228
229    name = "playwright_google_search"
230    description = (
231        "Performs a Google web search for your query and returns the top results "
232        "(title, link, short snippet) as a numbered list. Uses a real browser "
233        "(Playwright) that types the query into google.com directly -- no paid "
234        "search API, no API key, and no third-party search library involved. This "
235        "is the FALLBACK search tool -- try 'playwright_duckduckgo_search' FIRST for "
236        "almost every search, and only call this one if that comes back with no "
237        "results or the topic needs Google's broader index. Keep queries short and "
238        "simple -- the plain topic name plus a country/keyword or two -- rather than "
239        "long boolean or quoted-phrase chains."
240    )
241    inputs = {
242        "query": {"type": "string", "description": "The search query to perform. Keep it short and simple."},
243        "filter_year": {
244            "type": "string",
245            "description": "(Optional) restrict results to this year, e.g. '2023'.",
246            "nullable": True,
247        },
248    }
249    output_type = "string"
250
251    def __init__(self, max_results: int = 8, headless: bool = False, timeout_ms: int = 20000, debug: bool = False):
252        super().__init__()
253        self.max_results = max_results
254        self.headless = headless
255        self.timeout_ms = timeout_ms
256        self.debug = debug
257
258    def forward(self, query: str, filter_year: str | None = None) -> str:
259        try:
260            from playwright.sync_api import sync_playwright
261        except ImportError as e:
262            raise ImportError(
263                "PlaywrightGoogleSearchTool needs the `playwright` package and its "
264                "browser binaries. Install with:\n"
265                "    pip install playwright\n"
266                "    playwright install chromium"
267            ) from e
268
269        query = (query or "").strip()
270        if not query:
271            return "Error: empty search query."
272
273        params = {"q": query, "num": str(max(self.max_results, 10)), "hl": "en", "gl": "us", "pws": "0"}
274        if filter_year:
275            params["tbs"] = f"cdr:1,cd_min:1/1/{filter_year},cd_max:12/31/{filter_year}"
276        url = "https://www.google.com/search?" + urllib.parse.urlencode(params)
277
278        page_info = ""
279        with sync_playwright() as p:
280            browser = p.chromium.launch(
281                headless=self.headless,
282                args=["--disable-blink-features=AutomationControlled"],
283            )
284            try:
285                context = browser.new_context(
286                    user_agent=_USER_AGENT,
287                    locale="en-US",
288                    viewport={"width": 1366, "height": 900},
289                    extra_http_headers={"Accept-Language": "en-US,en;q=0.9"},
290                )
291                context.add_init_script(_ANTI_DETECTION_INIT_SCRIPT)
292                page = context.new_page()
293                page.goto(url, timeout=self.timeout_ms, wait_until="domcontentloaded")
294
295                # Best-effort consent-dialog dismissal (Google's EU/UK cookie wall) --
296                # harmless no-op if no such dialog appears for this session/region.
297                for label in _CONSENT_BUTTON_LABELS:
298                    try:
299                        page.get_by_role("button", name=label).click(timeout=1200)
300                        page.wait_for_load_state("domcontentloaded", timeout=self.timeout_ms)
301                        break
302                    except Exception:
303                        continue
304
305                # Wait for actual result content rather than a fixed sleep -- far more
306                # reliable across Google's varying render times. If nothing shows up
307                # in time, keep going anyway and let the extraction below report
308                # honestly (this also surfaces a captcha/consent page as "no results"
309                # rather than hanging).
310                try:
311                    page.wait_for_selector("h3, #rso, #search", timeout=6000)
312                except Exception:
313                    pass
314                page.wait_for_timeout(400)
315
316                extracted = page.evaluate(
317                    """
318                    ([ignoredFragments]) => {
319                      const isIgnored = (href) => ignoredFragments.some(f => href.includes(f));
320
321                      const results = [];
322                      const seenLinks = new Set();
323
324                      // Primary pass: every <h3> (the title Google puts on each organic
325                      // result), walking up to its nearest linked ancestor.
326                      document.querySelectorAll('h3').forEach(h3 => {
327                        const a = h3.closest('a[href]') || h3.parentElement?.querySelector('a[href]');
328                        if (!a || !a.href || !a.href.startsWith('http')) return;
329                        if (isIgnored(a.href) || seenLinks.has(a.href)) return;
330
331                        // Snippet: look at the result's outer block (a few levels up
332                        // from the link) and take its text, minus the title itself.
333                        let block = a.closest('div[data-hveid], div.g, div') || a.parentElement;
334                        let text = block ? block.innerText : '';
335                        const title = h3.innerText || '';
336                        if (title && text.startsWith(title)) text = text.slice(title.length);
337                        text = text.split('\\n').map(s => s.trim()).filter(Boolean).slice(0, 2).join(' ');
338
339                        seenLinks.add(a.href);
340                        results.push({ title: title || a.href, link: a.href, snippet: text });
341                      });
342
343                      // Fallback pass: if the h3-based pass found nothing (e.g. an
344                      // unusual layout variant), broadly scan external links with
345                      // non-trivial link text instead.
346                      if (results.length === 0) {
347                        document.querySelectorAll('a[href^="http"]').forEach(a => {
348                          const href = a.href;
349                          if (isIgnored(href) || seenLinks.has(href)) return;
350                          const text = (a.innerText || '').trim();
351                          if (text.length < 8) return;
352                          seenLinks.add(href);
353                          results.push({ title: text, link: href, snippet: '' });
354                        });
355                      }
356
357                      return {
358                        results: results.slice(0, 30),
359                        pageTitle: document.title,
360                        bodyLen: document.body ? document.body.innerText.length : 0,
361                      };
362                    }
363                    """,
364                    [_IGNORED_HOST_FRAGMENTS],
365                )
366                results = extracted.get("results", [])
367                if self.debug:
368                    page_info = (
369                        f" [debug: page title='{extracted.get('pageTitle')}', "
370                        f"body chars={extracted.get('bodyLen')}]"
371                    )
372            finally:
373                browser.close()
374
375        if not results:
376            year_note = f" (filtered to {filter_year})" if filter_year else ""
377            return (
378                f"No Google results found for '{query}'{year_note}.{page_info} This can happen if Google "
379                "served a consent/CAPTCHA page instead of results. Try playwright_duckduckgo_search "
380                "instead, like this:\n"
381                f"```python\n"
382                f'result = playwright_duckduckgo_search(query="{query}")\n'
383                f"print(result)\n"
384                f"```"
385            )
386
387        lines = [f"## Search Results for '{query}'{page_info}\n"]
388        n = 0
389        for r in results:
390            link = (r.get("link") or "").strip()
391            if not link:
392                continue
393            n += 1
394            title = (r.get("title") or link).strip()
395            snippet = (r.get("snippet") or "").strip()
396            lines.append(f"{n}. [{title}]({link})\n{snippet}\n")
397            if n >= self.max_results:
398                break
399
400        return "\n".join(lines)
401
402
403# ---------------------------------------------------------------------------
404# Extensions/keywords that make a link look like the actual downloadable document
405# file rather than a landing/details page about it. Deliberately broad (covers the
406# common legal-document file types plus generic "get me the file" wording) -- a
407# false positive here just means the agent double-checks a link that turns out to
408# be another HTML page, which playwright_visit_page reports honestly either way.
409# ---------------------------------------------------------------------------
410_DOWNLOAD_EXT_RE = r"\.(pdf|docx?|rtf|txt)(\?|#|$)"
411_DOWNLOAD_KEYWORD_RE = r"download|full[\s_-]?text|view[\s_-]?(the[\s_-])?(authoris|full)|attachment|/file/"
412
413
414class PlaywrightVisitPageTool(Tool):
415    """Opens a URL with a real headless Chromium browser (Playwright) and returns its
416    visible text plus every link on the page, with links that look like an actual
417    downloadable document (by file extension or surrounding wording) flagged and
418    listed first. This is the "read/browse a page" half of a Playwright-only
419    search-and-browse loop -- paired with PlaywrightGoogleSearchTool for the
420    "search" half -- so neither step needs the `requests` library, a paid search
421    API, or the existing requests-based SimpleTextBrowser.
422
423    If the URL itself resolves directly to a non-HTML file (a PDF, DOCX, etc.),
424    that's reported immediately as "this URL IS the document" instead of trying to
425    scrape it as a web page -- the agent doesn't need to visit it a second time.
426    """
427
428    name = "playwright_visit_page"
429    description = (
430        "Opens a URL with a real browser (Playwright) and returns the page's visible text plus "
431        "every link found on it, with links that look like an actual downloadable document file "
432        "(PDF/DOC/DOCX/TXT, or wording like 'Download'/'Full text'/'View authorised version') "
433        "flagged with a leading '⬇️ DOWNLOAD?' marker and listed first. Use this to read a search "
434        "result page, then call it again on any flagged link found on that page to go one level "
435        "deeper (e.g. from a government 'Details' page to the actual PDF it links to). If the URL "
436        "you pass in is itself already a direct file (not an HTML page), this tool reports that "
437        "immediately instead of trying to scrape it as a web page."
438    )
439    inputs = {
440        "url": {"type": "string", "description": "The exact URL to open (from a search result or a link found on a previously visited page)."},
441    }
442    output_type = "string"
443
444    def __init__(
445        self,
446        headless: bool = False,
447        timeout_ms: int = 20000,
448        max_text_chars: int = 5000,
449        max_links: int = 40,
450    ):
451        super().__init__()
452        self.headless = headless
453        self.timeout_ms = timeout_ms
454        self.max_text_chars = max_text_chars
455        self.max_links = max_links
456
457    def forward(self, url: str) -> str:
458        try:
459            from playwright.sync_api import sync_playwright
460        except ImportError as e:
461            raise ImportError(
462                "PlaywrightVisitPageTool needs the `playwright` package and its browser "
463                "binaries. Install with:\n"
464                "    pip install playwright\n"
465                "    playwright install chromium"
466            ) from e
467
468        url = (url or "").strip()
469        if not url:
470            return "Error: empty URL."
471        if not (url.startswith("http://") or url.startswith("https://")):
472            return f"Error: '{url}' is not an http(s) URL."
473
474        with sync_playwright() as p:
475            browser = p.chromium.launch(
476                headless=self.headless,
477                args=["--disable-blink-features=AutomationControlled"],
478            )
479            try:
480                context = browser.new_context(
481                    user_agent=_USER_AGENT,
482                    locale="en-US",
483                    viewport={"width": 1366, "height": 900},
484                    extra_http_headers={"Accept-Language": "en-US,en;q=0.9"},
485                    accept_downloads=True,
486                )
487                context.add_init_script(_ANTI_DETECTION_INIT_SCRIPT)
488                page = context.new_page()
489
490                try:
491                    response = page.goto(url, timeout=self.timeout_ms, wait_until="domcontentloaded")
492                except Exception as e:
493                    # A direct file link (e.g. a PDF) sometimes triggers Playwright's own
494                    # download handling rather than a normal navigation, which surfaces as
495                    # an exception here rather than a response -- treat that as a strong
496                    # signal this URL IS a downloadable file, not a real navigation failure.
497                    if "Download is starting" in str(e):
498                        return (
499                            f"'{url}' triggered a file download when opened -- this URL IS the "
500                            "document itself (not an HTML landing page). Use it directly as the "
501                            "document's URL; no further browsing of this link is needed."
502                        )
503                    return f"Error: could not load '{url}': {e}"
504
505                status = response.status if response else None
506                if status and status >= 400:
507                    return f"Error: '{url}' returned HTTP {status}."
508
509                content_type = (response.headers.get("content-type", "") if response else "").lower()
510                if content_type and "html" not in content_type and "text/plain" not in content_type:
511                    cl = response.headers.get("content-length") if response else None
512                    size_note = f", {cl} bytes" if cl and cl.isdigit() else ""
513                    return (
514                        f"'{url}' is itself a direct downloadable file (Content-Type: "
515                        f"{content_type}{size_note}). This URL IS the document -- no further "
516                        "browsing of this link is needed."
517                    )
518
519                # Best-effort settle for JS-rendered content -- keep going even if the page
520                # never truly goes idle (some sites keep long-poll/analytics connections open).
521                try:
522                    page.wait_for_load_state("networkidle", timeout=min(self.timeout_ms, 8000))
523                except Exception:
524                    pass
525
526                data = page.evaluate(
527                    """
528                    ([downloadExtRe, downloadKeywordRe]) => {
529                      const extRe = new RegExp(downloadExtRe, 'i');
530                      const kwRe = new RegExp(downloadKeywordRe, 'i');
531                      const seen = new Set();
532                      const links = [];
533                      document.querySelectorAll('a[href^="http"]').forEach(a => {
534                        const href = a.href;
535                        if (!href || seen.has(href)) return;
536                        const text = (a.innerText || a.textContent || '').trim().replace(/\\s+/g, ' ');
537                        if (!text && !extRe.test(href)) return;
538                        seen.add(href);
539                        const likelyDownload = extRe.test(href) || kwRe.test(href) || kwRe.test(text);
540                        links.push({ href, text: text.slice(0, 120), likelyDownload });
541                      });
542                      return {
543                        title: document.title || '',
544                        text: document.body ? document.body.innerText : '',
545                        links,
546                      };
547                    }
548                    """,
549                    [_DOWNLOAD_EXT_RE, _DOWNLOAD_KEYWORD_RE],
550                )
551            finally:
552                browser.close()
553
554        title = data.get("title", "") or url
555        text = (data.get("text", "") or "").strip()
556        if len(text) > self.max_text_chars:
557            text = text[: self.max_text_chars] + "\n...[truncated]"
558
559        links = data.get("links", [])
560        flagged = [ln for ln in links if ln.get("likelyDownload")]
561        other = [ln for ln in links if not ln.get("likelyDownload")]
562        shown = (flagged + other)[: self.max_links]
563
564        lines = [f"## Page: {title}\nURL: {url}\n", "### Content\n", text or "(no visible text extracted)", ""]
565        lines.append(f"### Links found on this page ({len(links)} total, showing {len(shown)})")
566        if flagged:
567            lines.append(f"({len(flagged)} flagged as possible document downloads -- listed first)")
568        for i, ln in enumerate(shown, start=1):
569            marker = "⬇️ DOWNLOAD? " if ln.get("likelyDownload") else ""
570            label = ln.get("text") or ln.get("href")
571            lines.append(f"{i}. {marker}[{label}]({ln.get('href')})")
572
573        _save_page_cache(url, title, text)
574
575        return "\n".join(lines)
576
577
578# ---------------------------------------------------------------------------
579# PlaywrightExtractLegalDocumentLinksTool
580#
581# Purpose: a dedicated "extract every link, then score/rank it against my
582# target topic" step -- run BEFORE opening individual candidate pages one by
583# one. Many government/legal-register sites (results listings, regulator
584# publication pages, gazette indexes) are JavaScript-rendered, so a plain
585# `requests`-based link scrape misses most or all of the real links; this
586# reuses the same Playwright browser the rest of this module already drives.
587#
588# Deliberately a SEPARATE tool from PlaywrightVisitPageTool rather than a mode
589# flag on it: PlaywrightVisitPageTool's job is "read this one page's content +
590# its links"; this tool's job is "given a page, tell me which of its links are
591# actually worth opening for a SPECIFIC target topic" -- it takes a
592# topic_keywords argument and returns a ranked, scored list, not raw content.
593# Named specifically (not "playwright_extract_links") so it's unambiguous next
594# to playwright_visit_page / playwright_duckduckgo_search / playwright_google_search
595# in a tool list, and so the agent doesn't confuse it with a generic scraper.
596# ---------------------------------------------------------------------------
597class PlaywrightExtractLegalDocumentLinksTool(Tool):
598    """Opens a URL with a real browser (Playwright -- required for JavaScript-
599    rendered listing/index pages a plain HTTP request can't see the links on),
600    extracts every link on the page, scores each one for relevance against a
601    target legal topic (keyword overlap with the link's visible text/URL, plus
602    a bonus for links that look like an actual downloadable document file or
603    carry download-style wording), and returns a ranked list -- most relevant
604    first -- instead of a flat, unordered dump of every link on the page.
605
606    Use this BEFORE opening individual candidate pages one by one: run it on a
607    search-result page or a regulator's publications/listing page, look at the
608    top-ranked links, and only then call `playwright_visit_page` on the ones
609    that actually look relevant. If it reports zero links at all (a page with
610    no outbound links, or a direct file URL), fall back to reading the page's
611    own content with `playwright_visit_page` instead.
612    """
613
614    name = "playwright_extract_legal_document_links"
615    description = (
616        "Opens a URL with a real browser (Playwright) and extracts every link on the page, then "
617        "scores and ranks each link by how relevant it looks to a target legal topic you specify "
618        "(keyword overlap with the link's visible text/URL, plus a bonus for links that look like "
619        "an actual downloadable document file -- PDF/DOC/DOCX/TXT -- or carry wording like "
620        "'Download'/'Full text'/'View authorised version'). Returns the top-ranked links first, "
621        "each with its relevance score, so you can decide which ones are actually worth opening "
622        "before visiting them one by one -- use this BEFORE playwright_visit_page on a search-"
623        "result page, a regulator's publications/listing page, or any other page likely to link "
624        "out to several documents. Needs a real browser because many such pages are JavaScript-"
625        "rendered and a plain HTTP request can't see their links at all. If this reports zero "
626        "links (e.g. the URL is itself a direct file, or a page with no outbound links), fall "
627        "back to reading the page's own content with playwright_visit_page instead."
628    )
629    inputs = {
630        "url": {"type": "string", "description": "The exact URL of the page whose links you want to extract and rank."},
631        "topic_keywords": {
632            "type": "string",
633            "description": (
634                "Short, plain keywords describing the legal document/topic you're looking for, e.g. "
635                "'cross-border data policy Cambodia' or 'data protection act Philippines'. Used to "
636                "score each link's relevance -- keep it short, the same way you'd phrase a search query."
637            ),
638        },
639    }
640    output_type = "string"
641
642    def __init__(
643        self,
644        headless: bool = False,
645        timeout_ms: int = 20000,
646        max_links_extracted: int = 200,
647        max_links_shown: int = 25,
648    ):
649        super().__init__()
650        self.headless = headless
651        self.timeout_ms = timeout_ms
652        self.max_links_extracted = max_links_extracted
653        self.max_links_shown = max_links_shown
654
655    @staticmethod
656    def _score_link(text: str, href: str, keywords: list[str]) -> int:
657        """Cheap, deterministic relevance score -- no LLM call. Counts keyword
658        hits in the link's visible text and URL (text hits weighted higher,
659        since a link's own wording is a stronger relevance signal than
660        incidental words in its URL path), plus a flat bonus for anything that
661        already looks like a direct document download (by extension or
662        surrounding wording, reusing this module's own download-detection
663        regexes so the signal is consistent with playwright_visit_page)."""
664        import re as _re
665
666        text_l = (text or "").lower()
667        href_l = (href or "").lower()
668        score = 0
669        for kw in keywords:
670            kw = kw.lower().strip()
671            if not kw:
672                continue
673            if kw in text_l:
674                score += 3
675            if kw in href_l:
676                score += 1
677        if _re.search(_DOWNLOAD_EXT_RE, href_l):
678            score += 5
679        if _re.search(_DOWNLOAD_KEYWORD_RE, href_l) or _re.search(_DOWNLOAD_KEYWORD_RE, text_l):
680            score += 3
681        return score
682
683    def forward(self, url: str, topic_keywords: str) -> str:
684        try:
685            from playwright.sync_api import sync_playwright
686        except ImportError as e:
687            raise ImportError(
688                "PlaywrightExtractLegalDocumentLinksTool needs the `playwright` package and its "
689                "browser binaries. Install with:\n"
690                "    pip install playwright\n"
691                "    playwright install chromium"
692            ) from e
693
694        url = (url or "").strip()
695        if not url:
696            return "Error: empty URL."
697        if not (url.startswith("http://") or url.startswith("https://")):
698            return f"Error: '{url}' is not an http(s) URL."
699
700        keywords = [w for w in (topic_keywords or "").replace(",", " ").split() if len(w) > 2]
701
702        with sync_playwright() as p:
703            browser = p.chromium.launch(
704                headless=self.headless,
705                args=["--disable-blink-features=AutomationControlled"],
706            )
707            try:
708                context = browser.new_context(
709                    user_agent=_USER_AGENT,
710                    locale="en-US",
711                    viewport={"width": 1366, "height": 900},
712                    extra_http_headers={"Accept-Language": "en-US,en;q=0.9"},
713                    accept_downloads=True,
714                )
715                context.add_init_script(_ANTI_DETECTION_INIT_SCRIPT)
716                page = context.new_page()
717
718                try:
719                    response = page.goto(url, timeout=self.timeout_ms, wait_until="domcontentloaded")
720                except Exception as e:
721                    if "Download is starting" in str(e):
722                        return (
723                            f"'{url}' triggered a file download when opened -- this URL IS a document "
724                            "itself, not a listing/index page with links to extract. Use it directly as "
725                            "a candidate document; there are no links to rank here."
726                        )
727                    return f"Error: could not load '{url}': {e}"
728
729                status = response.status if response else None
730                if status and status >= 400:
731                    return f"Error: '{url}' returned HTTP {status}."
732
733                content_type = (response.headers.get("content-type", "") if response else "").lower()
734                if content_type and "html" not in content_type and "text/plain" not in content_type:
735                    return (
736                        f"'{url}' is itself a direct downloadable file (Content-Type: {content_type}). "
737                        "This URL IS a document -- there are no links to extract/rank here."
738                    )
739
740                try:
741                    page.wait_for_load_state("networkidle", timeout=min(self.timeout_ms, 8000))
742                except Exception:
743                    pass
744
745                data = page.evaluate(
746                    """
747                    () => {
748                      const seen = new Set();
749                      const links = [];
750                      document.querySelectorAll('a[href^="http"]').forEach(a => {
751                        const href = a.href;
752                        if (!href || seen.has(href)) return;
753                        const text = (a.innerText || a.textContent || '').trim().replace(/\\s+/g, ' ');
754                        seen.add(href);
755                        links.push({ href, text: text.slice(0, 160) });
756                      });
757                      return { title: document.title || '', links };
758                    }
759                    """
760                )
761            finally:
762                browser.close()
763
764        title = data.get("title", "") or url
765        links = data.get("links", [])[: self.max_links_extracted]
766
767        if not links:
768            return (
769                f"'{url}' (page: {title}) has no outbound http(s) links to extract. Fall back to "
770                "reading this page's own content with playwright_visit_page instead."
771            )
772
773        scored = []
774        for ln in links:
775            href, text = ln.get("href", ""), ln.get("text", "")
776            scored.append((self._score_link(text, href, keywords), href, text))
777        scored.sort(key=lambda t: t[0], reverse=True)
778
779        shown = scored[: self.max_links_shown]
780        n_relevant = sum(1 for s, _, _ in scored if s > 0)
781
782        lines = [
783            f"## Links extracted from: {title}\nURL: {url}\n",
784            f"Keywords used for scoring: {', '.join(keywords) if keywords else '(none given)'}\n",
785            f"### Ranked links ({len(links)} total found, {n_relevant} scored relevant, showing top {len(shown)})\n",
786            "Open the highest-scoring links first with playwright_visit_page. A score of 0 means no "
787            "keyword or download signal matched at all -- usually site navigation/boilerplate, not a "
788            "candidate document.\n",
789        ]
790        for i, (score, href, text) in enumerate(shown, start=1):
791            label = text or href
792            lines.append(f"{i}. [score={score}] [{label}]({href})")
793
794        if n_relevant == 0:
795            lines.append(
796                "\nNote: nothing on this page scored above 0 for the given keywords -- consider a "
797                "different topic_keywords phrasing, or fall back to reading the page's own content "
798                "with playwright_visit_page in case the relevant text (not a link) is on this page "
799                "itself."
800            )
801
802        return "\n".join(lines)
803
804
805class PlaywrightReadEmbeddedPdfTool(Tool):
806    """Opens a URL using Playwright, scans the page DOM to identify any embedded PDF files
807    (via iframe, embed, or object tags, or direct PDF download links), downloads the PDF,
808    and extracts its text content using pdfplumber to return to the agent.
809    """
810
811    name = "playwright_read_embedded_pdf"
812    description = (
813        "Opens a webpage URL using Playwright, scans the DOM to identify any embedded PDF files "
814        "(in iframes, embed/object tags, or direct PDF links), downloads the PDF, and extracts "
815        "its text content using pdfplumber. Use this tool if you suspect a page contains an "
816        "embedded PDF viewer displaying document text that isn't captured by normal page reading."
817    )
818    inputs = {
819        "url": {"type": "string", "description": "The URL of the webpage that contains the embedded PDF."},
820    }
821    output_type = "string"
822
823    def __init__(
824        self,
825        headless: bool = False,
826        timeout_ms: int = 20000,
827        max_pdf_chars: int = 25000,
828    ):
829        super().__init__()
830        self.headless = headless
831        self.timeout_ms = timeout_ms
832        self.max_pdf_chars = max_pdf_chars
833
834    def forward(self, url: str) -> str:
835        try:
836            from playwright.sync_api import sync_playwright
837        except ImportError as e:
838            raise ImportError(
839                "PlaywrightReadEmbeddedPdfTool needs the `playwright` package. Install with:\n"
840                "    pip install playwright\n"
841                "    playwright install chromium"
842            ) from e
843
844        import requests
845        import tempfile
846        import os
847        import pdfplumber  # MIT — replaced PyMuPDF (AGPL) for Store distribution
848        import urllib.parse
849
850        url = (url or "").strip()
851        if not url:
852            return "Error: empty URL."
853
854        # If the input URL is already a PDF, process it directly
855        if url.lower().endswith(".pdf"):
856            pdf_urls = [url]
857            page_title = "Direct PDF Link"
858        else:
859            if not (url.startswith("http://") or url.startswith("https://")):
860                return f"Error: '{url}' is not an http(s) URL."
861
862            pdf_urls = []
863            page_title = url
864            with sync_playwright() as p:
865                browser = p.chromium.launch(
866                    headless=self.headless,
867                    args=["--disable-blink-features=AutomationControlled"],
868                )
869                try:
870                    context = browser.new_context(
871                        user_agent=_USER_AGENT,
872                        locale="en-US",
873                        viewport={"width": 1366, "height": 900},
874                        extra_http_headers={"Accept-Language": "en-US,en;q=0.9"},
875                        accept_downloads=True,
876                    )
877                    context.add_init_script(_ANTI_DETECTION_INIT_SCRIPT)
878                    page = context.new_page()
879
880                    try:
881                        response = page.goto(url, timeout=self.timeout_ms, wait_until="domcontentloaded")
882                        page_title = page.title() or url
883                    except Exception as e:
884                        if "Download is starting" in str(e):
885                            # The page URL itself triggered a direct download
886                            pdf_urls = [url]
887                        else:
888                            return f"Error: could not load '{url}': {e}"
889
890                    if not pdf_urls:
891                        # Scan the DOM for embedded PDFs
892                        detected = page.evaluate(
893                            """
894                            () => {
895                              const urls = new Set();
896                              
897                              // Check iframes (common for PDF.js or direct PDF embed)
898                              document.querySelectorAll('iframe').forEach(el => {
899                                const src = el.src || '';
900                                if (!src) return;
901                                if (src.toLowerCase().includes('.pdf')) {
902                                  urls.add(src);
903                                }
904                                // Try extracting from common PDF viewer query params like viewer.html?file=URL
905                                const fileParam = src.match(/[?&]file=([^&]+)/);
906                                if (fileParam) {
907                                  try { urls.add(decodeURIComponent(fileParam[1])); } catch(err) {}
908                                }
909                              });
910
911                              // Check embed tags
912                              document.querySelectorAll('embed').forEach(el => {
913                                const src = el.src || '';
914                                if (src && (src.toLowerCase().includes('.pdf') || (el.type || '').toLowerCase() === 'application/pdf')) {
915                                  urls.add(src);
916                                }
917                              });
918
919                              // Check object tags
920                              document.querySelectorAll('object').forEach(el => {
921                                const data = el.data || '';
922                                if (data && (data.toLowerCase().includes('.pdf') || (el.type || '').toLowerCase() === 'application/pdf')) {
923                                  urls.add(data);
924                                }
925                              });
926
927                              // Check links that look like PDFs
928                              document.querySelectorAll('a[href]').forEach(el => {
929                                const href = el.href || '';
930                                const text = (el.innerText || el.textContent || '').trim().toLowerCase();
931                                if (href.toLowerCase().includes('.pdf') || text.includes('view pdf') || text.includes('download pdf')) {
932                                  urls.add(href);
933                                }
934                              });
935
936                              return Array.from(urls);
937                            }
938                            """
939                        )
940                        # Resolve absolute URLs
941                        for u in detected:
942                            resolved = urllib.parse.urljoin(url, u)
943                            if resolved not in pdf_urls:
944                                pdf_urls.append(resolved)
945                finally:
946                    browser.close()
947
948        if not pdf_urls:
949            return f"No embedded PDFs or direct PDF links found on webpage: '{url}'."
950
951        # Fetch and extract text from the detected PDFs (up to 3 to avoid excessive time/text limit issues)
952        lines = [f"## Webpage Analyzed: {page_title}", f"URL: {url}", f"Detected {len(pdf_urls)} embedded PDF(s). Processing..."]
953        
954        for idx, pdf_url in enumerate(pdf_urls[:3], 1):
955            lines.append(f"\n--- PDF #{idx}: {pdf_url} ---")
956            temp_path = None
957            try:
958                headers = {"User-Agent": _USER_AGENT}
959                res = requests.get(pdf_url, headers=headers, timeout=20, stream=True)
960                if res.status_code >= 400:
961                    lines.append(f"Error downloading PDF: HTTP Status {res.status_code}")
962                    continue
963                
964                # Write to temp file
965                with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tf:
966                    for chunk in res.iter_content(chunk_size=8192):
967                        tf.write(chunk)
968                    temp_path = tf.name
969                
970                # Extract text using pdfplumber (MIT)
971                pdf_text_parts = []
972                with pdfplumber.open(temp_path) as pdf:
973                    for page in pdf.pages:
974                        page_text = page.extract_text() or ""
975                        pdf_text_parts.append(page_text)
976                        if sum(len(x) for x in pdf_text_parts) > self.max_pdf_chars:
977                            pdf_text_parts.append("\n...[truncated due to length limit]...")
978                            break
979
980                extracted_text = "\n".join(pdf_text_parts).strip()
981                if extracted_text:
982                    lines.append(extracted_text)
983                else:
984                    lines.append("(No text could be extracted from this PDF -- it might be scanned image pages)")
985            except Exception as pdf_err:
986                lines.append(f"Failed to process PDF: {pdf_err}")
987            finally:
988                if temp_path and os.path.exists(temp_path):
989                    try:
990                        os.remove(temp_path)
991                    except:
992                        pass
993                        
994        return "\n".join(lines)
995
996
997# ── Shared page state for navigation tools ──────────────────────────
998# PlaywrightVisitPageTool caches the last-visited page here so
999# PageUp/PageDown/FindOnPage/FindNext can navigate within its content
1000# without re-launching a browser.
1001_page_cache = {
1002    "url": None,
1003    "title": None,
1004    "full_text": "",
1005    "viewport_pos": 0,
1006    "viewport_size": 2000,
1007    "find_query": None,
1008    "find_last_viewport": None,
1009}
1010
1011
1012def _save_page_cache(url, title, text):
1013    _page_cache["url"] = url
1014    _page_cache["title"] = title
1015    _page_cache["full_text"] = text or ""
1016    _page_cache["viewport_pos"] = 0
1017    _page_cache["find_query"] = None
1018    _page_cache["find_last_viewport"] = None
1019
1020
1021def _format_viewport(cache):
1022    total = len(cache["full_text"])
1023    vsize = cache["viewport_size"]
1024    pos = cache["viewport_pos"]
1025    total_pages = max(1, (total + vsize - 1) // vsize)
1026    current_page = pos // vsize + 1
1027    content = cache["full_text"][pos:pos + vsize]
1028    return (
1029        f"Address: {cache['url']}\n"
1030        f"Title: {cache['title']}\n"
1031        f"Viewport position: Showing page {current_page} of {total_pages}.\n"
1032        f"=======================\n"
1033        f"{content}"
1034    )
1035
1036
1037class PlaywrightPageDownTool(Tool):
1038    name = "playwright_page_down"
1039    description = "Scroll the viewport DOWN one page-length in the currently visited page and return the new viewport content."
1040    inputs = {}
1041    output_type = "string"
1042
1043    def forward(self) -> str:
1044        cache = _page_cache
1045        if not cache["url"]:
1046            return "No page has been visited yet. Use playwright_visit_page first."
1047        total = len(cache["full_text"])
1048        cache["viewport_pos"] = min(cache["viewport_pos"] + cache["viewport_size"], max(0, total - 1))
1049        return _format_viewport(cache)
1050
1051
1052class PlaywrightPageUpTool(Tool):
1053    name = "playwright_page_up"
1054    description = "Scroll the viewport UP one page-length in the currently visited page and return the new viewport content."
1055    inputs = {}
1056    output_type = "string"
1057
1058    def forward(self) -> str:
1059        cache = _page_cache
1060        if not cache["url"]:
1061            return "No page has been visited yet. Use playwright_visit_page first."
1062        cache["viewport_pos"] = max(cache["viewport_pos"] - cache["viewport_size"], 0)
1063        return _format_viewport(cache)
1064
1065
1066class PlaywrightFindOnPageTool(Tool):
1067    name = "playwright_find_on_page"
1068    description = "Scroll the viewport to the first occurrence of the search string. This is equivalent to Ctrl+F on the currently visited page."
1069    inputs = {
1070        "search_string": {
1071            "type": "string",
1072            "description": "The string to search for on the page. Supports wildcards like '*'.",
1073        }
1074    }
1075    output_type = "string"
1076
1077    def forward(self, search_string: str) -> str:
1078        cache = _page_cache
1079        if not cache["url"]:
1080            return "No page has been visited yet. Use playwright_visit_page first."
1081
1082        import re
1083        query = re.sub(r"\*", ".*", re.escape(search_string))
1084        full = cache["full_text"]
1085
1086        match = re.search(query, full, re.IGNORECASE)
1087        if not match:
1088            cache["find_query"] = search_string
1089            cache["find_last_viewport"] = None
1090            return (
1091                f"Address: {cache['url']}\n"
1092                f"Title: {cache['title']}\n"
1093                f"=======================\n"
1094                f"The search string '{search_string}' was not found on this page."
1095            )
1096
1097        pos = match.start()
1098        vsize = cache["viewport_size"]
1099        cache["viewport_pos"] = max(0, pos - vsize // 4)
1100        cache["find_query"] = search_string
1101        cache["find_last_viewport"] = cache["viewport_pos"]
1102        return _format_viewport(cache)
1103
1104
1105class PlaywrightFindNextTool(Tool):
1106    name = "playwright_find_next"
1107    description = "Scroll the viewport to the next occurrence of the search string. Use after playwright_find_on_page."
1108    inputs = {}
1109    output_type = "string"
1110
1111    def forward(self) -> str:
1112        cache = _page_cache
1113        if not cache["url"]:
1114            return "No page has been visited yet. Use playwright_visit_page first."
1115        if not cache["find_query"]:
1116            return "No active search. Use playwright_find_on_page first."
1117
1118        import re
1119        query = re.sub(r"\*", ".*", re.escape(cache["find_query"]))
1120        full = cache["full_text"]
1121        start = cache["viewport_pos"] + cache["viewport_size"]
1122        if cache["find_last_viewport"] is not None and cache["find_last_viewport"] >= start:
1123            start = cache["find_last_viewport"] + 1
1124
1125        if start >= len(full):
1126            start = 0
1127
1128        match = re.search(query, full[start:], re.IGNORECASE)
1129        if not match:
1130            cache["find_last_viewport"] = None
1131            return (
1132                f"Address: {cache['url']}\n"
1133                f"Title: {cache['title']}\n"
1134                f"=======================\n"
1135                f"No more occurrences of '{cache['find_query']}' found."
1136            )
1137
1138        pos = start + match.start()
1139        vsize = cache["viewport_size"]
1140        cache["viewport_pos"] = max(0, pos - vsize // 4)
1141        cache["find_last_viewport"] = cache["viewport_pos"]
1142        return _format_viewport(cache)
1143
1144
1145class PlaywrightArchiveSearchTool(Tool):
1146    name = "playwright_find_archived_url"
1147    description = "Given a url, searches the Wayback Machine and returns the archived version of the url closest to the desired date. Use this when a page is dead, blocked, or has changed since the date you care about."
1148    inputs = {
1149        "url": {
1150            "type": "string",
1151            "description": "The url you need the archive for.",
1152        },
1153        "date": {
1154            "type": "string",
1155            "description": "The date to find the archive for, in 'YYYYMMDD' format (e.g. '27 June 2008' → '20080627').",
1156            "nullable": True,
1157        },
1158    }
1159    output_type = "string"
1160
1161    def forward(self, url: str, date: str | None = None) -> str:
1162        import requests
1163
1164        no_timestamp_url = f"https://archive.org/wayback/available?url={urllib.parse.quote(url)}"
1165        archive_url = no_timestamp_url + (f"&timestamp={date}" if date else "")
1166        try:
1167            response = requests.get(archive_url, timeout=30).json()
1168        except Exception as e:
1169            return f"Error querying Wayback Machine: {e}"
1170
1171        if "archived_snapshots" in response and "closest" in response["archived_snapshots"]:
1172            closest = response["archived_snapshots"]["closest"]
1173        else:
1174            try:
1175                response2 = requests.get(no_timestamp_url, timeout=30).json()
1176                if "archived_snapshots" in response2 and "closest" in response2["archived_snapshots"]:
1177                    closest = response2["archived_snapshots"]["closest"]
1178                else:
1179                    return f"No archive found for '{url}' on Wayback Machine."
1180            except Exception as e:
1181                return f"Error querying Wayback Machine: {e}"
1182
1183        target_url = closest["url"]
1184        snapshot_date = closest["timestamp"][:8] if "timestamp" in closest else "unknown"
1185        return (
1186            f"Web archive for url {url}, snapshot taken at date {snapshot_date}:\n"
1187            f"Archived URL: {target_url}\n\n"
1188            f"To view this archived page, call playwright_visit_page with the archived URL above."
1189        )
1190