Rasterize

Converts URLs, PDF files, and emails to an image file or PDF file.

Utilities · Rasterize

Details

IDRasterize
ProviderOpen Source
CategoryUtilities
From Version5.0.0
Docker Imagedemisto/chromium:151.0.7922.11855526
Supported ModulesAgentix EDR Cortex Cloud Cloud Runtime Security XSIAM

README

Converts URLs, PDF files, and emails to an image file or PDF file.

Docker Security Recommendations

If you are using the integration to rasterize un-trusted URLs or HTML content, such as those obtained via external emails, we recommend following the instructions at the Network Hardening Guide (Cortex XSOAR 6.13) or Docker network hardening Guide (Cortex XSOAR 8 Cloud) or Docker network hardening Guide (Cortex XSOAR 8.7 On-prem) under the Block Internal Network Access section.

Configure Rasterize in Cortex

Parameter Description Required
with_error Return Errors. False
wait_time Time to wait before taking a screenshot (in seconds). False
max_page_load_time Maximum amount of time to wait for a page to load (in seconds). False
chrome_options Chrome options (Advanced. See Configuration Notes.) False
is_https Use secure requests protocol (HTTPS). False
proxy Use system proxy settings. False
rasterize_mode Rasterize Mode. (See Configuration Notes.) False

Configuration Notes:

  • Return Errors: If this checkbox is not selected, a warning will be returned instead of an error.
  • Chrome options: A comma-separated list of Chrome options to add or remove for rasterization. Use for advanced troubleshooting. If a value contains a comma (for example, when setting the user agent value), escape it with the backslash (\) character. To remove a default option that is used, put the option in square brackets. For example, to add the option –disable-auto-reload and remove the option –disable-dev-shm-usage, set the following value:

      --disable-auto-reload,[--disable-dev-shm-usage]
    

    To set a language for the browser, add the –accept-lang argument followed by the desired language code in IETF BCP 47 format. For example, --accept-lang=de-DE.
    If you want to set the language to en-US, use en-GB instead.

  • Rasterize Mode: It is possible to rasterize either via Chrome WebDriver or Chrome Headless CLI. WebDriver supports more options than Headless CLI. Such as support for the offline option in the rasterize-emails command. There are some urls that do not rasterize well with WebDriver and may succeed with Headless CLI. Thus, it is recommended to use the WebDriver - Preferred mode, which will use WebDriver as a start and fallback to Headless CLI if it fails.
  • Use system proxy settings: Select this checkbox to use the system’s proxy settings. Important: this integration does not support proxies which require authentication.

Limitations

  • Rasterize does not support private network paths.

  • The default timeout is 5 minutes (300 seconds). If the rasterize command processes a large number of URLs, or even a smaller number of URLs that take a long time to load, it can result in a timeout error. For example, 100 URLs each taking 5 seconds to open would total 500 seconds, exceeding the default timeout limit.
    In cases where you encounter timeout errors when processing many URLs, you can either:

    • Split the URLs into smaller batches to process.
    • Increase the timeout setting for the specific task in the playbook that runs the rasterize command. To increase the timeout for a playbook task, modify the task’s configuration by accessing the task settings, clicking the Advanced section, and adjusting the Execution timeout (seconds) field to a higher value appropriate for the workload.

Commands

You can execute these commands from the CLI, as part of an automation, or in a playbook.
After you successfully execute a command, a DBot message appears in the War Room with the command details.

rasterize


Converts the contents of a URL to an image file or a PDF file.

Base Command

rasterize

Input

Argument Name Description Required
wait_time Time to wait before taking a screenshot (in seconds ). Optional
max_page_load_time Maximum time to wait for a page to load (in seconds). Optional
url The URL to rasterize. Must be the full URL, including the http prefix. Required
width The page width, for example, 1024px. Specify with or without the px suffix. Optional
height The page height, for example, 800px. Specify with or without the px suffix. Optional
type The file type to which to convert the contents of the URL. Can be “pdf” or “png”. Default is “png”. Optional
file_name The name the file will be saved as. Default is “url”. Optional
full_screen Get the full page. The actual page width and height will be auto calculated up to a max value of 8000px. (Marking full_screen as true means that the values for width and height arguments might not be respected). Optional
mode Rasterize mode to use (WebDriver or Headless CLI). If not specified, will use according to the integration instance settings. Optional

Context Output

Path Type Description
InfoFile.Name string File name.
InfoFile.EntryID string File entry ID.
InfoFile.Size number File size.
InfoFile.Type string File type, e.g., “PE”
InfoFile.Info string Basic information of the file.
InfoFile.Extension string File extension.

Command Example

!rasterize url=http://google.com

Context Example

{
    "InfoFile": {
        "EntryID": "922@6e069bc4-2a1e-43ea-8ed3-ea558e377751",
        "Extension": "png",
        "Info": "image/png",
        "Name": "url.png",
        "Size": 29909,
        "Type": "PNG image data, 1024 x 800, 8-bit/color RGBA, non-interlaced"
    }
}

Passing Multiple URLs

To pass multiple URLs as an argument when one or more URL contains a comma, use a JSON list formatted as a string as follows:
"[\"https://www.a.com\", \"https://www.b.com\"]"

