Skip to content

feat: 添加了支持选择元素截图和等待资源加载完成之后再截图的功能#14

Open
kyuphoenix wants to merge 2 commits into
AstrBotDevs:mainfrom
kyuphoenix:feat-selector-and-resource-wait
Open

feat: 添加了支持选择元素截图和等待资源加载完成之后再截图的功能#14
kyuphoenix wants to merge 2 commits into
AstrBotDevs:mainfrom
kyuphoenix:feat-selector-and-resource-wait

Conversation

@kyuphoenix

@kyuphoenix kyuphoenix commented Jun 1, 2026

Copy link
Copy Markdown

本次更新为 T2I 渲染服务新增了两组可选配置,旨在提升截图的灵活性与渲染稳定性。

  1. 新增功能详情
  • Selector Screenshot:支持通过 CSS 选择器按指定 HTML 元素截图,有效避免整页截图带来的多余边缘问题。
    • 支持配置 selector(目标元素)、fallback_selector(备用元素)及 selector_timeout(查找超时)。
    • 若未匹配到元素,将记录 warning 并回退至默认截图行为。
  • Resource Wait:支持在截图前等待远程资源加载完成,解决图片或字体渲染不完整的问题。
    • 启用后将等待 networkidle、所有 元素加载/解码完成以及 document.fonts.ready 状态。
    • 支持自定义 resource_timeout,并提供超时自动回退机制,确保渲染请求不会因个别失效资源而永久阻塞。
  1. 使用说明相关配置项均需放置在请求体的 options 字段中。
  • Selector 使用示例:
     await self.html_render(
         tmpl, data, return_url=False,
         options={
             "selector": "#container",
             "fallback_selector": "body",
             "selector_timeout": 1000,
         },
     )
  • Resource Wait 使用示例[cite: 4]:
    await self.html_render(
        tmpl, data, return_url=False,
        options={
            "wait_for_resources": True,
            "resource_timeout": 10000,
        },
    )

修改后如果不传入对应配置项则保持功能与原先相同,避免因更新服务导致插件失效

Summary by Sourcery

Add configurable element-based screenshot targeting and optional resource-loading waits to the T2I HTML rendering flow while preserving existing defaults when unset.

New Features:

  • Allow screenshots to target a specific DOM element via CSS selectors with optional fallback and timeout settings instead of always capturing the full page.
  • Enable an optional pre-screenshot wait for network idleness, image loading/decoding, and font readiness with configurable timeouts.
  • Support configuring navigation wait state for page loading before screenshot capture.

Enhancements:

  • Extend screenshot option filtering to ignore new selector and resource-wait fields when invoking Playwright APIs to keep calls compatible.

Documentation:

  • Document the new selector-based screenshot and resource-wait options, including defaults, behavior, and example usage in JSON requests and AstrBot plugin calls.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces element-specific screenshots using CSS selectors (with fallback options and timeouts) and resource waiting (waiting for network idle, image decoding, and font loading before taking a screenshot). The review feedback suggests several robustness improvements: wrapping query_selector calls in try-except blocks to handle invalid CSS selectors gracefully, avoiding object destructuring in the JavaScript evaluation string to prevent Playwright parsing issues, and verifying that the result of page.evaluate is a dictionary before calling .get() to avoid potential AttributeError exceptions.

Comment thread src/render.py
Comment on lines +298 to +336
async def _resolve_screenshot_element(self, page, screenshot_options: ScreenshotOptions):
"""Resolve an element target for screenshot when selector options are provided."""

if not screenshot_options.selector:
return None

element = None
if screenshot_options.selector_timeout is not None:
try:
element = await page.wait_for_selector(
screenshot_options.selector,
timeout=screenshot_options.selector_timeout,
)
except Exception as e:
logger.debug(
f"html2pic: wait for selector '{screenshot_options.selector}' failed: {e}"
)
else:
element = await page.query_selector(screenshot_options.selector)

if element is not None:
logger.info(
f"html2pic: screenshot element matched selector '{screenshot_options.selector}'"
)
return element

