Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,11 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

git add VERSION **/pom.xml
# Stage the root pom.xml explicitly and the rest via a quoted glob so git's
# own pathspec (wildmatch) expands '**' to any depth. Unquoted, bash without
# globstar treats '**' as '*' and stages only one-directory-deep poms,
# leaving the root parent POM and nested module POMs stale.
git add VERSION pom.xml '**/pom.xml'

if git diff --cached --quiet; then
echo "::notice::No version changes to commit — version may already be bumped."
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
4.11.12-SNAPSHOT
4.11.14-SNAPSHOT
7 changes: 7 additions & 0 deletions docs/ql-functions-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,13 @@ FROM DOM_LOAD_AND_SELECT('https://example.com', 'a[href]');

Automatically appends `img` / `a` to the CSS query if not present.

PowerCSS selectors are fully supported in the query argument, including `:expr(...)` with spaces, e.g. select images wider than 200px:

```sql
SELECT DOM_ALL_IMGS(DOM, 'img:expr(width > 200)') AS wide_images FROM DOM_LOAD('...');
SELECT DOM_FIRST_IMG(DOM, 'img:expr(width > 200 && height > 200)') AS hero_image FROM DOM_LOAD('...');
```

```sql
-- DOM_ALL_IMGS: All image absolute src URLs
SELECT DOM_ALL_IMGS(DOM) AS images FROM DOM_LOAD('...');
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

<groupId>ai.platon.pulsar</groupId>
<artifactId>pulsar</artifactId>
<version>4.11.12-SNAPSHOT</version>
<version>4.11.14-SNAPSHOT</version>
<packaging>pom</packaging>

<name>Browser4 Base</name>
Expand Down
2 changes: 1 addition & 1 deletion pulsar-bom/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<groupId>ai.platon.pulsar</groupId>
<artifactId>pulsar-bom</artifactId>
<name>Pulsar BOM</name>
<version>4.11.12-SNAPSHOT</version>
<version>4.11.14-SNAPSHOT</version>

<packaging>pom</packaging>

Expand Down
2 changes: 1 addition & 1 deletion pulsar-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<parent>
<groupId>ai.platon.pulsar</groupId>
<artifactId>pulsar</artifactId>
<version>4.11.12-SNAPSHOT</version>
<version>4.11.14-SNAPSHOT</version>
</parent>

<artifactId>pulsar-core</artifactId>
Expand Down
2 changes: 1 addition & 1 deletion pulsar-core/pulsar-browser/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>ai.platon.pulsar</groupId>
<artifactId>pulsar</artifactId>
<version>4.11.12-SNAPSHOT</version>
<version>4.11.14-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package ai.platon.pulsar.api.snapshot

/**
* Shared interactive-only filtering contract for ARIA snapshot rendering.
*
* Both [AriaSnapshotRenderer] and [NanoAriaSnapshotRenderer] must agree on what
* "interactive" means for [ai.platon.pulsar.chrome.dom.model.AriaSnapshotOptions.interactive],
* otherwise the same DOM produces different interactive snapshots depending on which
* renderer runs (viewport-scoped snapshots use the nano renderer, whole-page snapshots
* use the full renderer).
*
* A node qualifies when it is an interactive widget by [INTERACTIVE_ROLES] or carries an
* interactability signal (`isInteractable` / `interactive` flag, computed from clickability,
* cursor:pointer style, native control tags and AX roles during snapshot collection).
*
* Addressability must NOT qualify a node: backendNodeId-based refs are assigned to
* virtually every DOM node, so a ref-based early return turned the interactive filter
* into a no-op (Browser4base issue #3). Renderers skip non-qualifying nodes and promote
* their children instead.
*/
object AriaSnapshotFiltering {
/** Roles of interactive widgets kept in interactive-only snapshots. */
val INTERACTIVE_ROLES = setOf(
"button", "link", "textbox", "checkbox", "combobox", "searchbox",
"spinbutton", "slider", "radio", "option", "listbox", "menuitem", "tab",
"switch", "treeitem", "menuitemcheckbox", "menuitemradio"
)

/**
* Decide whether a node qualifies for interactive-only snapshots.
*
* @param role The node's effective ARIA role (explicit or implicit).
* @param interactable The snapshot-level interactability flag: [ai.platon.pulsar.api.model.MergedDOMTreeNode.isInteractable]
* in the full renderer, [ai.platon.pulsar.api.model.NanoDOMTreeNode.interactive] in the nano renderer.
* Both carry the same underlying value computed during snapshot collection.
*/
fun isInteractiveNode(role: String, interactable: Boolean?): Boolean {
return interactable == true || role in INTERACTIVE_ROLES
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,18 @@ object AriaSnapshotRenderer {
val props = renderProps(node, role, accessibleName, options)
val ref = original.backendNodeId.takeIf { it != null && it > 0 }?.let { "e$it" }

// --interactive: skip non-interactive nodes, promote their children
if (options.interactive && !isInteractiveNode(node, role, props, ref)) {
// --interactive: skip non-interactive nodes, promote their children.
// Addressability (ref/backendNodeId) is deliberately not a qualifying signal,
// see AriaSnapshotFiltering for the shared contract.
if (options.interactive && !AriaSnapshotFiltering.isInteractiveNode(role, node.originalNode.isInteractable)) {
return children
}

// --compact: skip generic/group/paragraph nodes that carry no semantic info
if (options.compact && shouldCompactNode(role, accessibleName, props, children)) {
// --compact: skip generic/group/paragraph nodes that carry no semantic info.
// When --interactive is active the filter already removed structural noise, so
// compacting a kept (interactive) node would drop genuine click targets
// (e.g. a nameless cursor:pointer div).
if (!options.interactive && options.compact && shouldCompactNode(role, accessibleName, props, children)) {
return children
}

Expand Down Expand Up @@ -141,18 +146,6 @@ object AriaSnapshotRenderer {
return props
}

private fun isInteractiveNode(
node: OptimizedDOMTreeNode,
role: String,
props: LinkedHashMap<String, String>,
ref: String?
): Boolean {
if (ref != null) return true
if (node.interactiveIndex != null) return true
if (node.originalNode.isInteractable == true) return true
return role in INTERACTIVE_ROLES
}

private fun shouldCompactNode(
role: String,
accessibleName: String?,
Expand Down Expand Up @@ -312,10 +305,4 @@ object AriaSnapshotRenderer {
val nodeName = node.nodeName.trim().lowercase(Locale.ROOT)
return nodeName == "#text" || nodeName == "text"
}

private val INTERACTIVE_ROLES = setOf(
"button", "link", "textbox", "checkbox", "combobox", "searchbox",
"spinbutton", "slider", "radio", "option", "listbox", "menuitem", "tab",
"switch", "treeitem", "menuitemcheckbox", "menuitemradio"
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,17 @@ object NanoAriaSnapshotRenderer {
val role = role(node, attrs) ?: return children
val props = renderProps(attrs, role, accessibleName, options)

// --interactive: skip non-interactive nodes, promote their children
if (options.interactive && !isInteractiveNode(node, role, props)) {
// --interactive: skip non-interactive nodes, promote their children.
// Addressability (locator-based ref) is deliberately not a qualifying signal,
// see AriaSnapshotFiltering for the shared contract.
if (options.interactive && !AriaSnapshotFiltering.isInteractiveNode(role, node.interactive)) {
return children
}

// --compact: skip generic/group/paragraph nodes that carry no semantic info
if (options.compact && shouldCompact(node, role, accessibleName, props, children)) {
// --compact: skip generic/group/paragraph nodes that carry no semantic info.
// When --interactive is active the filter already removed structural noise, so
// compacting a kept (interactive) node would drop genuine click targets.
if (!options.interactive && options.compact && shouldCompact(node, role, accessibleName, props, children)) {
return children
}

Expand Down Expand Up @@ -143,16 +147,6 @@ object NanoAriaSnapshotRenderer {
return props
}

private fun isInteractiveNode(
node: NanoDOMTreeNode,
role: String,
props: LinkedHashMap<String, String>
): Boolean {
if (node.ref > 0) return true
if (node.interactive == true) return true
return role in INTERACTIVE_ROLES
}

private fun shouldCompact(
node: NanoDOMTreeNode,
role: String,
Expand Down Expand Up @@ -293,10 +287,4 @@ object NanoAriaSnapshotRenderer {
private fun stringAttributes(node: NanoDOMTreeNode): Map<String, String> {
return node.attributes.orEmpty().mapValues { (_, value) -> value.toString() }
}

private val INTERACTIVE_ROLES = setOf(
"button", "link", "textbox", "checkbox", "combobox", "searchbox",
"spinbutton", "slider", "radio", "option", "listbox", "menuitem", "tab",
"switch", "treeitem", "menuitemcheckbox", "menuitemradio"
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@ package ai.platon.pulsar.chrome.dom.model
* they do not affect CDP-level data collection (see [ai.platon.pulsar.api.model.SnapshotOptions] for that).
*/
data class AriaSnapshotOptions(
/** Only include interactive elements (buttons, links, inputs, etc.). */
/**
* Only include interactive elements (buttons, links, inputs, etc.).
*
* A node qualifies when its role is an interactive widget or it carries an
* interactability signal (clickability, cursor:pointer, native control or AX-role
* heuristics computed during snapshot collection). Addressability alone — a
* backendNodeId-based `ref` — does NOT qualify a node, since backend node ids are
* assigned to virtually every DOM node. Non-qualifying nodes are skipped and their
* interactive descendants are promoted instead. Both renderers
* (viewport/nano and whole-page/full) share the same predicate.
*/
val interactive: Boolean = false,
/** Always include href URLs for link elements (prevent URL-collapse). */
val urls: Boolean = false,
Expand Down
Loading