Improve autofill extension: better field detection + Basic Auth support
- Split filled flag into userFilled/passFilled for independent tracking - Add findUserFieldNearPassword() for DOM-relative lookup near password field - Add isVisible() helper to skip disabled/hidden/offscreen inputs - Add console.log tracing for debugging - Add background.js service worker with webRequest.onAuthRequired for Basic Auth - Add _url_with_credentials() to embed login:pass in URL for HTTP Basic Auth - Use /usr/lib/chromium/chromium binary directly (bypass Debian wrapper) - Add --enable-logging=stderr for console.log capture in chromium logs
This commit is contained in:
+118
-24
@@ -7,6 +7,7 @@ import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import urlparse, urlunparse, quote
|
||||
|
||||
DISPLAY = os.environ.get("DISPLAY", ":1")
|
||||
CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080")
|
||||
@@ -29,33 +30,71 @@ _lock = threading.Lock()
|
||||
_AUTOFILL_CONTENT_JS = r"""
|
||||
(function() {
|
||||
const CREDS = __CREDS__;
|
||||
let filled = false;
|
||||
let userFilled = false;
|
||||
let passFilled = false;
|
||||
console.log('[PortalAutofill] loaded for', location.href);
|
||||
|
||||
function findUserField() {
|
||||
function isVisible(el) {
|
||||
if (!el) return false;
|
||||
if (el.disabled || el.readOnly) return false;
|
||||
if (el.offsetParent === null && el.type !== 'email') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function findUserFieldByAttrs() {
|
||||
const candidates = document.querySelectorAll(
|
||||
'input[type="email"], ' +
|
||||
'input[autocomplete*="username"], ' +
|
||||
'input[autocomplete*="email"], ' +
|
||||
'input[name*="user" i], ' +
|
||||
'input[name*="login" i], ' +
|
||||
'input[name*="email" i], ' +
|
||||
'input[id*="user" i], ' +
|
||||
'input[id*="login" i], ' +
|
||||
'input[id*="email" i], ' +
|
||||
'input[type="text"]'
|
||||
'input[name*="user" i]:not([type="password"]):not([type="hidden"]), ' +
|
||||
'input[name*="login" i]:not([type="password"]):not([type="hidden"]), ' +
|
||||
'input[name*="email" i]:not([type="password"]):not([type="hidden"]), ' +
|
||||
'input[id*="user" i]:not([type="password"]):not([type="hidden"]), ' +
|
||||
'input[id*="login" i]:not([type="password"]):not([type="hidden"]), ' +
|
||||
'input[id*="email" i]:not([type="password"]):not([type="hidden"])'
|
||||
);
|
||||
for (const el of candidates) {
|
||||
if (el.type === 'password' || el.type === 'hidden') continue;
|
||||
if (el.offsetParent === null && el.type !== 'email') continue;
|
||||
return el;
|
||||
if (isVisible(el)) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findUserFieldNearPassword(passEl) {
|
||||
if (!passEl) return null;
|
||||
// Walk up to find the closest form-like container, then look for
|
||||
// any text/email input that comes BEFORE the password element.
|
||||
let container = passEl.closest('form');
|
||||
if (!container) {
|
||||
let cur = passEl.parentElement;
|
||||
while (cur && cur !== document.body) {
|
||||
if (cur.querySelectorAll('input').length >= 2) { container = cur; break; }
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
}
|
||||
if (!container) container = document.body;
|
||||
const inputs = container.querySelectorAll('input');
|
||||
let candidate = null;
|
||||
for (const el of inputs) {
|
||||
if (el === passEl) break;
|
||||
const t = (el.type || 'text').toLowerCase();
|
||||
if (t === 'password' || t === 'hidden' || t === 'submit' || t === 'button' ||
|
||||
t === 'checkbox' || t === 'radio' || t === 'file') continue;
|
||||
if (!isVisible(el)) continue;
|
||||
candidate = el;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function findUserField(passEl) {
|
||||
return findUserFieldByAttrs() || findUserFieldNearPassword(passEl) ||
|
||||
Array.from(document.querySelectorAll('input[type="text"], input:not([type])'))
|
||||
.find(isVisible) || null;
|
||||
}
|
||||
|
||||
function findPassField() {
|
||||
const list = document.querySelectorAll('input[type="password"]');
|
||||
for (const el of list) {
|
||||
if (el.offsetParent !== null) return el;
|
||||
if (isVisible(el)) return el;
|
||||
}
|
||||
return list[0] || null;
|
||||
}
|
||||
@@ -73,14 +112,23 @@ _AUTOFILL_CONTENT_JS = r"""
|
||||
}
|
||||
|
||||
function tryFill() {
|
||||
if (filled) return;
|
||||
if (userFilled && passFilled) return;
|
||||
const p = findPassField();
|
||||
if (!p) return;
|
||||
const u = findUserField();
|
||||
let did = false;
|
||||
if (CREDS.login && u) did = setNativeValue(u, CREDS.login) || did;
|
||||
if (CREDS.password) did = setNativeValue(p, CREDS.password) || did;
|
||||
if (did) filled = true;
|
||||
const u = findUserField(p);
|
||||
if (CREDS.password && p && !passFilled) {
|
||||
if (setNativeValue(p, CREDS.password)) {
|
||||
passFilled = true;
|
||||
console.log('[PortalAutofill] password filled');
|
||||
}
|
||||
}
|
||||
if (CREDS.login && u && !userFilled) {
|
||||
if (setNativeValue(u, CREDS.login)) {
|
||||
userFilled = true;
|
||||
console.log('[PortalAutofill] user filled');
|
||||
}
|
||||
}
|
||||
if (!CREDS.login) userFilled = true;
|
||||
if (!CREDS.password) passFilled = true;
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
@@ -89,12 +137,18 @@ _AUTOFILL_CONTENT_JS = r"""
|
||||
tryFill();
|
||||
}
|
||||
|
||||
const obs = new MutationObserver(() => { if (!filled) tryFill(); });
|
||||
const obs = new MutationObserver(() => {
|
||||
if (!(userFilled && passFilled)) tryFill();
|
||||
});
|
||||
if (document.documentElement) {
|
||||
obs.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
const resetAndRefill = () => { filled = false; setTimeout(tryFill, 150); };
|
||||
const resetAndRefill = () => {
|
||||
userFilled = !CREDS.login;
|
||||
passFilled = !CREDS.password;
|
||||
setTimeout(tryFill, 150);
|
||||
};
|
||||
['pushState', 'replaceState'].forEach(fn => {
|
||||
const orig = history[fn];
|
||||
history[fn] = function() { const r = orig.apply(this, arguments); resetAndRefill(); return r; };
|
||||
@@ -108,6 +162,9 @@ _AUTOFILL_MANIFEST = {
|
||||
"name": "Portal Autofill",
|
||||
"version": "1.0",
|
||||
"description": "Auto-fill credentials for portal session",
|
||||
"background": {"service_worker": "background.js"},
|
||||
"permissions": ["webRequest"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
@@ -118,6 +175,19 @@ _AUTOFILL_MANIFEST = {
|
||||
],
|
||||
}
|
||||
|
||||
_AUTOFILL_BACKGROUND_JS = r"""
|
||||
const CREDS = __CREDS__;
|
||||
if (CREDS.login || CREDS.password) {
|
||||
chrome.webRequest.onAuthRequired.addListener(
|
||||
function(details, callback) {
|
||||
callback({ authCredentials: { username: CREDS.login || '', password: CREDS.password || '' } });
|
||||
},
|
||||
{ urls: ['<all_urls>'] },
|
||||
['asyncBlocking']
|
||||
);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _create_autofill_extension(login: str, password: str) -> str | None:
|
||||
if not login and not password:
|
||||
@@ -125,10 +195,13 @@ def _create_autofill_extension(login: str, password: str) -> str | None:
|
||||
ext_dir = tempfile.mkdtemp(prefix="chrome-autofill-ext-")
|
||||
creds_json = json.dumps({"login": login, "password": password})
|
||||
content_js = _AUTOFILL_CONTENT_JS.replace("__CREDS__", creds_json)
|
||||
background_js = _AUTOFILL_BACKGROUND_JS.replace("__CREDS__", creds_json)
|
||||
with open(os.path.join(ext_dir, "manifest.json"), "w") as f:
|
||||
json.dump(_AUTOFILL_MANIFEST, f)
|
||||
with open(os.path.join(ext_dir, "content.js"), "w") as f:
|
||||
f.write(content_js)
|
||||
with open(os.path.join(ext_dir, "background.js"), "w") as f:
|
||||
f.write(background_js)
|
||||
return ext_dir
|
||||
|
||||
|
||||
@@ -257,6 +330,18 @@ def apply_resolution(width: int | None, height: int | None) -> tuple[int, int]:
|
||||
return safe_w, safe_h
|
||||
|
||||
|
||||
def _url_with_credentials(url: str, login: str, password: str) -> str:
|
||||
"""Embed login:password into URL so Chromium auto-handles Basic Auth."""
|
||||
if not login and not password:
|
||||
return url
|
||||
parsed = urlparse(url)
|
||||
netloc = parsed.hostname or ""
|
||||
if parsed.port:
|
||||
netloc += f":{parsed.port}"
|
||||
user_info = quote(login, safe="") + ":" + quote(password, safe="")
|
||||
return urlunparse(parsed._replace(netloc=f"{user_info}@{netloc}"))
|
||||
|
||||
|
||||
def open_web(
|
||||
url: str,
|
||||
width: int | None = None,
|
||||
@@ -267,9 +352,16 @@ def open_web(
|
||||
safe_w, safe_h = apply_resolution(width, height)
|
||||
profile_dir = _create_chrome_profile()
|
||||
extension_dir = _create_autofill_extension(login, password)
|
||||
# Embed credentials in URL for HTTP Basic Auth (no dialog shown)
|
||||
url_with_creds = _url_with_credentials(url, login, password)
|
||||
|
||||
# Use the real Chromium binary directly to avoid the Debian wrapper which
|
||||
# injects an empty `--load-extension=` from /etc/chromium.d/extensions.
|
||||
chromium_bin = "/usr/lib/chromium/chromium"
|
||||
if not os.path.isfile(chromium_bin):
|
||||
chromium_bin = "chromium"
|
||||
cmd = [
|
||||
"chromium",
|
||||
chromium_bin,
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
@@ -285,6 +377,8 @@ def open_web(
|
||||
f"--window-size={safe_w},{safe_h}",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--enable-logging=stderr",
|
||||
"--v=0",
|
||||
"--lang=ru-RU",
|
||||
"--accept-lang=ru-RU,ru",
|
||||
"--password-store=basic",
|
||||
@@ -293,7 +387,7 @@ def open_web(
|
||||
if extension_dir:
|
||||
cmd.append(f"--load-extension={extension_dir}")
|
||||
cmd.append(f"--disable-extensions-except={extension_dir}")
|
||||
cmd.append(url)
|
||||
cmd.append(url_with_creds)
|
||||
|
||||
_start_process(cmd, "web", url)
|
||||
_state["profile_dir"] = profile_dir
|
||||
|
||||
Reference in New Issue
Block a user