if screenshot_options.fallback_selector:
fallback = await page.query_selector(screenshot_options.fallback_selector)
if fallback is not None:
logger.info(
f"html2pic: selector '{screenshot_options.selector}' not found, "
f"screenshot fallback selector '{screenshot_options.fallback_selector}'"
)
return fallback

logger.warning(
f"html2pic: selector '{screenshot_options.selector}' not found, fallback to page screenshot"
)
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

如果用户传入了非法的 CSS 选择器(例如语法错误的选择器),page.query_selector 会抛出异常。在当前实现中,如果 selector_timeoutNone,或者在解析 fallback_selector 时,这些 query_selector 调用没有被 try...except 保护,会导致整个渲染请求崩溃并返回 500 错误。建议对所有的 query_selector 调用进行异常捕获,确保在选择器非法时能够优雅地记录警告并回退到整页截图。

    async def _resolve_screenshot_element(self, page, screenshot_options: ScreenshotOptions):
        """Resolve an element target for screenshot when selector options are provided."""

        if not screenshot_options.selector:
            return None

        element = None
        if screenshot_options.selector_timeout is not None:
            try:
                element = await page.wait_for_selector(
                    screenshot_options.selector,
                    timeout=screenshot_options.selector_timeout,
                )
            except Exception as e:
                logger.debug(
                    f"html2pic: wait for selector '{screenshot_options.selector}' failed: {e}"
                )
        else:
            try:
                element = await page.query_selector(screenshot_options.selector)
            except Exception as e:
                logger.warning(
                    f"html2pic: query selector '{screenshot_options.selector}' failed: {e}"
                )

        if element is not None:
            logger.info(
                f"html2pic: screenshot element matched selector '{screenshot_options.selector}'"
            )
            return element

        if screenshot_options.fallback_selector:
            try:
                fallback = await page.query_selector(screenshot_options.fallback_selector)
                if fallback is not None:
                    logger.info(
                        f"html2pic: selector '{screenshot_options.selector}' not found, "
                        f"screenshot fallback selector '{screenshot_options.fallback_selector}'"
                    )
                    return fallback
            except Exception as e:
                logger.warning(
                    f"html2pic: query fallback selector '{screenshot_options.fallback_selector}' failed: {e}"
                )

        logger.warning(
            f"html2pic: selector '{screenshot_options.selector}' not found, fallback to page screenshot"
        )
        return None

