529 lines
18 KiB
JavaScript
529 lines
18 KiB
JavaScript
// isDomainMatch 由 utils.js 提供(全局函数)
|
||
|
||
// 2FA / 验证码检测:检测到则不自动提交,仅填充
|
||
function detectCaptcha() {
|
||
// 1. reCAPTCHA
|
||
if (document.querySelector(".g-recaptcha, [data-sitekey], iframe[src*='recaptcha'], iframe[src*='google.com/recaptcha']")) {
|
||
return { type: "reCAPTCHA", reason: "检测到 reCAPTCHA" };
|
||
}
|
||
// 2. hCaptcha
|
||
if (document.querySelector(".h-captcha, iframe[src*='hcaptcha']")) {
|
||
return { type: "hCaptcha", reason: "检测到 hCaptcha" };
|
||
}
|
||
// 3. 国内常见滑块/验证码容器
|
||
const sliderSelectors = [
|
||
"#slider", ".slider", ".nc_wrapper", ".nc_iconfont",
|
||
"#captcha", ".captcha", ".geetest", "#geetest",
|
||
"[class*='slider']", "[id*='slider']", "[id*='captcha']", "[class*='captcha']",
|
||
"canvas[id*='captcha']", "canvas[id*='verify']"
|
||
];
|
||
for (const sel of sliderSelectors) {
|
||
try {
|
||
const el = document.querySelector(sel);
|
||
if (el && el.offsetParent !== null) {
|
||
return { type: "slider/captcha", reason: `检测到验证码元素 (${sel})` };
|
||
}
|
||
} catch { /* selector 可能无效 */ }
|
||
}
|
||
// 4. 明显的"验证码"文字输入框(带图形码)
|
||
const codeInputs = document.querySelectorAll(
|
||
"input[name*='captcha' i], input[name*='verify' i], input[name*='code' i], input[id*='captcha' i], input[id*='verify' i], input[placeholder*='验证码' i], input[placeholder*='验证' i]"
|
||
);
|
||
for (const input of codeInputs) {
|
||
if (input.offsetParent !== null && input.type !== "hidden") {
|
||
return { type: "captcha-input", reason: "检测到验证码输入框" };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function setNativeValue(el, value) {
|
||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||
window.HTMLInputElement.prototype,
|
||
"value"
|
||
)?.set || Object.getOwnPropertyDescriptor(
|
||
window.HTMLTextAreaElement.prototype,
|
||
"value"
|
||
)?.set;
|
||
|
||
if (nativeInputValueSetter) {
|
||
nativeInputValueSetter.call(el, value);
|
||
} else {
|
||
el.value = value;
|
||
}
|
||
|
||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||
}
|
||
|
||
function findUsernameField(config) {
|
||
if (config.usernameSelector) {
|
||
const el = document.querySelector(config.usernameSelector);
|
||
if (el) return el;
|
||
}
|
||
|
||
const passwordField = findPasswordField(config);
|
||
if (!passwordField) return null;
|
||
|
||
const form = passwordField.closest("form");
|
||
const searchScope = form || document;
|
||
|
||
const selectors = [
|
||
"input[type='text']",
|
||
"input[type='email']",
|
||
"input[type='tel']",
|
||
"input:not([type='password']):not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio'])",
|
||
];
|
||
|
||
for (const sel of selectors) {
|
||
const inputs = searchScope.querySelectorAll(sel);
|
||
for (const input of inputs) {
|
||
if (input.offsetParent !== null || input.offsetWidth > 0) {
|
||
const name = (input.name || "").toLowerCase();
|
||
const id = (input.id || "").toLowerCase();
|
||
const placeholder = (input.placeholder || "").toLowerCase();
|
||
const autocomplete = (input.getAttribute("autocomplete") || "").toLowerCase();
|
||
|
||
if (
|
||
name.includes("user") || name.includes("name") || name.includes("account") ||
|
||
name.includes("email") || name.includes("login") || name.includes("mail") ||
|
||
id.includes("user") || id.includes("name") || id.includes("account") ||
|
||
id.includes("email") || id.includes("login") ||
|
||
placeholder.includes("user") || placeholder.includes("name") ||
|
||
placeholder.includes("email") || placeholder.includes("account") ||
|
||
placeholder.includes("登录") || placeholder.includes("账号") ||
|
||
placeholder.includes("用户") || placeholder.includes("邮箱") ||
|
||
autocomplete.includes("username") || autocomplete.includes("email")
|
||
) {
|
||
return input;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const allInputs = searchScope.querySelectorAll(
|
||
"input[type='text'], input[type='email'], input:not([type='password']):not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio'])"
|
||
);
|
||
for (const input of allInputs) {
|
||
if (input.offsetParent !== null || input.offsetWidth > 0) {
|
||
return input;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function findPasswordField(config) {
|
||
if (config.passwordSelector) {
|
||
const el = document.querySelector(config.passwordSelector);
|
||
if (el) return el;
|
||
}
|
||
|
||
const passwordInputs = document.querySelectorAll("input[type='password']");
|
||
for (const input of passwordInputs) {
|
||
if (input.offsetParent !== null || input.offsetWidth > 0) {
|
||
return input;
|
||
}
|
||
}
|
||
|
||
return passwordInputs.length > 0 ? passwordInputs[0] : null;
|
||
}
|
||
|
||
function findSubmitButton(config, passwordField) {
|
||
if (config.submitSelector) {
|
||
const el = document.querySelector(config.submitSelector);
|
||
if (el) return el;
|
||
}
|
||
|
||
const form = passwordField ? passwordField.closest("form") : null;
|
||
const searchScope = form || document;
|
||
|
||
const submitSelectors = [
|
||
"button[type='submit']",
|
||
"input[type='submit']",
|
||
"button[type='button']",
|
||
"input[type='button']",
|
||
];
|
||
|
||
for (const sel of submitSelectors) {
|
||
const btns = searchScope.querySelectorAll(sel);
|
||
for (const btn of btns) {
|
||
if (btn.offsetParent !== null || btn.offsetWidth > 0) {
|
||
const text = (btn.textContent || btn.value || "").toLowerCase().trim();
|
||
if (
|
||
text.includes("登录") || text.includes("login") || text.includes("log in") ||
|
||
text.includes("sign in") || text.includes("提交") || text.includes("submit") ||
|
||
text.includes("确定") || text.includes("ok") || text.includes("go")
|
||
) {
|
||
return btn;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const sel of submitSelectors) {
|
||
const btn = searchScope.querySelector(sel);
|
||
if (btn && (btn.offsetParent !== null || btn.offsetWidth > 0)) {
|
||
return btn;
|
||
}
|
||
}
|
||
|
||
const allButtons = searchScope.querySelectorAll("button, input[type='button'], a[role='button']");
|
||
for (const btn of allButtons) {
|
||
if (btn.offsetParent !== null || btn.offsetWidth > 0) {
|
||
const text = (btn.textContent || btn.value || "").toLowerCase().trim();
|
||
if (
|
||
text.includes("登录") || text.includes("login") || text.includes("log in") ||
|
||
text.includes("sign in")
|
||
) {
|
||
return btn;
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function fillForm(config, canSubmit = true) {
|
||
const passwordField = findPasswordField(config);
|
||
if (!passwordField) {
|
||
console.log("[AutoLogin] 未找到密码输入框");
|
||
return false;
|
||
}
|
||
|
||
const usernameField = findUsernameField(config);
|
||
|
||
if (usernameField) {
|
||
setNativeValue(usernameField, config.username);
|
||
console.log("[AutoLogin] 已填充用户名");
|
||
} else {
|
||
console.log("[AutoLogin] 未找到用户名输入框,跳过用户名填充");
|
||
}
|
||
|
||
setNativeValue(passwordField, config.password);
|
||
console.log("[AutoLogin] 已填充密码");
|
||
|
||
// 验证码检测:检测到则强制不自动提交,提示用户手动操作
|
||
const captcha = detectCaptcha();
|
||
let actualCanSubmit = canSubmit;
|
||
if (captcha && config.autoSubmit) {
|
||
actualCanSubmit = false;
|
||
console.warn(`[AutoLogin] ${captcha.reason},已跳过自动提交,请手动完成验证码后点击登录`);
|
||
// 在页面右上角显示提示浮窗
|
||
showCaptchaToast(captcha.reason);
|
||
}
|
||
|
||
if (config.autoSubmit && actualCanSubmit) {
|
||
const submitBtn = findSubmitButton(config, passwordField);
|
||
if (submitBtn) {
|
||
// 延迟提交:config.delayMs 给某些需要 JS 初始化的页面留时间
|
||
const delay = Math.max(300, Number(config.delayMs) || 300);
|
||
setTimeout(() => {
|
||
console.log(`[AutoLogin] 自动点击登录按钮(延迟 ${delay}ms)`);
|
||
submitBtn.click();
|
||
}, delay);
|
||
return true;
|
||
}
|
||
|
||
if (passwordField.form) {
|
||
const delay = Math.max(300, Number(config.delayMs) || 300);
|
||
console.log("[AutoLogin] 未找到登录按钮,尝试提交表单");
|
||
setTimeout(() => {
|
||
passwordField.form.requestSubmit
|
||
? passwordField.form.requestSubmit()
|
||
: passwordField.form.submit();
|
||
}, delay);
|
||
return true;
|
||
}
|
||
|
||
console.log("[AutoLogin] 未找到登录按钮,也无法提交表单");
|
||
} else if (config.autoSubmit && !actualCanSubmit && captcha) {
|
||
console.warn("[AutoLogin] 已检测到验证码,仅填充不自动提交,请手动完成验证码后点击登录");
|
||
} else if (config.autoSubmit && !canSubmit) {
|
||
console.warn("[AutoLogin] 自动登录失败次数已达上限,仅填充不自动提交,请手动确认后点击登录");
|
||
} else {
|
||
console.log("[AutoLogin] 自动提交已关闭,仅填充");
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// 验证码提示浮窗:在页面右上角显示,几秒后自动消失
|
||
let captchaToastEl = null;
|
||
function showCaptchaToast(reason) {
|
||
if (captchaToastEl && captchaToastEl.parentNode) {
|
||
captchaToastEl.parentNode.removeChild(captchaToastEl);
|
||
}
|
||
const el = document.createElement("div");
|
||
el.style.cssText = `
|
||
position: fixed;
|
||
top: 16px;
|
||
right: 16px;
|
||
z-index: 2147483647;
|
||
background: #1a1a2e;
|
||
border: 1px solid #e94560;
|
||
border-radius: 8px;
|
||
padding: 10px 14px;
|
||
color: #e0e0e0;
|
||
font-size: 13px;
|
||
font-family: "Segoe UI","Microsoft YaHei",sans-serif;
|
||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||
max-width: 300px;
|
||
`;
|
||
el.innerHTML = `<div style="font-weight:600;color:#e94560;margin-bottom:4px;">需要手动操作</div>` +
|
||
`<div style="font-size:11px;color:#a0a0b0;">${reason},已为你填充账密,请完成验证码后点击登录</div>`;
|
||
document.body.appendChild(el);
|
||
captchaToastEl = el;
|
||
setTimeout(() => {
|
||
if (el && el.parentNode) el.parentNode.removeChild(el);
|
||
captchaToastEl = null;
|
||
}, 8000);
|
||
}
|
||
|
||
const MAX_LOGIN_ATTEMPTS = 3;
|
||
const ATTEMPT_COOLDOWN = 5 * 60 * 1000;
|
||
|
||
// 失败计数按 envId+domain 索引,支持同 domain 在不同环境下独立计数
|
||
function attemptKey(envId, domain) {
|
||
return `${envId}::${domain}`;
|
||
}
|
||
|
||
async function getDomainAttempts(envId, domain) {
|
||
const key = attemptKey(envId, domain);
|
||
const data = await chrome.storage.local.get("loginAttempts");
|
||
const attempts = data.loginAttempts || {};
|
||
return attempts[key] || { count: 0, lastAttempt: 0 };
|
||
}
|
||
|
||
async function incrementDomainAttempts(envId, domain) {
|
||
const key = attemptKey(envId, domain);
|
||
const data = await chrome.storage.local.get("loginAttempts");
|
||
const attempts = data.loginAttempts || {};
|
||
const cur = attempts[key] || { count: 0, lastAttempt: 0 };
|
||
cur.count += 1;
|
||
cur.lastAttempt = Date.now();
|
||
attempts[key] = cur;
|
||
await chrome.storage.local.set({ loginAttempts: attempts });
|
||
return cur;
|
||
}
|
||
|
||
async function resetDomainAttempts(envId, domain) {
|
||
const key = attemptKey(envId, domain);
|
||
const data = await chrome.storage.local.get("loginAttempts");
|
||
const attempts = data.loginAttempts || {};
|
||
if (attempts[key]) {
|
||
delete attempts[key];
|
||
await chrome.storage.local.set({ loginAttempts: attempts });
|
||
}
|
||
}
|
||
|
||
// 从 session 中扁平化读取所有配置(解锁后明文存于 session,未解锁时为空)
|
||
async function loadAllConfigs() {
|
||
const data = await chrome.storage.session.get("environments");
|
||
const environments = data.environments || [];
|
||
const all = [];
|
||
environments.forEach((env) => {
|
||
(env.configs || []).forEach((cfg) => {
|
||
all.push({ ...cfg, envId: env.id, envName: env.name });
|
||
});
|
||
});
|
||
return all;
|
||
}
|
||
|
||
async function autoFillOnLoad() {
|
||
const allConfigs = await loadAllConfigs();
|
||
if (allConfigs.length === 0) return;
|
||
|
||
// 跳过 enabled === false 的配置(独立开关)
|
||
const enabledConfigs = allConfigs.filter((c) => c.enabled !== false);
|
||
if (enabledConfigs.length === 0) return;
|
||
|
||
const matched = enabledConfigs.filter((c) => isDomainMatch(window.location.href, c.domain));
|
||
if (matched.length === 0) return;
|
||
|
||
// 单个匹配:直接填充
|
||
if (matched.length === 1) {
|
||
await fillSingleConfig(matched[0]);
|
||
return;
|
||
}
|
||
|
||
// 多个匹配(跨环境):弹浮窗让用户选择
|
||
console.log(`[AutoLogin] 匹配到 ${matched.length} 个环境配置,弹出选择菜单`);
|
||
showEnvPicker(matched);
|
||
}
|
||
|
||
// SPA 动态表单:尝试填充,表单未出现时持续重试 30 秒
|
||
const SPA_FILL_TIMEOUT_MS = 30000;
|
||
const SPA_RETRY_INTERVAL_MS = 500;
|
||
|
||
async function fillSingleConfig(config) {
|
||
console.log(`[AutoLogin] 使用环境「${config.envName || "默认"}」的配置开始填充`);
|
||
|
||
// 检查自动登录尝试次数,超过上限则仅填充不自动提交
|
||
let canSubmit = config.autoSubmit;
|
||
if (config.autoSubmit) {
|
||
const attempts = await getDomainAttempts(config.envId, config.domain);
|
||
const now = Date.now();
|
||
if (now - attempts.lastAttempt > ATTEMPT_COOLDOWN) {
|
||
attempts.count = 0;
|
||
}
|
||
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
|
||
canSubmit = false;
|
||
console.warn(
|
||
`[AutoLogin] 环境「${config.envName}」域名 ${config.domain} 自动登录已失败 ${attempts.count} 次,` +
|
||
`达到上限 ${MAX_LOGIN_ATTEMPTS} 次,停止自动提交。` +
|
||
`请手动确认账号密码后点击登录,或 ${Math.ceil(ATTEMPT_COOLDOWN / 60000)} 分钟后重试。`
|
||
);
|
||
} else {
|
||
await incrementDomainAttempts(config.envId, config.domain);
|
||
}
|
||
}
|
||
|
||
// SPA 重试:表单未出现时持续重试,30 秒超时
|
||
const startTime = Date.now();
|
||
const tryFill = () => {
|
||
const elapsed = Date.now() - startTime;
|
||
if (elapsed > SPA_FILL_TIMEOUT_MS) {
|
||
console.warn(`[AutoLogin] SPA 动态表单等待超时(${SPA_FILL_TIMEOUT_MS / 1000}s),已停止尝试`);
|
||
return;
|
||
}
|
||
const filled = fillForm(config, canSubmit);
|
||
if (!filled) {
|
||
setTimeout(tryFill, SPA_RETRY_INTERVAL_MS);
|
||
}
|
||
};
|
||
|
||
tryFill();
|
||
}
|
||
|
||
// 多环境命中时,在页面右上角插入选择浮窗
|
||
let envPickerEl = null;
|
||
function showEnvPicker(configs) {
|
||
// 移除已有浮窗
|
||
if (envPickerEl && envPickerEl.parentNode) {
|
||
envPickerEl.parentNode.removeChild(envPickerEl);
|
||
envPickerEl = null;
|
||
}
|
||
|
||
const overlay = document.createElement("div");
|
||
overlay.id = "auto-login-env-picker";
|
||
overlay.style.cssText = `
|
||
position: fixed;
|
||
top: 16px;
|
||
right: 16px;
|
||
z-index: 2147483647;
|
||
background: #1a1a2e;
|
||
border: 1px solid #e94560;
|
||
border-radius: 10px;
|
||
padding: 14px 16px;
|
||
min-width: 240px;
|
||
max-width: 320px;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||
font-family: "Segoe UI","Microsoft YaHei",sans-serif;
|
||
color: #e0e0e0;
|
||
font-size: 13px;
|
||
`;
|
||
|
||
const title = document.createElement("div");
|
||
title.textContent = "选择要使用哪个环境";
|
||
title.style.cssText = "font-weight:600;color:#e94560;margin-bottom:10px;font-size:14px;";
|
||
overlay.appendChild(title);
|
||
|
||
const list = document.createElement("div");
|
||
list.style.cssText = "display:flex;flex-direction:column;gap:6px;";
|
||
configs.forEach((cfg) => {
|
||
const btn = document.createElement("button");
|
||
btn.style.cssText = `
|
||
text-align:left;
|
||
background:#16213e;
|
||
border:1px solid #0f3460;
|
||
border-radius:6px;
|
||
padding:8px 10px;
|
||
color:#e0e0e0;
|
||
cursor:pointer;
|
||
font-size:12px;
|
||
font-family:inherit;
|
||
transition:background 0.15s;
|
||
`;
|
||
const envLabel = escapeHtml(cfg.envName || "默认");
|
||
const domainLabel = escapeHtml(cfg.alias || cfg.domain);
|
||
const userLabel = escapeHtml(cfg.username);
|
||
btn.innerHTML = `
|
||
<div style="font-weight:600;color:#e94560;">${envLabel}</div>
|
||
<div style="color:#888;font-size:11px;margin-top:2px;">${domainLabel} · ${userLabel}</div>
|
||
`;
|
||
btn.addEventListener("mouseenter", () => {
|
||
btn.style.background = "#0f3460";
|
||
});
|
||
btn.addEventListener("mouseleave", () => {
|
||
btn.style.background = "#16213e";
|
||
});
|
||
btn.addEventListener("click", async () => {
|
||
overlay.parentNode && overlay.parentNode.removeChild(overlay);
|
||
envPickerEl = null;
|
||
await fillSingleConfig(cfg);
|
||
});
|
||
list.appendChild(btn);
|
||
});
|
||
overlay.appendChild(list);
|
||
|
||
const closeBtn = document.createElement("button");
|
||
closeBtn.textContent = "取消";
|
||
closeBtn.style.cssText = `
|
||
margin-top:10px;
|
||
width:100%;
|
||
padding:6px;
|
||
background:transparent;
|
||
border:1px solid #0f3460;
|
||
border-radius:6px;
|
||
color:#888;
|
||
cursor:pointer;
|
||
font-size:11px;
|
||
font-family:inherit;
|
||
`;
|
||
closeBtn.addEventListener("click", () => {
|
||
overlay.parentNode && overlay.parentNode.removeChild(overlay);
|
||
envPickerEl = null;
|
||
});
|
||
overlay.appendChild(closeBtn);
|
||
|
||
document.body.appendChild(overlay);
|
||
envPickerEl = overlay;
|
||
}
|
||
|
||
// escapeHtml 由 utils.js 提供(全局函数)
|
||
|
||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||
if (message.action === "manualFill" && message.config) {
|
||
// 手动触发填充,重置该环境+域名的失败计数
|
||
resetDomainAttempts(message.config.envId, message.config.domain);
|
||
const filled = fillForm(message.config, true);
|
||
sendResponse({ success: filled });
|
||
} else if (message.action === "manualFillMulti" && Array.isArray(message.configs)) {
|
||
// popup 多环境命中:弹出选择菜单
|
||
showEnvPicker(message.configs);
|
||
sendResponse({ success: true });
|
||
}
|
||
return true;
|
||
});
|
||
|
||
autoFillOnLoad();
|
||
|
||
let lastUrl = window.location.href;
|
||
const observer = new MutationObserver(() => {
|
||
if (window.location.href !== lastUrl) {
|
||
const prevUrl = lastUrl;
|
||
lastUrl = window.location.href;
|
||
// URL 变化后,如果新页面不再匹配任何配置,重置之前匹配环境的失败计数
|
||
loadAllConfigs().then((allConfigs) => {
|
||
const stillMatched = allConfigs.some((c) => isDomainMatch(window.location.href, c.domain));
|
||
if (!stillMatched) {
|
||
const prevMatched = allConfigs.filter((c) => isDomainMatch(prevUrl, c.domain));
|
||
prevMatched.forEach((c) => resetDomainAttempts(c.envId, c.domain));
|
||
}
|
||
});
|
||
setTimeout(autoFillOnLoad, 1000);
|
||
}
|
||
});
|
||
observer.observe(document.body, { childList: true, subtree: true });
|