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:
2026-05-01 04:44:15 +00:00
parent d57acb416b
commit 9bd38ed6db
+118 -24
View File
@@ -7,6 +7,7 @@ import subprocess
import tempfile import tempfile
import threading import threading
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, urlunparse, quote
DISPLAY = os.environ.get("DISPLAY", ":1") DISPLAY = os.environ.get("DISPLAY", ":1")
CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080") CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080")
@@ -29,33 +30,71 @@ _lock = threading.Lock()
_AUTOFILL_CONTENT_JS = r""" _AUTOFILL_CONTENT_JS = r"""
(function() { (function() {
const CREDS = __CREDS__; 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( const candidates = document.querySelectorAll(
'input[type="email"], ' + 'input[type="email"], ' +
'input[autocomplete*="username"], ' + 'input[autocomplete*="username"], ' +
'input[autocomplete*="email"], ' + 'input[autocomplete*="email"], ' +
'input[name*="user" i], ' + 'input[name*="user" i]:not([type="password"]):not([type="hidden"]), ' +
'input[name*="login" i], ' + 'input[name*="login" i]:not([type="password"]):not([type="hidden"]), ' +
'input[name*="email" i], ' + 'input[name*="email" i]:not([type="password"]):not([type="hidden"]), ' +
'input[id*="user" i], ' + 'input[id*="user" i]:not([type="password"]):not([type="hidden"]), ' +
'input[id*="login" i], ' + 'input[id*="login" i]:not([type="password"]):not([type="hidden"]), ' +
'input[id*="email" i], ' + 'input[id*="email" i]:not([type="password"]):not([type="hidden"])'
'input[type="text"]'
); );
for (const el of candidates) { for (const el of candidates) {
if (el.type === 'password' || el.type === 'hidden') continue; if (isVisible(el)) return el;
if (el.offsetParent === null && el.type !== 'email') continue;
return el;
} }
return null; 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() { function findPassField() {
const list = document.querySelectorAll('input[type="password"]'); const list = document.querySelectorAll('input[type="password"]');
for (const el of list) { for (const el of list) {
if (el.offsetParent !== null) return el; if (isVisible(el)) return el;
} }
return list[0] || null; return list[0] || null;
} }
@@ -73,14 +112,23 @@ _AUTOFILL_CONTENT_JS = r"""
} }
function tryFill() { function tryFill() {
if (filled) return; if (userFilled && passFilled) return;
const p = findPassField(); const p = findPassField();
if (!p) return; const u = findUserField(p);
const u = findUserField(); if (CREDS.password && p && !passFilled) {
let did = false; if (setNativeValue(p, CREDS.password)) {
if (CREDS.login && u) did = setNativeValue(u, CREDS.login) || did; passFilled = true;
if (CREDS.password) did = setNativeValue(p, CREDS.password) || did; console.log('[PortalAutofill] password filled');
if (did) filled = true; }
}
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') { if (document.readyState === 'loading') {
@@ -89,12 +137,18 @@ _AUTOFILL_CONTENT_JS = r"""
tryFill(); tryFill();
} }
const obs = new MutationObserver(() => { if (!filled) tryFill(); }); const obs = new MutationObserver(() => {
if (!(userFilled && passFilled)) tryFill();
});
if (document.documentElement) { if (document.documentElement) {
obs.observe(document.documentElement, { childList: true, subtree: true }); 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 => { ['pushState', 'replaceState'].forEach(fn => {
const orig = history[fn]; const orig = history[fn];
history[fn] = function() { const r = orig.apply(this, arguments); resetAndRefill(); return r; }; history[fn] = function() { const r = orig.apply(this, arguments); resetAndRefill(); return r; };
@@ -108,6 +162,9 @@ _AUTOFILL_MANIFEST = {
"name": "Portal Autofill", "name": "Portal Autofill",
"version": "1.0", "version": "1.0",
"description": "Auto-fill credentials for portal session", "description": "Auto-fill credentials for portal session",
"background": {"service_worker": "background.js"},
"permissions": ["webRequest"],
"host_permissions": ["<all_urls>"],
"content_scripts": [ "content_scripts": [
{ {
"matches": ["<all_urls>"], "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: def _create_autofill_extension(login: str, password: str) -> str | None:
if not login and not password: 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-") ext_dir = tempfile.mkdtemp(prefix="chrome-autofill-ext-")
creds_json = json.dumps({"login": login, "password": password}) creds_json = json.dumps({"login": login, "password": password})
content_js = _AUTOFILL_CONTENT_JS.replace("__CREDS__", creds_json) 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: with open(os.path.join(ext_dir, "manifest.json"), "w") as f:
json.dump(_AUTOFILL_MANIFEST, f) json.dump(_AUTOFILL_MANIFEST, f)
with open(os.path.join(ext_dir, "content.js"), "w") as f: with open(os.path.join(ext_dir, "content.js"), "w") as f:
f.write(content_js) f.write(content_js)
with open(os.path.join(ext_dir, "background.js"), "w") as f:
f.write(background_js)
return ext_dir return ext_dir
@@ -257,6 +330,18 @@ def apply_resolution(width: int | None, height: int | None) -> tuple[int, int]:
return safe_w, safe_h 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( def open_web(
url: str, url: str,
width: int | None = None, width: int | None = None,
@@ -267,9 +352,16 @@ def open_web(
safe_w, safe_h = apply_resolution(width, height) safe_w, safe_h = apply_resolution(width, height)
profile_dir = _create_chrome_profile() profile_dir = _create_chrome_profile()
extension_dir = _create_autofill_extension(login, password) 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 = [ cmd = [
"chromium", chromium_bin,
"--no-sandbox", "--no-sandbox",
"--disable-dev-shm-usage", "--disable-dev-shm-usage",
"--disable-gpu", "--disable-gpu",
@@ -285,6 +377,8 @@ def open_web(
f"--window-size={safe_w},{safe_h}", f"--window-size={safe_w},{safe_h}",
"--no-first-run", "--no-first-run",
"--no-default-browser-check", "--no-default-browser-check",
"--enable-logging=stderr",
"--v=0",
"--lang=ru-RU", "--lang=ru-RU",
"--accept-lang=ru-RU,ru", "--accept-lang=ru-RU,ru",
"--password-store=basic", "--password-store=basic",
@@ -293,7 +387,7 @@ def open_web(
if extension_dir: if extension_dir:
cmd.append(f"--load-extension={extension_dir}") cmd.append(f"--load-extension={extension_dir}")
cmd.append(f"--disable-extensions-except={extension_dir}") cmd.append(f"--disable-extensions-except={extension_dir}")
cmd.append(url) cmd.append(url_with_creds)
_start_process(cmd, "web", url) _start_process(cmd, "web", url)
_state["profile_dir"] = profile_dir _state["profile_dir"] = profile_dir