mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 11:42:23 +02:00
151 lines
8.3 KiB
Python
151 lines
8.3 KiB
Python
"""Container-scoped submit click for the receipt-gated send path. Exists because the ranked
|
||
interactives listing caps at 60 rows and a composer's own submit can fall off it (X's compose
|
||
modal: covered feed rows behind the overlay ate the cap, so no "Post" row ever reached the index
|
||
picker, measured live 0/2 deliveries). Scope = the dialog/form ancestor of the editable holding
|
||
the payload when there is one, else a bounded nearest-scope-first upward walk, so a page-level
|
||
opener with the same label can never be chosen. A wrong resolution still fails the send receipt
|
||
downstream, never a false delivery claim."""
|
||
|
||
import json
|
||
import re
|
||
from typing import Any, Dict, Optional
|
||
|
||
# BROAD submit vocabulary shared by the index picker (browser_agent) and the JS below, one source
|
||
# so the two tiers can never drift apart.
|
||
SEND_LABELS = frozenset({
|
||
"send", "send now", "send message", # LinkedIn / Gmail / DMs
|
||
"post", "post all", "tweet", "reply", # X / Threads compose + reply
|
||
"publish", "comment", "share", # articles / YouTube+FB comments / shares
|
||
})
|
||
|
||
# Gmail names its Send button 'Send (⌘Enter)': a shortcut suffix wrapped in bidi
|
||
# isolates that defeats exact matching. Strip control chars + any parenthesized tail before compare.
|
||
P_NAME_NOISE_RE = re.compile(r"[-]|\([^)]*\)")
|
||
|
||
|
||
def clean_button_name(name: str) -> str:
|
||
return P_NAME_NOISE_RE.sub("", name or "").strip().lower()
|
||
|
||
# Resolves the submit and returns its viewport center; the caller clicks it through the REAL
|
||
# input path (BrowserClickPoint). Synthetic el.click() is ignored by web-component sites
|
||
# (shreddit live), and a real click lands on whatever is topmost, so overlays can't be fooled.
|
||
P_CONTAINER_SUBMIT_JS = r"""(() => {
|
||
const PAYLOAD = %s;
|
||
const LABELS = new Set(%s);
|
||
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||
const vis = (el) => !!el && el.getClientRects().length > 0;
|
||
const enabled = (el) => !el.disabled && el.getAttribute('aria-disabled') !== 'true';
|
||
// Same cleaning as clean_button_name: Gmail's Send is 'Send (⌘Enter)' in bidi isolates.
|
||
const clean = (s) => norm((s || '').replace(/[-]|\([^)]*\)/g, ''));
|
||
const labelOf = (el) => clean(el.getAttribute('aria-label') || el.textContent || '');
|
||
const holds = (el) => ((el.value || el.textContent || '').indexOf(PAYLOAD) !== -1);
|
||
// Shadow piercing both ways: reddit's composer AND its submit live in shreddit shadow roots.
|
||
const deep = (root, sel, out, depth) => {
|
||
if (depth > 10 || out.length > 4000) return out;
|
||
let hits; try { hits = root.querySelectorAll(sel); } catch (e) { hits = []; }
|
||
for (const el of hits) out.push(el);
|
||
let all; try { all = root.querySelectorAll('*'); } catch (e) { return out; }
|
||
for (const el of all) { if (el.shadowRoot) deep(el.shadowRoot, sel, out, depth + 1); }
|
||
return out;
|
||
};
|
||
const up = (el) => el.parentElement || (el.getRootNode() && el.getRootNode().host) || null;
|
||
const ed = deep(document, '[contenteditable="true"],textarea,input', [], 0)
|
||
.find((e) => vis(e) && holds(e));
|
||
if (!ed) return { ok: false, why: 'no editable holding the payload' };
|
||
// Ranked, not exact-match. A closed label set resolves X's "Post" and little else: live, reddit
|
||
// labels its submit "Post to r/test" and it scored ZERO, so the send fell through to blind-tapping
|
||
// a coordinate. Exact match still outranks everything, so every site that resolves today resolves
|
||
// the identical button; the weaker tiers only ever answer where the strong one found nothing.
|
||
const VERB = /^(send|post|repl(?:y|ies)|comment|tweet|publish|share|submit)\b/;
|
||
const rank = (b) => {
|
||
const l = labelOf(b);
|
||
if (LABELS.has(l)) return 3; // exact: today's behaviour
|
||
if ((b.getAttribute('type') || '').toLowerCase() === 'submit') return 2; // the form's own submit
|
||
if (l.length <= 40 && VERB.test(l)) return 1; // "post to r/test", "reply all"
|
||
return 0;
|
||
};
|
||
// A DISABLED submit is not a missing one, and collapsing the two is what made this whole path
|
||
// lie. Live on reddit: the button is right there, labelled "post", and greyed out because the
|
||
// form still wants a title. We reported "no submit control", guessed a coordinate, and announced
|
||
// a delivery. Keeping the blocked candidate lets the caller say the true thing instead.
|
||
let blocked = null;
|
||
const submitIn = (root) => {
|
||
let best = null, bestRank = 0;
|
||
for (const b of deep(root, 'button,[role="button"],input[type="submit"]', [], 0)) {
|
||
if (!vis(b)) continue;
|
||
const r = rank(b);
|
||
if (!r) continue;
|
||
if (!enabled(b)) { if (!blocked || r > rank(blocked)) blocked = b; continue; }
|
||
if (r > bestRank) { bestRank = r; best = b; }
|
||
}
|
||
return best;
|
||
};
|
||
const isScope = (el) => { try { return el.matches('[role="dialog"],[role="alertdialog"],form'); } catch (e) { return false; } };
|
||
let scope = null;
|
||
for (let node = ed; node; node = up(node)) { if (isScope(node)) { scope = node; break; } }
|
||
let btn = scope ? submitIn(scope) : null;
|
||
if (!btn) {
|
||
// Nearest-scope-first walk: X's inline submit shares an ancestor 20 hops above the Draft.js
|
||
// editable while foreign tweets' buttons only enter at 28 (measured live), so 24 finds the
|
||
// composer's own submit and stops before any wider scope could.
|
||
//
|
||
// Also the fallback when a scope WAS found and held no submit. Reddit puts its composer in a
|
||
// <form> and its submit in a sticky action bar OUTSIDE that form, so scoping alone answered
|
||
// "no submit control" and the whole write path degraded to a coordinate guess. Walking after a
|
||
// barren scope cannot reach wider than the walk would have reached anyway.
|
||
let node = up(ed);
|
||
for (let hop = 0; node && node !== document.body && hop < 24; hop++, node = up(node)) {
|
||
btn = submitIn(node);
|
||
if (btn) break;
|
||
}
|
||
}
|
||
if (!btn) return blocked
|
||
? { ok: false, disabled: true, name: labelOf(blocked),
|
||
why: 'the submit control is present but DISABLED, so the form is not complete yet '
|
||
+ '(a required field such as a title is still empty, or the editor never registered the text)' }
|
||
: { ok: false, why: 'no submit control in the composer container' };
|
||
const r0 = btn.getBoundingClientRect();
|
||
if (r0.top < 0 || r0.bottom > window.innerHeight) btn.scrollIntoView({ block: 'center' });
|
||
const r = btn.getBoundingClientRect();
|
||
return { ok: true, name: labelOf(btn), rank: rank(btn),
|
||
xPct: (r.left + r.width / 2) / window.innerWidth * 100,
|
||
yPct: (r.top + r.height / 2) / window.innerHeight * 100 };
|
||
})()"""
|
||
|
||
|
||
def container_submit_expression(payload: str) -> str:
|
||
"""The container-scoped submit click for a composer holding `payload` (prefix-matched, same
|
||
24-char truncation the fill verifier uses)."""
|
||
return P_CONTAINER_SUBMIT_JS % (json.dumps((payload or "")[:24]), json.dumps(sorted(SEND_LABELS)))
|
||
|
||
|
||
def parse_eval_value(res: object) -> Optional[Dict[str, Any]]:
|
||
"""The dict a BrowserEvaluate returned, or None. Unreadable shapes are None (honest miss)."""
|
||
val: object = None
|
||
if isinstance(res, dict) and "error" not in res:
|
||
val = res.get("value")
|
||
if val is None and isinstance(res.get("text"), str):
|
||
try:
|
||
val = json.loads(res["text"])
|
||
except (json.JSONDecodeError, ValueError):
|
||
val = None
|
||
return val if isinstance(val, dict) else None
|
||
|
||
|
||
# The browser tool's own words when an index no longer resolves. Anchored on the stable half of the
|
||
# sentence ("not in the cached element map"), not the whole string, so a reworded tail doesn't
|
||
# silently turn the retry off and take the fast write path down with it.
|
||
P_STALE_INDEX_RE = re.compile(r"not in the cached element map|refresh the index", re.I)
|
||
|
||
|
||
def is_stale_index_error(res: object) -> bool:
|
||
"""Did this tool result fail because the element index went stale?
|
||
|
||
Distinct from every other failure: a stale index means the element was THERE and the page
|
||
re-rendered underneath us, so re-listing and retrying is correct. A genuine miss (no such
|
||
control, refused input) must not retry, because retrying a real failure is how you double-post.
|
||
"""
|
||
if not isinstance(res, dict):
|
||
return False
|
||
return bool(P_STALE_INDEX_RE.search(str(res.get("error") or "")))
|