Alternatively, you can wrap the entire list in backticks () so it is interpreted as a single argument. If you do this, make sure to: -Only use double quotes (") to enclose each URL string. -Replace any double quotes in a URL with single quotes (') or%22, or pass the URL separately to ensure proper parsing and avoid conflicts caused by nested quotation marks. for example: `["https://www.a.com", "https://www.b.com/?q='test_arg'/search"]``

Human Readable Output

!image

rasterize-email


Converts the body of an email to an image file or a PDF file.

Base Command

rasterize-email

Input

Argument Name Description Required
htmlBody The HTML body of the email. Required
width The HTML page width, for example, 600px. Specify with or without the px suffix. Optional
height The HTML page height, for example, 800px. Specify with or without the px suffix. Optional
type The file type to which to convert the email body. Can be “pdf” or “png”. Default is “png”. Optional
offline If “true”, will block all outgoing communication. Optional
file_name The name the file will be saved as. Default is “email”. Optional
full_screen Get the full page. The actual page width and height will be auto calculated up to a max value of 8000px. (Marking full_screen as true means that the values for width and height arguments might not be respected). Optional
mode Rasterize mode to use (WebDriver or Headless CLI). If not specified, will use according to the integration instance settings. Optional

Context Output

Path Type Description
InfoFile.Name string File name.
InfoFile.EntryID string File entry ID.
InfoFile.Size number File size.
InfoFile.Type string File type, e.g., “PE”
InfoFile.Info string Basic information of the file.
InfoFile.Extension string File extension.

Command Example

!rasterize-email htmlBody="<html><head><meta http-equiv=\"Content-Type\" content=\"text/html;charset=utf-8\"></head><body><br>---------- TEST FILE ----------<br></body></html>"

Context Example

{
    "InfoFile": {
        "EntryID": "926@6e069bc4-2a1e-43ea-8ed3-ea558e377751",
        "Extension": "png",
        "Info": "image/png",
        "Name": "email.png",
        "Size": 5243,
        "Type": "PNG image data, 600 x 800, 8-bit/color RGBA, non-interlaced"
    }
}

Human Readable Output

!image

rasterize-image


Converts an image file to a PDF file.

Base Command

rasterize-image

Input

Argument Name Description Required
EntryID The entry ID of the image file. Required
width The image width, for example, 600px. Specify with or without the px suffix. Optional
height The image height, for example, 800px. Specify with or without the px suffix. If empty, the height is the entire image. Optional
file_name The name the file will be saved as. Default is the EntryID. Optional
full_screen Get the full page. The actual page width and height will be auto calculated up to a max value of 8000px. (Marking full_screen as true means that the values for width and height arguments might not be respected). Optional
mode Rasterize mode to use (WebDriver or Headless CLI). If not specified, will use according to the integration instance settings. Optional

Context Output

Path Type Description
InfoFile.Name string File name.
InfoFile.EntryID string File entry ID.
InfoFile.Size number File size.
InfoFile.Type string File type, e.g., “PE”
InfoFile.Info string Basic information of the file.
InfoFile.Extension string File extension.

Command Example

!rasterize-image EntryID=889@6e069bc4-2a1e-43ea-8ed3-ea558e377751

Context Example

{
    "InfoFile": {
        "EntryID": "930@6e069bc4-2a1e-43ea-8ed3-ea558e377751",
        "Extension": "pdf",
        "Info": "application/pdf",
        "Name": "889@6e069bc4-2a1e-43ea-8ed3-ea558e377751.pdf",
        "Size": 21856,
        "Type": "PDF document, version 1.4"
    }
}

Human Readable Output

rasterize-pdf


Converts a PDF file to an image file.

Base Command

rasterize-pdf

Input

Argument Name Description Required
EntryID The entry ID of PDF file. Required
maxPages The maximum number of pages to render. Default is “3”. Optional
pdfPassword The password to access the PDF. Optional
horizontal Whether to stack the pages horizontally. If “true”, will stack the pages horizontally. If “false”, will stack the pages vertically. Default is “false”. Optional
file_name The name the file will be saved as. Default is “image”. Optional

Context Output

Path Type Description
InfoFile.Name string File name.
InfoFile.EntryID string File entry ID.
InfoFile.Size number File size.
InfoFile.Type string File type, e.g., “PE”
InfoFile.Info string Basic information of the file.
InfoFile.Extension string File extension.

Command Example

!rasterize-pdf EntryID=897@6e069bc4-2a1e-43ea-8ed3-ea558e377751

Context Example

{
    "InfoFile": {
        "EntryID": "934@6e069bc4-2a1e-43ea-8ed3-ea558e377751",
        "Extension": "jpeg",
        "Info": "image/jpeg",
        "Name": "image.jpeg",
        "Size": 77514,
        "Type": "JPEG image data, JFIF standard 1.01, aspect ratio, density 1x1, segment length 16, baseline, precision 8, 1700x2200, components 3"
    }
}

Human Readable Output

rasterize-html


Converts an html file to a PDF or PNG file.

Base Command

rasterize-html

Input

Argument Name Description Required
EntryID The entry ID of the html file. Required
width The html file width, for example, 600px. Specify with or without the px suffix. Optional
height The html file height, for example, 800px. Specify with or without the px suffix. If empty, the height is the entire image. Optional
file_name The name the file will be saved as. Default is the EntryID. Optional
type The file type to which to convert the html file. Can be “pdf” or “png”. Default is “png”. Optional
full_screen Get the full page. The actual page width and height will be auto calculated up to a max value of 8000px. (Marking full_screen as true means that the values for width and height arguments might not be respected). Optional
wait_time Time to wait before taking a screenshot (in seconds ). Optional

Context Output

Path Type Description
InfoFile.Name string File name.
InfoFile.EntryID string File entry ID.
InfoFile.Size number File size.
InfoFile.Type string File type, e.g., “PE”
InfoFile.Info string Basic information of the file.
InfoFile.Extension string File extension.

Command Example

!rasterize-html EntryID=889@6e069bc4-2a1e-43ea-8ed3-ea558e4586751

Context Example

{
    "InfoFile": {
        "EntryID": "930@6e069bc4-2a1e-43ea-8ed3-ea558e458651",
        "Extension": "png",
        "Info": "application/png",
        "Name": "image.png",
        "Size": 21856,
        "Type": "png document, version 1.4"
    }
}

Human Readable Output

Configuration parameters

  • with_error — Return Errors
  • wait_time — Time to wait before taking a screenshot (in seconds)
  • max_page_load_time — Maximum time to wait for a page to load (in seconds)
  • chrome_options — Chrome options (Advanced. See [?])
  • max_chromes_count — Number of maximum Chrome instances to keep running simultaneously.
  • max_chrome_tabs_count — Number of maximum tabs each Chrome will be allowed to open.
  • is_https — Use secure requests protocol (HTTPS).
  • rasterize_mode — Rasterize Mode
  • blocked_urls — List of domains to block
  • proxy — Use system proxy settings
  • lightweight — Lightweight

Commands (6)

  • rasterize

    Converts the contents of a URL to an image file or a PDF file.

  • rasterize-email

    Converts the body of an email to an image file or a PDF file.

  • rasterize-extract

    Extracts structured text content from web pages in markdown format.

  • rasterize-html

    Converts an HTML file to an image file or a PDF file.

  • rasterize-image

    Converts an image file to a PDF file.

  • rasterize-pdf

    Converts a PDF file to an image file.

from pathlib import Path
import demistomock as demisto  # noqa: F401
from CommonServerPython import *  # noqa: F401
import logging
import psutil
import base64
import os
import pychrome
import random
import subprocess
import tempfile
import threading
import time
import traceback
import websocket
import uuid
import json
from concurrent.futures import ThreadPoolExecutor
from enum import Enum
from threading import Event
from io import BytesIO
from PIL import Image, ImageDraw
from pdf2image import convert_from_path
from PyPDF2 import PdfReader
from functools import lru_cache
from urllib.parse import urlparse
import ipaddress
import gc
# region constants and configurations

pypdf_logger = logging.getLogger("PyPDF2")
pypdf_logger.setLevel(logging.ERROR)  # Supress warnings, which would come out as XSOAR errors while not being errors

# Chrome respects proxy env params
handle_proxy()
# Make sure our python code doesn't go through a proxy when communicating with Chrome webdriver
os.environ["no_proxy"] = "localhost,127.0.0.1"
# Needed for cases that rasterize is running with non-root user (docker hardening)
os.environ["HOME"] = tempfile.gettempdir()

CHROME_ERROR_URL = "chrome-error://chromewebdata"
CHROME_EXE = os.getenv("CHROME_EXE", "/opt/google/chrome/google-chrome")
USER_AGENT = (
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"
)
CHROME_OPTIONS = [
    "--headless",
    "--disable-gpu",
    "--no-sandbox",
    "--hide-scrollbars",
    "--disable-infobars",
    "--start-maximized",
    "--start-fullscreen",
    "--ignore-certificate-errors",
    "--disable-dev-shm-usage",
    f'--user-agent="{USER_AGENT}"',
    "--enable-low-end-device-mode",  # Forces Chrome to clear memory cache of inactive tabs more frequently
    "--renderer-process-limit=3",  # Limit renderer processes to reduce overhead
    "--disable-background-networking",  # Prevent background processes
    "--disable-default-apps",  # Disables installation of default apps
    "--disable-component-extensions-with-background-pages",  # Disable background extensions
    "--disable-component-update",  # Disable component updates
    "--disable-breakpad",  # Disable crash reporting to save memory
    "--disable-domain-reliability",  # Disables Domain Reliability Monitoring
    "--disable-gaia-services",  # Disables GAIA services such as enrollment and OAuth session restore
    "--no-first-run",  # Skip first run tasks
    "--mute-audio",  # Disable audio processing
    "--disable-notifications",  # Disables the Web Notification and the Push APIs
    "--disable-speech-api",  # Disables the Web Speech API
]

LIGHTWEIGHT_CHROME_OPTIONS = [
    '--js-flags="--max-old-space-size=256 --max-semi-space-size=2"',
    "--single-process",
    "--disable-software-rasterizer",  # Pairs with --disable-gpu, removes the SwiftShader fallback
    "--disable-extensions",  # No extensions are ever needed
    "--disable-plugins",  # No plugins are ever needed
    "--disable-sync",  # No account sync
    "--disable-translate",  # No translation service
    "--disk-cache-size=1",  # Effectively disable the on-disk (tmpfs-backed) HTTP cache
    "--media-cache-size=1",  # Effectively disable the media cache
    "--disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints",
]

WITH_ERRORS = demisto.params().get("with_error", True)
IS_HTTPS = argToBoolean(demisto.params().get("is_https", False))

# The default wait time before taking a screenshot
DEFAULT_WAIT_TIME = max(int(demisto.params().get("wait_time", 0)), 0)
DEFAULT_PAGE_LOAD_TIME = int(demisto.params().get("max_page_load_time", 180))
TAB_CLOSE_WAIT_TIME = 1

# Used it in several places
DEFAULT_RETRIES_COUNT = 4
DEFAULT_RETRY_WAIT_IN_SECONDS = 3
PAGES_LIMITATION = 20
SCREENSHOT_TIMEOUT = 60

# chrome instance data keys
INSTANCE_ID = "instance_id"
CHROME_INSTANCE_OPTIONS = "chrome_options"
RASTERIZATION_COUNT = "rasterization_count"

BLOCKED_URLS = argToList(demisto.params().get("blocked_urls", "").lower())

try:
    env_max_rasterizations_count = os.getenv("MAX_RASTERIZATIONS_COUNT", "500")
    MAX_RASTERIZATIONS_COUNT = int(env_max_rasterizations_count)
except Exception as e:
    demisto.info(f"Exception trying to parse MAX_RASTERIZATIONS_COUNT, {e}")
    MAX_RASTERIZATIONS_COUNT = 500

FIRST_CHROME_PORT = 9301

try:
    env_max_chromes_count = os.getenv("MAX_CHROMES_COUNT", "64")
    MAX_CHROMES_COUNT = int(env_max_chromes_count)
except Exception as e:
    demisto.info(f"Exception trying to parse MAX_CHROMES_COUNT, {e}")
    MAX_CHROMES_COUNT = 64

try:
    # Max number of tabs each Chrome will open before not responding for more requests
    env_max_chrome_tabs_count = os.getenv("MAX_CHROME_TABS_COUNT", "10")
    MAX_CHROME_TABS_COUNT = int(env_max_chrome_tabs_count)
except Exception as e:
    demisto.info(f"Exception trying to parse MAX_CHROME_TABS_COUNT, {e}")
    MAX_CHROME_TABS_COUNT = 10

# Memory pressure tolerance: if available memory drops below this value while a page is loading,
# wait_for_page_load_with_memory_guard will abort the wait and take a screenshot of whatever has
# rendered so far, preventing an OOM kill.
#
# The tolerance is expressed as a *percentage* of the container's total memory limit so it scales
# automatically when the memory limit changes (e.g. 65% of a 1 GiB limit is ~650 MiB; raising the
# limit to 2 GiB automatically makes the tolerance ~1300 MiB with no code change).
#
# Full-screen captures use a larger viewport and therefore allocate more memory, so they use a
# higher tolerance (default 70%) - the guard aborts earlier to leave more headroom for the bigger
# screenshot.
try:
    MEMORY_PRESSURE_TOLERANCE_PERCENT = float(os.getenv("MEMORY_PRESSURE_TOLERANCE_PERCENT", "65"))
except Exception as e:
    demisto.info(f"Exception trying to parse MEMORY_PRESSURE_TOLERANCE_PERCENT, {e}")
    MEMORY_PRESSURE_TOLERANCE_PERCENT = 65.0

try:
    MEMORY_PRESSURE_TOLERANCE_PERCENT_FULL_SCREEN = float(os.getenv("MEMORY_PRESSURE_TOLERANCE_PERCENT_FULL_SCREEN", "70"))
except Exception as e:
    demisto.info(f"Exception trying to parse MEMORY_PRESSURE_TOLERANCE_PERCENT_FULL_SCREEN, {e}")
    MEMORY_PRESSURE_TOLERANCE_PERCENT_FULL_SCREEN = 70.0

try:
    MEMORY_PRESSURE_TOLERANCE_FALLBACK_MB = int(os.getenv("MEMORY_PRESSURE_TOLERANCE_MB", "650"))
except Exception as e:
    demisto.info(f"Exception trying to parse MEMORY_PRESSURE_TOLERANCE_MB, {e}")
    MEMORY_PRESSURE_TOLERANCE_FALLBACK_MB = 650

IS_LIGHTWEIGHT = argToBoolean(demisto.params().get("lightweight", False))
if IS_LIGHTWEIGHT:  # In lightweight mode, we only allow one Chrome instance and one tab per instance
    MAX_CHROMES_COUNT = 1
    MAX_CHROME_TABS_COUNT = 1
    MAX_RASTERIZATIONS_COUNT = 1
    # Apply the extra memory-saving launch flags only in lightweight mode.
    CHROME_OPTIONS = CHROME_OPTIONS + LIGHTWEIGHT_CHROME_OPTIONS

# Polling for rasterization commands to complete
DEFAULT_POLLING_INTERVAL = 0.1

# Consts for custom width and height
MAX_FULLSCREEN_WIDTH = 8000
MAX_FULLSCREEN_HEIGHT = 8000
DEFAULT_WIDTH, DEFAULT_HEIGHT = 600, 800

# Local Chrome
LOCAL_CHROME_HOST = "127.0.0.1"

CHROME_LOG_FILE_PATH = "/var/chrome_headless.log"
CHROME_INSTANCES_FILE_PATH = "/var/chrome_instances.json"


class RasterizeType(Enum):
    PNG = "png"
    PDF = "pdf"
    JSON = "json"
    TEXT = "text"


# endregion

##### Memory Pressure Monitoring #####


def get_container_working_set_bytes() -> int:
    """
    Calculates the container's memory working set in bytes.
    Matches the Kubernetes/cAdvisor formula: working_set = memory.current - inactive_file.

    Returns:
        int: Working set memory in bytes, or 0 if the cgroup v2 files cannot be read.
    """
    try:
        with open("/sys/fs/cgroup/memory.current") as f:
            mem_current = int(f.read().strip())

        inactive_file = 0
        with open("/sys/fs/cgroup/memory.stat") as f:
            for line in f:
                if line.startswith("inactive_file "):
                    inactive_file = int(line.split()[1])
                    break

        return max(0, mem_current - inactive_file)

    except (FileNotFoundError, ValueError, PermissionError) as e:
        demisto.debug(f"get_container_working_set_bytes: Could not read cgroup v2 memory stats: {e}")
        return 0


def get_container_total_memory_bytes() -> int:
    """
    Returns the container's total (hard) memory limit in bytes, read from cgroup v2 memory.max.

    Returns:
        int: Total memory limit in bytes.
             Returns -1 when no hard memory limit is set on the cgroup ("max").
             Returns 0 when the cgroup v2 files cannot be read.
    """
    try:
        with open("/sys/fs/cgroup/memory.max") as f:
            max_val = f.read().strip()

        if max_val == "max":
            # No hard limit is set on this cgroup
            return -1

        return int(max_val)

    except (FileNotFoundError, ValueError, PermissionError) as e:
        demisto.debug(f"get_container_total_memory_bytes: Could not read cgroup v2 memory.max: {e}")
        return 0


def get_container_available_memory_bytes() -> int:
    """
    Returns the available memory in bytes (memory.max - working_set).

    Returns:
        int: Available memory in bytes.
             Returns -1 when no hard memory limit is set on the cgroup ("max").
             Returns 0 when the cgroup v2 files cannot be read.
    """
    mem_max = get_container_total_memory_bytes()

    # Propagate the special "no hard limit" (-1) and "unreadable" (0) signals unchanged.
    if mem_max <= 0:
        return mem_max

    working_set = get_container_working_set_bytes()
    available = max(0, mem_max - working_set)
    demisto.debug(
        f"get_container_available_memory_bytes: mem_max={mem_max / (1024 * 1024):.1f} MiB, "
        f"working_set={working_set / (1024 * 1024):.1f} MiB, "
        f"available={available / (1024 * 1024):.1f} MiB",
    )
    return available


def compute_memory_pressure_tolerance_bytes(percent: float, fallback_mb: int = 650) -> int:
    """
    Computes the memory-pressure tolerance (the available-memory floor) as a percentage of the
    container's total memory limit, so it auto-scales when the memory limit changes.

    For example, with percent=65 and a 1 GiB limit the tolerance is ~650 MiB; if the limit is
    raised to 2 GiB the tolerance automatically becomes ~1300 MiB without any code change.

    When the container has no hard memory limit, or the limit cannot be read, the function falls
    back to a fixed value (fallback_mb).

    Args:
        percent: Percentage (0-100) of the total memory limit to use as the tolerance floor.
        fallback_mb: Fixed tolerance in MiB to use when the total memory limit is unavailable.

    Returns:
        int: The tolerance in bytes.
    """
    total_memory = get_container_total_memory_bytes()
    if total_memory > 0:
        tolerance = int(total_memory * (percent / 100.0))
        demisto.debug(
            f"compute_memory_pressure_tolerance_bytes: total={total_memory / (1024 * 1024):.1f} MiB, "
            f"percent={percent}%, tolerance={tolerance / (1024 * 1024):.1f} MiB",
        )
        return tolerance

    # No hard limit set ("max") or the value could not be read - use the fixed fallback.
    demisto.debug(
        f"compute_memory_pressure_tolerance_bytes: no readable memory limit "
        f"({total_memory=}), falling back to {fallback_mb} MiB",
    )
    return fallback_mb * 1024 * 1024


# Available-memory floor in bytes, computed from the container's total memory limit so it scales
# automatically with the configured memory size (see the comment near the constant definitions).
MEMORY_PRESSURE_TOLERANCE_BYTES = compute_memory_pressure_tolerance_bytes(
    percent=MEMORY_PRESSURE_TOLERANCE_PERCENT,
    fallback_mb=MEMORY_PRESSURE_TOLERANCE_FALLBACK_MB,
)


def set_memory_pressure_tolerance_for_capture(full_screen: bool) -> int:
    """
    Recomputes and stores the module-level MEMORY_PRESSURE_TOLERANCE_BYTES for the current command,
    using a higher percentage for full-screen captures (which allocate more memory).

    Each command handler calls this once after reading its full_screen argument, so the memory guard
    (which reads the module-level MEMORY_PRESSURE_TOLERANCE_BYTES) uses the right floor without having
    to drill the flag through navigate_to_path / wait_for_page_load_with_memory_guard.

    Args:
        full_screen: Whether the upcoming capture is full-screen.

    Returns:
        int: The newly computed tolerance in bytes.
    """
    global MEMORY_PRESSURE_TOLERANCE_BYTES
    percent = MEMORY_PRESSURE_TOLERANCE_PERCENT_FULL_SCREEN if full_screen else MEMORY_PRESSURE_TOLERANCE_PERCENT
    MEMORY_PRESSURE_TOLERANCE_BYTES = compute_memory_pressure_tolerance_bytes(
        percent=percent,
        fallback_mb=MEMORY_PRESSURE_TOLERANCE_FALLBACK_MB,
    )
    demisto.debug(
        f"set_memory_pressure_tolerance_for_capture: {full_screen=}, using {percent}% -> "
        f"{MEMORY_PRESSURE_TOLERANCE_BYTES / (1024 * 1024):.1f} MiB",
    )
    return MEMORY_PRESSURE_TOLERANCE_BYTES


##### CDP management #####


def _safe_call_cdp_with_args(
    tab: Optional[pychrome.Tab],
    method_path: str,
    tab_id: str,
    path: str,
    **kwargs: Any,
) -> None:
    """
    Safely invokes a Chrome DevTools Protocol method on *tab* with optional keyword
    arguments, swallowing and logging any pychrome exception. Used so transient errors
    (tab already stopping/disconnected) never propagate out of the page-load wait or
    freeze sequence. Some CDP methods require parameters (e.g.
    Network.emulateNetworkConditions, Fetch.enable) which are forwarded via **kwargs;
    methods that take no arguments can be invoked by omitting kwargs entirely.

    Args:
        tab: The pychrome tab (may be None — call is a no-op in that case).
        method_path: Dotted CDP method, e.g. "Network.emulateNetworkConditions" or "Page.stopLoading".
        tab_id: Tab identifier for logging.
        path: URL/path being loaded, for logging.
        **kwargs: Keyword arguments forwarded to the CDP method.
    """
    if tab is None:
        return
    try:
        target: Any = tab
        for part in method_path.split("."):
            target = getattr(target, part)
        target(**kwargs)
        demisto.debug(
            f"wait_for_page_load_with_memory_guard: {method_path}({kwargs}) called, {tab_id=}, {path=}",
        )
    except Exception as ex:
        demisto.debug(
            f"wait_for_page_load_with_memory_guard: {method_path}({kwargs}) failed "
            f"(tab may already be stopping/disconnected): {ex}, {tab_id=}, {path=}",
        )


##### Safe page loading management #####


def _freeze_tab_for_screenshot(tab: Optional[pychrome.Tab], tab_id: str, path: str) -> None:
    """
    Halts all further memory growth in *tab* while keeping it screenshot-able.

    Unlike a single ``Page.stopLoading`` call (which only cancels the current navigation's
    in-flight network requests), this sequence:

    1. Takes the renderer offline (`Network.emulateNetworkConditions` + `Fetch.enable`)
       so JS-initiated XHR/fetch/img requests fail instantly and stop allocating.
    2. Cancels current in-flight requests (`Page.stopLoading`).
    3. Halts the JS event loop (`Emulation.setScriptExecutionDisabled` and
       `Page.setWebLifecycleState("frozen")`), so requestAnimationFrame, setInterval,
       IntersectionObserver lazy-loaders, and SPA hydration stop allocating.
    4. Runs GC / memory purge hints — now effective because nothing is allocating
       on top of them.

    After this returns the tab is still alive and ``Page.captureScreenshot`` will return
    the last painted frame (the compositor surface survives a frozen tab). The caller is
    expected to capture promptly and then close the tab.

    Each CDP call is wrapped in a safe helper because some methods are not implemented in
    every Chrome build (e.g. `Page.setWebLifecycleState` requires headless-shell with the
    appropriate flag), so we tolerate per-call failures.

    Args:
        tab: The pychrome tab to freeze (may be None — call is a no-op in that case).
        tab_id: Tab identifier for logging.
        path: URL/path being loaded, for logging.
    """
    if tab is None:
        return

    demisto.debug(f"_freeze_tab_for_screenshot: starting freeze sequence, {tab_id=}, {path=}")

    # 1a. Make sure the Network domain is enabled before issuing emulateNetworkConditions.
    _safe_call_cdp_with_args(tab=tab, method_path="Network.enable", tab_id=tab_id, path=path)

    # 1b. Cut the network at the renderer: every new request fails immediately.
    _safe_call_cdp_with_args(
        tab=tab,
        method_path="Network.emulateNetworkConditions",
        tab_id=tab_id,
        path=path,
        offline=True,
        latency=0,
        downloadThroughput=0,
        uploadThroughput=0,
    )

    # 1c. Belt-and-braces: disable any existing Fetch interception first so that
    #     pending requestIds are cleanly invalidated before we re-enable with a
    #     catch-all pattern.
    _safe_call_cdp_with_args(tab=tab, method_path="Fetch.disable", tab_id=tab_id, path=path)
    _safe_call_cdp_with_args(
        tab=tab,
        method_path="Fetch.enable",
        tab_id=tab_id,
        path=path,
        patterns=[{"urlPattern": "*"}],
    )

    # 2. Cancel the current navigation's in-flight fetches.
    _safe_call_cdp_with_args(tab=tab, method_path="Page.stopLoading", tab_id=tab_id, path=path)

    # 3a. Halt the JS event loop so rAF / setInterval / observers stop allocating.
    _safe_call_cdp_with_args(
        tab=tab,
        method_path="Emulation.setScriptExecutionDisabled",
        tab_id=tab_id,
        path=path,
        value=True,
    )

    # 3b. Signal the page-lifecycle layer that we are frozen (best-effort; not all
    #     Chrome builds support this CDP method).
    _safe_call_cdp_with_args(
        tab=tab,
        method_path="Page.setWebLifecycleState",
        tab_id=tab_id,
        path=path,
        state="frozen",
    )

    # 4. With nothing allocating on top of them, the GC hints can actually shrink
    #    the V8 heap and release renderer-side caches.
    _safe_call_cdp_with_args(tab=tab, method_path="HeapProfiler.collectGarbage", tab_id=tab_id, path=path)
    _safe_call_cdp_with_args(tab=tab, method_path="Memory.forciblyPurgeJavaScriptMemory", tab_id=tab_id, path=path)

    # 5. Drop the browser-level network cache populated by this tab.
    _safe_call_cdp_with_args(tab=tab, method_path="Network.clearBrowserCache", tab_id=tab_id, path=path)

    demisto.debug(
        f"_freeze_tab_for_screenshot: freeze sequence complete, "
        f"available={get_container_available_memory_bytes() / (1024 * 1024):.1f} MiB, "
        f"{tab_id=}, {path=}",
    )


def wait_for_page_load_with_memory_guard(
    tab_ready_event: Event,
    navigation_timeout: int,
    tolerance_bytes: Optional[int] = None,
    poll_interval: float = 0.1,
    tab_id: str = "",
    path: str = "",
    tab: Optional[pychrome.Tab] = None,
) -> bool:
    """
    Waits for *tab_ready_event* to be set, but aborts the wait early if available container
    memory drops below *tolerance_bytes*.

    When memory pressure is detected the tab is **frozen** (network cut + JS halted +
    GC purge — see :func:`_freeze_tab_for_screenshot`) and the event is set so the caller
    can immediately capture a partial screenshot of whatever rendered so far. Freezing
    halts further allocation without destroying the compositor surface, so the screenshot
    still succeeds and the OOM is avoided.

    Args:
        tab_ready_event: The threading.Event that signals page load completion.
        navigation_timeout: Maximum seconds to wait (same as the normal page-load timeout).
        tolerance_bytes: Available-memory floor in bytes. When None (the default), the
            current module-level MEMORY_PRESSURE_TOLERANCE_BYTES is used, which is recomputed per command via
            set_memory_pressure_tolerance_for_capture (a higher percentage is used for
            full-screen captures).
        poll_interval: How often (seconds) to sample memory while waiting. Default: 0.5 s.
        tab_id: Tab identifier for logging.
        path: URL/path being loaded, for logging.
        tab: Optional pychrome.Tab used to stop loading and reclaim memory on early exit.

    Returns:
        bool: True if the event was set normally (page finished loading or timed out),
              False if the wait was aborted early due to memory pressure.
    """
    if tolerance_bytes is None:
        tolerance_bytes = MEMORY_PRESSURE_TOLERANCE_BYTES

    deadline = time.monotonic() + navigation_timeout  # pylint: disable=E9003

    # If there is no cgroup memory limit, the memory-pressure check is not applicable.
    # Fall back to a single blocking wait instead of busy-polling every poll_interval.
    if get_container_available_memory_bytes() == -1:
        demisto.debug(
            f"wait_for_page_load_with_memory_guard: no cgroup memory limit detected; "
            f"falling back to plain wait, {tab_id=}, {path=}",
        )
        tab_ready_event.wait(timeout=navigation_timeout)
        return True

    while True:
        # Check if the page has finished loading.
        if tab_ready_event.wait(timeout=poll_interval):
            _freeze_tab_for_screenshot(tab, tab_id, path)
            demisto.debug(
                f"wait_for_page_load_with_memory_guard: normal completion, "
                f"available={get_container_available_memory_bytes() / (1024 * 1024):.1f} MiB, "
                f"{tab_id=}, {path=}",
            )
            return True

        # Check for timeout.
        if time.monotonic() >= deadline:  # pylint: disable=E9003
            # Stop the still-loading tab so it cannot keep consuming memory after we return.
            _safe_call_cdp_with_args(tab=tab, method_path="Page.stopLoading", tab_id=tab_id, path=path)
            demisto.debug(
                f"wait_for_page_load_with_memory_guard: navigation_timeout reached ({navigation_timeout}s), {tab_id=}, {path=}",
            )
            return True  # Caller handles the timeout warning as before.

        # Sample available memory.
        available = get_container_available_memory_bytes()
        if available <= tolerance_bytes:
            _freeze_tab_for_screenshot(tab, tab_id, path)
            tab_ready_event.set()
            return False  # False signals that we aborted early due to memory pressure.


# region utility classes


def excepthook_recv_loop(args: threading.ExceptHookArgs) -> None:
    """
    Suppressing exceptions that might happen after the tab was closed.
    """
    demisto.debug(f"excepthook_recv_loop, {args.exc_type=}")
    exc_value = args.exc_value
    if args.exc_type in [json.decoder.JSONDecodeError, websocket._exceptions.WebSocketConnectionClosedException]:
        # Suppress
        demisto.debug(f"Suppressed Exception in _recv_loop: {args.exc_type=}")
    else:
        demisto.info(f"Unsuppressed Exception in _recv_loop: {args.exc_type=}")
        if exc_value:
            demisto.info(f"Unsuppressed Exception in _recv_loop: {args.exc_type=}, {exc_value=}")
        else:
            demisto.info(f"Unsuppressed Exception in _recv_loop: {args.exc_type=}, empty exc_value")


class TabLifecycleManager:
    def __init__(self, browser, chrome_port, offline_mode):
        self.browser = browser
        self.chrome_port = chrome_port
        self.offline_mode = offline_mode
        self.tab = None

    def __enter__(self):
        try:
            self.tab = self.browser.new_tab()
        except Exception as ex:
            demisto.info(f"TabLifecycleManager, __enter__, {self.chrome_port=}, failed to create a new tab due to {ex}")
            raise ex
        try:
            self.tab.start()
        except Exception as ex:
            demisto.info(f"TabLifecycleManager, __enter__, {self.chrome_port=}, failed to start a new tab due to {ex}")
            raise ex
        try:
            if self.offline_mode:
                self.tab.Network.emulateNetworkConditions(offline=True, latency=-1, downloadThroughput=-1, uploadThroughput=-1)
            else:
                self.tab.Network.emulateNetworkConditions(offline=False, latency=-1, downloadThroughput=-1, uploadThroughput=-1)
        except Exception as ex:
            demisto.info(f"TabLifecycleManager, __enter__, {self.chrome_port=}, failed to set tab NetworkConditions due to {ex}")
            raise ex

        try:
            self.tab.Page.enable()
        except Exception as ex:
            demisto.info(f"TabLifecycleManager, __enter__, {self.chrome_port=}, failed to enable a new tab due to {ex}")
            raise ex

        if BLOCKED_URLS:
            try:
                patterns = [{"urlPattern": f"*{pat}*", "requestStage": "Request"} for pat in BLOCKED_URLS]
                self.tab.Fetch.enable(patterns=patterns)
            except Exception as ex:
                demisto.info(
                    f"TabLifecycleManager, __enter__, {self.chrome_port=}, failed to enable Fetch interception due to {ex}"
                )
                raise ex

        return self.tab

    def __exit__(self, exc_type, exc_val, exc_tb):  # noqa: F841
        if self.tab:
            tab_id = self.tab.id
            # Suppressing exceptions that might happen after the tab was closed.
            threading.excepthook = excepthook_recv_loop

            try:
                time.sleep(TAB_CLOSE_WAIT_TIME)  # pylint: disable=E9003
                self.tab.Page.disable()
            except Exception as ex:
                demisto.info(f"TabLifecycleManager, __exit__, {self.chrome_port=}, failed to disable page due to {ex}")

            try:
                self.tab.stop()
            except Exception as ex:
                demisto.info(f"TabLifecycleManager, __exit__, {self.chrome_port=}, failed to stop tab {tab_id} due to {ex}")

            try:
                self.browser.close_tab(tab_id)
            except Exception as ex:
                demisto.info(f"TabLifecycleManager, __exit__, {self.chrome_port=}, failed to close tab {tab_id} due to {ex}")

            time.sleep(TAB_CLOSE_WAIT_TIME)  # pylint: disable=E9003


class PychromeEventHandler:
    request_id = None
    screen_lock = threading.Lock()

    def __init__(self, browser: pychrome.Browser, tab: pychrome.Tab, tab_ready_event: Event, path: str, navigation_timeout: int):
        self.browser = browser
        self.tab = tab
        self.tab_ready_event = tab_ready_event
        self.start_frame = None
        self.is_mailto = False
        self.path = path
        self.navigation_timeout = navigation_timeout
        self.is_private_network_url = False
        self.document_url = ""

    def page_frame_started_loading(self, frameId):
        demisto.debug(f"PychromeEventHandler.page_frame_started_loading, {frameId=}, {self.tab.id=}, {self.path=}")
        self.start_frame = frameId
        if self.request_id:
            # We're in redirect
            demisto.debug(f"Frame (reload) started loading: {frameId}, clearing {self.request_id=}, {self.tab.id=}, {self.path=}")
            self.request_id = None
            self.response_received = False
            # self.start_frame = None
        else:
            demisto.debug(f"Frame started loading: {frameId}, no request_id, {self.tab.id=}, {self.path=}")

    def network_data_received(self, requestId, timestamp, dataLength, encodedDataLength):  # noqa: F841
        demisto.debug(f"PychromeEventHandler.network_data_received, {requestId=}, {self.tab.id=}, {self.path=}")
        if requestId and not self.request_id:
            demisto.debug(f"PychromeEventHandler.network_data_received, Using {requestId=}, {self.tab.id=}, {self.path=}")
            self.request_id = requestId
        else:
            demisto.debug(f"PychromeEventHandler.network_data_received, Not using {requestId=}, {self.tab.id=}, {self.path=}")

    def page_frame_stopped_loading(self, frameId):
        """
        Callback handler for when a frame has stopped loading in the page.

        This method is called by Chrome when a frame in the page finishes loading. It checks if
        the finished frame is the main frame we're tracking, then verifies the loaded URL. If the
        URL indicates a Chrome error page for a local file, it attempts to retry loading. Otherwise,
        it signals that the page is ready by setting the tab_ready_event.

        Args:
            frameId: The identifier of the frame that has finished loading

        Returns:
            None
        """
        demisto.debug(
            f"PychromeEventHandler.page_frame_stopped_loading, {self.start_frame=}, {frameId=}, {self.tab.id=}, {self.path=}"
        )
        # Check if this is the main frame that finished loading
        if self.start_frame == frameId:
            try:
                # Check if the loaded page is a Chrome error page, which indicates a failed load
                # Only retry loading when the URL is a direct file path
                # This helps handle cases where temporary files fail to load on the first attempt
                if self.path.lower().startswith("file://"):
                    frame_url = self.get_frame_tree_url()
                    if frame_url and frame_url.lower().startswith(CHROME_ERROR_URL):
                        demisto.debug(f"Encountered chrome-error {frame_url=}, {self.tab.id=}, {self.path=} retrying...")
                        self.retry_loading()
                    else:
                        demisto.debug(
                            "PychromeEventHandler.page_frame_stopped_loading, setting tab_ready_event, "
                            f"{self.tab.id=}, {self.path=}"
                        )
                        self.tab_ready_event.set()
                else:
                    demisto.debug(
                        f"PychromeEventHandler.page_frame_stopped_loading, setting tab_ready_event, {self.tab.id=}, {self.path=}"
                    )
                    self.tab_ready_event.set()
            except (pychrome.exceptions.RuntimeException, pychrome.exceptions.UserAbortException) as ex:
                demisto.debug(f"page_frame_stopped_loading: Tab {self.tab.id=} for {self.path=} is stopping/stopped: {ex}")
                self.tab_ready_event.set()
            except Exception as ex:
                demisto.info(f"Unexpected exception in page_frame_stopped_loading {self.path=}, {self.tab.id=}: {ex}")
                self.tab_ready_event.set()

    def get_frame_tree_url(self) -> str:
        """
        Gets the frame tree URL from the tab and handles potential exceptions.

        Returns:
            str: The frame URL if successful, empty string on failure.
        """
        try:
            frame_tree_result = self.tab.Page.getFrameTree()
            frame_url = frame_tree_result.get("frameTree", {}).get("frame", {}).get("url", "")
            demisto.debug(
                f"PychromeEventHandler.get_frame_tree_url, Frame URL: {frame_url}, Original path: {self.path}, {self.tab.id}"
            )
            return frame_url
        except (pychrome.exceptions.RuntimeException, pychrome.exceptions.UserAbortException) as ex:
            # The tab is already stopping or has been stopped
            demisto.debug(
                f"get_frame_tree_url: Tab {self.tab.id=} for {self.path=} is stopping/stopped while getting frame tree: {ex}"
            )
            return ""
        except Exception as ex:
            demisto.debug(f"Unexpected error getting frame tree URL for {self.tab.id=}, {self.path=}: {ex}")
            return ""

    def retry_loading(self):
        """
        Attempts to reload the page multiple times.

        This method will try to reload the current page up to DEFAULT_RETRIES_COUNT times
        if it encounters a Chrome error page. It sets the tab_ready_event when successful.
        """
        for retry_count in range(1, DEFAULT_RETRIES_COUNT + 1):
            demisto.debug(f"Retrying loading URL {self.path}, {self.tab.id}. Attempt {retry_count}/{DEFAULT_RETRIES_COUNT}")
            try:
                if self.navigation_timeout > 0:
                    self.tab.Page.navigate(url=self.path, _timeout=self.navigation_timeout)
                else:
                    self.tab.Page.navigate(url=self.path)
            except Exception as e:
                demisto.debug(
                    f"Error during navigation to {self.tab.id=}, {self.path=} attempt {retry_count}/{DEFAULT_RETRIES_COUNT}: {e}"
                )

            safe_sleep(DEFAULT_PAGE_LOAD_TIME / DEFAULT_RETRIES_COUNT + 1)

            try:
                frame_url = self.get_frame_tree_url()
            except Exception as e:
                demisto.debug(
                    f"Error getting frame URL in retry_loading for {self.tab.id=}, {self.path=} "
                    f"attempt {retry_count}/{DEFAULT_RETRIES_COUNT}: {str(e)}"
                )
                frame_url = ""

            # If frame_url is empty string, we can't continue retrying - the tab may be in a bad state
            if not frame_url:
                demisto.debug(
                    f"Retry {retry_count}/{DEFAULT_RETRIES_COUNT} failed: Could not get frame URL. "
                    f"Stopping after {DEFAULT_RETRIES_COUNT} retry attempts. "
                    f"For {self.tab.id=}, {self.path=}"
                )
                self.tab_ready_event.set()
                return

            if not frame_url.lower().startswith(CHROME_ERROR_URL):
                demisto.debug(f"Retry {retry_count}/{DEFAULT_RETRIES_COUNT} successful. {self.tab.id=}, {self.path=}")
                self.tab_ready_event.set()
                return

            demisto.debug(
                f"Retry {retry_count}/{DEFAULT_RETRIES_COUNT} failed: Page still showing Chrome error. "
                f"{self.tab.id=}, {self.path=}"
            )

        demisto.debug(f"Max retries ({DEFAULT_RETRIES_COUNT}) reached, could not load the page. {self.tab.id=}, {self.path=}")
        # Ensure we always set the event to prevent hanging
        self.tab_ready_event.set()

    def network_request_will_be_sent(self, documentURL: str, **kwargs):
        """Triggered when a request is sent by the browser, catches mailto URLs."""
        demisto.debug(f"PychromeEventHandler.network_request_will_be_sent, {documentURL=}, {self.tab.id=}, {self.path=}")
        self.document_url = documentURL
        self.is_mailto = documentURL.lower().startswith("mailto:")
        self.is_private_network_url = is_private_network(documentURL)
        demisto.debug(f"Private network URL check for {documentURL=}: {self.is_private_network_url}")
        demisto.debug(f"mailto URL check for {documentURL=}: {self.is_mailto}")
        request_url = kwargs.get("request", {}).get("url", "")

        if any(value in request_url for value in BLOCKED_URLS):
            demisto.info(
                f"The following URL is blocked. Consider updating the 'List of domains to block' parameter:{request_url}"
            )

    def handle_request_paused(self, **kwargs):
        request_id = kwargs.get("requestId")
        request_url = kwargs.get("request", {}).get("url") or ""

        # abort the request if the url inside blocked_urls param and its redirect request
        if any(value in request_url for value in BLOCKED_URLS):
            try:
                self.tab.Fetch.failRequest(requestId=request_id, errorReason="Aborted")
                demisto.debug(f"Request aborted: {request_url=} , {request_id=}, {self.tab.id=}, {self.path=}")
            except Exception as ex:
                # The interception ID may have been invalidated by a concurrent Fetch.disable/enable
                # (e.g. from _freeze_tab_for_screenshot).  This is benign — the request is already gone.
                demisto.debug(
                    f"handle_request_paused: Fetch.failRequest failed (stale interception ID): "
                    f"{ex}, {request_id=}, {request_url=}, {self.tab.id=}, {self.path=}"
                )
        else:
            # Safety check in case the fetch enable patterns paused requests that shouldn't be blocked
            try:
                demisto.debug(f"Request continued: {request_url=} , {request_id=}, {self.tab.id=}, {self.path=}")
                self.tab.Fetch.continueRequest(requestId=request_id)
            except Exception as ex:
                # The interception ID may have been invalidated by a concurrent Fetch.disable/enable
                # (e.g. from _freeze_tab_for_screenshot).  This is benign — the request is already gone.
                demisto.debug(
                    f"handle_request_paused: Fetch.continueRequest failed (stale interception ID): "
                    f"{ex}, {request_id=}, {request_url=}, {self.tab.id=}, {self.path=}"
                )


# endregion

_EXTRACTION_JAVASCRIPT = """
(function() {
    // --- JSON Detection and Extraction (First Priority) ---
    const contentType = (document.contentType ||
                         document.querySelector('meta[http-equiv="Content-Type"]')?.content ||
                         '').toLowerCase();
    const isJson = contentType.includes('application/json') ||
                   contentType.includes('application/ld+json');

    if (isJson) {
        const jsonContent = document.querySelector('pre') ?
                            document.querySelector('pre').textContent :
                            document.body.textContent;
        return { type: 'json', content: jsonContent };
    }

    // --- Metadata and Tag Extraction ---
    let metadataOutput = [];

    // Function to safely extract content from a meta tag
    function getMetaContent(selector) {
        const element = document.querySelector(selector);
        return element ? element.content.trim() : '';
    }

    // Extract Title, Description, and Tags from the <head>
    const title = document.title.trim();
    if (title) metadataOutput.push(`# ${title}`);

    const description = getMetaContent('meta[name="description"]');
    if (description) metadataOutput.push(`\\n**Description:** ${description}`);

    const ogTags = getMetaContent('meta[property="og:tags"], meta[name="keywords"]');
    if (ogTags) metadataOutput.push(`\\n**Tags:** ${ogTags}`);

    // Append a separator after metadata
    if (metadataOutput.length > 0) metadataOutput.push('\\n\\n---\\n\\n');

    // --- HTML/Markdown Extraction ---
    const MAIN_CONTENT_SELECTORS = 'main, article, [class*="pan-article-content-wrapper"], [class*="post-content"], '
        + '#main-article-body, .entry-content, #content, #article-body';

    const IGNORE_SELECTORS = 'nav, header, footer, aside, script, style, noscript, form, iframe, button, video, audio, '
        + '[role="navigation"], [role="banner"], [role="contentinfo"], [role="complementary"], '
        + '.sidebar, #comments, .comment-area, '
        + '[class*="table-of-contents"], [id*="table-of-contents"], .article-sidebar-container, '
        + '.related-content, .widget, .subscribe-form, .product-nav, .additional-resources, .share-buttons, '
        + '.related-posts, .related-articles, [id^="related-articles"], [id^="related-resources"], '
        + '[class*="product-list"], [class*="subscribe"], '
        + '.share-widget, .tag-list, .article-footer, .resources-container, .products-and-services, '
        + '[class*="nav-column"], [class*="mega-dropdown-menu"], [class*="services-list"], '
        + '[class*="sub-menu-col"], [class*="tab-pane"], '
        + '[class*="copyright"], [class*="footer-columns"], .pan-footer-container, [data-type*="script"], '
        + '[class*="site-nav"], [class*="mobile-header"], [id^="main-nav-menu"]';

    const traversalRoot = document.querySelector(MAIN_CONTENT_SELECTORS) || document.body;
    const markdownOutput = metadataOutput;

    function traverse(node) {
        if (node.nodeType === 3) { // TEXT_NODE
            const text = node.textContent.trim();
            if (text) markdownOutput.push(text + ' ');
        } else if (node.nodeType === 1) { // ELEMENT_NODE
            if (node.matches(IGNORE_SELECTORS)) {
                 return;
            }

            const tag = node.tagName.toLowerCase();
            const children = Array.from(node.childNodes);

            if (tag === 'div' && node.textContent.trim().length === 0 && node.children.length === 0) {
                 return;
            }

            let [prefix, suffix, skipChildren] = ['', '', false];

            switch (tag) {
                case 'h1':
                case 'h2':
                case 'h3':
                case 'h4':
                case 'h5':
                case 'h6':
                    const level = parseInt(tag[1], 10);
                    [prefix, suffix] = [`\\n\\n${'#'.repeat(level)} `, '\\n\\n'];
                    break;

                // Table support with proper markdown formatting
                case 'table':
                    [prefix, suffix] = ['\\n\\n', '\\n\\n'];
                    break;
                case 'thead':
                    break;
                case 'tbody':
                    break;
                case 'tr':
                    const isHeaderRow = node.parentNode.tagName.toLowerCase() === 'thead';
                    if (isHeaderRow) {
                        // Header row
                        [prefix, suffix] = ['| ', ' |\\n'];
                    } else {
                        // Check if this is first body row (need separator)
                        const prevRow = node.previousElementSibling;
                        const needsSeparator = !prevRow || prevRow.parentNode?.tagName?.toLowerCase() === 'thead';
                        if (needsSeparator) {
                            const colCount = node.querySelectorAll('td, th').length;
                            const separator = '|' + ' --- |'.repeat(colCount) + '\\n';
                            [prefix, suffix] = [separator + '| ', ' |\\n'];
                        } else {
                            [prefix, suffix] = ['| ', ' |\\n'];
                        }
                    }
                    break;
                case 'td':
                case 'th':
                    [prefix, suffix] = [' ', ' |'];
                    break;

                case 'p':
                case 'div':
                    [prefix, suffix] = ['\\n\\n', '\\n\\n'];
                    break;

                case 'li':
                    const listSymbol = node.parentNode.tagName.toLowerCase() === 'ol'
                        ? `${Array.from(node.parentNode.children).indexOf(node) + 1}. `
                        : '* ';
                    prefix = `\\n${listSymbol}`;
                    break;

                case 'a':
                    const href = node.getAttribute('href');
                    const text = node.textContent.trim();
                    if (href && text && !href.toLowerCase().startsWith('mailto:')) {
                        prefix = `[${text}](${href}) `;
                        skipChildren = true;
                    } else if (text) {
                        prefix = text;
                        skipChildren = true;
                    }
                    break;
                case 'strong':
                case 'b':
                    [prefix, suffix] = ['**', '**'];
                    break;
                case 'br':
                case 'hr':
                    prefix = '\\n';
                    break;
                case 'pre':
                    prefix = '\\n\\n```\\n';
                    suffix = '\\n```\\n\\n';
                    break;
            }

            markdownOutput.push(prefix);

            if (!skipChildren) {
                children.forEach(traverse);
            }

            markdownOutput.push(suffix);
        }
    }

    traverse(traversalRoot);

    // Final Cleanup
    let finalContent = markdownOutput.join('').trim();

    finalContent = finalContent.replace(/ +/g, ' ');
    finalContent = finalContent.replace(/\\n\\n\\n+/g, '\\n\\n');
    finalContent = finalContent.replace(/[ \\t]*\\n[ \\t]*/g, '\\n');
    finalContent = finalContent.replace(/ ([.,;:!?])/g, '$1');
    finalContent = finalContent.replace(/\\*\\*\\n\\n/g, '**');
    finalContent = finalContent.replace(/\\*\\* \\*\\* /g, ' ');

    // Table cleanup
    finalContent = finalContent.replace(/\\|(\\s*?)\\|(\\s*?)-\\|/g, '|');
    finalContent = finalContent.replace(/\\|\\s*?\\n/g, '\\n');
    finalContent = finalContent.split('\\n').map(line => line.trim()).join('\\n');
    finalContent = finalContent.trim();

    return { type: 'html', content: finalContent };
})();
"""


def extract_content_from_tab(tab: pychrome.Tab, navigation_timeout: int) -> tuple[str, str]:
    """
    Executes the JavaScript to extract ONLY the structured content string.
    For JSON content (detected by Content-Type), returns raw JSON. For HTML, returns markdown-formatted content.

    The JavaScript handles both detection and extraction in a single call for optimal performance.

    Returns:
        tuple[str, str]: A tuple containing:
            - str: The structured content string (JSON or Markdown).
            - str: The final URL navigated to (as auxiliary data).
    """
    demisto.debug(f"Executing content-only extraction for tab {tab.id}")

    try:
        frame_tree_result = tab.Page.getFrameTree()
        final_url = frame_tree_result.get("frameTree", {}).get("frame", {}).get("url", "N/A")
    except Exception as ex:
        demisto.debug(f"Could not get frame URL: {ex}")
        final_url = "N/A"

    try:
        result = tab.Runtime.evaluate(expression=_EXTRACTION_JAVASCRIPT, returnByValue=True, _timeout=navigation_timeout)
        extraction_result = result.get("result", {}).get("value", {})

        content_type = extraction_result.get("type", "html")
        raw_content = extraction_result.get("content", "").strip()

        if content_type == "json":
            try:
                parsed_json = json.loads(raw_content)
                content_string = json.dumps(parsed_json, separators=(", ", ": "), ensure_ascii=False)
                demisto.debug(f"Successfully parsed and formatted JSON (detected by Content-Type) from {final_url}")
            except json.JSONDecodeError as json_err:
                demisto.debug(f"Could not parse JSON from {final_url}: {json_err}, returning raw content")
                content_string = raw_content
        else:
            content_string = raw_content

        if not content_string:
            raise DemistoException("Extraction failed: Received empty content string from JavaScript execution.")

        return content_string, final_url

    except Exception as ex:
        demisto.error(f"Failed to extract structured content from tab {tab.id}: {ex}")
        return f"Extraction Error: {str(ex)}", final_url


# endregion


def get_chrome_processes(port) -> list:
    try:
        processes = []
        # get all the processes running on the machine
        for pid in os.listdir("/proc"):
            if pid.isdigit():
                try:
                    with open(f"/proc/{pid}/cmdline") as f:
                        cmd = f.read().replace("\x00", " ").strip()
                        if cmd:
                            processes.append(f"{pid} {cmd}")
                except Exception:
                    pass

        # identifiers the relevant chrome processes
        chrome_identifiers = ["chrom", "headless", f"--remote-debugging-port={port}"]
        chrome_renderer_identifiers = ["--type=renderer"]
        # filter by the identifiers the relevant processes and get it as list
        chrome_processes = [
            process
            for process in processes
            if all(identifier in process for identifier in chrome_identifiers)
            and not any(identifier in process for identifier in chrome_renderer_identifiers)
        ]

        demisto.debug(f"Detected {len(chrome_processes)} Chrome processes running on port {port}")
        return chrome_processes
    except Exception as e:
        demisto.info(f"Unexpected exception when fetching chrome process list, error: {e}")
        return []


def get_chrome_browser(port: str) -> pychrome.Browser | None:
    # Verify that the process has started
    for attempt in range(DEFAULT_RETRIES_COUNT):
        running_chromes_count = len(get_chrome_processes(port))
        if running_chromes_count < 1:
            demisto.debug(f"Attempt {attempt + 1}/{DEFAULT_RETRIES_COUNT}: Process not started yet, sleeping...")
            time.sleep(DEFAULT_RETRY_WAIT_IN_SECONDS + attempt * 2)
        else:
            break
    else:
        # Even if the process hasn't started, attempt connection in case it starts meanwhile.
        demisto.debug(f"Process did not start after {DEFAULT_RETRIES_COUNT} attempts. Moving on to try to connect.")

    # connect to the Chrome browser instance
    browser_url = f"http://{LOCAL_CHROME_HOST}:{port}"
    for i in range(DEFAULT_RETRIES_COUNT):
        try:
            demisto.debug(f"Trying to connect to {browser_url=}, iteration {i + 1}/{DEFAULT_RETRIES_COUNT}")
            browser = pychrome.Browser(url=browser_url)

            # Use list_tab to ping the browser and make sure it's available
            tabs_count = len(browser.list_tab())
            demisto.debug(f"get_chrome_browser, {port=}, {tabs_count=}, {MAX_CHROME_TABS_COUNT=}")
            # if tabs_count < MAX_CHROME_TABS_COUNT:
            demisto.debug(f"Connected to Chrome on port {port} with {tabs_count} tabs")
            return browser
        except requests.exceptions.ConnectionError as exp:
            exp_str = str(exp)
            connection_refused = "connection refused"
            if connection_refused in exp_str:
                demisto.debug(f"Failed to connect to Chrome on port {port} on iteration {i + 1}. {connection_refused}")
            else:
                demisto.debug(
                    f"Failed to connect to Chrome on port {port} on iteration {i + 1}. ConnectionError, {exp_str=}, {exp=}"
                )

        # Mild backoff
        time.sleep(DEFAULT_RETRY_WAIT_IN_SECONDS + i * 2)  # pylint: disable=E9003

    return None


def read_json_file(json_file_path: str = CHROME_INSTANCES_FILE_PATH) -> dict[str, Any]:
    """
    Read the content from a JSON file and return it as a Python dictionary or list.
    :param file_path: Path to the JSON file.
    :return: The JSON content as a Python dictionary or list, or None if the file does not exist or is empty.
    """
    if not os.path.exists(json_file_path):
        demisto.info(f"File '{json_file_path}' does not exist.")
        return {}
    try:
        with open(json_file_path) as file:
            # Read and parse the JSON data
            data = json.load(file)
            return data
    except json.JSONDecodeError:
        demisto.debug(f"Error decoding JSON from the file '{json_file_path}'.")
        return {}


def increase_counter_chrome_instances_file(chrome_port: str = ""):
    """
    The function will increase the counter of the port "chrome_port"
    If the file "CHROME_INSTANCES_FILE_PATH" exists the function will increase the counter of the port "chrome_port."

    :param chrome_port: Port for Chrome instance.
    """
    existing_data = read_json_file()

    if chrome_port in existing_data:
        existing_data[chrome_port][RASTERIZATION_COUNT] = existing_data[chrome_port].get(RASTERIZATION_COUNT, 0) + 1
        write_chrome_instances_file(existing_data)
    else:
        demisto.info(f"Chrome port '{chrome_port}' not found.")


def terminate_port_chrome_instances_file(chrome_port: str = ""):
    """
    The function will increase the counter of the port "chrome_port"
    If the file "CHROME_INSTANCES_FILE_PATH" exists the function will increase the counter of the port "chrome_port."

    :param chrome_port: Port for Chrome instance.
    """
    existing_data = read_json_file()

    if chrome_port in existing_data:
        del existing_data[chrome_port]
        write_chrome_instances_file(existing_data)
    else:
        demisto.info(f"Chrome port '{chrome_port}' not found.")


def add_new_chrome_instance(new_chrome_instance_content: Optional[Dict] = None) -> None:
    """Add new Chrome instance content to the JSON file.

    :param new_chrome_instance_content: Data to write to the file. If None, an empty file is created.

    """
    existing_data = read_json_file()

    if new_chrome_instance_content:
        existing_data.update(new_chrome_instance_content)

    write_chrome_instances_file(existing_data)


def write_chrome_instances_file(new_chrome_content: Optional[Dict] = {}):
    """
    Add new Chrome instance content to the JSON file.

    :param new_chrome_content: Data to write to the file. If None, an empty file is created.

    """
    try:
        with open(CHROME_INSTANCES_FILE_PATH, "w") as file:
            json.dump(new_chrome_content, file, indent=4)
    except Exception as e:
        demisto.debug(f"An error occurred while writing to the file: {e}")


def opt_name(opt: str) -> str:
    return opt.split("=", 1)[0]


def get_chrome_options(default_options, user_options):
    """Return the command line options for Chrome

    Returns:
        list -- merged options
    """
    demisto.debug(f"get_chrome_options, {default_options=}, {user_options=}")
    if not user_options:
        # Nothing to do
        return default_options.copy()

    user_options = re.split(r"(?<!\\),", user_options)
    demisto.debug(f"user Chrome options: {user_options}")

    options = []
    remove_opts = []
    for opt in user_options:
        opt = opt.strip()
        if opt.startswith("[") and opt.endswith("]"):
            remove_opts.append(opt[1:-1])
        else:
            options.append(opt.replace(r"\,", ","))
    # Remove values (such as in user-agent)
    option_names = [opt_name(x) for x in options]
    # Add filtered defaults only if not in removed and we don't have it already
    options.extend([x for x in default_options if (opt_name(x) not in remove_opts and opt_name(x) not in option_names)])
    return options


def start_chrome_headless(chrome_port, instance_id, chrome_options, chrome_binary=CHROME_EXE):
    try:
        logfile = open(CHROME_LOG_FILE_PATH, "ab")

        default_chrome_options = CHROME_OPTIONS
        default_chrome_options.append(f"--remote-debugging-port={chrome_port}")
        subprocess_options = [chrome_binary]
        user_chrome_options = demisto.params().get(CHROME_INSTANCE_OPTIONS, "")
        subprocess_options.extend(get_chrome_options(default_chrome_options, user_chrome_options))
        demisto.debug(f"Starting Chrome with {subprocess_options=}")

        process = subprocess.Popen(subprocess_options, stdout=logfile, stderr=subprocess.STDOUT)
        demisto.debug(f"Chrome started on port {chrome_port}, pid: {process.pid},returncode: {process.returncode}")

        if process:
            demisto.debug(f"New Chrome session active on {chrome_port=}: {chrome_options=} {chrome_options=}")
            browser = get_chrome_browser(chrome_port)
            if browser:
                new_chrome_instance = {
                    chrome_port: {INSTANCE_ID: instance_id, CHROME_INSTANCE_OPTIONS: chrome_options, RASTERIZATION_COUNT: 0}
                }
                add_new_chrome_instance(new_chrome_instance_content=new_chrome_instance)
            else:
                process.kill()
                return None, None
            return browser, chrome_port
        else:
            demisto.debug(f"Chrome did not start successfully on port {chrome_port}. Return code: {process.returncode}")
    except subprocess.SubprocessError as ex:
        demisto.info(f"Error starting Chrome on port {chrome_port}. Error: {ex}")
    demisto.info("Could not connect to Chrome.")

    return None, None


def terminate_chrome(chrome_port: str = "", killall: bool = False) -> None:  # pragma: no cover
    """
    Terminates Chrome processes based on the specified criteria.

    This function provides two modes of operation:
    1. If `chrome_port` is specified, it will terminate the Chrome process
       associated with the given port, and `killall` is automatically set to False.
    2. If `chrome_port` is not specified and `killall` is set to True, it will
       terminate all running Chrome processes to ensure efficiency by clearing the cache.

    Args:
        chrome_port (str, optional): The port number of the Chrome process to terminate.
                                     Default is an empty string.
        killall (bool, optional): Flag to terminate all running Chrome processes.
                                  Default is False.

    Returns:
        None
    """
    process_in_list = get_chrome_processes(chrome_port)

    if killall:
        # fetch the pids of the processes
        pids = [int(process.split()[0]) for process in process_in_list]
    else:
        # fetch the pid of the process. the list contain just one process with the given chrome_port
        process_string_representation = process_in_list[0]
        pids = [int(process_string_representation.split()[0])]

    for pid in pids:
        # for each pid, get the process by it PID and terminate it
        process = psutil.Process(pid)
        if process:
            try:
                demisto.debug(f"terminate_chrome, {process=}")
                process.kill()
            except Exception as e:
                demisto.info(f"Exception when trying to kill chrome with {pid=}, {e}")
    terminate_port_chrome_instances_file(chrome_port=chrome_port)
    demisto.debug("terminate_chrome, Finish")


def chrome_manager() -> tuple[Any | None, str | None]:
    """
    Manages Chrome instances based on user-specified chrome options and integration instance ID.

    This function performs the following steps:
    1. Retrieves the instance ID of the integration and the Chrome options set by the user.
    2. Checks if the instance ID has been used previously.
        - If the instance ID is new, generates a new Chrome instance with the specified Chrome options.
        - If the instance ID has been used:
            - If the current Chrome options differ from the saved options for this instance ID,
              it terminates the existing Chrome instance and generates a new one with the new options.
            - If the current Chrome options match the saved options for this instance ID,
              it reuses the existing Chrome instance.

    Returns:
        tuple[Any | None, int | None]: A tuple containing:
            - The Browser or None if an error occurred.
            - The chrome port or None if an error occurred.
    """
    # If instance_id or chrome_options are not set, assign 'None' to these variables.
    # This way, when fetching the content from the file, if there was no instance_id or chrome_options before,
    # it can compare between the fetched 'None' string and the 'None' that assigned.
    instance_id = demisto.callingContext.get("context", {}).get("IntegrationInstanceID", "None") or "None"
    chrome_options = demisto.params().get("chrome_options", "None")
    chrome_instances_contents = read_json_file(CHROME_INSTANCES_FILE_PATH)
    instance_id_dict = {
        value[INSTANCE_ID]: {"chrome_port": key, CHROME_INSTANCE_OPTIONS: value[CHROME_INSTANCE_OPTIONS]}
        for key, value in chrome_instances_contents.items()
    }
    if not chrome_instances_contents or instance_id not in instance_id_dict:
        return generate_new_chrome_instance(instance_id, chrome_options)

    elif chrome_options != instance_id_dict.get(instance_id, {}).get(CHROME_INSTANCE_OPTIONS, ""):
        # If the current Chrome options differ from the saved options for this instance ID,
        # it terminates the existing Chrome instance and generates a new one with the new options.
        chrome_port = instance_id_dict.get(instance_id, {}).get("chrome_port", "")
        terminate_chrome(chrome_port=chrome_port)
        return generate_new_chrome_instance(instance_id, chrome_options)

    chrome_port = instance_id_dict.get(instance_id, {}).get("chrome_port", "")
    browser = get_chrome_browser(chrome_port)
    return browser, chrome_port


def chrome_manager_one_port() -> tuple[pychrome.Browser | None, str | None]:
    """
    Manages Chrome instances based on user-specified chrome options and integration instance ID.
    ONLY uses one chrome instance per chrome option, until https://issues.chromium.org/issues/379034728 is fixed.


    This function performs the following steps:
    1. Retrieves the Chrome options set by the user.
    2. Checks if the  Chrome options has been used previously.
        - If the Chrome options wasn't used and the file is empty, generates a new Chrome instance with
        the specified Chrome options.
        - If the  Chrome options exists in the dictionary- it reuses the existing Chrome instance.
        -  If the Chrome options wasn't used and the file isn't empty- it terminates all the use port and
        generates a new one with the new options.

    Returns:
        tuple[Any | None, int | None]: A tuple containing:
            - The Browser or None if an error occurred.
            - The chrome port or None if an error occurred.
    """
    # If instance_id or chrome_options are not set, assign 'None' to these variables.
    # This way, when fetching the content from the file, if there was no instance_id or chrome_options before,
    # it can compare between the fetched 'None' string and the 'None' that assigned.
    instance_id = demisto.callingContext.get("context", {}).get("IntegrationInstanceID", "None") or "None"
    chrome_options = demisto.params().get("chrome_options", "None")
    chrome_instances_contents = read_json_file(CHROME_INSTANCES_FILE_PATH)
    demisto.debug(f"chrome_manager {chrome_instances_contents=} {chrome_options=} {instance_id=}")
    chrome_options_dict = {
        options[CHROME_INSTANCE_OPTIONS]: {"chrome_port": port} for port, options in chrome_instances_contents.items()
    }
    chrome_port = chrome_options_dict.get(chrome_options, {}).get("chrome_port", "")
    if not chrome_instances_contents:  # or instance_id not in chrome_options_dict.keys():
        demisto.debug("chrome_manager: condition chrome_instances_contents is empty")
        return generate_new_chrome_instance(instance_id, chrome_options)
    if chrome_options in chrome_options_dict:
        demisto.debug("chrome_manager: condition chrome_options in chrome_options_dict is true")
        browser = get_chrome_browser(chrome_port)
        return browser, chrome_port
    for chrome_port_ in chrome_instances_contents:
        if chrome_port_ == "None":
            terminate_port_chrome_instances_file(chrome_port_)
            demisto.debug(f"chrome_manager {chrome_port_=}, removing the port from chrome_instances file")
            continue
        demisto.debug(f"chrome_manager {chrome_port_=}, terminating the port")
        terminate_chrome(chrome_port=chrome_port_)
    return generate_new_chrome_instance(instance_id, chrome_options)


##### Chrome Instance Management #####


def find_existing_chrome_port() -> str | None:
    """
    Finds the port of an already-running Chrome process.

    Used as a fallback when generate_chrome_port() returns None (all ports occupied),
    which can happen after an OOM kill clears the chrome_instances file while Chrome
    is still running. In lightweight mode MAX_CHROMES_COUNT=1, so there is at most
    one port to check.

    Returns:
        str | None: The port string of the first occupied Chrome port, or None if none found.
    """
    first_chrome_port = FIRST_CHROME_PORT
    ports_list = list(range(first_chrome_port, first_chrome_port + MAX_CHROMES_COUNT))
    for chrome_port in ports_list:
        if len(get_chrome_processes(chrome_port)) > 0:
            demisto.debug(f"find_existing_chrome_port: found existing Chrome on port {chrome_port}")
            return str(chrome_port)
    return None


def generate_new_chrome_instance(instance_id: str, chrome_options: str) -> tuple[Any | None, str | None]:
    chrome_port = generate_chrome_port()
    if chrome_port is None:
        # Try to reconnect to the already-running Chrome instead of failing.
        chrome_port = find_existing_chrome_port()
        if chrome_port is None:
            demisto.error("generate_new_chrome_instance: no available or existing Chrome port found.")
            return None, None
        demisto.info(f"generate_new_chrome_instance: reconnecting to existing Chrome on port {chrome_port}")
        browser = get_chrome_browser(chrome_port)
        if browser:
            new_chrome_instance = {
                chrome_port: {INSTANCE_ID: instance_id, CHROME_INSTANCE_OPTIONS: chrome_options, RASTERIZATION_COUNT: 0}
            }
            add_new_chrome_instance(new_chrome_instance_content=new_chrome_instance)
            return browser, chrome_port
        demisto.error(f"generate_new_chrome_instance: could not connect to existing Chrome on port {chrome_port}")
        return None, None
    return start_chrome_headless(chrome_port, instance_id, chrome_options)


def generate_chrome_port() -> str | None:
    first_chrome_port = FIRST_CHROME_PORT
    ports_list = list(range(first_chrome_port, first_chrome_port + MAX_CHROMES_COUNT))
    random.shuffle(ports_list)
    demisto.debug(f"Searching for Chrome on these ports: {ports_list}")
    for chrome_port in ports_list:
        len_running_chromes = len(get_chrome_processes(chrome_port))
        demisto.debug(f"Found {len_running_chromes=} on port {chrome_port}")

        if len_running_chromes == 0:
            # There's no Chrome listening on that port, Start a new Chrome there
            demisto.debug(f"No Chrome found on port {chrome_port}, using the port.")
            return str(chrome_port)

        # There's already a Chrome listening on that port, Don't use it

    demisto.error(f"Max retries ({MAX_CHROMES_COUNT}) reached, could not connect to Chrome")
    return None


def setup_tab_event(
    browser: pychrome.Browser, tab: pychrome.Tab, path: str, navigation_timeout: int
) -> tuple[PychromeEventHandler, Event]:  # pragma: no cover
    tab_ready_event = Event()
    tab_event_handler = PychromeEventHandler(browser, tab, tab_ready_event, path, navigation_timeout)

    tab.Network.enable()
    tab.Network.dataReceived = tab_event_handler.network_data_received
    # tab.Network.responseReceived = tab_event_handler.network_response_received
    tab.Network.requestWillBeSent = tab_event_handler.network_request_will_be_sent

    tab.Page.frameStartedLoading = tab_event_handler.page_frame_started_loading
    tab.Page.frameStoppedLoading = tab_event_handler.page_frame_stopped_loading

    tab.Fetch.requestPaused = tab_event_handler.handle_request_paused

    return tab_event_handler, tab_ready_event


def navigate_to_path(browser, tab: pychrome.Tab, path, wait_time, navigation_timeout) -> PychromeEventHandler:  # pragma: no cover
    tab_event_handler, tab_ready_event = setup_tab_event(browser, tab, path, navigation_timeout)

    try:
        demisto.info(f"Starting tab navigation to given path: {path} on {tab.id=}")

        allTimeSamplingProfile = tab.Memory.getAllTimeSamplingProfile()
        demisto.debug(f"allTimeSamplingProfile before navigation {allTimeSamplingProfile=} on {tab.id=}, {path=}")
        heapUsage = tab.Runtime.getHeapUsage()
        demisto.debug(f"heapUsage before navigation {heapUsage=} on {tab.id=}, {path=}")

        if navigation_timeout > 0:
            tab.Page.navigate(url=path, _timeout=navigation_timeout)
        else:
            tab.Page.navigate(url=path)

        demisto.debug(f"Waiting for tab_ready_event on {tab.id=}, {path=}")

        if IS_LIGHTWEIGHT:
            page_loaded_normally = wait_for_page_load_with_memory_guard(
                tab_ready_event=tab_ready_event,
                navigation_timeout=navigation_timeout,
                tab_id=tab.id,
                path=path,
                tab=tab,
            )
            if not page_loaded_normally:
                return_warning(
                    f"Warning: Rasterize aborted page-load wait due to memory pressure. "
                    f"A partial screenshot will be captured for {path}"
                )
            elif not tab_ready_event.is_set():
                return_warning(
                    f"Warning: Rasterize failed to navigate to the specified path due to a timeout "
                    f"of {navigation_timeout} seconds, some content might be missing .\n{path=}"
                )
        else:
            if not tab_ready_event.wait(navigation_timeout):
                return_warning(
                    f"Warning: Rasterize failed to navigate to the specified path due to a timeout "
                    f"of {navigation_timeout} seconds, some content might be missing .\n{path=}"
                )

        demisto.debug(f"After waiting for tab_ready_event on {tab.id=}, {path=}")

        if wait_time > 0:
            demisto.info(f"Sleeping before capturing screenshot, {wait_time=}, {tab.id=}, {path=}")
        else:
            demisto.debug(f"Not sleeping before capturing screenshot, {wait_time=}. {tab.id=}, {path=}")
        time.sleep(wait_time)  # pylint: disable=E9003
        demisto.debug(f"Navigated to {path=} on {tab.id=}")

        allTimeSamplingProfile = tab.Memory.getAllTimeSamplingProfile()
        demisto.debug(f"allTimeSamplingProfile after navigation {allTimeSamplingProfile=} on {tab.id=}")
        heapUsage = tab.Runtime.getHeapUsage()
        demisto.debug(f"heapUsage after navigation {heapUsage=} on {tab.id=}")

    except pychrome.exceptions.TimeoutException as ex:
        return_error(f"Navigation timeout: {ex} thrown while trying to navigate to {path}, {tab.id=}")
    except pychrome.exceptions.PyChromeException as ex:
        return_error(f"Exception: {ex} thrown while trying to navigate to {path}, {tab.id=}")

    return tab_event_handler


def backoff(polled_item: Any, wait_time=DEFAULT_WAIT_TIME, polling_interval=DEFAULT_POLLING_INTERVAL) -> tuple[Any, float]:
    operation_time = 0
    while polled_item is None and operation_time < wait_time:
        time.sleep(polling_interval)  # pylint: disable=E9003
        operation_time += polling_interval
    return polled_item, operation_time


def screenshot_image(
    browser: pychrome.Browser,
    tab: pychrome.Tab,
    path: str,
    wait_time: int,
    navigation_timeout: int,
    full_screen=False,
    include_url=False,
    include_source=False,
):  # pragma: no cover
    """Takes a screenshot of a web page using Chrome browser.

    Args:
        browser: The Chrome browser instance.
        tab: The Chrome tab instance.
        path: The URL or file path to capture.
        wait_time: Time to wait before taking the screenshot.
        navigation_timeout: Maximum time to wait for page load.
        full_screen: Whether to capture full page. Defaults to False.
        include_url: Whether to include URL in the image. Defaults to False.
        include_source: Whether to include page source in the response. Defaults to False.

    Returns:
        tuple: A tuple containing:
            - bytes: The captured image data.
            - str: The page source if include_source is True, otherwise an empty string.

    Raises:
        DemistoException: If the URL is a local file or starts with "mailto:".
    """
    command = demisto.command()
    if path.lower().startswith("file://") and command not in [
        "rasterize-email",
        "rasterize-html",
        "rasterize-image",
        "test-module",
    ]:
        # In some rasterize commands we create a temporary file, and we only rasterize it
        demisto.info(f"Rejected path: {path}. Local files cannot be rasterized for this command.")
        return None, ("Cannot rasterize local files")
    tab_event_handler = navigate_to_path(browser, tab, path, wait_time, navigation_timeout)

    if tab_event_handler.is_mailto:
        # Determine the appropriate URL to display in the error message
        display_url = tab_event_handler.document_url if tab_event_handler.document_url != tab_event_handler.path else path

        # Create a more descriptive error message
        if tab_event_handler.document_url != tab_event_handler.path:
            # Handle redirect case where original URL redirects to mailto
            error_msg = (
                f'URLs that start with "mailto:" cannot be rasterized.\n'
                f"Original URL: {path}\n"
                f"Redirected to: {tab_event_handler.document_url}"
            )
            demisto.info(f"Mailto redirect detected - {error_msg}, tab_id={tab.id}")
        else:
            # Handle direct mailto URL case
            error_msg = f'URLs that start with "mailto:" cannot be rasterized.\nURL: {display_url}'
            demisto.info(f"Direct mailto URL detected - {error_msg}, tab_id={tab.id}")

        return None, error_msg

    if tab_event_handler.is_private_network_url:
        demisto.info(
            'URLs that belong to the "This" Network (0.0.0.0/8), or'
            f" the Loopback Network (127.0.0.0/8) cannot be rasterized.\nURL: {path}, {tab.id=}"
        )
        return None, (
            'URLs that belong to the "This" Network (0.0.0.0/8), or'
            f" the Loopback Network (127.0.0.0/8) cannot be rasterized.\nURL: {path}"
        )

    try:
        page_layout_metrics = tab.Page.getLayoutMetrics()
    except Exception as ex:
        demisto.info(f"Failed to get tab LayoutMetrics for {tab.id=} {path=} due to {ex}")
        raise ex

    demisto.debug(f"{page_layout_metrics=} {tab.id=} {path=}.")
    css_content_size = page_layout_metrics["cssContentSize"]
    try:
        if full_screen:
            viewport = css_content_size
            viewport["scale"] = (
                1 if not IS_LIGHTWEIGHT else 0.75
            )  # In lightweight mode, use a smaller scale to reduce memory usage.
            screenshot_data = tab.Page.captureScreenshot(clip=viewport, captureBeyondViewport=True, _timeout=SCREENSHOT_TIMEOUT)[
                "data"
            ]
        else:
            screenshot_data = tab.Page.captureScreenshot(_timeout=SCREENSHOT_TIMEOUT)["data"]
    except Exception as ex:
        demisto.info(f"Failed to capture screenshot due to {ex}, {tab.id=}, {path=}")
        raise ex
    # Make sure that the (asynchronous) screenshot data is available before continuing with execution
    screenshot_data, operation_time = backoff(screenshot_data)
    if screenshot_data:
        demisto.debug(f"Screenshot image of {path=} on {tab.id=}, available after {operation_time} seconds.")
    else:
        demisto.info(f"Screenshot image of {path=} on {tab.id=}, not available after {operation_time} seconds.")

    allTimeSamplingProfile = tab.Memory.getAllTimeSamplingProfile()
    demisto.debug(f"allTimeSamplingProfile after screenshot {allTimeSamplingProfile=} on {tab.id=}, {path=}")
    heapUsage = tab.Runtime.getHeapUsage()
    demisto.debug(f"heapUsage after screenshot {heapUsage=} on {tab.id=}, {path=}")

    captured_image = base64.b64decode(screenshot_data)
    # Release the (potentially large) base64 string immediately so we do not hold the encoded and
    # decoded copies of the image in memory at the same time.
    del screenshot_data
    gc.collect()
    if not captured_image:
        demisto.info(f"Empty snapshot, {tab.id=}, {path=}")
    else:
        demisto.info(f"Captured snapshot, {len(captured_image)=}, {tab.id=}, {path=}")

    # Page URL, if needed
    if include_url:
        demisto.debug(f"Including URL in image for path: {path}, {tab.id=}, {path=}")
        captured_image_object = Image.open(BytesIO(captured_image))
        demisto.debug(f"Original image size: {captured_image_object.size}, {tab.id=}, {path=}")

        image_with_url = Image.new(captured_image_object.mode, (css_content_size["width"], css_content_size["height"] + 20))
        demisto.debug(f"New image size with URL: {image_with_url.size}, {tab.id=}, {path=}")

        image_with_url.paste(captured_image_object, (0, 20))
        ImageDraw.Draw(image_with_url).text((0, 0), path, fill=(255, 255, 255))

        img_byte_arr = BytesIO()
        image_with_url.save(img_byte_arr, format="PNG")
        ret_value = img_byte_arr.getvalue()
        demisto.debug(f"Size of image with URL: {len(ret_value)} bytes, {tab.id=}, {path=}")

        # Release the intermediate PIL images and buffers; otherwise the source bitmap, the new
        # canvas and the re-encoded buffer (three full-size copies) stay alive until function exit.
        captured_image_object.close()
        image_with_url.close()
        img_byte_arr.close()
        del captured_image_object, image_with_url, img_byte_arr, captured_image
        gc.collect()
    else:
        ret_value = captured_image
        del captured_image
        gc.collect()

    # Page source, if needed
    response_body = ""
    if include_source:
        demisto.debug(f"screenshot_image, include_source, waiting for request_id, {tab.id=}, {path=}")
        request_id, request_id_operation_time = backoff(tab_event_handler.request_id)
        if request_id:
            demisto.debug(f"request_id available after {request_id_operation_time} seconds, {tab.id=}, {path=}.")
        else:
            demisto.info(f"request_id not available after {request_id_operation_time} seconds, {tab.id=}, {path=}.")
        demisto.debug(f"Got {request_id=} after {request_id_operation_time} seconds, {tab.id=}, {path=}.")

        try:
            response_body = tab.Network.getResponseBody(requestId=request_id, _timeout=navigation_timeout)["body"]
            demisto.debug(f"screenshot_image, {include_source=}, {response_body=}, {tab.id=}, {path=}")

            response_body, operation_time = backoff(response_body)
            if response_body:
                demisto.debug(
                    f"Response Body available after {operation_time} seconds, {len(response_body)=}, {tab.id=}, {path=}"
                )
            else:
                demisto.info(f"Response Body not available after {operation_time} seconds, {tab.id=}, {path=}.")

        except Exception as ex:  # This exception is raised when a non-existent URL is provided.
            demisto.info(f"Exception when calling Network.getResponseBody with {request_id=}, {ex=}, {tab.id=}, {path=}")
            demisto.info(f"Failed to get URL body due to {ex}")
            response_body = "Failed to get URL body"

    return ret_value, response_body


def screenshot_pdf(
    browser: pychrome.Browser, tab: pychrome.Tab, path: str, wait_time: int, navigation_timeout: int, include_url: bool
):  # pragma: no cover
    navigate_to_path(browser, tab, path, wait_time, navigation_timeout)
    header_template = ""
    if include_url:
        header_template = "<span class=url></span>"

    try:
        pdf_data = tab.Page.printToPDF(headerTemplate=header_template)["data"]
    except Exception as ex:
        demisto.info(f"Failed to get PDF due to {ex}, {tab.id=}")
        raise ex
    # Make sure that the (asynchronous) PDF data is available before continuing with execution
    pdf_data, operation_time = backoff(pdf_data)
    if pdf_data:
        demisto.debug(f"PDF Data available after {operation_time} seconds, {tab.id=}, {path=}.")
    else:
        demisto.info(f"PDF Data not available after {operation_time} seconds, {tab.id=}, {path=}.")

    ret_value = base64.b64decode(pdf_data)
    return ret_value, None


def extract_text_content(
    browser: pychrome.Browser, tab: pychrome.Tab, path: str, wait_time: int, navigation_timeout: int
):  # pragma: no cover
    """Extracts structured text content from a web page using Chrome browser.

    Args:
        browser: The Chrome browser instance.
        tab: The Chrome tab instance.
        path: The URL or file path to extract content from.
        wait_time: Time to wait before extracting content.
        navigation_timeout: Maximum time to wait for page load.

    Returns:
        tuple: A tuple containing:
            - str: The extracted content string.
            - str: The final URL navigated to.

    Raises:
        DemistoException: If the URL is a mailto or private network URL.
    """
    tab_event_handler = navigate_to_path(browser, tab, path, wait_time, navigation_timeout)

    if tab_event_handler.is_mailto or tab_event_handler.is_private_network_url:
        error_msg = f'Cannot rasterize "mailto:" or private network URLs. URL: {tab_event_handler.document_url}'
        demisto.info(f"Blocked URL for text extraction - {error_msg}, tab_id={tab.id}")
        return None, error_msg

    demisto.debug(f"Executing extract_content_from_tab for TEXT, {path=}, {tab.id=}")

    extracted_content, final_url = extract_content_from_tab(tab, navigation_timeout)

    return extracted_content, final_url


def rasterize_thread(
    browser: pychrome.Browser,
    chrome_port,
    path: str,
    rasterize_type: RasterizeType = RasterizeType.PNG,
    wait_time: int = DEFAULT_WAIT_TIME,
    offline_mode: bool = False,
    navigation_timeout: int = DEFAULT_PAGE_LOAD_TIME,
    include_url: bool = False,
    full_screen: bool = False,
    width: int = DEFAULT_WIDTH,
    height: int = DEFAULT_HEIGHT,
):
    demisto.debug(f"rasterize_thread, starting TabLifecycleManager, {path=}, {rasterize_type=}")
    with TabLifecycleManager(browser, chrome_port, offline_mode) as tab:
        try:
            tab.call_method("Emulation.setVisibleSize", width=width, height=height)
        except Exception as ex:
            demisto.info(f"Failed to set the chrome tab size due to {ex}")
            raise ex
        demisto.debug(f"Determining rasterization type: {rasterize_type=}, for {path=}, {tab.id=}")
        if rasterize_type == RasterizeType.PNG or str(rasterize_type).lower() == RasterizeType.PNG.value:
            demisto.debug(f"Executing screenshot_image for PNG, {path=}, {tab.id=}")
            return screenshot_image(
                browser,
                tab,
                path,
                wait_time=wait_time,
                navigation_timeout=navigation_timeout,
                full_screen=full_screen,
                include_url=include_url,
            )

        elif rasterize_type == RasterizeType.PDF or str(rasterize_type).lower() == RasterizeType.PDF.value:
            demisto.debug(f"Executing screenshot_pdf for PDF, {path=}, {tab.id=}")
            return screenshot_pdf(
                browser, tab, path, wait_time=wait_time, navigation_timeout=navigation_timeout, include_url=include_url
            )

        elif rasterize_type == RasterizeType.JSON or str(rasterize_type).lower() == RasterizeType.JSON.value:
            demisto.debug(f"Executing screenshot_image for JSON, {path=}, {tab.id=}")
            return screenshot_image(
                browser,
                tab,
                path,
                wait_time=wait_time,
                navigation_timeout=navigation_timeout,
                full_screen=full_screen,
                include_url=include_url,
                include_source=True,
            )

        elif rasterize_type == RasterizeType.TEXT or str(rasterize_type).lower() == RasterizeType.TEXT.value:
            demisto.debug(f"Executing extract_text_content for TEXT, {path=}, {tab.id=}")
            return extract_text_content(browser, tab, path, wait_time=wait_time, navigation_timeout=navigation_timeout)

        else:
            raise DemistoException(f"Unsupported rasterization type: {rasterize_type}.")


def kill_zombie_processes():
    # Iterate over all running processes
    demisto.debug("Starting kill_zombie_processes")
    zombie_count = 0
    processed_count = 0
    try:
        for proc in psutil.process_iter(["pid", "name", "status"]):
            processed_count += 1
            try:
                # Check if the process is a zombie
                if proc.info["status"] == psutil.STATUS_ZOMBIE:
                    zombie_count += 1
                    demisto.info(f"found zombie process with pid {proc.pid}")
                    waitres = os.waitpid(int(proc.pid), os.WNOHANG)
                    demisto.info(f"waitpid result: {waitres}")
            except (psutil.NoSuchProcess, psutil.AccessDenied) as e:
                # Handle cases where process may have already terminated or access is denied
                demisto.info(f"failed to kill zombie with pid {proc.pid}. Error: {str(e)}")
                continue
    except Exception as e:
        demisto.debug(f"Failed to iterate over processes. Error: {e}")

    demisto.info(
        "kill_zombie_processes completed. "
        f"Processed {processed_count} processes, "
        f"found and attempted to kill {zombie_count} zombies."
    )


def extract_hostname(url: str) -> str:
    """
    Extract hostname from URL, adding http:// if protocol is missing.

    Args:
        url (str): The URL to process

    Returns:
        str: The extracted hostname
    """
    if not url.startswith(("http://", "https://")):
        url = "http://" + url
    try:
        parsed = urlparse(url)
        return parsed.netloc.split(":")[0]  # Remove port if exists
    except Exception:
        return ""


@lru_cache(maxsize=128 if IS_LIGHTWEIGHT else 1024)
def is_private_network(url: str) -> bool:
    """
    Check if a URL's hostname belongs to a private network.

    Args:
        url (str): The URL to check

    Returns:
        bool: True if the hostname is in a private network, False otherwise
    """
    try:
        if not (hostname := extract_hostname(url)):
            demisto.debug(f"Problematic URL detected: Unable to extract hostname from {url}")
            return False

        return ipaddress.ip_address(hostname).is_private

    except (ValueError, AttributeError):
        demisto.debug(f"Problematic URL detected: Unable to process {url}")
        return False


def remove_leading_zeros_from_ip_addresses(path: str) -> str:
    """
    Removes leading zeros from IP addresses in the given path.
    as leading zeros is not valid in IP addresses.
    This function will only remove leading zeros from the IP address
    Args:
        path (str): The path to process.

    Returns:
        str: The processed path with leading zeros removed from IP addresses.
    """
    if not (hostname := extract_hostname(path)):
        return path

    # If hostname contains letters, it's not an IP address
    if bool(re.search("[a-zA-Z]", hostname)):
        return path
    # Check if the hostname is an IP address
    # Check if it's a valid IP address
    ip_pattern = r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
    if re.match(ip_pattern, hostname):
        octets = hostname.split(".")
        normalized_ip = ".".join(str(int(octet)) for octet in octets)
        result = path.replace(hostname, normalized_ip)
        if result != path:
            demisto.info(f"IP address normalized: {path} -> {result}")
        return result

    return path


def perform_rasterize(
    path: str | list[str],
    rasterize_type: RasterizeType = RasterizeType.PNG,
    wait_time: int = DEFAULT_WAIT_TIME,
    offline_mode: bool = False,
    navigation_timeout: int = DEFAULT_PAGE_LOAD_TIME,
    include_url: bool = False,
    full_screen: bool = False,
    width: int = DEFAULT_WIDTH,
    height: int = DEFAULT_HEIGHT,
):
    """
    Capturing a snapshot of a path (url/file), using Chrome Driver
    :param offline_mode: when set to True, will block any outgoing communication
    :param path: file path, or website url
    :param rasterize_type: result type: .png/.pdf
    :param wait_time: time in seconds to wait before taking a screenshot
    :param navigation_timeout: amount of time to wait for a page load to complete before throwing an error
    :param include_url: should the URL be included in the output image/PDF
    :param full_screen: when set to True, the snapshot will take the whole page
    :param width: window width
    :param height: window height
    """

    # convert the path param to list in case we have only one string
    paths: list[str] = argToList(path)
    demisto.debug(f"perform_rasterize, {paths=}, {rasterize_type=}")
    paths = [remove_leading_zeros_from_ip_addresses(path_value) for path_value in paths]
    # create a list with all the paths that start with "mailto:"
    mailto_paths = [path_value for path_value in paths if path_value.startswith("mailto:")]
    demisto.debug(f"Identified {len(mailto_paths)} mailto paths: {mailto_paths=}")
    private_network_paths = [path_value for path_value in paths if is_private_network(path_value)]
    demisto.debug(f"Identified {len(private_network_paths)} private network paths: {private_network_paths=}")

    if private_network_paths or mailto_paths:
        paths = list(set(paths) - set(mailto_paths))
        paths = list(set(paths) - set(private_network_paths))
        demisto.error(f"Not rasterizing the following invalid paths: {private_network_paths + mailto_paths}")
        return_results(
            CommandResults(
                readable_output=(
                    "The following paths were skipped as they are not valid for rasterization:"
                    f" {private_network_paths + mailto_paths}"
                )
            )
        )
    if not paths:
        message = "There are no valid paths to rasterize"
        demisto.error(message)
        return_error(message)
        return None

    # until https://issues.chromium.org/issues/379034728 is fixed, we can only use one chrome port
    browser, chrome_port = chrome_manager_one_port()

    if browser:
        support_multithreading()
        with ThreadPoolExecutor(max_workers=MAX_CHROME_TABS_COUNT) as executor:
            rasterization_threads = []
            rasterization_results = []
            for current_path in paths:
                if not current_path.startswith("http") and not current_path.startswith("file:///"):
                    protocol = "http" + "s" * IS_HTTPS
                    current_path = f"{protocol}://{current_path}"

                # Start a new thread in group of max_tabs
                rasterization_threads.append(
                    (
                        executor.submit(
                            rasterize_thread,
                            browser=browser,
                            chrome_port=chrome_port,
                            path=current_path,
                            rasterize_type=rasterize_type,
                            wait_time=wait_time,
                            offline_mode=offline_mode,
                            navigation_timeout=navigation_timeout,
                            include_url=include_url,
                            full_screen=full_screen,
                            width=width,
                            height=height,
                        ),
                        current_path,
                    )
                )
            # Wait for all tasks to complete
            executor.shutdown(wait=True)
            demisto.info(
                f"perform_rasterize Finished {len(rasterization_threads)} rasterize operations,"
                f"active tabs len: {len(browser.list_tab())}, {path=}"
            )

            chrome_instances_file_content: dict = read_json_file()  # CR fix name

            rasterization_count = chrome_instances_file_content.get(chrome_port, {}).get(RASTERIZATION_COUNT, 0) + len(
                rasterization_threads
            )

            demisto.debug(
                f"perform_rasterize checking if the chrome in port:{chrome_port} should be deleted:"
                f"{rasterization_count=}, {MAX_RASTERIZATIONS_COUNT=}, {len(browser.list_tab())=}, {path=}"
            )
            if not chrome_port:
                demisto.debug(f"perform_rasterize: the chrome port was not found, {path=}")
            elif IS_LIGHTWEIGHT or rasterization_count >= MAX_RASTERIZATIONS_COUNT:
                # In lightweight mode we always terminate Chrome at the end of the command so no Chrome
                # process (and its renderer RSS) survives into the next playbook iteration / command run.
                demisto.info(f"perform_rasterize: terminating Chrome after {rasterization_count=} rasterization, {path=}")
                terminate_chrome(chrome_port=chrome_port)
            else:
                increase_counter_chrome_instances_file(chrome_port=chrome_port)

            # Get the results
            for current_thread, path in rasterization_threads:
                try:
                    ret_value, response_body = current_thread.result()
                    if ret_value:
                        rasterization_results.append((ret_value, response_body))
                    else:
                        return_results(
                            CommandResults(
                                readable_output=str(response_body),
                                entry_type=(EntryType.ERROR if WITH_ERRORS else EntryType.WARNING),
                            )
                        )
                except Exception as ex:
                    error_msg = f"Failed to rasterize the path {path}, exception: {str(ex)}"
                    demisto.debug(error_msg)
                    return_err_or_warn(error_msg)
            return rasterization_results

    else:
        chrome_instances_contents = read_json_file(CHROME_INSTANCES_FILE_PATH)
        chrome_options_dict = {
            options[CHROME_INSTANCE_OPTIONS]: {"chrome_port": port} for port, options in chrome_instances_contents.items()
        }
        chrome_options = demisto.params().get("chrome_options", "None")
        chrome_port = chrome_options_dict.get(chrome_options, {}).get("chrome_port", "")

        # Get all Chrome headless processes for diagnostic purposes
        # Using get_chrome_processes("") to match any port (equivalent to grep port=)
        chrome_processes = get_chrome_processes("")
        ps_aux_output = "\n".join(chrome_processes) if chrome_processes else "No Chrome processes found"
        try:
            with open(CHROME_LOG_FILE_PATH) as f:
                chrome_headless_content = f.read().strip()
        except (FileNotFoundError, PermissionError, OSError):
            chrome_headless_content = f"Could not read {CHROME_LOG_FILE_PATH}"

        try:
            df_output = subprocess.check_output(["df", "-h"], stderr=subprocess.STDOUT, text=True).strip()
        except subprocess.CalledProcessError:
            df_output = "Could not get disk usage information"

        try:
            free_output = "\n".join(subprocess.check_output(["free", "-h"], stderr=subprocess.STDOUT, text=True).splitlines())
        except subprocess.CalledProcessError:
            free_output = "Could not get memory information"

        try:
            chromedriver = subprocess.check_output(
                ["chromedriver", "--version"], stderr=subprocess.STDOUT, text=True
            ).splitlines()
        except subprocess.CalledProcessError:
            chromedriver = ["chromedriver not found or not executable"]

        try:
            chrome_version = subprocess.check_output(
                ["google-chrome", "--version"], stderr=subprocess.STDOUT, text=True
            ).splitlines()
        except subprocess.CalledProcessError:
            chrome_version = ["google-chrome not found or not executable"]

        demisto.debug(f"{chrome_instances_contents=}")
        demisto.debug(f"ps aux command result:\n{ps_aux_output}")
        demisto.debug(f"chrome_headless.log:\n{chrome_headless_content}")
        demisto.debug(f"df command result:\n{df_output}")
        demisto.debug(f"free command result:\n{free_output}")
        demisto.debug(f"chrome driver: {chromedriver}")
        demisto.debug(f"chrome version: {chrome_version}")

        message = "Could not use local Chrome for rasterize command"
        demisto.error(message)
        return_error(message)
        return None


def return_err_or_warn(msg):  # pragma: no cover
    return_error(msg) if WITH_ERRORS else return_warning(msg, exit=True)


# region CommandHandlers
def rasterize_image_command():
    args = demisto.args()
    entry_id = args.get("EntryID")
    width, height = get_width_height(demisto.args())
    full_screen = argToBoolean(demisto.args().get("full_screen", False))
    set_memory_pressure_tolerance_for_capture(full_screen)

    file_name = args.get("file_name", entry_id)

    file_path = demisto.getFilePath(entry_id).get("path")
    file_name = f"{file_name}.pdf"

    with open(file_path, "rb") as f:
        output = perform_rasterize(
            path=f"file://{os.path.realpath(f.name)}",
            width=width,
            height=height,
            rasterize_type=RasterizeType.PDF,
            full_screen=full_screen,
        )
        res = []
        for current_output in output:
            res.append(fileResult(filename=file_name, data=current_output[0], file_type=entryTypes["entryInfoFile"]))
        demisto.results(res)


def rasterize_email_command():  # pragma: no cover
    html_body = demisto.args().get("htmlBody")
    width, height = get_width_height(demisto.args())
    full_screen = argToBoolean(demisto.args().get("full_screen", False))
    set_memory_pressure_tolerance_for_capture(full_screen)

    offline = demisto.args().get("offline", "false") == "true"

    rasterize_type_arg = demisto.args().get("type", "png").lower()
    file_name = demisto.args().get("file_name", uuid.uuid4())
    file_name = f"{file_name}.{rasterize_type_arg}"
    rasterize_type = RasterizeType(rasterize_type_arg)

    navigation_timeout = int(demisto.args().get("max_page_load_time", DEFAULT_PAGE_LOAD_TIME))

    try:
        with tempfile.NamedTemporaryFile(mode="w", suffix=".html", encoding="utf-8-sig") as tf:
            demisto.debug(f"rasterize-email, {html_body=}")
            tf.write(f'<html style="background:white";>{html_body}</html>')
            tf.flush()
            real_path = os.path.realpath(tf.name)
            path = f"file://{real_path}"
            file_stat = Path.stat(Path(real_path))
            demisto.debug(f"rasterize-email, rasterizing {path=}, {file_stat=}")
            rasterize_output = perform_rasterize(
                path=path,
                rasterize_type=rasterize_type,
                width=width,
                height=height,
                offline_mode=offline,
                navigation_timeout=navigation_timeout,
                full_screen=full_screen,
            )

            res = fileResult(filename=file_name, data=rasterize_output[0][0])
    except Exception as err:
        demisto.error(str(err))
        return_error(f"Failed to rasterize email: {err}")

    if rasterize_type == RasterizeType.PNG or str(rasterize_type).lower() == RasterizeType.PNG.value:
        res["Type"] = entryTypes["image"]

    demisto.results(res)


def convert_pdf_to_jpeg(path: str, max_pages: str, password: str):
    """
    Converts a PDF file into a jpeg image
    :param path: file's path
    :param max_pages: max pages to render,
    :param password: PDF password
    :return: A list of stream of combined images
    """
    demisto.debug(f"Loading file at Path: {path}")
    input_pdf = PdfReader(open(path, "rb"), strict=False, password=password)
    pages = len(input_pdf.pages) if max_pages == "*" else min(int(max_pages), len(input_pdf.pages))

    with tempfile.TemporaryDirectory() as output_folder:
        convert_from_path(
            pdf_path=path,
            fmt="jpeg",
            first_page=1,
            last_page=pages,
            output_folder=output_folder,
            userpw=password,
            output_file="converted_pdf_",
        )

        images = []
        for page in sorted(os.listdir(output_folder)):
            if os.path.isfile(os.path.join(output_folder, page)) and "converted_pdf_" in page:
                image = Image.open(os.path.join(output_folder, page))
                output = BytesIO()
                image.save(output, "JPEG")  # type: ignore
                images.append(output.getvalue())

        return images


def rasterize_pdf_command():  # pragma: no cover
    entry_id = demisto.args().get("EntryID")
    password = demisto.args().get("pdfPassword")
    max_pages = demisto.args().get("maxPages", PAGES_LIMITATION)
    file_name = demisto.args().get("file_name", "image")

    file_path = demisto.getFilePath(entry_id).get("path")

    file_name = f"{file_name}.jpeg"

    with open(file_path, "rb") as f:
        images = convert_pdf_to_jpeg(path=os.path.realpath(f.name), max_pages=max_pages, password=password)
        results = []

        for image in images:
            res = fileResult(filename=file_name, data=image)
            res["Type"] = entryTypes["image"]
            results.append(res)

        demisto.results(results)


def rasterize_html_command():
    args = demisto.args()
    entry_id = args.get("EntryID")
    width, height = get_width_height(demisto.args())
    full_screen = argToBoolean(demisto.args().get("full_screen", False))
    set_memory_pressure_tolerance_for_capture(full_screen)

    rasterize_type = args.get("type", "png").lower()
    file_name = args.get("file_name", "email")
    wait_time = int(args.get("wait_time", 0))

    file_name = f"{file_name}.{rasterize_type}"
    file_path = demisto.getFilePath(entry_id).get("path")
    os.rename(f"./{file_path}", "file.html")

    output = perform_rasterize(
        path=f"file://{os.path.realpath('file.html')}",
        width=width,
        height=height,
        rasterize_type=rasterize_type,
        wait_time=wait_time,
        full_screen=full_screen,
    )

    res = fileResult(filename=file_name, data=output[0][0])
    if rasterize_type == "png":
        res["Type"] = entryTypes["image"]
    return_results(res)


def module_test():  # pragma: no cover
    # Setting up a mock email file
    with tempfile.NamedTemporaryFile("w+") as test_file:
        test_file.write(
            '<html><head><meta http-equiv="Content-Type" content="text/html;charset=utf-8">'
            "</head><body><br>---------- TEST FILE ----------<br></body></html>"
        )
        test_file.flush()
        file_path = f"file://{os.path.realpath(test_file.name)}"

        # Rasterize the file
        perform_rasterize(path=file_path, wait_time=0)

    demisto.results("ok")


def get_list_item(list_of_items: list, index: int, default_value: str):
    if index >= len(list_of_items):
        return default_value

    return list_of_items[index]


def process_urls(urls):
    if isinstance(urls, str) and urls.startswith("["):
        urls = argToList(urls)
    urls = [urls] if isinstance(urls, str) else urls
    return urls


def add_filename_suffix(file_names: list, file_extension: str):
    ret_value = []
    for current_filename in file_names:
        ret_value.append(f"{current_filename}.{file_extension}")
    return ret_value


def rasterize_command():  # pragma: no cover
    urls = demisto.getArg("url")
    urls = process_urls(urls)
    width, height = get_width_height(demisto.args())
    full_screen = argToBoolean(demisto.args().get("full_screen", False))
    set_memory_pressure_tolerance_for_capture(full_screen)
    rasterize_type = RasterizeType(demisto.args().get("type", "png").lower())
    wait_time = int(demisto.args().get("wait_time", 0))
    navigation_timeout = int(demisto.args().get("max_page_load_time", DEFAULT_PAGE_LOAD_TIME))
    file_name = demisto.args().get("file_name", "url")
    include_url = argToBoolean(demisto.args().get("include_url", False))

    file_extension = "png"
    if rasterize_type == RasterizeType.PDF or str(rasterize_type).lower() == RasterizeType.PDF.value:
        file_extension = "pdf"

    demisto.debug(f"file_name type is: {type(file_name)}")
    file_names = argToList(file_name)
    file_names = add_filename_suffix(file_names, file_extension)

    rasterize_output = perform_rasterize(
        path=urls,
        rasterize_type=rasterize_type,
        wait_time=wait_time,
        navigation_timeout=navigation_timeout,
        include_url=include_url,
        full_screen=full_screen,
        width=width,
        height=height,
    )
    demisto.debug(f"rasterize_command response, {rasterize_type=}, {len(rasterize_output)=}")

    for index, (current_rasterize_output, current_url) in enumerate(zip(rasterize_output, urls)):
        if isinstance(current_rasterize_output, str):
            return_results(
                CommandResults(
                    readable_output=f"Error for URL {current_url!r}:\n{current_rasterize_output}",
                    raw_response=current_rasterize_output,
                    entry_type=EntryType.ERROR,
                )
            )
        elif rasterize_type == RasterizeType.JSON or str(rasterize_type).lower() == RasterizeType.JSON.value:
            output = {
                "image_b64": base64.b64encode(current_rasterize_output[0]).decode("utf8"),
                "html": current_rasterize_output[1],
                "current_url": current_url,
            }
            return_results(CommandResults(raw_response=output, readable_output=f"Successfully rasterize url: {current_url}"))
        else:
            res = []
            current_res = fileResult(
                filename=get_list_item(file_names, index, f"url.{file_extension}"),
                data=current_rasterize_output[0],
                file_type=entryTypes["entryInfoFile"],
            )

            if rasterize_type == RasterizeType.PNG or str(rasterize_type).lower() == RasterizeType.PNG.value:
                current_res["Type"] = entryTypes["image"]

            res.append(current_res)

            demisto.results(res)


def rasterize_extract_command():  # pragma: no cover
    """Extracts structured text content from web pages.

    This command uses Chrome browser to navigate to URLs and extract their text content
    in a structured markdown format. It processes multiple URLs concurrently and returns
    the extracted content along with metadata.

    Args:
        None (uses demisto.args() internally)

    Command Arguments:
        url: Single URL or list of URLs to extract content from
        wait_time: Time in seconds to wait before extracting content (default: DEFAULT_WAIT_TIME)
        max_page_load_time: Maximum time to wait for page load (default: DEFAULT_PAGE_LOAD_TIME)

    Returns:
        None (uses return_results() to output CommandResults)

    Outputs:
        For each URL, returns a CommandResults with:
        - URL: The final URL after any redirects
        - Content: The extracted markdown-formatted text content

    Raises:
        Errors are handled internally and returned as CommandResults with ERROR entry type
    """
    args = demisto.args()
    urls = argToList(args.get("url"))

    wait_time = int(args.get("wait_time", DEFAULT_WAIT_TIME))
    navigation_timeout = int(args.get("max_page_load_time", DEFAULT_PAGE_LOAD_TIME))

    demisto.debug(f"Starting rasterize-extract for URLs: {urls}")

    rasterize_output = perform_rasterize(
        path=urls, rasterize_type=RasterizeType.TEXT, wait_time=wait_time, navigation_timeout=navigation_timeout
    )

    results = []

    for index, url in enumerate(urls):
        result_tuple = rasterize_output[index]

        if isinstance(result_tuple, str):
            error_msg = result_tuple
            results.append(
                CommandResults(
                    readable_output=f"Error rasterizing {url!r}:\n{error_msg}",
                    entry_type=EntryType.ERROR,
                )
            )
            continue

        extracted_content, final_url = result_tuple

        if isinstance(extracted_content, str) and extracted_content.startswith("Extraction Error:"):
            results.append(
                CommandResults(
                    readable_output=(
                        f"Error extracting content from {url!r}:\n{extracted_content}\n"
                        "The page did not render within the page-load timeout. It may be slow to load, "
                        "or protected by anti-bot/bot-detection (for example Cloudflare, or the Chrome Web Store). "
                        "If this URL is expected to be slow, retry once with a higher 'max_page_load_time'; "
                        "otherwise the page is likely blocked and a different source/action should be used for it."
                    ),
                    entry_type=EntryType.ERROR,
                )
            )
            continue

        outputs = {"URL": final_url, "Content": extracted_content}

        results.append(
            CommandResults(
                outputs=outputs,
                readable_output=f"### Content Extracted from: {final_url}\n---\n{extracted_content}",
                outputs_prefix="Rasterize",
                outputs_key_field="URL",
            )
        )

    return_results(results)


# endregion


def get_width_height(args: dict[str, str]) -> tuple[int, int]:
    """
    Get common args.
    :param args: dict to get args from
    :return: width, height, rasterize mode
    """
    width = int(args.get("width", f"{DEFAULT_WIDTH} px").rstrip("px"))
    height = int(args.get("height", f"{DEFAULT_HEIGHT} px").rstrip("px"))

    # Check that the width and height meet the safeguard limit
    width = min(width, MAX_FULLSCREEN_WIDTH)
    height = min(height, MAX_FULLSCREEN_HEIGHT)

    demisto.debug(f"Processed dimensions: width={width}, height={height}")
    return width, height


def main():  # pragma: no cover
    command = demisto.command()

    demisto.debug(f"Command being called is {command}")
    demisto.debug(f"Using performance params: {MAX_CHROMES_COUNT=}, {MAX_CHROME_TABS_COUNT=}, {MAX_RASTERIZATIONS_COUNT=}")

    threading.excepthook = excepthook_recv_loop

    try:
        if demisto.command() == "test-module":
            module_test()

        elif demisto.command() == "rasterize-image":
            rasterize_image_command()

        elif demisto.command() == "rasterize-email":
            rasterize_email_command()

        elif demisto.command() == "rasterize-pdf":
            rasterize_pdf_command()

        elif demisto.command() == "rasterize-html":
            rasterize_html_command()

        elif demisto.command() == "rasterize":
            rasterize_command()

        elif demisto.command() == "rasterize-extract":
            rasterize_extract_command()

        else:
            raise NotImplementedError(f"command {command} is not supported")

    except Exception as ex:
        return_err_or_warn(f"Failed to execute {command} command.\nUnexpected exception: {ex}\nTrace:{traceback.format_exc()}")
    finally:
        kill_zombie_processes()


if __name__ in ("__builtin__", "builtins", "__main__"):
    main()