Comment thread src/render.py Outdated
Comment on lines +365 to +408
result = await page.evaluate(
"""
async ({ timeout }) => {
const images = Array.from(document.images || []);
const waitImage = (img) => {
if (img.complete) {
return Promise.resolve();
}
if (typeof img.decode === "function") {
return img.decode().catch(() => undefined);
}
return new Promise((resolve) => {
img.addEventListener("load", resolve, { once: true });
img.addEventListener("error", resolve, { once: true });
});
};
const waitFonts = () => {
if (document.fonts && document.fonts.ready) {
return document.fonts.ready.catch(() => undefined);
}
return Promise.resolve();
};
let timedOut = false;
const allResources = Promise.all([
...images.map(waitImage),
waitFonts(),
]);
const timeoutPromise = new Promise((resolve) => {
setTimeout(() => {
timedOut = true;
resolve();
}, timeout);
});
await Promise.race([allResources, timeoutPromise]);
return {
imageCount: images.length,
completeCount: images.filter((img) => img.complete).length,
brokenCount: images.filter((img) => img.complete && img.naturalWidth === 0).length,
timedOut,
};
}
""",
{"timeout": resource_timeout},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在 Playwright Python 中,使用字符串传递 JS 函数时,如果函数参数中包含对象解构(如 async ({ timeout }) =>),可能会导致 Playwright 的函数检测启发式算法失效,从而无法正确执行该函数或引发解析错误。建议改用普通的参数列表(如 async (timeout) =>),并将 resource_timeout 直接作为参数传入。

            result = await page.evaluate(
                """
                async (timeout) => {
                  const images = Array.from(document.images || []);
                  const waitImage = (img) => {
                    if (img.complete) {
                      return Promise.resolve();
                    }
                    if (typeof img.decode === "function") {
                      return img.decode().catch(() => undefined);
                    }
                    return new Promise((resolve) => {
                      img.addEventListener("load", resolve, { once: true });
                      img.addEventListener("error", resolve, { once: true });
                    });
                  };
                  const waitFonts = () => {
                    if (document.fonts && document.fonts.ready) {
                      return document.fonts.ready.catch(() => undefined);
                    }
                    return Promise.resolve();
                  };
                  let timedOut = false;
                  const allResources = Promise.all([
                    ...images.map(waitImage),
                    waitFonts(),
                  ]);
                  const timeoutPromise = new Promise((resolve) => {
                    setTimeout(() => {
                      timedOut = true;
                      resolve();
                    }, timeout);
                  });
                  await Promise.race([allResources, timeoutPromise]);
                  return {
                    imageCount: images.length,
                    completeCount: images.filter((img) => img.complete).length,
                    brokenCount: images.filter((img) => img.complete && img.naturalWidth === 0).length,
                    timedOut,
                  };
                }
                """,
                resource_timeout,
            )

Comment thread src/render.py
Comment on lines +409 to +426
if result.get("timedOut"):
logger.warning(
"html2pic: wait for resources timed out after "
f"{timeout}ms, images={result.get('completeCount')}/"
f"{result.get('imageCount')}"
)
elif result.get("brokenCount"):
logger.warning(
"html2pic: resources loaded with broken images, "
f"broken={result.get('brokenCount')}/"
f"{result.get('imageCount')}"
)
else:
logger.info(
"html2pic: resources ready, "
f"images={result.get('completeCount')}/"
f"{result.get('imageCount')}"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

page.evaluate 的返回值 result 在某些异常情况下(例如 JS 执行未返回预期对象或返回了 null/undefined)可能为 None。如果直接调用 result.get("timedOut") 会抛出 AttributeError。虽然该代码块处于 try...except 中,但这会导致打印出不必要的错误日志,掩盖了真实的执行状态。建议在调用 .get() 之前先检查 result 是否为字典类型。

Suggested change
if result.get("timedOut"):
logger.warning(
"html2pic: wait for resources timed out after "
f"{timeout}ms, images={result.get('completeCount')}/"
f"{result.get('imageCount')}"
)
elif result.get("brokenCount"):
logger.warning(
"html2pic: resources loaded with broken images, "
f"broken={result.get('brokenCount')}/"
f"{result.get('imageCount')}"
)
else:
logger.info(
"html2pic: resources ready, "
f"images={result.get('completeCount')}/"
f"{result.get('imageCount')}"
)
if not isinstance(result, dict):
logger.warning("html2pic: wait for resources returned invalid result")
elif result.get("timedOut"):
logger.warning(
"html2pic: wait for resources timed out after "
f"{timeout}ms, images={result.get('completeCount')}/"
f"{result.get('imageCount')}"
)
elif result.get("brokenCount"):
logger.warning(
"html2pic: resources loaded with broken images, "
f"broken={result.get('brokenCount')}/"
f"{result.get('imageCount')}"
)
else:
logger.info(
"html2pic: resources ready, "
f"images={result.get('completeCount')}/"
f"{result.get('imageCount')}"
)

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In _resolve_screenshot_element, the wait_for_selector call catches a broad Exception which can hide non-timeout issues; consider catching TimeoutError (or Playwright’s specific timeout exception) instead so real selector errors still surface.
  • selector_timeout and resource_timeout are typed as float milliseconds but are ultimately passed to Playwright as integer timeouts; it may be clearer and less error‑prone to standardize these as integer millisecond values or document/convert them explicitly at the boundary.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `_resolve_screenshot_element`, the `wait_for_selector` call catches a broad `Exception` which can hide non-timeout issues; consider catching `TimeoutError` (or Playwright’s specific timeout exception) instead so real selector errors still surface.
- `selector_timeout` and `resource_timeout` are typed as `float` milliseconds but are ultimately passed to Playwright as integer timeouts; it may be clearer and less error‑prone to standardize these as integer millisecond values or document/convert them explicitly at the boundary.

## Individual Comments

### Comment 1
<location path="src/render.py" line_range="305-311" />
<code_context>
+            return None
+
+        element = None
+        if screenshot_options.selector_timeout is not None:
+            try:
+                element = await page.wait_for_selector(
+                    screenshot_options.selector,
+                    timeout=screenshot_options.selector_timeout,
+                )
+            except Exception as e:
+                logger.debug(
+                    f"html2pic: wait for selector '{screenshot_options.selector}' failed: {e}"
</code_context>
<issue_to_address>
**issue (bug_risk):** Narrow the exception handling around `wait_for_selector` to avoid masking non-timeout errors.

Catching `Exception` here turns all failures into a “selector not found” case, which can hide real problems (e.g., invalid selector, detached frame, other runtime errors) and only trigger the fallback full-page screenshot. Please catch only the timeout-related exception type (e.g., `TimeoutError` or the appropriate Playwright timeout error) and let other exceptions propagate so genuine bugs aren’t silently swallowed.
</issue_to_address>

### Comment 2
<location path="src/render.py" line_range="365-374" />
<code_context>
+            result = await page.evaluate(
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Align Playwright’s evaluation timeout with the custom `resource_timeout` to avoid unexpected early termination.

`resource_timeout` is only passed into the page context; Playwright’s own `page.evaluate` timeout remains at its default and may terminate the call before the in-page timeout triggers. Consider also passing `timeout=resource_timeout` (or `timeout=min(resource_timeout, screenshot_options.timeout or resource_timeout)`) to `page.evaluate` so both layers enforce the same max duration.

Suggested implementation:

```python
        resource_timeout = remaining_timeout()
        if resource_timeout <= 0:
            logger.warning(f"html2pic: wait for resources timed out after {timeout}ms")
            return

        # Align Playwright's evaluation timeout with the in-page timeout
        # If screenshot_options.timeout is set, clamp to the smaller value
        eval_timeout = resource_timeout
        if "screenshot_options" in locals() and getattr(screenshot_options, "timeout", None) is not None:
            eval_timeout = min(resource_timeout, screenshot_options.timeout)

        try:
            result = await page.evaluate(
                """
                async ({ timeout }) => {
                  const images = Array.from(document.images || []);
                  const waitImage = (img) => {
                    if (img.complete) {
                      return Promise.resolve();
                    }
                    if (typeof img.decode === "function") {
                      return img.decode().catch(() => undefined);
                    }

```

```python
            result = await page.evaluate(
                """
                async ({ timeout }) => {
                  const images = Array.from(document.images || []);
                  const waitImage = (img) => {
                    if (img.complete) {
                      return Promise.resolve();
                    }
                    if (typeof img.decode === "function") {
                      return img.decode().catch(() => undefined);
                    }

```

The snippet you provided stops inside the JavaScript function body, so I cannot see the full `page.evaluate` call. To fully implement the timeout alignment, you should also:

1. Ensure that when you pass the `timeout` into the page context, you use `eval_timeout` instead of `resource_timeout`, for example:
   - Change the argument object from:
     - `{"timeout": resource_timeout}`
     - to:
     - `{"timeout": eval_timeout}`

2. Add the Playwright timeout keyword argument to the `page.evaluate` call, so the final call shape is:
   - `result = await page.evaluate(<script>, {"timeout": eval_timeout}, timeout=eval_timeout)`
   Adjust for your actual argument formatting/indentation.

3. If `screenshot_options` is not available in this scope or its API is different (e.g., `screenshot_options.get("timeout")` instead of attribute access), update the `eval_timeout` calculation accordingly, preserving the intent:
   - clamp Playwright’s timeout to `min(resource_timeout, configured_screenshot_timeout)` and fall back to `resource_timeout` when no explicit timeout is configured.

These small follow-up adjustments will ensure both the in-page timeout and Playwright’s own timeout enforce the same maximum duration.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/render.py Outdated
Comment thread src/render.py Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant